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 |
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SaasInvoice;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Помечает просроченные неоплаченные счета статусом overdue (Этап 1 «оплата по счёту»).
|
||||
* Только issued → overdue по expires_at; оплаченные/отменённые не трогаются.
|
||||
*/
|
||||
class ExpireInvoicesCommand extends Command
|
||||
{
|
||||
protected $signature = 'invoices:expire';
|
||||
|
||||
protected $description = 'Помечает просроченные неоплаченные счета статусом overdue';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
SaasInvoice::where('status', SaasInvoice::STATUS_ISSUED)
|
||||
->where('expires_at', '<', now())
|
||||
->update(['status' => SaasInvoice::STATUS_OVERDUE]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Autopodbor;
|
||||
|
||||
class RunInFlightException extends \RuntimeException {}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Billing\Invoice\InvoicePaymentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin: список счетов + ручная отметка оплаты (Этап 1 «оплата по счёту»).
|
||||
* Зона saas-admin/admin-db. Зачисление делегируется InvoicePaymentService
|
||||
* (идемпотентно, под tenant RLS-контекстом).
|
||||
*/
|
||||
class AdminInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(private readonly InvoicePaymentService $payments) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min(100, max(10, (int) $request->query('per_page', 25)));
|
||||
|
||||
$query = DB::table('saas_invoices as i')
|
||||
->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id')
|
||||
->select(
|
||||
'i.id', 'i.invoice_number', 'i.amount_total', 'i.status',
|
||||
'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.organization_name as tenant_name', 'i.payer_name'
|
||||
);
|
||||
|
||||
$status = $request->query('status');
|
||||
if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) {
|
||||
$query->where('i.status', $status);
|
||||
}
|
||||
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
if ($search !== '') {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('i.invoice_number', 'ilike', "%{$search}%")
|
||||
->orWhere('i.payer_name', 'ilike', "%{$search}%")
|
||||
->orWhere('t.organization_name', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$page = $query->orderByDesc('i.issued_at')->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => array_map(static fn ($r) => (array) $r, $page->items()),
|
||||
'meta' => [
|
||||
'current_page' => $page->currentPage(),
|
||||
'last_page' => $page->lastPage(),
|
||||
'total' => $page->total(),
|
||||
'per_page' => $page->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function markPaid(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->payments->markPaid($id);
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -307,14 +307,7 @@ class BillingController extends Controller
|
||||
$rows = DB::table('saas_invoices')
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('issued_at', 'desc')
|
||||
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']);
|
||||
|
||||
// Какие счета уже имеют закрывающий документ (акт) — для кнопки «Скачать акт».
|
||||
$actInvoiceIds = DB::table('saas_upd_documents')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('invoice_id')
|
||||
->pluck('invoice_id')
|
||||
->flip();
|
||||
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(static fn (\stdClass $r): array => [
|
||||
@@ -323,11 +316,7 @@ class BillingController extends Controller
|
||||
'amount_total' => $r->amount_total,
|
||||
'status' => $r->status,
|
||||
'issued_at' => $r->issued_at,
|
||||
'expires_at' => $r->expires_at,
|
||||
'has_pdf' => $r->pdf_path !== null,
|
||||
'has_act' => isset($actInvoiceIds[$r->id]),
|
||||
'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null,
|
||||
'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SaasInvoice;
|
||||
use App\Models\SaasUpdDocument;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\Invoice\InvoiceService;
|
||||
use App\Services\Billing\Invoice\RequisitesIncompleteException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Клиентские эндпоинты «оплата по счёту» (под middleware auth:sanctum + tenant).
|
||||
* Создание счёта (самообслуживание), скачивание PDF счёта и акта (tenant-scoped).
|
||||
*/
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
public function __construct(private readonly InvoiceService $invoices) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
|
||||
]);
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
|
||||
|
||||
try {
|
||||
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
|
||||
} catch (RequisitesIncompleteException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['invoice' => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'amount_total' => $invoice->amount_total,
|
||||
'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf",
|
||||
]], 201);
|
||||
}
|
||||
|
||||
public function pdf(Request $request, int $id): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
|
||||
abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404);
|
||||
|
||||
return $this->inlinePdf($invoice->pdf_path, 'Schet-'.$invoice->invoice_number);
|
||||
}
|
||||
|
||||
public function act(Request $request, int $id): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
|
||||
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail();
|
||||
abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404);
|
||||
|
||||
return $this->inlinePdf($act->pdf_path, 'Akt-'.$act->upd_number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдать PDF для просмотра в браузере (inline) с ASCII-безопасным именем —
|
||||
* кириллица в Content-Disposition ломала имя файла в браузере (random GUID).
|
||||
*/
|
||||
private function inlinePdf(string $path, string $baseName): Response
|
||||
{
|
||||
$content = Storage::disk('local')->get($path);
|
||||
$filename = Str::ascii($baseName).'.pdf'; // напр. Schet-SCh-2026-00001.pdf
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,9 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Email-уведомление об оплате тарифного счёта (ТЗ §18.5, событие
|
||||
@@ -33,10 +31,6 @@ class InvoicePaidNotification extends Mailable
|
||||
public string $amountRub,
|
||||
public ?string $invoiceNumber,
|
||||
public ?string $tariffName,
|
||||
/** Относительный путь PDF-акта на диске 'local' (для вложения). */
|
||||
public ?string $actPdfPath = null,
|
||||
/** Номер акта — для имени файла вложения. */
|
||||
public ?string $actNumber = null,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
@@ -59,24 +53,4 @@ class InvoicePaidNotification extends Mailable
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложение: PDF закрывающего документа (Акт), если он сформирован.
|
||||
*
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
if ($this->actPdfPath === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$name = 'Akt-'.Str::ascii((string) $this->actNumber).'.pdf';
|
||||
|
||||
return [
|
||||
Attachment::fromStorageDisk('local', $this->actPdfPath)
|
||||
->as($name)
|
||||
->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -19,6 +19,6 @@ class LegalEntity extends Model
|
||||
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
|
||||
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
|
||||
'bank_bik', 'bank_corr', 'director_name', 'director_post',
|
||||
'director_basis', 'vat_mode', 'is_default',
|
||||
'director_basis', 'vat_mode',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Счёт на оплату (schema.sql table saas_invoices). RLS по tenant_id.
|
||||
* Этап 1 «оплата по счёту»: выставляется клиентом, оплачивается банковским
|
||||
* переводом, отмечается администратором (InvoicePaymentService).
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $legal_entity_id
|
||||
* @property string $invoice_number
|
||||
* @property string $payer_type
|
||||
* @property string|null $payer_name
|
||||
* @property string|null $payer_inn
|
||||
* @property string|null $payer_kpp
|
||||
* @property string|null $payer_address
|
||||
* @property string|null $payer_email
|
||||
* @property string $amount_net
|
||||
* @property string|null $vat_rate
|
||||
* @property string|null $vat_amount
|
||||
* @property string $amount_total
|
||||
* @property string|null $payment_purpose
|
||||
* @property int|null $transaction_id
|
||||
* @property string|null $pdf_path
|
||||
* @property string $status
|
||||
* @property Carbon|null $issued_at
|
||||
* @property Carbon|null $expires_at
|
||||
* @property Carbon|null $paid_at
|
||||
* @property Carbon|null $cancelled_at
|
||||
*/
|
||||
class SaasInvoice extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_ISSUED = 'issued';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_OVERDUE = 'overdue';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id', 'legal_entity_id', 'invoice_number',
|
||||
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
|
||||
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
|
||||
'transaction_id', 'pdf_path', 'status',
|
||||
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_net' => 'decimal:2',
|
||||
'vat_amount' => 'decimal:2',
|
||||
'amount_total' => 'decimal:2',
|
||||
'issued_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return HasMany<SaasInvoiceItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(SaasInvoiceItem::class, 'invoice_id');
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Позиция счёта (schema.sql table saas_invoice_items). RLS косвенно через invoice_id.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $invoice_id
|
||||
* @property string $name
|
||||
* @property string|null $okpd2
|
||||
* @property string $quantity
|
||||
* @property string $unit
|
||||
* @property string $price
|
||||
* @property string $amount_net
|
||||
* @property string|null $vat_rate
|
||||
* @property string|null $vat_amount
|
||||
* @property string $amount_total
|
||||
*/
|
||||
class SaasInvoiceItem extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_id', 'name', 'okpd2', 'quantity', 'unit',
|
||||
'price', 'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => 'decimal:3',
|
||||
'price' => 'decimal:2',
|
||||
'amount_net' => 'decimal:2',
|
||||
'amount_total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Закрывающий документ (schema.sql table saas_upd_documents). RLS по tenant_id.
|
||||
* Для УСН без НДС используем upd_function='ДОП' (передаточный документ без
|
||||
* счёта-фактуры) — формируется как Акт об оказании услуг (ActService).
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $legal_entity_id
|
||||
* @property string $upd_number
|
||||
* @property string $upd_function
|
||||
* @property int|null $correction_for
|
||||
* @property string $buyer_type
|
||||
* @property string|null $buyer_name
|
||||
* @property string|null $buyer_inn
|
||||
* @property string|null $buyer_kpp
|
||||
* @property string|null $buyer_address
|
||||
* @property string $amount_net
|
||||
* @property string|null $vat_rate
|
||||
* @property string|null $vat_amount
|
||||
* @property string $amount_total
|
||||
* @property int|null $invoice_id
|
||||
* @property int|null $transaction_id
|
||||
* @property string|null $pdf_path
|
||||
* @property string $status
|
||||
* @property Carbon|null $issued_at
|
||||
*/
|
||||
class SaasUpdDocument extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'saas_upd_documents';
|
||||
|
||||
public const FUNCTION_DOP = 'ДОП';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id', 'legal_entity_id', 'upd_number', 'upd_function', 'correction_for',
|
||||
'buyer_type', 'buyer_name', 'buyer_inn', 'buyer_kpp', 'buyer_address',
|
||||
'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
|
||||
'invoice_id', 'transaction_id', 'pdf_path', 'status', 'issued_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_net' => 'decimal:2',
|
||||
'vat_amount' => 'decimal:2',
|
||||
'amount_total' => 'decimal:2',
|
||||
'issued_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AutopodborServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// v1: заглушка. Реальный движок биндится здесь, когда будет готов.
|
||||
$this->app->bind(CompetitorAgent::class, FakeCompetitorAgent::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult};
|
||||
|
||||
interface CompetitorAgent
|
||||
{
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class FindCompetitorsRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $regionCode,
|
||||
public readonly array $examples,
|
||||
public readonly array $aboutSelf,
|
||||
public readonly bool $includeFederal,
|
||||
public readonly int $maxCompetitors,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class FindCompetitorsResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{name:string,description?:?string,is_federal?:bool,relevance_pct?:?int,site_url?:?string,directory_urls?:array,provenance?:array}> $competitors
|
||||
*/
|
||||
public function __construct(public readonly array $competitors) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class ResolveByNameRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly int $regionCode,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class ResolveByNameResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{name:string,description?:?string,site_url?:?string,directory_urls?:array,provenance?:array}> $candidates
|
||||
*/
|
||||
public function __construct(public readonly array $candidates) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class StudyCompetitorRequest
|
||||
{
|
||||
/**
|
||||
* @param array{name:string,site_url?:?string,directory_urls?:array} $competitor
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $competitor,
|
||||
public readonly int $regionCode,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class StudyCompetitorResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{signal_type:string,identifier:string,phone_kind?:?string,phone_type?:?string,provenance_url?:?string,provenance_label?:?string}> $sources
|
||||
*/
|
||||
public function __construct(public readonly array $sources) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
|
||||
final class FakeCompetitorAgent implements CompetitorAgent
|
||||
{
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
|
||||
{
|
||||
return new FindCompetitorsResult([
|
||||
['name' => 'Окна Комфорт', 'description' => 'Пластиковые окна и остекление балконов под ключ.', 'is_federal' => false, 'relevance_pct' => 100, 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Пластика Окон', 'description' => 'Окна ПВХ, лоджии, входные группы.', 'is_federal' => false, 'relevance_pct' => 96, 'site_url' => 'plastika-okon-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/2'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Фабрика Окон', 'description' => 'Федеральная сеть окон ПВХ, филиал в регионе.', 'is_federal' => true, 'relevance_pct' => 84, 'site_url' => 'fabrika-okon.ru', 'directory_urls' => ['https://2gis.ru/firm/3'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Балкон-Сервис 16', 'description' => 'Остекление балконов; окна частично.', 'is_federal' => false, 'relevance_pct' => 61, 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/4', 'https://2gis.ru/firm/4'], 'provenance' => ['via' => 'similar-pages']],
|
||||
]);
|
||||
}
|
||||
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
|
||||
{
|
||||
return new StudyCompetitorResult([
|
||||
['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
|
||||
['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'],
|
||||
['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
|
||||
['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'phone_type' => 'city', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'],
|
||||
['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'phone_type' => 'mobile', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'],
|
||||
['signal_type' => 'call', 'identifier' => '88002001122', 'phone_kind' => 'real', 'phone_type' => 'tollfree', 'provenance_url' => 'https://okna-komfort-kzn.ru/contacts', 'provenance_label' => 'бесплатная линия 8-800 на сайте'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
|
||||
{
|
||||
return new ResolveByNameResult([
|
||||
['name' => $r->name, 'description' => 'Найдено по названию (заглушка).', 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'name-search']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сервис списания за прогон автоподбора конкурентов.
|
||||
*
|
||||
* Контракт:
|
||||
* - Списание только при готовом результате (by-success).
|
||||
* - Атомарное: весь flow в одной DB-транзакции.
|
||||
* - Идемпотентное: повторный вызов с тем же run не изменяет баланс
|
||||
* (guard по balance_transaction_id).
|
||||
* - bcmath: никаких float-арифметик.
|
||||
*
|
||||
* @throws InsufficientBalanceException если balance_rub < priceRub.
|
||||
* До throw баланс и транзакции не меняются.
|
||||
*/
|
||||
final class AutopodborChargeService
|
||||
{
|
||||
public function chargeForRun(AutopodborRun $run, string $priceRub): void
|
||||
{
|
||||
DB::transaction(function () use ($run, $priceRub): void {
|
||||
// Блокируем run первым — guard идемпотентности
|
||||
/** @var AutopodborRun $locked */
|
||||
$locked = AutopodborRun::whereKey($run->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ($locked->balance_transaction_id !== null) {
|
||||
// Уже списано — идемпотентный возврат без второго списания
|
||||
return;
|
||||
}
|
||||
|
||||
if (bccomp($priceRub, '0', 2) === 0) {
|
||||
// Бесплатный прогон — без ledger-строки; фиксируем факт нулевой стоимости.
|
||||
if ($locked->price_rub_charged === null) {
|
||||
$locked->price_rub_charged = '0.00';
|
||||
$locked->save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Блокируем tenant для атомарного изменения баланса
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
// bcmath: сравниваем с точностью 2 знака
|
||||
if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: (int) bcmul($priceRub, '100', 0),
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
$newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2);
|
||||
|
||||
// Обновляем баланс через DB::table (как в LedgerService) — надёжнее при decimal
|
||||
DB::table('tenants')
|
||||
->where('id', $tenant->id)
|
||||
->update(['balance_rub' => $newBalance]);
|
||||
|
||||
// Записываем транзакцию
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE,
|
||||
'amount_rub' => '-'.$priceRub,
|
||||
'amount_leads' => null,
|
||||
'balance_rub_after' => $newBalance,
|
||||
'balance_leads_after' => null,
|
||||
'related_type' => AutopodborRun::class,
|
||||
'related_id' => $locked->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Фиксируем на run идемпотентный маркер
|
||||
$locked->balance_transaction_id = $tx->id;
|
||||
$locked->price_rub_charged = $priceRub;
|
||||
$locked->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
final class AutopodborDedup
|
||||
{
|
||||
public function __construct(private AutopodborNormalizer $norm) {}
|
||||
|
||||
/**
|
||||
* Ищет существующий проект арендатора с тем же типом и нормализованным идентификатором.
|
||||
* Возвращает id найденного проекта или null.
|
||||
*/
|
||||
public function existingProjectId(int $tenantId, string $signalType, string $identifier): ?int
|
||||
{
|
||||
$needle = $signalType === 'call'
|
||||
? $this->norm->phone($identifier)
|
||||
: $this->norm->domainHead($identifier);
|
||||
|
||||
return Project::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('signal_type', $signalType)
|
||||
->where('signal_identifier', $needle)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедупликация источников внутри переданного списка по нормализованному ключу.
|
||||
* Возвращает уникальные элементы с добавленным полем dedup_key.
|
||||
*
|
||||
* @param array<int, array{signal_type: string, identifier: string}> $sources
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function dedupSources(array $sources): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($sources as $s) {
|
||||
$key = $this->norm->sourceKey($s['signal_type'], $s['identifier']);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$s['dedup_key'] = $key;
|
||||
$out[] = $s;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедупликация конкурентов внутри переданного списка по нормализованному ключу.
|
||||
* Возвращает уникальные элементы с добавленным полем dedup_key.
|
||||
*
|
||||
* @param array<int, array{name: string, site_url?: string|null}> $competitors
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function dedupCompetitors(array $competitors): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($competitors as $c) {
|
||||
$key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$c['dedup_key'] = $key;
|
||||
$out[] = $c;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Support\PhoneNormalizer;
|
||||
|
||||
/**
|
||||
* Нормализует домены и телефоны для дедупликации конкурентов и источников.
|
||||
*/
|
||||
final class AutopodborNormalizer
|
||||
{
|
||||
/**
|
||||
* Возвращает «голову» домена: без схемы, www, пути, порта, нижний регистр.
|
||||
* Примеры:
|
||||
* https://www.Okna-Komfort.RU/contacts → okna-komfort.ru
|
||||
* http://site.ru:8080/path?x=1 → site.ru
|
||||
*/
|
||||
public function domainHead(string $raw): string
|
||||
{
|
||||
$s = trim(mb_strtolower($raw));
|
||||
// Убираем схему (http://, https://, ftp:// и т.п.)
|
||||
$s = preg_replace('#^[a-z]+://#', '', $s);
|
||||
// Убираем www.
|
||||
$s = preg_replace('#^www\.#', '', $s);
|
||||
// Берём только host часть (до первого /)
|
||||
$s = explode('/', $s)[0];
|
||||
// Убираем query string если вдруг осталась
|
||||
$s = explode('?', $s)[0];
|
||||
// Убираем порт
|
||||
$s = explode(':', $s)[0];
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует телефон к виду 7xxxxxxxxxx (11 цифр, без плюса).
|
||||
* Использует существующий PhoneNormalizer::normalize, который возвращает +7XXXXXXXXXX.
|
||||
*/
|
||||
public function phone(string $raw): string
|
||||
{
|
||||
$normalized = PhoneNormalizer::normalize($raw);
|
||||
|
||||
if ($normalized === null) {
|
||||
// Fallback: оставить только цифры и привести к 7xxxxxxxxxx
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
|
||||
return '7' . substr($digits, 1);
|
||||
}
|
||||
if (strlen($digits) === 10) {
|
||||
return '7' . $digits;
|
||||
}
|
||||
return $digits;
|
||||
}
|
||||
|
||||
// PhoneNormalizer возвращает +7XXXXXXXXXX — срезаем ведущий '+'
|
||||
return ltrim($normalized, '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит dedup-ключ для источника (сайт или звонок).
|
||||
* Формат: «type:нормализованный_идентификатор»
|
||||
*/
|
||||
public function sourceKey(string $type, string $identifier): string
|
||||
{
|
||||
$id = $type === 'call'
|
||||
? $this->phone($identifier)
|
||||
: $this->domainHead($identifier);
|
||||
|
||||
return $type . ':' . $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Срезает хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
|
||||
* Если значка нет — строка возвращается без изменений.
|
||||
* Примеры:
|
||||
* 'Окна Комфорт ✓' → 'Окна Комфорт'
|
||||
* 'Окна Комфорт 🎭' → 'Окна Комфорт'
|
||||
* 'Окна Комфорт' → 'Окна Комфорт'
|
||||
* 'Балкон-Сервис 16' → 'Балкон-Сервис 16'
|
||||
*/
|
||||
public function stripBadge(string $name): string
|
||||
{
|
||||
// Срезаем ровно один хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
|
||||
// Используем mb-безопасный regex с флагом u (эмодзи 🎭 — 4-байтный).
|
||||
return preg_replace('/\s*(?:\x{2713}|\x{1F3AD})\s*$/u', '', $name) ?? $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит dedup-ключ для конкурента.
|
||||
* Если есть сайт — «site:домен», иначе «name:имя_в_нижнем_регистре».
|
||||
*/
|
||||
public function competitorKey(string $name, ?string $siteUrl): string
|
||||
{
|
||||
if ($siteUrl !== null) {
|
||||
return 'site:' . $this->domainHead($siteUrl);
|
||||
}
|
||||
|
||||
// Нижний регистр + схлопываем пробелы
|
||||
$normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name)));
|
||||
|
||||
return 'name:' . $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
|
||||
final class AutopodborProjectCreator
|
||||
{
|
||||
public function __construct(private ProjectService $projects) {}
|
||||
|
||||
/**
|
||||
* @param int[] $sourceIds
|
||||
* @param array{regions:int[],daily_limit_target:int,delivery_days_mask:int} $common
|
||||
* @return Project[]
|
||||
*/
|
||||
public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$sources = AutopodborSource::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $sourceIds)->with('competitor')->get();
|
||||
|
||||
$created = [];
|
||||
foreach ($sources as $src) {
|
||||
$name = $this->uniqueName($tenantId, $this->displayName($src));
|
||||
$project = $this->projects->create($tenant, [
|
||||
'name' => $name,
|
||||
'signal_type' => $src->signal_type,
|
||||
'signal_identifier' => $src->identifier,
|
||||
'daily_limit_target' => $common['daily_limit_target'],
|
||||
'regions' => $common['regions'],
|
||||
'delivery_days_mask' => $common['delivery_days_mask'],
|
||||
]);
|
||||
if (! $launch) {
|
||||
$project->update(['is_active' => false, 'paused_at' => now()]);
|
||||
$project = $project->fresh();
|
||||
}
|
||||
$src->update(['created_project_id' => $project->id]);
|
||||
$created[] = $project;
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function displayName(AutopodborSource $s): string
|
||||
{
|
||||
$n = $s->competitor->name;
|
||||
if ($s->signal_type === 'call' && $s->phone_kind === 'real') {
|
||||
return $n.' ✓';
|
||||
}
|
||||
if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') {
|
||||
return $n.' 🎭';
|
||||
}
|
||||
|
||||
return $n;
|
||||
}
|
||||
|
||||
private function uniqueName(int $tenantId, string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (Project::where('tenant_id', $tenantId)->where('name', $name)->exists()) {
|
||||
$i++;
|
||||
$name = $base.' '.$i;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\SystemSettings;
|
||||
|
||||
final class AutopodborRunService
|
||||
{
|
||||
public function __construct(
|
||||
private AutopodborNormalizer $normalizer = new AutopodborNormalizer(),
|
||||
) {}
|
||||
|
||||
private function assertNoInFlight(int $tenantId, string $kind): void
|
||||
{
|
||||
$exists = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->where('kind', $kind)
|
||||
->whereIn('status', ['queued', 'running'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new RunInFlightException();
|
||||
}
|
||||
}
|
||||
|
||||
private function priceGate(int $tenantId, string $key): string
|
||||
{
|
||||
$price = (string) (SystemSettings::get($key) ?? '0');
|
||||
$balance = (string) Tenant::whereKey($tenantId)->value('balance_rub');
|
||||
|
||||
if (bccomp($balance, $price, 2) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: (int) bcmul($price, '100', 0),
|
||||
balanceRub: $balance,
|
||||
);
|
||||
}
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
public function startSearch(
|
||||
int $tenantId,
|
||||
int $regionCode,
|
||||
array $examples,
|
||||
array $aboutSelf,
|
||||
bool $includeFederal,
|
||||
): AutopodborRun {
|
||||
$this->assertNoInFlight($tenantId, 'search');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_search_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'search',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'params' => [
|
||||
'examples' => $examples,
|
||||
'about_self' => $aboutSelf,
|
||||
'include_federal' => $includeFederal,
|
||||
],
|
||||
]);
|
||||
|
||||
RunAutopodborSearchJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function startStudy(int $tenantId, int $competitorId): AutopodborRun
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
|
||||
|
||||
if ($comp->studied_at !== null) {
|
||||
return $comp->studyRun;
|
||||
}
|
||||
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $comp->searchRun?->region_code,
|
||||
'competitor_id' => $comp->id,
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
|
||||
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
|
||||
*
|
||||
* @param array{name:string, site_url:?string, directory_urls:array} $competitorData
|
||||
*/
|
||||
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$comp = AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'search_run_id' => null,
|
||||
'name' => $competitorData['name'],
|
||||
'origin' => 'manual',
|
||||
'relevance_pct' => null,
|
||||
'site_url' => $competitorData['site_url'] ?? null,
|
||||
'directory_urls' => $competitorData['directory_urls'] ?? [],
|
||||
'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null),
|
||||
]);
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'competitor_id' => $comp->id,
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'resolve');
|
||||
// resolve бесплатный — без priceGate
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'resolve',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'params' => ['name' => $name],
|
||||
]);
|
||||
|
||||
RunAutopodborResolveJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Invoice;
|
||||
|
||||
use App\Models\LegalEntity;
|
||||
use App\Models\SaasInvoice;
|
||||
use App\Models\SaasUpdDocument;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Формирует закрывающий документ (Акт об оказании услуг, без НДС, УСН) по
|
||||
* оплаченному счёту. Хранится в saas_upd_documents (upd_function=ДОП — передаточный
|
||||
* документ без счёта-фактуры). PDF — в приватный storage.
|
||||
*/
|
||||
final class ActService
|
||||
{
|
||||
public function __construct(private readonly PdfRenderer $pdf) {}
|
||||
|
||||
public function createForInvoice(SaasInvoice $invoice, int $transactionId): SaasUpdDocument
|
||||
{
|
||||
$seller = LegalEntity::findOrFail($invoice->legal_entity_id);
|
||||
$now = Carbon::now('Europe/Moscow');
|
||||
$number = str_replace('СЧ-', 'АКТ-', (string) $invoice->invoice_number);
|
||||
|
||||
$act = SaasUpdDocument::create([
|
||||
'tenant_id' => $invoice->tenant_id,
|
||||
'legal_entity_id' => $invoice->legal_entity_id,
|
||||
'upd_number' => $number,
|
||||
'upd_function' => SaasUpdDocument::FUNCTION_DOP,
|
||||
'buyer_type' => $invoice->payer_type,
|
||||
'buyer_name' => $invoice->payer_name,
|
||||
'buyer_inn' => $invoice->payer_inn,
|
||||
'buyer_kpp' => $invoice->payer_kpp,
|
||||
'buyer_address' => $invoice->payer_address,
|
||||
'amount_net' => $invoice->amount_total,
|
||||
'vat_rate' => 0,
|
||||
'vat_amount' => 0,
|
||||
'amount_total' => $invoice->amount_total,
|
||||
'invoice_id' => $invoice->id,
|
||||
'transaction_id' => $transactionId,
|
||||
'status' => 'issued',
|
||||
'issued_at' => $now,
|
||||
]);
|
||||
|
||||
$path = $this->pdf->renderToStorage('pdf.act', [
|
||||
'act' => $act,
|
||||
'seller' => $seller,
|
||||
'invoiceNumber' => $invoice->invoice_number,
|
||||
], "acts/{$act->id}-{$number}.pdf");
|
||||
|
||||
$act->pdf_path = $path;
|
||||
$act->save();
|
||||
|
||||
return $act;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Invoice;
|
||||
|
||||
use App\Models\SaasInvoice;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Атомарная нумерация счетов: СЧ-ГГГГ-NNNNN, последовательно по legal_entity_id+год.
|
||||
* Advisory-lock на пару (legal_entity_id, year) сериализует параллельные вызовы;
|
||||
* UNIQUE (legal_entity_id, invoice_number) в схеме — последний барьер от дублей.
|
||||
* Вызывать ВНУТРИ транзакции (xact-lock держится до COMMIT).
|
||||
*/
|
||||
final class InvoiceNumberGenerator
|
||||
{
|
||||
public function next(int $legalEntityId, ?Carbon $now = null): string
|
||||
{
|
||||
$now ??= Carbon::now('Europe/Moscow');
|
||||
$year = (int) $now->year;
|
||||
|
||||
// Advisory lock на пару чисел (legal_entity_id, year) — освобождается на COMMIT.
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?, ?)', [$legalEntityId, $year]);
|
||||
|
||||
$prefix = sprintf('СЧ-%d-', $year);
|
||||
$maxNumber = SaasInvoice::query()
|
||||
->where('legal_entity_id', $legalEntityId)
|
||||
->where('invoice_number', 'like', $prefix.'%')
|
||||
->orderByDesc('invoice_number')
|
||||
->value('invoice_number');
|
||||
|
||||
$seq = 1;
|
||||
if ($maxNumber !== null) {
|
||||
$seq = ((int) substr((string) $maxNumber, strlen($prefix))) + 1;
|
||||
}
|
||||
|
||||
return sprintf('%s%05d', $prefix, $seq);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Invoice;
|
||||
|
||||
use App\Mail\InvoicePaidNotification;
|
||||
use App\Models\SaasInvoice;
|
||||
use App\Models\SaasTransaction;
|
||||
use App\Models\SaasUpdDocument;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно),
|
||||
* зачисление баланса (BillingTopupService), создание акта, письмо клиенту.
|
||||
* Зеркалит идемпотентность и RLS-контекст PaymentWebhookController.
|
||||
*/
|
||||
final class InvoicePaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BillingTopupService $topup,
|
||||
private readonly ActService $acts,
|
||||
) {}
|
||||
|
||||
public function markPaid(int $invoiceId): void
|
||||
{
|
||||
$invoice = SaasInvoice::findOrFail($invoiceId);
|
||||
|
||||
$credited = DB::transaction(function () use ($invoice): bool {
|
||||
// RLS-контекст транзакции (PgBouncer-safe SET LOCAL), как в webhook.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $invoice->tenant_id);
|
||||
|
||||
// Атомарно занимаем issued→paid; 0 строк = уже оплачен (дубль/гонка).
|
||||
$claimed = SaasInvoice::where('id', $invoice->id)
|
||||
->where('status', SaasInvoice::STATUS_ISSUED)
|
||||
->update(['status' => SaasInvoice::STATUS_PAID, 'paid_at' => now()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return false; // идемпотентный no-op
|
||||
}
|
||||
|
||||
$tx = SaasTransaction::create([
|
||||
'tenant_id' => $invoice->tenant_id,
|
||||
'type' => 'topup',
|
||||
'amount_rub' => $invoice->amount_total,
|
||||
'gateway_code' => 'bank_transfer',
|
||||
'payment_method' => 'bank_transfer',
|
||||
'legal_entity_id' => $invoice->legal_entity_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'status' => 'success',
|
||||
'description' => 'Оплата по счёту '.$invoice->invoice_number,
|
||||
'created_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
|
||||
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
|
||||
|
||||
SaasTransaction::where('id', $tx->id)->update([
|
||||
'balance_rub_after' => $balanceTx->balance_rub_after,
|
||||
'balance_transaction_id' => $balanceTx->id,
|
||||
'upd_id' => $act->id,
|
||||
]);
|
||||
SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (! $credited) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Письмо — после COMMIT (избегаем отправки при откате транзакции).
|
||||
// К письму прикладываем PDF-акт (закрывающий документ).
|
||||
$tenant = Tenant::find($invoice->tenant_id);
|
||||
$recipient = User::where('tenant_id', $invoice->tenant_id)->orderBy('id')->first();
|
||||
if ($tenant !== null && $recipient !== null) {
|
||||
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->first();
|
||||
Mail::to($recipient->email)->queue(new InvoicePaidNotification(
|
||||
$recipient,
|
||||
$tenant,
|
||||
(string) $invoice->amount_total,
|
||||
$invoice->invoice_number,
|
||||
null,
|
||||
$act?->pdf_path,
|
||||
$act?->upd_number,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Invoice;
|
||||
|
||||
use App\Models\LegalEntity;
|
||||
use App\Models\SaasInvoice;
|
||||
use App\Models\SaasInvoiceItem;
|
||||
use App\Models\TenantRequisites;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Создание счёта на пополнение баланса (УСН, без НДС). Вызывается из HTTP под
|
||||
* middleware tenant (RLS-контекст). Нумерация атомарна. PDF — в приватный storage.
|
||||
*/
|
||||
final class InvoiceService
|
||||
{
|
||||
/** Наименование услуги в счёте/акте (УСН без НДС). */
|
||||
public const SERVICE_NAME = 'Оплата генерации рекламных лидов';
|
||||
|
||||
public function __construct(
|
||||
private readonly InvoiceNumberGenerator $numbers,
|
||||
private readonly PdfRenderer $pdf,
|
||||
) {}
|
||||
|
||||
public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice
|
||||
{
|
||||
$req = TenantRequisites::where('tenant_id', $tenantId)->first();
|
||||
if ($req === null || blank($req->inn)) {
|
||||
throw new RequisitesIncompleteException('Заполните реквизиты компании, чтобы выставить счёт.');
|
||||
}
|
||||
|
||||
// «Наш» получатель — юрлицо-оператор по флагу is_default; иначе первое.
|
||||
$seller = LegalEntity::where('is_default', true)->first()
|
||||
?? LegalEntity::orderBy('id')->firstOrFail();
|
||||
|
||||
$payerEmail = null;
|
||||
if ($userId !== null) {
|
||||
$email = User::query()->whereKey($userId)->value('email');
|
||||
$payerEmail = is_string($email) && $email !== '' ? $email : null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) {
|
||||
$now = Carbon::now('Europe/Moscow');
|
||||
$number = $this->numbers->next((int) $seller->id, $now);
|
||||
|
||||
$invoice = SaasInvoice::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'legal_entity_id' => $seller->id,
|
||||
'invoice_number' => $number,
|
||||
'payer_type' => $req->subject_type === 'individual' ? 'individual' : 'legal',
|
||||
'payer_name' => $req->legal_name ?? $req->contact_name,
|
||||
'payer_inn' => $req->inn,
|
||||
'payer_kpp' => $req->kpp,
|
||||
'payer_address' => $req->legal_address,
|
||||
'payer_email' => $payerEmail,
|
||||
'amount_net' => $amountRub,
|
||||
'vat_rate' => 0,
|
||||
'vat_amount' => 0,
|
||||
'amount_total' => $amountRub,
|
||||
'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.',
|
||||
'status' => SaasInvoice::STATUS_ISSUED,
|
||||
'issued_at' => $now,
|
||||
'expires_at' => $now->copy()->addWeekdays(5),
|
||||
]);
|
||||
|
||||
SaasInvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'name' => self::SERVICE_NAME,
|
||||
'quantity' => 1,
|
||||
'unit' => 'усл.',
|
||||
'price' => $amountRub,
|
||||
'amount_net' => $amountRub,
|
||||
'vat_rate' => 0,
|
||||
'vat_amount' => 0,
|
||||
'amount_total' => $amountRub,
|
||||
]);
|
||||
|
||||
$path = $this->pdf->renderToStorage('pdf.invoice', [
|
||||
'invoice' => $invoice,
|
||||
'items' => $invoice->items()->get(),
|
||||
'seller' => $seller,
|
||||
], "invoices/{$invoice->id}-{$number}.pdf");
|
||||
|
||||
$invoice->pdf_path = $path;
|
||||
$invoice->save();
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Invoice;
|
||||
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Рендер Blade-шаблона в PDF и сохранение в приватный storage (disk 'local').
|
||||
* Возвращает относительный путь для saas_invoices.pdf_path / saas_upd_documents.pdf_path.
|
||||
*/
|
||||
final class PdfRenderer
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public function renderToStorage(string $view, array $data, string $relativePath): string
|
||||
{
|
||||
$pdf = Pdf::loadView($view, $data)->setPaper('a4');
|
||||
Storage::disk('local')->put($relativePath, $pdf->output());
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Invoice;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Реквизиты компании клиента не заполнены — счёт выставить нельзя.
|
||||
*/
|
||||
final class RequisitesIncompleteException extends RuntimeException {}
|
||||
@@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\AutopodborServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"laravel/framework": "^13.7",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^3.0",
|
||||
|
||||
Generated
+144
-523
@@ -4,85 +4,8 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "da84c833d162bd54a2eff0f338eead8a",
|
||||
"content-hash": "10306f01cb35d564d5004d2202f0c7b3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T08:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
@@ -533,161 +456,6 @@
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
|
||||
},
|
||||
"time": "2026-03-03T13:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.6.0",
|
||||
@@ -2589,73 +2357,6 @@
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
|
||||
},
|
||||
"time": "2026-06-23T18:43:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -4316,86 +4017,6 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.33 || 2.2.2",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
||||
"phpunit/phpunit": "8.5.52",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.4.6",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.3",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
|
||||
},
|
||||
"time": "2026-06-18T15:10:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v7.4.8",
|
||||
@@ -6985,149 +6606,6 @@
|
||||
],
|
||||
"time": "2026-03-30T13:44:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
@@ -15993,6 +15471,149 @@
|
||||
},
|
||||
"time": "2026-02-17T17:25:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
"version": "2.0.1",
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Set some default values. It is possible to add all defines that can be set
|
||||
| in dompdf_config.inc.php. You can also override the entire config file.
|
||||
|
|
||||
*/
|
||||
'show_warnings' => false, // Throw an Exception on warnings from dompdf
|
||||
|
||||
'public_path' => null, // Override the public path if needed
|
||||
|
||||
/*
|
||||
* Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £.
|
||||
*/
|
||||
'convert_entities' => true,
|
||||
|
||||
'options' => [
|
||||
/**
|
||||
* The location of the DOMPDF font directory
|
||||
*
|
||||
* The location of the directory where DOMPDF will store fonts and font metrics
|
||||
* Note: This directory must exist and be writable by the webserver process.
|
||||
* *Please note the trailing slash.*
|
||||
*
|
||||
* Notes regarding fonts:
|
||||
* Additional .afm font metrics can be added by executing load_font.php from command line.
|
||||
*
|
||||
* Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
|
||||
* be embedded in the pdf file or the PDF may not display correctly. This can significantly
|
||||
* increase file size unless font subsetting is enabled. Before embedding a font please
|
||||
* review your rights under the font license.
|
||||
*
|
||||
* Any font specification in the source HTML is translated to the closest font available
|
||||
* in the font directory.
|
||||
*
|
||||
* The pdf standard "Base 14 fonts" are:
|
||||
* Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
|
||||
* Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
|
||||
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
|
||||
* Symbol, ZapfDingbats.
|
||||
*/
|
||||
'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
|
||||
/**
|
||||
* The location of the DOMPDF font cache directory
|
||||
*
|
||||
* This directory contains the cached font metrics for the fonts used by DOMPDF.
|
||||
* This directory can be the same as DOMPDF_FONT_DIR
|
||||
*
|
||||
* Note: This directory must exist and be writable by the webserver process.
|
||||
*/
|
||||
'font_cache' => storage_path('fonts'),
|
||||
|
||||
/**
|
||||
* The location of a temporary directory.
|
||||
*
|
||||
* The directory specified must be writeable by the webserver process.
|
||||
* The temporary directory is required to download remote images and when
|
||||
* using the PDFLib back end.
|
||||
*/
|
||||
'temp_dir' => sys_get_temp_dir(),
|
||||
|
||||
/**
|
||||
* ==== IMPORTANT ====
|
||||
*
|
||||
* dompdf's "chroot": Prevents dompdf from accessing system files or other
|
||||
* files on the webserver. All local files opened by dompdf must be in a
|
||||
* subdirectory of this directory. DO NOT set it to '/' since this could
|
||||
* allow an attacker to use dompdf to read any files on the server. This
|
||||
* should be an absolute path.
|
||||
* This is only checked on command line call by dompdf.php, but not by
|
||||
* direct class use like:
|
||||
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
|
||||
*/
|
||||
'chroot' => realpath(base_path()),
|
||||
|
||||
/**
|
||||
* Protocol whitelist
|
||||
*
|
||||
* Protocols and PHP wrappers allowed in URIs, and the validation rules
|
||||
* that determine if a resouce may be loaded. Full support is not guaranteed
|
||||
* for the protocols/wrappers specified
|
||||
* by this array.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
'allowed_protocols' => [
|
||||
'data://' => ['rules' => []],
|
||||
'file://' => ['rules' => []],
|
||||
'http://' => ['rules' => []],
|
||||
'https://' => ['rules' => []],
|
||||
],
|
||||
|
||||
/**
|
||||
* Operational artifact (log files, temporary files) path validation
|
||||
*/
|
||||
'artifactPathValidation' => null,
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
'log_output_file' => null,
|
||||
|
||||
/**
|
||||
* Whether to enable font subsetting or not.
|
||||
*/
|
||||
'enable_font_subsetting' => false,
|
||||
|
||||
/**
|
||||
* The PDF rendering backend to use
|
||||
*
|
||||
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
|
||||
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
|
||||
* fall back on CPDF. 'GD' renders PDFs to graphic files.
|
||||
* {@link * Canvas_Factory} ultimately determines which rendering class to
|
||||
* instantiate based on this setting.
|
||||
*
|
||||
* Both PDFLib & CPDF rendering backends provide sufficient rendering
|
||||
* capabilities for dompdf, however additional features (e.g. object,
|
||||
* image and font support, etc.) differ between backends. Please see
|
||||
* {@link PDFLib_Adapter} for more information on the PDFLib backend
|
||||
* and {@link CPDF_Adapter} and lib/class.pdf.php for more information
|
||||
* on CPDF. Also see the documentation for each backend at the links
|
||||
* below.
|
||||
*
|
||||
* The GD rendering backend is a little different than PDFLib and
|
||||
* CPDF. Several features of CPDF and PDFLib are not supported or do
|
||||
* not make any sense when creating image files. For example,
|
||||
* multiple pages are not supported, nor are PDF 'objects'. Have a
|
||||
* look at {@link GD_Adapter} for more information. GD support is
|
||||
* experimental, so use it at your own risk.
|
||||
*
|
||||
* @link http://www.pdflib.com
|
||||
* @link http://www.ros.co.nz/pdf
|
||||
* @link http://www.php.net/image
|
||||
*/
|
||||
'pdf_backend' => 'CPDF',
|
||||
|
||||
/**
|
||||
* html target media view which should be rendered into pdf.
|
||||
* List of types and parsing rules for future extensions:
|
||||
* http://www.w3.org/TR/REC-html40/types.html
|
||||
* screen, tty, tv, projection, handheld, print, braille, aural, all
|
||||
* Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
|
||||
* Note, even though the generated pdf file is intended for print output,
|
||||
* the desired content might be different (e.g. screen or projection view of html file).
|
||||
* Therefore allow specification of content here.
|
||||
*/
|
||||
'default_media_type' => 'screen',
|
||||
|
||||
/**
|
||||
* The default paper size.
|
||||
*
|
||||
* North America standard is "letter"; other countries generally "a4"
|
||||
*
|
||||
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
|
||||
*/
|
||||
'default_paper_size' => 'a4',
|
||||
|
||||
/**
|
||||
* The default paper orientation.
|
||||
*
|
||||
* The orientation of the page (portrait or landscape).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
'default_paper_orientation' => 'portrait',
|
||||
|
||||
/**
|
||||
* The default font family
|
||||
*
|
||||
* Used if no suitable fonts can be found. This must exist in the font folder.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
'default_font' => 'dejavu sans',
|
||||
|
||||
/**
|
||||
* Image DPI setting
|
||||
*
|
||||
* This setting determines the default DPI setting for images and fonts. The
|
||||
* DPI may be overridden for inline images by explictly setting the
|
||||
* image's width & height style attributes (i.e. if the image's native
|
||||
* width is 600 pixels and you specify the image's width as 72 points,
|
||||
* the image will have a DPI of 600 in the rendered PDF. The DPI of
|
||||
* background images can not be overridden and is controlled entirely
|
||||
* via this parameter.
|
||||
*
|
||||
* For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
|
||||
* If a size in html is given as px (or without unit as image size),
|
||||
* this tells the corresponding size in pt.
|
||||
* This adjusts the relative sizes to be similar to the rendering of the
|
||||
* html page in a reference browser.
|
||||
*
|
||||
* In pdf, always 1 pt = 1/72 inch
|
||||
*
|
||||
* Rendering resolution of various browsers in px per inch:
|
||||
* Windows Firefox and Internet Explorer:
|
||||
* SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
|
||||
* Linux Firefox:
|
||||
* about:config *resolution: Default:96
|
||||
* (xorg screen dimension in mm and Desktop font dpi settings are ignored)
|
||||
*
|
||||
* Take care about extra font/image zoom factor of browser.
|
||||
*
|
||||
* In images, <img> size in pixel attribute, img css style, are overriding
|
||||
* the real image dimension in px for rendering.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
'dpi' => 96,
|
||||
|
||||
/**
|
||||
* Enable embedded PHP
|
||||
*
|
||||
* If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained
|
||||
* within <script type="text/php"> ... </script> tags.
|
||||
*
|
||||
* ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages)
|
||||
* is a security risk.
|
||||
* Embedded scripts are run with the same level of system access available to dompdf.
|
||||
* Set this option to false (recommended) if you wish to process untrusted documents.
|
||||
* This setting may increase the risk of system exploit.
|
||||
* Do not change this settings without understanding the consequences.
|
||||
* Additional documentation is available on the dompdf wiki at:
|
||||
* https://github.com/dompdf/dompdf/wiki
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
'enable_php' => false,
|
||||
|
||||
/**
|
||||
* Enable inline JavaScript
|
||||
*
|
||||
* If this setting is set to true then DOMPDF will automatically insert JavaScript code contained
|
||||
* within <script type="text/javascript"> ... </script> tags as written into the PDF.
|
||||
* NOTE: This is PDF-based JavaScript to be executed by the PDF viewer,
|
||||
* not browser-based JavaScript executed by Dompdf.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
'enable_javascript' => true,
|
||||
|
||||
/**
|
||||
* Enable remote file access
|
||||
*
|
||||
* If this setting is set to true, DOMPDF will access remote sites for
|
||||
* images and CSS files as required.
|
||||
*
|
||||
* ==== IMPORTANT ====
|
||||
* This can be a security risk, in particular in combination with isPhpEnabled and
|
||||
* allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...);
|
||||
* This allows anonymous users to download legally doubtful internet content which on
|
||||
* tracing back appears to being downloaded by your server, or allows malicious php code
|
||||
* in remote html pages to be executed by your server with your account privileges.
|
||||
*
|
||||
* This setting may increase the risk of system exploit. Do not change
|
||||
* this settings without understanding the consequences. Additional
|
||||
* documentation is available on the dompdf wiki at:
|
||||
* https://github.com/dompdf/dompdf/wiki
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
'enable_remote' => false,
|
||||
|
||||
/**
|
||||
* List of allowed remote hosts
|
||||
*
|
||||
* Each value of the array must be a valid hostname.
|
||||
*
|
||||
* This will be used to filter which resources can be loaded in combination with
|
||||
* isRemoteEnabled. If enable_remote is FALSE, then this will have no effect.
|
||||
*
|
||||
* Leave to NULL to allow any remote host.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
'allowed_remote_hosts' => null,
|
||||
|
||||
/**
|
||||
* A ratio applied to the fonts height to be more like browsers' line height
|
||||
*/
|
||||
'font_height_ratio' => 1.1,
|
||||
|
||||
/**
|
||||
* Use the HTML5 Lib parser
|
||||
*
|
||||
* @deprecated This feature is now always on in dompdf 2.x
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
'enable_html5_parser' => true,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -3227,34 +3227,3 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/ExpireInvoicesTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
|
||||
@@ -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 {
|
||||
@@ -576,39 +576,3 @@ export async function executePdErasure(id: number, adminUserId?: number): Promis
|
||||
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
|
||||
|
||||
export interface AdminInvoiceRow {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
amount_total: string;
|
||||
status: string;
|
||||
issued_at: string;
|
||||
expires_at: string | null;
|
||||
tenant_id: number;
|
||||
tenant_name: string | null;
|
||||
payer_name: string | null;
|
||||
}
|
||||
|
||||
export interface ListAdminInvoicesParams {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
export interface ListAdminInvoicesResponse {
|
||||
data: AdminInvoiceRow[];
|
||||
meta: { current_page: number; last_page: number; total: number; per_page: number };
|
||||
}
|
||||
|
||||
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
|
||||
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function markInvoicePaid(id: number): Promise<void> {
|
||||
await ensureCsrfCookie();
|
||||
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
|
||||
}
|
||||
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
@@ -73,19 +73,7 @@ export interface BillingInvoice {
|
||||
amount_total: string;
|
||||
status: string;
|
||||
issued_at: string;
|
||||
expires_at: string | null;
|
||||
has_pdf: boolean;
|
||||
has_act: boolean;
|
||||
pdf_url: string | null;
|
||||
act_url: string | null;
|
||||
}
|
||||
|
||||
/** Ответ POST /api/billing/invoices — созданный счёт. */
|
||||
export interface CreatedInvoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
amount_total: string;
|
||||
pdf_url: string;
|
||||
}
|
||||
|
||||
/** GET /api/billing/transactions — пагинированная история транзакций. */
|
||||
@@ -94,21 +82,12 @@ export async function getTransactions(params: { page?: number; type?: string }):
|
||||
return data;
|
||||
}
|
||||
|
||||
/** GET /api/billing/invoices — счета тенанта. */
|
||||
/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
|
||||
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
|
||||
const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST /api/billing/invoices — выставить счёт по реквизитам тенанта (оплата по счёту). */
|
||||
export async function createInvoice(amountRub: number): Promise<CreatedInvoice> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ invoice: CreatedInvoice }>('/api/billing/invoices', {
|
||||
amount_rub: amountRub,
|
||||
});
|
||||
return data.invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат POST /api/billing/topup — две формы:
|
||||
* • заглушка (флаг ВЫКЛ): transaction + balance_rub (мгновенное зачисление);
|
||||
|
||||
@@ -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>
|
||||
@@ -61,7 +61,7 @@ defineExpose({ load, invoices });
|
||||
</v-alert>
|
||||
|
||||
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
|
||||
Здесь появятся выставленные вами счета на оплату.
|
||||
Счета появятся после первой оплаты.
|
||||
</div>
|
||||
|
||||
<ul v-else class="invoices-list pa-2 ma-0">
|
||||
@@ -72,30 +72,9 @@ defineExpose({ load, invoices });
|
||||
<span class="sub">{{ statusLabel(inv.status) }}</span>
|
||||
</span>
|
||||
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
|
||||
<span class="inv-actions">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-file-pdf-box"
|
||||
:href="inv.pdf_url ?? undefined"
|
||||
target="_blank"
|
||||
:disabled="!inv.has_pdf"
|
||||
:data-testid="`inv-pdf-${inv.id}`"
|
||||
>
|
||||
Счёт
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="inv.has_act"
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-file-document-check-outline"
|
||||
:href="inv.act_url ?? undefined"
|
||||
target="_blank"
|
||||
:data-testid="`inv-act-${inv.id}`"
|
||||
>
|
||||
Акт
|
||||
</v-btn>
|
||||
</span>
|
||||
<v-btn variant="text" size="small" prepend-icon="mdi-file-pdf-box" :disabled="!inv.has_pdf">
|
||||
PDF
|
||||
</v-btn>
|
||||
</li>
|
||||
</ul>
|
||||
</v-card>
|
||||
@@ -162,9 +141,4 @@ defineExpose({ load, invoices });
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
.inv-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TopupDialog — диалог пополнения рублёвого баланса.
|
||||
* TopupDialog — диалог пополнения рублёвого баланса (audit E1).
|
||||
*
|
||||
* Два способа:
|
||||
* • «Карта» — POST /api/billing/topup (заглушка мгновенного зачисления ИЛИ
|
||||
* редирект на ЮKassa, если флаг billing_yookassa_enabled ВКЛ).
|
||||
* • «По счёту» (для юрлиц) — POST /api/billing/invoices: формирует PDF-счёт по
|
||||
* реквизитам тенанта, баланс пополнится после ручной отметки оплаты админом.
|
||||
* MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
|
||||
* платёжного шлюза — реальная оплата post-Б-1). При успехе эмитит
|
||||
* `success` с новым балансом и закрывается.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { topup, createInvoice } from '../../api/billing';
|
||||
import { topup } from '../../api/billing';
|
||||
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
|
||||
import { redirectTo } from '../../utils/redirect';
|
||||
|
||||
const model = defineModel<boolean>({ required: true });
|
||||
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
|
||||
const emit = defineEmits<{ success: [balanceRub: string] }>();
|
||||
|
||||
const PRESETS = [1000, 5000, 10000, 25000];
|
||||
|
||||
const method = ref<'card' | 'invoice'>('card');
|
||||
const amount = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
@@ -32,14 +29,12 @@ const amountError = computed<string | null>(() => {
|
||||
|
||||
const canSubmit = computed(() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value);
|
||||
|
||||
const submitLabel = computed(() => (method.value === 'invoice' ? 'Сформировать счёт' : 'Пополнить'));
|
||||
|
||||
// Сброс состояния при каждом открытии диалога — нет префилла прошлой суммы/ошибки.
|
||||
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
|
||||
// NewDealDialog) — нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
|
||||
watch(model, (open) => {
|
||||
if (open) {
|
||||
amount.value = null;
|
||||
errorMsg.value = null;
|
||||
method.value = 'card';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,26 +42,11 @@ function setPreset(value: number): void {
|
||||
amount.value = value;
|
||||
}
|
||||
|
||||
function openPdf(url: string): void {
|
||||
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSubmit.value || amount.value === null) return;
|
||||
submitting.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
if (method.value === 'invoice') {
|
||||
const invoice = await createInvoice(amount.value);
|
||||
openPdf(invoice.pdf_url);
|
||||
emit('invoiced', invoice.invoice_number);
|
||||
model.value = false;
|
||||
amount.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await topup(amount.value);
|
||||
// Реальный шлюз (флаг ВКЛ): редирект на страницу оплаты ЮKassa.
|
||||
if (res.confirmation_url) {
|
||||
@@ -91,7 +71,7 @@ function close(): void {
|
||||
errorMsg.value = null;
|
||||
}
|
||||
|
||||
defineExpose({ method, amount, submit, canSubmit, errorMsg });
|
||||
defineExpose({ amount, submit, canSubmit, errorMsg });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,18 +79,6 @@ defineExpose({ method, amount, submit, canSubmit, errorMsg });
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
|
||||
<v-card-text>
|
||||
<v-btn-toggle
|
||||
v-model="method"
|
||||
mandatory
|
||||
density="comfortable"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
data-testid="topup-method"
|
||||
>
|
||||
<v-btn value="card">Картой</v-btn>
|
||||
<v-btn value="invoice">По счёту (для юрлиц)</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-text-field
|
||||
v-model.number="amount"
|
||||
type="number"
|
||||
@@ -127,16 +95,8 @@ defineExpose({ method, amount, submit, canSubmit, errorMsg });
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="method === 'invoice'"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-2"
|
||||
>
|
||||
Счёт сформируется по реквизитам вашей компании. Оплатите его банковским переводом —
|
||||
баланс пополнится после поступления денег. Закрывающий документ (Акт) сформируется
|
||||
автоматически после оплаты.
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
|
||||
Платёжный шлюз подключается после регистрации юр. лица — на текущем этапе баланс пополняется сразу.
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
|
||||
@@ -147,7 +107,7 @@ defineExpose({ method, amount, submit, canSubmit, errorMsg });
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
|
||||
{{ submitLabel }}
|
||||
Пополнить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,8 +29,8 @@ const navItems: NavItem[] = [
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
|
||||
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/admin/invoices' },
|
||||
{ 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',
|
||||
@@ -222,12 +228,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/admin/AdminBillingView.vue'),
|
||||
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
|
||||
},
|
||||
{
|
||||
path: '/admin/invoices',
|
||||
name: 'admin-invoices',
|
||||
component: () => import('../views/admin/AdminInvoicesView.vue'),
|
||||
meta: { layout: 'admin', title: 'Счета', requiresAuth: true, devLabel: 'Admin Invoices' },
|
||||
},
|
||||
{
|
||||
path: '/admin/leads',
|
||||
name: 'admin-leads',
|
||||
@@ -270,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,
|
||||
};
|
||||
});
|
||||
@@ -9,9 +9,10 @@
|
||||
* Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.
|
||||
* TopupDialog «Пополнить баланс» — Task 5 (E1).
|
||||
*/
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
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';
|
||||
@@ -21,7 +22,7 @@ import { getWallet, type Wallet } from '../api/billing';
|
||||
import { extractErrorMessage } from '../api/client';
|
||||
import { useTenantStore } from '../stores/tenantStore';
|
||||
|
||||
const activeView = ref<'overview' | 'charges' | 'invoices'>('overview');
|
||||
const activeView = ref<'overview' | 'charges'>('overview');
|
||||
const tenant = useTenantStore();
|
||||
|
||||
const wallet = ref<Wallet | null>(null);
|
||||
@@ -32,9 +33,6 @@ const topupSnackbar = ref(false);
|
||||
// Возврат с платёжной страницы шлюза (?topup=return): баланс зачислится по webhook.
|
||||
const paymentReturn = ref(false);
|
||||
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
|
||||
const invoicesTableRef = ref<InstanceType<typeof InvoicesTable> | null>(null);
|
||||
const invoiceSnackbar = ref(false);
|
||||
const invoiceMsg = ref('');
|
||||
|
||||
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
|
||||
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
|
||||
@@ -68,16 +66,6 @@ async function onTopupSuccess(): Promise<void> {
|
||||
txTableRef.value?.refresh();
|
||||
}
|
||||
|
||||
async function onInvoiced(invoiceNumber: string): Promise<void> {
|
||||
// Счёт выставлен — переключаемся на вкладку «Счета», обновляем список и тост.
|
||||
topupOpen.value = false;
|
||||
invoiceMsg.value = `Счёт ${invoiceNumber} сформирован. Файл открыт в новой вкладке.`;
|
||||
invoiceSnackbar.value = true;
|
||||
activeView.value = 'invoices';
|
||||
await nextTick();
|
||||
await invoicesTableRef.value?.load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
paymentReturn.value = new URLSearchParams(window.location.search).get('topup') === 'return';
|
||||
void loadWallet();
|
||||
@@ -118,7 +106,6 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
<v-tabs v-model="activeView" color="primary" class="mt-4">
|
||||
<v-tab value="overview">Обзор</v-tab>
|
||||
<v-tab value="charges">Списания</v-tab>
|
||||
<v-tab value="invoices">Счета</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="activeView">
|
||||
@@ -145,23 +132,22 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
|
||||
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
|
||||
|
||||
<AutopodborServicesPanel />
|
||||
|
||||
<TransactionsTable ref="txTableRef" />
|
||||
|
||||
<InvoicesTable />
|
||||
</template>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="charges">
|
||||
<ChargesTab />
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="invoices">
|
||||
<InvoicesTable ref="invoicesTableRef" />
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
|
||||
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" @invoiced="onInvoiced" />
|
||||
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
|
||||
|
||||
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000"> Баланс пополнен. </v-snackbar>
|
||||
<v-snackbar v-model="invoiceSnackbar" color="success" :timeout="6000">{{ invoiceMsg }}</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,262 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AdminInvoicesView — SaaS-admin экран «Счета» (Этап 1 «оплата по счёту»).
|
||||
* Серверная пагинация/поиск/фильтр по статусу. Кнопка «Отметить оплаченным»
|
||||
* у выставленных счетов открывает диалог подтверждения → markInvoicePaid →
|
||||
* зачисление баланса + формирование Акта на бэкенде.
|
||||
*/
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import {
|
||||
listAdminInvoices,
|
||||
markInvoicePaid,
|
||||
type AdminInvoiceRow,
|
||||
} from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Черновик',
|
||||
issued: 'Выставлен',
|
||||
paid: 'Оплачен',
|
||||
overdue: 'Просрочен',
|
||||
cancelled: 'Отменён',
|
||||
};
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
issued: 'info',
|
||||
paid: 'success',
|
||||
overdue: 'warning',
|
||||
cancelled: 'grey',
|
||||
draft: 'grey',
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', title: 'Все статусы' },
|
||||
{ value: 'issued', title: 'Выставленные' },
|
||||
{ value: 'paid', title: 'Оплаченные' },
|
||||
{ value: 'overdue', title: 'Просроченные' },
|
||||
{ value: 'cancelled', title: 'Отменённые' },
|
||||
];
|
||||
|
||||
const rows = ref<AdminInvoiceRow[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref<string | null>(null);
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = ref(25);
|
||||
const total = ref(0);
|
||||
const lastPage = ref(1);
|
||||
const search = ref('');
|
||||
const filterStatus = ref('');
|
||||
|
||||
const confirmOpen = ref(false);
|
||||
const confirmRow = ref<AdminInvoiceRow | null>(null);
|
||||
const marking = ref(false);
|
||||
const snackbar = ref(false);
|
||||
const snackMsg = ref('');
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function statusLabel(s: string): string {
|
||||
return STATUS_LABELS[s] ?? s;
|
||||
}
|
||||
function statusColor(s: string): string {
|
||||
return STATUS_COLORS[s] ?? 'grey';
|
||||
}
|
||||
function formatDate(iso: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' }) : '—';
|
||||
}
|
||||
function formatAmount(v: string): string {
|
||||
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2 }).format(Number(v));
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const res = await listAdminInvoices({
|
||||
status: filterStatus.value || undefined,
|
||||
search: search.value || undefined,
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
});
|
||||
rows.value = res.data;
|
||||
total.value = res.meta.total;
|
||||
lastPage.value = res.meta.last_page;
|
||||
} catch (e) {
|
||||
loadError.value = extractErrorMessage(e, 'Не удалось загрузить счета.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goPage(p: number): void {
|
||||
page.value = p;
|
||||
void load();
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1;
|
||||
void load();
|
||||
}, 400);
|
||||
});
|
||||
|
||||
watch(filterStatus, () => {
|
||||
page.value = 1;
|
||||
void load();
|
||||
});
|
||||
|
||||
function askMarkPaid(row: AdminInvoiceRow): void {
|
||||
confirmRow.value = row;
|
||||
confirmOpen.value = true;
|
||||
}
|
||||
|
||||
async function doMarkPaid(): Promise<void> {
|
||||
if (confirmRow.value === null) return;
|
||||
marking.value = true;
|
||||
try {
|
||||
await markInvoicePaid(confirmRow.value.id);
|
||||
snackMsg.value = `Счёт ${confirmRow.value.invoice_number} отмечен оплаченным, баланс зачислен.`;
|
||||
snackbar.value = true;
|
||||
confirmOpen.value = false;
|
||||
confirmRow.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
snackMsg.value = extractErrorMessage(e, 'Не удалось отметить оплату.');
|
||||
snackbar.value = true;
|
||||
} finally {
|
||||
marking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ rows, page, perPage, total, search, filterStatus, goPage, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container class="invoices-admin" fluid>
|
||||
<div class="page-head mb-4">
|
||||
<h1 class="text-h5 page-title ma-0">Счета</h1>
|
||||
</div>
|
||||
|
||||
<div class="filters mb-4">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Поиск по номеру, клиенту, плательщику"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
style="max-width: 420px"
|
||||
/>
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="STATUS_FILTERS"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="Статус"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
style="max-width: 220px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-card variant="outlined">
|
||||
<div v-if="loading" class="py-10 d-flex justify-center">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="loadError" type="error" variant="tonal" class="ma-4" role="alert">
|
||||
{{ loadError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="py-10 text-center text-medium-emphasis">
|
||||
Счетов не найдено.
|
||||
</div>
|
||||
|
||||
<v-table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Номер</th>
|
||||
<th>Клиент</th>
|
||||
<th>Плательщик</th>
|
||||
<th class="text-right">Сумма, ₽</th>
|
||||
<th>Статус</th>
|
||||
<th>Оплатить до</th>
|
||||
<th class="text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rows" :key="r.id">
|
||||
<td class="num">{{ formatDate(r.issued_at) }}</td>
|
||||
<td class="num">{{ r.invoice_number }}</td>
|
||||
<td>{{ r.tenant_name ?? '—' }}</td>
|
||||
<td>{{ r.payer_name ?? '—' }}</td>
|
||||
<td class="text-right num">{{ formatAmount(r.amount_total) }}</td>
|
||||
<td><v-chip :color="statusColor(r.status)" size="small" variant="tonal">{{ statusLabel(r.status) }}</v-chip></td>
|
||||
<td class="num">{{ formatDate(r.expires_at) }}</td>
|
||||
<td class="text-right">
|
||||
<v-btn
|
||||
v-if="r.status === 'issued' || r.status === 'overdue'"
|
||||
color="success"
|
||||
size="small"
|
||||
variant="flat"
|
||||
:data-testid="`mark-paid-${r.id}`"
|
||||
@click="askMarkPaid(r)"
|
||||
>
|
||||
Отметить оплаченным
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<div v-if="lastPage > 1" class="d-flex justify-center py-4">
|
||||
<v-pagination
|
||||
:model-value="page"
|
||||
:length="lastPage"
|
||||
:total-visible="7"
|
||||
@update:model-value="goPage"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="confirmOpen" max-width="460">
|
||||
<v-card v-if="confirmRow">
|
||||
<v-card-title class="text-h6">Подтверждение оплаты</v-card-title>
|
||||
<v-card-text>
|
||||
Отметить счёт <b>{{ confirmRow.invoice_number }}</b> на сумму
|
||||
<b>{{ formatAmount(confirmRow.amount_total) }} ₽</b>
|
||||
(клиент: {{ confirmRow.tenant_name ?? confirmRow.payer_name ?? '—' }}) как оплаченный?
|
||||
Баланс клиента будет пополнен, сформируется Акт.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="marking" @click="confirmOpen = false">Отмена</v-btn>
|
||||
<v-btn color="success" variant="flat" :loading="marking" @click="doMarkPaid">Подтверждаю</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar" :timeout="5000" color="success">{{ snackMsg }}</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 24;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
</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);
|
||||
}
|
||||
@@ -276,13 +276,7 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-caption">Дни недели приёма</span>
|
||||
<v-btn-toggle
|
||||
v-model="selectedDays"
|
||||
multiple
|
||||
density="comfortable"
|
||||
class="mt-1 day-toggle"
|
||||
selected-class="day-active"
|
||||
>
|
||||
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
|
||||
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
<div class="mt-1">
|
||||
@@ -442,9 +436,9 @@ const reqSaving = ref(false);
|
||||
const reqGeneralError = ref<string | null>(null);
|
||||
|
||||
const subjectTypeItems = [
|
||||
{ value: 'individual', title: 'Физическое лицо' },
|
||||
{ value: 'individual', title: 'Физлицо' },
|
||||
{ value: 'sole_proprietor', title: 'ИП' },
|
||||
{ value: 'legal_entity', title: 'Юридическое лицо' },
|
||||
{ value: 'legal_entity', title: 'Юрлицо' },
|
||||
];
|
||||
|
||||
// Зеркало RequisitesService::isLightComplete — тип лица + имя + телефон (+ ИНН для юр/ИП).
|
||||
@@ -773,12 +767,4 @@ defineExpose({
|
||||
border-color: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
/* Выбранные дни недели — сплошная зелёная заливка, как в ProjectDetailsDrawer (.pdd-day.active) */
|
||||
.day-toggle :deep(.v-btn.day-active) {
|
||||
background-color: #0f6e56;
|
||||
color: #fff;
|
||||
}
|
||||
.day-toggle :deep(.v-btn.day-active .v-btn__overlay) {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,9 +37,9 @@ const lookupMessage = ref('');
|
||||
const lookupError = ref(false);
|
||||
|
||||
const subjectTypes = [
|
||||
{ value: 'individual', label: 'Физическое лицо' },
|
||||
{ value: 'individual', label: 'Физлицо' },
|
||||
{ value: 'sole_proprietor', label: 'ИП' },
|
||||
{ value: 'legal_entity', label: 'Юридическое лицо' },
|
||||
{ value: 'legal_entity', label: 'Юрлицо' },
|
||||
];
|
||||
|
||||
const requiresInn = computed(
|
||||
@@ -49,10 +49,8 @@ const requiresInn = computed(
|
||||
const isLegalEntity = computed(() => form.subject_type === 'legal_entity');
|
||||
const isSoleProprietor = computed(() => form.subject_type === 'sole_proprietor');
|
||||
|
||||
// Блок платёжных реквизитов виден для ИП и юрлица; у физлица банковских реквизитов нет.
|
||||
const showPayment = computed(
|
||||
() => form.subject_type !== null && form.subject_type !== 'individual',
|
||||
);
|
||||
// Блок платёжных реквизитов виден, как только выбран тип лица.
|
||||
const showPayment = computed(() => form.subject_type !== null);
|
||||
// КПП — только юрлицо; ОГРН/ОГРНИП и юр.адрес — юрлицо и ИП; банк — всегда (когда showPayment).
|
||||
const showKpp = computed(() => isLegalEntity.value);
|
||||
const showOgrn = computed(() => isLegalEntity.value || isSoleProprietor.value);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #000; padding: 4px; text-align: left; }
|
||||
th { background: #eee; }
|
||||
h1 { font-size: 15px; margin: 12px 0; }
|
||||
.right { text-align: right; }
|
||||
.sign { margin-top: 30px; }
|
||||
.sign td { border: none; padding: 8px 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Акт № {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}</h1>
|
||||
|
||||
<p><b>Исполнитель:</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}</p>
|
||||
<p><b>Заказчик:</b> {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}{{ $act->buyer_kpp ? ', КПП '.$act->buyer_kpp : '' }}</p>
|
||||
<p><b>Основание:</b> счёт № {{ $invoiceNumber }}</p>
|
||||
|
||||
<table>
|
||||
<tr><th>№</th><th>Наименование услуги</th><th>Сумма</th></tr>
|
||||
<tr><td>1</td><td>Оплата генерации рекламных лидов</td><td>{{ number_format((float) $act->amount_total, 2, '.', ' ') }} ₽</td></tr>
|
||||
</table>
|
||||
|
||||
<p class="right"><b>Всего оказано услуг на сумму: {{ number_format((float) $act->amount_total, 2, '.', ' ') }} ₽</b><br>Без НДС</p>
|
||||
<p>Вышеперечисленные услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам оказания услуг не имеет.</p>
|
||||
|
||||
<table class="sign">
|
||||
<tr>
|
||||
<td style="width:50%">Исполнитель<br><br>_______________ / {{ $seller->director_name ?? $seller->name }}</td>
|
||||
<td style="width:50%">Заказчик<br><br>_______________ / {{ $act->buyer_name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,57 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
.bank td { border: 1px solid #000; padding: 4px; vertical-align: top; }
|
||||
.items th, .items td { border: 1px solid #000; padding: 4px; text-align: left; }
|
||||
.items th { background: #eee; }
|
||||
h1 { font-size: 15px; margin: 12px 0; }
|
||||
.right { text-align: right; }
|
||||
.muted { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table class="bank">
|
||||
<tr>
|
||||
<td rowspan="2" style="width:55%">{{ $seller->bank_name }}</td>
|
||||
<td style="width:15%">БИК</td>
|
||||
<td>{{ $seller->bank_bik }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Сч. №</td>
|
||||
<td>{{ $seller->bank_corr }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Получатель<br>{{ $seller->name }}<br>ИНН {{ $seller->inn }} {{ $seller->kpp ? 'КПП '.$seller->kpp : '' }}</td>
|
||||
<td>Сч. №</td>
|
||||
<td>{{ $seller->bank_account }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h1>Счёт на оплату № {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}</h1>
|
||||
|
||||
<p><b>Поставщик (Исполнитель):</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}{{ $seller->legal_address ? ', '.$seller->legal_address : '' }}</p>
|
||||
<p><b>Покупатель (Заказчик):</b> {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}{{ $invoice->payer_kpp ? ', КПП '.$invoice->payer_kpp : '' }}{{ $invoice->payer_address ? ', '.$invoice->payer_address : '' }}</p>
|
||||
|
||||
<table class="items">
|
||||
<tr><th>№</th><th>Наименование</th><th>Кол-во</th><th>Ед.</th><th>Цена</th><th>Сумма</th></tr>
|
||||
@foreach($items as $i => $it)
|
||||
<tr>
|
||||
<td>{{ $i + 1 }}</td>
|
||||
<td>{{ $it->name }}</td>
|
||||
<td>{{ (int) $it->quantity }}</td>
|
||||
<td>{{ $it->unit }}</td>
|
||||
<td>{{ number_format((float) $it->price, 2, '.', ' ') }}</td>
|
||||
<td>{{ number_format((float) $it->amount_total, 2, '.', ' ') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
<p class="right"><b>Итого: {{ number_format((float) $invoice->amount_total, 2, '.', ' ') }} ₽</b><br>Без НДС</p>
|
||||
<p><b>Назначение платежа:</b> {{ $invoice->payment_purpose }}</p>
|
||||
<p class="muted">Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -104,14 +104,6 @@ Schedule::command('billing:preflight-sweep')
|
||||
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
|
||||
|
||||
// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue.
|
||||
// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов.
|
||||
Schedule::command('invoices:expire')
|
||||
->dailyAt('03:40')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null));
|
||||
|
||||
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
|
||||
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
|
||||
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
|
||||
|
||||
+23
-9
@@ -139,11 +139,6 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
|
||||
// SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1).
|
||||
Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index');
|
||||
Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid')
|
||||
->whereNumber('id');
|
||||
|
||||
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
|
||||
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
|
||||
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
|
||||
@@ -243,9 +238,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun
|
||||
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
|
||||
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
|
||||
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
|
||||
Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store');
|
||||
Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id');
|
||||
Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id');
|
||||
});
|
||||
|
||||
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
|
||||
@@ -338,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
|
||||
@@ -389,7 +404,6 @@ Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт истори
|
||||
Route::view('/admin', 'welcome');
|
||||
Route::view('/admin/tenants', 'welcome');
|
||||
Route::view('/admin/billing', 'welcome');
|
||||
Route::view('/admin/invoices', 'welcome');
|
||||
Route::view('/admin/incidents', 'welcome');
|
||||
Route::view('/admin/system', 'welcome');
|
||||
Route::view('/admin/pricing-tiers', 'welcome');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user