From b2da19c8d0601d9a7cd5036cb3d2038a4245d4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 8 Jul 2026 17:02:25 +0300 Subject: [PATCH] =?UTF-8?q?docs(autopodbor):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=20+=20=D1=87=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD-задачи 0-9: миграция batch_id, AutopodborBudgetGate, AutopodborStudyScheduler, startStudyBatch, tick в джобе, крон-страховка, endpoint, фронт-подвал, верификация. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/observer/STATUS.md | 2 +- ...07-08-autopodbor-step2-batch-fair-queue.md | 1023 +++++++++++++++++ 2 files changed, 1024 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-08-autopodbor-step2-batch-fair-queue.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 7e1f7cfd..ff0d63df 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-08T12:10:22.168Z +Last updated: 2026-07-08T13:51:29.033Z | Контролёр | Состояние | Детали | |---|---|---| diff --git a/docs/superpowers/plans/2026-07-08-autopodbor-step2-batch-fair-queue.md b/docs/superpowers/plans/2026-07-08-autopodbor-step2-batch-fair-queue.md new file mode 100644 index 00000000..3a1549d9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-autopodbor-step2-batch-fair-queue.md @@ -0,0 +1,1023 @@ +# Пакетный сбор источников (шаг 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](../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: +```bash +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` и т.д. Это — БАЗОВАЯ ветка. (Ожидаемо — линия автоподбора, влитая в боевую.) Зафиксировать её имя как ``. + +- [ ] **Step 2: Создать изолированный worktree от базовой ветки** (skill `superpowers:using-git-worktrees`): + +Run: +```bash +git worktree add --detach ../wt-autopodbor-batch +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: +```bash +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 по ВСЕМ тенантам». Свериться, как это делают существующие джобы/команды (поиск роли/обхода RLS): + +Run: +```bash +grep -rn "set_config('app.current_tenant\|BYPASSRLS\|crm_supplier_worker\|withoutTenantScope\|SET app.current_tenant" app/ | head -20 +``` +Expected: понять паттерн системного доступа (напр. отдельное соединение/роль в джобах). Заложить его в Задачу 3. Если единого паттерна нет — использовать выделенный запрос от системной роли; согласовать с `rls-reviewer`. + +--- + +## 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 +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.** + +```bash +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` + +Контракт: +```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 + 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 + $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 + $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 +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; + } + + /** + * Системный (межтенантный) билдер БД — распорядителю нужно видеть ВСЕ тенанты. + * ПОДСТАВИТЬ реальный путь из Задачи 0 Step 4 (отдельное соединение/роль crm_supplier_worker + * или системный запрос без tenant-контекста). Ниже — плейсхолдер-обёртка для читаемости. + */ + private function systemQuery(): \Illuminate\Database\Query\Builder + { + // TODO(Task 0 Step 4): вернуть builder на системном соединении с обходом RLS. + return DB::connection($this->systemConnectionName())->table('autopodbor_runs'); + } + + private function systemConnectionName(): string + { + // Имя соединения из config/database.php, под которым джобы автоподбора уже ходят межтенантно. + return config('autopodbor.system_connection', config('database.default')); + } +} +``` + +> **Важно исполнителю:** `systemQuery()`/`systemConnectionName()` — единственное место с неопределённостью. В Задаче 0 Step 4 найден реальный способ межтенантного доступа (как в существующих джобах). Подставить его сюда ДО прогона межтенантных тестов; согласовать с `rls-reviewer`. Функционально `pickNextRunId()` уже полон. + +- [ ] **Step 4: Запустить тест — PASS.** + +Run: `php artisan test tests/Unit/Autopodbor/AutopodborStudySchedulerTest.php` Expected: PASS (4 теста). + +- [ ] **Step 5: Commit.** + +```bash +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 +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 +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 +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): + +```php +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.** + +```bash +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 +tick(); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 2: Зарегистрировать в расписании `everyMinute()`** (место — по Задаче 0). Пример для `routes/console.php` (L11+): +```php +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.** + +```bash +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-тест.** + +```php +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`: +```php +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); +} +``` +Маршрут (рядом с существующим `study`): +```php +Route::post('autopodbor/study/batch', [AutopodborController::class, 'studyBatch']); +``` +> Способ получить tenant/`$uid` скопировать 1:1 из существующего `study()` (Задача 0 Step 3) — не изобретать. + +- [ ] **Step 4: Запустить тесты — PASS.** + +Run: `php artisan test tests/Feature/Autopodbor/StudyBatchEnqueueTest.php` Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +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 Step 3) +- Modify: Vue-компонент «Конкурентное поле» (список + нижний подвал выбора), api-модуль автоподбора. +- Test: `app/tests/Frontend/KonkurentnoePoleBatch.spec.ts` + +- [ ] **Step 1: Добавить API-вызов в фронтовый api-модуль.** + +```ts +// 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 { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/autopodbor/study/batch', { + competitor_ids: competitorIds, + }); + return data; +} +``` + +- [ ] **Step 2: Написать падающий Vitest на подвал.** + +```ts +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.** + +```bash +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). +```