Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b3683c6b1 | |||
| 793b20a39c | |||
| 3561028dd2 | |||
| 4387333118 | |||
| 3d4261cba1 | |||
| ef815c0b8c | |||
| 9b4622da85 | |||
| 23263d18a0 | |||
| 5ba553a0cc | |||
| 48509572b5 | |||
| 3bc4325b78 | |||
| 361d02a256 | |||
| 33ac1a5954 | |||
| 17d93a144b | |||
| aa807c0ed4 | |||
| e52e958484 | |||
| 8cc6511edd | |||
| 02d2163e75 | |||
| 3c8886c97f | |||
| f208fe2f65 | |||
| 98b26f6191 | |||
| d9b3e8dbe1 | |||
| a3b68dbb95 | |||
| 78d1965430 | |||
| 1de6984035 | |||
| 4042890b0a | |||
| 77498df63b | |||
| 6789879a2c | |||
| 3b9c1b8bdc | |||
| 0a111d9f85 | |||
| 3c2bb18537 | |||
| df19af99f9 | |||
| b5c88b2f1d | |||
| 2de1f1e35f | |||
| cc73a70f9e | |||
| 786f796223 | |||
| e7660edd79 | |||
| 1fe071f203 |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
+47
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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> · <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> · <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>Дайте 2–5 примеров конкурентов, которых точно знаете. Чем больше — тем точнее.</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>Дайте 2–5 примеров конкурентов, которых точно знаете. Чем больше — тем точнее.</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">✓ изучен</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)">
|
||||
Открыть источники →<br>
|
||||
<span class="ld-paynote">уже оплачено</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="ld-btn-primary" @click="studyCompetitor(comp)">
|
||||
Изучить подробнее →<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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user