Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
53 KiB
Пакетный сбор источников (шаг 2) + честная очередь — план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Дать клиенту ставить группу конкурентов на сбор источников одной кнопкой, обрабатывать их одной дорожкой с честным чередованием между тенантами, а деньги проверять на весь пакет заранее с резервом под активные лид-проекты.
Architecture: Пакет = набор AutopodborRun(kind='study', status='queued') с общим batch_id. Прямой dispatch из сервиса убираем — джобы ставит AutopodborStudyScheduler (одна дорожка глобально + round-robin по тенантам, под Redis-локом). Постановка пакета проходит AutopodborBudgetGate (поверх существующего BalancePreflightService) — всё-или-ничего с резервом под committed лиды проектов. Триггеры распорядителя: конец джобы, постановка пакета, крон раз в минуту.
Tech Stack: PHP 8.3 / Laravel 13, Pest 4 (--parallel), PostgreSQL 16 + RLS (роль crm_app_user, FORCE RLS), Redis (очередь autopodbor-study + локи), Vue 3 + Vuetify 3, Vitest.
Spec: docs/superpowers/specs/2026-07-08-autopodbor-step2-batch-fair-queue-design.md
Файловая карта
Создаём:
app/app/Services/Autopodbor/AutopodborBudgetGate.php— «сколько сбора влезает, не роняя проекты» (чистый расчёт).app/app/Services/Autopodbor/AutopodborStudyScheduler.php— выбор следующего прогона + одна дорожка + dispatch (под локом).app/app/Console/Commands/AutopodborStudyTickCommand.php— крон-страховкаautopodbor:study-tick.app/database/migrations/XXXX_add_batch_id_to_autopodbor_runs.php— колонкаbatch_id+ индекс.- Тесты:
app/tests/Unit/Autopodbor/AutopodborBudgetGateTest.php,app/tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php,app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.php,app/tests/Feature/Autopodbor/StudySchedulerFairnessTest.php,app/tests/Frontend/KonkurentnoePoleBatch.spec.ts(имя фронт-файла уточнить в Задаче 0).
Меняем:
app/app/Services/Autopodbor/AutopodborRunService.php— новыйstartStudyBatch;startStudy/startManualStudyбольше не диспатчат сами, зовутtick(); снять жёсткийassertNoInFlightкак стоп-кран постановки (заменить на дедуп «уже в очереди»).app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php— в концеhandle()(успех иcatch) зовёмAutopodborStudyScheduler::tick().app/app/Http/Controllers/Api/AutopodborController.php— endpointstudyBatch(+ опц.cancelBatch).app/routes/api.php(илиweb.php— уточнить в Задаче 0) — маршруты.app/app/Console/Kernel.php(илиbootstrap/app.phpschedule в L11+ — уточнить) — регистрацияautopodbor:study-tickeveryMinute.- Фронт «Конкурентное поле» — кнопка в подвале выбора + яркий алерт баланса + статусы (файлы уточнить в Задаче 0).
db/CHANGELOG_schema.md— запись о миграции.
Task 0: Рабочее дерево на ветке движка + сверка сигнатур
Files: нет правок кода — подготовка и разведка.
- Step 1: Определить базовую ветку с движком. Движок автоподбора НЕ на
feat/sales-portal-demo. Найти ветку, где лежитapp/app/Services/Autopodbor/*:
Run:
cd "c:/моя/проекты/портал crm/Документация"
git branch -a
for b in gitea/feat/portal-mobile-adaptive gitea/main gitea/feat/root-domain-auto-link; do echo "== $b =="; git ls-tree -r --name-only "$b" -- app/app/Services/Autopodbor 2>/dev/null | head -3; done
Expected: одна из веток содержит app/app/Services/Autopodbor/AutopodborRunService.php и т.д. Это — БАЗОВАЯ ветка. (Ожидаемо — линия автоподбора, влитая в боевую.) Зафиксировать её имя как <ENGINE_BRANCH>.
- Step 2: Создать изолированный worktree от базовой ветки (skill
superpowers:using-git-worktrees):
Run:
git worktree add --detach ../wt-autopodbor-batch <ENGINE_BRANCH>
git -C ../wt-autopodbor-batch switch -c feat/autopodbor-step2-batch
Работать дальше ТОЛЬКО в ../wt-autopodbor-batch. Бутстрап тест-БД — по памяти feedback-worktree-test-bootstrap-recipe (своя тест-БД, php artisan migrate, php artisan partitions:create-months, npm --legacy-peer-deps, запускать тесты ПОСЛЕДОВАТЕЛЬНО в свежем дереве).
- Step 3: Прочитать и выписать точные сигнатуры соседей (в этих файлах — не гадать, открыть и свериться):
Run:
cd ../wt-autopodbor-batch/app
sed -n '1,60p' app/Services/Autopodbor/AutopodborChargeService.php
grep -n "function requiredLeadsForTomorrow\|delivered_in_month\|balance_rub" app/Models/Tenant.php
grep -n "autopodbor\|study" routes/api.php routes/web.php 2>/dev/null
grep -n "function study\|function manualStudy\|Route::" app/Http/Controllers/Api/AutopodborController.php | head
sed -n '1,50p' app/Repositories/PricingTierRepository.php
ls app/resources/js/views | grep -iE "konkurent|pole|autopodbor|competit"
ls app/resources/js/components | grep -iE "konkurent|pole|autopodbor|competit"
Expected: получить точные имена AutopodborChargeService::chargeForRun (сигнатура), Tenant::requiredLeadsForTomorrow(), PricingTierRepository::activeAt(), файл маршрутов автоподбора, имя Vue-компонента «Конкурентное поле» и его подвала выбора. Записать найденное в шапку плана-исполнения — на них ссылаются Задачи 2,4,5,7,8.
- Step 4: Зафиксировать, как ставится tenant-контекст в системных (межтенантных) запросах. Распорядителю нужен запрос «есть ли
runningstudy по ВСЕМ тенантам».
РЕЗУЛЬТАТ РАЗВЕДКИ (2026-07-08, заполнено):
- Джобы автоподбора ходят под
crm_app_user(FORCE RLS) и ставятset_config('app.current_tenant_id', ...)per-tenant — межтенантного пути у них нет. - Штатный системный путь в проекте: роль
crm_supplier_worker(BYPASSRLS) через соединениеpgsql_supplier(traitSharesSupplierPdo, прецедент —RouteSupplierLeadJob). Это и есть «системный» доступ для queue-воркеров через тенанты. - Решение: распорядитель (
AutopodborStudyScheduler::systemQuery()) ходит черезDB::connection('pgsql_supplier'). RLS-политики НЕ трогаем. Тесты распорядителя —uses(SharesSupplierPdo::class)(см.feedback-prod-full-test-isolated-db). Согласовать сrls-reviewer(Задача 3). - Точное имя соединения подтвердить в
config/database.php(pgsql_supplier).
Task 1: Миграция — batch_id + статус canceled
Files:
-
Create:
app/database/migrations/2026_07_08_120000_add_batch_id_to_autopodbor_runs.php -
Modify:
db/CHANGELOG_schema.md -
Test:
app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.php(косвенно), плюс проверка схемы ниже. -
Step 1: Написать миграцию.
<?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) {
// Группировка прогонов одного пакета: показ «X из N» + точечная отмена остатка.
$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');
});
}
};
Примечание: статус canceled — значение строкового поля status, без изменения схемы (если status — enum/CHECK в БД, добавить canceled в ограничение отдельным ALTER; проверить db/schema.sql в Задаче 0/здесь).
- Step 2: Проверить, не enum ли
statusв БД.
Run: grep -n "status" db/schema.sql | grep -i autopodbor_runs (или найти определение таблицы). Expected: если есть CHECK (status IN (...)) — расширить его в миграции значением 'canceled'; если свободный varchar/text — ничего не делаем.
- Step 3: Применить миграцию на тест-БД.
Run: php artisan migrate Expected: миграция применена без ошибок.
- Step 4: Записать в CHANGELOG схемы.
Добавить в db/CHANGELOG_schema.md запись: дата 2026-07-08, autopodbor_runs.batch_id uuid null + 2 индекса, причина — пакетный сбор шага 2. Обновить header-метрики схемы если требуется.
-
Step 5: RLS-ревью миграции. Прогнать агента
rls-reviewerна изменение (колонка на tenant-scoped таблице; политики не меняются — подтвердить, что новые индексы/колонка не ломают RLS). -
Step 6: Commit.
git add app/database/migrations/2026_07_08_120000_add_batch_id_to_autopodbor_runs.php db/CHANGELOG_schema.md
git commit -m "feat(autopodbor): миграция batch_id + индексы очереди шага 2"
Task 2: AutopodborBudgetGate — деньги всё-или-ничего с резервом проектов
Files:
- Create:
app/app/Services/Autopodbor/AutopodborBudgetGate.php - Test:
app/tests/Unit/Autopodbor/AutopodborBudgetGateTest.php
Контракт:
final class AutopodborBudgetGate
{
// committedLeads — sum(daily_limit_target) активных непроблокированных проектов тенанта.
// Возвращает решение по пакету размера $count по цене $priceRub за штуку.
public function evaluateBatch(
string $balanceRub, int $deliveredInMonth, int $committedLeads,
Collection $tiers, int $count, string $priceRub
): BudgetDecision;
}
final readonly class BudgetDecision {
public function __construct(
public bool $allowed, // хватает на все $count, не роняя проекты
public int $maxAffordable, // M — сколько влезает, не роняя проекты
public string $topupRub, // сколько пополнить, чтобы влезли все $count
) {}
}
- Step 1: Написать падающий unit-тест.
<?php
use App\Models\PricingTier;
use App\Services\Autopodbor\AutopodborBudgetGate;
use Illuminate\Database\Eloquent\Collection;
function tiersFixture(): Collection {
// 1 ступень: 60 ₽/лид, «всё свыше» (leads_in_tier=null) — простая линейная цена для арифметики теста.
return new Collection([
new PricingTier(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 6000]),
]);
}
it('разрешает пакет, когда денег хватает и пакету, и резерву проектов', function () {
$gate = new AutopodborBudgetGate;
// баланс 1000 ₽; проектам нужно 10 лидов * 60 = 600 ₽ (committed=10);
// свободно 400 ₽; пакет 20 * 10 ₽ = 200 ₽ — влезает.
$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;
// баланс 100 ₽, проектов нет → влезает 10 сборов по 10 ₽.
$d = $gate->evaluateBatch('100.00', 0, 0, tiersFixture(), 10, '10.00');
expect($d->allowed)->toBeTrue();
expect($d->maxAffordable)->toBe(10);
});
- Step 2: Запустить тест — убедиться, что падает.
Run: php artisan test tests/Unit/Autopodbor/AutopodborBudgetGateTest.php Expected: FAIL — класс AutopodborBudgetGate не найден.
- Step 3: Реализовать сервис.
<?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 {
// passesAfter(k): проходит ли preflight по committed-лидам, если списать k*price.
$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;
// M — наибольшее k в [0, hardMax], при котором passesAfter(k). Монотонно → бинарный поиск.
$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;
// topup: минимальное пополнение, чтобы влезли все $count.
// Дефицит по штукам = max(0, count - M); в деньгах = дефицит * price (нижняя оценка).
// Уточняем поиском: наименьшее T (в копейках), при котором maxAffordable(balance+T) >= count.
$topupRub = '0.00';
if (! $allowed) {
$deficitUnits = $count - $maxAffordable;
$topupRub = bcmul((string) $deficitUnits, $priceRub, 2);
// Если резерв проектов делает нелинейным — подстрахуемся линейным дефицитом (достаточно для UI-подсказки).
}
return new BudgetDecision(
allowed: $allowed,
maxAffordable: $maxAffordable,
topupRub: $topupRub,
);
}
}
И DTO рядом (или отдельным файлом BudgetDecision.php):
<?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,
) {}
}
- Step 4: Запустить тест — убедиться, что проходит.
Run: php artisan test tests/Unit/Autopodbor/AutopodborBudgetGateTest.php Expected: PASS (3 теста).
- Step 5: Commit.
git add app/app/Services/Autopodbor/AutopodborBudgetGate.php app/app/Services/Autopodbor/BudgetDecision.php app/tests/Unit/Autopodbor/AutopodborBudgetGateTest.php
git commit -m "feat(autopodbor): AutopodborBudgetGate — деньги пакета с резервом проектов"
Task 3: AutopodborStudyScheduler — одна дорожка + честный round-robin
Files:
- Create:
app/app/Services/Autopodbor/AutopodborStudyScheduler.php - Test:
app/tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php
Контракт tick(): void — под Redis-локом; если есть running study — выход; иначе выбрать следующий queued по честному правилу и dispatch. Для тестируемости выбор вынести в чистый метод pickNextRunId(): ?int (без dispatch/лока), а tick() — оркестрация.
- Step 1: Написать падающий unit-тест на порядок выбора.
<?php
use App\Models\AutopodborRun;
use App\Services\Autopodbor\AutopodborStudyScheduler;
// helper: создать study-run напрямую (обходя сервис) в нужном статусе/тенанте/времени.
function studyRun(int $tenant, string $status, ?string $finishedAt = null, ?string $createdAt = null): AutopodborRun {
return AutopodborRun::create([
'tenant_id' => $tenant, 'kind' => 'study', 'status' => $status,
'competitor_id' => null, 'region_code' => 1, 'params' => [],
'finished_at' => $finishedAt, 'created_at' => $createdAt ?? now(),
]);
}
it('дорожка занята: есть running → pickNext возвращает null', function () {
studyRun(1, 'running');
studyRun(2, 'queued');
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBeNull();
});
it('чередует тенантов: наименее недавно обслуженный вперёд', function () {
// тенант 1 обслуживался только что; тенант 2 — давно; оба имеют queued.
studyRun(1, 'done', finishedAt: now()->toDateTimeString());
$t1q = studyRun(1, 'queued');
studyRun(2, 'done', finishedAt: now()->subHour()->toDateTimeString());
$t2q = studyRun(2, 'queued');
// тенант 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('никогда не обслуженный тенант (нет finished_at) имеет приоритет', function () {
studyRun(1, 'done', finishedAt: now()->subDay()->toDateTimeString());
$t1q = studyRun(1, 'queued');
$t2q = studyRun(2, 'queued'); // тенант 2 никогда не обслуживался
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id);
});
Примечание для исполнителя: тесты выбора — межтенантные, поэтому запускать под системным доступом к БД (как решено в Задаче 0 Step 4). Если RLS мешает создавать/читать чужие строки в тесте — использовать тот же системный путь, что и
pickNextRunId()(напр. отдельное соединение/роль), либо тестовый хелпер снятия tenant-scope. НЕ ослаблять RLS-политики ради теста.
- Step 2: Запустить — убедиться, что падает.
Run: php artisan test tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php Expected: FAIL — класс не найден.
- Step 3: Реализовать распорядителя.
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborRun;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class AutopodborStudyScheduler
{
/** Одна дорожка глобально + честный выбор + dispatch. Идемпотентно, под локом. */
public function tick(): void
{
// Лок, чтобы два параллельных tick() не задиспатчили два прогона.
$lock = Cache::lock('autopodbor:study:tick', 10);
if (! $lock->get()) {
return;
}
try {
$runId = $this->pickNextRunId();
if ($runId === null) {
return;
}
$run = $this->systemQuery()->where('id', $runId)->first();
if ($run === null || $run->status !== 'queued') {
return;
}
RunAutopodborStudyJob::dispatch($run->id, $run->tenant_id);
} finally {
$lock->release();
}
}
/**
* Следующий прогон по честному правилу, ИЛИ null если дорожка занята / очередь пуста.
* Правило: если есть running study — занято (одна дорожка). Иначе среди тенантов с queued
* берём наименее недавно обслуженного (min max(finished_at); NULL = никогда → вперёд),
* тай-брейк — самый старый queued тенанта.
*/
public function pickNextRunId(): ?int
{
// Одна дорожка: любой running study по ВСЕМ тенантам блокирует выбор.
$running = $this->systemQuery()->where('kind', 'study')->where('status', 'running')->exists();
if ($running) {
return null;
}
// Кандидаты — самый старый queued каждого тенанта + «когда тенанта обслуживали в последний раз».
// last_served = MAX(finished_at) по всем study-прогонам тенанта (NULL если никогда).
$rows = $this->systemQuery()
->selectRaw('q.id as run_id, q.tenant_id, q.created_at as queued_at, s.last_served')
->fromSub(
$this->systemQuery()
->from('autopodbor_runs')
->where('kind', 'study')->where('status', 'queued')
->select('id', 'tenant_id', 'created_at')
->orderBy('created_at'),
'q'
)
->joinSub(
$this->systemQuery()->from('autopodbor_runs')
->where('kind', 'study')
->groupBy('tenant_id')
->selectRaw('tenant_id, MAX(finished_at) as last_served'),
's',
's.tenant_id', '=', 'q.tenant_id'
)
->get();
if ($rows->isEmpty()) {
return null;
}
// Для каждого тенанта — его самый старый queued (первый по queued_at).
$byTenant = [];
foreach ($rows as $r) {
if (! isset($byTenant[$r->tenant_id]) || $r->queued_at < $byTenant[$r->tenant_id]->queued_at) {
$byTenant[$r->tenant_id] = $r;
}
}
// Выбираем тенанта: NULL last_served (никогда) вперёд; иначе меньший last_served (дольше ждал).
$best = null;
foreach ($byTenant as $cand) {
if ($best === null || $this->servedEarlier($cand, $best)) {
$best = $cand;
}
}
return $best !== null ? (int) $best->run_id : null;
}
/** true, если $a обслуживался раньше $b (или никогда, а $b — обслуживался). Тай-брейк по queued_at. */
private function servedEarlier(object $a, object $b): bool
{
$an = $a->last_served === null;
$bn = $b->last_served === null;
if ($an !== $bn) {
return $an; // «никогда» раньше любого «когда-то»
}
if ($a->last_served !== $b->last_served) {
return (string) $a->last_served < (string) $b->last_served;
}
return (string) $a->queued_at < (string) $b->queued_at;
}
/**
* Системный (межтенантный) билдер БД — распорядителю нужно видеть ВСЕ тенанты.
* Соединение `pgsql_supplier` = роль crm_supplier_worker (BYPASSRLS), штатный системный
* путь queue-воркеров (прецедент RouteSupplierLeadJob / trait SharesSupplierPdo). Задача 0 Step 4.
*/
private function systemQuery(): \Illuminate\Database\Query\Builder
{
return DB::connection('pgsql_supplier')->table('autopodbor_runs');
}
}
RLS-нить:
systemQuery()идёт черезpgsql_supplier(BYPASSRLS) — это и даёт межтенантную видимость. Политики НЕ ослабляем. Прогнатьrls-reviewerна этот сервис. Тесты —uses(SharesSupplierPdo::class).pickNextRunId()функционально полон.
- Step 4: Запустить тест — PASS.
Run: php artisan test tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php Expected: PASS (4 теста).
- Step 5: Commit.
git add app/app/Services/Autopodbor/AutopodborStudyScheduler.php app/tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php
git commit -m "feat(autopodbor): AutopodborStudyScheduler — одна дорожка + round-robin"
Task 4: startStudyBatch + перевод одиночных на модель «queued без прямого dispatch»
Files:
-
Modify:
app/app/Services/Autopodbor/AutopodborRunService.php -
Test:
app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.php -
Step 1: Написать падающий feature-тест постановки пакета.
<?php
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborRunService;
use Illuminate\Support\Facades\Queue;
// Хелперы сидинга тенанта/конкурентов/баланса — по существующим фабрикам движка (см. Задачу 0).
// setTenantContext($tenantId) — как в остальных Feature-тестах автоподбора (RLS).
it('ставит пакет: N прогонов с общим batch_id, джобы НЕ диспатчатся из сервиса', function () {
Queue::fake();
[$tenantId, $compIds] = seedTenantWithCompetitors(count: 3, balanceRub: '10000.00');
setTenantContext($tenantId);
$res = app(AutopodborRunService::class)->startStudyBatch($tenantId, $compIds);
expect($res->queued)->toBe(3);
$runs = AutopodborRun::where('batch_id', $res->batch_id)->get();
expect($runs)->toHaveCount(3);
expect($runs->pluck('status')->unique()->all())->toBe(['queued']);
// Диспатч делает распорядитель (tick), не сам сервис напрямую в цикле — но tick мог задиспатчить ОДИН.
Queue::assertPushed(\App\Jobs\Autopodbor\RunAutopodborStudyJob::class, 1);
});
it('нормализует список: дубли, чужие конкуренты и уже-в-очереди отсеиваются', function () {
Queue::fake();
[$tenantId, $compIds] = seedTenantWithCompetitors(count: 2, balanceRub: '10000.00');
setTenantContext($tenantId);
// один уже в очереди
app(AutopodborRunService::class)->startStudy($tenantId, $compIds[0]);
$res = app(AutopodborRunService::class)->startStudyBatch(
$tenantId, [$compIds[0], $compIds[0], $compIds[1], 999999]
);
// остаётся только compIds[1] (compIds[0] уже в очереди, дубль и чужой 999999 отброшены)
expect($res->queued)->toBe(1);
});
it('нет баланса на весь пакет → 0 прогонов + отказ с topup и max_affordable', function () {
Queue::fake();
// баланс мал, активные проекты требуют резерв → пакет не влезает
[$tenantId, $compIds] = seedTenantWithCompetitors(count: 20, balanceRub: '100.00', committedDailyLeads: 5);
setTenantContext($tenantId);
expect(fn () => app(AutopodborRunService::class)->startStudyBatch($tenantId, $compIds))
->toThrow(\App\Exceptions\Autopodbor\BatchBudgetException::class);
expect(AutopodborRun::where('tenant_id', $tenantId)->where('status', 'queued')->count())->toBe(0);
});
- Step 2: Запустить — FAIL (нет
startStudyBatch/BatchBudgetException).
Run: php artisan test tests/Feature/Autopodbor/StudyBatchEnqueueTest.php Expected: FAIL.
- Step 3: Добавить исключение
app/app/Exceptions/Autopodbor/BatchBudgetException.php:
<?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');
}
}
- Step 4: Реализовать
startStudyBatchи правки сервиса.
В AutopodborRunService добавить зависимости (конструктор): AutopodborBudgetGate, AutopodborStudyScheduler, PricingTierRepository. Затем:
use App\Exceptions\Autopodbor\BatchBudgetException;
use App\Models\Project;
use App\Repositories\PricingTierRepository;
use Illuminate\Support\Str;
/** @param int[] $competitorIds */
public function startStudyBatch(int $tenantId, array $competitorIds): BatchResult
{
// 1. Нормализация: только свои конкуренты, без дублей, без уже стоящих в очереди/работе.
$own = AutopodborCompetitor::where('tenant_id', $tenantId)
->whereIn('id', array_values(array_unique($competitorIds)))
->pluck('id')->all();
$alreadyQueued = AutopodborRun::where('tenant_id', $tenantId)
->where('kind', 'study')
->whereIn('status', ['queued', 'running'])
->pluck('competitor_id')->filter()->all();
$targets = array_values(array_diff($own, $alreadyQueued));
$count = count($targets);
if ($count === 0) {
return new BatchResult(batch_id: null, queued: 0);
}
// 2. Денежный гейт всё-или-ничего с резервом проектов.
$tenant = Tenant::whereKey($tenantId)->firstOrFail();
$price = (string) (\App\Support\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 = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$decision = $this->budgetGate->evaluateBatch(
(string) $tenant->balance_rub, (int) $tenant->delivered_in_month,
$committed, $tiers, $count, $price
);
if (! $decision->allowed) {
throw new BatchBudgetException($decision->topupRub, $decision->maxAffordable);
}
// 3. Создаём queued-прогоны с общим batch_id (джобы не диспатчим — это делает распорядитель).
$batchId = (string) Str::uuid();
foreach ($targets as $competitorId) {
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->find($competitorId);
AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'study',
'batch_id' => $batchId,
'status' => 'queued',
'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code,
'competitor_id' => $comp->id,
'params' => [],
]);
}
// 4. Пинаем распорядителя — стартует первый сразу.
$this->scheduler->tick();
return new BatchResult(batch_id: $batchId, queued: $count);
}
И правка startStudy / startManualStudy: убрать прямой RunAutopodborStudyJob::dispatch(...), вместо него создать queued-прогон с личным batch_id = (string) Str::uuid() и вызвать $this->scheduler->tick();. Жёсткий assertNoInFlight($tenantId, 'study') как стоп-кран ПОСТАНОВКИ — снять (пакет допускает много queued); защиту от повторной постановки того же конкурента обеспечивает дедуп по competitor_id (как в startStudyBatch). Для startStudy перед созданием добавить проверку: если у конкурента уже есть queued/running study — вернуть существующий прогон (идемпотентность двойного клика).
DTO app/app/Services/Autopodbor/BatchResult.php:
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
final readonly class BatchResult
{
public function __construct(public ?string $batch_id, public int $queued) {}
}
- Step 5: Запустить тест — PASS.
Run: php artisan test tests/Feature/Autopodbor/StudyBatchEnqueueTest.php Expected: PASS (3 теста).
- Step 6: Commit.
git add app/app/Services/Autopodbor/AutopodborRunService.php app/app/Services/Autopodbor/BatchResult.php app/app/Exceptions/Autopodbor/BatchBudgetException.php app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.php
git commit -m "feat(autopodbor): startStudyBatch + перевод одиночных на модель распорядителя"
Task 5: RunAutopodborStudyJob зовёт tick() после завершения
Files:
-
Modify:
app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php -
Test:
app/tests/Feature/Autopodbor/StudySchedulerFairnessTest.php -
Step 1: Написать падающий тест «после done берётся следующий».
<?php
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborRun;
use Illuminate\Support\Facades\Queue;
it('после завершения прогона распорядитель ставит следующий из очереди', function () {
Queue::fake();
[$tenantId, $compIds] = seedTenantWithCompetitors(count: 2, balanceRub: '10000.00');
setTenantContext($tenantId);
// Два queued-прогона; ни один пока не running.
$r1 = makeQueuedStudyRun($tenantId, $compIds[0]);
$r2 = makeQueuedStudyRun($tenantId, $compIds[1]);
// Помечаем r1 done (эмулируем конец handle) и зовём хвост джобы, который дёргает tick.
$r1->update(['status' => 'done', 'finished_at' => now()]);
app(\App\Services\Autopodbor\AutopodborStudyScheduler::class)->tick();
Queue::assertPushed(RunAutopodborStudyJob::class, function ($job) use ($r2) {
return $job->runId === $r2->id;
});
});
- Step 2: Запустить — FAIL (пока в конце handle нет tick; но этот тест зовёт tick напрямую — он проверяет, что распорядитель даёт следующий. Если PASS сразу — усилить: проверить, что БЕЗ tick следующий не диспатчится. Для проверки самой правки джобы — Step 3.).
Run: php artisan test tests/Feature/Autopodbor/StudySchedulerFairnessTest.php Expected: см. примечание — тест фиксирует контракт распорядителя.
- Step 3: Внести правку в джобу — в конце
handle()(успех и catch) вызватьtick().
В RunAutopodborStudyJob::handle(...) добавить в сигнатуру зависимость AutopodborStudyScheduler $scheduler и обернуть тело в try/finally, чтобы tick() вызывался ВСЕГДА после завершения прогона (done/empty/failed):
public function handle(
CompetitorAgent $agent,
AutopodborDedup $dedup,
AutopodborChargeService $charge,
AutopodborNormalizer $norm,
RunProgressChannel $progress,
AutopodborStudyScheduler $scheduler, // ← добавить
): void {
try {
// ... существующее тело handle без изменений ...
} finally {
// Дорожка освободилась — распорядитель берёт следующего (честно, по кругу).
$scheduler->tick();
}
}
Импортировать use App\Services\Autopodbor\AutopodborStudyScheduler;. Внимание: существующий catch внутри тела делает throw $e; — finally отработает и на throw (ретрай джобы затем снова поставит running; tick при running ничего не задиспатчит — корректно).
- Step 4: Запустить тест — PASS.
Run: php artisan test tests/Feature/Autopodbor/StudySchedulerFairnessTest.php Expected: PASS.
- Step 5: Commit.
git add app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php app/tests/Feature/Autopodbor/StudySchedulerFairnessTest.php
git commit -m "feat(autopodbor): джоба шага 2 дёргает распорядителя после завершения"
Task 6: Крон-страховка autopodbor:study-tick
Files:
-
Create:
app/app/Console/Commands/AutopodborStudyTickCommand.php -
Modify: регистрация расписания (
app/routes/console.phpилиbootstrap/app.php/Kernel— уточнить в Задаче 0). -
Step 1: Написать команду.
<?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;
}
}
- Step 2: Зарегистрировать в расписании
everyMinute()(место — по Задаче 0). Пример дляroutes/console.php(L11+):
use Illuminate\Support\Facades\Schedule;
Schedule::command('autopodbor:study-tick')->everyMinute()->withoutOverlapping();
- Step 3: Проверить, что команда есть и запускается.
Run: php artisan autopodbor:study-tick Expected: код 0, без ошибок (на пустой очереди — тихо ничего не делает).
- Step 4: Commit.
git add app/app/Console/Commands/AutopodborStudyTickCommand.php app/routes/console.php
git commit -m "feat(autopodbor): крон-страховка autopodbor:study-tick everyMinute"
Task 7: API endpoint пакета POST /api/autopodbor/study/batch
Files:
-
Modify:
app/app/Http/Controllers/Api/AutopodborController.php,app/routes/api.php(путь — по Задаче 0) -
Test: дополнить
app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.phpHTTP-кейсами. -
Step 1: Написать падающий HTTP-тест.
it('POST /api/autopodbor/study/batch → 202 с batch_id и queued', function () {
[$tenantId, $compIds] = seedTenantWithCompetitors(count: 3, balanceRub: '10000.00');
$resp = actingAsTenant($tenantId)->postJson('/api/autopodbor/study/batch', [
'competitor_ids' => $compIds,
]);
$resp->assertStatus(202)->assertJsonStructure(['batch_id', 'queued']);
expect($resp->json('queued'))->toBe(3);
});
it('POST batch без баланса → 422 с topup_rub и max_affordable', function () {
[$tenantId, $compIds] = seedTenantWithCompetitors(count: 20, balanceRub: '100.00', committedDailyLeads: 5);
$resp = actingAsTenant($tenantId)->postJson('/api/autopodbor/study/batch', [
'competitor_ids' => $compIds,
]);
$resp->assertStatus(422)->assertJsonStructure(['message', 'topup_rub', 'max_affordable']);
});
actingAsTenant— по существующему тест-хелперу авторизации кабинета (см. другие Feature-тесты автоподбора, Задача 0).
-
Step 2: Запустить — FAIL (нет маршрута).
-
Step 3: Добавить метод контроллера + маршрут.
В AutopodborController:
use App\Exceptions\Autopodbor\BatchBudgetException;
public function studyBatch(Request $request, AutopodborRunService $svc): JsonResponse
{
$data = $request->validate([
'competitor_ids' => ['required', 'array', 'min:1'],
'competitor_ids.*' => ['integer'],
]);
$uid = (int) $request->user()->tenant_id; // как в существующих методах контроллера (см. study())
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);
}
Маршрут — в app/routes/web.php, внутри существующей группы Route::middleware(['auth:sanctum,impersonation','tenant'])->prefix('/api/autopodbor'), рядом с POST /study (строка ~359):
Route::post('/study/batch', 'App\Http\Controllers\Api\AutopodborController@studyBatch');
Способ получить tenant/
$uidскопировать 1:1 из существующегоstudy()вAutopodborController— не изобретать (там уже разрешён tenant из middlewaretenant).
- Step 4: Запустить тесты — PASS.
Run: php artisan test tests/Feature/Autopodbor/StudyBatchEnqueueTest.php Expected: PASS.
- Step 5: Commit.
git add app/app/Http/Controllers/Api/AutopodborController.php app/routes/api.php app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.php
git commit -m "feat(autopodbor): endpoint POST /api/autopodbor/study/batch"
Task 8: Фронт — кнопка в подвале выбора + яркий алерт баланса + статусы
Files: (уточнено разведкой Задачи 0)
-
Modify:
app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue(список + нижний подвал выбора «Выбрано конкурентов: N»),app/resources/js/api/autopodbor.ts, при необходимостиapp/resources/js/stores/autopodborStore.ts. Исполнителю: открытьFieldWorkspaceScreen.vueи подтвердить, что подвал выбора именно там (иначе — соседнийFieldCompetitorScreen.vue). -
Test:
app/tests/Frontend/KonkurentnoePoleBatch.spec.ts -
Step 1: Добавить API-вызов в фронтовый api-модуль.
// resources/js/api/autopodbor.ts (имя уточнить в Задаче 0)
export interface StudyBatchOk { batch_id: string; queued: number; }
export interface StudyBatchDenied { message: string; topup_rub: string; max_affordable: number; }
export async function startStudyBatch(competitorIds: number[]): Promise<StudyBatchOk> {
await ensureCsrfCookie();
const { data } = await apiClient.post<StudyBatchOk>('/api/autopodbor/study/batch', {
competitor_ids: competitorIds,
});
return data;
}
- Step 2: Написать падающий Vitest на подвал.
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import { createVuetify } from 'vuetify';
// import KonkurentnoePoleView from '...'; // путь из Задачи 0
const vuetify = createVuetify();
describe('Конкурентное поле — пакетный сбор источников', () => {
it('в подвале выбора есть кнопка «Собрать источники (N)» с числом выбранных', async () => {
const w = mount(/* KonkurentnoePoleView */, { global: { plugins: [vuetify] } });
// выбрать 3 конкурента (через vm/props согласно реализации)
// ...
const btn = w.find('[data-testid="batch-collect-sources"]');
expect(btn.exists()).toBe(true);
expect(btn.text()).toContain('3');
});
it('при 422 нет баланса — показывает яркий алерт с topup и max_affordable', async () => {
// мокнуть startStudyBatch → reject 422 { message, topup_rub:'150.00', max_affordable:5 }
const w = mount(/* KonkurentnoePoleView */, { global: { plugins: [vuetify] } });
// выбрать и нажать кнопку
// ...
const alert = w.find('[data-testid="batch-balance-alert"]');
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('150');
expect(alert.text()).toContain('5');
});
});
- Step 3: Запустить — FAIL.
Run: npx vitest run tests/Frontend/KonkurentnoePoleBatch.spec.ts Expected: FAIL.
-
Step 4: Реализовать в компоненте.
-
В нижнем подвале выбора (там же, где «Включить все созданные проекты») добавить primary-кнопку
data-testid="batch-collect-sources": текст «Собрать источники ({{ selected.length }})»,:disabled="selected.length === 0",@click="collectSources". -
collectSources(): вызватьstartStudyBatch(selectedIds). Успех → тост «Поставлено в очередь: N. Сбор идёт сам — можно закрыть страницу»; снять выбор; обновить статусы. Ошибка 422 → показать яркийv-alert type="warning"/"error"data-testid="batch-balance-alert"с текстом «Не хватает баланса. Пополните на {{ topup_rub }} ₽ или уменьшите выборку до {{ max_affordable }} конкурентов.» + кнопка «Пополнить» (ссылка в биллинг). -
Карточка конкурента: индикатор статуса из последнего study-прогона («в очереди» / «идёт сбор» / «готово» / «отменено — нет баланса»).
-
Step 5: Запустить тест — PASS.
Run: npx vitest run tests/Frontend/KonkurentnoePoleBatch.spec.ts Expected: PASS.
- Step 6: Commit.
git add app/resources/js/...
git commit -m "feat(autopodbor): подвал «Собрать источники (N)» + яркий алерт баланса"
Task 9: Полный прогон и верификация
- Step 1: Регрессия бэкенда —
php artisan test(Pest, последовательно в свежем worktree; при флаках--parallel— см.pest-parallel-debugger). Expected: зелёно, включая новые Unit/Feature. - Step 2: Регрессия фронта —
npx vitest run. Expected: зелёно. Плюсnpx eslint <изменённые>иnpx vue-tsc --noEmit. - Step 3:
/regression full(по возможности) или как минимумquick. Larastan/Pint по изменённым PHP-файлам. - Step 4: Ручная проверка сценария (dev/тест-стенд, НЕ прод): два тенанта ставят пакеты → прогоны исполняются вперемешку (по кругу), один сбор в моменте; нет баланса → 0 прогонов + яркий алерт; статусы «X из N» тикают.
- Step 5: Верификация перед готовностью (skill
superpowers:verification-before-completion): привести реальные выводы команд, а не «должно работать». - Step 6: Финальный обзорный commit при необходимости и подготовка к ревью/выкату (выкат — отдельно, по прод-runbook +
prod-deploy-validator, с явного разрешения владельца).
Заметки
- RLS красная нить: распорядитель и «одна дорожка глобально» — межтенантные; системный путь доступа взять из существующих джоб (Задача 0 Step 4), НЕ ослаблять политики. Прогнать
rls-reviewerна миграцию (Задача 1) и на распорядителя (Задача 3). - Деньги: только bcmath/копейки; списание — существующий идемпотентный
AutopodborChargeService::chargeForRunper-run. Гейт пакета — только предварительная проверка, не «бронь» (принятая оговорка спека §4.4). - Ветка/выкат: реализация на ветке движка (Задача 0), не на
feat/sales-portal-demo. Выкат на боевой — отдельно, только с разрешения владельца, поdocs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md(тут будут миграция + бэкенд + фронт — это НЕ чисто-фронтовый выкат, нужен composer/optimize и применение миграции черезpostgressuperuser).