Compare commits

...

38 Commits

Author SHA1 Message Date
Дмитрий 1b3683c6b1 fix(конкурентное поле): 6 находок теста «тупого клиента» — ошибки, регион, дедуп, миграции
- адресные сообщения в окнах сбора/изучения (маппер autopodborErrorMessage)
- регион по умолчанию = пустой плейсхолдер «выберите регион»
- кнопка «Собрать источники» у изучённого конкурента → «Источники собраны»
- сквозной дедуп предложений между прогонами (без двойного списания, ретрай цел)
- убран захардкоженный admin_user_id с фронта (id ставит бэкенд)
- идемпотентный гард в 3 миграции автоподбора (migrate:fresh снова зелёный)
- заглушка Агента: +тип 8-800 (tollfree) для полноты эмуляции

Тесты: Pest автоподбор 82/82, Vitest 62/62, vite build зелёный.

эскейп: фиксируй (авторизовано владельцем)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 06:42:33 +03:00
Дмитрий 793b20a39c feat(конкурентное поле): доводка фронта до прототипа — F1/F2/F3 + чистка M2
Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт):

- F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» —
  карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт +
  Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка
  «Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform.
- F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен
  доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита,
  сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню.
- F3: колонка «когда списывается» в панели доп.услуг биллинга.
- M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не
  было переходов; ручное добавление живёт окном на «Поле».

Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build .
НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:57:58 +03:00
Дмитрий 3561028dd2 docs(Конкурентное поле): прототип, план реализации, эстафета и Playwright-сверка с Омегой
HANDOFF (состояние/решения/окружение), impl-plan (фазы+догрузки), кликабельный прототип
2026-06-29-konkurentnoe-pole-proto.html (визуал-эталон), omega-visual-check (живая сверка
рабочего места с реальными конкурентами Омеги, скрины FIELD-*/PROTO-*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:20:25 +03:00
Дмитрий 4387333118 feat(Конкурентное поле): рабочее место конкуренты→источники→проекты (поверх автоподбора)
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.

Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.

API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).

Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».

Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:18:46 +03:00
Дмитрий 3d4261cba1 chore(схема): NB о дрейфе счётчика RLS-политик (тело 49 vs канон 47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:19:51 +03:00
Дмитрий ef815c0b8c fix(автоподбор): идемпотентность джоб при ретрае + zero-price short-circuit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:17:44 +03:00
Дмитрий 9b4622da85 chore(схема): canon-sync schema.sql v8.58 — 3 таблицы автоподбора + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:09:34 +03:00
Дмитрий 23263d18a0 test(автоподбор): сквозной smoke по всем экранам + defineExpose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:00:22 +03:00
Дмитрий 5ba553a0cc feat(автоподбор): фронт — экран изменения проекта
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:53:36 +03:00
Дмитрий 48509572b5 feat(автоподбор): фронт — экраны создания проектов и готово
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:47:56 +03:00
Дмитрий 3bc4325b78 feat(автоподбор): фронт — экран источников конкурента (выбор, ручной источник)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:41:07 +03:00
Дмитрий 361d02a256 feat(автоподбор): фронт — экраны загрузки и списка конкурентов
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:33:20 +03:00
Дмитрий 33ac1a5954 feat(автоподбор): фронт — экраны форм подбора и своего конкурента
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:22:36 +03:00
Дмитрий 17d93a144b feat(автоподбор): эндпоинт конкурентов прогона + competitor_id в RunResource
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:16 +03:00
Дмитрий aa807c0ed4 feat(автоподбор): фронт — каркас экрана, вход, роут, пункт меню
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:29 +03:00
Дмитрий e52e958484 feat(автоподбор): фронт — api-клиент и Pinia store
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:58:51 +03:00
Дмитрий 8cc6511edd feat(автоподбор): ручные эндпоинты — manual-study и добавление источника
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:50:37 +03:00
Дмитрий 02d2163e75 feat(автоподбор): API ядро — контроллер, роуты, ресурсы
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:42:41 +03:00
Дмитрий 3c8886c97f feat(автоподбор): stripBadge — чистое имя конкурента без значка
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:33:11 +03:00
Дмитрий f208fe2f65 feat(автоподбор): создатель проектов из источников (имя+значок+суффикс)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:27:04 +03:00
Дмитрий 98b26f6191 feat(автоподбор): RunService — старт, гейт баланса, один in-flight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:20:54 +03:00
Дмитрий d9b3e8dbe1 feat(автоподбор): джоба резолва по названию
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:16:14 +03:00
Дмитрий a3b68dbb95 docs(автоподбор): записка для продолжения после компакта (state + что прочитать)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:54:44 +03:00
Дмитрий 78d1965430 feat(автоподбор): джоба шага 2 (изучение конкурента, источники)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:49:57 +03:00
Дмитрий 1de6984035 feat(автоподбор): джоба шага 1 (подбор конкурентов)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:46:42 +03:00
Дмитрий 4042890b0a feat(автоподбор): идемпотентное списание за прогон (bcmath, only-on-success)
- AutopodborChargeService::chargeForRun — DB::transaction + lockForUpdate
  на AutopodborRun (guard идемпотентности по balance_transaction_id) и Tenant;
  bcmath (bcsub/bccomp/bcmul), никаких float; throw InsufficientBalanceException
  до любых изменений баланса при нехватке средств.
- Миграция 2026_06_28_110100: расширяет CHECK constraint
  balance_transactions_type_check — добавляет 'autopodbor_charge'.
- Тест: 2 money-инварианта (идемпотентность + noop при нехватке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:41:06 +03:00
Дмитрий 77498df63b feat(автоподбор): дедуп конкурентов/источников/проектов
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:35:38 +03:00
Дмитрий 6789879a2c feat(автоподбор): нормализатор домена/телефона + dedup-ключи
AutopodborNormalizer: domainHead (схема/www/путь/порт → голова),
phone (через PhoneNormalizer::normalize → 7xxxxxxxxxx без плюса),
sourceKey и competitorKey для дедупликации конкурентов и источников.
4 теста, 9 assertions, все GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:32:23 +03:00
Дмитрий 3b9c1b8bdc feat(автоподбор): интерфейс движка CompetitorAgent + заглушка + binding
- CompetitorAgent interface: findCompetitors / studyCompetitor / resolveByName
- FakeCompetitorAgent: 4 конкурента, 5 источников, 1 кандидат по имени
- AutopodborServiceProvider: bind(CompetitorAgent → FakeCompetitorAgent)
- Регистрация провайдера в bootstrap/providers.php (Laravel 11+)
- Pest.php: extend TestCase для Unit/Autopodbor (контейнер в Unit-тестах)
- Тест: 1/1 PASS, 10 assertions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:28:56 +03:00
Дмитрий 0a111d9f85 feat(автоподбор): DTO контракта движка (6 шт.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:20:10 +03:00
Дмитрий 3c2bb18537 feat(автоподбор): тип проводки autopodbor_charge + ключи настроек
- BalanceTransaction::TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'
- сид-миграция 4 ключей system_settings (idempotent):
  autopodbor_enabled (bool, 0), autopodbor_price_search_rub (decimal, 0),
  autopodbor_price_study_rub (decimal, 0), autopodbor_max_competitors (int, 15)
- Unit + Feature тесты, оба PASS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:16:59 +03:00
Дмитрий df19af99f9 feat(автоподбор): Eloquent-модели run/competitor/source
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:12:35 +03:00
Дмитрий b5c88b2f1d docs(автоподбор): план — выделенная тестовая БД liderra_testing_apk + RLS-харднинг конвенция
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:08:59 +03:00
Дмитрий 2de1f1e35f fix(автоподбор): RLS NULLIF-харднинг (v8.57) + CHANGELOG v8.58 для 3 таблиц
Политики tenant_isolation приведены к каноничной харднинг-форме
NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
(после переноса на gitea/main с v8.57). Запись CHANGELOG для всех 3 таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:02:40 +03:00
Дмитрий cc73a70f9e feat(автоподбор): таблица autopodbor_sources + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:27 +03:00
Дмитрий 786f796223 feat(автоподбор): таблица autopodbor_competitors + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:26 +03:00
Дмитрий e7660edd79 feat(автоподбор): таблица autopodbor_runs + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:10 +03:00
Дмитрий 1fe071f203 docs(автоподбор): дизайн-документ + план реализации
Дизайн и пошаговый план фичи «Автоподбор конкурентов» (ИИ-агент находит
конкурентов и их источники). Движок — отдельной сессией, здесь розетка+заглушка.
План сверен с кодом: RLS app.current_tenant_id, tenant-контекст SET LOCAL,
тестовая БД liderra_testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:57:27 +03:00
115 changed files with 14166 additions and 4 deletions
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
class RunInFlightException extends \RuntimeException {}
@@ -0,0 +1,581 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Http\Resources\Autopodbor\CompetitorResource;
use App\Http\Resources\Autopodbor\RunResource;
use App\Http\Resources\Autopodbor\SourceResource;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\AutopodborProjectCreator;
use App\Services\Autopodbor\AutopodborRunService;
use App\Services\Billing\BalancePreflightService;
use App\Support\SystemSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Клиентский API автоподбора конкурентов.
*
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
* Все выборки дополнительно скоупятся по tenant_id (пояс+подтяжки к RLS).
*/
class AutopodborController extends Controller
{
/** GET /api/autopodbor/state */
public function state(Request $request): JsonResponse
{
$tenantId = $request->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),
]
);
});
return response()->json([
'data' => new CompetitorResource($comp),
'sources' => $sources,
]);
}
/** 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/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:500'],
'directory' => ['nullable', 'string', 'max:500'],
'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]);
}
/**
* 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:500'],
'directory' => ['nullable', 'string', 'max:500'],
'directory_urls' => ['nullable', 'array'],
'directory_urls.*' => ['string', 'max:500'],
]);
$uid = $request->user()->tenant_id;
$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)));
$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,
'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): 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:500'],
'directory_urls' => ['sometimes', 'array'],
'directory_urls.*' => ['string', 'max:500'],
'box' => ['sometimes', 'string', 'in:proposal,field'],
]);
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
->findOrFail($competitor);
$comp->update($v);
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);
}
$comp->sources()->delete();
$comp->delete();
return response()->json(null, 204);
}
/** GET /api/autopodbor/proposals — конкуренты в ящике «предложения», сорт по похожести. */
public function proposals(Request $request): JsonResponse
{
$competitors = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
->where('box', 'proposal')
->orderByDesc('relevance_pct')
->orderBy('id')
->get();
return response()->json(['data' => CompetitorResource::collection($competitors)]);
}
/** 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'],
'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);
}
$src->delete();
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): JsonResponse
{
$v = $request->validate([
'competitor_id' => ['required', 'integer'],
'raw' => ['required', 'string', 'max:500'],
]);
$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);
$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' => 'Добавлено вручную',
],
);
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',
'delivery_days_mask' => 'required|integer',
'launch' => 'boolean',
]);
$tenant = $request->user()->tenant;
$launch = (bool) ($v['launch'] ?? false);
// Балансовый preflight при launch=true
if ($launch) {
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target'];
$preflight = $this->runPreflight($tenant, $wouldBe);
if (! $preflight['passes']) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBe,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
}
$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,
);
return response()->json([
'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(),
], 201);
}
/**
* Копия helper'а из ProjectController балансовый preflight.
*
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// preflight пропускаем (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,
];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CompetitorResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
'studied_at' => $this->studied_at?->toIso8601String(),
'study_run_id' => $this->study_run_id,
'search_run_id' => $this->search_run_id,
];
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class RunResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
'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(),
];
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SourceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\AutopodborDedup;
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;
class RunAutopodborResolveJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId) {}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
{
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно
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;
}
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Support\SystemSettings;
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;
class RunAutopodborSearchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId) {}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge): void
{
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
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;
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
$res = $agent->findCompetitors(new FindCompetitorsRequest(
regionCode: (int) $run->region_code,
examples: $p['examples'] ?? [],
aboutSelf: $p['about_self'] ?? [],
includeFederal: (bool) ($p['include_federal'] ?? false),
maxCompetitors: $max,
));
$unique = $dedup->dedupCompetitors($res->competitors);
// Сквозной дедуп: убираем конкурентов, уже известных тенанту (в поле или предложениях
// из прошлых прогонов) — иначе повторный подбор плодит дубли карточек. Если после
// фильтра ничего нового не осталось — прогон пустой и НЕ списывается (как и обычное «пусто»).
// Исключаем конкурентов ЭТОГО же прогона (иначе ретрай упавшего прогона схлопнул бы
// собственные результаты в «пусто»). Фильтруем только чужие прогоны и ручных.
$existingKeys = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->pluck('dedup_key')
->all();
$unique = array_values(array_filter(
$unique,
fn (array $c) => ! in_array($c['dedup_key'], $existingKeys, true),
));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
foreach (array_slice($unique, 0, $max) 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'] ?? [],
'provenance' => $c['provenance'] ?? [],
]
);
}
$price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0');
$charge->chargeForRun($run, $price);
$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;
}
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Support\SystemSettings;
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;
class RunAutopodborStudyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId) {}
public function handle(
CompetitorAgent $agent,
AutopodborDedup $dedup,
AutopodborChargeService $charge,
AutopodborNormalizer $norm,
): void {
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
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 {
$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 ?? [],
],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
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,
]
);
}
$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()]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborCompetitor extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'search_run_id',
'name',
'description',
'is_federal',
'relevance_pct',
'origin',
'site_url',
'directory_urls',
'provenance',
'dedup_key',
'study_run_id',
'studied_at',
'box',
];
protected $casts = [
'is_federal' => 'bool',
'directory_urls' => 'array',
'provenance' => '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');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborRun extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'kind',
'status',
'region_code',
'params',
'competitor_id',
'price_rub_charged',
'balance_transaction_id',
'error_code',
'started_at',
'finished_at',
];
protected $casts = [
'params' => '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');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutopodborSource extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'competitor_id',
'study_run_id',
'signal_type',
'identifier',
'phone_kind',
'phone_type',
'provenance_url',
'provenance_label',
'dedup_key',
'created_project_id',
'box',
];
protected $casts = [
'created_at' => 'datetime',
];
public function competitor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id');
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'created_project_id');
}
}
+2
View File
@@ -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 = [
@@ -0,0 +1,16 @@
<?php
namespace App\Providers;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider
{
public function register(): void
{
// v1: заглушка. Реальный движок биндится здесь, когда будет готов.
$this->app->bind(CompetitorAgent::class, FakeCompetitorAgent::class);
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult};
interface CompetitorAgent
{
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsRequest
{
public function __construct(
public readonly int $regionCode,
public readonly array $examples,
public readonly array $aboutSelf,
public readonly bool $includeFederal,
public readonly int $maxCompetitors,
) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsResult
{
/**
* @param array<int,array{name:string,description?:?string,is_federal?:bool,relevance_pct?:?int,site_url?:?string,directory_urls?:array,provenance?:array}> $competitors
*/
public function __construct(public readonly array $competitors) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class ResolveByNameRequest
{
public function __construct(
public readonly string $name,
public readonly int $regionCode,
) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class ResolveByNameResult
{
/**
* @param array<int,array{name:string,description?:?string,site_url?:?string,directory_urls?:array,provenance?:array}> $candidates
*/
public function __construct(public readonly array $candidates) {}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class StudyCompetitorRequest
{
/**
* @param array{name:string,site_url?:?string,directory_urls?:array} $competitor
*/
public function __construct(
public readonly array $competitor,
public readonly int $regionCode,
) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class StudyCompetitorResult
{
/**
* @param array<int,array{signal_type:string,identifier:string,phone_kind?:?string,phone_type?:?string,provenance_url?:?string,provenance_label?:?string}> $sources
*/
public function __construct(public readonly array $sources) {}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
final class FakeCompetitorAgent implements CompetitorAgent
{
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
{
return new FindCompetitorsResult([
['name' => 'Окна Комфорт', 'description' => 'Пластиковые окна и остекление балконов под ключ.', 'is_federal' => false, 'relevance_pct' => 100, 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'similar-pages']],
['name' => 'Пластика Окон', 'description' => 'Окна ПВХ, лоджии, входные группы.', 'is_federal' => false, 'relevance_pct' => 96, 'site_url' => 'plastika-okon-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/2'], 'provenance' => ['via' => 'similar-pages']],
['name' => 'Фабрика Окон', 'description' => 'Федеральная сеть окон ПВХ, филиал в регионе.', 'is_federal' => true, 'relevance_pct' => 84, 'site_url' => 'fabrika-okon.ru', 'directory_urls' => ['https://2gis.ru/firm/3'], 'provenance' => ['via' => 'similar-pages']],
['name' => 'Балкон-Сервис 16', 'description' => 'Остекление балконов; окна частично.', 'is_federal' => false, 'relevance_pct' => 61, 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/4', 'https://2gis.ru/firm/4'], 'provenance' => ['via' => 'similar-pages']],
]);
}
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
{
return new StudyCompetitorResult([
['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'],
['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'phone_type' => 'city', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'],
['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'phone_type' => 'mobile', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'],
['signal_type' => 'call', 'identifier' => '88002001122', 'phone_kind' => 'real', 'phone_type' => 'tollfree', 'provenance_url' => 'https://okna-komfort-kzn.ru/contacts', 'provenance_label' => 'бесплатная линия 8-800 на сайте'],
]);
}
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
{
return new ResolveByNameResult([
['name' => $r->name, 'description' => 'Найдено по названию (заглушка).', 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'name-search']],
]);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\AutopodborRun;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* Сервис списания за прогон автоподбора конкурентов.
*
* Контракт:
* - Списание только при готовом результате (by-success).
* - Атомарное: весь flow в одной DB-транзакции.
* - Идемпотентное: повторный вызов с тем же run не изменяет баланс
* (guard по balance_transaction_id).
* - bcmath: никаких float-арифметик.
*
* @throws InsufficientBalanceException если balance_rub < priceRub.
* До throw баланс и транзакции не меняются.
*/
final class AutopodborChargeService
{
public function chargeForRun(AutopodborRun $run, string $priceRub): void
{
DB::transaction(function () use ($run, $priceRub): void {
// Блокируем run первым — guard идемпотентности
/** @var AutopodborRun $locked */
$locked = AutopodborRun::whereKey($run->id)->lockForUpdate()->firstOrFail();
if ($locked->balance_transaction_id !== null) {
// Уже списано — идемпотентный возврат без второго списания
return;
}
if (bccomp($priceRub, '0', 2) === 0) {
// Бесплатный прогон — без ledger-строки; фиксируем факт нулевой стоимости.
if ($locked->price_rub_charged === null) {
$locked->price_rub_charged = '0.00';
$locked->save();
}
return;
}
// Блокируем tenant для атомарного изменения баланса
/** @var Tenant $tenant */
$tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail();
// bcmath: сравниваем с точностью 2 знака
if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) {
throw new InsufficientBalanceException(
priceKopecks: (int) bcmul($priceRub, '100', 0),
balanceRub: (string) $tenant->balance_rub,
);
}
$newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2);
// Обновляем баланс через DB::table (как в LedgerService) — надёжнее при decimal
DB::table('tenants')
->where('id', $tenant->id)
->update(['balance_rub' => $newBalance]);
// Записываем транзакцию
$tx = BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE,
'amount_rub' => '-'.$priceRub,
'amount_leads' => null,
'balance_rub_after' => $newBalance,
'balance_leads_after' => null,
'related_type' => AutopodborRun::class,
'related_id' => $locked->id,
'created_at' => now(),
]);
// Фиксируем на run идемпотентный маркер
$locked->balance_transaction_id = $tx->id;
$locked->price_rub_charged = $priceRub;
$locked->save();
});
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Models\Project;
final class AutopodborDedup
{
public function __construct(private AutopodborNormalizer $norm) {}
/**
* Ищет существующий проект арендатора с тем же типом и нормализованным идентификатором.
* Возвращает id найденного проекта или null.
*/
public function existingProjectId(int $tenantId, string $signalType, string $identifier): ?int
{
$needle = $signalType === 'call'
? $this->norm->phone($identifier)
: $this->norm->domainHead($identifier);
return Project::query()
->where('tenant_id', $tenantId)
->where('signal_type', $signalType)
->where('signal_identifier', $needle)
->value('id');
}
/**
* Дедупликация источников внутри переданного списка по нормализованному ключу.
* Возвращает уникальные элементы с добавленным полем dedup_key.
*
* @param array<int, array{signal_type: string, identifier: string}> $sources
* @return array<int, array>
*/
public function dedupSources(array $sources): array
{
$seen = [];
$out = [];
foreach ($sources as $s) {
$key = $this->norm->sourceKey($s['signal_type'], $s['identifier']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$s['dedup_key'] = $key;
$out[] = $s;
}
return $out;
}
/**
* Дедупликация конкурентов внутри переданного списка по нормализованному ключу.
* Возвращает уникальные элементы с добавленным полем dedup_key.
*
* @param array<int, array{name: string, site_url?: string|null}> $competitors
* @return array<int, array>
*/
public function dedupCompetitors(array $competitors): array
{
$seen = [];
$out = [];
foreach ($competitors as $c) {
$key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$c['dedup_key'] = $key;
$out[] = $c;
}
return $out;
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Support\PhoneNormalizer;
/**
* Нормализует домены и телефоны для дедупликации конкурентов и источников.
*/
final class AutopodborNormalizer
{
/**
* Возвращает «голову» домена: без схемы, www, пути, порта, нижний регистр.
* Примеры:
* https://www.Okna-Komfort.RU/contacts okna-komfort.ru
* http://site.ru:8080/path?x=1 site.ru
*/
public function domainHead(string $raw): string
{
$s = trim(mb_strtolower($raw));
// Убираем схему (http://, https://, ftp:// и т.п.)
$s = preg_replace('#^[a-z]+://#', '', $s);
// Убираем www.
$s = preg_replace('#^www\.#', '', $s);
// Берём только host часть (до первого /)
$s = explode('/', $s)[0];
// Убираем query string если вдруг осталась
$s = explode('?', $s)[0];
// Убираем порт
$s = explode(':', $s)[0];
return $s;
}
/**
* Нормализует телефон к виду 7xxxxxxxxxx (11 цифр, без плюса).
* Использует существующий PhoneNormalizer::normalize, который возвращает +7XXXXXXXXXX.
*/
public function phone(string $raw): string
{
$normalized = PhoneNormalizer::normalize($raw);
if ($normalized === null) {
// Fallback: оставить только цифры и привести к 7xxxxxxxxxx
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
return '7' . substr($digits, 1);
}
if (strlen($digits) === 10) {
return '7' . $digits;
}
return $digits;
}
// PhoneNormalizer возвращает +7XXXXXXXXXX — срезаем ведущий '+'
return ltrim($normalized, '+');
}
/**
* Строит dedup-ключ для источника (сайт или звонок).
* Формат: «type:нормализованный_идентификатор»
*/
public function sourceKey(string $type, string $identifier): string
{
$id = $type === 'call'
? $this->phone($identifier)
: $this->domainHead($identifier);
return $type . ':' . $id;
}
/**
* Срезает хвостовой значок ( или 🎭) вместе с пробелами перед ним.
* Если значка нет строка возвращается без изменений.
* Примеры:
* 'Окна Комфорт ✓' 'Окна Комфорт'
* 'Окна Комфорт 🎭' 'Окна Комфорт'
* 'Окна Комфорт' 'Окна Комфорт'
* 'Балкон-Сервис 16' 'Балкон-Сервис 16'
*/
public function stripBadge(string $name): string
{
// Срезаем ровно один хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
// Используем mb-безопасный regex с флагом u (эмодзи 🎭 — 4-байтный).
return preg_replace('/\s*(?:\x{2713}|\x{1F3AD})\s*$/u', '', $name) ?? $name;
}
/**
* Строит dedup-ключ для конкурента.
* Если есть сайт «site:домен», иначе «name:имя_в_нижнем_регистре».
*/
public function competitorKey(string $name, ?string $siteUrl): string
{
if ($siteUrl !== null) {
return 'site:' . $this->domainHead($siteUrl);
}
// Нижний регистр + схлопываем пробелы
$normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name)));
return 'name:' . $normalized;
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
final class AutopodborProjectCreator
{
public function __construct(private ProjectService $projects) {}
/**
* @param int[] $sourceIds
* @param array{regions:int[],daily_limit_target:int,delivery_days_mask:int} $common
* @return Project[]
*/
public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array
{
$tenant = Tenant::findOrFail($tenantId);
$sources = AutopodborSource::where('tenant_id', $tenantId)
->whereIn('id', $sourceIds)->with('competitor')->get();
$created = [];
foreach ($sources as $src) {
$name = $this->uniqueName($tenantId, $this->displayName($src));
$project = $this->projects->create($tenant, [
'name' => $name,
'signal_type' => $src->signal_type,
'signal_identifier' => $src->identifier,
'daily_limit_target' => $common['daily_limit_target'],
'regions' => $common['regions'],
'delivery_days_mask' => $common['delivery_days_mask'],
]);
if (! $launch) {
$project->update(['is_active' => false, 'paused_at' => now()]);
$project = $project->fresh();
}
$src->update(['created_project_id' => $project->id]);
$created[] = $project;
}
return $created;
}
private function displayName(AutopodborSource $s): string
{
$n = $s->competitor->name;
if ($s->signal_type === 'call' && $s->phone_kind === 'real') {
return $n.' ✓';
}
if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') {
return $n.' 🎭';
}
return $n;
}
private function uniqueName(int $tenantId, string $base): string
{
$name = $base;
$i = 1;
while (Project::where('tenant_id', $tenantId)->where('name', $name)->exists()) {
$i++;
$name = $base.' '.$i;
}
return $name;
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\Tenant;
use App\Support\SystemSettings;
final class AutopodborRunService
{
public function __construct(
private AutopodborNormalizer $normalizer = new AutopodborNormalizer(),
) {}
private function assertNoInFlight(int $tenantId, string $kind): void
{
$exists = AutopodborRun::where('tenant_id', $tenantId)
->where('kind', $kind)
->whereIn('status', ['queued', 'running'])
->exists();
if ($exists) {
throw new RunInFlightException();
}
}
private function priceGate(int $tenantId, string $key): string
{
$price = (string) (SystemSettings::get($key) ?? '0');
$balance = (string) Tenant::whereKey($tenantId)->value('balance_rub');
if (bccomp($balance, $price, 2) < 0) {
throw new InsufficientBalanceException(
priceKopecks: (int) bcmul($price, '100', 0),
balanceRub: $balance,
);
}
return $price;
}
public function startSearch(
int $tenantId,
int $regionCode,
array $examples,
array $aboutSelf,
bool $includeFederal,
): AutopodborRun {
$this->assertNoInFlight($tenantId, 'search');
$this->priceGate($tenantId, 'autopodbor_price_search_rub');
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'search',
'status' => 'queued',
'region_code' => $regionCode,
'params' => [
'examples' => $examples,
'about_self' => $aboutSelf,
'include_federal' => $includeFederal,
],
]);
RunAutopodborSearchJob::dispatch($run->id);
return $run;
}
public function startStudy(int $tenantId, int $competitorId): AutopodborRun
{
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
if ($comp->studied_at !== null) {
return $comp->studyRun;
}
$this->assertNoInFlight($tenantId, 'study');
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'study',
'status' => 'queued',
'region_code' => $comp->searchRun?->region_code,
'competitor_id' => $comp->id,
'params' => [],
]);
RunAutopodborStudyJob::dispatch($run->id);
return $run;
}
/**
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
*
* @param array{name:string, site_url:?string, directory_urls:array} $competitorData
*/
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
{
$this->assertNoInFlight($tenantId, 'study');
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenantId,
'search_run_id' => null,
'name' => $competitorData['name'],
'origin' => 'manual',
'relevance_pct' => null,
'site_url' => $competitorData['site_url'] ?? null,
'directory_urls' => $competitorData['directory_urls'] ?? [],
'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null),
]);
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'study',
'status' => 'queued',
'region_code' => $regionCode,
'competitor_id' => $comp->id,
'params' => [],
]);
RunAutopodborStudyJob::dispatch($run->id);
return $run;
}
public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun
{
$this->assertNoInFlight($tenantId, 'resolve');
// resolve бесплатный — без priceGate
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'resolve',
'status' => 'queued',
'region_code' => $regionCode,
'params' => ['name' => $name],
]);
RunAutopodborResolveJob::dispatch($run->id);
return $run;
}
}
+1
View File
@@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
App\Providers\AutopodborServiceProvider::class,
];
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Idempotent: после migrate:fresh schema.sql создаёт эту таблицу первой (canon-sync v8.58).
// Без гарда Schema::create падает дублем — как и в остальных миграциях проекта
// (см. 2026_05_19_..._create_supplier_manual_sync_queue).
$exists = DB::selectOne("SELECT to_regclass('public.autopodbor_runs') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
Schema::create('autopodbor_runs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
$table->string('kind', 16); // search | study | resolve
$table->string('status', 16)->default('queued'); // queued|running|done|empty|failed
$table->smallInteger('region_code')->nullable();
$table->jsonb('params')->default(DB::raw("'{}'::jsonb"));
$table->unsignedBigInteger('competitor_id')->nullable();
$table->decimal('price_rub_charged', 12, 2)->nullable();
$table->unsignedBigInteger('balance_transaction_id')->nullable();
$table->string('error_code', 64)->nullable();
$table->timestampTz('created_at')->useCurrent();
$table->timestampTz('started_at')->nullable();
$table->timestampTz('finished_at')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'kind', 'status']);
});
DB::statement('ALTER TABLE autopodbor_runs ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE autopodbor_runs FORCE ROW LEVEL SECURITY');
DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
}
public function down(): void
{
Schema::dropIfExists('autopodbor_runs');
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Idempotent: после migrate:fresh schema.sql создаёт эту таблицу первой (canon-sync v8.58).
$exists = DB::selectOne("SELECT to_regclass('public.autopodbor_competitors') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
Schema::create('autopodbor_competitors', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('search_run_id')->nullable();
$table->string('name', 255);
$table->text('description')->nullable();
$table->boolean('is_federal')->default(false);
$table->smallInteger('relevance_pct')->nullable();
$table->string('origin', 16)->default('auto'); // auto|manual|resolve
$table->string('site_url', 255)->nullable();
$table->jsonb('directory_urls')->default(DB::raw("'[]'::jsonb"));
$table->jsonb('provenance')->default(DB::raw("'{}'::jsonb"));
$table->string('dedup_key', 255);
$table->unsignedBigInteger('study_run_id')->nullable();
$table->timestampTz('studied_at')->nullable();
$table->timestampTz('created_at')->useCurrent();
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
$table->foreign('search_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete();
$table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete();
$table->index(['tenant_id', 'search_run_id']);
$table->unique(['tenant_id', 'search_run_id', 'dedup_key'], 'autopodbor_competitor_dedup');
});
DB::statement('ALTER TABLE autopodbor_competitors ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE autopodbor_competitors FORCE ROW LEVEL SECURITY');
DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
}
public function down(): void
{
Schema::dropIfExists('autopodbor_competitors');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Idempotent: после migrate:fresh schema.sql создаёт эту таблицу первой (canon-sync v8.58).
$exists = DB::selectOne("SELECT to_regclass('public.autopodbor_sources') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
Schema::create('autopodbor_sources', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('competitor_id');
$table->unsignedBigInteger('study_run_id');
$table->string('signal_type', 8); // site | call
$table->string('identifier', 255); // голова домена / 7xxxxxxxxxx
$table->string('phone_kind', 12)->nullable(); // real | substitute | null(site)
$table->string('provenance_url', 500)->nullable();
$table->string('provenance_label', 255)->nullable();
$table->string('dedup_key', 255);
$table->unsignedBigInteger('created_project_id')->nullable();
$table->timestampTz('created_at')->useCurrent();
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
$table->foreign('competitor_id')->references('id')->on('autopodbor_competitors')->cascadeOnDelete();
$table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->cascadeOnDelete();
$table->foreign('created_project_id')->references('id')->on('projects')->nullOnDelete();
$table->unique(['competitor_id', 'dedup_key'], 'autopodbor_source_dedup');
$table->index(['tenant_id', 'competitor_id']);
});
DB::statement('ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY');
DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
}
public function down(): void
{
Schema::dropIfExists('autopodbor_sources');
}
};
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Сид настроек модуля «Автоподбор конкурентов» (Task 5).
* Вставляет 4 ключа в system_settings (idempotent).
*/
return new class extends Migration
{
public function up(): void
{
$rows = [
[
'key' => 'autopodbor_enabled',
'value' => '0',
'type' => 'bool',
'description' => 'Автоподбор конкурентов: вкл/выкл вкладку',
'updated_at' => now(),
],
[
'key' => 'autopodbor_price_search_rub',
'value' => '0',
'type' => 'decimal',
'description' => 'Цена подбора конкурентов (шаг 1), ₽',
'updated_at' => now(),
],
[
'key' => 'autopodbor_price_study_rub',
'value' => '0',
'type' => 'decimal',
'description' => 'Цена изучения конкурента (шаг 2), ₽',
'updated_at' => now(),
],
[
'key' => 'autopodbor_max_competitors',
'value' => '15',
'type' => 'int',
'description' => 'Макс. число конкурентов на выдаче шага 1',
'updated_at' => now(),
],
];
foreach ($rows as $row) {
$exists = DB::table('system_settings')->where('key', $row['key'])->exists();
if (! $exists) {
DB::table('system_settings')->insert($row);
}
}
}
public function down(): void
{
DB::table('system_settings')->whereIn('key', [
'autopodbor_enabled',
'autopodbor_price_search_rub',
'autopodbor_price_study_rub',
'autopodbor_max_competitors',
])->delete();
}
};
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Добавляет 'autopodbor_charge' в CHECK constraint balance_transactions_type_check.
*
* Фича «Автоподбор конкурентов» списание за прогон через AutopodborChargeService.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
);
DB::statement(
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
'CHECK (type IN ('.
"'trial_bonus','topup','lead_charge','refund',".
"'manual_adjustment','historical_import',".
"'chargeback_writedown','chargeback_repayment',".
"'migration',".
"'autopodbor_charge'".
'))'
);
}
public function down(): void
{
DB::statement(
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
);
DB::statement(
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
'CHECK (type IN ('.
"'trial_bonus','topup','lead_charge','refund',".
"'manual_adjustment','historical_import',".
"'chargeback_writedown','chargeback_repayment',".
"'migration'".
'))'
);
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* «Конкурентное поле» два ящика (предложение / в поле) на конкурентах и источниках.
* Approach A (спек §14.1): не плодим таблицы добавляем пометку-состояние к существующим.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE autopodbor_competitors ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'");
DB::statement("ALTER TABLE autopodbor_competitors ADD CONSTRAINT autopodbor_competitors_box_chk CHECK (box IN ('proposal', 'field'))");
DB::statement('CREATE INDEX autopodbor_competitors_tenant_box_idx ON autopodbor_competitors (tenant_id, box)');
DB::statement("ALTER TABLE autopodbor_sources ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'");
DB::statement("ALTER TABLE autopodbor_sources ADD CONSTRAINT autopodbor_sources_box_chk CHECK (box IN ('proposal', 'field'))");
DB::statement('CREATE INDEX autopodbor_sources_competitor_box_idx ON autopodbor_sources (competitor_id, box)');
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS autopodbor_sources_competitor_box_idx');
DB::statement('ALTER TABLE autopodbor_sources DROP CONSTRAINT IF EXISTS autopodbor_sources_box_chk');
DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS box');
DB::statement('DROP INDEX IF EXISTS autopodbor_competitors_tenant_box_idx');
DB::statement('ALTER TABLE autopodbor_competitors DROP CONSTRAINT IF EXISTS autopodbor_competitors_box_chk');
DB::statement('ALTER TABLE autopodbor_competitors DROP COLUMN IF EXISTS box');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Тип номера телефона (городской/мобильный/8-800) то, что даёт определитель (DaData).
* Спек §14.5, вариант «и тип, и коллтрекинг»: phone_type ДОПОЛНЯЕТ phone_kind
* (настоящий/подменный, /🎭), не заменяет его. Для сайтов phone_type = NULL.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN phone_type VARCHAR(12)');
DB::statement("ALTER TABLE autopodbor_sources ADD CONSTRAINT autopodbor_sources_phone_type_chk CHECK (phone_type IS NULL OR phone_type IN ('city', 'mobile', 'tollfree'))");
}
public function down(): void
{
DB::statement('ALTER TABLE autopodbor_sources DROP CONSTRAINT IF EXISTS autopodbor_sources_phone_type_chk');
DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS phone_type');
}
};
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Дефолтные тарифы доп. услуг «Конкурентного поля» (решение владельца 29.06, спек §14.11):
* - подбор конкурентов (шаг 1) = 300 ;
* - изучение конкурента / сбор источников (шаг 2) = 50 .
*
* Сид Task 5 завёл ключи со значением '0'. Здесь проставляем рабочие дефолты,
* но ТОЛЬКО если значение всё ещё '0' (не затираем то, что админ уже поправил
* через PUT /api/admin/system-settings/{key}). Идемпотентно.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('system_settings')
->where('key', 'autopodbor_price_search_rub')->where('value', '0')
->update(['value' => '300', 'updated_at' => now()]);
DB::table('system_settings')
->where('key', 'autopodbor_price_study_rub')->where('value', '0')
->update(['value' => '50', 'updated_at' => now()]);
}
public function down(): void
{
DB::table('system_settings')
->where('key', 'autopodbor_price_search_rub')->where('value', '300')
->update(['value' => '0', 'updated_at' => now()]);
DB::table('system_settings')
->where('key', 'autopodbor_price_study_rub')->where('value', '50')
->update(['value' => '0', 'updated_at' => now()]);
}
};
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* ДЕМО-сид для визуальной проверки «Конкурентного поля» глазами клиента (Омега).
* НЕ для прода. Данные конкурентов из реальных прогонов движка 28-29.06 (Красноярск,
* займы под залог авто). Создаёт демо-тенант + логин, включает фичу, наполняет поле и
* предложения реальными конкурентами и источниками (сайты + телефоны с типами).
*
* Запуск (только dev): php artisan db:seed --class=OmegaDemoFieldSeeder
* Логин: omega-demo@liderra.local / omega12345
*/
class OmegaDemoFieldSeeder extends Seeder
{
public function run(): void
{
// 1) Тенант «Омега (демо)» + пользователь с известным паролем
$tenant = Tenant::firstOrNew(['subdomain' => 'omega-demo']);
$tenant->organization_name = 'Омега (демо поля)';
$tenant->contact_email = 'omega-demo@liderra.local';
$tenant->status = 'active';
$tenant->balance_rub = '50000.00';
$tenant->delivered_in_month = 0;
$tenant->save();
$user = User::firstOrNew(['email' => 'omega-demo@liderra.local']);
$user->tenant_id = $tenant->id;
$user->first_name = 'Омега';
$user->last_name = 'Демо';
$user->password_hash = Hash::make('omega12345');
$user->email_verified_at = now();
$user->is_active = true;
$user->save();
// 2) Включить фичу + тарифы доп.услуг
SystemSetting::updateOrCreate(['key' => 'autopodbor_enabled'], ['value' => '1', 'type' => 'bool']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '300', 'type' => 'decimal']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '50', 'type' => 'decimal']);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
// чистим прошлый демо-прогон (идемпотентность)
$oldRuns = AutopodborRun::where('tenant_id', $tenant->id)->pluck('id');
AutopodborSource::where('tenant_id', $tenant->id)->delete();
AutopodborCompetitor::where('tenant_id', $tenant->id)->delete();
AutopodborRun::whereIn('id', $oldRuns)->delete();
$run = AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done',
'region_code' => 24, 'params' => ['region' => 'Красноярский край'],
]);
// 3) Реальные конкуренты Омеги (прогон 28-29.06). box=field — клиент отобрал в поле.
$gis = 'https://2gis.ru/krasnoyarsk/firm/0';
$ya = 'https://yandex.ru/maps/org/0';
$field = [
['КрасЛомбард', 'kraslombard24.ru', false, 95, 'Сеть ломбардов, займы под залог авто и техники', [$gis, $ya], [
['site', 'kraslombard24.ru', null, null],
['call', '73912771717', 'real', 'city'],
]],
['Голд Авто Инвест', 'goldautoinvest.ru', false, 90, 'Займы под залог автомобилей, Красноярск', [$gis, $ya], [
['site', 'goldautoinvest.ru', null, null],
['call', '73912000111', 'substitute', 'city'],
['call', '79130000222', 'real', 'mobile'],
]],
['Финео', 'fineo24.ru', true, 80, 'Федеральный сервис займов под ПТС', [$gis], [
['site', 'fineo24.ru', null, null],
['call', '78005000333', 'real', 'tollfree'],
]],
['Cashmotor', 'cashmotor.ru', true, 78, 'Федеральный автоломбард, залог авто', [], [
['site', 'cashmotor.ru', null, null],
]],
['Локо-Банк', 'lockobank.ru', true, 72, 'Автокредиты и займы под залог авто', [$ya], [
['site', 'lockobank.ru', null, null],
]],
];
// box=proposal — найдено движком, ещё не отобрано в поле
$proposals = [
['Автоломбард Экспресс', 'avtolombard-express.ru', false, 85, 'Срочные займы под залог авто, Красноярск'],
['Caranga', 'caranga.ru', true, 70, 'Федеральный автоломбanд'],
['Драйвзайм', 'drivezaim.ru', true, 65, 'Займы под залог ПТС онлайн'],
['Залог24', 'zalog24h.ru', true, 60, 'Круглосуточные займы под залог'],
['Кредди', 'creddy.ru', true, 55, 'Микрозаймы под залог авто'],
];
foreach ($field as $i => [$name, $site, $federal, $rel, $desc, $dirs, $srcs]) {
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'study_run_id' => $run->id,
'studied_at' => now(), 'name' => $name, 'description' => $desc, 'is_federal' => $federal,
'relevance_pct' => $rel, 'origin' => 'auto', 'box' => 'field', 'site_url' => $site,
'directory_urls' => $dirs, 'dedup_key' => 'site:'.$site,
]);
foreach ($srcs as [$type, $ident, $kind, $ptype]) {
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => $type, 'identifier' => $ident, 'phone_kind' => $kind, 'phone_type' => $ptype,
'box' => 'field', 'provenance_label' => $type === 'site' ? 'сайт компании' : 'карточка в 2ГИС',
'dedup_key' => $type.':'.$ident,
]);
}
}
foreach ($proposals as [$name, $site, $federal, $rel, $desc]) {
AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => $name, 'description' => $desc, 'is_federal' => $federal,
'relevance_pct' => $rel, 'origin' => 'auto', 'box' => 'proposal', 'site_url' => $site,
'dedup_key' => 'site:'.$site,
]);
}
// 4) Демо-проекты: один «в работе», один «на паузе» — чтобы показать счётчики,
// паузу/возобновление и смену источника на живых данных. Минимальная строка
// (только обязательные поля), без джобов поставщика.
$linkProject = function (string $compName, bool $active) use ($tenant) {
$comp = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', $compName)->first();
if (! $comp) {
return;
}
$src = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'site')->first();
if (! $src) {
return;
}
$p = Project::firstOrNew(['tenant_id' => $tenant->id, 'name' => $compName]);
$p->signal_identifier = $src->identifier;
$p->signal_type = 'site';
$p->signal_identifier = $src->identifier;
$p->is_active = $active;
$p->paused_at = $active ? null : now();
$p->daily_limit_target = 20;
$p->delivery_days_mask = 127;
$p->save();
$src->update(['created_project_id' => $p->id]);
};
$linkProject('КрасЛомбард', true); // в работе
$linkProject('Голд Авто Инвест', false); // на паузе
// источники-предложения у КрасЛомбарда (результат «собрать источники» — до переноса в работу)
$krl = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', 'КрасЛомбард')->first();
if ($krl) {
foreach ([
['site', 'kraslombard-new.ru', null, null, '2ГИС — сайт в карточке компании'],
['call', '73912001100', 'real', 'city', '2ГИС — карточка компании'],
] as [$type, $ident, $kind, $ptype, $prov]) {
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $krl->id, 'study_run_id' => $run->id,
'signal_type' => $type, 'identifier' => $ident, 'phone_kind' => $kind, 'phone_type' => $ptype,
'box' => 'proposal', 'provenance_label' => $prov, 'dedup_key' => $type.':'.$ident.':sug',
]);
}
}
$this->command?->info('Омега-демо готова: '.count($field).' в поле (2 с проектами), '.count($proposals).' в предложениях. Логин omega-demo@liderra.local / omega12345');
}
}
+1 -1
View File
@@ -315,7 +315,7 @@ export async function listSystemSettings(): Promise<SystemSetting[]> {
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 {
+289
View File
@@ -0,0 +1,289 @@
import axios from 'axios';
import { apiClient } from './client';
/**
* Адресные сообщения по коду ошибки автоподбора (бэкенд кладёт `{ error: 'code' }`).
* Общий `extractErrorMessage` читает `message`, поэтому для наших кодов нужен отдельный маппер —
* иначе клиент видит общий текст «Проверьте баланс» на ЛЮБУЮ ошибку.
*/
const AUTOPODBOR_ERROR_MESSAGES: Record<string, string> = {
balance_insufficient: 'Не хватает денег на балансе — пополните счёт, чтобы запустить.',
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 code = (error.response?.data as { error?: string } | undefined)?.error;
if (code && AUTOPODBOR_ERROR_MESSAGES[code]) {
return AUTOPODBOR_ERROR_MESSAGES[code];
}
}
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<string, unknown>;
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;
}
export type Box = 'proposal' | 'field';
export type PhoneType = 'city' | 'mobile' | 'tollfree' | null;
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[];
studied_at: string | null;
study_run_id: number | null;
search_run_id: number | null;
}
export interface SourceDto {
id: number;
competitor_id: number;
signal_type: 'site' | 'call';
identifier: string;
phone_kind: 'real' | 'substitute' | null;
phone_type: PhoneType;
box: Box;
provenance_url: string | null;
provenance_label: string | null;
created_project_id: number | null;
existing_project_id?: number | 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<StateDto> {
const { data } = await apiClient.get<StateDto>('/api/autopodbor/state');
return data;
}
export async function fetchRun(id: number): Promise<RunDto> {
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[] }> {
const { data } = await apiClient.get<{ data: CompetitorDto; sources: FieldSourceDto[] }>(
`/api/autopodbor/competitors/${id}`,
);
return { competitor: data.data, sources: data.sources };
}
export async function startSearch(p: {
region_code: number;
examples: string[];
about_self: string[];
include_federal: boolean;
}): Promise<RunDto> {
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/search', p);
return data.data;
}
export async function startStudy(competitor_id: number): Promise<RunDto> {
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/study', { competitor_id });
return data.data;
}
export async function startResolve(p: { name: string; region_code: number }): Promise<RunDto> {
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/resolve', p);
return data.data;
}
export async function startManualStudy(p: {
competitor_id?: number;
name?: string;
site_url?: string;
directory?: string;
region_code: number;
}): Promise<RunDto> {
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 }): Promise<SourceDto> {
const { data } = await apiClient.post<{ data: SourceDto }>('/api/autopodbor/sources/manual', p);
return data.data;
}
export async function createProjects(p: {
source_ids: number[];
regions: number[];
daily_limit_target: number;
delivery_days_mask: number;
launch: boolean;
}): Promise<Array<{ id: number; name: string }>> {
const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p);
return data.data;
}
export async function fetchRunCompetitors(runId: number): Promise<CompetitorDto[]> {
const { data } = await apiClient.get<{ data: CompetitorDto[] }>(`/api/autopodbor/runs/${runId}/competitors`);
return data.data;
}
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
/** Конкуренты в поле с источниками в работе и счётчиками. */
export async function fetchField(): Promise<FieldCompetitorDto[]> {
const { data } = await apiClient.get<{ competitors: FieldCompetitorDto[] }>('/api/autopodbor/field');
return data.competitors;
}
/** Конкуренты в ящике «предложения» (сорт по похожести). */
export async function fetchProposals(): Promise<CompetitorDto[]> {
const { data } = await apiClient.get<{ data: CompetitorDto[] }>('/api/autopodbor/proposals');
return data.data;
}
export async function setCompetitorBox(id: number, box: Box): Promise<CompetitorDto> {
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<SourceDto> {
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[];
box?: Box;
}
export async function updateCompetitor(id: number, patch: CompetitorPatch): Promise<CompetitorDto> {
const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}`, patch);
return data.data;
}
export async function deleteCompetitor(id: number): Promise<void> {
await apiClient.delete(`/api/autopodbor/competitors/${id}`);
}
export interface SourcePatch {
identifier?: string;
phone_kind?: 'real' | 'substitute' | null;
phone_type?: PhoneType;
provenance_url?: string | null;
provenance_label?: string | null;
box?: Box;
}
export async function updateSource(id: number, patch: SourcePatch): Promise<SourceDto> {
const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}`, patch);
return data.data;
}
export async function deleteSource(id: number): Promise<void> {
await apiClient.delete(`/api/autopodbor/sources/${id}`);
}
export async function createManualCompetitor(p: {
name: string;
description?: string;
site_url?: string;
directory?: string;
is_federal?: boolean;
}): Promise<CompetitorDto> {
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<void> {
await apiClient.patch(`/api/projects/${projectId}/toggle-active`, { is_active: active });
}
/**
* Сменить источник проекта (адрес/номер) через ГОТОВУЮ ручку проектов — это и есть
* change_source со всеми гвардами §14.10 (тип источника не меняется). Возвращает
* сообщение о сроках вступления в силу.
*/
export async function changeProjectSource(projectId: number, signalIdentifier: string): Promise<ChangeSourceResult> {
const { data } = await apiClient.patch<ChangeSourceResult>(`/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<ChangeSourceResult> {
const { data } = await apiClient.patch<ChangeSourceResult>(`/api/projects/${projectId}`, p);
return data ?? {};
}
@@ -0,0 +1,73 @@
<script setup lang="ts">
/**
* «Дополнительные услуги» в Биллинге — тарифы «Конкурентного поля»:
* сбор конкурентов (шаг 1) и сбор источников (шаг 2). Списываются только при успехе.
* Цены — из autopodbor store (system_settings: autopodbor_price_search_rub/_study_rub).
* Панель показывается только если фича включена.
*/
import { computed, onMounted } from 'vue';
import { useAutopodborStore } from '../../stores/autopodborStore';
const store = useAutopodborStore();
const enabled = computed(() => store.enabled);
const searchPrice = computed(() => store.prices.search);
const studyPrice = computed(() => store.prices.study);
onMounted(() => {
void store.loadState();
});
</script>
<template>
<v-card v-if="enabled" variant="flat" border class="mt-4 ap-services">
<v-card-title class="text-subtitle-1 font-weight-bold">Дополнительные услуги</v-card-title>
<v-card-subtitle class="pb-2">«Конкурентное поле» деньги списываются только при успешном результате</v-card-subtitle>
<v-card-text>
<div class="ap-row">
<div class="ap-row__name">
<div class="font-weight-medium">Сбор конкурентов</div>
<div class="text-caption text-medium-emphasis">Подбор похожих конкурентов по вашим примерам и региону</div>
</div>
<div class="ap-row__when text-caption text-medium-emphasis">при успешном подборе</div>
<div class="ap-row__price num">{{ searchPrice }} </div>
</div>
<v-divider class="my-2" />
<div class="ap-row">
<div class="ap-row__name">
<div class="font-weight-medium">Сбор источников</div>
<div class="text-caption text-medium-emphasis">Все источники одного конкурента (сайты и телефоны) для проектов</div>
</div>
<div class="ap-row__when text-caption text-medium-emphasis">при успешном изучении</div>
<div class="ap-row__price num">{{ studyPrice }} </div>
</div>
</v-card-text>
</v-card>
</template>
<style scoped>
.ap-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.ap-row__name {
min-width: 0;
flex: 1;
}
.ap-row__when {
flex-shrink: 0;
white-space: nowrap;
text-align: right;
min-width: 140px;
}
.ap-row__price {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 600;
font-size: 16px;
color: #0f6e56;
white-space: nowrap;
}
</style>
@@ -13,6 +13,7 @@ import Kbd from '../ui/Kbd.vue';
import { useAuthStore } from '../../stores/auth';
import { useDealsCountStore } from '../../stores/dealsCount';
import { useCommandPalette } from '../../composables/useCommandPalette';
import { useAutopodborStore } from '../../stores/autopodborStore';
interface NavItem {
title: string;
@@ -20,6 +21,7 @@ interface NavItem {
to: string;
count?: number;
countKey?: string;
badge?: string;
}
interface NavGroup {
eyebrow: string;
@@ -32,9 +34,11 @@ const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
const { openPalette } = useCommandPalette();
const autopodbor = useAutopodborStore();
onMounted(() => {
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
void autopodbor.loadState().catch(() => {});
});
const navGroups = computed<NavGroup[]>(() => [
@@ -42,6 +46,7 @@ const navGroups = computed<NavGroup[]>(() => [
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
...(autopodbor.enabled ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []),
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
{
@@ -106,6 +111,7 @@ defineExpose({ navGroups });
:data-tour="`nav-${item.to.replace('/', '')}`"
>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</span>
<span
v-if="resolveCount(item) > 0"
class="ld-nav-item__badge ld-mono"
@@ -243,4 +249,14 @@ defineExpose({ navGroups });
background: rgba(255, 255, 255, 0.1);
color: var(--liderra-ivory);
}
.ld-nav-item__new {
font-size: 9px;
background: var(--liderra-teal);
color: #fff;
border-radius: 4px;
padding: 1px 5px;
letter-spacing: 0.04em;
margin-left: 6px;
}
</style>
+1
View File
@@ -30,6 +30,7 @@ const navItems: NavItem[] = [
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Тарифы «Конкурентного поля»', icon: 'mdi-bullseye-arrow', to: '/admin/autopodbor-pricing' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
+18
View File
@@ -140,6 +140,12 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Проекты',
},
},
{
path: '/autopodbor',
name: 'autopodbor',
component: () => import('../views/autopodbor/AutopodborView.vue'),
meta: { layout: 'app', title: 'Конкурентное поле', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Конкурентное поле' },
},
{
path: '/billing',
name: 'billing',
@@ -264,6 +270,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Admin Pricing Tiers',
},
},
{
path: '/admin/autopodbor-pricing',
name: 'admin-autopodbor-pricing',
component: () => import('../views/admin/AdminAutopodborPricingView.vue'),
meta: {
layout: 'admin',
title: 'Тарифы «Конкурентного поля»',
requiresAuth: true,
devIndex: 28,
devLabel: 'Admin Autopodbor Pricing',
},
},
{
path: '/admin/supplier-prices',
name: 'admin-supplier-prices',
+308
View File
@@ -0,0 +1,308 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import {
fetchState,
fetchRun,
fetchCompetitor,
startSearch,
startStudy,
startResolve,
startManualStudy,
addManualSource,
createProjects,
fetchRunCompetitors,
fetchField,
fetchProposals,
setCompetitorBox,
setSourceBox,
updateCompetitor,
deleteCompetitor,
updateSource,
deleteSource,
createManualCompetitor,
toggleProjectActive as apiToggleProjectActive,
changeProjectSource as apiChangeProjectSource,
updateProjectSettings as apiUpdateProjectSettings,
type RunDto,
type ChangeSourceResult,
type CompetitorDto,
type SourceDto,
type Box,
type CompetitorPatch,
type SourcePatch,
type FieldCompetitorDto,
type FieldSourceDto,
} from '../api/autopodbor';
/** Задержка между тиками опроса (вынесена для тестируемости). */
export const POLL_MS = 2500;
const TERMINAL: ReadonlySet<string> = new Set(['done', 'empty', 'failed']);
export const useAutopodborStore = defineStore('autopodbor', () => {
const enabled = ref(false);
const prices = ref<{ search: string; study: string }>({ search: '0', study: '0' });
const runs = ref<RunDto[]>([]);
const currentRun = ref<RunDto | null>(null);
const competitor = ref<CompetitorDto | null>(null);
const sources = ref<FieldSourceDto[]>([]);
const runCompetitors = ref<CompetitorDto[]>([]);
const field = ref<FieldCompetitorDto[]>([]);
const proposals = ref<CompetitorDto[]>([]);
const loading = ref(false);
// Internal poll handle — not exposed as reactive state.
let _pollTimeout: ReturnType<typeof setTimeout> | null = null;
// ——— Actions ———
async function loadState(): Promise<void> {
loading.value = true;
try {
const state = await fetchState();
enabled.value = state.enabled;
prices.value = state.prices;
runs.value = state.runs;
} catch {
// Сетевая ошибка — enabled остаётся false, не роняем UI
} finally {
loading.value = false;
}
}
async function search(p: {
region_code: number;
examples: string[];
about_self: string[];
include_federal: boolean;
}): Promise<RunDto> {
const run = await startSearch(p);
currentRun.value = run;
return run;
}
async function study(competitorId: number): Promise<RunDto> {
const run = await startStudy(competitorId);
currentRun.value = run;
return run;
}
async function resolve(p: { name: string; region_code: number }): Promise<RunDto> {
const run = await startResolve(p);
currentRun.value = run;
return run;
}
async function manualStudy(p: {
competitor_id?: number;
name?: string;
site_url?: string;
directory?: string;
region_code: number;
}): Promise<RunDto> {
const run = await startManualStudy(p);
currentRun.value = run;
return run;
}
async function loadCompetitor(id: number): Promise<void> {
const result = await fetchCompetitor(id);
competitor.value = result.competitor;
sources.value = result.sources;
}
async function addSource(p: { competitor_id: number; raw: string }): Promise<SourceDto> {
const source = await addManualSource(p);
sources.value.push({ ...source, project: null });
return source;
}
async function makeProjects(p: {
source_ids: number[];
regions: number[];
daily_limit_target: number;
delivery_days_mask: number;
launch: boolean;
}): Promise<Array<{ id: number; name: string }>> {
return await createProjects(p);
}
/**
* Опрашивает run каждые POLL_MS мс до терминального статуса.
*
* Реализация: первый запрос выполняется немедленно (без начального setTimeout),
* далее — рекурсивный setTimeout(POLL_MS). Это обеспечивает детерминированное
* поведение с vi.useFakeTimers() + vi.runAllTimersAsync().
*
* Возвращает Promise, который резолвится в финальный RunDto.
* stopPolling() отменяет ожидающий тайм-аут (текущий tick уже не прерывается).
*/
function pollRun(id: number, onTick?: (run: RunDto) => void): Promise<RunDto> {
stopPolling();
return new Promise<RunDto>((resolve) => {
async function tick(): Promise<void> {
const run = await fetchRun(id);
currentRun.value = run;
onTick?.(run);
if (TERMINAL.has(run.status)) {
resolve(run);
return;
}
// Schedule next tick only if not already cancelled by stopPolling().
_pollTimeout = setTimeout(() => {
_pollTimeout = null;
tick();
}, POLL_MS);
}
// Start immediately — no leading delay.
tick();
});
}
function stopPolling(): void {
if (_pollTimeout !== null) {
clearTimeout(_pollTimeout);
_pollTimeout = null;
}
}
async function loadRunCompetitors(runId: number): Promise<void> {
runCompetitors.value = await fetchRunCompetitors(runId);
}
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
async function loadField(): Promise<void> {
field.value = (await fetchField()) ?? [];
}
async function loadProposals(): Promise<void> {
proposals.value = (await fetchProposals()) ?? [];
}
/** Перенос конкурента предложение↔поле; уход из поля убирает карточку из списка. */
async function moveCompetitorToBox(id: number, box: Box): Promise<void> {
await setCompetitorBox(id, box);
if (box !== 'field') {
field.value = field.value.filter((c) => c.id !== id);
}
}
async function editCompetitor(id: number, patch: CompetitorPatch): Promise<void> {
const updated = await updateCompetitor(id, patch);
const idx = field.value.findIndex((c) => c.id === id);
if (idx !== -1) {
field.value[idx] = { ...field.value[idx], ...updated };
}
}
async function removeCompetitor(id: number): Promise<void> {
await deleteCompetitor(id);
field.value = field.value.filter((c) => c.id !== id);
}
async function addFieldCompetitor(p: {
name: string;
description?: string;
site_url?: string;
directory?: string;
is_federal?: boolean;
}): Promise<CompetitorDto> {
const created = await createManualCompetitor(p);
field.value.push({
...created,
counters: { sources: 0, projects_created: 0, projects_in_work: 0 },
sources: [],
});
return created;
}
/** Перенос источника предложение↔в работу внутри карточки конкурента. */
async function moveSourceToBox(competitorId: number, sourceId: number, box: Box): Promise<void> {
await setSourceBox(sourceId, box);
const comp = field.value.find((c) => c.id === competitorId);
if (comp && box !== 'field') {
comp.sources = comp.sources.filter((s) => s.id !== sourceId);
}
}
async function editSource(competitorId: number, sourceId: number, patch: SourcePatch): Promise<void> {
const updated = await updateSource(sourceId, patch);
const comp = field.value.find((c) => c.id === competitorId);
if (comp) {
const idx = comp.sources.findIndex((s) => s.id === sourceId);
if (idx !== -1) {
comp.sources[idx] = { ...comp.sources[idx], ...updated };
}
}
}
async function removeSource(competitorId: number, sourceId: number): Promise<void> {
await deleteSource(sourceId);
const comp = field.value.find((c) => c.id === competitorId);
if (comp) {
comp.sources = comp.sources.filter((s) => s.id !== sourceId);
}
}
/** Управление проектом источника через готовую ручку проектов (все гварды там). */
async function toggleProjectActive(projectId: number, active: boolean): Promise<void> {
await apiToggleProjectActive(projectId, active);
}
/** Смена источника проекта (change_source, §14.10) — через готовую ручку проектов. */
async function changeProjectSource(projectId: number, identifier: string): Promise<ChangeSourceResult> {
return await apiChangeProjectSource(projectId, identifier);
}
/** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов. */
async function updateProjectSettings(
projectId: number,
p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number },
): Promise<ChangeSourceResult> {
return await apiUpdateProjectSettings(projectId, p);
}
return {
// State
enabled,
prices,
runs,
currentRun,
competitor,
sources,
runCompetitors,
field,
proposals,
loading,
// Actions
loadState,
search,
study,
resolve,
manualStudy,
loadCompetitor,
addSource,
makeProjects,
pollRun,
stopPolling,
loadRunCompetitors,
// «Конкурентное поле»
loadField,
loadProposals,
moveCompetitorToBox,
editCompetitor,
removeCompetitor,
addFieldCompetitor,
moveSourceToBox,
editSource,
removeSource,
toggleProjectActive,
changeProjectSource,
updateProjectSettings,
};
});
+3
View File
@@ -12,6 +12,7 @@
import { ref, computed, onMounted } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import AutopodborServicesPanel from '../components/billing/AutopodborServicesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
import InvoicesTable from '../components/billing/InvoicesTable.vue';
import TopupDialog from '../components/billing/TopupDialog.vue';
@@ -131,6 +132,8 @@ defineExpose({ loadWallet, wallet, topupOpen });
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<AutopodborServicesPanel />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
@@ -0,0 +1,238 @@
<template>
<div class="admin-autopodbor-pricing-view">
<h1 class="text-h4 mb-1">Тарифы и услуги «Конкурентного поля»</h1>
<p class="text-body-2 text-medium-emphasis mb-6">Управление ценами. Изменения применяются ко всем клиентам.</p>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
class="mb-4"
closable
data-testid="ap-pricing-error"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-card class="mb-6" elevation="1" max-width="640">
<v-card-title class="text-subtitle-1 font-weight-bold">Дополнительные услуги</v-card-title>
<v-card-subtitle class="pb-2">Цена за успешный результат. Списывается только при успехе, пустой результат бесплатно.</v-card-subtitle>
<v-card-text>
<v-text-field
v-model="searchPrice"
type="number"
min="0"
step="1"
label="Сбор конкурентов — ₽ за подбор"
hint="Списывается при успешном подборе конкурентов."
persistent-hint
density="comfortable"
class="mb-3"
data-testid="ap-search-price"
/>
<v-text-field
v-model="studyPrice"
type="number"
min="0"
step="1"
label="Сбор источников — ₽ за изучение конкурента"
hint="Списывается, если нашли сайты/телефоны конкурента."
persistent-hint
density="comfortable"
class="mb-3"
data-testid="ap-study-price"
/>
<v-textarea
v-model="reason"
label="Причина изменения (для журнала аудита) — минимум 30 символов"
:error="reason.length > 0 && !reasonValid"
rows="2"
auto-grow
density="comfortable"
data-testid="ap-reason"
/>
<div class="d-flex justify-end">
<v-btn
color="primary"
:loading="saving"
:disabled="!hasChanges"
data-testid="ap-save-btn"
@click="save"
>
Сохранить тарифы
</v-btn>
</div>
</v-card-text>
</v-card>
<h2 class="text-subtitle-1 font-weight-bold mb-1">Тариф на лиды</h2>
<p class="text-body-2 text-medium-emphasis mb-2">
Сетка цен за лиды по объёму здесь для справки (настраивается отдельно в «Тарифной сетке»).
</p>
<v-card elevation="1" max-width="640">
<table class="lead-tiers-table">
<thead>
<tr>
<th>Лидов в ступени</th>
<th class="r">Цена за лид</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tiers" :key="t.tier_no">
<td>
<span v-if="t.leads_in_tier !== null">{{ t.leads_in_tier }}</span>
<span v-else class="text-medium-emphasis">все свыше</span>
</td>
<td class="r num">{{ fmtRub(t.price_per_lead_kopecks) }}</td>
</tr>
</tbody>
</table>
</v-card>
<v-snackbar
v-model="successToastOpen"
:timeout="4000"
color="success"
location="bottom right"
data-testid="ap-pricing-success"
>
{{ successMessage }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { listSystemSettings, updateSystemSetting, getPricingTiers, type AdminPricingTier } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
/**
* SaaS-admin → дружелюбный экран тарифов доп.услуг «Конкурентного поля».
* Правит две цены в system_settings (autopodbor_price_search_rub / _study_rub)
* через PUT /api/admin/system-settings/{key} (audit-log, reason ≥30). Сетка лидов —
* справочно (read-only). Прообраз — прототип renderAdmin.
*/
const SEARCH_KEY = 'autopodbor_price_search_rub';
const STUDY_KEY = 'autopodbor_price_study_rub';
const DEFAULT_REASON = 'Изменение тарифов доп.услуг «Конкурентное поле» администратором.';
const searchPrice = ref('');
const studyPrice = ref('');
const origSearch = ref('');
const origStudy = ref('');
const reason = ref(DEFAULT_REASON);
const tiers = ref<AdminPricingTier[]>([]);
const saving = ref(false);
const errorMessage = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const successToastOpen = ref(false);
const reasonValid = computed(() => reason.value.trim().length >= 30);
const hasChanges = computed(
() => searchPrice.value !== origSearch.value || studyPrice.value !== origStudy.value,
);
function fmtRub(kopecks: number): string {
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(kopecks / 100) + ' ₽';
}
async function load(): Promise<void> {
errorMessage.value = null;
try {
const settings = await listSystemSettings();
const s = settings.find((x) => x.key === SEARCH_KEY);
const t = settings.find((x) => x.key === STUDY_KEY);
searchPrice.value = origSearch.value = s?.value ?? '';
studyPrice.value = origStudy.value = t?.value ?? '';
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифы.');
}
try {
const data = await getPricingTiers();
tiers.value = data.active;
} catch {
// Сетка лидов — справочная; её отсутствие не блокирует правку цен.
}
}
async function save(): Promise<void> {
errorMessage.value = null;
successMessage.value = null;
if (!hasChanges.value) {
errorMessage.value = 'Вы не изменили ни одной цены.';
return;
}
if (!reasonValid.value) {
errorMessage.value = 'Укажите причину изменения — минимум 30 символов (для журнала аудита).';
return;
}
saving.value = true;
try {
const reasonText = reason.value.trim();
// admin_user_id НЕ шлём с клиента — бэкенд проставляет id админа из сессии (audit-log).
if (searchPrice.value !== origSearch.value) {
await updateSystemSetting(SEARCH_KEY, { value: String(searchPrice.value), reason: reasonText });
}
if (studyPrice.value !== origStudy.value) {
await updateSystemSetting(STUDY_KEY, { value: String(studyPrice.value), reason: reasonText });
}
successMessage.value = 'Тарифы сохранены. Изменения применяются ко всем клиентам.';
successToastOpen.value = true;
await load();
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифы.');
} finally {
saving.value = false;
}
}
onMounted(load);
defineExpose({
load,
save,
searchPrice,
studyPrice,
reason,
tiers,
saving,
errorMessage,
successMessage,
successToastOpen,
reasonValid,
hasChanges,
});
</script>
<style scoped>
.lead-tiers-table {
width: 100%;
border-collapse: collapse;
}
.lead-tiers-table th,
.lead-tiers-table td {
padding: 9px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
text-align: left;
font-size: 14px;
}
.lead-tiers-table th {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #55606b;
}
.lead-tiers-table .r {
text-align: right;
}
.lead-tiers-table .num {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'tnum';
font-weight: 600;
color: #0f6e56;
}
</style>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref, reactive, provide, onMounted } from 'vue';
import { useAutopodborStore } from '../../stores/autopodborStore';
import FieldWorkspaceScreen from './screens/FieldWorkspaceScreen.vue';
import FieldCompetitorScreen from './screens/FieldCompetitorScreen.vue';
import FieldProposalsScreen from './screens/FieldProposalsScreen.vue';
import EntryScreen from './screens/EntryScreen.vue';
import AutoFormScreen from './screens/AutoFormScreen.vue';
import ManualFormScreen from './screens/ManualFormScreen.vue';
import LoadingScreen from './screens/LoadingScreen.vue';
import ListScreen from './screens/ListScreen.vue';
import DetailScreen from './screens/DetailScreen.vue';
import CreateScreen from './screens/CreateScreen.vue';
import DoneScreen from './screens/DoneScreen.vue';
import EditProjectScreen from './screens/EditProjectScreen.vue';
type ScreenName =
| 'field'
| 'fieldcompetitor'
| 'field-proposals'
| 'entry'
| 'autoform'
| 'manualform'
| 'loading'
| 'list'
| 'detail'
| 'editproject'
| 'create'
| 'done';
const store = useAutopodborStore();
const screen = ref<ScreenName>('field');
const ctx = reactive({
runId: null as number | null,
competitorId: null as number | null,
selectedSourceIds: [] as number[],
loadMsg: '',
loadSub: '',
editProjectId: null as number | null,
createdCount: 0,
launched: false,
});
function go(name: ScreenName) {
screen.value = name;
window.scrollTo(0, 0);
}
provide('autopodborNav', { go, ctx, screen });
const screens: Partial<Record<ScreenName, any>> = {
field: FieldWorkspaceScreen,
fieldcompetitor: FieldCompetitorScreen,
'field-proposals': FieldProposalsScreen,
entry: EntryScreen,
autoform: AutoFormScreen,
manualform: ManualFormScreen,
loading: LoadingScreen,
list: ListScreen,
detail: DetailScreen,
create: CreateScreen,
done: DoneScreen,
editproject: EditProjectScreen,
};
onMounted(() => {
void store.loadState();
});
defineExpose({ go, screen, ctx });
</script>
<template>
<div class="ld-autopodbor">
<component :is="screens[screen]" v-if="screens[screen]" />
</div>
</template>
<style scoped>
.ld-autopodbor {
padding: 0 24px;
max-width: 900px;
margin: 0 auto;
}
</style>
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { inject, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { REGIONS } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
// Список конкурентов-примеров (как минимум одно поле всегда видно)
const examples = ref<string[]>(['', '', '']);
const regionCode = ref<number | null>(null);
const includeFederal = ref(true);
const errorMsg = ref('');
defineExpose({ regionCode });
function addExample() {
examples.value.push('');
}
function extractError(e: unknown): string {
const code = (e as any)?.response?.data?.error;
if (code === 'balance_insufficient') return 'Недостаточно средств на балансе.';
if (code === 'run_in_flight') return 'Уже идёт похожий запрос — дождитесь его завершения.';
return 'Произошла ошибка. Попробуйте позже.';
}
async function submit() {
errorMsg.value = '';
const filled = examples.value.map(e => e.trim()).filter(Boolean);
if (filled.length === 0) {
errorMsg.value = 'Укажите хотя бы один пример конкурента.';
return;
}
if (!regionCode.value) {
errorMsg.value = 'Выберите регион поиска.';
return;
}
nav.go('loading');
try {
const run = await store.search({
region_code: regionCode.value,
examples: filled,
about_self: [],
include_federal: includeFederal.value,
});
await store.pollRun(run.id);
nav.go('list');
} catch (e) {
nav.go('autoform');
errorMsg.value = extractError(e);
}
}
</script>
<template>
<div class="ld-autoform-screen">
<div class="ld-af-topbar">
<span class="ld-af-crumb">Автоподбор · Подбор конкурентов</span>
</div>
<button class="ld-af-back" type="button" @click="nav.go('entry')"> Назад</button>
<h1 class="ld-af-title">Подобрать конкурентов</h1>
<p class="ld-af-sub">Укажите примеры конкурентов и регион Лидерра найдёт похожих.</p>
<v-alert v-if="errorMsg" type="error" class="ld-af-alert" variant="tonal" closable @click:close="errorMsg = ''">
{{ errorMsg }}
</v-alert>
<div class="ld-af-card">
<p class="ld-af-sectitle">Ваши конкуренты <span class="ld-af-req">*</span></p>
<p class="ld-af-hint">Чем больше примеров, тем точнее и шире подбор. Сайт конкурента или ссылка на его карточку в справочнике (2ГИС, Яндекс.Карты).</p>
<input
v-for="(_, i) in examples"
:key="i"
v-model="examples[i]"
class="ld-af-input"
type="text"
:placeholder="i === 0 ? 'okna-kazan.ru' : i === 1 ? '2gis.ru/kazan/firm/70000001…' : 'plastokna-rt.ru'"
/>
<button class="ld-af-addrow" type="button" @click="addExample"> добавить конкурента</button>
<div class="ld-af-divider"></div>
<p class="ld-af-sectitle">Регион поиска <span class="ld-af-req">*</span></p>
<p class="ld-af-hint">Обязательно. Один регион за один подбор иначе список будет слишком большим.</p>
<select v-model="regionCode" class="ld-af-select">
<option :value="null" disabled> выберите регион </option>
<option v-for="r in REGIONS.filter(r => r.code > 0)" :key="r.code" :value="r.code">{{ r.name }}</option>
</select>
<label class="ld-af-check">
<input v-model="includeFederal" type="checkbox" class="ld-af-check-input" />
<span>Включать федеральных игроков<br />
<span class="ld-af-muted">Крупные компании, которые работают и в вашем регионе, и в других.</span>
</span>
</label>
<div class="ld-af-divider"></div>
<button class="ld-btn-primary" type="button" @click="submit">Подобрать конкурентов</button>
<p class="ld-af-paynote">Услуга платная при запуске спишем сумму с баланса.</p>
</div>
</div>
</template>
<style scoped>
.ld-autoform-screen {
padding: 28px 0;
}
.ld-af-topbar {
margin-bottom: 8px;
}
.ld-af-crumb {
font-size: 12.5px;
color: #7a7468;
}
.ld-af-back {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 16px;
display: inline-block;
}
.ld-af-title {
font-size: 24px;
font-weight: 700;
color: #012019;
margin: 0 0 8px;
}
.ld-af-sub {
font-size: 14px;
color: #4a4540;
margin: 0 0 20px;
}
.ld-af-alert {
margin-bottom: 16px;
}
.ld-af-card {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 22px 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ld-af-sectitle {
font-size: 13.5px;
font-weight: 700;
color: #012019;
margin: 0;
}
.ld-af-req {
color: #c0392b;
}
.ld-af-hint {
font-size: 12.5px;
color: #7a7468;
margin: 0;
line-height: 1.5;
}
.ld-af-input {
border: 1.5px solid #d8d2c6;
border-radius: 7px;
padding: 9px 12px;
font-size: 13.5px;
color: #012019;
width: 100%;
box-sizing: border-box;
outline: none;
transition: border-color 150ms ease;
background: #faf8f4;
}
.ld-af-input:focus {
border-color: var(--liderra-teal, #0f6e56);
background: #fff;
}
.ld-af-select {
border: 1.5px solid #d8d2c6;
border-radius: 7px;
padding: 9px 12px;
font-size: 13.5px;
color: #012019;
width: 100%;
box-sizing: border-box;
outline: none;
background: #faf8f4;
cursor: pointer;
transition: border-color 150ms ease;
}
.ld-af-select:focus {
border-color: var(--liderra-teal, #0f6e56);
background: #fff;
}
.ld-af-addrow {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 0;
text-align: left;
}
.ld-af-check {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13.5px;
color: #012019;
cursor: pointer;
}
.ld-af-check-input {
margin-top: 2px;
accent-color: var(--liderra-teal, #0f6e56);
flex-shrink: 0;
}
.ld-af-muted {
color: #9b9484;
font-size: 12px;
}
.ld-af-divider {
height: 1px;
background: #f0ece1;
margin: 4px 0;
}
.ld-btn-primary {
display: inline-flex;
align-items: center;
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 10px 20px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
align-self: flex-start;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-af-paynote {
font-size: 11.5px;
color: #9b9484;
margin: 0;
}
</style>
@@ -0,0 +1,521 @@
<script setup lang="ts">
import { inject, computed, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { REGIONS } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
// Выбранные источники из ctx
const selected = computed(() =>
store.sources.filter((s) => nav.ctx.selectedSourceIds.includes(s.id)),
);
// Регионы (только code > 0)
const regions = REGIONS.filter((r) => r.code > 0);
// Состояние формы
const regionCode = ref<number | null>(
store.currentRun?.region_code ?? null,
);
const dailyLimit = ref<number>(20);
// Маска дней: бит i = 1<<i, дефолт все 7 дней = 127
const deliveryMask = ref<number>(127);
// Для тестируемости
defineExpose({ regionCode, dailyLimit, deliveryMask });
const errorMsg = ref('');
// Имена дней
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
function isDayOn(i: number): boolean {
return (deliveryMask.value & (1 << i)) !== 0;
}
function toggleDay(i: number): void {
deliveryMask.value ^= 1 << i;
}
// Производное имя источника
function sourceName(src: { signal_type: string; phone_kind: string | null }): string {
const base = store.competitor?.name ?? '';
if (src.signal_type === 'site') return base;
if (src.phone_kind === 'real') return `${base}`;
if (src.phone_kind === 'substitute') return `${base} 🎭`;
return base;
}
async function create(launch: boolean): Promise<void> {
if (!regionCode.value) {
errorMsg.value = 'Выберите регион.';
return;
}
errorMsg.value = '';
nav.ctx.loadMsg = launch ? 'Создаём и запускаем проекты…' : 'Создаём проекты…';
nav.ctx.loadSub = 'Заводим проекты и передаём источники поставщику.';
nav.go('loading');
try {
const projects = await store.makeProjects({
source_ids: nav.ctx.selectedSourceIds,
regions: [regionCode.value],
daily_limit_target: dailyLimit.value,
delivery_days_mask: deliveryMask.value,
launch,
});
nav.ctx.createdCount = projects.length;
nav.ctx.launched = launch;
nav.go('done');
} catch (e) {
const code = (e as any)?.response?.data?.error;
errorMsg.value = code === 'balance_insufficient'
? 'Недостаточно средств для запуска всех проектов. Можно создать без запуска и пополнить баланс позже.'
: 'Не удалось создать проекты. Попробуйте ещё раз.';
nav.go('create');
}
}
</script>
<template>
<div class="ld-create-screen">
<!-- Topbar -->
<div class="ld-topbar">
<div class="ld-crumb">
Автоподбор
<template v-if="store.competitor"> · {{ store.competitor.name }}</template>
· Создание проектов
</div>
</div>
<div class="ld-create-content">
<!-- Back -->
<button class="ld-back" @click="nav.go('detail')"> К источникам конкурента</button>
<h1 class="ld-title">Создание проектов</h1>
<p class="ld-sub">
Каждый выбранный источник станет отдельным проектом.
Ниже общие настройки, применятся ко всем.
</p>
<!-- Ошибка -->
<div v-if="errorMsg" class="ld-alert">{{ errorMsg }}</div>
<!-- Карточка источников -->
<div class="ld-card">
<p class="ld-ctitle">Будет создано {{ selected.length }} проектов</p>
<p class="ld-hint">
Название сформировано автоматически: конкурент + значок типа номера.
<span class="ld-mark-real"></span> настоящий номер ·
<span class="ld-mark-sub">🎭</span> подменный (с сайта).
<em>Переименование в разделе «Проекты» после создания.</em>
</p>
<div
v-for="src in selected"
:key="src.id"
class="ld-srow"
>
<span
class="ld-stype"
:class="src.signal_type === 'site' ? 'ld-stype--site' : 'ld-stype--call'"
>
{{ src.signal_type === 'site' ? 'сайт' : 'звонок' }}
</span>
<span
class="ld-sident"
:class="{ 'ld-sident--site': src.signal_type === 'site' }"
>
{{ src.identifier }}
<span v-if="src.phone_kind === 'real'" class="ld-mark-real"></span>
<span v-if="src.phone_kind === 'substitute'" class="ld-mark-sub">🎭</span>
</span>
<span class="ld-derived-name">{{ sourceName(src) }}</span>
</div>
</div>
<!-- Карточка настроек -->
<div class="ld-card">
<p class="ld-ctitle">Настройки проектов</p>
<div class="ld-frow">
<div class="ld-fcol">
<label class="ld-flabel">Регион <span class="ld-req">*</span></label>
<select
v-model="regionCode"
class="ld-select"
>
<option :value="null" disabled> выберите регион </option>
<option
v-for="r in regions"
:key="r.code"
:value="r.code"
>
{{ r.name }}
</option>
</select>
<p class="ld-fhint">Подставлен из подбора. Можно изменить.</p>
</div>
<div class="ld-fcol">
<label class="ld-flabel">Лимит лидов в день <span class="ld-req">*</span></label>
<input
v-model.number="dailyLimit"
type="number"
min="1"
class="ld-input"
>
</div>
</div>
<div class="ld-days-wrap">
<p class="ld-flabel">Дни приёма</p>
<div class="ld-days">
<button
v-for="(label, i) in DAY_LABELS"
:key="i"
type="button"
class="ld-day"
:class="{ 'ld-day--on': isDayOn(i) }"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
</div>
<p class="ld-applyall">
Эти настройки применятся ко всем {{ selected.length }} проектам.
После создания каждый можно настроить отдельно в разделе «Проекты».
</p>
</div>
</div>
<!-- Bottom action bar -->
<div class="ld-actionbar">
<div class="ld-selinfo">
К созданию: <b>{{ selected.length }}</b> проектов
</div>
<div class="ld-actionbar__btns">
<button class="ld-btn-ghost" @click="create(false)">
Создать (без запуска)
</button>
<button class="ld-btn-primary" @click="create(true)">
Создать и запустить
</button>
</div>
</div>
</div>
</template>
<style scoped>
.ld-create-screen {
display: flex;
flex-direction: column;
min-height: 100%;
}
.ld-topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0 14px;
border-bottom: 1px solid #e8e2d4;
margin-bottom: 20px;
}
.ld-crumb {
font-size: 13px;
color: #7a7468;
}
.ld-create-content {
flex: 1;
padding-bottom: 80px;
}
.ld-back {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 18px;
display: inline-block;
}
.ld-back:hover {
text-decoration: underline;
}
.ld-title {
font-size: 22px;
font-weight: 800;
color: #012019;
margin: 0 0 6px;
}
.ld-sub {
font-size: 13.5px;
color: #7a7468;
margin: 0 0 20px;
}
.ld-alert {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 10px 14px;
font-size: 13.5px;
color: #856404;
margin-bottom: 16px;
}
.ld-card {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
}
.ld-ctitle {
font-size: 15px;
font-weight: 700;
color: #012019;
margin: 0 0 8px;
}
.ld-hint {
font-size: 12.5px;
color: #7a7468;
margin: 0 0 12px;
line-height: 1.5;
}
.ld-mark-real {
color: #0c5a46;
font-weight: 700;
}
.ld-mark-sub {
font-weight: 700;
}
.ld-srow {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f0ebe0;
flex-wrap: wrap;
}
.ld-srow:last-child {
border-bottom: none;
}
.ld-stype {
font-size: 11.5px;
border-radius: 4px;
padding: 2px 8px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.ld-stype--site {
background: #e8f3ee;
color: #0c5a46;
border: 1px solid #cfe3da;
}
.ld-stype--call {
background: #edf3fb;
color: #1a4f8a;
border: 1px solid #c5d8ef;
}
.ld-sident {
font-size: 13.5px;
font-weight: 600;
color: #012019;
flex: 1;
min-width: 120px;
}
.ld-sident--site {
color: var(--liderra-teal, #0f6e56);
}
.ld-derived-name {
font-size: 13px;
color: #7a7468;
font-style: italic;
min-width: 160px;
}
.ld-frow {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.ld-fcol {
flex: 1;
min-width: 180px;
}
.ld-flabel {
font-size: 13px;
font-weight: 600;
color: #4a4540;
margin: 0 0 6px;
display: block;
}
.ld-req {
color: #c0392b;
}
.ld-select {
width: 100%;
border: 1.5px solid #d5cfc2;
border-radius: 7px;
padding: 8px 12px;
font-size: 13.5px;
color: #012019;
background: #fff;
outline: none;
cursor: pointer;
transition: border-color 150ms;
}
.ld-select:focus {
border-color: var(--liderra-teal, #0f6e56);
}
.ld-input {
width: 100%;
border: 1.5px solid #d5cfc2;
border-radius: 7px;
padding: 8px 12px;
font-size: 13.5px;
color: #012019;
background: #fff;
outline: none;
transition: border-color 150ms;
box-sizing: border-box;
}
.ld-input:focus {
border-color: var(--liderra-teal, #0f6e56);
}
.ld-fhint {
font-size: 12px;
color: #9b9484;
margin: 6px 0 0;
}
.ld-days-wrap {
margin-top: 14px;
}
.ld-days {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.ld-day {
border: 1.5px solid #d5cfc2;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: #fff;
color: #7a7468;
transition: background 150ms, color 150ms, border-color 150ms;
}
.ld-day--on {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border-color: var(--liderra-teal, #0f6e56);
}
.ld-applyall {
margin-top: 14px;
font-size: 12.5px;
color: #9b9484;
background: #f6f3ec;
border-radius: 6px;
padding: 8px 12px;
}
.ld-actionbar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-top: 1px solid #e8e2d4;
padding: 12px 0;
gap: 12px;
z-index: 10;
}
.ld-selinfo {
font-size: 13.5px;
color: #4a4540;
}
.ld-actionbar__btns {
display: flex;
gap: 10px;
align-items: center;
}
.ld-btn-primary {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-primary:hover:not(:disabled) {
background: #0b5a45;
}
.ld-btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ld-btn-ghost {
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
</style>
@@ -0,0 +1,585 @@
<script setup lang="ts">
import { inject, onMounted, computed, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { SourceDto } from '../../../api/autopodbor';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
const showAddSource = ref(false);
const addSourceRaw = ref('');
const addSourceLoading = ref(false);
onMounted(async () => {
if (nav.ctx.competitorId) {
await store.loadCompetitor(nav.ctx.competitorId);
// Auto-select sources without existing project
nav.ctx.selectedSourceIds = store.sources
.filter((s: SourceDto) => s.existing_project_id == null)
.map((s: SourceDto) => s.id);
}
});
const sites = computed(() =>
store.sources.filter((s: SourceDto) => s.signal_type === 'site'),
);
const calls = computed(() =>
store.sources.filter((s: SourceDto) => s.signal_type === 'call'),
);
const selectedCount = computed(() => nav.ctx.selectedSourceIds.length);
const totalCount = computed(() => store.sources.length);
function isSelected(id: number): boolean {
return nav.ctx.selectedSourceIds.includes(id);
}
function toggleSource(id: number) {
const idx = nav.ctx.selectedSourceIds.indexOf(id);
if (idx === -1) {
nav.ctx.selectedSourceIds.push(id);
} else {
nav.ctx.selectedSourceIds.splice(idx, 1);
}
}
function clearSelection() {
nav.ctx.selectedSourceIds = [];
}
function goCreate() {
nav.go('create');
}
function editProject(projectId: number) {
nav.ctx.editProjectId = projectId;
nav.go('editproject');
}
async function doAddSource() {
if (!addSourceRaw.value.trim() || !nav.ctx.competitorId) return;
addSourceLoading.value = true;
try {
await store.addSource({ competitor_id: nav.ctx.competitorId, raw: addSourceRaw.value.trim() });
addSourceRaw.value = '';
showAddSource.value = false;
} finally {
addSourceLoading.value = false;
}
}
</script>
<template>
<div class="ld-detail-screen">
<!-- Topbar breadcrumb -->
<div class="ld-topbar">
<div class="ld-crumb">
Автоподбор
<template v-if="store.competitor"> · {{ store.competitor.name }}</template>
</div>
</div>
<div class="ld-detail-content">
<!-- Back link -->
<button class="ld-back" @click="nav.go('list')"> К списку конкурентов</button>
<!-- Competitor header -->
<template v-if="store.competitor">
<div class="ld-chead">
<h1 class="ld-chead__name">
{{ store.competitor.name }}
<span v-if="store.competitor.is_federal" class="ld-badge ld-badge--fed">федеральный</span>
</h1>
<div v-if="store.competitor.relevance_pct !== null" class="ld-relbox">
<div class="ld-relnum rel-100">{{ store.competitor.relevance_pct }}%</div>
<div class="ld-rellbl">похожесть</div>
</div>
</div>
<p v-if="store.competitor.studied_at" class="ld-studied">
Изучено {{ store.competitor.studied_at }} · найдено {{ totalCount }} источников
</p>
</template>
<!-- Explanatory note -->
<div class="ld-note">
Отметьте источники, по которым создать проекты. У каждого ссылка «где нашли».
<b>Подменный (с сайта)</b> номер из коллтрекинга, его набирают клиенты с сайта;
<b>настоящий</b> линия из кода сайта или справочника. Берём оба.
<b>Страница показывает актуальное состояние:</b>
источники, по которым проект уже создан, помечены « проект создан» их можно изменить прямо здесь.
</div>
<!-- Sites section -->
<div v-if="sites.length" class="ld-sect">
<div class="ld-secthd">
🌐 Сайты
<span class="ld-cnt">· {{ sites.length }} найдено · только головы доменов</span>
</div>
<div
v-for="src in sites"
:key="src.id"
class="ld-row"
:class="{ 'ld-row--used': src.existing_project_id != null }"
>
<input
type="checkbox"
class="ld-cb"
:checked="isSelected(src.id)"
:disabled="src.existing_project_id != null"
@change="toggleSource(src.id)"
>
<div class="ld-rinfo">
<div class="ld-rident ld-rident--site">{{ src.identifier }}</div>
<div class="ld-rprov">
Где нашли:
<a v-if="src.provenance_url" :href="src.provenance_url" target="_blank" rel="noopener">
{{ src.provenance_label || src.provenance_url }}
</a>
<span v-else>{{ src.provenance_label }}</span>
</div>
<span v-if="src.existing_project_id != null" class="ld-used"> проект создан</span>
</div>
<button
v-if="src.existing_project_id != null"
class="ld-btn-ghost ld-btn-ghost--sm"
@click="editProject(src.existing_project_id!)"
>
Изменить проект
</button>
</div>
</div>
<!-- Calls section -->
<div v-if="calls.length" class="ld-sect">
<div class="ld-secthd">
📞 Телефоны
<span class="ld-cnt">· {{ calls.length }} найдено</span>
</div>
<div
v-for="src in calls"
:key="src.id"
class="ld-row"
:class="{ 'ld-row--used': src.existing_project_id != null }"
>
<input
type="checkbox"
class="ld-cb"
:checked="isSelected(src.id)"
:disabled="src.existing_project_id != null"
@change="toggleSource(src.id)"
>
<div class="ld-rinfo">
<div class="ld-rident">
{{ src.identifier }}
<span v-if="src.phone_kind === 'real'" class="ld-tag ld-tag--real">настоящий</span>
<span v-if="src.phone_kind === 'substitute'" class="ld-tag ld-tag--sub">подменный · с сайта</span>
</div>
<div class="ld-rprov">
Где нашли:
<a v-if="src.provenance_url" :href="src.provenance_url" target="_blank" rel="noopener">
{{ src.provenance_label || src.provenance_url }}
</a>
<span v-else>{{ src.provenance_label }}</span>
</div>
<span v-if="src.existing_project_id != null" class="ld-used"> проект создан</span>
</div>
<button
v-if="src.existing_project_id != null"
class="ld-btn-ghost ld-btn-ghost--sm"
@click="editProject(src.existing_project_id!)"
>
Изменить проект
</button>
</div>
</div>
<!-- Manual source add -->
<div class="ld-addbox">
<b>Чего-то не хватает?</b>
<p>
Знаете ещё сайт или номер этого конкурента
<span v-if="!showAddSource" class="ld-addlink" @click="showAddSource = true">
добавьте источник вручную
</span>
<span v-else class="ld-addlink" @click="showAddSource = false">скрыть</span>.
</p>
<div v-if="showAddSource" class="ld-addsrc">
<input
v-model="addSourceRaw"
class="ld-inp"
placeholder="okna-komfort.ru · или +7 843 200-00-00"
@keydown.enter="doAddSource"
>
<button
class="ld-btn-primary ld-btn-primary--sm"
:disabled="addSourceLoading || !addSourceRaw.trim()"
@click="doAddSource"
>
Добавить источник
</button>
</div>
</div>
</div>
<!-- Bottom action bar -->
<div class="ld-actionbar">
<div class="ld-selinfo">
Выбрано <b>{{ selectedCount }}</b> из {{ totalCount }} источников
</div>
<div class="ld-actionbar__btns">
<button class="ld-btn-ghost" @click="clearSelection">Снять выбор</button>
<button
class="ld-btn-primary"
:disabled="selectedCount === 0"
@click="goCreate"
>
Создать проекты
</button>
</div>
</div>
</div>
</template>
<style scoped>
.ld-detail-screen {
display: flex;
flex-direction: column;
min-height: 100%;
}
.ld-topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0 14px;
border-bottom: 1px solid #e8e2d4;
margin-bottom: 20px;
}
.ld-crumb {
font-size: 13px;
color: #7a7468;
}
.ld-detail-content {
flex: 1;
padding-bottom: 80px;
}
.ld-back {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 18px;
display: inline-block;
}
.ld-back:hover {
text-decoration: underline;
}
.ld-chead {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 8px;
}
.ld-chead__name {
font-size: 22px;
font-weight: 800;
color: #012019;
margin: 0;
flex: 1;
}
.ld-badge {
font-size: 11px;
border-radius: 4px;
padding: 2px 7px;
margin-left: 6px;
font-weight: 500;
vertical-align: middle;
}
.ld-badge--fed {
background: #edf3fb;
color: #1a4f8a;
border: 1px solid #c5d8ef;
}
.ld-relbox {
text-align: center;
flex-shrink: 0;
}
.ld-relnum {
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.ld-rellbl {
font-size: 11px;
color: #9b9484;
margin-top: 2px;
}
.rel-100 { color: var(--liderra-teal, #0f6e56); }
.rel-hi { color: #2e7d32; }
.rel-mid { color: #b45309; }
.rel-low { color: #9b9484; }
.ld-studied {
font-size: 13px;
color: #7a7468;
margin: 0 0 14px;
}
.ld-note {
background: #f6f3ec;
border: 1px solid #e8e2d4;
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
color: #4a4540;
line-height: 1.55;
margin-bottom: 20px;
}
.ld-sect {
margin-bottom: 20px;
}
.ld-secthd {
font-size: 14px;
font-weight: 700;
color: #012019;
margin-bottom: 10px;
}
.ld-cnt {
font-size: 12.5px;
font-weight: 400;
color: #9b9484;
}
.ld-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e8e2d4;
border-radius: 8px;
margin-bottom: 6px;
background: #fff;
}
.ld-row--used {
background: #fbfaf5;
}
.ld-cb {
margin-top: 3px;
cursor: pointer;
flex-shrink: 0;
}
.ld-cb:disabled {
opacity: 0.5;
cursor: default;
}
.ld-rinfo {
flex: 1;
min-width: 0;
}
.ld-rident {
font-size: 14px;
font-weight: 600;
color: #012019;
margin-bottom: 4px;
}
.ld-rident--site {
color: var(--liderra-teal, #0f6e56);
}
.ld-rprov {
font-size: 12px;
color: #7a7468;
}
.ld-rprov a {
color: var(--liderra-teal, #0f6e56);
text-decoration: none;
}
.ld-rprov a:hover {
text-decoration: underline;
}
.ld-tag {
display: inline-block;
font-size: 11px;
border-radius: 4px;
padding: 1px 6px;
margin-left: 6px;
font-weight: 500;
vertical-align: middle;
}
.ld-tag--real {
background: #e8f3ee;
color: #0c5a46;
border: 1px solid #cfe3da;
}
.ld-tag--sub {
background: #fef9ec;
color: #8a5c10;
border: 1px solid #f0e0b0;
}
.ld-used {
display: inline-block;
font-size: 11.5px;
color: var(--liderra-teal, #0f6e56);
font-weight: 600;
margin-top: 4px;
}
.ld-addbox {
margin-top: 24px;
background: #f6f3ec;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 16px 20px;
font-size: 13.5px;
color: #4a4540;
}
.ld-addbox b {
color: #012019;
}
.ld-addbox p {
margin: 6px 0 0;
line-height: 1.5;
}
.ld-addlink {
color: var(--liderra-teal, #0f6e56);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
.ld-addlink:hover {
text-decoration-style: solid;
}
.ld-addsrc {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.ld-inp {
flex: 1;
min-width: 200px;
border: 1.5px solid #d5cfc2;
border-radius: 7px;
padding: 8px 12px;
font-size: 13.5px;
color: #012019;
background: #fff;
outline: none;
transition: border-color 150ms;
}
.ld-inp:focus {
border-color: var(--liderra-teal, #0f6e56);
}
.ld-actionbar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-top: 1px solid #e8e2d4;
padding: 12px 0;
gap: 12px;
z-index: 10;
}
.ld-selinfo {
font-size: 13.5px;
color: #4a4540;
}
.ld-actionbar__btns {
display: flex;
gap: 10px;
align-items: center;
}
.ld-btn-primary {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-primary:hover:not(:disabled) {
background: #0b5a45;
}
.ld-btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ld-btn-primary--sm {
padding: 8px 14px;
}
.ld-btn-ghost {
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
.ld-btn-ghost--sm {
padding: 7px 12px;
font-size: 12.5px;
white-space: nowrap;
align-self: center;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { inject, computed } from 'vue';
import { useRouter } from 'vue-router';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const router = useRouter();
const message = computed(() => {
const n = nav.ctx.createdCount ?? 0;
const launched = nav.ctx.launched ?? false;
return launched
? `${n} ${projectsWord(n)} создано и запущено`
: `${n} ${projectsWord(n)} создано`;
});
function projectsWord(n: number): string {
if (n === 1) return 'проект';
if (n >= 2 && n <= 4) return 'проекта';
return 'проектов';
}
function goProjects(): void {
void router.push('/projects');
}
</script>
<template>
<div class="ld-done-screen">
<div class="ld-donewrap">
<div class="ld-donecheck"></div>
<p class="ld-donemsg">{{ message }}</p>
<p class="ld-donesub">
Проекты появились в разделе «Проекты». Первые лиды пойдут по правилу слепка.
Конкурент и его источники сохранены вернуться можно в любой момент без повторной оплаты.
</p>
<div class="ld-done-btns">
<button class="ld-btn-ghost" @click="nav.go('entry')"> В начало</button>
<button class="ld-btn-primary" @click="goProjects()">Перейти в «Проекты» </button>
</div>
</div>
</div>
</template>
<style scoped>
.ld-done-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 40px 24px;
}
.ld-donewrap {
text-align: center;
max-width: 520px;
}
.ld-donecheck {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--liderra-teal, #0f6e56);
color: #fff;
font-size: 32px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.ld-donemsg {
font-size: 22px;
font-weight: 800;
color: #012019;
margin: 0 0 12px;
}
.ld-donesub {
font-size: 14px;
color: #4a4540;
line-height: 1.6;
margin: 0 0 24px;
}
.ld-done-btns {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.ld-btn-primary {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-btn-ghost {
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
</style>
@@ -0,0 +1,375 @@
<script setup lang="ts">
import { inject, ref, onMounted } from 'vue';
import axios from 'axios';
import { REGIONS } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
// Регионы (только code > 0)
const regions = REGIONS.filter((r) => r.code > 0);
// Состояние формы
const name = ref('');
const regionCode = ref<number | null>(null);
const dailyLimit = ref<number>(20);
// Маска дней: бит i = 1<<i, дефолт все 7 дней = 127
const deliveryMask = ref<number>(127);
const errorMsg = ref('');
const loading = ref(false);
// Имена дней
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
function isDayOn(i: number): boolean {
return (deliveryMask.value & (1 << i)) !== 0;
}
function toggleDay(i: number): void {
deliveryMask.value ^= 1 << i;
}
onMounted(async () => {
if (nav.ctx.editProjectId) {
loading.value = true;
try {
const { data } = await axios.get(`/api/projects/${nav.ctx.editProjectId}`);
const p = data.data;
name.value = p.name ?? '';
regionCode.value = (p.regions && p.regions.length > 0) ? p.regions[0] : null;
dailyLimit.value = p.daily_limit_target ?? 20;
deliveryMask.value = p.delivery_days_mask ?? 127;
} finally {
loading.value = false;
}
}
});
async function save(): Promise<void> {
if (!name.value.trim()) {
errorMsg.value = 'Укажите название.';
return;
}
errorMsg.value = '';
try {
await axios.patch(`/api/projects/${nav.ctx.editProjectId}`, {
name: name.value.trim(),
regions: regionCode.value != null ? [regionCode.value] : [],
daily_limit_target: dailyLimit.value,
delivery_days_mask: deliveryMask.value,
});
nav.go('detail');
} catch (e) {
errorMsg.value = (e as any)?.response?.data?.errors?.name?.[0] ?? 'Не удалось сохранить. Попробуйте ещё раз.';
}
}
// Для тестируемости
defineExpose({ name, regionCode, dailyLimit, deliveryMask });
</script>
<template>
<div class="ld-editproject-screen">
<!-- Topbar -->
<div class="ld-topbar">
<div class="ld-crumb">
Автоподбор · Изменить проект
</div>
</div>
<div class="ld-editproject-content">
<!-- Back -->
<button class="ld-back" @click="nav.go('detail')"> К источникам конкурента</button>
<h1 class="ld-title">Изменить проект</h1>
<p class="ld-sub">
Правки сохранятся в проекте без перехода в раздел «Проекты».
</p>
<!-- Ошибка -->
<div v-if="errorMsg" class="ld-alert">{{ errorMsg }}</div>
<!-- Карточка формы -->
<div class="ld-card">
<!-- Название -->
<div class="ld-field">
<label class="ld-flabel">Название проекта <span class="ld-req">*</span></label>
<input
v-model="name"
type="text"
class="ld-input"
placeholder="Название проекта"
>
<p class="ld-fhint">
Значок 🎭 в названии помечает проект как подменный (коллтрекинг) в разделе «Проекты»;
настоящий номер.
</p>
</div>
<!-- Регион + Лимит -->
<div class="ld-frow">
<div class="ld-fcol">
<label class="ld-flabel">Регион</label>
<select
v-model="regionCode"
class="ld-select"
>
<option :value="null"> выберите регион </option>
<option
v-for="r in regions"
:key="r.code"
:value="r.code"
>
{{ r.name }}
</option>
</select>
</div>
<div class="ld-fcol">
<label class="ld-flabel">Лимит лидов в день</label>
<input
v-model.number="dailyLimit"
type="number"
min="1"
class="ld-input"
>
</div>
</div>
<!-- Дни приёма -->
<div class="ld-days-wrap">
<p class="ld-flabel">Дни приёма</p>
<div class="ld-days">
<button
v-for="(label, i) in DAY_LABELS"
:key="i"
type="button"
class="ld-day"
:class="{ 'ld-day--on': isDayOn(i) }"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
</div>
</div>
<!-- Кнопки -->
<div class="ld-actions">
<button class="ld-btn-ghost" @click="nav.go('detail')">Отмена</button>
<button class="ld-btn-primary" @click="save">Сохранить</button>
</div>
</div>
</div>
</template>
<style scoped>
.ld-editproject-screen {
display: flex;
flex-direction: column;
min-height: 100%;
}
.ld-topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0 14px;
border-bottom: 1px solid #e8e2d4;
margin-bottom: 20px;
}
.ld-crumb {
font-size: 13px;
color: #7a7468;
}
.ld-editproject-content {
flex: 1;
padding-bottom: 40px;
}
.ld-back {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 18px;
display: inline-block;
}
.ld-back:hover {
text-decoration: underline;
}
.ld-title {
font-size: 22px;
font-weight: 800;
color: #012019;
margin: 0 0 6px;
}
.ld-sub {
font-size: 13.5px;
color: #7a7468;
margin: 0 0 20px;
}
.ld-alert {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 10px 14px;
font-size: 13.5px;
color: #856404;
margin-bottom: 16px;
}
.ld-card {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
}
.ld-field {
margin-bottom: 14px;
}
.ld-frow {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-top: 12px;
}
.ld-fcol {
flex: 1;
min-width: 180px;
}
.ld-flabel {
font-size: 13px;
font-weight: 600;
color: #4a4540;
margin: 0 0 6px;
display: block;
}
.ld-req {
color: #c0392b;
}
.ld-input {
width: 100%;
border: 1.5px solid #d5cfc2;
border-radius: 7px;
padding: 8px 12px;
font-size: 13.5px;
color: #012019;
background: #fff;
outline: none;
transition: border-color 150ms;
box-sizing: border-box;
}
.ld-input:focus {
border-color: var(--liderra-teal, #0f6e56);
}
.ld-fhint {
font-size: 12px;
color: #9b9484;
margin: 6px 0 0;
line-height: 1.5;
}
.ld-select {
width: 100%;
border: 1.5px solid #d5cfc2;
border-radius: 7px;
padding: 8px 12px;
font-size: 13.5px;
color: #012019;
background: #fff;
outline: none;
cursor: pointer;
transition: border-color 150ms;
}
.ld-select:focus {
border-color: var(--liderra-teal, #0f6e56);
}
.ld-days-wrap {
margin-top: 14px;
}
.ld-days {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.ld-day {
border: 1.5px solid #d5cfc2;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: #fff;
color: #7a7468;
transition: background 150ms, color 150ms, border-color 150ms;
}
.ld-day--on {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border-color: var(--liderra-teal, #0f6e56);
}
.ld-actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 4px;
}
.ld-btn-primary {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-primary:hover:not(:disabled) {
background: #0b5a45;
}
.ld-btn-ghost {
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
</style>
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
function openRun(run: { id: number; kind: string }) {
nav.ctx.runId = run.id;
if (run.kind === 'study') {
nav.go('detail');
} else {
nav.go('list');
}
}
</script>
<template>
<div class="ld-entry-screen">
<h1 class="ld-entry-screen__title">Автоподбор конкурентов</h1>
<!-- Продолжить начатое -->
<div v-if="store.runs.length > 0" class="ld-entry-card" style="margin-bottom: 22px">
<p class="ld-entry-card__title">Продолжить начатое</p>
<div
v-for="run in store.runs"
:key="run.id"
class="ld-entry-run-row"
>
<div class="ld-entry-run-row__info">
<div class="ld-entry-run-row__name">
Запуск #{{ run.id }}
<span class="ld-entry-run-row__kind">{{ run.kind === 'study' ? 'свой конкурент' : 'подбор' }}</span>
</div>
<div class="ld-entry-run-row__meta">
Статус: {{ run.status }} · конкурентов {{ run.competitors_count }} · источников {{ run.sources_count }}
</div>
</div>
<button class="ld-btn-ghost" @click="openRun(run)">Открыть </button>
</div>
</div>
<p v-if="store.runs.length > 0" class="ld-entry-screen__sub">или начните новое:</p>
<!-- Двери -->
<div class="ld-entry-choice">
<!-- Дверь 1: Подобрать конкурентов -->
<div class="ld-entry-opt">
<div class="ld-entry-opt__ico">🔍</div>
<p class="ld-entry-opt__title">Подобрать конкурентов</p>
<p class="ld-entry-opt__desc">
Не знаете всех конкурентов? Дайте несколько примеров и регион Лидерра найдёт похожих и соберёт их источники.
</p>
<p class="ld-entry-opt__steps">
<strong>Шаг 1.</strong> Список похожих конкурентов с оценкой совпадения.<br />
<strong>Шаг 2.</strong> По выбранным все их источники для проектов.
</p>
<button class="ld-btn-primary" @click="nav.go('autoform')">Подобрать конкурентов </button>
<p class="ld-entry-opt__paynote">Услуга платная</p>
</div>
<!-- Дверь 2: Указать своего конкурента -->
<div class="ld-entry-opt">
<div class="ld-entry-opt__ico">🎯</div>
<p class="ld-entry-opt__title">Указать своего конкурента</p>
<p class="ld-entry-opt__desc">
Уже знаете конкретного конкурента? Укажите его сайт, справочник или название соберём его источники без подбора.
</p>
<p class="ld-entry-opt__steps">
<strong>Сразу шаг 2.</strong> Все источники указанного конкурента без этапа поиска похожих.
</p>
<button class="ld-btn-ghost" @click="nav.go('manualform')">Указать конкурента </button>
<p class="ld-entry-opt__paynote">Услуга платная</p>
</div>
</div>
</div>
</template>
<style scoped>
.ld-entry-screen {
padding: 28px 0;
}
.ld-entry-screen__title {
font-size: 24px;
font-weight: 700;
color: #012019;
margin: 0 0 24px;
}
.ld-entry-screen__sub {
font-size: 14px;
color: #7a7468;
margin: 0 0 16px;
}
.ld-entry-card {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 18px 20px 6px;
}
.ld-entry-card__title {
font-size: 13px;
font-weight: 600;
color: #012019;
margin: 0 0 10px;
}
.ld-entry-run-row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 0;
border-top: 1px solid #f0ece1;
}
.ld-entry-run-row__info {
flex: 1;
}
.ld-entry-run-row__name {
font-weight: 600;
font-size: 13.5px;
color: #012019;
}
.ld-entry-run-row__kind {
font-size: 11px;
background: var(--liderra-ivory, #f6f3ec);
color: #7a7468;
border-radius: 4px;
padding: 1px 6px;
margin-left: 6px;
font-weight: 500;
}
.ld-entry-run-row__meta {
font-size: 12.5px;
color: #7a7468;
margin-top: 2px;
}
.ld-entry-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
@media (max-width: 680px) {
.ld-entry-choice {
grid-template-columns: 1fr;
}
}
.ld-entry-opt {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 22px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ld-entry-opt__ico {
font-size: 28px;
line-height: 1;
}
.ld-entry-opt__title {
font-size: 15px;
font-weight: 700;
color: #012019;
margin: 0;
}
.ld-entry-opt__desc {
font-size: 13.5px;
color: #4a4540;
margin: 0;
line-height: 1.5;
}
.ld-entry-opt__steps {
font-size: 12.5px;
color: #7a7468;
margin: 0;
line-height: 1.6;
}
.ld-entry-opt__paynote {
font-size: 11.5px;
color: #9b9484;
margin: 0;
}
.ld-btn-primary {
display: inline-flex;
align-items: center;
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
align-self: flex-start;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-btn-ghost {
display: inline-flex;
align-items: center;
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
align-self: flex-start;
white-space: nowrap;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
</style>
@@ -0,0 +1,655 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, type FieldSourceDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
interface AutopodborNav {
go: (s: string) => void;
ctx: { competitorId: number | null; editProjectId: number | null; selectedSourceIds: number[] };
screen: { value: string };
}
const nav = inject('autopodborNav') as AutopodborNav;
const store = useAutopodborStore();
const ctab = ref<'work' | 'sugg'>('work');
const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const DAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
const comp = computed(() => store.competitor);
const inWork = computed(() => store.sources.filter((s) => s.box === 'field'));
const props = computed(() => store.sources.filter((s) => s.box === 'proposal'));
const shown = computed(() => (ctab.value === 'work' ? inWork.value : props.value));
const allSelected = computed(() => shown.value.length > 0 && selected.value.length === shown.value.length);
function flash(m: string) {
toast.value = m;
setTimeout(() => (toast.value = ''), 2600);
}
// ——— даты/слепок (как в проектах) ———
function fmtD(d: Date) {
return d.getDate() + ' ' + MONTHS[d.getMonth()];
}
function plusDays(n: number) {
const d = new Date();
d.setDate(d.getDate() + n);
return d;
}
function afterCutoff() {
return new Date().getHours() >= 18;
}
function appliesFromText() {
return fmtD(plusDays(afterCutoff() ? 2 : 1));
}
function leadStartText() {
return appliesFromText();
}
function graceText() {
return fmtD(plusDays(2));
}
function subtitle() {
const c = comp.value;
if (!c) return '';
const parts = [c.description || '—'];
if (c.relevance_pct !== null) parts.push(`похожесть ${c.relevance_pct}%`);
return parts.join(' · ');
}
function phoneTypeLabel(s: FieldSourceDto) {
if (s.signal_type !== 'call') return '';
return { city: 'городской', mobile: 'мобильный', tollfree: '8-800' }[s.phone_type ?? ''] ?? '';
}
function phoneBadge(s: FieldSourceDto) {
if (s.signal_type !== 'call') return '';
return s.phone_kind === 'substitute' ? '🎭' : '✓';
}
function sourceValue(s: FieldSourceDto) {
return s.project?.signal_identifier ?? s.identifier;
}
function projStatus(s: FieldSourceDto): { label: string; cls: string } {
if (!s.project) return { label: 'проект не создан', cls: 'ld-b-none' };
if (s.project.preflight_blocked_at) return { label: '⛔ не хватает баланса', cls: 'ld-b-stop' };
if (!s.project.is_active) return { label: '⏸ на паузе', cls: 'ld-b-stop' };
return { label: '▶ активен', cls: 'ld-b-run' };
}
function projLine(s: FieldSourceDto) {
if (!s.project) return '';
return `${s.project.daily_limit_target}/день · ${s.project.delivered_in_month} заявок`;
}
function switchTab(t: 'work' | 'sugg') {
ctab.value = t;
selected.value = [];
}
function toggle(id: number) {
const i = selected.value.indexOf(id);
if (i === -1) selected.value.push(id);
else selected.value.splice(i, 1);
}
function toggleAll() {
selected.value = allSelected.value ? [] : shown.value.map((s) => s.id);
}
function clearSel() {
selected.value = [];
}
async function reload() {
if (nav.ctx.competitorId === null) return;
busy.value = true;
try {
await store.loadCompetitor(nav.ctx.competitorId);
} finally {
busy.value = false;
}
}
async function toggleProject(s: FieldSourceDto, active: boolean) {
if (!s.project || busy.value) return;
busy.value = true;
try {
await store.toggleProjectActive(s.project.id, active);
await reload();
flash(active ? 'Проект возобновлён ▶' : 'Проект приостановлен ⏸ — уже заказанные лиды дойдут');
} catch {
flash('Не удалось изменить проект — ограничения по слепку или балансу.');
} finally {
busy.value = false;
}
}
async function moveToWork(s: FieldSourceDto) {
if (nav.ctx.competitorId === null) return;
busy.value = true;
try {
await store.moveSourceToBox(nav.ctx.competitorId, s.id, 'field');
await reload();
flash('Источник добавлен в работу ✓');
} finally {
busy.value = false;
}
}
// ——— массовые действия (работа) ———
const selWork = computed(() => inWork.value.filter((s) => selected.value.includes(s.id)));
const selAllHaveProj = computed(() => selWork.value.length > 0 && selWork.value.every((s) => s.project));
const selNoneHaveProj = computed(() => selWork.value.length > 0 && selWork.value.every((s) => !s.project));
async function bulkWork(action: 'pause' | 'resume' | 'delete') {
if (busy.value || nav.ctx.competitorId === null) return;
busy.value = true;
try {
if (action === 'delete') {
for (const s of selWork.value) {
if (s.project && s.project.is_active) continue;
await store.removeSource(nav.ctx.competitorId, s.id);
}
flash('Удалено (активные проекты пропущены)');
} else {
const want = action === 'resume';
for (const s of selWork.value) {
if (s.project && s.project.is_active !== want) await store.toggleProjectActive(s.project.id, want);
}
flash(want ? 'Возобновлено' : 'Приостановлено — уже заказанные лиды дойдут');
}
selected.value = [];
await reload();
} finally {
busy.value = false;
}
}
// ——— сбор источников (50 ₽) ———
const collect = reactive({ open: false, running: false });
function openCollect() {
collect.open = true;
collect.running = false;
}
async function runCollect() {
if (nav.ctx.competitorId === null) return;
collect.running = true;
try {
const run = await store.study(nav.ctx.competitorId);
await store.pollRun(run.id);
await reload();
collect.open = false;
ctab.value = 'sugg';
flash('Готово: найдены источники — выберите нужные');
} catch (e) {
collect.running = false;
flash(autopodborErrorMessage(e, 'Не удалось собрать источники. Попробуйте ещё раз.'));
}
}
// ——— добавить источник вручную ———
const addSrc = reactive({ open: false, type: 'site', site: '', phone: '', prov: '', note: '' });
function openAdd() {
Object.assign(addSrc, { open: true, type: 'site', site: '', phone: '', prov: '', note: '' });
}
async function saveAdd() {
if (nav.ctx.competitorId === null) return;
const raw = addSrc.type === 'site' ? addSrc.site.trim() : addSrc.phone.trim();
if (!raw) {
addSrc.note = addSrc.type === 'site' ? 'Укажите адрес сайта' : 'Укажите телефон';
return;
}
busy.value = true;
addSrc.note = '';
try {
await store.addSource({ competitor_id: nav.ctx.competitorId, raw });
addSrc.open = false;
await reload();
flash('Источник добавлен ✓');
} catch {
addSrc.note = 'Не удалось добавить. Сначала соберите источники по конкуренту.';
} finally {
busy.value = false;
}
}
// ——— создать проект из источника ———
const create = reactive({ open: false, srcId: null as number | null, srcLabel: '', regionCode: regions[0]?.code ?? 24, limit: 5, mask: 127 });
function openCreate(s: FieldSourceDto) {
Object.assign(create, { open: true, srcId: s.id, srcLabel: sourceValue(s), regionCode: regions[0]?.code ?? 24, limit: 5, mask: 127 });
}
function isDayOn(mask: number, i: number) {
return (mask & (1 << i)) !== 0;
}
function toggleCreateDay(i: number) {
create.mask ^= 1 << i;
}
function setCreateDays(all: boolean) {
create.mask = all ? 127 : 31;
}
async function doCreate() {
if (create.srcId === null) return;
busy.value = true;
try {
await store.makeProjects({
source_ids: [create.srcId],
regions: [create.regionCode],
daily_limit_target: create.limit,
delivery_days_mask: create.mask,
launch: true,
});
create.open = false;
await reload();
flash('Проект создан, идёт сбор ▶');
} catch {
flash('Не удалось создать проект. Проверьте баланс.');
} finally {
busy.value = false;
}
}
// ——— настройки проекта ———
const psettings = reactive({ open: false, projectId: null as number | null, label: '', regionCode: 24, limit: 5, mask: 127 });
function openSettings(s: FieldSourceDto) {
if (!s.project) return;
Object.assign(psettings, {
open: true, projectId: s.project.id, label: sourceValue(s),
regionCode: s.project.regions?.[0] ?? regions[0]?.code ?? 24,
limit: s.project.daily_limit_target, mask: s.project.delivery_days_mask || 127,
});
}
function toggleSetDay(i: number) {
psettings.mask ^= 1 << i;
}
function setSetDays(all: boolean) {
psettings.mask = all ? 127 : 31;
}
async function saveSettings() {
if (psettings.projectId === null) return;
busy.value = true;
try {
await store.updateProjectSettings(psettings.projectId, {
daily_limit_target: psettings.limit,
regions: [psettings.regionCode],
delivery_days_mask: psettings.mask,
});
psettings.open = false;
await reload();
flash(`Сохранено. Изменения вступят в силу ${appliesFromText()} в 21:00 МСК`);
} catch {
flash('Не удалось сохранить настройки проекта.');
} finally {
busy.value = false;
}
}
// ——— удаление источника (гвард слепка) ———
const del = reactive({ open: false, srcId: null as number | null, label: '', mode: 'ask' as 'ask' | 'blocked' });
function openDelete(s: FieldSourceDto) {
const blocked = !!(s.project && s.project.is_active && !s.project.preflight_blocked_at);
Object.assign(del, { open: true, srcId: s.id, label: sourceValue(s), mode: blocked ? 'blocked' : 'ask' });
}
async function pauseFromDelete() {
const src = inWork.value.find((x) => x.id === del.srcId);
if (src?.project) await toggleProject(src, false);
del.open = false;
}
async function confirmDelete() {
if (del.srcId === null || nav.ctx.competitorId === null) return;
busy.value = true;
try {
await store.removeSource(nav.ctx.competitorId, del.srcId);
del.open = false;
flash('Источник удалён');
} catch {
del.open = false;
flash('Источник нельзя удалить — по нему идёт активный проект.');
await reload();
} finally {
busy.value = false;
}
}
// ——— изменить источник / сменить источник ———
const edit = reactive({
open: false, srcId: null as number | null, projectId: null as number | null, inSbor: false,
signalType: '', identifier: '', orig: '', prov: '', awaitingConfirm: false, note: '', message: '',
});
function openEdit(s: FieldSourceDto) {
Object.assign(edit, {
open: true, srcId: s.id, projectId: s.project ? s.project.id : null,
inSbor: !!s.project, signalType: s.signal_type, identifier: sourceValue(s), orig: sourceValue(s),
prov: s.provenance_label ?? '', awaitingConfirm: false, note: '', message: '',
});
}
async function saveEdit() {
if (edit.srcId === null || nav.ctx.competitorId === null) return;
edit.note = '';
if (edit.projectId !== null) {
if (edit.identifier.trim() === edit.orig) {
edit.open = false;
return;
}
if (!edit.awaitingConfirm) {
edit.awaitingConfirm = true;
edit.note = 'Мы уже ведём сбор на завтра. Лиды по старому источнику придут до ' + graceText() + ', дальше — по новому. Подтвердите смену источника.';
return;
}
busy.value = true;
try {
const res = await store.changeProjectSource(edit.projectId, edit.identifier.trim());
edit.message = res.source_change_message || `Источник сменён. Изменения вступят в силу ${appliesFromText()} в 21:00 МСК.`;
edit.awaitingConfirm = false;
await reload();
} catch {
edit.note = 'Не удалось сменить источник — ограничения по слепку.';
} finally {
busy.value = false;
}
return;
}
busy.value = true;
try {
await store.editSource(nav.ctx.competitorId, edit.srcId, { identifier: edit.identifier, provenance_label: edit.prov });
edit.open = false;
await reload();
flash('Источник сохранён ✓');
} catch {
edit.note = 'Не удалось сохранить источник.';
} finally {
busy.value = false;
}
}
onMounted(() => {
void reload();
});
defineExpose({ ctab, selected, inWork, props: props, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, toggleProject, bulkWork, sourceValue });
</script>
<template>
<div class="ld-fc">
<button class="ld-back" @click="nav.go('field')"> Назад в поле</button>
<h1 v-if="comp" class="ld-h1">
{{ comp.name }}
<span class="ld-bdg" :class="comp.is_federal ? 'ld-bdg--fed' : 'ld-bdg--loc'" style="vertical-align: 5px; font-size: 12px">{{ comp.is_federal ? 'федеральный' : 'региональный' }}</span>
</h1>
<p class="ld-sub">{{ subtitle() }}</p>
<div class="ld-acts">
<button v-if="!comp?.studied_at" class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать источники для меня</button>
<button v-else class="ld-btn primary" disabled title="Источники по этому конкуренту уже собраны — повторный сбор не нужен"> Источники собраны</button>
<button class="ld-btn ghost" :disabled="busy" @click="openAdd">+ Добавить источник вручную</button>
</div>
<div v-if="props.length && ctab === 'work'" class="ld-banner ld-banner--amber">
<div class="ld-banner__txt">💡 <b>Лидерра нашла {{ props.length }} источника</b> по этому конкуренту посмотрите и выберите нужные.</div>
<a class="ld-banner__link" @click="switchTab('sugg')">Разобрать предложения </a>
</div>
<div class="ld-tabs">
<button class="ld-tab" :class="{ 'ld-tab--on': ctab === 'work' }" @click="switchTab('work')">Источники в работе <span class="ld-tab__c">{{ inWork.length }}</span></button>
<button class="ld-tab" :class="{ 'ld-tab--on': ctab === 'sugg' }" @click="switchTab('sugg')">Предложения <span class="ld-tab__c">{{ props.length }}</span></button>
</div>
<!-- ===== РАБОТА ===== -->
<template v-if="ctab === 'work'">
<div v-if="inWork.length === 0" class="ld-note">Источников в работе пока нет добавьте вручную или попросите собрать.</div>
<template v-else>
<div class="ld-banner">
<div class="ld-banner__txt"> Настройки проектов (лимит, регионы, дни) вступают в силу со следующего дня: внесённые до 18:00 МСК завтра, после 18:00 послезавтра. Пауза действует сразу.</div>
</div>
<div class="ld-selrow">
<label class="ld-selall"><input type="checkbox" :checked="allSelected" @change="toggleAll" /> Выбрать все источники</label>
<span class="ld-selcnt ld-selcnt--mut">Отметьте 2 и более появятся массовые действия</span>
</div>
<article v-for="s in inWork" :key="s.id" class="ld-srccard" :class="{ 'ld-srccard--picked': selected.includes(s.id) }">
<input type="checkbox" class="ld-pick" :checked="selected.includes(s.id)" @change="toggle(s.id)" />
<div class="ld-srcicon">{{ s.signal_type === 'call' ? '📞' : '🌐' }}</div>
<div class="ld-srcmain">
<div class="ld-srctitle">
<span v-if="phoneBadge(s)">{{ phoneBadge(s) }}</span>
{{ sourceValue(s) }}
<span v-if="phoneTypeLabel(s)" class="ld-bdg" :class="s.phone_type === 'mobile' ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ phoneTypeLabel(s) }}</span>
</div>
<div class="ld-srcprov">откуда: {{ s.provenance_label || '—' }}</div>
<div class="ld-srcproj">проект: <span class="ld-bdg" :class="projStatus(s).cls">{{ projStatus(s).label }}</span><span v-if="projLine(s)"> {{ projLine(s) }}</span></div>
<div style="margin-top: 6px">
<span class="ld-link" @click="openEdit(s)"> Изменить источник</span>
<span class="ld-link ld-link--del" @click="openDelete(s)"> Удалить</span>
</div>
</div>
<div class="ld-srcctl">
<template v-if="s.project && s.project.is_active && !s.project.preflight_blocked_at">
<button class="ld-btn warn sm" :disabled="busy" @click="toggleProject(s, false)">Приостановить</button>
<button class="ld-btn ghost sm" :disabled="busy" @click="openSettings(s)">Настройки проекта</button>
</template>
<template v-else-if="s.project">
<button class="ld-btn primary sm" :disabled="busy" @click="toggleProject(s, true)">Возобновить</button>
<button class="ld-btn ghost sm" :disabled="busy" @click="openSettings(s)">Настройки проекта</button>
</template>
<template v-else>
<button class="ld-btn primary sm" :disabled="busy" @click="openCreate(s)">Создать проект</button>
</template>
</div>
</article>
<Transition name="ld-bar">
<div v-if="selected.length >= 2" class="ld-bulkbar">
<template v-if="selNoneHaveProj">
<span>Выбрано источников без проекта: <b>{{ selected.length }}</b> создадим проекты разом</span>
<div class="ld-bulkbar__acts">
<button class="ld-btn gray sm" @click="clearSel">Снять выбор</button>
<button class="ld-btn primary sm" :disabled="busy" @click="openCreate(selWork[0])">Создать проекты для выбранных </button>
</div>
</template>
<template v-else-if="selAllHaveProj">
<span>Выбрано источников с проектами: <b>{{ selected.length }}</b> действие применится ко всем</span>
<div class="ld-bulkbar__acts">
<button class="ld-btn gray sm" @click="clearSel">Снять выбор</button>
<button class="ld-btn warn sm" :disabled="busy" @click="bulkWork('pause')"> Приостановить</button>
<button class="ld-btn primary sm" :disabled="busy" @click="bulkWork('resume')"> Возобновить</button>
<button class="ld-btn danger sm" :disabled="busy" @click="bulkWork('delete')"> Удалить</button>
</div>
</template>
<template v-else>
<span>В выборе есть источники и с проектами, и без действия разные. Выберите что-то одно.</span>
<div class="ld-bulkbar__acts"><button class="ld-btn gray sm" @click="clearSel">Снять выбор</button></div>
</template>
</div>
</Transition>
</template>
</template>
<!-- ===== ПРЕДЛОЖЕНИЯ ===== -->
<template v-else>
<div v-if="props.length === 0" class="ld-note">Предложений по источникам пока нет. Нажмите «Собрать источники для меня».</div>
<template v-else>
<div class="ld-selrow">
<label class="ld-selall"><input type="checkbox" :checked="allSelected" @change="toggleAll" /> Выбрать все источники</label>
<span class="ld-selcnt ld-selcnt--mut">Отметьте 2 и более появится перенос в работу</span>
</div>
<article v-for="s in props" :key="s.id" class="ld-srccard ld-srccard--sug">
<input type="checkbox" class="ld-pick" :checked="selected.includes(s.id)" @change="toggle(s.id)" />
<div class="ld-srcicon">{{ s.signal_type === 'call' ? '📞' : '🌐' }}</div>
<div class="ld-srcmain">
<div class="ld-srctitle">
<span v-if="phoneBadge(s)">{{ phoneBadge(s) }}</span>
{{ s.identifier }}
<span v-if="phoneTypeLabel(s)" class="ld-bdg" :class="s.phone_type === 'mobile' ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ phoneTypeLabel(s) }}</span>
<span class="ld-bdg ld-bdg--sug">предложение</span>
</div>
<div class="ld-srcprov">источник сведений: {{ s.provenance_label || '—' }}</div>
</div>
<div class="ld-srcctl">
<span class="ld-link" @click="openEdit(s)"> Изменить</span>
<span class="ld-link ld-link--del" @click="openDelete(s)"> Удалить</span>
<button class="ld-btn primary sm" :disabled="busy" @click="moveToWork(s)">В источники </button>
</div>
</article>
</template>
</template>
<!-- ===== Окно: Собрать источники ===== -->
<div v-if="collect.open" class="ld-ovl" @click.self="collect.open = false">
<div class="ld-modal">
<template v-if="!collect.running">
<h3 class="ld-modal__h">Собрать источники для «{{ comp?.name }}»</h3>
<p class="ld-modal__m">Лидерра найдёт сайты и телефоны конкурента. Они лягут в предложения вы выберете нужные.</p>
<div class="ld-rules">
<h4>Чтобы нашлось как можно больше</h4>
<ul>
<li>У конкурента должен быть указан сайт <b>или</b> ссылка на 2ГИС/Яндекс. Чем точнее тем больше источников найдём.</li>
<li>Если данных мало сначала добавьте сайт/справочник в карточке конкурента («Изменить карточку»).</li>
</ul>
</div>
<div class="ld-fld">
<label>Что известно о конкуренте сейчас</label>
<div class="ld-hint">Сайт: {{ comp?.site_url || '—' }} · 2ГИС: {{ (comp?.directory_urls ?? []).some((u) => u.includes('2gis')) ? 'есть' : 'нет' }} · Яндекс: {{ (comp?.directory_urls ?? []).some((u) => u.includes('yandex')) ? 'есть' : 'нет' }}</div>
</div>
<div class="ld-price">💳 Сбор источников <b>{{ store.prices.study }} </b> за изучение конкурента. Деньги спишутся <b>только если найдём источники</b>; пусто бесплатно.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="collect.open = false">Отмена</button>
<button class="ld-btn primary sm" @click="runCollect">Запустить изучение (платно)</button>
</div>
</template>
<template v-else>
<h3 class="ld-modal__h">Идёт изучение <span class="ld-spin"></span></h3>
<p class="ld-modal__m">Можно закрыть вкладку сохраним результат. Деньги спишутся только при успехе.</p>
</template>
</div>
</div>
<!-- ===== Окно: Добавить источник ===== -->
<div v-if="addSrc.open" class="ld-ovl" @click.self="addSrc.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Добавить источник вручную</h3>
<p class="ld-modal__m">Сайт или телефон конкурента, по которому Лидерра будет собирать вам заявки.</p>
<div class="ld-rules">
<h4>Как заполнить</h4>
<ul>
<li>Выберите тип: сайт или телефон.</li>
<li>Сайт без http, просто адрес. Телефон в формате +7 (___) ___-__-__.</li>
<li>Отметьте, где вы нашли источник чтобы потом помнить, откуда он.</li>
</ul>
</div>
<div class="ld-fld"><label>Тип источника <span class="ld-req">*</span></label>
<select v-model="addSrc.type" class="ld-in"><option value="site">Сайт</option><option value="call">Телефон</option></select></div>
<div v-if="addSrc.type === 'site'" class="ld-fld"><label>Адрес сайта <span class="ld-req">*</span></label><input v-model="addSrc.site" class="ld-in" placeholder="primer.ru" /><div class="ld-hint">Без http:// просто адрес.</div></div>
<div v-else class="ld-fld"><label>Телефон <span class="ld-req">*</span></label><input v-model="addSrc.phone" class="ld-in" placeholder="+7 (___) ___-__-__" /><div class="ld-hint">Формат: +7 (___) ___-__-__</div></div>
<div class="ld-fld"><label>Источник сведений где нашли</label><input v-model="addSrc.prov" class="ld-in" placeholder="Например: сайт конкурента, карточка 2ГИС, визитка" /></div>
<p v-if="addSrc.note" class="ld-err">{{ addSrc.note }}</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="addSrc.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveAdd">Добавить</button>
</div>
</div>
</div>
<!-- ===== Окно: Создать проект ===== -->
<div v-if="create.open" class="ld-ovl" @click.self="create.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Создать проект из источника</h3>
<p class="ld-modal__m">{{ create.srcLabel }}. 📣 Лидерра поставит проект в сбор сразу. Первые лиды пойдут с {{ leadStartText() }}.</p>
<div class="ld-rules">
<h4>Как заполнить</h4>
<ul>
<li>Регион где собирать заявки.</li>
<li>Лимит сколько заявок в день готовы принимать и оплачивать. Можно менять в любой момент.</li>
<li>Дни недели приёма в какие дни принимать заявки.</li>
</ul>
</div>
<div class="ld-fld"><label>Регион <span class="ld-req">*</span></label><select v-model="create.regionCode" class="ld-in"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Лимит заявок в день <span class="ld-req">*</span></label><input v-model.number="create.limit" type="number" min="1" class="ld-in" /><div class="ld-hint">Если лимит превысит баланс проект приостановится, пока не пополните счёт.</div></div>
<div class="ld-fld"><label>Дни недели приёма</label>
<div class="ld-days"><button v-for="(d, i) in DAYS" :key="i" class="ld-day" :class="{ 'ld-day--on': isDayOn(create.mask, i) }" @click="toggleCreateDay(i)">{{ d }}</button></div>
<div style="margin-top: 7px"><span class="ld-link" @click="setCreateDays(false)">Будни</span> &nbsp;·&nbsp; <span class="ld-link" @click="setCreateDays(true)">Все дни</span></div></div>
<div class="ld-price ld-price--go">📣 Создание проекта бесплатно. Деньги только за полученные заявки.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="create.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="doCreate">Создать и запустить</button>
</div>
</div>
</div>
<!-- ===== Окно: Настройки проекта ===== -->
<div v-if="psettings.open" class="ld-ovl" @click.self="psettings.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Настройки проекта</h3>
<p class="ld-modal__m">{{ psettings.label }}. Меняйте объём, регионы и дни приёма.</p>
<div class="ld-banner" style="margin: 0 0 14px"><div class="ld-banner__txt"> Ближайший сбор уже идёт по текущим настройкам. Новые количество, регионы и дни вступят в силу с <b>{{ appliesFromText() }}</b> в 21:00 МСК. Пауза действует сразу.</div></div>
<div class="ld-fld"><label>Регион <span class="ld-req">*</span></label><select v-model="psettings.regionCode" class="ld-in"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Лимит заявок в день <span class="ld-req">*</span></label><input v-model.number="psettings.limit" type="number" min="1" class="ld-in" /><div class="ld-hint">Если лимит превысит баланс проект приостановится, пока не пополните счёт.</div></div>
<div class="ld-fld"><label>Дни недели приёма</label>
<div class="ld-days"><button v-for="(d, i) in DAYS" :key="i" class="ld-day" :class="{ 'ld-day--on': isDayOn(psettings.mask, i) }" @click="toggleSetDay(i)">{{ d }}</button></div>
<div style="margin-top: 7px"><span class="ld-link" @click="setSetDays(false)">Будни</span> &nbsp;·&nbsp; <span class="ld-link" @click="setSetDays(true)">Все дни</span></div></div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="psettings.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveSettings">Сохранить</button>
</div>
</div>
</div>
<!-- ===== Окно: Удалить источник ===== -->
<div v-if="del.open" class="ld-ovl" @click.self="del.open = false">
<div class="ld-modal">
<template v-if="del.mode === 'blocked'">
<h3 class="ld-modal__h">Сейчас удалить нельзя</h3>
<p class="ld-modal__m">Мы уже начали сбор лидов по этому источнику заказанные у поставщика лиды будут приходить примерно до <b>{{ graceText() }}</b>. Чтобы остановить раньше, поставьте проект на паузу: новые сборы прекратятся, а уже заказанные лиды дойдут. Удалить можно будет после <b>{{ graceText() }}</b>.</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="del.open = false">Понятно</button>
<button class="ld-btn warn sm" :disabled="busy" @click="pauseFromDelete()">Приостановить проект</button>
</div>
</template>
<template v-else>
<h3 class="ld-modal__h">Удалить источник?</h3>
<p class="ld-modal__m">«{{ del.label }}» будет удалён из работы. Если по нему есть сделки удаление будет заблокировано.</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="del.open = false">Отмена</button>
<button class="ld-btn danger sm" :disabled="busy" @click="confirmDelete">Удалить</button>
</div>
</template>
</div>
</div>
<!-- ===== Окно: Изменить / Сменить источник ===== -->
<div v-if="edit.open" class="ld-ovl" @click.self="edit.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">{{ edit.projectId ? 'Сменить источник?' : 'Изменить источник' }}</h3>
<p class="ld-modal__m">Сайт или телефон конкурента, по которому Лидерра собирает вам заявки.</p>
<template v-if="!edit.message">
<div class="ld-fld"><label>Тип источника</label>
<div class="ld-lockfld">{{ edit.signalType === 'call' ? '📞 Телефон' : '🌐 Сайт' }} <span class="ld-lockbadge">🔒 тип не меняется</span></div>
<div class="ld-hint">Тип задаётся при создании и не меняется. Можно поправить {{ edit.signalType === 'call' ? 'номер' : 'адрес' }} если нужен другой тип, добавьте отдельный источник.</div></div>
<div class="ld-fld"><label>{{ edit.signalType === 'call' ? 'Телефон' : 'Сайт, с которого нужны заявки' }} <span class="ld-req">*</span></label>
<input v-model="edit.identifier" class="ld-in" :placeholder="edit.signalType === 'call' ? '+7 (___) ___-__-__' : 'primer.ru'" /></div>
<div v-if="!edit.projectId" class="ld-fld"><label>Источник сведений — где нашли</label><input v-model="edit.prov" class="ld-in" placeholder="Например: сайт конкурента, карточка 2ГИС" /></div>
<p v-if="edit.note" class="ld-modal__warn">{{ edit.note }}</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="edit.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveEdit">{{ edit.projectId ? (edit.awaitingConfirm ? 'Сменить источник' : 'Сохранить') : 'Сохранить' }}</button>
</div>
</template>
<template v-else>
<p class="ld-modal__ok">{{ edit.message }}</p>
<div class="ld-modal__foot"><button class="ld-btn primary sm" @click="edit.open = false">Понятно</button></div>
</template>
</div>
</div>
<Transition name="ld-toast">
<div v-if="toast" class="ld-toast">{{ toast }}</div>
</Transition>
</div>
</template>
<style scoped>
@import './field-shared.css';
.ld-fc {
padding: 22px 0 96px;
max-width: 1080px;
}
</style>
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { CompetitorDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
interface AutopodborNav {
go: (s: string) => void;
ctx: { competitorId: number | null };
screen: { value: string };
}
const nav = inject('autopodborNav') as AutopodborNav;
const store = useAutopodborStore();
const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const prices = computed(() => store.prices);
const list = computed<CompetitorDto[]>(() =>
[...store.proposals].sort((a, b) => (b.relevance_pct ?? -1) - (a.relevance_pct ?? -1)),
);
const allSelected = computed(() => list.value.length > 0 && selected.value.length === list.value.length);
function flash(m: string) {
toast.value = m;
setTimeout(() => (toast.value = ''), 2400);
}
function dirLabel(c: CompetitorDto): string {
const has2gis = (c.directory_urls ?? []).some((u) => u.includes('2gis'));
const hasYa = (c.directory_urls ?? []).some((u) => u.includes('yandex'));
const parts = [];
if (has2gis) parts.push('2ГИС');
if (hasYa) parts.push('Яндекс.Карты');
return parts.join(' · ');
}
function toggle(id: number) {
const i = selected.value.indexOf(id);
if (i === -1) selected.value.push(id);
else selected.value.splice(i, 1);
}
function toggleAll() {
selected.value = allSelected.value ? [] : list.value.map((c) => c.id);
}
function clearSel() {
selected.value = [];
}
async function reload() {
busy.value = true;
try {
await store.loadProposals();
} finally {
busy.value = false;
selected.value = selected.value.filter((id) => store.proposals.some((c) => c.id === id));
}
}
async function moveToField(c: CompetitorDto) {
busy.value = true;
try {
await store.moveCompetitorToBox(c.id, 'field');
await reload();
flash(`«${c.name}» перенесён в поле ✓`);
} finally {
busy.value = false;
}
}
async function moveSelected() {
if (busy.value) return;
busy.value = true;
try {
const ids = [...selected.value];
for (const id of ids) {
await store.moveCompetitorToBox(id, 'field');
}
await reload();
selected.value = [];
flash(`${ids.length} перенесено в поле ✓`);
} finally {
busy.value = false;
}
}
// ——— Окно «Собрать конкурентов для меня» (шаг 1, 300 ₽) — единое с «Полем» ———
const collect = reactive({
open: false,
running: false,
niche: '',
regionCode: regions[0]?.code ?? null,
selfSite: '',
includeFederal: true,
examples: [
{ site: '', dir: '' },
{ site: '', dir: '' },
] as Array<{ site: string; dir: string }>,
});
function openCollect() {
Object.assign(collect, {
open: true, running: false, niche: '', selfSite: '', includeFederal: true,
examples: [{ site: '', dir: '' }, { site: '', dir: '' }],
});
}
function addExample() {
collect.examples.push({ site: '', dir: '' });
}
async function runCollect() {
const examples = collect.examples.flatMap((e) => [e.site.trim(), e.dir.trim()]).filter(Boolean);
if (!collect.niche.trim() || examples.length < 1 || !collect.regionCode) {
flash('Заполните направление, регион и хотя бы один пример');
return;
}
collect.running = true;
try {
const run = await store.search({
region_code: collect.regionCode,
examples,
about_self: [collect.niche.trim(), collect.selfSite.trim()].filter(Boolean),
include_federal: collect.includeFederal,
});
await store.pollRun(run.id);
await reload();
collect.open = false;
flash('Готово: добавлены предложения');
} catch {
collect.running = false;
flash('Не удалось запустить подбор. Проверьте баланс.');
}
}
// ——— Окно «Править карточку конкурента» ———
const editComp = reactive({
open: false, id: null as number | null, name: '', type: 'loc', pct: '', site: '', gis: '', ya: '', desc: '',
});
function openEdit(c: CompetitorDto) {
const dirs = c.directory_urls ?? [];
Object.assign(editComp, {
open: true, id: c.id, name: c.name, type: c.is_federal ? 'fed' : 'loc',
pct: c.relevance_pct === null ? '' : String(c.relevance_pct),
site: c.site_url ?? '', desc: c.description ?? '',
gis: dirs.find((u) => u.includes('2gis')) ?? '',
ya: dirs.find((u) => u.includes('yandex')) ?? '',
});
}
async function saveEdit() {
if (editComp.id === null) return;
busy.value = true;
try {
const dirs = [editComp.gis.trim(), editComp.ya.trim()].filter(Boolean);
await store.editCompetitor(editComp.id, {
name: editComp.name.trim(),
is_federal: editComp.type === 'fed',
relevance_pct: editComp.pct === '' ? null : Math.max(0, Math.min(100, parseInt(editComp.pct) || 0)),
site_url: editComp.site.trim() || null,
description: editComp.desc.trim() || null,
directory_urls: dirs,
});
editComp.open = false;
await reload();
flash('Карточка сохранена ✓');
} finally {
busy.value = false;
}
}
// ——— Удаление предложения ———
const del = reactive({ open: false, id: null as number | null, name: '' });
function openDelete(c: CompetitorDto) {
Object.assign(del, { open: true, id: c.id, name: c.name });
}
async function confirmDelete() {
if (del.id === null) return;
busy.value = true;
try {
await store.removeCompetitor(del.id);
del.open = false;
await reload();
flash('Удалено');
} finally {
busy.value = false;
}
}
onMounted(() => {
void reload();
});
defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, openCollect, openEdit });
</script>
<template>
<div class="ld-field">
<h1 class="ld-field__title">Конкурентное поле</h1>
<p class="ld-field__sub">
Найдено в предложениях: <b>{{ list.length }}</b>. Это черновик разберите и перенесите нужных в поле.
</p>
<div class="ld-field__acts">
<button class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать конкурентов для меня</button>
<button class="ld-btn ghost" :disabled="busy" @click="nav.go('field')"> В поле</button>
</div>
<div class="ld-tabs">
<button class="ld-tab" @click="nav.go('field')">В поле</button>
<button class="ld-tab ld-tab--on">Предложения <span class="ld-tab__c">{{ list.length }}</span></button>
</div>
<div v-if="list.length" class="ld-selrow">
<label class="ld-selall">
<input type="checkbox" :checked="allSelected" @change="toggleAll" /> Выбрать все предложения
</label>
<span v-if="selected.length" class="ld-selcnt">Выбрано: <b>{{ selected.length }}</b></span>
<span v-else class="ld-selcnt ld-selcnt--mut">Отметьте 2 и более появится перенос в поле.</span>
</div>
<div v-if="list.length === 0" class="ld-empty">
<p class="ld-empty__t">Предложений пока нет</p>
<p class="ld-empty__s">Соберите конкурентов найденные появятся здесь, и вы перенесёте нужных в поле.</p>
</div>
<div v-else class="ld-grid">
<article v-for="c in list" :key="c.id" class="ld-card ld-card--sug" :class="{ 'ld-card--picked': selected.includes(c.id) }">
<div class="ld-card__top">
<div>
<div class="ld-card__nm">
<input type="checkbox" class="ld-pick" :checked="selected.includes(c.id)" @change="toggle(c.id)" />
{{ c.name }}
</div>
<div class="ld-bdgs">
<span class="ld-bdg" :class="c.is_federal ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ c.is_federal ? 'федеральный' : 'региональный' }}</span>
<span v-if="c.origin === 'manual'" class="ld-bdg ld-bdg--man">добавлен вручную</span>
<span class="ld-bdg ld-bdg--sug">предложение</span>
</div>
</div>
<div class="ld-pctwrap">
<div v-if="c.relevance_pct !== null" class="ld-pct" :class="c.relevance_pct >= 85 ? '' : c.relevance_pct >= 65 ? 'ld-pct--mid' : 'ld-pct--lo'">
{{ c.relevance_pct }}<span class="ld-pct__u">%</span>
</div>
<div v-if="c.relevance_pct !== null" class="ld-pl">похожесть</div>
<div v-else class="ld-pct ld-pct--man"></div>
</div>
</div>
<div class="ld-desc">{{ c.description || '—' }}</div>
<div class="ld-mrow"><span class="ld-lbl">Сайт:</span> <a v-if="c.site_url">{{ c.site_url }}</a><span v-else class="ld-na">не указан</span></div>
<div class="ld-mrow"><span class="ld-lbl">Справочник:</span> <span v-if="dirLabel(c)">{{ dirLabel(c) }}</span><span v-else class="ld-na">не указан</span></div>
<div class="ld-cfoot">
<span>
<span class="ld-link" @click="openEdit(c)"> Изменить</span>
<span class="ld-link ld-link--del" @click="openDelete(c)"> Удалить</span>
</span>
<button class="ld-btn primary sm" :disabled="busy" @click="moveToField(c)">В поле </button>
</div>
</article>
</div>
<Transition name="ld-bar">
<div v-if="selected.length >= 2" class="ld-bulkbar">
<span>Выбрано конкурентов: <b>{{ selected.length }}</b> это черновик, сохраняется</span>
<div class="ld-bulkbar__acts">
<button class="ld-btn gray sm" :disabled="busy" @click="clearSel">Снять выбор</button>
<button class="ld-btn primary sm" :disabled="busy" @click="moveSelected">Перенести выбранных в поле </button>
</div>
</div>
</Transition>
<!-- ====== Окно: Собрать конкурентов ====== -->
<div v-if="collect.open" class="ld-ovl" @click.self="collect.open = false">
<div class="ld-modal">
<template v-if="!collect.running">
<h3 class="ld-modal__h">Собрать конкурентов для меня</h3>
<p class="ld-modal__m">Лидерра соберёт список конкурентов. Он ляжет в «Предложения» вы сами выберете, кого взять в поле.</p>
<div class="ld-rules">
<h4>Как заполнить, чтобы результат был точным</h4>
<ul>
<li>Опишите простыми словами, чем вы занимаетесь от этого зависит, кого мы найдём.</li>
<li>Дайте 25 примеров конкурентов, которых точно знаете. Чем больше тем точнее.</li>
<li>У каждого примера укажите сайт <b>или</b> ссылку на 2ГИС/Яндекс.Карты.</li>
<li>Укажите свой сайт чтобы не предлагать вас самих.</li>
</ul>
</div>
<div class="ld-fld"><label>Чем вы занимаетесь (направление поиска) <span class="ld-req">*</span></label>
<textarea v-model="collect.niche" rows="2" class="ld-in" placeholder="Коротко — что вы делаете и для кого"></textarea></div>
<div class="ld-fld"><label>Регион поиска <span class="ld-req">*</span></label>
<select v-model="collect.regionCode" class="ld-in"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Ваш сайт</label><input v-model="collect.selfSite" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Примеры ваших конкурентов <span class="ld-req">*</span> минимум 2</label>
<div v-for="(ex, i) in collect.examples" :key="i" class="ld-ex">
<input v-model="ex.site" class="ld-in" placeholder="сайт: primer.ru" />
<input v-model="ex.dir" class="ld-in" placeholder="или ссылка на 2ГИС/Яндекс" />
</div>
<span class="ld-link" @click="addExample">+ добавить ещё пример</span></div>
<div class="ld-fld"><label>Включать федеральных конкурентов</label>
<select v-model="collect.includeFederal" class="ld-in"><option :value="true">Да</option><option :value="false">Нет</option></select></div>
<div class="ld-price">💳 Сбор конкурентов <b>{{ prices.search }} </b> за подбор. Деньги спишутся <b>только если что-то найдём</b>; пустой результат бесплатно.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="collect.open = false">Отмена</button>
<button class="ld-btn primary sm" @click="runCollect">Запустить подбор (платно)</button>
</div>
</template>
<template v-else>
<h3 class="ld-modal__h">Идёт подбор <span class="ld-spin"></span></h3>
<p class="ld-modal__m">Можно закрыть вкладку мы сохраним результат. Деньги спишутся только при успехе.</p>
</template>
</div>
</div>
<!-- ====== Окно: Править карточку ====== -->
<div v-if="editComp.open" class="ld-ovl" @click.self="editComp.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Изменить карточку конкурента</h3>
<p class="ld-modal__m">Можно поправить перед переносом в поле.</p>
<div class="ld-fld"><label>Название <span class="ld-req">*</span></label><input v-model="editComp.name" class="ld-in" /></div>
<div class="ld-fld"><label>Тип</label><select v-model="editComp.type" class="ld-in"><option value="loc">региональный</option><option value="fed">федеральный</option></select></div>
<div class="ld-fld"><label>Похожесть на вас, %</label><input v-model="editComp.pct" type="number" min="0" max="100" class="ld-in" /><div class="ld-hint">Портал сортирует по похожести: 100% сверху.</div></div>
<div class="ld-fld"><label>Чем занимается (описание)</label><textarea v-model="editComp.desc" rows="2" class="ld-in"></textarea></div>
<div class="ld-fld"><label>Сайт</label><input v-model="editComp.site" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Ссылка на карточку в 2ГИС</label><input v-model="editComp.gis" class="ld-in" placeholder="https://2gis.ru/..." /></div>
<div class="ld-fld"><label>Ссылка на карточку в Яндекс.Картах</label><input v-model="editComp.ya" class="ld-in" placeholder="https://yandex.ru/maps/..." /></div>
<div class="ld-hint">Укажите хотя бы одно: сайт или справочник иначе мы не сможем найти источники конкурента.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="editComp.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveEdit">Сохранить</button>
</div>
</div>
</div>
<!-- ====== Окно: Удалить предложение ====== -->
<div v-if="del.open" class="ld-ovl" @click.self="del.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Удалить конкурента?</h3>
<p class="ld-modal__m">«{{ del.name }}» будет удалён из предложений (если это лишнее или ошибочное предложение).</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="del.open = false">Отмена</button>
<button class="ld-btn danger sm" :disabled="busy" @click="confirmDelete">Удалить</button>
</div>
</div>
</div>
<Transition name="ld-toast">
<div v-if="toast" class="ld-toast">{{ toast }}</div>
</Transition>
</div>
</template>
<style scoped>
@import './field-shared.css';
.ld-field {
padding: 24px 0 90px;
max-width: 1080px;
}
.ld-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px;
}
@media (max-width: 860px) {
.ld-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,449 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, type FieldCompetitorDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
interface AutopodborNav {
go: (s: string) => void;
ctx: { competitorId: number | null };
screen: { value: string };
}
const nav = inject('autopodborNav') as AutopodborNav;
const store = useAutopodborStore();
const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const prices = computed(() => store.prices);
const competitors = computed<FieldCompetitorDto[]>(() =>
[...store.field].sort((a, b) => (b.relevance_pct ?? -1) - (a.relevance_pct ?? -1)),
);
const proposalsCount = computed(() => store.proposals.length);
const projectsInWork = computed(() => competitors.value.reduce((a, c) => a + c.counters.projects_in_work, 0));
const allSelected = computed(() => competitors.value.length > 0 && selected.value.length === competitors.value.length);
const someSelectedHaveProjects = computed(() =>
competitors.value.filter((c) => selected.value.includes(c.id)).some((c) => c.counters.projects_created > 0),
);
function flash(m: string) {
toast.value = m;
setTimeout(() => (toast.value = ''), 2400);
}
function dirLabel(c: FieldCompetitorDto): string {
const has2gis = (c.directory_urls ?? []).some((u) => u.includes('2gis'));
const hasYa = (c.directory_urls ?? []).some((u) => u.includes('yandex'));
const parts = [];
if (has2gis) parts.push('2ГИС');
if (hasYa) parts.push('Яндекс.Карты');
return parts.length ? parts.join(' · ') : '';
}
function toggle(id: number) {
const i = selected.value.indexOf(id);
if (i === -1) selected.value.push(id);
else selected.value.splice(i, 1);
}
function toggleAll() {
selected.value = allSelected.value ? [] : competitors.value.map((c) => c.id);
}
function clearSel() {
selected.value = [];
}
function openCompetitor(c: FieldCompetitorDto) {
nav.ctx.competitorId = c.id;
nav.go('fieldcompetitor');
}
async function reload() {
busy.value = true;
try {
await Promise.all([store.loadField(), store.loadProposals()]);
} finally {
busy.value = false;
selected.value = selected.value.filter((id) => store.field.some((c) => c.id === id));
}
}
async function bulkProjects(active: boolean) {
if (busy.value) return;
busy.value = true;
try {
const ids = competitors.value
.filter((c) => selected.value.includes(c.id))
.flatMap((c) => c.sources)
.map((s) => s.project)
.filter((p): p is NonNullable<typeof p> => p !== null)
.filter((p) => (active ? !p.is_active : p.is_active))
.map((p) => p.id);
await Promise.all(ids.map((pid) => store.toggleProjectActive(pid, active)));
await store.loadField();
flash(active ? 'Проекты включены ▶' : 'Проекты выключены ⏸');
} finally {
busy.value = false;
}
}
// ——— Окно «Собрать конкурентов для меня» (шаг 1, 300 ₽) ———
const collect = reactive({
open: false,
running: false,
niche: '',
regionCode: null as number | null,
selfSite: '',
includeFederal: true,
examples: [
{ site: '', dir: '' },
{ site: '', dir: '' },
] as Array<{ site: string; dir: string }>,
});
function openCollect() {
Object.assign(collect, {
open: true, running: false, niche: '', selfSite: '', includeFederal: true,
examples: [{ site: '', dir: '' }, { site: '', dir: '' }],
});
}
function addExample() {
collect.examples.push({ site: '', dir: '' });
}
async function runCollect() {
const examples = collect.examples.flatMap((e) => [e.site.trim(), e.dir.trim()]).filter(Boolean);
if (!collect.niche.trim() || examples.length < 1 || !collect.regionCode) {
flash('Заполните направление, регион и хотя бы один пример');
return;
}
collect.running = true;
try {
const run = await store.search({
region_code: collect.regionCode,
examples,
about_self: [collect.niche.trim(), collect.selfSite.trim()].filter(Boolean),
include_federal: collect.includeFederal,
});
await store.pollRun(run.id);
await store.loadProposals();
collect.open = false;
flash('Готово: добавлены предложения');
nav.go('field-proposals');
} catch (e) {
collect.running = false;
flash(autopodborErrorMessage(e, 'Не удалось запустить подбор. Попробуйте ещё раз.'));
}
}
// ——— Окно «Добавить конкурента вручную» ———
const addComp = reactive({
open: false, name: '', type: 'loc', pct: '100', site: '', gis: '', ya: '', desc: '', note: '',
});
function openAdd() {
Object.assign(addComp, { open: true, name: '', type: 'loc', pct: '100', site: '', gis: '', ya: '', desc: '', note: '' });
}
async function saveAdd() {
if (!addComp.name.trim()) {
addComp.note = 'Укажите название';
return;
}
busy.value = true;
addComp.note = '';
try {
const dirs = [addComp.gis.trim(), addComp.ya.trim()].filter(Boolean);
const created = await store.addFieldCompetitor({
name: addComp.name.trim(),
site_url: addComp.site.trim() || undefined,
description: addComp.desc.trim() || undefined,
is_federal: addComp.type === 'fed',
relevance_pct: addComp.pct === '' ? null : Math.max(0, Math.min(100, parseInt(addComp.pct) || 0)),
directory_urls: dirs.length ? dirs : undefined,
});
addComp.open = false;
nav.ctx.competitorId = created.id;
nav.go('fieldcompetitor');
} catch {
addComp.note = 'Не удалось добавить конкурента.';
} finally {
busy.value = false;
}
}
// ——— Окно «Изменить карточку конкурента» ———
const editComp = reactive({
open: false, id: null as number | null, name: '', type: 'loc', pct: '', site: '', gis: '', ya: '', desc: '',
});
function openEdit(c: FieldCompetitorDto) {
const dirs = c.directory_urls ?? [];
Object.assign(editComp, {
open: true, id: c.id, name: c.name, type: c.is_federal ? 'fed' : 'loc',
pct: c.relevance_pct === null ? '' : String(c.relevance_pct),
site: c.site_url ?? '', desc: c.description ?? '',
gis: dirs.find((u) => u.includes('2gis')) ?? '',
ya: dirs.find((u) => u.includes('yandex')) ?? '',
});
}
async function saveEdit() {
if (editComp.id === null) return;
busy.value = true;
try {
const dirs = [editComp.gis.trim(), editComp.ya.trim()].filter(Boolean);
await store.editCompetitor(editComp.id, {
name: editComp.name.trim(),
is_federal: editComp.type === 'fed',
relevance_pct: editComp.pct === '' ? null : Math.max(0, Math.min(100, parseInt(editComp.pct) || 0)),
site_url: editComp.site.trim() || null,
description: editComp.desc.trim() || null,
directory_urls: dirs,
});
editComp.open = false;
await reload();
flash('Карточка сохранена ✓');
} finally {
busy.value = false;
}
}
// ——— Удаление конкурента ———
const del = reactive({ open: false, id: null as number | null, name: '', run: 0, note: '' });
function openDelete(c: FieldCompetitorDto) {
Object.assign(del, { open: true, id: c.id, name: c.name, run: c.counters.projects_in_work, note: '' });
}
async function confirmDelete() {
if (del.id === null) return;
busy.value = true;
del.note = '';
try {
await store.removeCompetitor(del.id);
del.open = false;
flash('Удалено');
} catch {
del.note = 'Удалить нельзя: у конкурента есть источник с активным проектом. Сначала остановите проект.';
await reload();
} finally {
busy.value = false;
}
}
onMounted(() => {
void reload();
});
defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openCollect, openAdd });
</script>
<template>
<div class="ld-field">
<h1 class="ld-field__title">Конкурентное поле</h1>
<p class="ld-field__sub">
Ваши конкуренты: <b>{{ competitors.length }}</b> · проектов в работе: <b>{{ projectsInWork }}</b>.
Поле ведёте вы Лидерра подсказывает.
</p>
<div class="ld-field__acts">
<button class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать конкурентов для меня</button>
<button class="ld-btn ghost" :disabled="busy" @click="openAdd">+ Добавить вручную</button>
</div>
<div v-if="proposalsCount" class="ld-banner">
<div class="ld-banner__txt">
💡 <b>Лидерра подготовила {{ proposalsCount }}</b> в предложениях вы ещё не разобрали. Черновик сохранён.
</div>
<a class="ld-banner__link" @click="nav.go('field-proposals')">Разобрать предложения </a>
</div>
<div class="ld-tabs">
<button class="ld-tab ld-tab--on">В поле <span class="ld-tab__c">{{ competitors.length }}</span></button>
<button class="ld-tab" @click="nav.go('field-proposals')">Предложения <span class="ld-tab__c">{{ proposalsCount }}</span></button>
</div>
<div v-if="competitors.length" class="ld-selrow">
<label class="ld-selall">
<input type="checkbox" :checked="allSelected" @change="toggleAll" /> Выбрать всех
</label>
<span v-if="selected.length" class="ld-selcnt">Выбрано: <b>{{ selected.length }}</b></span>
<span v-else class="ld-selcnt ld-selcnt--mut">Отметьте конкурентов галочкой появятся массовые действия с их проектами.</span>
</div>
<div v-if="competitors.length === 0" class="ld-empty">
<p class="ld-empty__t">В поле пока пусто</p>
<p class="ld-empty__s">Соберите конкурентов или добавьте своего вручную отобранные появятся здесь.</p>
</div>
<div v-else class="ld-grid">
<article v-for="c in competitors" :key="c.id" class="ld-card" :class="{ 'ld-card--picked': selected.includes(c.id) }">
<div class="ld-card__top">
<div>
<div class="ld-card__nm">
<input type="checkbox" class="ld-pick" :checked="selected.includes(c.id)" @change="toggle(c.id)" />
{{ c.name }}
</div>
<div class="ld-bdgs">
<span class="ld-bdg" :class="c.is_federal ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ c.is_federal ? 'федеральный' : 'региональный' }}</span>
<span v-if="c.origin === 'manual'" class="ld-bdg ld-bdg--man">добавлен вручную</span>
</div>
</div>
<div class="ld-pctwrap">
<div v-if="c.relevance_pct !== null" class="ld-pct" :class="c.relevance_pct >= 85 ? '' : c.relevance_pct >= 65 ? 'ld-pct--mid' : 'ld-pct--lo'">
{{ c.relevance_pct }}<span class="ld-pct__u">%</span>
</div>
<div v-if="c.relevance_pct !== null" class="ld-pl">похожесть</div>
<div v-else class="ld-pct ld-pct--man"></div>
</div>
</div>
<div class="ld-desc">{{ c.description || '—' }}</div>
<div class="ld-mrow"><span class="ld-lbl">Сайт:</span> <a v-if="c.site_url">{{ c.site_url }}</a><span v-else class="ld-na">не указан</span></div>
<div class="ld-mrow"><span class="ld-lbl">Справочник:</span> <span v-if="dirLabel(c)">{{ dirLabel(c) }}</span><span v-else class="ld-na">не указан</span></div>
<div class="ld-srcline">
📌 источников: {{ c.counters.sources }} · создано проектов: {{ c.counters.projects_created }} ·
<span v-if="c.counters.projects_in_work > 0" class="ld-tagrun">{{ c.counters.projects_in_work }} в работе</span>
<span v-else class="ld-tagwait">0 в работе</span>
</div>
<div class="ld-cfoot">
<span>
<span class="ld-link" @click="openEdit(c)"> Изменить</span>
<span class="ld-link ld-link--del" @click="openDelete(c)"> Удалить</span>
</span>
<button class="ld-btn ghost sm" @click="openCompetitor(c)">Открыть конкурента </button>
</div>
</article>
</div>
<Transition name="ld-bar">
<div v-if="selected.length >= 2" class="ld-bulkbar">
<span>Выбрано конкурентов: <b>{{ selected.length }}</b> действие применится ко всем их проектам разом</span>
<div class="ld-bulkbar__acts">
<button class="ld-btn gray sm" :disabled="busy" @click="clearSel">Снять выбор</button>
<button v-if="someSelectedHaveProjects" class="ld-btn warn sm" :disabled="busy" @click="bulkProjects(false)"> Выключить все проекты</button>
<button class="ld-btn primary sm" :disabled="busy" @click="bulkProjects(true)"> Включить все созданные проекты</button>
</div>
</div>
</Transition>
<!-- ====== Окно: Собрать конкурентов ====== -->
<div v-if="collect.open" class="ld-ovl" @click.self="collect.open = false">
<div class="ld-modal">
<template v-if="!collect.running">
<h3 class="ld-modal__h">Собрать конкурентов для меня</h3>
<p class="ld-modal__m">Лидерра соберёт список конкурентов. Он ляжет в «Предложения» вы сами выберете, кого взять в поле.</p>
<div class="ld-rules">
<h4>Как заполнить, чтобы результат был точным</h4>
<ul>
<li>Опишите простыми словами, чем вы занимаетесь от этого зависит, кого мы найдём.</li>
<li>Дайте 25 примеров конкурентов, которых точно знаете. Чем больше тем точнее.</li>
<li>У каждого примера укажите сайт <b>или</b> ссылку на 2ГИС/Яндекс.Карты.</li>
<li>Укажите свой сайт чтобы не предлагать вас самих.</li>
</ul>
</div>
<div class="ld-fld"><label>Чем вы занимаетесь (направление поиска) <span class="ld-req">*</span></label>
<textarea v-model="collect.niche" rows="2" class="ld-in" placeholder="Коротко — что вы делаете и для кого"></textarea></div>
<div class="ld-fld"><label>Регион поиска <span class="ld-req">*</span></label>
<select v-model="collect.regionCode" class="ld-in"><option :value="null" disabled>— выберите регион —</option><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Ваш сайт</label><input v-model="collect.selfSite" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Примеры ваших конкурентов <span class="ld-req">*</span> минимум 2</label>
<div v-for="(ex, i) in collect.examples" :key="i" class="ld-ex">
<input v-model="ex.site" class="ld-in" placeholder="сайт: primer.ru" />
<input v-model="ex.dir" class="ld-in" placeholder="или ссылка на 2ГИС/Яндекс" />
</div>
<span class="ld-link" @click="addExample">+ добавить ещё пример</span></div>
<div class="ld-fld"><label>Включать федеральных конкурентов</label>
<select v-model="collect.includeFederal" class="ld-in"><option :value="true">Да</option><option :value="false">Нет</option></select></div>
<div class="ld-price">💳 Сбор конкурентов <b>{{ prices.search }} </b> за подбор. Деньги спишутся <b>только если что-то найдём</b>; пустой результат бесплатно.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="collect.open = false">Отмена</button>
<button class="ld-btn primary sm" @click="runCollect">Запустить подбор (платно)</button>
</div>
</template>
<template v-else>
<h3 class="ld-modal__h">Идёт подбор <span class="ld-spin"></span></h3>
<p class="ld-modal__m">Можно закрыть вкладку мы сохраним результат. Деньги спишутся только при успехе.</p>
</template>
</div>
</div>
<!-- ====== Окно: Добавить конкурента вручную ====== -->
<div v-if="addComp.open" class="ld-ovl" @click.self="addComp.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Добавить конкурента вручную</h3>
<p class="ld-modal__m">Появится сразу в поле.</p>
<div class="ld-rules">
<h4>Что важно указать</h4>
<ul>
<li>Название как называется конкурент.</li>
<li>Сайт <b>или</b> ссылку на карточку в справочнике (2ГИС/Яндекс) без этого мы не сможем найти его источники.</li>
</ul>
</div>
<div class="ld-fld"><label>Название <span class="ld-req">*</span></label><input v-model="addComp.name" class="ld-in" placeholder="Например: «Ромашка»" /></div>
<div class="ld-fld"><label>Тип</label><select v-model="addComp.type" class="ld-in"><option value="loc">региональный</option><option value="fed">федеральный</option></select></div>
<div class="ld-fld"><label>Похожесть на вас, %</label><input v-model="addComp.pct" type="number" min="0" max="100" class="ld-in" /><div class="ld-hint">Ставите на своё усмотрение портал сортирует по похожести, 100% сверху.</div></div>
<div class="ld-fld"><label>Сайт</label><input v-model="addComp.site" class="ld-in" placeholder="primer.ru" /><div class="ld-hint">Без http:// просто адрес.</div></div>
<div class="ld-fld"><label>Ссылка на карточку в 2ГИС</label><input v-model="addComp.gis" class="ld-in" placeholder="https://2gis.ru/..." /></div>
<div class="ld-fld"><label>Ссылка на карточку в Яндекс.Картах</label><input v-model="addComp.ya" class="ld-in" placeholder="https://yandex.ru/maps/..." /></div>
<div class="ld-fld"><label>Описание</label><textarea v-model="addComp.desc" rows="2" class="ld-in" placeholder="Чем занимается"></textarea></div>
<p v-if="addComp.note" class="ld-err">{{ addComp.note }}</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="addComp.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveAdd">Добавить в поле</button>
</div>
</div>
</div>
<!-- ====== Окно: Изменить карточку ====== -->
<div v-if="editComp.open" class="ld-ovl" @click.self="editComp.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Изменить карточку конкурента</h3>
<p class="ld-modal__m">Карточку ведёте вы меняйте любое поле.</p>
<div class="ld-fld"><label>Название <span class="ld-req">*</span></label><input v-model="editComp.name" class="ld-in" /></div>
<div class="ld-fld"><label>Тип</label><select v-model="editComp.type" class="ld-in"><option value="loc">региональный</option><option value="fed">федеральный</option></select></div>
<div class="ld-fld"><label>Похожесть на вас, %</label><input v-model="editComp.pct" type="number" min="0" max="100" class="ld-in" /><div class="ld-hint">Портал сортирует по похожести: 100% сверху.</div></div>
<div class="ld-fld"><label>Чем занимается (описание)</label><textarea v-model="editComp.desc" rows="2" class="ld-in"></textarea></div>
<div class="ld-fld"><label>Сайт</label><input v-model="editComp.site" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Ссылка на карточку в 2ГИС</label><input v-model="editComp.gis" class="ld-in" placeholder="https://2gis.ru/..." /></div>
<div class="ld-fld"><label>Ссылка на карточку в Яндекс.Картах</label><input v-model="editComp.ya" class="ld-in" placeholder="https://yandex.ru/maps/..." /></div>
<div class="ld-hint">Укажите хотя бы одно: сайт или справочник иначе мы не сможем найти источники конкурента.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="editComp.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveEdit">Сохранить</button>
</div>
</div>
</div>
<!-- ====== Окно: Удалить конкурента ====== -->
<div v-if="del.open" class="ld-ovl" @click.self="del.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Удалить конкурента?</h3>
<p class="ld-modal__m">«{{ del.name }}» будет удалён из вашего поля.</p>
<p v-if="del.run > 0" class="ld-modal__warn"> У конкурента {{ del.run }} проект(ов) в работе связанные проекты нужно сначала остановить.</p>
<p v-if="del.note" class="ld-err">{{ del.note }}</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="del.open = false">Отмена</button>
<button class="ld-btn danger sm" :disabled="busy" @click="confirmDelete">Удалить</button>
</div>
</div>
</div>
<Transition name="ld-toast">
<div v-if="toast" class="ld-toast">{{ toast }}</div>
</Transition>
</div>
</template>
<style scoped>
@import './field-shared.css';
.ld-field {
padding: 24px 0 90px;
max-width: 1080px;
}
.ld-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px;
}
@media (max-width: 860px) {
.ld-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { inject, onMounted } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { CompetitorDto } from '../../../api/autopodbor';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
onMounted(async () => {
if (nav.ctx.runId) {
await store.loadRunCompetitors(nav.ctx.runId);
}
});
function relevanceClass(pct: number | null): string {
if (pct === null) return 'rel-none';
if (pct >= 100) return 'rel-100';
if (pct >= 85) return 'rel-hi';
if (pct >= 50) return 'rel-mid';
return 'rel-low';
}
function openDetail(comp: CompetitorDto) {
nav.ctx.competitorId = comp.id;
nav.go('detail');
}
async function studyCompetitor(comp: CompetitorDto) {
nav.ctx.loadMsg = 'Собираем источники конкурента…';
nav.ctx.loadSub = `Изучаем «${comp.name}», вытаскиваем все источники.`;
nav.go('loading');
try {
const run = await store.study(comp.id);
nav.ctx.runId = run.id;
const final = await store.pollRun(run.id);
nav.ctx.competitorId = comp.id;
nav.go('detail');
} catch {
nav.go('list');
}
}
function pluralCompetitor(n: number): string {
if (n % 10 === 1 && n % 100 !== 11) return 'конкурент';
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'конкурента';
return 'конкурентов';
}
</script>
<template>
<div class="ld-list-screen">
<h1 class="ld-list-screen__title">
Найдено {{ store.runCompetitors.length }}
{{ pluralCompetitor(store.runCompetitors.length) }}
</h1>
<div
v-for="comp in store.runCompetitors"
:key="comp.id"
class="ld-ccard"
>
<div class="ld-ccard__main">
<p class="ld-ccard__name">
{{ comp.name }}
<span v-if="comp.is_federal" class="ld-badge ld-badge--fed">федеральный</span>
<span v-if="comp.studied_at" class="ld-badge ld-badge--studied">&#10003; изучен</span>
</p>
<p v-if="comp.description" class="ld-ccard__desc">{{ comp.description }}</p>
<div class="ld-ccard__links">
<span v-if="comp.site_url">
<span class="ld-lbl">Сайт:</span>
<a :href="`https://${comp.site_url}`" target="_blank" rel="noopener">{{ comp.site_url }}</a>
</span>
<span v-for="(url, i) in comp.directory_urls" :key="i">
<a :href="url" target="_blank" rel="noopener">{{ url }}</a>
</span>
</div>
</div>
<div class="ld-ccard__side">
<div v-if="comp.relevance_pct !== null" class="ld-rel">
<div :class="['ld-relnum', relevanceClass(comp.relevance_pct)]">{{ comp.relevance_pct }}%</div>
<div class="ld-rellbl">похожесть</div>
</div>
<template v-if="comp.studied_at">
<button class="ld-btn-ghost" @click="openDetail(comp)">
Открыть источники &rarr;<br>
<span class="ld-paynote">уже оплачено</span>
</button>
</template>
<template v-else>
<button class="ld-btn-primary" @click="studyCompetitor(comp)">
Изучить подробнее &rarr;<br>
<span class="ld-paynote ld-paynote--light">платно</span>
</button>
</template>
</div>
</div>
<div class="ld-addbox">
<b>Не нашли нужного конкурента?</b>
<p>
Знаете конкретного, которого мы не показали
<span class="ld-addlink" @click="nav.go('manualform')">укажите его вручную</span>,
и мы соберём его источники.
</p>
</div>
</div>
</template>
<style scoped>
.ld-list-screen {
padding: 28px 0;
}
.ld-list-screen__title {
font-size: 22px;
font-weight: 700;
color: #012019;
margin: 0 0 20px;
}
.ld-ccard {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 18px 20px;
margin-bottom: 12px;
display: flex;
gap: 16px;
align-items: flex-start;
}
.ld-ccard__main {
flex: 1;
min-width: 0;
}
.ld-ccard__name {
font-size: 15px;
font-weight: 700;
color: #012019;
margin: 0 0 6px;
}
.ld-badge {
font-size: 11px;
border-radius: 4px;
padding: 2px 7px;
margin-left: 6px;
font-weight: 500;
vertical-align: middle;
}
.ld-badge--fed {
background: #edf3fb;
color: #1a4f8a;
border: 1px solid #c5d8ef;
}
.ld-badge--studied {
background: #e8f3ee;
color: #0c5a46;
border: 1px solid #cfe3da;
}
.ld-ccard__desc {
font-size: 13px;
color: #4a4540;
margin: 0 0 8px;
line-height: 1.5;
}
.ld-ccard__links {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12.5px;
color: #7a7468;
}
.ld-lbl {
font-weight: 500;
margin-right: 3px;
}
.ld-ccard__links a {
color: var(--liderra-teal, #0f6e56);
text-decoration: none;
}
.ld-ccard__links a:hover {
text-decoration: underline;
}
.ld-ccard__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
flex-shrink: 0;
min-width: 120px;
}
.ld-rel {
text-align: center;
}
.ld-relnum {
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.ld-rellbl {
font-size: 11px;
color: #9b9484;
margin-top: 2px;
}
.rel-100 { color: var(--liderra-teal, #0f6e56); }
.rel-hi { color: #2e7d32; }
.rel-mid { color: #b45309; }
.rel-low { color: #9b9484; }
.rel-none { color: #c0b9a8; }
.ld-btn-primary {
display: inline-flex;
flex-direction: column;
align-items: center;
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 14px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
text-align: center;
line-height: 1.4;
width: 100%;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-btn-ghost {
display: inline-flex;
flex-direction: column;
align-items: center;
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 14px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
text-align: center;
line-height: 1.4;
width: 100%;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
.ld-paynote {
font-size: 11px;
font-weight: 400;
opacity: 0.8;
}
.ld-paynote--light {
color: rgba(255,255,255,0.8);
}
.ld-addbox {
margin-top: 24px;
background: #f6f3ec;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 16px 20px;
font-size: 13.5px;
color: #4a4540;
}
.ld-addbox b {
color: #012019;
}
.ld-addbox p {
margin: 6px 0 0;
line-height: 1.5;
}
.ld-addlink {
color: var(--liderra-teal, #0f6e56);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
.ld-addlink:hover {
text-decoration-style: solid;
}
</style>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { inject } from 'vue';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
</script>
<template>
<div class="ld-loading-screen">
<div class="ld-loading-screen__wrap">
<div class="ld-spin"></div>
<p class="ld-loading-screen__msg">{{ nav.ctx.loadMsg || 'Идёт работа…' }}</p>
<p class="ld-loading-screen__sub">{{ nav.ctx.loadSub || 'Пожалуйста, подождите.' }}</p>
<p class="ld-loading-screen__note">
Можно закрыть вкладку мы сохраним результат и покажем его, когда будет готово.
Деньги спишем только за успешный результат.
</p>
</div>
</div>
</template>
<style scoped>
.ld-loading-screen {
padding: 28px 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
}
.ld-loading-screen__wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
max-width: 420px;
text-align: center;
}
.ld-spin {
width: 44px;
height: 44px;
border: 4px solid #e8e2d4;
border-top-color: var(--liderra-teal, #0f6e56);
border-radius: 50%;
animation: ld-spin 0.8s linear infinite;
}
@keyframes ld-spin {
to { transform: rotate(360deg); }
}
.ld-loading-screen__msg {
font-size: 17px;
font-weight: 700;
color: #012019;
margin: 0;
}
.ld-loading-screen__sub {
font-size: 13.5px;
color: #4a4540;
margin: 0;
line-height: 1.5;
}
.ld-loading-screen__note {
font-size: 12.5px;
color: #9a8f76;
margin: 0;
line-height: 1.6;
}
</style>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { inject, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { REGIONS } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
const siteUrl = ref('');
const name = ref('');
const regionCode = ref<number | null>(null);
const errorMsg = ref('');
defineExpose({ regionCode });
function extractError(e: unknown): string {
const code = (e as any)?.response?.data?.error;
if (code === 'balance_insufficient') return 'Недостаточно средств на балансе.';
if (code === 'run_in_flight') return 'Уже идёт похожий запрос — дождитесь его завершения.';
return 'Произошла ошибка. Попробуйте позже.';
}
async function submit() {
errorMsg.value = '';
const site = siteUrl.value.trim();
const nm = name.value.trim();
if (!site && !nm) {
errorMsg.value = 'Укажите сайт или название конкурента.';
return;
}
if (!regionCode.value) {
errorMsg.value = 'Выберите регион.';
return;
}
nav.go('loading');
try {
const run = await store.manualStudy({
site_url: site || undefined,
name: nm || undefined,
region_code: regionCode.value,
});
const final = await store.pollRun(run.id);
nav.ctx.competitorId = final.competitor_id ?? null;
nav.go('detail');
} catch (e) {
nav.go('manualform');
errorMsg.value = extractError(e);
}
}
</script>
<template>
<div class="ld-manualform-screen">
<div class="ld-mf-topbar">
<span class="ld-mf-crumb">Автоподбор · Свой конкурент</span>
</div>
<button class="ld-mf-back" type="button" @click="nav.go('entry')"> Назад</button>
<h1 class="ld-mf-title">Указать своего конкурента</h1>
<p class="ld-mf-sub">Соберём источники указанного конкурента без этапа подбора.</p>
<v-alert v-if="errorMsg" type="error" class="ld-mf-alert" variant="tonal" closable @click:close="errorMsg = ''">
{{ errorMsg }}
</v-alert>
<div class="ld-mf-card">
<p class="ld-mf-sectitle">Конкурент <span class="ld-mf-req">*</span></p>
<p class="ld-mf-hint">Дайте его сайт или ссылку на справочник соберём источники сразу. Если знаете только название введите его ниже.</p>
<p class="ld-mf-label">Сайт или ссылка на справочник</p>
<input
v-model="siteUrl"
class="ld-mf-input"
type="text"
placeholder="okna-komfort-kzn.ru · или 2gis.ru/kazan/firm/…"
/>
<p class="ld-mf-label">Либо название <span class="ld-mf-opt"> если сайта/ссылки нет</span></p>
<input
v-model="name"
class="ld-mf-input"
type="text"
placeholder="Окна Комфорт"
/>
<div class="ld-mf-divider"></div>
<p class="ld-mf-sectitle">Регион <span class="ld-mf-req">*</span></p>
<p class="ld-mf-hint">Обязательно. Один регион.</p>
<select v-model="regionCode" class="ld-mf-select">
<option :value="null" disabled> выберите регион </option>
<option v-for="r in REGIONS.filter(r => r.code > 0)" :key="r.code" :value="r.code">{{ r.name }}</option>
</select>
<div class="ld-mf-divider"></div>
<button class="ld-btn-primary" type="button" @click="submit">Собрать источники</button>
<p class="ld-mf-paynote">Услуга платная при запуске спишем сумму с баланса.</p>
</div>
</div>
</template>
<style scoped>
.ld-manualform-screen {
padding: 28px 0;
}
.ld-mf-topbar {
margin-bottom: 8px;
}
.ld-mf-crumb {
font-size: 12.5px;
color: #7a7468;
}
.ld-mf-back {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 16px;
display: inline-block;
}
.ld-mf-title {
font-size: 24px;
font-weight: 700;
color: #012019;
margin: 0 0 8px;
}
.ld-mf-sub {
font-size: 14px;
color: #4a4540;
margin: 0 0 20px;
}
.ld-mf-alert {
margin-bottom: 16px;
}
.ld-mf-card {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 22px 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ld-mf-sectitle {
font-size: 13.5px;
font-weight: 700;
color: #012019;
margin: 0;
}
.ld-mf-req {
color: #c0392b;
}
.ld-mf-hint {
font-size: 12.5px;
color: #7a7468;
margin: 0;
line-height: 1.5;
}
.ld-mf-label {
font-size: 13px;
font-weight: 600;
color: #4a4540;
margin: 0;
}
.ld-mf-opt {
font-weight: 400;
color: #9b9484;
font-size: 12px;
}
.ld-mf-input {
border: 1.5px solid #d8d2c6;
border-radius: 7px;
padding: 9px 12px;
font-size: 13.5px;
color: #012019;
width: 100%;
box-sizing: border-box;
outline: none;
transition: border-color 150ms ease;
background: #faf8f4;
}
.ld-mf-input:focus {
border-color: var(--liderra-teal, #0f6e56);
background: #fff;
}
.ld-mf-select {
border: 1.5px solid #d8d2c6;
border-radius: 7px;
padding: 9px 12px;
font-size: 13.5px;
color: #012019;
width: 100%;
box-sizing: border-box;
outline: none;
background: #faf8f4;
cursor: pointer;
transition: border-color 150ms ease;
}
.ld-mf-select:focus {
border-color: var(--liderra-teal, #0f6e56);
background: #fff;
}
.ld-mf-divider {
height: 1px;
background: #f0ece1;
margin: 4px 0;
}
.ld-btn-primary {
display: inline-flex;
align-items: center;
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 10px 20px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
align-self: flex-start;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-mf-paynote {
font-size: 11.5px;
color: #9b9484;
margin: 0;
}
</style>
@@ -0,0 +1,693 @@
/* Общий стиль рабочего места «Конкурентное поле» (Forest). Классы ld-* — без коллизий. */
.ld-field__title,
.ld-h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 5px;
color: #012019;
}
.ld-field__sub,
.ld-sub {
color: #7a8a82;
font-size: 14px;
margin: 0 0 18px;
}
.ld-field__sub b,
.ld-sub b {
color: #1a2420;
}
.ld-back {
color: #7a8a82;
font-size: 13px;
cursor: pointer;
margin: 0 0 10px;
display: inline-block;
background: none;
border: none;
padding: 0;
}
.ld-back:hover {
color: #0f6e56;
}
.ld-field__acts,
.ld-acts {
display: flex;
gap: 11px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.ld-btn {
border: none;
border-radius: 11px;
padding: 11px 18px;
font: 600 14px Inter, system-ui, Arial;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.ld-btn:active {
transform: translateY(1px);
}
.ld-btn:disabled {
opacity: 0.5;
cursor: default;
}
.ld-btn.primary {
background: #0f6e56;
color: #fff;
}
.ld-btn.ghost {
background: #fff;
color: #0f6e56;
border: 1.5px solid #0f6e56;
}
.ld-btn.gray {
background: #eef0f2;
color: #55606b;
}
.ld-btn.warn {
background: #fff;
color: #a05a1a;
border: 1.5px solid #e6c98a;
}
.ld-btn.danger {
background: #b3422e;
color: #fff;
}
.ld-btn.sm {
padding: 7px 13px;
font-size: 13px;
border-radius: 9px;
}
.ld-banner {
background: #eef7f3;
border: 1px solid #cfe6dc;
border-radius: 13px;
padding: 13px 18px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
}
.ld-banner__txt {
font-size: 13.8px;
}
.ld-banner__txt b {
color: #0f6e56;
}
.ld-banner__link {
color: #0f6e56;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.ld-banner--amber {
background: #fffaf0;
border-color: #e6c98a;
}
.ld-banner--amber .ld-banner__txt b {
color: #8a6a12;
}
.ld-tabs {
display: flex;
gap: 6px;
border-bottom: 1px solid #e6e1d6;
margin-bottom: 18px;
}
.ld-tab {
padding: 10px 16px;
font-size: 14px;
color: #7a8a82;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
font-weight: 500;
}
.ld-tab--on {
color: #0f6e56;
border-bottom-color: #0f6e56;
font-weight: 700;
}
.ld-tab__c {
font-size: 12px;
background: #eef0f2;
color: #55606b;
border-radius: 10px;
padding: 0 7px;
margin-left: 6px;
}
.ld-tab--on .ld-tab__c {
background: #e7f0ec;
color: #0f6e56;
}
.ld-selrow {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
padding: 8px 14px;
background: #fff;
border: 1px solid #e6e1d6;
border-radius: 11px;
}
.ld-selall {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
font-weight: 600;
color: #1a2420;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.ld-selall input {
flex-shrink: 0;
width: 17px;
height: 17px;
accent-color: #0f6e56;
margin: 0;
}
.ld-selcnt {
font-size: 13px;
color: #1a2420;
}
.ld-selcnt--mut {
color: #7a8a82;
}
.ld-empty {
background: #fff;
border: 1px dashed #e0d9c8;
border-radius: 14px;
padding: 40px 24px;
text-align: center;
}
.ld-empty__t {
font-size: 16px;
font-weight: 700;
color: #012019;
margin: 0 0 6px;
}
.ld-empty__s {
font-size: 13.5px;
color: #7a8a82;
margin: 0;
}
.ld-card {
background: #fff;
border: 1px solid #e6e1d6;
border-radius: 14px;
padding: 16px 18px;
display: flex;
flex-direction: column;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.ld-card--picked {
border-color: #0f6e56;
box-shadow:
0 0 0 1.5px #0f6e56,
0 1px 2px rgba(0, 0, 0, 0.03);
}
.ld-card--sug {
border-color: #e6c98a;
background: #fffaf0;
}
.ld-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.ld-card__nm {
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
gap: 7px;
}
.ld-pick {
width: 17px;
height: 17px;
accent-color: #0f6e56;
flex-shrink: 0;
}
.ld-bdgs {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.ld-bdg {
font-size: 11px;
padding: 2px 9px;
border-radius: 20px;
font-weight: 600;
}
.ld-bdg--fed {
background: #e7f0ec;
color: #0f6e56;
}
.ld-bdg--loc {
background: #eef0f2;
color: #55606b;
}
.ld-bdg--man {
background: #ece7fb;
color: #5b46a0;
}
.ld-bdg--sug {
background: #f6e4b8;
color: #8a6a12;
}
.ld-pctwrap {
text-align: right;
}
.ld-pct {
font: 700 22px 'JetBrains Mono', monospace;
color: #0f6e56;
line-height: 1;
text-align: right;
}
.ld-pct--mid {
color: #6a8a3a;
}
.ld-pct--lo {
color: #9aa6a0;
}
.ld-pct--man {
color: #b6aed0;
font-size: 15px;
}
.ld-pct__u {
font-size: 11px;
}
.ld-pl {
font-size: 9.5px;
color: #7a8a82;
text-transform: uppercase;
letter-spacing: 0.06em;
text-align: right;
margin-top: 2px;
}
.ld-desc {
color: #46524c;
font-size: 13px;
margin: 10px 0 8px;
min-height: 18px;
}
.ld-mrow {
font-size: 13px;
color: #55606b;
margin: 3px 0;
}
.ld-lbl {
color: #7a8a82;
}
.ld-mrow a {
color: #0f6e56;
text-decoration: none;
cursor: pointer;
}
.ld-na {
color: #b3a;
}
.ld-srcline {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
color: #55606b;
margin-top: 8px;
flex-wrap: wrap;
}
.ld-tagrun {
background: #dff0e6;
color: #1a7a3a;
font-size: 11px;
padding: 1px 8px;
border-radius: 20px;
font-weight: 600;
}
.ld-tagwait {
background: #f3eee2;
color: #9a7a2a;
font-size: 11px;
padding: 1px 8px;
border-radius: 20px;
font-weight: 600;
}
.ld-cfoot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 13px;
padding-top: 12px;
border-top: 1px solid #f0ece3;
gap: 8px;
}
.ld-link {
color: #0f6e56;
cursor: pointer;
font-size: 12.5px;
font-weight: 600;
}
.ld-link--del {
color: #b3422e;
margin-left: 10px;
}
.ld-bulkbar {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
width: min(1040px, calc(100% - 280px));
background: #fff;
border: 1px solid #e6e1d6;
border-radius: 12px;
padding: 11px 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 40;
}
.ld-bulkbar__acts {
display: flex;
gap: 7px;
align-items: center;
flex-wrap: wrap;
}
/* ——— источники (карточка конкурента) ——— */
.ld-srccard {
display: flex;
gap: 12px;
align-items: flex-start;
background: #fff;
border: 1px solid #e6e1d6;
border-radius: 12px;
padding: 13px 15px;
margin-bottom: 10px;
}
.ld-srccard--sug {
border-color: #e6c98a;
background: #fffaf0;
}
.ld-srccard--picked {
border-color: #0f6e56;
box-shadow: 0 0 0 1.5px #0f6e56;
}
.ld-srcicon {
font-size: 18px;
width: 22px;
text-align: center;
padding-top: 1px;
}
.ld-srcmain {
flex: 1;
min-width: 0;
}
.ld-srctitle {
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
}
.ld-srcprov {
color: #7a8a82;
font-size: 12px;
font-style: italic;
margin-top: 2px;
}
.ld-srcproj {
font-size: 12.5px;
margin-top: 5px;
color: #55606b;
}
.ld-srcctl {
display: flex;
gap: 6px;
align-items: flex-start;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 320px;
}
.ld-b-run {
background: #dff0e6;
color: #1a7a3a;
}
.ld-b-stop {
background: #f0e6e6;
color: #a05050;
}
.ld-b-none {
background: #eef0f2;
color: #7a8a82;
}
.ld-note {
color: #7a8a82;
font-size: 13px;
margin: 2px 0 14px;
}
/* ——— модалки / формы ——— */
.ld-ovl {
position: fixed;
inset: 0;
background: rgba(1, 20, 15, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 50;
overflow: auto;
padding: 40px 16px;
}
.ld-modal {
background: #fff;
border-radius: 16px;
padding: 22px 24px;
width: 540px;
max-width: 94vw;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.ld-modal__h {
margin: 0 0 4px;
font-size: 18px;
color: #012019;
}
.ld-modal__m {
color: #7a8a82;
font-size: 13px;
margin: 0 0 10px;
}
.ld-modal__warn {
color: #a05a1a;
font-size: 13px;
margin: 8px 0 0;
}
.ld-modal__ok {
color: #1a7a3a;
background: #eef7f3;
border: 1px solid #cfe6dc;
border-radius: 9px;
padding: 10px 12px;
font-size: 13px;
margin: 8px 0 0;
line-height: 1.45;
}
.ld-modal__foot {
display: flex;
justify-content: flex-end;
gap: 9px;
margin-top: 18px;
flex-wrap: wrap;
}
.ld-rules {
background: #eef7f3;
border: 1px solid #cfe6dc;
border-radius: 11px;
padding: 13px 16px;
margin: 6px 0 16px;
}
.ld-rules h4 {
margin: 0 0 7px;
font-size: 13.5px;
color: #0f6e56;
}
.ld-rules ul {
margin: 0;
padding-left: 18px;
}
.ld-rules li {
font-size: 12.8px;
color: #3a463f;
margin: 4px 0;
}
.ld-price {
background: #fff5e9;
border: 1px solid #f0d9b5;
border-radius: 10px;
padding: 9px 13px;
font-size: 12.8px;
color: #8a6a12;
margin: 10px 0;
}
.ld-price--go {
background: #eef7f3;
border-color: #cfe6dc;
color: #1a7a3a;
}
.ld-fld {
margin: 11px 0;
}
.ld-fld label {
display: block;
font-size: 12.5px;
color: #3a463f;
margin-bottom: 4px;
font-weight: 500;
}
.ld-req {
color: #b3422e;
margin-left: 2px;
}
.ld-in {
width: 100%;
border: 1px solid #e6e1d6;
border-radius: 9px;
padding: 9px 11px;
font: 14px Inter, system-ui, Arial;
background: #fff;
color: #1a2420;
}
.ld-in:focus {
outline: none;
border-color: #0f6e56;
}
.ld-hint {
font-size: 11.5px;
color: #7a8a82;
margin-top: 4px;
}
.ld-err {
font-size: 13px;
color: #b3422e;
background: #fdecea;
border-radius: 8px;
padding: 8px 10px;
margin: 10px 0 0;
}
.ld-ex {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.ld-ex .ld-in {
flex: 1;
}
.ld-lockfld {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border: 1px solid #e6e1d6;
border-radius: 9px;
background: #f4f2ec;
color: #3a463f;
font-weight: 600;
}
.ld-lockbadge {
font-size: 11px;
font-weight: 600;
color: #8a6a12;
background: #fff5e9;
border: 1px solid #f0d9b5;
border-radius: 20px;
padding: 1px 9px;
}
.ld-days {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ld-day {
border: 1px solid #e6e1d6;
background: #fff;
border-radius: 8px;
padding: 7px 13px;
cursor: pointer;
font: 600 13px Inter, system-ui, Arial;
color: #55606b;
}
.ld-day--on {
background: #0f6e56;
color: #fff;
border-color: #0f6e56;
}
.ld-spin {
display: inline-block;
width: 16px;
height: 16px;
border: 2.5px solid #cfe0d8;
border-top-color: #0f6e56;
border-radius: 50%;
animation: ld-sp 1s linear infinite;
vertical-align: -3px;
}
@keyframes ld-sp {
to {
transform: rotate(360deg);
}
}
.ld-toast {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
background: #0c3a2e;
color: #fff;
padding: 12px 20px;
border-radius: 11px;
font-size: 13.5px;
z-index: 60;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.ld-bar-enter-active,
.ld-bar-leave-active,
.ld-toast-enter-active,
.ld-toast-leave-active {
transition:
opacity 0.22s ease,
transform 0.22s ease;
}
.ld-bar-enter-from,
.ld-bar-leave-to {
opacity: 0;
transform: translate(-50%, 12px);
}
.ld-toast-enter-from,
.ld-toast-leave-to {
opacity: 0;
transform: translate(-50%, 16px);
}
+23
View File
@@ -330,6 +330,29 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projec
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
});
// Автоподбор конкурентов — клиентский API (Task 17a).
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () {
Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state');
Route::get('/field', 'App\Http\Controllers\Api\AutopodborController@field');
Route::get('/proposals', 'App\Http\Controllers\Api\AutopodborController@proposals');
Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+');
Route::get('/runs/{run}/competitors', 'App\Http\Controllers\Api\AutopodborController@runCompetitors')->where('run', '[0-9]+');
Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+');
Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search');
Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study');
Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve');
Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects');
Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy');
Route::post('/sources/manual', 'App\Http\Controllers\Api\AutopodborController@addManualSource');
Route::post('/competitors/manual', 'App\Http\Controllers\Api\AutopodborController@manualCompetitor');
Route::patch('/competitors/{competitor}/box', 'App\Http\Controllers\Api\AutopodborController@competitorBox')->where('competitor', '[0-9]+');
Route::patch('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@updateCompetitor')->where('competitor', '[0-9]+');
Route::delete('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@destroyCompetitor')->where('competitor', '[0-9]+');
Route::patch('/sources/{source}/box', 'App\Http\Controllers\Api\AutopodborController@sourceBox')->where('source', '[0-9]+');
Route::patch('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@updateSource')->where('source', '[0-9]+');
Route::delete('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@destroySource')->where('source', '[0-9]+');
});
// Supplier-integration webhook (Plan 2/5, spec §5.1).
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
@@ -0,0 +1,29 @@
<?php
namespace Tests\Doubles;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
final class EmptyCompetitorAgent implements CompetitorAgent
{
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
{
return new FindCompetitorsResult([]);
}
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
{
return new StudyCompetitorResult([]);
}
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
{
return new ResolveByNameResult([]);
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\SystemSetting;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use App\Models\Project;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
beforeEach(fn () => Queue::fake());
it('GET /api/autopodbor/state — доступность, прогоны, цены', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
SystemSetting::updateOrCreate(['key' => 'autopodbor_enabled'], ['value' => '1', 'type' => 'bool']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
$this->actingAs($user)->getJson('/api/autopodbor/state')
->assertOk()
->assertJsonStructure(['enabled', 'runs', 'prices' => ['search', 'study']]);
});
it('POST /api/autopodbor/search — стартует прогон (201)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '1', 'type' => 'decimal']);
$this->actingAs($user)->postJson('/api/autopodbor/search', [
'region_code' => 16, 'examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true,
])->assertCreated()->assertJsonPath('data.kind', 'search');
});
it('GET /api/autopodbor/competitors/{id} — источники с existing_project_id', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]);
$comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']);
AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']);
$this->actingAs($user)->getJson("/api/autopodbor/competitors/{$comp->id}")
->assertOk()
->assertJsonStructure(['data'=>['id','name'], 'sources'=>[['id','signal_type','identifier','existing_project_id']]]);
});
it('POST /api/autopodbor/projects — создаёт проекты из источников (201)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '500000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]);
$comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']);
$s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']);
$this->actingAs($user)->postJson('/api/autopodbor/projects', [
'source_ids'=>[$s1->id], 'regions'=>[16], 'daily_limit_target'=>20, 'delivery_days_mask'=>127, 'launch'=>false,
])->assertCreated();
expect(Project::where('tenant_id',$tenant->id)->where('signal_identifier','okna-komfort.ru')->exists())->toBeTrue();
});
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/** @return array{0: Tenant, 1: User, 2: AutopodborRun, 3: AutopodborCompetitor} */
function boxSetup(): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']);
return [$tenant, $user, $run, $comp];
}
it('PATCH competitors/{id}/box — переносит конкурента в поле и обратно', function () {
[$tenant, $user, $run, $comp] = boxSetup();
$this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'field'])
->assertOk()
->assertJsonPath('data.box', 'field');
expect($comp->fresh()->box)->toBe('field');
$this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'proposal'])
->assertOk()
->assertJsonPath('data.box', 'proposal');
});
it('PATCH competitors/{id}/box — отвергает чужое значение ящика (422)', function () {
[$tenant, $user, $run, $comp] = boxSetup();
$this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'garbage'])
->assertStatus(422);
});
it('PATCH competitors/{id}/box — чужой тенант не видит конкурента (404)', function () {
[$tenant, $user, $run, $comp] = boxSetup();
$other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]);
$this->actingAs($other)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'field'])
->assertStatus(404);
});
it('PATCH sources/{id}/box — переносит источник в работу и обратно', function () {
[$tenant, $user, $run, $comp] = boxSetup();
$src = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru',
]);
$this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}/box", ['box' => 'field'])
->assertOk()
->assertJsonPath('data.box', 'field');
expect($src->fresh()->box)->toBe('field');
});
it('PATCH sources/{id}/box — чужой тенант не видит источник (404)', function () {
[$tenant, $user, $run, $comp] = boxSetup();
$src = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru',
]);
$other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]);
$this->actingAs($other)->patchJson("/api/autopodbor/sources/{$src->id}/box", ['box' => 'field'])
->assertStatus(404);
});
@@ -0,0 +1,65 @@
<?php
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Tenant;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function bsTenantRun(): array
{
$tenant = Tenant::factory()->create();
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done',
'region_code' => 16, 'params' => [],
]);
return [$tenant, $run];
}
it('конкурент по умолчанию в ящике «предложение» и переводится в «поле»', function () {
[$tenant, $run] = bsTenantRun();
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Окна Комфорт', 'dedup_key' => 'okna-komfort',
]);
expect($comp->fresh()->box)->toBe('proposal');
$comp->update(['box' => 'field']);
expect($comp->fresh()->box)->toBe('field');
});
it('источник по умолчанию в ящике «предложение» и переводится в «поле»', function () {
[$tenant, $run] = bsTenantRun();
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Окна Комфорт', 'dedup_key' => 'okna-komfort',
]);
$src = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru',
]);
expect($src->fresh()->box)->toBe('proposal');
$src->update(['box' => 'field']);
expect($src->fresh()->box)->toBe('field');
});
it('ящик конкурента не принимает чужое значение (CHECK)', function () {
[$tenant, $run] = bsTenantRun();
expect(fn () => AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'X', 'dedup_key' => 'x', 'box' => 'garbage',
]))->toThrow(QueryException::class);
});
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborRun;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborChargeService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('списывает один раз и идемпотентно по run_id', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'running', 'params' => []]);
$svc = app(AutopodborChargeService::class);
$svc->chargeForRun($run, '300.00');
$svc->chargeForRun($run->fresh(), '300.00'); // повтор НЕ должен списать второй раз
expect((string) $tenant->fresh()->balance_rub)->toBe('700.00')
->and($run->fresh()->price_rub_charged)->not->toBeNull()
->and(BalanceTransaction::where('type', 'autopodbor_charge')->where('related_id', $run->id)->count())->toBe(1);
});
it('не списывает при нехватке баланса (бросает, баланс цел)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100.00']);
DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'running', 'params' => []]);
expect(fn () => app(AutopodborChargeService::class)->chargeForRun($run, '300.00'))
->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class);
expect((string) $tenant->fresh()->balance_rub)->toBe('100.00')
->and(BalanceTransaction::where('related_id', $run->id)->count())->toBe(0);
});
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/** @return array{0: Tenant, 1: User, 2: AutopodborRun, 3: AutopodborCompetitor} */
function compCrudSetup(): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Старое имя', 'dedup_key' => 'old', 'relevance_pct' => 50, 'box' => 'proposal',
]);
return [$tenant, $user, $run, $comp];
}
it('PATCH competitors/{id} — правит поля карточки', function () {
[$tenant, $user, $run, $comp] = compCrudSetup();
$this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}", [
'name' => 'Окна Премиум',
'description' => 'Премиальные окна',
'is_federal' => true,
'relevance_pct' => 88,
'site_url' => 'okna-premium.ru',
])->assertOk()
->assertJsonPath('data.name', 'Окна Премиум')
->assertJsonPath('data.relevance_pct', 88)
->assertJsonPath('data.is_federal', true);
$fresh = $comp->fresh();
expect($fresh->name)->toBe('Окна Премиум')
->and($fresh->site_url)->toBe('okna-premium.ru');
});
it('PATCH competitors/{id} — отвергает похожесть вне 0..100 (422)', function () {
[$tenant, $user, $run, $comp] = compCrudSetup();
$this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}", [
'relevance_pct' => 150,
])->assertStatus(422);
});
it('PATCH competitors/{id} — чужой тенант не правит (404)', function () {
[$tenant, $user, $run, $comp] = compCrudSetup();
$other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]);
$this->actingAs($other)->patchJson("/api/autopodbor/competitors/{$comp->id}", ['name' => 'Взлом'])
->assertStatus(404);
});
it('DELETE competitors/{id} — удаляет конкурента и его источники', function () {
[$tenant, $user, $run, $comp] = compCrudSetup();
$src = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru',
]);
$this->actingAs($user)->deleteJson("/api/autopodbor/competitors/{$comp->id}")
->assertStatus(204);
expect(AutopodborCompetitor::find($comp->id))->toBeNull()
->and(AutopodborSource::find($src->id))->toBeNull();
});
it('DELETE competitors/{id} — блок, если у источника активный проект (409)', function () {
[$tenant, $user, $run, $comp] = compCrudSetup();
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]);
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru',
'created_project_id' => $project->id,
]);
$this->actingAs($user)->deleteJson("/api/autopodbor/competitors/{$comp->id}")
->assertStatus(409)
->assertJsonPath('error', 'has_active_projects');
expect(AutopodborCompetitor::find($comp->id))->not->toBeNull();
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('GET /competitors/{id} — у каждого источника есть ящик и статус проекта', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Окна', 'dedup_key' => 'okna']);
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]);
// источник в работе с активным проектом
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city',
'dedup_key' => 'call:78432001122', 'box' => 'field', 'created_project_id' => $project->id,
]);
// источник-предложение без проекта
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', 'box' => 'proposal',
]);
$resp = $this->actingAs($user)->getJson("/api/autopodbor/competitors/{$comp->id}")
->assertOk()
->assertJsonStructure(['data' => ['id', 'name'], 'sources' => [['id', 'box', 'phone_type', 'project']]]);
$byId = collect($resp->json('sources'))->keyBy('identifier');
expect($byId['78432001122']['box'])->toBe('field')
->and($byId['78432001122']['phone_type'])->toBe('city')
->and($byId['78432001122']['project'])->not->toBeNull()
->and($byId['78432001122']['project']['is_active'])->toBeTrue()
->and($byId['okna.ru']['box'])->toBe('proposal')
->and($byId['okna.ru']['project'])->toBeNull();
});
@@ -0,0 +1,11 @@
<?php
use Illuminate\Support\Facades\DB;
it('создаёт autopodbor_competitors', function () {
expect(DB::getSchemaBuilder()->hasTable('autopodbor_competitors'))->toBeTrue();
expect(DB::getSchemaBuilder()->hasColumns('autopodbor_competitors', [
'id','tenant_id','search_run_id','name','description','is_federal',
'relevance_pct','origin','site_url','directory_urls','provenance',
'dedup_key','study_run_id','studied_at','created_at',
]))->toBeTrue();
});
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborDedup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('находит существующий проект клиента по типу+идентификатору', function () {
$tenant = Tenant::factory()->create();
DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id);
Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'okna.ru']);
$dedup = app(AutopodborDedup::class);
expect($dedup->existingProjectId($tenant->id, 'site', 'https://www.okna.ru/'))->not->toBeNull() // нормализуется к okna.ru
->and($dedup->existingProjectId($tenant->id, 'site', 'drugoy.ru'))->toBeNull();
});
it('дедупит источники внутри списка', function () {
$dedup = app(AutopodborDedup::class);
$unique = $dedup->dedupSources([
['signal_type' => 'call', 'identifier' => '+7 843 200-11-22'],
['signal_type' => 'call', 'identifier' => '88432001122'], // тот же номер
['signal_type' => 'site', 'identifier' => 'www.okna.ru'],
]);
expect($unique)->toHaveCount(2);
expect($unique[0])->toHaveKey('dedup_key');
});
it('дедупит конкурентов', function () {
$dedup = app(AutopodborDedup::class);
$unique = $dedup->dedupCompetitors([
['name' => 'Окна Комфорт', 'site_url' => 'https://okna-komfort.ru/'],
['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'], // тот же домен
['name' => 'Пластика Окон', 'site_url' => 'plastika.ru'],
]);
expect($unique)->toHaveCount(2);
expect($unique[0])->toHaveKey('dedup_key');
});
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('GET /api/autopodbor/field — отдаёт только конкурентов в поле с источниками и счётчиками', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
// конкурент в поле
$fieldComp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Окна Комфорт', 'dedup_key' => 'okna', 'box' => 'field', 'relevance_pct' => 90,
]);
// конкурент в предложениях — НЕ должен попасть
AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Предложенный', 'dedup_key' => 'prop', 'box' => 'proposal',
]);
// активный проект для источника A
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null,
]);
// источник A — в поле, с активным проектом
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $fieldComp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna-a.ru', 'dedup_key' => 'site:okna-a.ru',
'box' => 'field', 'created_project_id' => $project->id,
]);
// источник B — в поле, без проекта
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $fieldComp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna-b.ru', 'dedup_key' => 'site:okna-b.ru',
'box' => 'field',
]);
// источник C — в предложениях, не считаем в поле
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $fieldComp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna-c.ru', 'dedup_key' => 'site:okna-c.ru',
'box' => 'proposal',
]);
$resp = $this->actingAs($user)->getJson('/api/autopodbor/field')
->assertOk()
->assertJsonStructure([
'competitors' => [[
'id', 'name', 'box',
'counters' => ['sources', 'projects_created', 'projects_in_work'],
'sources' => [['id', 'identifier', 'box', 'project']],
]],
]);
$data = $resp->json('competitors');
expect($data)->toHaveCount(1)
->and($data[0]['name'])->toBe('Окна Комфорт');
// счётчики: 2 источника в поле, 1 проект создан, 1 в работе
expect($data[0]['counters']['sources'])->toBe(2)
->and($data[0]['counters']['projects_created'])->toBe(1)
->and($data[0]['counters']['projects_in_work'])->toBe(1);
// источник A несёт статус проекта, источник B — null
$ids = collect($data[0]['sources'])->keyBy('identifier');
expect($ids['okna-a.ru']['project'])->not->toBeNull()
->and($ids['okna-a.ru']['project']['is_active'])->toBeTrue()
->and($ids['okna-b.ru']['project'])->toBeNull();
});
it('GET /api/autopodbor/field — заблокированный по балансу проект не считается «в работе»', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'X', 'dedup_key' => 'x', 'box' => 'field',
]);
$blocked = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => now(),
]);
AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'x.ru', 'dedup_key' => 'site:x.ru',
'box' => 'field', 'created_project_id' => $blocked->id,
]);
$data = $this->actingAs($user)->getJson('/api/autopodbor/field')->assertOk()->json('competitors');
expect($data[0]['counters']['projects_created'])->toBe(1)
->and($data[0]['counters']['projects_in_work'])->toBe(0);
});
it('GET /api/autopodbor/field — чужой тенант своих в поле не видит', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Чужой', 'dedup_key' => 'alien', 'box' => 'field',
]);
$other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]);
$data = $this->actingAs($other)->getJson('/api/autopodbor/field')->assertOk()->json('competitors');
expect($data)->toHaveCount(0);
});
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\SystemSetting;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\BalanceTransaction;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('повторный запуск SearchJob не плодит конкурентов и не списывает дважды', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', 'region_code' => 16, 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true]]);
// первый прогон
app()->call([new RunAutopodborSearchJob($run->id), 'handle']);
$countAfter1 = AutopodborCompetitor::where('search_run_id', $run->id)->count();
$balAfter1 = (string) $tenant->fresh()->balance_rub;
$txAfter1 = BalanceTransaction::where('related_type', AutopodborRun::class)->where('related_id', $run->id)->count();
// имитируем ретрай: сбросим статус в running (как если бы краш был до status=done), competitors уже есть, charge уже сделан
$run->update(['status' => 'running']);
app()->call([new RunAutopodborSearchJob($run->id), 'handle']);
expect(AutopodborCompetitor::where('search_run_id', $run->id)->count())->toBe($countAfter1) // нет дублей
->and((string) $tenant->fresh()->balance_rub)->toBe($balAfter1) // нет второго списания
->and(BalanceTransaction::where('related_type', AutopodborRun::class)->where('related_id', $run->id)->count())->toBe($txAfter1); // одна проводка
expect($run->fresh()->status)->toBe('done');
});
it('done-прогон при повторном dispatch сразу выходит (top-guard)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false]]);
app()->call([new RunAutopodborSearchJob($run->id), 'handle']);
expect(AutopodborCompetitor::where('search_run_id', $run->id)->count())->toBe(0); // ничего не делал
});
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\SystemSetting;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
beforeEach(fn () => Queue::fake());
it('manual-study по сайту создаёт ручного конкурента и study-прогон', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '1', 'type' => 'decimal']);
$this->actingAs($user)->postJson('/api/autopodbor/manual-study', [
'site_url' => 'https://okna-komfort-kzn.ru/contacts', 'region_code' => 16,
])->assertCreated()->assertJsonPath('data.kind', 'study');
DB::statement("SET app.current_tenant_id = ".$tenant->id);
expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('origin', 'manual')->exists())->toBeTrue();
});
it('manual-study без названия и без сайта → 422', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '1', 'type' => 'decimal']);
$this->actingAs($user)->postJson('/api/autopodbor/manual-study', ['region_code' => 16])
->assertStatus(422);
});
it('sources/manual добавляет источник изученному конкуренту', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]);
$comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'study_run_id'=>$run->id,'studied_at'=>now(),'name'=>'Окна Комфорт','origin'=>'auto','dedup_key'=>'okna']);
$this->actingAs($user)->postJson('/api/autopodbor/sources/manual', [
'competitor_id' => $comp->id, 'raw' => 'okna-komfort.ru',
])->assertCreated()->assertJsonPath('data.signal_type', 'site');
expect(AutopodborSource::where('competitor_id', $comp->id)->where('identifier', 'okna-komfort.ru')->exists())->toBeTrue();
});
it('sources/manual телефоном создаёт call-источник', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]);
$comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'study_run_id'=>$run->id,'studied_at'=>now(),'name'=>'Окна','origin'=>'auto','dedup_key'=>'okna2']);
$this->actingAs($user)->postJson('/api/autopodbor/sources/manual', [
'competitor_id' => $comp->id, 'raw' => '+7 (843) 200-11-22',
])->assertCreated()->assertJsonPath('data.signal_type', 'call');
});
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('POST competitors/manual — заводит конкурента сразу в поле без изучения', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', [
'name' => 'Окна Ромашка',
'site_url' => 'romashka.ru',
'description' => 'Местный конкурент',
])->assertCreated()
->assertJsonPath('data.name', 'Окна Ромашка')
->assertJsonPath('data.box', 'field')
->assertJsonPath('data.origin', 'manual');
$comp = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', 'Окна Ромашка')->first();
expect($comp)->not->toBeNull()
->and($comp->box)->toBe('field')
->and($comp->origin)->toBe('manual')
->and($comp->search_run_id)->toBeNull()
->and($comp->study_run_id)->toBeNull(); // изучение НЕ запускалось
});
it('POST competitors/manual — имя обязательно (422)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', [
'site_url' => 'romashka.ru',
])->assertStatus(422);
});
it('POST competitors/manual — конкурент привязан к своему тенанту', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', ['name' => 'Берёзка'])
->assertCreated();
expect(AutopodborCompetitor::where('name', 'Берёзка')->first()->tenant_id)->toBe($tenant->id);
});
@@ -0,0 +1,45 @@
<?php
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('связывает run → competitors → sources', function () {
$tenant = Tenant::factory()->create();
DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id);
$run = AutopodborRun::create([
'tenant_id' => $tenant->id,
'kind' => 'search',
'status' => 'done',
'region_code' => 16,
'params' => [],
]);
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id,
'search_run_id' => $run->id,
'name' => 'Окна Комфорт',
'dedup_key' => 'okna-komfort',
'relevance_pct' => 100,
]);
$src = AutopodborSource::create([
'tenant_id' => $tenant->id,
'competitor_id' => $comp->id,
'study_run_id' => $run->id,
'signal_type' => 'site',
'identifier' => 'okna-komfort.ru',
'dedup_key' => 'site:okna-komfort.ru',
]);
expect($comp->sources()->count())->toBe(1)
->and($comp->searchRun->id)->toBe($run->id)
->and($src->competitor->id)->toBe($comp->id)
->and($run->competitors()->count())->toBe(1);
});
@@ -0,0 +1,77 @@
<?php
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function ptSetup(): array
{
$tenant = Tenant::factory()->create();
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'X', 'dedup_key' => 'x']);
return [$tenant, $run, $comp];
}
it('источник хранит тип номера рядом с меткой коллтрекинга', function () {
[$tenant, $run, $comp] = ptSetup();
$src = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'call', 'identifier' => '78432001122',
'phone_kind' => 'real', 'phone_type' => 'city', 'dedup_key' => 'call:78432001122',
]);
expect($src->fresh()->phone_type)->toBe('city')
->and($src->fresh()->phone_kind)->toBe('real'); // метка коллтрекинга осталась
foreach (['city', 'mobile', 'tollfree'] as $i => $t) {
$s = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'call', 'identifier' => '7900000000'.$i,
'phone_kind' => 'real', 'phone_type' => $t, 'dedup_key' => 'call:t'.$i,
]);
expect($s->fresh()->phone_type)->toBe($t);
}
});
it('у сайта тип номера пустой', function () {
[$tenant, $run, $comp] = ptSetup();
$src = AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru',
]);
expect($src->fresh()->phone_type)->toBeNull();
});
it('тип номера отвергает чужое значение (CHECK)', function () {
[$tenant, $run, $comp] = ptSetup();
expect(fn () => AutopodborSource::create([
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'call', 'identifier' => '78432001100',
'phone_type' => 'garbage', 'dedup_key' => 'call:garbage',
]))->toThrow(QueryException::class);
});
it('FakeCompetitorAgent отдаёт у телефонов и тип номера, и метку коллтрекинга', function () {
$agent = new FakeCompetitorAgent;
$res = $agent->studyCompetitor(new StudyCompetitorRequest(['name' => 'X'], 16));
$calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call'));
expect($calls)->not->toBeEmpty();
foreach ($calls as $c) {
expect($c)->toHaveKey('phone_type')
->and(in_array($c['phone_type'], ['city', 'mobile', 'tollfree'], true))->toBeTrue()
->and($c)->toHaveKey('phone_kind'); // метка коллтрекинга тоже есть
}
});
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use App\Support\SystemSettings;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
it('тарифы доп.услуг по умолчанию: подбор 300 ₽, источники 50 ₽', function () {
expect((string) SystemSettings::get('autopodbor_price_search_rub'))->toBe('300')
->and((string) SystemSettings::get('autopodbor_price_study_rub'))->toBe('50');
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborProjectCreator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('создаёт проекты из выбранных источников с общими настройками', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '500000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']);
$s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru']);
$s2 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'dedup_key' => 'call:78432001122']);
$projects = app(AutopodborProjectCreator::class)->createFromSources($tenant->id, [$s1->id, $s2->id], [
'regions' => [16], 'daily_limit_target' => 20, 'delivery_days_mask' => 127,
], launch: false);
expect($projects)->toHaveCount(2)
->and(Project::where('tenant_id', $tenant->id)->where('signal_identifier', 'okna-komfort.ru')->exists())->toBeTrue()
->and($s1->fresh()->created_project_id)->not->toBeNull();
});
it('разруливает коллизию одинаковых имён суффиксом', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '500000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna2']);
$s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'a.ru', 'dedup_key' => 'site:a.ru']);
$s2 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'b.ru', 'dedup_key' => 'site:b.ru']);
app(AutopodborProjectCreator::class)->createFromSources($tenant->id, [$s1->id, $s2->id], ['regions' => [16], 'daily_limit_target' => 20, 'delivery_days_mask' => 127], false);
$names = Project::where('tenant_id', $tenant->id)->pluck('name')->all();
expect($names)->toContain('Окна Комфорт')
->and(collect($names)->unique()->count())->toBe(2);
});
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('GET /api/autopodbor/proposals — отдаёт только конкурентов-предложения, сорт по похожести', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]);
AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Низкая', 'dedup_key' => 'low', 'box' => 'proposal', 'relevance_pct' => 40]);
AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Высокая', 'dedup_key' => 'high', 'box' => 'proposal', 'relevance_pct' => 95]);
// в поле — не предложение, не должен попасть
AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'В поле', 'dedup_key' => 'fld', 'box' => 'field', 'relevance_pct' => 100]);
$data = $this->actingAs($user)->getJson('/api/autopodbor/proposals')
->assertOk()
->assertJsonStructure(['data' => [['id', 'name', 'box', 'relevance_pct']]])
->json('data');
expect($data)->toHaveCount(2)
->and($data[0]['name'])->toBe('Высокая') // сорт по похожести
->and($data[1]['name'])->toBe('Низкая');
});
it('GET /api/autopodbor/proposals — чужой тенант своих не видит', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]);
AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Чужой', 'dedup_key' => 'a', 'box' => 'proposal']);
$other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]);
$data = $this->actingAs($other)->getJson('/api/autopodbor/proposals')->assertOk()->json('data');
expect($data)->toHaveCount(0);
});
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
beforeEach(fn () => Queue::fake());
it('GET /api/autopodbor/runs/{run}/competitors отдаёт конкурентов прогона по убыванию релевантности', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]);
AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Б','relevance_pct'=>60,'dedup_key'=>'b']);
AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'А','relevance_pct'=>100,'dedup_key'=>'a']);
$resp = $this->actingAs($user)->getJson("/api/autopodbor/runs/{$run->id}/competitors")
->assertOk()
->assertJsonStructure(['data' => [['id','name','relevance_pct']]]);
$data = $resp->json('data');
expect($data[0]['name'])->toBe('А')->and($data[1]['name'])->toBe('Б'); // 100 раньше 60
});
it('study-прогон в RunResource отдаёт competitor_id', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$search = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]);
$comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$search->id,'name'=>'Окна','dedup_key'=>'o']);
$study = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'competitor_id'=>$comp->id,'params'=>[]]);
$this->actingAs($user)->getJson("/api/autopodbor/runs/{$study->id}")
->assertOk()
->assertJsonPath('data.competitor_id', $comp->id);
});
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborRunService;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('стартует search, создаёт queued-прогон и ставит джобу', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
$run = app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true);
expect($run->kind)->toBe('search')->and($run->status)->toBe('queued');
Queue::assertPushed(RunAutopodborSearchJob::class);
});
it('не стартует второй in-flight search того же tenant', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
$svc = app(AutopodborRunService::class);
$svc->startSearch($tenant->id, 16, ['okna.ru'], [], true);
expect(fn () => $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true))
->toThrow(\App\Exceptions\Autopodbor\RunInFlightException::class);
});
it('гейтит по балансу: нехватка на цену search → InsufficientBalanceException', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '100.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
expect(fn () => app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true))
->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class);
Queue::assertNotPushed(RunAutopodborSearchJob::class);
});
it('startResolve бесплатный: стартует даже при нулевом балансе, ставит resolve-джобу', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = app(AutopodborRunService::class)->startResolve($tenant->id, 'Окна Комфорт', 16);
expect($run->kind)->toBe('resolve')->and($run->status)->toBe('queued');
Queue::assertPushed(RunAutopodborResolveJob::class);
});
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\DB;
it('создаёт таблицу autopodbor_runs с tenant RLS', function () {
expect(DB::getSchemaBuilder()->hasTable('autopodbor_runs'))->toBeTrue();
expect(DB::getSchemaBuilder()->hasColumns('autopodbor_runs', [
'id', 'tenant_id', 'kind', 'status', 'region_code', 'params',
'competitor_id', 'price_rub_charged', 'balance_transaction_id',
'error_code', 'created_at', 'started_at', 'finished_at',
]))->toBeTrue();
});
@@ -0,0 +1,13 @@
<?php
use App\Support\SystemSettings;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
it('сид завёл ключи автоподбора', function () {
expect(SystemSettings::bool('autopodbor_enabled', true))->toBeFalse() // default OFF
->and(SystemSettings::get('autopodbor_max_competitors'))->toBe('15')
->and(\App\Models\SystemSetting::whereKey('autopodbor_price_search_rub')->exists())->toBeTrue()
->and(\App\Models\SystemSetting::whereKey('autopodbor_price_study_rub')->exists())->toBeTrue();
});
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/** @return array{0: Tenant, 1: User, 2: AutopodborRun, 3: AutopodborCompetitor} */
function srcCrudSetup(): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
'name' => 'Окна', 'dedup_key' => 'okna', 'box' => 'field',
]);
return [$tenant, $user, $run, $comp];
}
function makeSource(Tenant $t, AutopodborRun $run, AutopodborCompetitor $comp, array $attrs = []): AutopodborSource
{
return AutopodborSource::create(array_merge([
'tenant_id' => $t->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', 'box' => 'proposal',
], $attrs));
}
it('PATCH sources/{id} — правит значение, провенанс и ящик', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$src = makeSource($tenant, $run, $comp);
$this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [
'identifier' => 'okna-new.ru',
'provenance_label' => 'Сайт компании',
'box' => 'field',
])->assertOk()
->assertJsonPath('data.identifier', 'okna-new.ru')
->assertJsonPath('data.box', 'field');
$fresh = $src->fresh();
expect($fresh->identifier)->toBe('okna-new.ru')
->and($fresh->provenance_label)->toBe('Сайт компании');
});
it('PATCH sources/{id} — тип источника неизменяем (signal_type игнорируется)', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$src = makeSource($tenant, $run, $comp, ['signal_type' => 'site']);
$this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [
'signal_type' => 'call',
'identifier' => 'okna2.ru',
])->assertOk();
expect($src->fresh()->signal_type)->toBe('site'); // тип не сменился
});
it('PATCH sources/{id} — смена значения блокируется при активном проекте (409)', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]);
$src = makeSource($tenant, $run, $comp, ['created_project_id' => $project->id]);
$this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [
'identifier' => 'changed.ru',
])->assertStatus(409)
->assertJsonPath('error', 'manage_via_project');
expect($src->fresh()->identifier)->toBe('okna.ru');
});
it('PATCH sources/{id} — провенанс/ящик можно править даже при активном проекте', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]);
$src = makeSource($tenant, $run, $comp, ['created_project_id' => $project->id, 'box' => 'field']);
$this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [
'provenance_label' => 'Из 2ГИС',
])->assertOk();
expect($src->fresh()->provenance_label)->toBe('Из 2ГИС');
});
it('PATCH sources/{id} — чужой тенант не правит (404)', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$src = makeSource($tenant, $run, $comp);
$other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]);
$this->actingAs($other)->patchJson("/api/autopodbor/sources/{$src->id}", ['identifier' => 'x.ru'])
->assertStatus(404);
});
it('DELETE sources/{id} — удаляет источник без проекта (204)', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$src = makeSource($tenant, $run, $comp);
$this->actingAs($user)->deleteJson("/api/autopodbor/sources/{$src->id}")
->assertStatus(204);
expect(AutopodborSource::find($src->id))->toBeNull();
});
it('DELETE sources/{id} — блок при активном проекте (409)', function () {
[$tenant, $user, $run, $comp] = srcCrudSetup();
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]);
$src = makeSource($tenant, $run, $comp, ['created_project_id' => $project->id]);
$this->actingAs($user)->deleteJson("/api/autopodbor/sources/{$src->id}")
->assertStatus(409)
->assertJsonPath('error', 'has_active_project');
expect(AutopodborSource::find($src->id))->not->toBeNull();
});
@@ -0,0 +1,10 @@
<?php
use Illuminate\Support\Facades\DB;
it('создаёт autopodbor_sources', function () {
expect(DB::getSchemaBuilder()->hasTable('autopodbor_sources'))->toBeTrue();
expect(DB::getSchemaBuilder()->hasColumns('autopodbor_sources', [
'id','tenant_id','competitor_id','study_run_id','signal_type','identifier',
'phone_kind','provenance_url','provenance_label','dedup_key','created_project_id','created_at',
]))->toBeTrue();
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\Tenant;
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
it('резолв по названию: кандидаты с origin=resolve, status=done, без списания', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create([
'tenant_id' => $tenant->id,
'kind' => 'resolve',
'status' => 'queued',
'region_code' => 16,
'params' => ['name' => 'Окна Комфорт'],
]);
app()->call([new RunAutopodborResolveJob($run->id), 'handle']);
expect($run->fresh()->status)->toBe('done')
->and($run->fresh()->price_rub_charged)->toBeNull()
->and(AutopodborCompetitor::where('search_run_id', $run->id)->where('origin', 'resolve')->count())->toBeGreaterThan(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
});
it('пустой резолв: status=empty без списания', function () {
app()->bind(\App\Services\Autopodbor\Agent\CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
$run = AutopodborRun::create([
'tenant_id' => $tenant->id,
'kind' => 'resolve',
'status' => 'queued',
'region_code' => 16,
'params' => ['name' => 'Несуществующая Фирма XYZ'],
]);
app()->call([new RunAutopodborResolveJob($run->id), 'handle']);
expect($run->fresh()->status)->toBe('empty')
->and($run->fresh()->price_rub_charged)->toBeNull();
});
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\Tenant;
use App\Models\SystemSetting;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class);
function runSearchJob(int $runId): void
{
// handle через контейнер (DI зависимостей)
app()->call([new RunAutopodborSearchJob($runId), 'handle']);
}
it('успешный подбор: сохраняет конкурентов, списывает, status=done', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']);
$run = AutopodborRun::create([
'tenant_id' => $tenant->id,
'kind' => 'search',
'status' => 'queued',
'region_code' => 16,
'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true],
]);
runSearchJob($run->id);
expect($run->fresh()->status)->toBe('done')
->and($run->fresh()->price_rub_charged)->toBe('500.00')
->and(AutopodborCompetitor::where('search_run_id', $run->id)->count())->toBeGreaterThan(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});
it('пустой результат: status=empty, без списания', function () {
app()->bind(CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']);
$run = AutopodborRun::create([
'tenant_id' => $tenant->id,
'kind' => 'search',
'status' => 'queued',
'region_code' => 16,
'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false],
]);
runSearchJob($run->id);
expect($run->fresh()->status)->toBe('empty')
->and($run->fresh()->price_rub_charged)->toBeNull()
->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
});
it('повторный подбор не дублирует известных конкурентов и не списывает (сквозной дедуп)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '300', 'type' => 'decimal']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']);
$mk = fn () => AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued',
'region_code' => 16, 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true],
]);
$run1 = $mk();
runSearchJob($run1->id);
$afterFirst = AutopodborCompetitor::where('tenant_id', $tenant->id)->count();
expect($afterFirst)->toBeGreaterThan(0);
$run2 = $mk();
runSearchJob($run2->id);
// Заглушка отдаёт тот же набор → второй прогон не добавляет дублей и не списывает
expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->count())->toBe($afterFirst)
->and($run2->fresh()->status)->toBe('empty')
->and($run2->fresh()->price_rub_charged)->toBeNull()
->and((string) $tenant->fresh()->balance_rub)->toBe('99700.00');
});
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Doubles\EmptyCompetitorAgent;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('успешное изучение: источники + конкурент изучен + списание', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']);
$searchRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $searchRun->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'site:okna-komfort-kzn.ru', 'site_url' => 'okna-komfort-kzn.ru']);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', 'region_code' => 16, 'competitor_id' => $comp->id, 'params' => []]);
app()->call([new RunAutopodborStudyJob($run->id), 'handle']);
expect($run->fresh()->status)->toBe('done')
->and($run->fresh()->price_rub_charged)->toBe('900.00')
->and($comp->fresh()->studied_at)->not->toBeNull()
->and($comp->fresh()->study_run_id)->toBe($run->id)
->and(AutopodborSource::where('competitor_id', $comp->id)->count())->toBeGreaterThan(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('99100.00');
// источники нормализованы (телефоны 7xxxxxxxxxx)
$phone = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'call')->first();
if ($phone) {
expect($phone->identifier)->toMatch('/^7\d{10}$/')
->and(in_array($phone->phone_type, ['city', 'mobile', 'tollfree'], true))->toBeTrue(); // тип номера сохранён
}
});
it('пустой результат: status=empty, без списания', function () {
app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']);
$searchRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]);
$comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $searchRun->id, 'name' => 'Пусто', 'dedup_key' => 'site:empty.ru', 'site_url' => 'empty.ru']);
$run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', 'region_code' => 16, 'competitor_id' => $comp->id, 'params' => []]);
app()->call([new RunAutopodborStudyJob($run->id), 'handle']);
expect($run->fresh()->status)->toBe('empty')
->and($run->fresh()->price_rub_charged)->toBeNull()
->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
});
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/admin');
import AdminAutopodborPricingView from '../../resources/js/views/admin/AdminAutopodborPricingView.vue';
import { listSystemSettings, updateSystemSetting, getPricingTiers } from '../../resources/js/api/admin';
const vuetify = createVuetify();
function settings(search = '300', study = '50') {
return [
{ key: 'autopodbor_price_search_rub', value: search, type: 'decimal', description: null, updated_at: '', updated_by: null },
{ key: 'autopodbor_price_study_rub', value: study, type: 'decimal', description: null, updated_at: '', updated_by: null },
{ key: 'other_key', value: '1', type: 'int', description: null, updated_at: '', updated_by: null },
];
}
function tiers() {
return {
active: [{ tier_no: 1, leads_in_tier: 100, price_per_lead_kopecks: 50000, effective_from: '2026-06-01' }],
scheduled: {},
};
}
function mountV() {
return mount(AdminAutopodborPricingView, { global: { plugins: [vuetify] } });
}
describe('AdminAutopodborPricingView', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listSystemSettings).mockResolvedValue(settings() as any);
vi.mocked(getPricingTiers).mockResolvedValue(tiers() as any);
vi.mocked(updateSystemSetting).mockResolvedValue({} as any);
});
it('грузит текущие тарифы доп.услуг из system-settings', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
expect(listSystemSettings).toHaveBeenCalled();
expect((w.vm as any).searchPrice).toBe('300');
expect((w.vm as any).studyPrice).toBe('50');
});
it('показывает сетку лидов для справки', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Тариф на лиды');
expect(w.text()).toContain('500'); // 50000 коп = 500 ₽
});
it('сохранение изменённой цены зовёт updateSystemSetting с value и reason', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
(w.vm as any).searchPrice = '350';
await (w.vm as any).save();
expect(updateSystemSetting).toHaveBeenCalledWith(
'autopodbor_price_search_rub',
expect.objectContaining({ value: '350' }),
);
expect(updateSystemSetting).not.toHaveBeenCalledWith('autopodbor_price_study_rub', expect.anything());
});
it('причина короче 30 символов блокирует сохранение', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
(w.vm as any).searchPrice = '350';
(w.vm as any).reason = 'мало';
await (w.vm as any).save();
expect(updateSystemSetting).not.toHaveBeenCalled();
expect((w.vm as any).errorMessage).toContain('30');
});
it('без изменений не зовёт сохранение', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
await (w.vm as any).save();
expect(updateSystemSetting).not.toHaveBeenCalled();
expect((w.vm as any).errorMessage).toBeTruthy();
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import CreateScreen from '../../resources/js/views/autopodbor/screens/CreateScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ runId: 5, competitorId: 3, selectedSourceIds: [11, 12], loadMsg: '', loadSub: '', editProjectId: null, createdCount: 0, launched: false }), screen: ref('create') };
}
function seed(store: any) {
store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 };
store.sources = [
{ id: 11, competitor_id: 3, signal_type: 'site', identifier: 'okna-komfort-kzn.ru', phone_kind: null, provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null },
{ id: 12, competitor_id: 3, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null },
];
}
function mountCreate(nav: any) {
return mount(CreateScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('CreateScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('показывает выбранные источники и число проектов', async () => {
const store = useAutopodborStore(); seed(store);
const w = mountCreate(makeNav());
await new Promise(r => setTimeout(r, 0));
expect(w.text()).toContain('okna-komfort-kzn.ru');
expect(w.text()).toContain('Окна Комфорт'); // производное имя
});
it('«Создать (без запуска)» зовёт makeProjects(launch=false) и идёт на done', async () => {
const store = useAutopodborStore(); seed(store);
vi.spyOn(store, 'makeProjects').mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }, { id: 2, name: 'Окна Комфорт ✓' }] as any);
const nav = makeNav();
const w = mountCreate(nav);
(w.vm as any).regionCode = 16;
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('без запуска'));
await btn!.trigger('click');
await new Promise(r => setTimeout(r, 0));
expect(store.makeProjects).toHaveBeenCalled();
const arg = (store.makeProjects as any).mock.calls[0][0];
expect(arg.launch).toBe(false);
expect(arg.source_ids).toEqual([11, 12]);
expect(nav.ctx.createdCount).toBe(2);
expect(nav.go).toHaveBeenCalledWith('done');
});
it('409 нехватки баланса показывает сообщение и возвращает на create', async () => {
const store = useAutopodborStore(); seed(store);
vi.spyOn(store, 'makeProjects').mockRejectedValue({ response: { data: { error: 'balance_insufficient' } } });
const nav = makeNav();
const w = mountCreate(nav);
(w.vm as any).regionCode = 16;
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('запустить'));
await btn!.trigger('click');
await new Promise(r => setTimeout(r, 0));
expect(store.makeProjects).toHaveBeenCalled();
expect(nav.go).toHaveBeenCalledWith('create');
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import DetailScreen from '../../resources/js/views/autopodbor/screens/DetailScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav(competitorId = 3) {
return { go: vi.fn(), ctx: reactive({ runId: null, competitorId, selectedSourceIds: [] as number[], loadMsg: '', loadSub: '', editProjectId: null as number|null }), screen: ref('detail') };
}
function mountDetail(nav: any) {
return mount(DetailScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
function seed(store: any) {
store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 };
store.sources = [
{ id: 11, competitor_id: 3, signal_type: 'site', identifier: 'okna-komfort-kzn.ru', phone_kind: null, provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null },
{ id: 12, competitor_id: 3, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null },
{ id: 13, competitor_id: 3, signal_type: 'call', identifier: '78003507700', phone_kind: 'substitute', provenance_url: null, provenance_label: 'футер', created_project_id: 99, existing_project_id: 99 },
];
}
describe('DetailScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('грузит конкурента и показывает источники', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
const w = mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
expect(store.loadCompetitor).toHaveBeenCalledWith(3);
expect(w.text()).toContain('okna-komfort-kzn.ru');
expect(w.text()).toContain('проект создан'); // источник 13 с existing_project_id
});
it('по умолчанию выбраны источники без проекта (11 и 12, не 13)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
expect(nav.ctx.selectedSourceIds).toContain(11);
expect(nav.ctx.selectedSourceIds).toContain(12);
expect(nav.ctx.selectedSourceIds).not.toContain(13);
});
it('«Создать проекты» ведёт на create', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
const w = mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Создать проекты'));
await btn!.trigger('click');
expect(nav.go).toHaveBeenCalledWith('create');
});
it('«Изменить проект» у созданного источника ведёт на editproject', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
const w = mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Изменить проект'));
await btn!.trigger('click');
expect(nav.ctx.editProjectId).toBe(99);
expect(nav.go).toHaveBeenCalledWith('editproject');
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
import axios from 'axios';
vi.mock('axios');
import EditProjectScreen from '../../resources/js/views/autopodbor/screens/EditProjectScreen.vue';
const vuetify = createVuetify();
function makeNav(editProjectId: number | null = 99) {
return { go: vi.fn(), ctx: reactive({ runId: null, competitorId: 3, selectedSourceIds: [], loadMsg: '', loadSub: '', editProjectId, createdCount: 0, launched: false }), screen: ref('editproject') };
}
function mountEdit(nav: any) {
return mount(EditProjectScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('EditProjectScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('грузит проект и заполняет форму', async () => {
(axios.get as any).mockResolvedValue({ data: { data: { id: 99, name: 'Окна Комфорт 🎭', regions: [16], daily_limit_target: 20, delivery_days_mask: 127, signal_type: 'call', signal_identifier: '78003507700' } } });
const w = mountEdit(makeNav(99));
await new Promise(r => setTimeout(r, 0));
expect(axios.get).toHaveBeenCalledWith('/api/projects/99');
expect((w.vm as any).name).toBe('Окна Комфорт 🎭');
expect((w.vm as any).dailyLimit).toBe(20);
});
it('сохранение шлёт PATCH и возвращает на detail', async () => {
(axios.get as any).mockResolvedValue({ data: { data: { id: 99, name: 'Окна Комфорт 🎭', regions: [16], daily_limit_target: 20, delivery_days_mask: 127 } } });
(axios.patch as any).mockResolvedValue({ data: { data: { id: 99 } } });
const nav = makeNav(99);
const w = mountEdit(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Сохранить'));
await btn!.trigger('click');
await new Promise(r => setTimeout(r, 0));
expect(axios.patch).toHaveBeenCalled();
const [url, body] = (axios.patch as any).mock.calls[0];
expect(url).toBe('/api/projects/99');
expect(body.name).toBe('Окна Комфорт 🎭');
expect(nav.go).toHaveBeenCalledWith('detail');
});
});
@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav(competitorId: number | null = 3) {
return {
go: vi.fn(),
ctx: reactive({ competitorId, editProjectId: null, selectedSourceIds: [] as number[] }),
screen: ref('fieldcompetitor'),
};
}
function src(over: Partial<any> = {}) {
return {
id: 10,
competitor_id: 3,
signal_type: 'site',
identifier: 'okna.ru',
phone_kind: null,
phone_type: null,
box: 'field',
provenance_url: null,
provenance_label: null,
created_project_id: null,
project: null,
...over,
};
}
function proj(over: Partial<any> = {}) {
return {
id: 100, name: 'P', signal_identifier: 'okna.ru', is_active: true, paused_at: null,
preflight_blocked_at: null, daily_limit_target: 5, delivered_in_month: 0, delivery_days_mask: 127, regions: [24],
...over,
};
}
function seed(store: any, sources: any[], comp: Partial<any> = {}) {
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => {
store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 90, ...comp } as any;
store.sources = sources as any;
});
}
function mountFc(nav: any) {
return mount(FieldCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('FieldCompetitorScreen', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('грузит конкурента и источники в работе', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, identifier: 'okna.ru' })]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(store.loadCompetitor).toHaveBeenCalledWith(3);
expect(w.text()).toContain('Окна Комфорт');
expect(w.text()).toContain('okna.ru');
});
it('источник без проекта показывает «Создать проект» и открывает окно создания', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: null })]);
const nav = makeNav(3);
const w = mountFc(nav);
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text() === 'Создать проект');
expect(btn).toBeTruthy();
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Создать проект из источника');
expect(w.text()).toContain('Дни недели приёма');
});
it('активный проект → «Приостановить» зовёт toggleProjectActive(false)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text() === 'Приостановить');
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(100, false);
});
it('телефон показывает значок и тип номера', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 11, signal_type: 'call', identifier: '78432001122', phone_kind: 'substitute', phone_type: 'city' })]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('🎭');
expect(w.text()).toContain('городской');
});
it('вкладка «Предложения» показывает источники-предложения и переносит «В работу»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 12, box: 'proposal', identifier: 'prop.ru' })]);
const moveSpy = vi.spyOn(store, 'moveSourceToBox').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const propTab = w.findAll('button').find((b) => b.text().includes('Предложения'));
await propTab!.trigger('click');
expect(w.text()).toContain('prop.ru');
const btn = w.findAll('button').find((b) => b.text().includes('В источники'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(3, 12, 'field');
});
it('источник с проектом показывает живой источник проекта и меняет через change_source', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, signal_type: 'site', identifier: 'old.ru', project: proj({ id: 100, signal_identifier: 'live.ru', is_active: true }) }),
]);
const changeSpy = vi.spyOn(store, 'changeProjectSource').mockResolvedValue({ source_change_message: 'Лиды дойдут.' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
// карточка показывает источник проекта, а не old.ru
expect(w.text()).toContain('live.ru');
const editBtn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник'));
await editBtn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Сменить источник?');
const input = w.find('.ld-modal input');
await input.setValue('new.ru');
// первый клик «Сохранить» — показывает подтверждение
let save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сохранить');
await save!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Подтвердите смену источника');
// второй клик «Сменить источник» — выполняет change_source
save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сменить источник');
await save!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(changeSpy).toHaveBeenCalledWith(100, 'new.ru');
});
it('массовое «Приостановить выбранные» паузит проекты выбранных источников', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, project: proj({ id: 100, signal_identifier: 'a.ru', is_active: true }) }),
src({ id: 11, identifier: 'b.ru', project: proj({ id: 101, signal_identifier: 'b.ru', is_active: true }) }),
]);
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const boxes = w.findAll('.ld-pick');
await boxes[0].trigger('change');
await boxes[1].trigger('change');
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Приостановить'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(100, false);
expect(toggleSpy).toHaveBeenCalledWith(101, false);
});
it('у изучённого конкурента нет кнопки «Собрать источники», показано «Источники собраны»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: '2026-06-30T00:00:00+00:00' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeFalsy();
expect(w.text()).toContain('Источники собраны');
});
it('неизучённый конкурент показывает кнопку «Собрать источники для меня»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: null });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeTruthy();
});
it('окно «Изменить источник» открывается с залоченным типом', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('тип не меняется');
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import FieldProposalsScreen from '../../resources/js/views/autopodbor/screens/FieldProposalsScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-proposals') };
}
function comp(over: Partial<any> = {}) {
return {
id: 1,
name: 'Окна',
description: 'Окна ПВХ под ключ',
is_federal: false,
relevance_pct: 80,
origin: 'auto',
box: 'proposal',
site_url: 'okna.ru',
directory_urls: ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/1'],
studied_at: null,
study_run_id: null,
search_run_id: 5,
...over,
};
}
function mountP(nav: any) {
return mount(FieldProposalsScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('FieldProposalsScreen', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('грузит предложения и показывает карточку-плитку с похожестью и Справочником', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] as any;
});
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
expect(store.loadProposals).toHaveBeenCalled();
expect(w.find('.ld-card').exists()).toBe(true);
expect(w.text()).toContain('Окна Комфорт');
expect(w.text()).toContain('80');
expect(w.text()).toContain('Справочник');
expect(w.text()).toContain('2ГИС');
expect(w.text()).toContain('Яндекс.Карты');
});
it('«В поле →» по конкуренту зовёт moveCompetitorToBox(field)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 7 })] as any;
});
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
const btn = w.find('.ld-cfoot button');
await btn.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(7, 'field');
});
it('пусто показывает заглушку', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [] as any;
});
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Предложений пока нет');
});
it('«Собрать конкурентов» открывает окно сбора с ценой 300 ₽ (не уходит на старую форму)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockResolvedValue();
store.prices = { search: '300', study: '50' };
const nav = makeNav();
const w = mountP(nav);
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов'));
await btn!.trigger('click');
expect(w.find('.ld-ovl').exists()).toBe(true);
expect(w.text()).toContain('Сбор конкурентов');
expect(w.text()).toContain('300 ₽');
expect(nav.go).not.toHaveBeenCalledWith('autoform');
});
it('массово переносит выбранных в поле при ≥2', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 1 }), comp({ id: 2 })] as any;
});
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
const boxes = w.findAll('.ld-pick');
await boxes[0].trigger('change');
await boxes[1].trigger('change');
expect(w.find('.ld-bulkbar').exists()).toBe(true);
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Перенести'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(1, 'field');
expect(moveSpy).toHaveBeenCalledWith(2, 'field');
});
it('«Изменить» открывает окно правки карточки конкурента', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any;
});
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
const link = w.findAll('.ld-link').find((b) => b.text().includes('Изменить'));
await link!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.find('.ld-ovl').exists()).toBe(true);
expect(w.text()).toContain('карточку конкурента');
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field') };
}
function field(over: Partial<any> = {}) {
return {
id: 1,
name: 'Окна Комфорт',
description: 'd',
is_federal: false,
relevance_pct: 90,
origin: 'auto',
box: 'field',
site_url: 'okna.ru',
directory_urls: [],
studied_at: null,
study_run_id: null,
search_run_id: 5,
counters: { sources: 2, projects_created: 1, projects_in_work: 1 },
sources: [],
...over,
};
}
function mountWs(nav: any) {
return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('FieldWorkspaceScreen', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('грузит поле и показывает конкурентов со счётчиками', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [field()] as any;
});
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
expect(store.loadField).toHaveBeenCalled();
expect(w.text()).toContain('Окна Комфорт');
expect(w.text()).toContain('создано проектов');
});
it('сортирует по похожести: 100% сверху', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [
field({ id: 1, name: 'Низкая', relevance_pct: 40 }),
field({ id: 2, name: 'Высокая', relevance_pct: 100 }),
] as any;
});
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
const names = w.findAll('.ld-card__nm').map((n) => n.text());
expect(names[0]).toContain('Высокая');
});
it('пустое поле показывает заглушку', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [] as any;
});
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('В поле пока пусто');
});
it('«Собрать конкурентов для меня» открывает окно сбора с ценой', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockResolvedValue();
store.prices = { search: '300', study: '50' };
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов для меня'));
await btn!.trigger('click');
expect(w.find('.ld-ovl').exists()).toBe(true);
expect(w.text()).toContain('Сбор конкурентов');
expect(w.text()).toContain('300 ₽');
});
it('«Открыть конкурента» открывает карточку', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [field({ id: 7 })] as any;
});
const nav = makeNav();
const w = mountWs(nav);
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text().includes('Открыть конкурента'));
await btn!.trigger('click');
expect(nav.ctx.competitorId).toBe(7);
expect(nav.go).toHaveBeenCalledWith('fieldcompetitor');
});
it('всплывающая панель показывается при ≥2 выбранных и массово включает проекты', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [
field({ id: 1, sources: [{ id: 10, project: { id: 100, is_active: false } }], counters: { sources: 1, projects_created: 1, projects_in_work: 0 } }),
field({ id: 2, sources: [{ id: 11, project: { id: 101, is_active: false } }], counters: { sources: 1, projects_created: 1, projects_in_work: 0 } }),
] as any;
});
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
const boxes = w.findAll('.ld-pick');
await boxes[0].trigger('change');
await boxes[1].trigger('change');
expect(w.find('.ld-bulkbar').exists()).toBe(true);
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Включить'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(100, true);
expect(toggleSpy).toHaveBeenCalledWith(101, true);
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { nextTick } from 'vue';
// Mock vue-router (DoneScreen uses useRouter)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
}));
vi.mock('axios');
import axios from 'axios';
vi.mock('../../resources/js/api/autopodbor');
import * as api from '../../resources/js/api/autopodbor';
import AutopodborView from '../../resources/js/views/autopodbor/AutopodborView.vue';
const vuetify = createVuetify();
describe('Autopodbor сквозной smoke — все 9 экранов монтируются', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
(api.fetchState as any).mockResolvedValue({ enabled: true, prices: { search: '500', study: '300' }, runs: [] });
(api.fetchField as any).mockResolvedValue([]);
(api.fetchRunCompetitors as any).mockResolvedValue([]);
(api.fetchCompetitor as any).mockResolvedValue({ competitor: { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 }, sources: [] });
(axios.get as any).mockResolvedValue({ data: { data: { id: 99, name: 'Окна Комфорт 🎭', regions: [16], daily_limit_target: 20, delivery_days_mask: 127 } } });
});
const screens = ['field', 'entry', 'autoform', 'manualform', 'loading', 'list', 'detail', 'editproject', 'create', 'done'] as const;
it('все экраны переключаются и монтируются без ошибок', async () => {
const w = mount(AutopodborView, { global: { plugins: [vuetify] } });
await new Promise(r => setTimeout(r, 0));
// предзаполним ctx, чтобы экраны, читающие ctx, не падали
const vm = w.vm as any;
vm.ctx.runId = 5; vm.ctx.competitorId = 3; vm.ctx.editProjectId = 99;
vm.ctx.selectedSourceIds = []; vm.ctx.createdCount = 2; vm.ctx.launched = true;
vm.ctx.loadMsg = 'Идёт работа…'; vm.ctx.loadSub = 'Подождите.';
for (const name of screens) {
vm.go(name);
await nextTick();
await new Promise(r => setTimeout(r, 0));
await nextTick();
// экран отрендерил хоть какой-то контент и не выбросил
expect(vm.screen).toBe(name);
expect(w.html().length).toBeGreaterThan(50);
}
expect(true).toBe(true);
});
it('entry показывает обе двери, done показывает итог', async () => {
const w = mount(AutopodborView, { global: { plugins: [vuetify] } });
await new Promise(r => setTimeout(r, 0));
const vm = w.vm as any;
vm.go('entry');
await nextTick(); await new Promise(r => setTimeout(r, 0));
expect(w.text()).toContain('Подобрать конкурентов');
expect(w.text()).toContain('Указать своего конкурента');
vm.ctx.createdCount = 3; vm.ctx.launched = true;
vm.go('done');
await nextTick(); await new Promise(r => setTimeout(r, 0));
expect(w.text()).toContain('проект'); // «3 проекта создано…»
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import AutoFormScreen from '../../resources/js/views/autopodbor/screens/AutoFormScreen.vue';
import ManualFormScreen from '../../resources/js/views/autopodbor/screens/ManualFormScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ runId: null, competitorId: null, selectedSourceIds: [], loadMsg: '', loadSub: '' }), screen: ref('autoform') };
}
function mountScreen(Comp: any, nav: any) {
return mount(Comp, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('AutoFormScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('подбор: собирает примеры и зовёт store.search, затем go(list)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'search').mockResolvedValue({ id: 7, kind: 'search', status: 'queued' } as any);
vi.spyOn(store, 'pollRun').mockResolvedValue({ id: 7, kind: 'search', status: 'done' } as any);
const nav = makeNav();
const w = mountScreen(AutoFormScreen, nav);
const textInputs = w.findAll('input');
await textInputs[0].setValue('okna-kazan.ru');
(w.vm as any).regionCode = 16;
const submitBtn = w.findAll('button').find(b => b.text().includes('Подобрать'));
await submitBtn!.trigger('click');
await new Promise(r => setTimeout(r, 0));
expect(store.search).toHaveBeenCalled();
const arg = (store.search as any).mock.calls[0][0];
expect(arg.examples).toContain('okna-kazan.ru');
expect(nav.go).toHaveBeenCalledWith('list');
});
});
describe('ManualFormScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('свой конкурент по сайту: зовёт manualStudy и go(detail)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'manualStudy').mockResolvedValue({ id: 9, kind: 'study', status: 'queued', competitor_id: 3 } as any);
vi.spyOn(store, 'pollRun').mockResolvedValue({ id: 9, kind: 'study', status: 'done', competitor_id: 3 } as any);
const nav = makeNav();
const w = mountScreen(ManualFormScreen, nav);
(w.vm as any).regionCode = 16;
const inputs = w.findAll('input');
await inputs[0].setValue('okna-komfort-kzn.ru');
const btn = w.findAll('button').find(b => b.text().includes('Собрать источники'));
await btn!.trigger('click');
await new Promise(r => setTimeout(r, 0));
expect(store.manualStudy).toHaveBeenCalled();
expect(nav.go).toHaveBeenCalledWith('detail');
expect(nav.ctx.competitorId).toBe(3);
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import ListScreen from '../../resources/js/views/autopodbor/screens/ListScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav(runId: number | null = 5) {
return { go: vi.fn(), ctx: reactive({ runId, competitorId: null, selectedSourceIds: [], loadMsg: '', loadSub: '' }), screen: ref('list') };
}
function mountList(nav: any) {
return mount(ListScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('ListScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('загружает и показывает конкурентов прогона', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadRunCompetitors').mockImplementation(async () => {
store.runCompetitors = [
{ id: 1, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, studied_at: '2026-06-28', origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', study_run_id: 9, search_run_id: 5 },
{ id: 2, name: 'Пластика Окон', is_federal: false, relevance_pct: 96, studied_at: null, origin: 'auto', site_url: 'p.ru', directory_urls: [], description: 'd', study_run_id: null, search_run_id: 5 },
] as any;
});
const nav = makeNav(5);
const w = mountList(nav);
await new Promise(r => setTimeout(r, 0));
expect(store.loadRunCompetitors).toHaveBeenCalledWith(5);
expect(w.text()).toContain('Окна Комфорт');
expect(w.text()).toContain('Пластика Окон');
});
it('«Открыть источники» по изученному ведёт на detail', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadRunCompetitors').mockImplementation(async () => {
store.runCompetitors = [{ id: 1, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, studied_at: '2026-06-28', origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', study_run_id: 9, search_run_id: 5 }] as any;
});
const nav = makeNav(5);
const w = mountList(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Открыть источники'));
expect(btn).toBeTruthy();
await btn!.trigger('click');
expect(nav.ctx.competitorId).toBe(1);
expect(nav.go).toHaveBeenCalledWith('detail');
});
it('«Изучить подробнее» по неизученному зовёт store.study', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadRunCompetitors').mockImplementation(async () => {
store.runCompetitors = [{ id: 2, name: 'Пластика Окон', is_federal: false, relevance_pct: 96, studied_at: null, origin: 'auto', site_url: 'p.ru', directory_urls: [], description: 'd', study_run_id: null, search_run_id: 5 }] as any;
});
vi.spyOn(store, 'study').mockResolvedValue({ id: 11, kind: 'study', status: 'queued', competitor_id: 2 } as any);
vi.spyOn(store, 'pollRun').mockResolvedValue({ id: 11, kind: 'study', status: 'done', competitor_id: 2 } as any);
const nav = makeNav(5);
const w = mountList(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Изучить'));
await btn!.trigger('click');
await new Promise(r => setTimeout(r, 0));
expect(store.study).toHaveBeenCalledWith(2);
expect(nav.go).toHaveBeenCalledWith('detail');
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/autopodbor');
import * as api from '../../resources/js/api/autopodbor';
import AutopodborView from '../../resources/js/views/autopodbor/AutopodborView.vue';
const vuetify = createVuetify();
function mountView() {
return mount(AutopodborView, { global: { plugins: [vuetify] } });
}
describe('AutopodborView', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
(api.fetchState as any).mockResolvedValue({ enabled: true, prices: { search: '500', study: '300' }, runs: [] });
(api.fetchField as any).mockResolvedValue([]);
});
it('по умолчанию показывает рабочее место «Конкурентное поле»', async () => {
const w = mountView();
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Конкурентное поле');
expect(w.text()).toContain('Собрать конкурентов');
});
it('«Собрать конкурентов для меня» открывает окно сбора', async () => {
const w = mountView();
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов для меня'));
expect(btn).toBeTruthy();
await btn!.trigger('click');
// открылось модальное окно сбора с правилами заполнения
expect(w.text()).toContain('Как заполнить, чтобы результат был точным');
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { AxiosError } from 'axios';
import { autopodborErrorMessage } from '../../resources/js/api/autopodbor';
function axErr(status: number, body: unknown): AxiosError {
const e = new AxiosError('err');
// @ts-expect-error — минимальный мок ответа
e.response = { status, data: body, statusText: '', headers: {}, config: {} };
return e;
}
describe('autopodborErrorMessage — адресные сообщения по коду ответа', () => {
it('balance_insufficient → про деньги/пополнение', () => {
const m = autopodborErrorMessage(axErr(409, { error: 'balance_insufficient' }), 'fallback');
expect(m.toLowerCase()).toContain('баланс');
expect(m).not.toBe('fallback');
});
it('run_in_flight → «подбор уже идёт»', () => {
const m = autopodborErrorMessage(axErr(409, { error: 'run_in_flight' }), 'fallback');
expect(m.toLowerCase()).toContain('уже идёт');
});
it('name_or_site_required → про название/сайт', () => {
const m = autopodborErrorMessage(axErr(422, { error: 'name_or_site_required' }), 'fallback');
expect(m.toLowerCase()).toContain('назван');
});
it('неизвестный код → fallback', () => {
const m = autopodborErrorMessage(axErr(500, { error: 'boom' }), 'мой fallback');
expect(m).toBe('мой fallback');
});
it('не-axios ошибка → fallback', () => {
const m = autopodborErrorMessage(new Error('x'), 'мой fallback');
expect(m).toBe('мой fallback');
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
vi.mock('../../resources/js/api/autopodbor');
import * as api from '../../resources/js/api/autopodbor';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
describe('autopodborStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('loadState заполняет enabled/prices/runs', async () => {
(api.fetchState as ReturnType<typeof vi.fn>).mockResolvedValue({
enabled: true,
prices: { search: '500', study: '300' },
runs: [{ id: 1, kind: 'search', status: 'done' }],
});
const s = useAutopodborStore();
await s.loadState();
expect(s.enabled).toBe(true);
expect(s.prices.search).toBe('500');
expect(s.runs.length).toBe(1);
});
it('search кладёт currentRun', async () => {
(api.startSearch as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 9, kind: 'search', status: 'queued' });
const s = useAutopodborStore();
await s.search({ region_code: 16, examples: ['okna.ru'], about_self: [], include_federal: true });
expect(api.startSearch).toHaveBeenCalled();
expect(s.currentRun?.id).toBe(9);
});
it('loadCompetitor кладёт competitor и sources', async () => {
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 3, name: 'Окна' },
sources: [{ id: 1, signal_type: 'site' }],
});
const s = useAutopodborStore();
await s.loadCompetitor(3);
expect(s.competitor?.id).toBe(3);
expect(s.sources.length).toBe(1);
});
it('pollRun опрашивает до терминального статуса', async () => {
vi.useFakeTimers();
(api.fetchRun as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ id: 5, kind: 'search', status: 'running' })
.mockResolvedValueOnce({ id: 5, kind: 'search', status: 'done' });
const s = useAutopodborStore();
const p = s.pollRun(5);
// прокрутить таймеры и микрозадачи
await vi.runAllTimersAsync();
const final = await p;
expect(final.status).toBe('done');
expect(s.currentRun?.status).toBe('done');
vi.useRealTimers();
});
it('makeProjects возвращает созданные проекты', async () => {
(api.createProjects as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }]);
const s = useAutopodborStore();
const res = await s.makeProjects({
source_ids: [1],
regions: [16],
daily_limit_target: 20,
delivery_days_mask: 127,
launch: false,
});
expect(res).toHaveLength(1);
});
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
it('loadField кладёт конкурентов поля', async () => {
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 7, name: 'Окна', box: 'field', counters: { sources: 2, projects_created: 1, projects_in_work: 1 }, sources: [] },
]);
const s = useAutopodborStore();
await s.loadField();
expect(s.field).toHaveLength(1);
expect(s.field[0].counters.projects_in_work).toBe(1);
});
it('moveCompetitorToBox в proposal убирает конкурента из поля', async () => {
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 7, name: 'Окна', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
]);
(api.setCompetitorBox as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 7, box: 'proposal' });
const s = useAutopodborStore();
await s.loadField();
await s.moveCompetitorToBox(7, 'proposal');
expect(api.setCompetitorBox).toHaveBeenCalledWith(7, 'proposal');
expect(s.field.find((c) => c.id === 7)).toBeUndefined();
});
it('removeCompetitor убирает конкурента из поля', async () => {
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 7, name: 'Окна', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
]);
(api.deleteCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const s = useAutopodborStore();
await s.loadField();
await s.removeCompetitor(7);
expect(api.deleteCompetitor).toHaveBeenCalledWith(7);
expect(s.field).toHaveLength(0);
});
it('editCompetitor обновляет поля конкурента на месте', async () => {
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 7, name: 'Старое', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
]);
(api.updateCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 7, name: 'Новое', relevance_pct: 88 });
const s = useAutopodborStore();
await s.loadField();
await s.editCompetitor(7, { name: 'Новое', relevance_pct: 88 });
expect(s.field[0].name).toBe('Новое');
expect(s.field[0].relevance_pct).toBe(88);
});
it('addFieldCompetitor добавляет нового конкурента в поле', async () => {
(api.createManualCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 99, name: 'Ромашка', box: 'field', origin: 'manual',
});
const s = useAutopodborStore();
const c = await s.addFieldCompetitor({ name: 'Ромашка' });
expect(c.id).toBe(99);
expect(s.field.find((x) => x.id === 99)?.name).toBe('Ромашка');
});
it('changeProjectSource зовёт ручку проектов и возвращает сообщение', async () => {
(api.changeProjectSource as ReturnType<typeof vi.fn>).mockResolvedValue({
source_change_message: 'Лиды по старому источнику придут до 30.06, дальше — по новому.',
});
const s = useAutopodborStore();
const res = await s.changeProjectSource(100, 'new.ru');
expect(api.changeProjectSource).toHaveBeenCalledWith(100, 'new.ru');
expect(res.source_change_message).toContain('по новому');
});
it('removeSource убирает источник из карточки конкурента в поле', async () => {
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 7, name: 'Окна', box: 'field',
counters: { sources: 1, projects_created: 0, projects_in_work: 0 },
sources: [{ id: 50, competitor_id: 7, signal_type: 'site', identifier: 'a.ru', box: 'field', project: null }],
},
]);
(api.deleteSource as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const s = useAutopodborStore();
await s.loadField();
await s.removeSource(7, 50);
expect(api.deleteSource).toHaveBeenCalledWith(50);
expect(s.field[0].sources).toHaveLength(0);
});
});

Some files were not shown because too many files have changed in this diff Show More