Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aeb6164ed2 | |||
| 07110557a1 | |||
| 0749e13d4e | |||
| d228a501dc | |||
| 097f7673ee | |||
| 2d5f91269a | |||
| e9b4f78ec0 | |||
| 861b969ec4 | |||
| edad482e49 |
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Autopodbor\AutopodborStudyScheduler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AutopodborStudyTickCommand extends Command
|
||||
{
|
||||
protected $signature = 'autopodbor:study-tick';
|
||||
|
||||
protected $description = 'Страховка: если дорожка шага 2 свободна, а очередь не пуста — запустить следующего.';
|
||||
|
||||
public function handle(AutopodborStudyScheduler $scheduler): int
|
||||
{
|
||||
$scheduler->tick();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Autopodbor;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class BatchBudgetException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $topupRub, public readonly int $maxAffordable)
|
||||
{
|
||||
parent::__construct('insufficient_balance_for_batch');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\Autopodbor\BatchBudgetException;
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -167,6 +168,32 @@ class AutopodborController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/study/batch */
|
||||
public function studyBatch(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'competitor_ids' => ['required', 'array', 'min:1'],
|
||||
'competitor_ids.*' => ['integer'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
try {
|
||||
$res = $svc->startStudyBatch($uid, $data['competitor_ids']);
|
||||
} catch (BatchBudgetException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Не хватает баланса для сбора источников по выбранным конкурентам.',
|
||||
'topup_rub' => $e->topupRub,
|
||||
'max_affordable' => $e->maxAffordable,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($res->queued === 0) {
|
||||
return response()->json(['message' => 'Нечего ставить: выбранные конкуренты уже в очереди или не найдены.'], 422);
|
||||
}
|
||||
|
||||
return response()->json(['batch_id' => $res->batch_id, 'queued' => $res->queued], 202);
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/resolve */
|
||||
public function resolve(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\AutopodborChargeService;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Services\Autopodbor\AutopodborStudyScheduler;
|
||||
use App\Services\Autopodbor\RunProgressChannel;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -53,116 +54,126 @@ class RunAutopodborStudyJob implements ShouldQueue
|
||||
AutopodborChargeService $charge,
|
||||
AutopodborNormalizer $norm,
|
||||
RunProgressChannel $progress,
|
||||
AutopodborStudyScheduler $scheduler,
|
||||
): void {
|
||||
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
|
||||
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
|
||||
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
|
||||
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
|
||||
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
|
||||
if ($this->tenantId !== null) {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
|
||||
}
|
||||
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
if ($this->tenantId === null) {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
|
||||
}
|
||||
|
||||
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
|
||||
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->update(['status' => 'running', 'started_at' => now()]);
|
||||
|
||||
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
|
||||
$progress->bind($run->id);
|
||||
|
||||
// Внешний try/finally: распорядитель должен взять следующего кандидата ПОСЛЕ ЛЮБОГО
|
||||
// завершения этого прогона — done/empty/failed, включая ранние return'ы и проброшенное
|
||||
// исключение из внутреннего catch. Дорожка шага 2 одна на весь портал (см.
|
||||
// AutopodborStudyScheduler) — если не звать tick() здесь, освободившаяся дорожка простаивала
|
||||
// бы до следующего крон-тика (autopodbor:study-tick, страховка раз в минуту).
|
||||
try {
|
||||
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
|
||||
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
|
||||
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
|
||||
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
|
||||
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
|
||||
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
|
||||
if ($this->tenantId !== null) {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
|
||||
}
|
||||
|
||||
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
|
||||
competitor: [
|
||||
'name' => $comp->name,
|
||||
'site_url' => $comp->site_url,
|
||||
'directory_urls' => $comp->directory_urls ?? [],
|
||||
'elements' => $comp->elements ?? [],
|
||||
'is_federal' => (bool) $comp->is_federal,
|
||||
],
|
||||
regionCode: (int) $run->region_code,
|
||||
));
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
$unique = $dedup->dedupSources($res->sources);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
if ($this->tenantId === null) {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
|
||||
}
|
||||
|
||||
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
|
||||
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
|
||||
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
|
||||
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
|
||||
$newCount = 0;
|
||||
$refoundDeletedIds = [];
|
||||
foreach ($unique as $s) {
|
||||
$identifier = $s['signal_type'] === 'call'
|
||||
? $norm->phone($s['identifier'])
|
||||
: $norm->domainHead($s['identifier']);
|
||||
$run->update(['status' => 'running', 'started_at' => now()]);
|
||||
|
||||
$existing = AutopodborSource::where('competitor_id', $comp->id)
|
||||
->where('dedup_key', $s['dedup_key'])
|
||||
->first();
|
||||
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
|
||||
$progress->bind($run->id);
|
||||
|
||||
AutopodborSource::updateOrCreate(
|
||||
[
|
||||
'competitor_id' => $comp->id,
|
||||
'dedup_key' => $s['dedup_key'],
|
||||
try {
|
||||
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
|
||||
|
||||
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
|
||||
competitor: [
|
||||
'name' => $comp->name,
|
||||
'site_url' => $comp->site_url,
|
||||
'directory_urls' => $comp->directory_urls ?? [],
|
||||
'elements' => $comp->elements ?? [],
|
||||
'is_federal' => (bool) $comp->is_federal,
|
||||
],
|
||||
[
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'study_run_id' => $run->id,
|
||||
'signal_type' => $s['signal_type'],
|
||||
'identifier' => $identifier,
|
||||
'phone_kind' => $s['phone_kind'] ?? null,
|
||||
'phone_type' => $s['phone_type'] ?? null,
|
||||
'provenance_url' => $s['provenance_url'] ?? null,
|
||||
'provenance_label' => $s['provenance_label'] ?? null,
|
||||
'where_found' => $s['where_found'] ?? null,
|
||||
'office' => $s['office'] ?? null,
|
||||
'confirmations' => $s['confirmations'] ?? 1,
|
||||
]
|
||||
);
|
||||
regionCode: (int) $run->region_code,
|
||||
));
|
||||
|
||||
if ($existing === null) {
|
||||
$newCount++; // совсем новый → в предложения
|
||||
} elseif ($existing->box === 'archived') {
|
||||
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
|
||||
$unique = $dedup->dedupSources($res->sources);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
|
||||
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
|
||||
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
|
||||
$newCount = 0;
|
||||
$refoundDeletedIds = [];
|
||||
foreach ($unique as $s) {
|
||||
$identifier = $s['signal_type'] === 'call'
|
||||
? $norm->phone($s['identifier'])
|
||||
: $norm->domainHead($s['identifier']);
|
||||
|
||||
$existing = AutopodborSource::where('competitor_id', $comp->id)
|
||||
->where('dedup_key', $s['dedup_key'])
|
||||
->first();
|
||||
|
||||
AutopodborSource::updateOrCreate(
|
||||
[
|
||||
'competitor_id' => $comp->id,
|
||||
'dedup_key' => $s['dedup_key'],
|
||||
],
|
||||
[
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'study_run_id' => $run->id,
|
||||
'signal_type' => $s['signal_type'],
|
||||
'identifier' => $identifier,
|
||||
'phone_kind' => $s['phone_kind'] ?? null,
|
||||
'phone_type' => $s['phone_type'] ?? null,
|
||||
'provenance_url' => $s['provenance_url'] ?? null,
|
||||
'provenance_label' => $s['provenance_label'] ?? null,
|
||||
'where_found' => $s['where_found'] ?? null,
|
||||
'office' => $s['office'] ?? null,
|
||||
'confirmations' => $s['confirmations'] ?? 1,
|
||||
]
|
||||
);
|
||||
|
||||
if ($existing === null) {
|
||||
$newCount++; // совсем новый → в предложения
|
||||
} elseif ($existing->box === 'archived') {
|
||||
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
|
||||
}
|
||||
}
|
||||
|
||||
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
|
||||
$charge->chargeForRun($run, $price);
|
||||
|
||||
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
|
||||
|
||||
$run->update([
|
||||
'status' => 'done',
|
||||
'finished_at' => now(),
|
||||
'result' => [
|
||||
'new_sources' => $newCount,
|
||||
'refound_deleted' => count($refoundDeletedIds),
|
||||
'refound_deleted_ids' => array_values($refoundDeletedIds),
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
|
||||
$charge->chargeForRun($run, $price);
|
||||
|
||||
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
|
||||
|
||||
$run->update([
|
||||
'status' => 'done',
|
||||
'finished_at' => now(),
|
||||
'result' => [
|
||||
'new_sources' => $newCount,
|
||||
'refound_deleted' => count($refoundDeletedIds),
|
||||
'refound_deleted_ids' => array_values($refoundDeletedIds),
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
} finally {
|
||||
$scheduler->tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class AutopodborRun extends Model
|
||||
'region_code',
|
||||
'params',
|
||||
'competitor_id',
|
||||
'batch_id',
|
||||
'price_rub_charged',
|
||||
'balance_transaction_id',
|
||||
'error_code',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final class AutopodborBudgetGate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BalancePreflightService $preflight = new BalancePreflightService,
|
||||
) {}
|
||||
|
||||
/** @param Collection<int, PricingTier> $tiers */
|
||||
public function evaluateBatch(
|
||||
string $balanceRub,
|
||||
int $deliveredInMonth,
|
||||
int $committedLeads,
|
||||
Collection $tiers,
|
||||
int $count,
|
||||
string $priceRub,
|
||||
): BudgetDecision {
|
||||
// Бесплатный сбор (цена = 0): пакет всегда разрешён независимо от баланса и резерва
|
||||
// проектов — списывать нечего. Иначе binary-search ниже даёт hardMax=0 (деление на цену
|
||||
// 0 → 0) и пакет ложно отказывается «пополните на 0 ₽».
|
||||
if (bccomp($priceRub, '0', 2) === 0) {
|
||||
return new BudgetDecision(
|
||||
allowed: true,
|
||||
maxAffordable: $count,
|
||||
topupRub: '0.00',
|
||||
);
|
||||
}
|
||||
|
||||
$passesAfter = function (int $k) use ($balanceRub, $deliveredInMonth, $committedLeads, $tiers, $priceRub): bool {
|
||||
$spent = bcmul((string) $k, $priceRub, 2);
|
||||
$after = bcsub($balanceRub, $spent, 2);
|
||||
if (bccomp($after, '0.00', 2) < 0) {
|
||||
return false;
|
||||
}
|
||||
if ($committedLeads <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->preflight->evaluate($after, $deliveredInMonth, $committedLeads, $tiers)->passes;
|
||||
};
|
||||
|
||||
$priceKop = (int) bcmul($priceRub, '100', 0);
|
||||
$balanceKop = (int) bcmul($balanceRub, '100', 0);
|
||||
$hardMax = $priceKop > 0 ? intdiv($balanceKop, $priceKop) : 0;
|
||||
|
||||
$lo = 0;
|
||||
$hi = $hardMax;
|
||||
while ($lo < $hi) {
|
||||
$mid = intdiv($lo + $hi + 1, 2);
|
||||
if ($passesAfter($mid)) {
|
||||
$lo = $mid;
|
||||
} else {
|
||||
$hi = $mid - 1;
|
||||
}
|
||||
}
|
||||
$maxAffordable = $lo;
|
||||
$allowed = $count <= $maxAffordable;
|
||||
|
||||
$topupRub = '0.00';
|
||||
if (! $allowed) {
|
||||
$deficitUnits = $count - $maxAffordable;
|
||||
$topupRub = bcmul((string) $deficitUnits, $priceRub, 2);
|
||||
}
|
||||
|
||||
return new BudgetDecision(
|
||||
allowed: $allowed,
|
||||
maxAffordable: $maxAffordable,
|
||||
topupRub: $topupRub,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Autopodbor\BatchBudgetException;
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AutopodborRunService
|
||||
{
|
||||
public function __construct(
|
||||
private AutopodborNormalizer $normalizer = new AutopodborNormalizer,
|
||||
private AutopodborBudgetGate $budgetGate = new AutopodborBudgetGate,
|
||||
private AutopodborStudyScheduler $scheduler = new AutopodborStudyScheduler,
|
||||
private PricingTierRepository $tiers = new PricingTierRepository,
|
||||
) {}
|
||||
|
||||
private function assertNoInFlight(int $tenantId, string $kind): void
|
||||
@@ -78,9 +84,19 @@ final class AutopodborRunService
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
|
||||
|
||||
// Повторный сбор источников разрешён (клиент жмёт «Собрать ещё раз»): жёсткого стопа
|
||||
// «уже изучали» больше нет. От двойного клика/нахлёста защищает assertNoInFlight ниже.
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
// Идемпотентность двойного клика: если у конкурента УЖЕ есть study-прогон в
|
||||
// очереди/в работе — вернуть его, второго не создаём. Жёсткий стоп по всему
|
||||
// тенанту (assertNoInFlight) снят — распорядитель сам держит одну дорожку и
|
||||
// разруливает нахлёст между конкурентами/тенантами.
|
||||
$existing = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->where('kind', 'study')
|
||||
->where('competitor_id', $comp->id)
|
||||
->whereIn('status', ['queued', 'running'])
|
||||
->first();
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
@@ -90,14 +106,90 @@ final class AutopodborRunService
|
||||
// Регион: у авто-конкурента — из его поиска; у ручного (без searchRun) — из прошлого изучения.
|
||||
'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code,
|
||||
'competitor_id' => $comp->id,
|
||||
'batch_id' => (string) Str::uuid(),
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id, $tenantId);
|
||||
// Не диспатчим напрямую — отдаём распорядителю (одна дорожка, round-robin).
|
||||
$this->scheduler->tick();
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ставит пакет изучений: N study-прогонов под общим batch_id.
|
||||
*
|
||||
* Нормализация: только конкуренты этого тенанта, дубли убираются, конкуренты с
|
||||
* уже активным (queued/running) study-прогоном отсеиваются. Денежный гейт —
|
||||
* на ВЕСЬ пакет разом (с резервом под активные проекты): не хватает на все N →
|
||||
* BatchBudgetException, ни одного прогона не создаём.
|
||||
*
|
||||
* @param array<int, int> $competitorIds
|
||||
*/
|
||||
public function startStudyBatch(int $tenantId, array $competitorIds): BatchResult
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $competitorIds)));
|
||||
|
||||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
// Конкуренты с уже активным study-прогоном — не ставим повторно.
|
||||
$busyCompetitorIds = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->where('kind', 'study')
|
||||
->whereIn('status', ['queued', 'running'])
|
||||
->whereNotNull('competitor_id')
|
||||
->pluck('competitor_id')
|
||||
->all();
|
||||
|
||||
$targets = $competitors
|
||||
->reject(fn (AutopodborCompetitor $c): bool => in_array($c->id, $busyCompetitorIds, true))
|
||||
->values();
|
||||
|
||||
if ($targets->isEmpty()) {
|
||||
return new BatchResult(null, 0);
|
||||
}
|
||||
|
||||
// Денежный гейт на весь пакет.
|
||||
$tenant = Tenant::whereKey($tenantId)->firstOrFail();
|
||||
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
|
||||
$committed = (int) Project::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$tiers = $this->tiers->activeAt(now('Europe/Moscow'));
|
||||
|
||||
$decision = $this->budgetGate->evaluateBatch(
|
||||
(string) $tenant->balance_rub,
|
||||
(int) $tenant->delivered_in_month,
|
||||
$committed,
|
||||
$tiers,
|
||||
$targets->count(),
|
||||
$price,
|
||||
);
|
||||
|
||||
if (! $decision->allowed) {
|
||||
throw new BatchBudgetException($decision->topupRub, $decision->maxAffordable);
|
||||
}
|
||||
|
||||
$batchId = (string) Str::uuid();
|
||||
foreach ($targets as $comp) {
|
||||
AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code,
|
||||
'competitor_id' => $comp->id,
|
||||
'batch_id' => $batchId,
|
||||
'params' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->scheduler->tick();
|
||||
|
||||
return new BatchResult($batchId, $targets->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
|
||||
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
|
||||
@@ -106,7 +198,6 @@ final class AutopodborRunService
|
||||
*/
|
||||
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$comp = AutopodborCompetitor::create([
|
||||
@@ -126,10 +217,12 @@ final class AutopodborRunService
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'competitor_id' => $comp->id,
|
||||
'batch_id' => (string) Str::uuid(),
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id, $tenantId);
|
||||
// Не диспатчим напрямую — отдаём распорядителю.
|
||||
$this->scheduler->tick();
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Планировщик шага 2 «изучение» — одна дорожка на весь портал + честный
|
||||
* round-robin между тенантами.
|
||||
*
|
||||
* Изучение конкурентов — тяжёлый сетевой шаг. Чтобы не забивать очередь одним
|
||||
* тенантом и не гонять два изучения разом, планировщик держит РОВНО ОДНУ активную
|
||||
* дорожку (не более одного study-прогона в статусе `running` на весь портал) и,
|
||||
* когда дорожка свободна, отдаёт её «наименее недавно обслуженному» тенанту.
|
||||
*
|
||||
* Все чтения идут через соединение `pgsql_supplier` (роль crm_supplier_worker) —
|
||||
* распорядителю нужна межтенантная видимость всей очереди. Межтенантность обеспечивает
|
||||
* permissive RLS-политика `autopodbor_runs_supplier_read` (FOR SELECT TO crm_supplier_worker
|
||||
* USING true, миграция 2026_07_08_130000), работающая независимо от того, есть ли у роли
|
||||
* атрибут BYPASSRLS (на Managed PG кастомным ролям он не гарантирован).
|
||||
*/
|
||||
class AutopodborStudyScheduler
|
||||
{
|
||||
/**
|
||||
* Соединение под межтенантную видимость очереди. Видимость по всем тенантам даёт
|
||||
* permissive-политика autopodbor_runs_supplier_read для роли crm_supplier_worker
|
||||
* (+ BYPASSRLS, если он выдан).
|
||||
*/
|
||||
private const CONNECTION = 'pgsql_supplier';
|
||||
|
||||
private const LOCK_KEY = 'autopodbor:study:tick';
|
||||
|
||||
/**
|
||||
* Выбрать id следующего study-прогона для запуска, либо null.
|
||||
*
|
||||
* Правила:
|
||||
* 1. Если хоть один study-прогон уже `running` (по ВСЕМ тенантам) — дорожка
|
||||
* занята, вернуть null.
|
||||
* 2. Иначе среди тенантов с `queued` study-прогонами выбрать «наименее недавно
|
||||
* обслуженного»: ключ тенанта = MAX(finished_at) его study-прогонов; NULL
|
||||
* (никогда не обслуживался) — наивысший приоритет; тай-брейк — самый старый
|
||||
* created_at среди его queued-прогонов.
|
||||
* 3. Внутри выбранного тенанта — его самый старый queued-прогон. Вернуть id.
|
||||
*/
|
||||
public function pickNextRunId(): ?int
|
||||
{
|
||||
$conn = DB::connection(self::CONNECTION);
|
||||
|
||||
// (1) одна дорожка: есть running study → занято.
|
||||
$busy = $conn->table('autopodbor_runs')
|
||||
->where('kind', 'study')
|
||||
->where('status', 'running')
|
||||
->exists();
|
||||
if ($busy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Все queued study-прогоны (по всем тенантам).
|
||||
$queued = $conn->table('autopodbor_runs')
|
||||
->where('kind', 'study')
|
||||
->where('status', 'queued')
|
||||
->get(['id', 'tenant_id', 'created_at']);
|
||||
if ($queued->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// last-served по тенанту = MAX(finished_at) среди ВСЕХ его study-прогонов.
|
||||
// NULL означает «никогда не обслуживался».
|
||||
$lastServed = $conn->table('autopodbor_runs')
|
||||
->where('kind', 'study')
|
||||
->groupBy('tenant_id')
|
||||
->select('tenant_id', DB::raw('MAX(finished_at) AS last_finished'))
|
||||
->pluck('last_finished', 'tenant_id');
|
||||
|
||||
// (2) выбираем тенанта.
|
||||
$best = null; // ['tenant' => int, 'last' => ?string, 'oldestCreated' => string]
|
||||
foreach ($queued->groupBy('tenant_id') as $tenantId => $runs) {
|
||||
$candidate = [
|
||||
'tenant' => (int) $tenantId,
|
||||
'last' => $lastServed[$tenantId] ?? null,
|
||||
'oldestCreated' => (string) $runs->min('created_at'),
|
||||
];
|
||||
if ($best === null || $this->isBetter($candidate, $best)) {
|
||||
$best = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// (3) внутри выбранного тенанта — самый старый queued.
|
||||
$winner = $queued
|
||||
->where('tenant_id', $best['tenant'])
|
||||
->sortBy('created_at')
|
||||
->first();
|
||||
|
||||
return (int) $winner->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить одну итерацию планировщика под Redis-локом: если дорожка свободна и
|
||||
* есть кандидат — задиспатчить его изучение. Лок защищает от гонки нескольких
|
||||
* тиков (два воркера не отправят два изучения разом).
|
||||
*/
|
||||
public function tick(): void
|
||||
{
|
||||
$lock = Cache::lock(self::LOCK_KEY, 10);
|
||||
|
||||
if (! $lock->get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$runId = $this->pickNextRunId();
|
||||
if ($runId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Перечитать прогон под тем же межтенантным соединением: статус мог
|
||||
// измениться между выбором и диспатчем.
|
||||
$run = DB::connection(self::CONNECTION)
|
||||
->table('autopodbor_runs')
|
||||
->where('id', $runId)
|
||||
->first();
|
||||
|
||||
if ($run === null || $run->status !== 'queued') {
|
||||
return;
|
||||
}
|
||||
|
||||
RunAutopodborStudyJob::dispatch((int) $run->id, (int) $run->tenant_id);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* $a приоритетнее $b? Приоритет: никогда не обслуженный (last === null) вперёд;
|
||||
* затем обслуженный раньше вперёд; тай-брейк — более старый created_at.
|
||||
*
|
||||
* @param array{tenant:int,last:?string,oldestCreated:string} $a
|
||||
* @param array{tenant:int,last:?string,oldestCreated:string} $b
|
||||
*/
|
||||
private function isBetter(array $a, array $b): bool
|
||||
{
|
||||
$aNever = $a['last'] === null;
|
||||
$bNever = $b['last'] === null;
|
||||
|
||||
if ($aNever !== $bNever) {
|
||||
return $aNever; // никогда не обслуженный — выше
|
||||
}
|
||||
|
||||
if (! $aNever && $a['last'] !== $b['last']) {
|
||||
// оба обслужены: обслуженный раньше — выше
|
||||
return strcmp((string) $a['last'], (string) $b['last']) < 0;
|
||||
}
|
||||
|
||||
// тай-брейк: самый старый queued created_at — выше
|
||||
return strcmp($a['oldestCreated'], $b['oldestCreated']) < 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
final readonly class BatchResult
|
||||
{
|
||||
public function __construct(public ?string $batch_id, public int $queued) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
final readonly class BudgetDecision
|
||||
{
|
||||
public function __construct(
|
||||
public bool $allowed,
|
||||
public int $maxAffordable,
|
||||
public string $topupRub,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('autopodbor_runs', function (Blueprint $table) {
|
||||
$table->uuid('batch_id')->nullable()->after('kind');
|
||||
$table->index(['tenant_id', 'batch_id'], 'autopodbor_runs_tenant_batch_idx');
|
||||
$table->index(['status', 'kind'], 'autopodbor_runs_status_kind_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('autopodbor_runs', function (Blueprint $table) {
|
||||
$table->dropIndex('autopodbor_runs_status_kind_idx');
|
||||
$table->dropIndex('autopodbor_runs_tenant_batch_idx');
|
||||
$table->dropColumn('batch_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Распорядитель шага 2 (AutopodborStudyScheduler) читает очередь по ВСЕМ тенантам
|
||||
// через соединение pgsql_supplier (роль crm_supplier_worker). Явная permissive
|
||||
// SELECT-политика гарантирует межтенантную видимость независимо от атрибута
|
||||
// BYPASSRLS роли (Managed PG может его не выдавать). Изоляция не нарушается:
|
||||
// это системная queue-роль (как в supplier-flow), пишет по-прежнему под tenant-контекстом.
|
||||
//
|
||||
// На dev/тест-БД роль crm_supplier_worker может отсутствовать (её создаёт
|
||||
// db/00_create_roles.sql, а не миграции) — тогда политику не создаём, чтобы migrate
|
||||
// не падал.
|
||||
DB::statement('DROP POLICY IF EXISTS autopodbor_runs_supplier_read ON autopodbor_runs');
|
||||
|
||||
$roleExists = (bool) DB::selectOne("SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker'");
|
||||
if ($roleExists) {
|
||||
DB::statement('CREATE POLICY autopodbor_runs_supplier_read ON autopodbor_runs FOR SELECT TO crm_supplier_worker USING (true)');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP POLICY IF EXISTS autopodbor_runs_supplier_read ON autopodbor_runs');
|
||||
}
|
||||
};
|
||||
@@ -221,6 +221,25 @@ export async function startStudy(competitor_id: number): Promise<RunDto> {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** Итог постановки пакетного сбора источников в очередь (для нескольких конкурентов разом). */
|
||||
export interface StudyBatchOk {
|
||||
batch_id: string;
|
||||
queued: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакетный сбор источников по нескольким конкурентам разом (шаг 2 для выборки в «Поле»).
|
||||
* Бэкенд ставит задачи в очередь и отвечает 202 `{ batch_id, queued }`. При нехватке баланса —
|
||||
* 422 `{ message, topup_rub, max_affordable }` (компонент показывает яркий алерт баланса).
|
||||
* CSRF — как у соседних POST: `apiClient` уже шлёт X-XSRF-TOKEN автоматически (withXSRFToken).
|
||||
*/
|
||||
export async function startStudyBatch(competitorIds: number[]): Promise<StudyBatchOk> {
|
||||
const { data } = await apiClient.post<StudyBatchOk>('/api/autopodbor/study/batch', {
|
||||
competitor_ids: competitorIds,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function startResolve(p: { name: string; region_code: number }): Promise<RunDto> {
|
||||
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/resolve', p);
|
||||
return data.data;
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
*
|
||||
* Billing v2 Spec A §3.4.8.
|
||||
*/
|
||||
import { tierRangeText } from '../../utils/pricingTiers';
|
||||
|
||||
interface TierPreview {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
@@ -20,7 +18,14 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
function rangeText(idx: number): string {
|
||||
return tierRangeText(props.tiers, idx);
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = props.tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = props.tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Обёртка: десктоп (>=1280) — слот #table (обычный v-data-table),
|
||||
* телефон и планшет (<1280) — слот #card (список карточек по каждому item).
|
||||
* Широкие таблицы (Сделки/Списания) не влезают в узкую колонку планшета-портрета
|
||||
* рядом с rail-меню — правые колонки обрезались, поэтому на планшете тоже карточки.
|
||||
* Обёртка: десктоп/планшет — слот #table (обычный v-data-table),
|
||||
* телефон — слот #card (список карточек по каждому item).
|
||||
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
|
||||
*/
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
defineProps<{ items: unknown[] }>();
|
||||
const { isDesktop } = useLayoutTier();
|
||||
const { isPhone } = useLayoutTier();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="isDesktop">
|
||||
<slot name="table" :items="items" />
|
||||
<template v-if="isPhone">
|
||||
<slot name="card" :items="items" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="card" :items="items" />
|
||||
<slot name="table" :items="items" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -43,14 +43,6 @@ function hasSource(deal: MockDeal): boolean {
|
||||
return Boolean(deal.sourceIdentifier || deal.sourceProjectId);
|
||||
}
|
||||
|
||||
// «Конкурент» — только бренд, без описания: имя конкурента хранится как
|
||||
// «Бренд, описание…», в колонке показываем часть до первой запятой.
|
||||
function competitorShort(name: string | null | undefined): string {
|
||||
if (!name) return '';
|
||||
const i = name.indexOf(',');
|
||||
return (i === -1 ? name : name.slice(0, i)).trim();
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
@@ -111,10 +103,9 @@ function toggleSelect(id: number): void {
|
||||
<router-link
|
||||
v-if="item.competitorId"
|
||||
class="cell-link"
|
||||
:title="item.competitorName ?? undefined"
|
||||
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
|
||||
@click.stop
|
||||
>{{ competitorShort(item.competitorName) }}</router-link
|
||||
>{{ item.competitorName }}</router-link
|
||||
>
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</template>
|
||||
@@ -208,10 +199,9 @@ function toggleSelect(id: number): void {
|
||||
<router-link
|
||||
v-if="deal.competitorId"
|
||||
class="deal-card__competitor"
|
||||
:title="deal.competitorName ?? undefined"
|
||||
:to="{ name: 'autopodbor', query: { competitor: String(deal.competitorId) } }"
|
||||
@click.stop
|
||||
>{{ competitorShort(deal.competitorName) }}</router-link
|
||||
>{{ deal.competitorName }}</router-link
|
||||
>
|
||||
<span v-else class="deal-card__project">{{ stripChannelPrefix(deal.project) }}</span>
|
||||
<span v-if="!hasSource(deal) && signalLabel(deal.signalType)" class="deal-card__signal">{{
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Единый источник истины для показа объёмных ступеней тарифа.
|
||||
*
|
||||
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) — сколько лидов
|
||||
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
|
||||
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
|
||||
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
|
||||
*
|
||||
* Эта функция переводит ширину в тот накопительный диапазон, который видит
|
||||
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
|
||||
* не разошлись в цифрах.
|
||||
*/
|
||||
export interface TierWidth {
|
||||
leads_in_tier: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
|
||||
* ступеней, отсортированном по возрастанию tier_no.
|
||||
*
|
||||
* Пример (ширины 250/500/…): idx=0 → «1–250», idx=1 → «251–750».
|
||||
* Последняя ступень (`leads_in_tier === null`) → «19251+».
|
||||
*/
|
||||
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="activeHeaders"
|
||||
:headers="tierHeaders"
|
||||
:items="active"
|
||||
:items-per-page="7"
|
||||
density="comfortable"
|
||||
@@ -33,17 +33,10 @@
|
||||
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
|
||||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||||
</template>
|
||||
<template #[`item.client_range`]="{ item }">
|
||||
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
|
||||
</template>
|
||||
<template #[`item.price_rub`]="{ item }">
|
||||
{{ fmtRub(item.price_per_lead_kopecks) }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
<div class="text-caption text-medium-emphasis px-4 pb-3">
|
||||
«Лидов в ступени» — сколько заявок помещается именно в эту ступень (ширина). «Диапазон» —
|
||||
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
|
||||
@@ -75,16 +68,11 @@
|
||||
style="max-width: 240px"
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mb-2">
|
||||
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
|
||||
помещается). Справа сразу видно, <strong>что увидит клиент</strong> — накопительный диапазон.
|
||||
</p>
|
||||
<table class="editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ступень</th>
|
||||
<th>Лидов в ступени</th>
|
||||
<th>Клиент увидит</th>
|
||||
<th>Цена за лид (₽)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -102,9 +90,6 @@
|
||||
/>
|
||||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||||
</td>
|
||||
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
|
||||
{{ editorRangeText(t) }} лидов
|
||||
</td>
|
||||
<td>
|
||||
<v-text-field
|
||||
v-model="t.price_rub"
|
||||
@@ -163,7 +148,6 @@ import {
|
||||
type PricingTierEditorRow,
|
||||
} from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
import { tierRangeText } from '../../utils/pricingTiers';
|
||||
|
||||
/**
|
||||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
|
||||
@@ -214,28 +198,6 @@ const tierHeaders = [
|
||||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||||
];
|
||||
|
||||
// Активная сетка показывает ещё и накопительный диапазон — ровно то, что видит
|
||||
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
|
||||
const activeHeaders = [
|
||||
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
|
||||
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
|
||||
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
|
||||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||||
];
|
||||
|
||||
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
|
||||
// и переводим ширины в накопительный диапазон (общий помощник — как в кабинете).
|
||||
function activeRangeText(item: AdminPricingTier): string {
|
||||
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
|
||||
return tierRangeText(active.value, idx);
|
||||
}
|
||||
|
||||
// Живой предпросмотр диапазона в редакторе — пересчитывается при правке ширины.
|
||||
function editorRangeText(row: PricingTierEditorRow): string {
|
||||
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
|
||||
return tierRangeText(editor.value, idx);
|
||||
}
|
||||
|
||||
const nextMonthStart = computed(() => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
@@ -345,12 +307,4 @@ defineExpose({
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.editor-range {
|
||||
color: #6b6456;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, reactive, ref } from 'vue';
|
||||
import { useAutopodborStore } from '../../../stores/autopodborStore';
|
||||
import { autopodborErrorMessage, type FieldCompetitorDto, type DuplicateGroupDto, type CompetitorElement, type MergeEventDto } from '../../../api/autopodbor';
|
||||
import { autopodborErrorMessage, startStudyBatch, type FieldCompetitorDto, type DuplicateGroupDto, type CompetitorElement, type MergeEventDto } from '../../../api/autopodbor';
|
||||
import { REGIONS_ALPHA } from '../../../constants/regions';
|
||||
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
|
||||
import CompetitorElementsEditor from './CompetitorElementsEditor.vue';
|
||||
@@ -101,6 +101,43 @@ async function bulkProjects(active: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// ——— Пакетный сбор источников по выбранным конкурентам (шаг 2 разом) ———
|
||||
// Яркий алерт нехватки баланса (422): показываем topup_rub и max_affordable, ссылку в биллинг.
|
||||
const balanceAlert = ref<{ topup_rub: string | number; max_affordable: number } | null>(null);
|
||||
|
||||
function isBatch422(err: unknown): err is { response: { status: number; data: { topup_rub: string | number; max_affordable: number } } } {
|
||||
const e = err as { response?: { status?: number; data?: { topup_rub?: unknown; max_affordable?: unknown } } };
|
||||
const d = e?.response?.data;
|
||||
// Балансовый 422 — ТОЛЬКО когда бэкенд реально прислал числа для алерта. Прочие 422
|
||||
// (валидация, «нечего ставить») не имеют topup_rub/max_affordable → обычный тост с message,
|
||||
// иначе рисуем «Пополните на ₽» с пустыми числами.
|
||||
return e?.response?.status === 422
|
||||
&& d != null
|
||||
&& typeof d.topup_rub !== 'undefined'
|
||||
&& typeof d.max_affordable !== 'undefined';
|
||||
}
|
||||
|
||||
async function collectSources() {
|
||||
if (busy.value || selected.value.length === 0) return;
|
||||
busy.value = true;
|
||||
balanceAlert.value = null;
|
||||
try {
|
||||
const res = await startStudyBatch([...selected.value]);
|
||||
flash(`Поставлено в очередь: ${res.queued}. Сбор идёт сам — можно закрыть страницу`);
|
||||
clearSel();
|
||||
await store.loadField();
|
||||
} catch (e) {
|
||||
if (isBatch422(e)) {
|
||||
const d = e.response.data;
|
||||
balanceAlert.value = { topup_rub: d.topup_rub, max_affordable: d.max_affordable };
|
||||
} else {
|
||||
flash(autopodborErrorMessage(e, 'Не удалось запустить сбор. Попробуйте ещё раз.'));
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ——— Финальный проход «Почистить поле»: склейка дублей с подтверждением клиента ———
|
||||
const { onOverlayMousedown, onOverlayMouseup } = useOverlayDismiss();
|
||||
// sel — id выбранных к слиянию в группе. По умолчанию выбраны ВСЕ (группа из 2 сливается как раньше).
|
||||
@@ -358,7 +395,7 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openCollect, openAdd, dupes, dupCount, openDupes, mergeGroup, dismissGroup, history, openHistory, restoreEvent });
|
||||
defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, collectSources, balanceAlert, openCollect, openAdd, dupes, dupCount, openDupes, mergeGroup, dismissGroup, history, openHistory, restoreEvent });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -450,10 +487,23 @@ defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openColle
|
||||
<button class="ld-btn gray sm" :disabled="busy" @click="clearSel">Снять выбор</button>
|
||||
<button v-if="someSelectedHaveProjects" class="ld-btn warn sm" :disabled="busy" @click="bulkProjects(false)">⏸ Выключить все проекты</button>
|
||||
<button class="ld-btn primary sm" :disabled="busy" @click="bulkProjects(true)">▶ Включить все созданные проекты</button>
|
||||
<button class="ld-btn primary sm" data-testid="batch-collect-sources" :disabled="busy || selected.length === 0" @click="collectSources">🔎 Собрать источники ({{ selected.length }})</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Яркий алерт нехватки баланса при пакетном сборе (422) -->
|
||||
<Transition name="ld-bar">
|
||||
<div v-if="balanceAlert" class="ld-balert" data-testid="batch-balance-alert" role="alert">
|
||||
<button class="ld-balert__x" aria-label="Закрыть" @click="balanceAlert = null">✕</button>
|
||||
<div class="ld-balert__body">
|
||||
<b>Не хватает баланса.</b>
|
||||
Пополните на {{ balanceAlert.topup_rub }} ₽ или уменьшите выборку до {{ balanceAlert.max_affordable }} конкурентов.
|
||||
</div>
|
||||
<a class="ld-btn primary sm" href="/billing">Пополнить</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ====== Окно: Собрать конкурентов ====== -->
|
||||
<!-- ===== Окно: Найти и объединить дубли (финальный проход) ===== -->
|
||||
<div v-if="dupes.open" class="ld-ovl" @mousedown="onOverlayMousedown" @mouseup="(e) => onOverlayMouseup(e, () => { dupes.open = false; })">
|
||||
@@ -674,4 +724,38 @@ defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openColle
|
||||
color: #8a8578;
|
||||
font-weight: 400;
|
||||
}
|
||||
.ld-balert {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 84px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
max-width: 720px;
|
||||
width: calc(100% - 32px);
|
||||
padding: 14px 44px 14px 18px;
|
||||
border: 2px solid #c26a00;
|
||||
border-radius: 12px;
|
||||
background: #fff4e2;
|
||||
color: #5b3200;
|
||||
box-shadow: 0 10px 30px rgba(120, 70, 0, 0.28);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ld-balert__body {
|
||||
flex: 1;
|
||||
}
|
||||
.ld-balert__x {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #8a5a1a;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -204,3 +204,20 @@ Schedule::command('scheduler:check-heartbeats')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
|
||||
|
||||
// Задача 6 (пакетный сбор источников): страховка распорядителя шага 2. Джоба
|
||||
// RunAutopodborStudyJob уже зовёт AutopodborStudyScheduler::tick() в finally после
|
||||
// КАЖДОГО завершения прогона — это основной канал продвижения очереди. Этот крон —
|
||||
// подстраховка на случай, если дорожка освободилась не через завершение джобы
|
||||
// (например воркер шага 2 не поднят/упал молча) — раз в минуту проверяет и,
|
||||
// если дорожка свободна, а очередь не пуста, стартует следующего.
|
||||
//
|
||||
// НЕ используем ->withoutOverlapping(): требует таблицу cache_locks, которой у нас
|
||||
// нет в schema.sql (см. .env.example CACHE_STORE=database + комментарий выше в этом
|
||||
// файле у Hole #2). Overlap и так не страшен — pickNextRunId()/tick() уже защищены
|
||||
// собственным Cache::lock('autopodbor:study:tick', 10) внутри AutopodborStudyScheduler,
|
||||
// а сам pickNextRunId() проверяет status='running' атомарно на момент чтения.
|
||||
Schedule::command('autopodbor:study-tick')
|
||||
->everyMinute()
|
||||
->onSuccess(fn () => $hb->recordRunResult('autopodbor:study-tick', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('autopodbor:study-tick', false, 'Command failed', null));
|
||||
|
||||
@@ -357,6 +357,7 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopo
|
||||
Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+');
|
||||
Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search');
|
||||
Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study');
|
||||
Route::post('/study/batch', 'App\Http\Controllers\Api\AutopodborController@studyBatch');
|
||||
Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve');
|
||||
Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects');
|
||||
Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
function studyBatchApiCompetitor(int $tenantId, string $name): AutopodborCompetitor
|
||||
{
|
||||
return AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $name,
|
||||
'origin' => 'manual',
|
||||
'dedup_key' => Str::slug($name).'-'.Str::random(6),
|
||||
'box' => 'field',
|
||||
]);
|
||||
}
|
||||
|
||||
it('POST /api/autopodbor/study/batch — достаточный баланс → 202 с batch_id и queued', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
|
||||
$c1 = studyBatchApiCompetitor($tenant->id, 'Окна А');
|
||||
$c2 = studyBatchApiCompetitor($tenant->id, 'Окна Б');
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/autopodbor/study/batch', [
|
||||
'competitor_ids' => [$c1->id, $c2->id],
|
||||
])->assertStatus(202)
|
||||
->assertJsonStructure(['batch_id', 'queued'])
|
||||
->assertJsonPath('queued', 2);
|
||||
|
||||
expect(AutopodborRun::where('batch_id', $response->json('batch_id'))->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('POST /api/autopodbor/study/batch — маленький баланс + committed проекты → 422 с topup_rub и max_affordable', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
|
||||
// Активный проект-обязательство + сетка тарифов — гейт учитывает резерв проектов (паритет со StudyBatchEnqueueTest).
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]);
|
||||
PricingTier::factory()->create([
|
||||
'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 3000,
|
||||
'is_active' => true, 'effective_from' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$c1 = studyBatchApiCompetitor($tenant->id, 'Окна А');
|
||||
$c2 = studyBatchApiCompetitor($tenant->id, 'Окна Б');
|
||||
|
||||
$this->actingAs($user)->postJson('/api/autopodbor/study/batch', [
|
||||
'competitor_ids' => [$c1->id, $c2->id],
|
||||
])->assertStatus(422)
|
||||
->assertJsonStructure(['message', 'topup_rub', 'max_affordable']);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Autopodbor\BatchBudgetException;
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Autopodbor\AutopodborRunService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
function batchCompetitor(int $tenantId, string $name): AutopodborCompetitor
|
||||
{
|
||||
return AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $name,
|
||||
'origin' => 'manual',
|
||||
'dedup_key' => Str::slug($name).'-'.Str::random(6),
|
||||
'box' => 'field',
|
||||
]);
|
||||
}
|
||||
|
||||
it('ставит пакет: N queued-прогонов с общим batch_id, распорядитель стартует ровно первого', function () {
|
||||
Queue::fake();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
|
||||
$c1 = batchCompetitor($tenant->id, 'Окна А');
|
||||
$c2 = batchCompetitor($tenant->id, 'Окна Б');
|
||||
$c3 = batchCompetitor($tenant->id, 'Окна В');
|
||||
|
||||
$result = app(AutopodborRunService::class)->startStudyBatch($tenant->id, [$c1->id, $c2->id, $c3->id]);
|
||||
|
||||
expect($result->queued)->toBe(3)
|
||||
->and($result->batch_id)->not->toBeNull();
|
||||
|
||||
$runs = AutopodborRun::where('batch_id', $result->batch_id)->get();
|
||||
expect($runs)->toHaveCount(3)
|
||||
->and($runs->pluck('status')->unique()->values()->all())->toBe(['queued'])
|
||||
->and($runs->pluck('kind')->unique()->values()->all())->toBe(['study']);
|
||||
|
||||
// Джобы не диспатчатся напрямую из сервиса — распорядитель стартует РОВНО первого.
|
||||
Queue::assertPushed(RunAutopodborStudyJob::class, 1);
|
||||
});
|
||||
|
||||
it('нормализация: дубли, чужие конкуренты и уже-в-очереди отсеиваются', function () {
|
||||
Queue::fake();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$other = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
|
||||
$c1 = batchCompetitor($tenant->id, 'Окна А');
|
||||
$c2 = batchCompetitor($tenant->id, 'Окна Б');
|
||||
$foreign = batchCompetitor($other->id, 'Чужой');
|
||||
$busy = batchCompetitor($tenant->id, 'Занятый');
|
||||
// У «занятого» уже есть queued study-прогон → должен быть отсеян.
|
||||
AutopodborRun::create([
|
||||
'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued',
|
||||
'competitor_id' => $busy->id, 'region_code' => 1, 'params' => [],
|
||||
]);
|
||||
|
||||
$result = app(AutopodborRunService::class)->startStudyBatch(
|
||||
$tenant->id,
|
||||
[$c1->id, $c1->id, $c2->id, $foreign->id, $busy->id],
|
||||
);
|
||||
|
||||
expect($result->queued)->toBe(2);
|
||||
|
||||
$runs = AutopodborRun::where('batch_id', $result->batch_id)->get();
|
||||
expect($runs)->toHaveCount(2)
|
||||
->and($runs->pluck('competitor_id')->sort()->values()->all())
|
||||
->toBe(collect([$c1->id, $c2->id])->sort()->values()->all());
|
||||
});
|
||||
|
||||
it('нет баланса на весь пакет → BatchBudgetException, ни одного прогона не создано', function () {
|
||||
Queue::fake();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
|
||||
// Активный проект-обязательство + сетка тарифов — гейт учитывает резерв проектов.
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]);
|
||||
PricingTier::factory()->create([
|
||||
'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 3000,
|
||||
'is_active' => true, 'effective_from' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$c1 = batchCompetitor($tenant->id, 'Окна А');
|
||||
$c2 = batchCompetitor($tenant->id, 'Окна Б');
|
||||
|
||||
expect(fn () => app(AutopodborRunService::class)->startStudyBatch($tenant->id, [$c1->id, $c2->id]))
|
||||
->toThrow(BatchBudgetException::class);
|
||||
|
||||
expect(AutopodborRun::where('tenant_id', $tenant->id)->where('kind', 'study')->count())->toBe(0);
|
||||
Queue::assertNotPushed(RunAutopodborStudyJob::class);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
use Tests\Doubles\EmptyCompetitorAgent;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('после завершения прогона джоба сама зовёт распорядителя, и тот диспатчит следующий queued', function () {
|
||||
Queue::fake();
|
||||
// Детерминированная заглушка агента (без реальной сети) — прогон уйдёт в `empty` быстро.
|
||||
app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
|
||||
$comp1 = AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenant->id, 'name' => 'Окна А', 'origin' => 'manual',
|
||||
'dedup_key' => Str::slug('Окна А').'-'.Str::random(6), 'box' => 'field',
|
||||
]);
|
||||
$comp2 = AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenant->id, 'name' => 'Окна Б', 'origin' => 'manual',
|
||||
'dedup_key' => Str::slug('Окна Б').'-'.Str::random(6), 'box' => 'field',
|
||||
]);
|
||||
|
||||
$run1 = AutopodborRun::create([
|
||||
'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued',
|
||||
'competitor_id' => $comp1->id, 'region_code' => 1, 'params' => [],
|
||||
]);
|
||||
AutopodborRun::whereKey($run1->id)->update(['created_at' => now()->subMinutes(5)]);
|
||||
|
||||
$run2 = AutopodborRun::create([
|
||||
'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued',
|
||||
'competitor_id' => $comp2->id, 'region_code' => 1, 'params' => [],
|
||||
]);
|
||||
AutopodborRun::whereKey($run2->id)->update(['created_at' => now()]);
|
||||
|
||||
// Класс должен инстанцироваться с новой зависимостью в handle() без ошибок конструктора/резолва.
|
||||
expect(new RunAutopodborStudyJob($run1->id, $tenant->id))->toBeInstanceOf(RunAutopodborStudyJob::class);
|
||||
|
||||
// Прогоняем ПЕРВЫЙ прогон РЕАЛЬНО (через handle(), не через Queue) — Queue::fake() гарантирует,
|
||||
// что единственный dispatch RunAutopodborStudyJob, который мы увидим, придёт ИЗНУТРИ handle()
|
||||
// (от распорядителя), а не от нашего собственного вызова.
|
||||
app()->call([new RunAutopodborStudyJob($run1->id, $tenant->id), 'handle']);
|
||||
|
||||
expect($run1->fresh()->status)->toBe('empty');
|
||||
|
||||
Queue::assertPushed(RunAutopodborStudyJob::class, fn ($job) => $job->runId === $run2->id);
|
||||
});
|
||||
@@ -143,62 +143,6 @@ describe('AdminPricingTiersView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
});
|
||||
|
||||
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
|
||||
const cell = wrapper.find('[data-testid="active-range-2"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.text()).toContain('101–300');
|
||||
});
|
||||
|
||||
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
|
||||
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
|
||||
});
|
||||
|
||||
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
// defaultEditor: ширины 100,200 → ступень 2 = 101–300
|
||||
const cell = wrapper.find('[data-testid="editor-range-2"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.text()).toContain('101–300');
|
||||
});
|
||||
|
||||
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.editorOpen = true;
|
||||
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251–450');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -4,10 +4,6 @@ import { createVuetify } from 'vuetify';
|
||||
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
|
||||
// проверяют табличный слой — фиксируем десктоп-ширину.
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const sampleDeals: MockDeal[] = [
|
||||
@@ -132,29 +128,6 @@ describe('DealsTable', () => {
|
||||
expect(links[1].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3', project: '5' } });
|
||||
});
|
||||
|
||||
it('«Конкурент» — только бренд до первой запятой, без описания', () => {
|
||||
const deal: MockDeal = {
|
||||
id: 12,
|
||||
name: '79000000002',
|
||||
phone: '79000000002',
|
||||
statusSlug: 'new',
|
||||
project: 'X',
|
||||
manager: { initials: '—', name: '—' },
|
||||
cost: 0,
|
||||
receivedMinutesAgo: 1,
|
||||
competitorId: 4,
|
||||
competitorName: 'AutoMoney, микрокредитная компания',
|
||||
sourceIdentifier: '79135396546',
|
||||
sourceSignalType: 'call',
|
||||
sourceProjectId: 5,
|
||||
sourceIsActive: true,
|
||||
sourceExtraCount: 0,
|
||||
};
|
||||
const w = mountTable([deal]);
|
||||
expect(w.text()).toContain('AutoMoney');
|
||||
expect(w.text()).not.toContain('микрокредитная');
|
||||
});
|
||||
|
||||
it('показывает « · +N» при нескольких источниках проекта', () => {
|
||||
const deal: MockDeal = {
|
||||
id: 11,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
vi.mock('../../resources/js/api/autopodbor');
|
||||
import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue';
|
||||
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
|
||||
import { startStudyBatch, autopodborErrorMessage, type FieldCompetitorDto } from '../../resources/js/api/autopodbor';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function makeNav() {
|
||||
return { go: vi.fn(), ctx: reactive({ competitorId: null as number | null }), screen: ref('field') };
|
||||
}
|
||||
|
||||
function field(over: Partial<FieldCompetitorDto> = {}): FieldCompetitorDto {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Окна Комфорт',
|
||||
description: 'd',
|
||||
is_federal: false,
|
||||
relevance_pct: 90,
|
||||
origin: 'auto',
|
||||
box: 'field',
|
||||
site_url: 'okna.ru',
|
||||
directory_urls: [],
|
||||
studied_at: null,
|
||||
study_run_id: null,
|
||||
search_run_id: 5,
|
||||
counters: { sources: 2, projects_created: 1, projects_in_work: 1 },
|
||||
sources: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mountWs(nav: ReturnType<typeof makeNav>) {
|
||||
return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
|
||||
}
|
||||
|
||||
async function mountWithTwo() {
|
||||
const store = useAutopodborStore();
|
||||
vi.spyOn(store, 'loadField').mockImplementation(async () => {
|
||||
store.field = [field({ id: 1, name: 'Первый' }), field({ id: 2, name: 'Второй' })];
|
||||
});
|
||||
vi.spyOn(store, 'loadProposalGroups').mockResolvedValue();
|
||||
const w = mountWs(makeNav());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// Отмечаем обоих конкурентов галочками → появляется плавающий подвал.
|
||||
const boxes = w.findAll('.ld-pick');
|
||||
await boxes[0].trigger('change');
|
||||
await boxes[1].trigger('change');
|
||||
return { w, store };
|
||||
}
|
||||
|
||||
describe('Конкурентное поле — пакетный сбор источников', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('кнопка «Собрать источники (N)» в подвале показывает число выбранных', async () => {
|
||||
const { w } = await mountWithTwo();
|
||||
const btn = w.find('[data-testid="batch-collect-sources"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toContain('2');
|
||||
expect(btn.text()).toContain('Собрать источники');
|
||||
});
|
||||
|
||||
it('при 422 показывается яркий алерт с topup и max_affordable', async () => {
|
||||
vi.mocked(startStudyBatch).mockRejectedValue({
|
||||
response: { status: 422, data: { message: 'no money', topup_rub: '150.00', max_affordable: 5 } },
|
||||
});
|
||||
const { w } = await mountWithTwo();
|
||||
const btn = w.find('[data-testid="batch-collect-sources"]');
|
||||
await btn.trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const alert = w.find('[data-testid="batch-balance-alert"]');
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.text()).toContain('150');
|
||||
expect(alert.text()).toContain('5');
|
||||
});
|
||||
|
||||
it('422 без topup_rub («нечего ставить») → балансовый алерт НЕ показывается, тост с message', async () => {
|
||||
// Ошибка-axios без topup_rub/max_affordable: это не нехватка баланса, а бизнес-правило.
|
||||
// autopodborErrorMessage авто-замокан всем модулем — вернём конкретный message бэкенда.
|
||||
vi.mocked(autopodborErrorMessage).mockImplementation(
|
||||
(e: unknown, fallback: string) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback,
|
||||
);
|
||||
vi.mocked(startStudyBatch).mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 422, data: { message: 'Нечего ставить в очередь' } },
|
||||
});
|
||||
const { w } = await mountWithTwo();
|
||||
const btn = w.find('[data-testid="batch-collect-sources"]');
|
||||
await btn.trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await w.vm.$nextTick();
|
||||
|
||||
// Балансовый алерт НЕ показан.
|
||||
expect(w.find('[data-testid="batch-balance-alert"]').exists()).toBe(false);
|
||||
// Показан обычный тост с конкретным message от бэкенда.
|
||||
const toast = w.find('.ld-toast');
|
||||
expect(toast.exists()).toBe(true);
|
||||
expect(toast.text()).toContain('Нечего ставить в очередь');
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,6 @@ describe('ResponsiveTable', () => {
|
||||
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
|
||||
});
|
||||
it('на планшете-портрете (768) показывает слот card, а не обрезанную таблицу', () => {
|
||||
const w = mountAt(768);
|
||||
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
|
||||
});
|
||||
it('на десктопе (>=1280) показывает слот table', () => {
|
||||
const w = mountAt(1440);
|
||||
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
|
||||
|
||||
@@ -7,10 +7,6 @@ import type { BillingTransaction, TransactionsPage } from '../../resources/js/ap
|
||||
|
||||
vi.mock('../../resources/js/api/billing');
|
||||
|
||||
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
|
||||
// проверяют табличный слой — фиксируем десктоп-ширину.
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
|
||||
|
||||
/**
|
||||
* Единый источник истины для «ширина ступени → накопительный диапазон».
|
||||
* Числа — ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
|
||||
* ширины 250/500/1000/2500/5000/10000/∞ → 1–250, 251–750, …, 19251+.
|
||||
*/
|
||||
const tiers = [
|
||||
{ leads_in_tier: 250 },
|
||||
{ leads_in_tier: 500 },
|
||||
{ leads_in_tier: 1000 },
|
||||
{ leads_in_tier: 2500 },
|
||||
{ leads_in_tier: 5000 },
|
||||
{ leads_in_tier: 10000 },
|
||||
{ leads_in_tier: null },
|
||||
];
|
||||
|
||||
describe('tierRangeText', () => {
|
||||
it('первая ступень начинается с 1', () => {
|
||||
expect(tierRangeText(tiers, 0)).toBe('1–250');
|
||||
});
|
||||
|
||||
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
|
||||
expect(tierRangeText(tiers, 1)).toBe('251–750');
|
||||
});
|
||||
|
||||
it('третья ступень продолжает накопление', () => {
|
||||
expect(tierRangeText(tiers, 2)).toBe('751–1750');
|
||||
});
|
||||
|
||||
it('шестая ступень', () => {
|
||||
// 250+500+1000+2500 = 4250 → пятая 4251–9250 → шестая 9251–19250
|
||||
expect(tierRangeText(tiers, 5)).toBe('9251–19250');
|
||||
});
|
||||
|
||||
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
|
||||
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
|
||||
expect(tierRangeText(tiers, 6)).toBe('19251+');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use App\Services\Autopodbor\AutopodborBudgetGate;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
function tiersFixture(): Collection
|
||||
{
|
||||
return new Collection([
|
||||
new PricingTier([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 6000,
|
||||
'is_active' => true,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
it('разрешает пакет, когда денег хватает и пакету, и резерву проектов', function () {
|
||||
$gate = new AutopodborBudgetGate;
|
||||
$d = $gate->evaluateBatch('1000.00', 0, 10, tiersFixture(), 20, '10.00');
|
||||
expect($d->allowed)->toBeTrue();
|
||||
expect($d->maxAffordable)->toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('запрещает пакет и считает M, когда пакет роняет проекты', function () {
|
||||
$gate = new AutopodborBudgetGate;
|
||||
// баланс 650; проектам нужно 10*60=600; свободно 50 → влезает 5 по 10.
|
||||
$d = $gate->evaluateBatch('650.00', 0, 10, tiersFixture(), 20, '10.00');
|
||||
expect($d->allowed)->toBeFalse();
|
||||
expect($d->maxAffordable)->toBe(5);
|
||||
expect((float) $d->topupRub)->toBeGreaterThan(0.0);
|
||||
});
|
||||
|
||||
it('committed=0 (нет активных проектов) — весь баланс доступен пакету', function () {
|
||||
$gate = new AutopodborBudgetGate;
|
||||
$d = $gate->evaluateBatch('100.00', 0, 0, tiersFixture(), 10, '10.00');
|
||||
expect($d->allowed)->toBeTrue();
|
||||
expect($d->maxAffordable)->toBe(10);
|
||||
});
|
||||
|
||||
it('price=0 (бесплатный сбор) — пакет разрешён независимо от баланса и резерва', function () {
|
||||
$gate = new AutopodborBudgetGate;
|
||||
// Нулевой баланс и активные проекты в резерве — но цена сбора 0, списывать нечего.
|
||||
$d = $gate->evaluateBatch('0.00', 0, 10, tiersFixture(), 25, '0.00');
|
||||
expect($d->allowed)->toBeTrue();
|
||||
expect($d->maxAffordable)->toBe(25);
|
||||
expect($d->topupRub)->toBe('0.00');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Autopodbor\AutopodborStudyScheduler;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
// Логический номер тенанта (1,2,…) → реальный tenants.id (FK autopodbor_runs.tenant_id).
|
||||
// Сбрасываем перед каждым тестом: DatabaseTransactions откатывает строки, поэтому
|
||||
// карта не должна протекать между тестами.
|
||||
beforeEach(function () {
|
||||
$GLOBALS['__sched_tenant_map'] = [];
|
||||
});
|
||||
|
||||
function studyRun(int $tenant, string $status, ?string $finishedAt = null, ?string $createdAt = null): AutopodborRun {
|
||||
if (! isset($GLOBALS['__sched_tenant_map'][$tenant])) {
|
||||
$GLOBALS['__sched_tenant_map'][$tenant] = Tenant::factory()->create()->id;
|
||||
}
|
||||
$tenantId = $GLOBALS['__sched_tenant_map'][$tenant];
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId, 'kind' => 'study', 'status' => $status,
|
||||
'competitor_id' => null, 'region_code' => 1, 'params' => [],
|
||||
'finished_at' => $finishedAt,
|
||||
]);
|
||||
if ($createdAt !== null) {
|
||||
// created_at не в timestamps — задаём явно через query builder (мимо fillable),
|
||||
// чтобы порядок был детерминирован
|
||||
AutopodborRun::whereKey($run->id)->update(['created_at' => $createdAt]);
|
||||
$run->refresh();
|
||||
}
|
||||
return $run;
|
||||
}
|
||||
|
||||
it('дорожка занята: есть running → pickNext возвращает null', function () {
|
||||
studyRun(1, 'running');
|
||||
studyRun(2, 'queued');
|
||||
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBeNull();
|
||||
});
|
||||
|
||||
it('чередует тенантов: наименее недавно обслуженный вперёд', function () {
|
||||
studyRun(1, 'done', finishedAt: now()->toDateTimeString());
|
||||
studyRun(1, 'queued');
|
||||
studyRun(2, 'done', finishedAt: now()->subHour()->toDateTimeString());
|
||||
$t2q = studyRun(2, 'queued');
|
||||
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id);
|
||||
});
|
||||
|
||||
it('внутри тенанта берёт самый старый queued', function () {
|
||||
$old = studyRun(1, 'queued', createdAt: now()->subMinutes(10)->toDateTimeString());
|
||||
studyRun(1, 'queued', createdAt: now()->toDateTimeString());
|
||||
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($old->id);
|
||||
});
|
||||
|
||||
it('никогда не обслуженный тенант имеет приоритет', function () {
|
||||
studyRun(1, 'done', finishedAt: now()->subDay()->toDateTimeString());
|
||||
studyRun(1, 'queued');
|
||||
$t2q = studyRun(2, 'queued');
|
||||
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id);
|
||||
});
|
||||
@@ -4,6 +4,47 @@
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.63, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## ⏸ ХВОСТ (2026-07-08) — Автоподбор: RLS-политика межтенантного распорядителя шага 2
|
||||
|
||||
Ветка `feat/autopodbor-step2-batch` (worktree `wt-autopodbor-batch`, НЕ на проде). Ревью нашло риск:
|
||||
`AutopodborStudyScheduler` читает `autopodbor_runs` межтенантно через соединение `pgsql_supplier`
|
||||
(роль `crm_supplier_worker`), полагаясь на BYPASSRLS. На боевом Managed PG эта привилегия кастомным
|
||||
ролям **не гарантирована** → на сессии без tenant-контекста политика `tenant_isolation` вернёт 0 строк
|
||||
и распорядитель зависнет.
|
||||
|
||||
**Применено миграцией (`2026_07_08_130000_autopodbor_runs_supplier_read_policy`), тело `schema.sql` — ОТЛОЖЕННЫЙ canon-sync:**
|
||||
|
||||
- +RLS policy `autopodbor_runs_supplier_read` ON `autopodbor_runs` `FOR SELECT TO crm_supplier_worker USING (true)`
|
||||
— явная permissive-политика межтенантного чтения очереди, работает независимо от атрибута BYPASSRLS роли.
|
||||
Причина: межтенантный распорядитель шага 2 (fair-queue). Запись по-прежнему идёт под tenant-контекстом —
|
||||
изоляция не ослабляется.
|
||||
|
||||
**Структурно:** `autopodbor_runs` +1 RLS-политика. Таблицы/колонки/индексы/функции/триггеры/партиции
|
||||
БЕЗ изменений. Миграция идемпотентна (`DROP POLICY IF EXISTS` + создание только если роль
|
||||
`crm_supplier_worker` существует — на dev/тесте её создаёт `db/00_create_roles.sql`, а не миграции,
|
||||
поэтому на тест-БД политика не создаётся и migrate не падает). Воркстри-фича, НЕ на проде.
|
||||
|
||||
## ⏸ ХВОСТ (2026-07-08) — Автоподбор: batch_id + индексы очереди «пакетный сбор источников» шага 2
|
||||
|
||||
Ветка `feat/autopodbor-step2-batch` (worktree `wt-autopodbor-batch`, НЕ на проде). Готовим пакетный
|
||||
сбор источников шага 2 — см. spec
|
||||
`docs/superpowers/specs/2026-07-08-autopodbor-step2-batch-fair-queue-design.md`. Прогоны одного пакета
|
||||
(«отправили N сайтов разом») группируются общим `batch_id`, плюс индекс под честную очередь по
|
||||
`status`+`kind`. Статус `canceled` подготовлен на уровне соглашения (колонка `status` — свободный
|
||||
`VARCHAR(16)` без CHECK-ограничения в `schema.sql`, поэтому дополнительных ALTER не потребовалось).
|
||||
|
||||
**Применено миграцией (в тест-БД `liderra_testing_apk2batch`), тело `schema.sql` — ОТЛОЖЕННЫЙ canon-sync:**
|
||||
|
||||
- `autopodbor_runs` +`batch_id UUID NULL` (после `kind`) — миграция
|
||||
`2026_07_08_120000_add_batch_id_to_autopodbor_runs`.
|
||||
- +индекс `autopodbor_runs_tenant_batch_idx (tenant_id, batch_id)`.
|
||||
- +индекс `autopodbor_runs_status_kind_idx (status, kind)` — под честную очередь пакетного сбора.
|
||||
|
||||
**Структурно (для будущего canon-sync в тело):** `autopodbor_runs` +1 колонка +2 индекса. Функций/
|
||||
триггеров/партиций/RLS без изменений. Версия `schema.sql` остаётся v8.63 до отдельного canon-sync
|
||||
(этот файл — журнал изменений *миграций*, тело `schema.sql` синкает контроллер отдельно, см. §5 п.6/п.10
|
||||
CLAUDE.md — прямые правки `schema.sql` только с записью сюда).
|
||||
|
||||
## v8.63 (2026-07-07) — Автоподбор: колонка dismissed_actualize_keys (мягкое «Отказаться» на актуализации)
|
||||
|
||||
Ветка `worktree-avtopodbor`. Кнопка «Отказаться» на карточках «На актуализацию»: клиент отклоняет
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Лидерра — ЗАЩИЩЁННЫЙ выкат фронт-сборки портала (public/build).
|
||||
#
|
||||
# ЗАЧЕМ. Портал выкатывается ПОЛНОЙ заменой папки public/build. Если собрать
|
||||
# из УСТАРЕВШЕЙ ветки (где нет чужих свежих фич) и залить — чужая работа
|
||||
# МОЛЧА затирается. Реальный инцидент 08.07.2026: выкат Яндекс.Метрики,
|
||||
# собранный из ветки без колонок «Сделок», снёс колонки «Конкурент/Источник».
|
||||
#
|
||||
# ЧТО ДЕЛАЕТ. Перед подменой сверяет: новая сборка НЕ должна ПОТЕРЯТЬ ни одной
|
||||
# критичной фичи, которая есть в живой сборке. Потеряла — ОТКАЗ, боевой не тронут.
|
||||
# Осознанный откат (когда фичу убираем намеренно) — только с FORCE=1.
|
||||
#
|
||||
# ВАЖНО. Маркеры ищем ТОЛЬКО в чанках, на которые ссылается manifest.json
|
||||
# (активная сборка), а не по всей assets/ — там копятся «осколки» старых
|
||||
# overlay-выкатов, из-за которых наивная проверка промахивалась.
|
||||
#
|
||||
# ИСПОЛЬЗОВАНИЕ (с dev-машины):
|
||||
# tar czf build.tgz -C app/public/build .
|
||||
# scp build.tgz liderra:/tmp/build.tgz
|
||||
# ssh liderra 'sudo bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "ветка/sha/что выкатываем"'
|
||||
# # только проверить, не выкатывая:
|
||||
# ssh liderra 'sudo DRY_RUN=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "проверка"'
|
||||
# # осознанный откат/удаление фичи:
|
||||
# ssh liderra 'sudo FORCE=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "rollback: ..."'
|
||||
#
|
||||
# Откат в любой момент: mv build.bak-guard-<ts> build (см. вывод скрипта).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
TGZ="${1:?укажи путь к tar.gz со сборкой (содержимое папки build)}"
|
||||
INFO="${2:-}"
|
||||
FORCE="${FORCE:-}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
BASE=/var/www/liderra/app/public
|
||||
LIVE="$BASE/build"
|
||||
|
||||
# Критичные маркеры фич: если строка есть в ЖИВОЙ сборке — обязана быть и в новой.
|
||||
# ➕ Добавляй строку сюда, когда выкатываешь новую пользовательскую фичу,
|
||||
# чтобы её тоже нельзя было случайно затереть следующим выкатом.
|
||||
MARKERS=(
|
||||
"Конкурент" # колонки «Сделок»: Конкурент / Источник
|
||||
"mc.yandex" # Яндекс.Метрика (счётчик + Вебвизор)
|
||||
"ym-hide-content" # маскировка ПДн лидов для Метрики (152-ФЗ) — критично
|
||||
"deal-card" # мобильные/планшетные карточки сделок
|
||||
"autopodbor" # автоподбор конкурентов
|
||||
)
|
||||
|
||||
echo "▶ Защищённый выкат сборки портала. Инфо: ${INFO:-—}"
|
||||
[ -f "$TGZ" ] || { echo "❌ Нет файла: $TGZ"; exit 2; }
|
||||
|
||||
STAGING="$BASE/.build-staging.$$"
|
||||
rm -rf "$STAGING"; mkdir -p "$STAGING"
|
||||
tar xzf "$TGZ" -C "$STAGING"
|
||||
[ -f "$STAGING/manifest.json" ] || { echo "❌ В сборке нет manifest.json — это не билд портала."; rm -rf "$STAGING"; exit 2; }
|
||||
|
||||
# Список АКТИВНЫХ чанков (js/css) по manifest — без orphan-осколков прошлых выкатов.
|
||||
active_files() { # $1 = dir сборки → печатает полные пути к активным чанкам
|
||||
local dir="$1"
|
||||
grep -oE 'assets/[A-Za-z0-9_.-]+\.(js|css)' "$dir/manifest.json" | sort -u | sed "s|^|$dir/|"
|
||||
}
|
||||
mapfile -t LIVE_FILES < <(active_files "$LIVE" 2>/dev/null || true)
|
||||
mapfile -t NEW_FILES < <(active_files "$STAGING" 2>/dev/null || true)
|
||||
|
||||
hits() { # $1 = маркер, далее список файлов → сколько файлов содержат маркер
|
||||
local m="$1"; shift
|
||||
[ "$#" -eq 0 ] && { echo 0; return 0; }
|
||||
# grep возвращает 1 при «не найдено»; при pipefail+set -e это рвёт скрипт —
|
||||
# поэтому глушим код через "|| true", а число берём из вывода wc.
|
||||
local c
|
||||
c=$(grep -lF -- "$m" "$@" 2>/dev/null | wc -l) || true
|
||||
echo "${c:-0}"
|
||||
}
|
||||
|
||||
# ── ГЛАВНАЯ ЗАЩИТА: новая сборка не должна терять фичи живой ──────────────────
|
||||
missing=()
|
||||
for m in "${MARKERS[@]}"; do
|
||||
live_n=$(hits "$m" "${LIVE_FILES[@]}")
|
||||
new_n=$(hits "$m" "${NEW_FILES[@]}")
|
||||
if [ "$live_n" -gt 0 ] && [ "$new_n" -eq 0 ]; then
|
||||
missing+=("$m")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ] && [ "$FORCE" != "1" ]; then
|
||||
echo ""
|
||||
echo "🛑 ОТКАЗ: новая сборка ТЕРЯЕТ фичи, которые есть на боевом:"
|
||||
printf ' • %s\n' "${missing[@]}"
|
||||
echo ""
|
||||
echo " Похоже, собрано из УСТАРЕВШЕЙ ветки (без чужой работы)."
|
||||
echo " Пересобери из ветки-надмножества — где есть ВСЁ живое."
|
||||
echo " Если это ОСОЗНАННЫЙ откат/удаление фичи — повтори с FORCE=1."
|
||||
rm -rf "$STAGING"
|
||||
exit 3
|
||||
fi
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "⚠ FORCE=1: осознанно теряем фичи (${missing[*]}) — по прямому указанию."
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "✅ DRY_RUN: проверка пройдена, все живые фичи на месте. Выкат НЕ выполнялся."
|
||||
rm -rf "$STAGING"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Подмена с бэкапом и отметкой о выкате ────────────────────────────────────
|
||||
ts="$(date -u +%Y%m%d-%H%M%S)"
|
||||
mv "$LIVE" "$BASE/build.bak-guard-$ts"
|
||||
mv "$STAGING" "$LIVE"
|
||||
appjs=$(grep -oE 'assets/app-[a-zA-Z0-9_-]+\.js' "$LIVE/manifest.json" | head -1)
|
||||
printf '{"deployed_at":"%s","info":"%s","bundle":"%s","guard_ok":true}\n' \
|
||||
"$ts" "${INFO//\"/}" "$appjs" > "$LIVE/DEPLOYED.json"
|
||||
chown -R www-data:www-data "$LIVE"
|
||||
echo "✅ Выкат ок. Бандл: $appjs. Бэкап прежней сборки: build.bak-guard-$ts"
|
||||
Reference in New Issue
Block a user