Files
portal/docs/superpowers/plans/2026-07-08-autopodbor-step2-batch-fair-queue.md
T

53 KiB
Raw Blame History

Пакетный сбор источников (шаг 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 — endpoint studyBatch (+ опц. cancelBatch).
  • app/routes/api.php (или web.php — уточнить в Задаче 0) — маршруты.
  • app/app/Console/Kernel.php (или bootstrap/app.php schedule в L11+ — уточнить) — регистрация autopodbor:study-tick everyMinute.
  • Фронт «Конкурентное поле» — кнопка в подвале выбора + яркий алерт баланса + статусы (файлы уточнить в Задаче 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-контекст в системных (межтенантных) запросах. Распорядителю нужен запрос «есть ли running study по ВСЕМ тенантам».

РЕЗУЛЬТАТ РАЗВЕДКИ (2026-07-08, заполнено):

  • Джобы автоподбора ходят под crm_app_user (FORCE RLS) и ставят set_config('app.current_tenant_id', ...) per-tenant — межтенантного пути у них нет.
  • Штатный системный путь в проекте: роль crm_supplier_worker (BYPASSRLS) через соединение pgsql_supplier (trait SharesSupplierPdo, прецедент — 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.php HTTP-кейсами.

  • 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 из middleware tenant).

  • 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::chargeForRun per-run. Гейт пакета — только предварительная проверка, не «бронь» (принятая оговорка спека §4.4).
  • Ветка/выкат: реализация на ветке движка (Задача 0), не на feat/sales-portal-demo. Выкат на боевой — отдельно, только с разрешения владельца, по docs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md (тут будут миграция + бэкенд + фронт — это НЕ чисто-фронтовый выкат, нужен composer/optimize и применение миграции через postgres superuser).