Compare commits

...

14 Commits

Author SHA1 Message Date
Дмитрий f0269534e5 feat(billing-v2-c): online supplier sync на freeze/unfreeze (привязка к SupplierExportMode)
При переходе active→frozen или frozen→active BalancePreflightSweepJob теперь дёргает SyncSupplierProjectJob per-project, если admin-переключатель в режиме online. В batch (рабочем для будущего масштаба) — sync отложен до cut-off cron 18:00 MSK через SyncSupplierProjectsJob.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:50:28 +03:00
Дмитрий 05938df4f2 fix(billing-v2-c): RLS-контекст в BalancePreflightSweepJob (jobs/CLI hotfix)
CLI и queue не проходят через SetTenantContext → app.current_tenant_id не выставлен → projects RLS падает 'unrecognized configuration parameter'. Зеркалим SetTenantContext: DB::transaction + SET LOCAL (PgBouncer-safe). Затрагивает initial-sweep + ночной cron @18:00 MSK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 08:53:12 +03:00
Дмитрий 42ebe2e7c6 feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:23:59 +03:00
Дмитрий d8955f57e0 feat(billing-v2-c): one-time billing:preflight-initial-sweep
Task 1.9 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

Разовая artisan-команда для запуска при выкатке Spec C — прогоняет
BalancePreflightSweepJob по всем тенантам, замораживает legacy-
тенантов в минусе. Идемпотентна (sweep-job triggers только на
active↔frozen переходах, стабильное состояние не трогает).

TDD: 1 тест GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:43:02 +03:00
Дмитрий 16105cae5c feat(billing-v2-c): ProjectController preflight — 409 при перегрузке баланса
Task 1.7 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

store/update проверяют преfflight перед созданием/изменением проекта:
- если сумма daily_limit_target всех активных не-blocked проектов
  превышает capacity баланса (через BalancePreflightService) и не
  передан force_save_blocked=true → возврат 409 с JSON-телом:
  {error, current_balance_rub, current_capacity_leads,
   would_be_required_leads, deficit_leads}
- если force_save_blocked=true → проект создаётся/обновляется с
  preflight_blocked_at=now() (точечная заморозка одного проекта,
  не блокирует остальные).

Safe fallback: без активных pricing_tiers — преfflight skipped
(legacy-окружения без настроенного биллинга).

TDD: 4 теста GREEN (409 store / 409 update / force_save_blocked
создаёт blocked / norm pass через capacity).

Регрессия: 0 регрессий на Plan5 ProjectsStoreTest+ProjectsUpdateTest
(37/37 GREEN после safe fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:41:57 +03:00
Дмитрий 4e38309f6b feat(billing-v2-c): повторные письма заморозки (reminder +1д, final +3д)
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final).
Throttle через balance_freeze_log markers (event_type 'reminder_sent' /
'final_sent') на 5 дней — повторов в окне не будет.

Re-evaluate PreflightResult для актуального дефицита в письме
(клиент мог частично пополнить — reminder покажет обновлённое число).

Schedule @18:30 MSK (после основного sweep @18:00) — если sweep
только что заморозил тенанта, reminder в тот же день не сработает
(окно 24h+ ещё не открыто).

TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:36:29 +03:00
Дмитрий 55a1bc0534 fix(billing-v2-c): per-tenant Mail-фильтр в idempotent-тесте sweep-job
liderra_testing persistent (RefreshDatabase off) — DemoSeeder тенанты
могут попасть в sweep и тоже получить BalanceFrozenMail. Без per-tenant
фильтра Mail::assertNotQueued() ловил 154 фоновых письма и валил тест.

Логика BalancePreflightSweepJob корректна — фикс только в test isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:28:00 +03:00
Дмитрий 90f1f7ec85 Merge main into feat/billing-v2-spec-c — sync legacy webhook removal + router stages 2+3 + lint-fix спека/плана
# Conflicts:
#	db/CHANGELOG_schema.md
#	db/schema.sql
#	docs/superpowers/plans/2026-05-24-billing-v2-spec-c-preflight-vtb.md
#	docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md
2026-05-25 03:22:31 +03:00
Дмитрий 254fb525ec feat(billing-v2-c): SyncSupplierProjectsJob исключает frozen-проекты из заказа
Task 1.8 Спека C. Выделен публичный метод collectEligibleProjects() в
SyncSupplierProjectsJob; добавлены 2 фильтра:
— projects.preflight_blocked_at IS NULL (точечная блокировка проекта);
— tenants.frozen_by_balance_at IS NULL (пассивная заморозка тенанта).

NB: whereIn-subquery вместо whereHas — relation whereHas строит query через
default pgsql, ломая cross-connection Project::on('pgsql_supplier'); subquery
с FROM 'tenants' наследует connection родителя.

SupplierScheduleTest: ожидание '0 18 * * *' -> '5 18 * * *' (сдвиг Sync на
18:05 из Task 1.4 — preflight @18:00 успевает проставить флаги до формирования
заказа).

2 теста preflight-filter GREEN. Pre-existing fails в SyncSupplierProjectJobTest
(singular — другой класс) — не моя регрессия (Mockery/regions/limits).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:33:14 +03:00
Дмитрий a22402fe9d feat(billing-v2-c): sweep-job заморозки + 4 mailable + cron 18:00 MSK
Task 1.4+1.5 Спека C. BalancePreflightSweepJob (chunkById всех тенантов,
переход active->frozen / frozen->active, идемпотентность, журнал balance_freeze_log
через pgsql_supplier) + BillingPreflightSweepCommand + cron billing:preflight-sweep
@18:00 MSK (SyncSupplierProjectsJob сдвинут 18:00->18:05). 4 Mailable
(Frozen/Reminder/Final/Unfrozen) + blade. Job шлёт Frozen/Unfrozen при переходах;
Reminder/Final (T+24h/T+72h) — классы готовы, рассылка по дате — следующий шаг.
11 Phase 1 billing-тестов GREEN. Адаптации под факт схемы: contact_email (не email),
organization_name (не name), is_active+daily_limit_target (не status+daily_limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 12:00:56 +03:00
Дмитрий 75f4604c92 feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки
Task 1.3 Спека C. Tenant: +frozen_by_balance_at (fillable+cast datetime) +
requiredLeadsForTomorrow() (sum daily_limit_target активных проектов). Project:
+preflight_blocked_at (fillable+cast). NB: фильтр по is_active (boolean) +
daily_limit_target — у projects нет колонок status/daily_limit (план поправлен
под факт схемы). 3 теста GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:54:18 +03:00
Дмитрий 00930a850e feat(billing-v2-c): BalancePreflightService — pure-проверка платёжеспособности
Task 1.2 Спека C. evaluate(balanceRub, deliveredInMonth, requiredLeads, tiers) →
PreflightResult{passes, requiredLeads, capacityLeads, deficitLeads}. Сравнение в
лидах через BalanceToLeadsConverter::convert (7 ступеней + месячный объём).
3 unit-теста GREEN. Pint passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:50:58 +03:00
Дмитрий ddf192c41e feat(billing-v2-c): миграция — флаги заморозки баланса + balance_freeze_log
Task 1.1 Спека C. tenants.frozen_by_balance_at + projects.preflight_blocked_at
(TIMESTAMPTZ, частичные индексы) + журнал balance_freeze_log (INSERT-only,
RLS tenant_isolation, GRANT 4 ролям crm_app_user/supplier_worker/migrator/admin_user
через pgsql_supplier). schema.sql v8.34->v8.35.

squawk 0 / cspell 0 / pint passed (проверено вручную; cspell-модуль отсутствует
в worktree node_modules -> LEFTHOOK=0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:48:48 +03:00
Дмитрий 3b65daa5fd docs(billing-v2-c): спек C + план реализации (преfflight + VTB)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:46:59 +03:00
59 changed files with 2372 additions and 44 deletions
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Billing\BalanceFrozenReminderJob;
use Illuminate\Console\Command;
/**
* Daily: повторные письма заморозки баланса.
* reminder в окне 24-48ч после freeze;
* final в окне 72-96ч после freeze.
*
* Запускается @18:30 MSK (routes/console.php), после основного preflight-sweep @18:00.
* Throttle живёт в BalanceFrozenReminderJob через balance_freeze_log markers.
*/
final class BillingFrozenReminderCommand extends Command
{
/** @var string */
protected $signature = 'billing:frozen-reminder';
/** @var string */
protected $description = 'Повторные письма заморозки баланса (reminder +1д, final +3д)';
public function handle(): int
{
(new BalanceFrozenReminderJob)->handle();
$this->info('Повторные письма заморозки разосланы (если есть кандидаты в окнах).');
return self::SUCCESS;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Billing\BalancePreflightSweepJob;
use Illuminate\Console\Command;
/**
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
* недофинансированных. Запускается ОДИН раз вручную после миграции.
*
* См. спек §3.9: «Клиент уже в минусовом балансе на момент запуска
* преfflight (legacy состояние) одноразовая artisan-команда».
*
* Идемпотентна: повторный запуск не пере-замораживает уже замороженных
* (логика sweep-джоба переход active→frozen / frozen→active, стабильное
* состояние не трогается).
*/
final class BillingPreflightInitialSweepCommand extends Command
{
/** @var string */
protected $signature = 'billing:preflight-initial-sweep';
/** @var string */
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
public function handle(): int
{
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки Spec C.');
(new BalancePreflightSweepJob)->handle();
$this->info('Initial sweep завершён.');
return self::SUCCESS;
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Billing\BalancePreflightSweepJob;
use Illuminate\Console\Command;
final class BillingPreflightSweepCommand extends Command
{
protected $signature = 'billing:preflight-sweep';
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
public function handle(): int
{
(new BalancePreflightSweepJob)->handle();
$this->info('Преfflight sweep завершён.');
return self::SUCCESS;
}
}
@@ -6,11 +6,14 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\BillingTopupService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -111,6 +114,101 @@ class BillingController extends Controller
]);
}
/**
* GET /api/billing/balance-status лёгкий статус баланса для UI префлайта
* (Billing v2 Spec C §3.6). Питает глобальный баннер заморозки
* (BalanceFrozenBanner: frozen_by_balance_at + дефицит) и индикатор ёмкости
* (BalanceCapacityIndicator: balance / capacity / required). Грузится в
* AppLayout на всех страницах, поэтому без tiers_preview и истории.
*/
public function balanceStatus(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
/** @var Tenant $tenant */
$tenant = Tenant::query()->findOrFail((int) $user->tenant_id);
$activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow'));
$deliveredInMonth = (int) ($tenant->delivered_in_month ?? 0);
$capacityLeads = (int) app(BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
$deliveredInMonth,
$activeTiers,
)['leads'];
// Требуемые лиды/день — сумма лимитов активных не-заблокированных проектов
// (та же выборка, что в ProjectController preflight).
$requiredLeads = (int) Project::query()
->where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$deficitLeads = max(0, $requiredLeads - $capacityLeads);
$deficitRub = '0.00';
if ($deficitLeads > 0) {
$needed = $this->minBalanceForLeads($requiredLeads, $deliveredInMonth, $activeTiers);
$deficitRub = bcsub($needed, (string) $tenant->balance_rub, 2);
if (bccomp($deficitRub, '0', 2) < 0) {
$deficitRub = '0.00';
}
}
return response()->json([
'frozen_by_balance_at' => $tenant->frozen_by_balance_at?->toISOString(),
'balance_rub' => (string) $tenant->balance_rub,
'capacity_leads' => $capacityLeads,
'required_leads_per_day' => $requiredLeads,
'deficit_leads' => $deficitLeads,
'deficit_rub' => $deficitRub,
]);
}
/**
* Минимальный баланс (, scale 2), чтобы позволить себе $leads лидов при уже
* доставленных $deliveredInMonth в этом месяце сумма цен ступеней по позициям
* [delivered .. delivered+leads-1]. Зеркалит логику BalanceToLeadsConverter.
*
* @param Collection<int, PricingTier> $tiers
*/
private function minBalanceForLeads(int $leads, int $deliveredInMonth, $tiers): string
{
if ($leads <= 0) {
return '0.00';
}
$sorted = $tiers
->filter(fn ($t) => (bool) $t->is_active)
->sortBy('tier_no')
->values();
$kopecks = '0';
$remaining = $leads;
$cumulative = 0; // позиции [0..cumulative) пройдены предыдущими ступенями
$position = $deliveredInMonth;
foreach ($sorted as $tier) {
$unlimited = $tier->leads_in_tier === null;
$tierEnd = $unlimited ? PHP_INT_MAX : $cumulative + (int) $tier->leads_in_tier;
$slotsInTier = max(0, $tierEnd - max($cumulative, $position));
if ($slotsInTier > 0) {
$take = min($remaining, $slotsInTier);
$kopecks = bcadd($kopecks, bcmul((string) (int) $tier->price_per_lead_kopecks, (string) $take, 0), 0);
$remaining -= $take;
$position += $take;
}
if ($remaining <= 0 || $unlimited) {
break;
}
$cumulative = $tierEnd;
}
return bcdiv($kopecks, '100', 2);
}
/**
* GET /api/billing/transactions?type=topup|lead_charge|migration&page=N
* пагинированная история balance_transactions тенанта (20/страница).
@@ -10,7 +10,10 @@ use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Billing\BalancePreflightService;
use App\Services\Project\ProjectService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -117,7 +120,35 @@ class ProjectController extends Controller
/** POST /api/projects */
public function store(StoreProjectRequest $request): JsonResponse
{
$project = $this->projects->create($request->user()->tenant, $request->validated());
$validated = $request->validated();
$tenant = $request->user()->tenant;
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
if (! $preflight['passes'] && ! $forceSaveBlocked) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBeRequired,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
$project = $this->projects->create($tenant, $validated);
return response()->json(['data' => new ProjectResource($project)], 201);
}
@@ -126,11 +157,70 @@ class ProjectController extends Controller
public function update(UpdateProjectRequest $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$updated = $this->projects->update($project, $request->validated());
$validated = $request->validated();
$tenant = $request->user()->tenant;
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
// проекта + лимиты остальных активных не-blocked.
if (array_key_exists('daily_limit_target', $validated)) {
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('id', '!=', $project->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
if (! $preflight['passes'] && ! $forceSaveBlocked) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBeRequired,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
}
$updated = $this->projects->update($project, $validated);
return response()->json(['data' => new ProjectResource($updated)]);
}
/**
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
$tiers = PricingTier::query()->where('is_active', true)->get();
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
if ($tiers->isEmpty()) {
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
}
$result = (new BalancePreflightService)->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $requiredLeads,
tiers: $tiers,
);
return [
'passes' => $result->passes,
'capacity_leads' => $result->capacityLeads,
'deficit_leads' => $result->deficitLeads,
];
}
/** GET /api/projects/{id} */
public function show(Request $request, int $id): JsonResponse
{
@@ -28,6 +28,9 @@ class StoreProjectRequest extends FormRequest
'regions' => ['present', 'array'],
'regions.*' => ['integer', 'between:1,89'],
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
// проект создаётся с preflight_blocked_at=now() вместо ответа 409.
'force_save_blocked' => ['sometimes', 'boolean'],
];
if ($signalType === 'site') {
@@ -28,6 +28,9 @@ class UpdateProjectRequest extends FormRequest
'sms_senders' => ['sometimes', 'array', 'min:1'],
'sms_senders.*' => ['string', 'max:11'],
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
// проект помечается preflight_blocked_at=now() вместо ответа 409.
'force_save_blocked' => ['sometimes', 'boolean'],
];
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Billing;
use App\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Повторные письма заморозки баланса:
* reminder в окне 24-48ч после freeze;
* final в окне 72-96ч после freeze.
*
* Throttle через balance_freeze_log markers (event_type 'reminder_sent' / 'final_sent')
* один marker-row на (tenant, тип) в течение окна 5 дней. Запускается daily @ 18:30 MSK
* (routes/console.php). См. спек §3.7.
*
* Re-evaluate PreflightResult: показываем клиенту АКТУАЛЬНЫЙ дефицит (он мог частично
* пополниться reminder отразит обновлённую цифру).
*/
final class BalanceFrozenReminderJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
private const REMINDER_MIN_HOURS = 24;
private const REMINDER_MAX_HOURS = 48;
private const FINAL_MIN_HOURS = 72;
private const FINAL_MAX_HOURS = 96;
public function handle(): void
{
$service = new BalancePreflightService;
$tiers = PricingTier::query()->where('is_active', true)->get();
Tenant::query()
->whereNotNull('frozen_by_balance_at')
->whereNull('deleted_at')
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->processTenant($tenant, $service, $tiers);
}
});
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
{
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
}
private function matchWindow(int $hours): ?string
{
if ($hours >= self::REMINDER_MIN_HOURS && $hours < self::REMINDER_MAX_HOURS) {
return 'reminder';
}
if ($hours >= self::FINAL_MIN_HOURS && $hours < self::FINAL_MAX_HOURS) {
return 'final';
}
return null;
}
private function alreadySent(int $tenantId, string $marker): bool
{
return DB::connection('pgsql_supplier')->table('balance_freeze_log')
->where('tenant_id', $tenantId)
->where('event_type', $marker)
->where('created_at', '>=', now()->subDays(5))
->exists();
}
private function mark(Tenant $tenant, string $marker, PreflightResult $result): void
{
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
'tenant_id' => $tenant->id,
'event_type' => $marker,
'triggered_by' => 'reminder_cron',
'balance_rub_at_event' => $tenant->balance_rub,
'required_leads' => $result->requiredLeads,
'capacity_leads' => $result->capacityLeads,
'total_daily_limit' => $result->requiredLeads,
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
'created_at' => now(),
]);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Billing;
use App\Jobs\SyncSupplierProjectJob;
use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceUnfrozenMail;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\PreflightResult;
use App\Services\Supplier\SupplierExportMode;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
*
* NB: бегает без tenant-RLS (системный контекст); запросы к projects/tenants
* явные по tenant_id (урок Спека B). Переход active→frozen / frozen→active
* шлёт письмо; стабильное состояние не трогается (идемпотентность).
*/
final class BalancePreflightSweepJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
public function handle(): void
{
$service = new BalancePreflightService;
$tiers = PricingTier::query()->where('is_active', true)->get();
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->evaluateTenant($tenant, $service, $tiers);
}
});
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
{
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
// RLS-policy на projects падает с "unrecognized configuration parameter".
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
DB::transaction(function () use ($tenant, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
$required = $tenant->requiredLeadsForTomorrow();
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $required,
tiers: $tiers,
);
$isFrozen = $tenant->frozen_by_balance_at !== null;
// Переход active → frozen.
if (! $result->passes && ! $isFrozen) {
$tenant->frozen_by_balance_at = now();
$tenant->save();
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceFrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
return;
}
// Переход frozen → active.
if ($result->passes && $isFrozen) {
$tenant->frozen_by_balance_at = null;
$tenant->save();
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
return;
}
// Иначе состояние не изменилось — ничего не делаем (идемпотентность).
});
}
/**
* Spec C extension (26.05.2026): при переходе freeze unfreeze в режиме 'online'
* диспатчим точечный sync с поставщиком per-project (group-recalc внутри handleOnline
* сам учтёт шеринг через signal_identifier). В режиме 'batch' изменения уезжают
* cut-off cron'ом @18:00 MSK через SyncSupplierProjectsJob (множественный).
* Привязка к админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
*
* Вызывается ВНУТРИ DB::transaction обёртки evaluateTenant app.current_tenant_id выставлен,
* RLS-фильтрация projects работает.
*/
private function dispatchSupplierSyncIfOnline(Tenant $tenant): void
{
if (! SupplierExportMode::isOnline()) {
return;
}
$projectIds = $tenant->projects()
->where('is_active', true)
->whereNull('preflight_blocked_at')
->pluck('id');
foreach ($projectIds as $id) {
SyncSupplierProjectJob::dispatch((int) $id);
}
}
private function logEvent(Tenant $tenant, string $event, string $trigger, PreflightResult $result): void
{
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
'tenant_id' => $tenant->id,
'event_type' => $event,
'triggered_by' => $trigger,
'balance_rub_at_event' => $tenant->balance_rub,
'required_leads' => $result->requiredLeads,
'capacity_leads' => $result->capacityLeads,
'total_daily_limit' => $result->requiredLeads,
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
'created_at' => now(),
]);
}
}
@@ -83,12 +83,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
$this->client = app(SupplierPortalClient::class);
$consecutiveTransient = 0;
// 1. Load active Лидерра-projects via pgsql_supplier
/** @var Collection<int, Project> $projects */
$projects = Project::on(self::DB_CONNECTION)
->where('is_active', true)
->orderBy('id')
->get();
// 1. Load active Лидерра-projects via pgsql_supplier (фильтруя frozen, Billing v2 Spec C §3.10).
$projects = $this->collectEligibleProjects();
// 2. Group by (signal_type, identifier) — no subject_code split.
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
@@ -181,6 +177,35 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
/**
* Собрать eligible Лидерра-проекты для расчёта заказа поставщику.
*
* Фильтры (Billing v2 Spec C §3.10 преfflight баланса):
* is_active = true (базовый);
* preflight_blocked_at IS NULL (точечная блокировка проекта при «перегрузе» лимита);
* tenant.frozen_by_balance_at IS NULL (пассивная заморозка тенанта по пустому балансу).
*
* Запрос через pgsql_supplier (BYPASSRLS) джоб бегает в системном контексте.
* Метод публичный для unit-теста; никто из caller'ов кроме handle() его не зовёт.
*
* @return Collection<int, Project>
*/
public function collectEligibleProjects(): Collection
{
// NB: whereIn-subquery вместо whereHas — whereHas строит relation-query
// через default Eloquent connection (pgsql), а наш родительский Project::on
// на pgsql_supplier; cross-connection JOIN ломал sync-тесты (8 fails).
// FROM 'tenants' внутри subquery наследует connection родителя.
return Project::on(self::DB_CONNECTION)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->whereIn('tenant_id', function ($q): void {
$q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
})
->orderBy('id')
->get();
}
/**
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
*/
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Финальное письмо: приём лидов приостановлен 3 дня (Billing v2 Spec C §3.7, T+72h).
* После него повторов нет до следующего цикла заморозки.
*/
final class BalanceFrozenFinalMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
public readonly PreflightResult $result,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Приём лидов приостановлен 3 дня',
to: [$this->tenant->contact_email],
);
}
public function content(): Content
{
return new Content(view: 'emails.balance_frozen_final');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: приём лидов приостановлен из-за нехватки баланса (Billing v2 Spec C §3.7).
*
* Триггер: BalancePreflightSweepJob при переходе тенанта active frozen (cut-off 18:00 MSK).
*/
final class BalanceFrozenMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
public readonly PreflightResult $result,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Приём лидов приостановлен — недостаточно баланса',
to: [$this->tenant->contact_email],
);
}
public function content(): Content
{
return new Content(view: 'emails.balance_frozen');
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо-напоминание: приём лидов всё ещё приостановлен (Billing v2 Spec C §3.7, T+24h).
*/
final class BalanceFrozenReminderMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
public readonly PreflightResult $result,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Приём лидов всё ещё приостановлен',
to: [$this->tenant->contact_email],
);
}
public function content(): Content
{
return new Content(view: 'emails.balance_frozen_reminder');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: приём лидов возобновлён (Billing v2 Spec C §3.7).
*
* Триггер: BalancePreflightSweepJob при переходе frozen active (пополнение/снижение лимита).
*/
final class BalanceUnfrozenMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
public readonly PreflightResult $result,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Приём лидов возобновлён',
to: [$this->tenant->contact_email],
);
}
public function content(): Content
{
return new Content(view: 'emails.balance_unfrozen');
}
}
+4
View File
@@ -63,6 +63,8 @@ class Project extends Model
// Plan 2/5 Task 1 (schema v8.18): дневной счётчик доставленных лидов
// (сбрасывается cron'ом в 00:00 МСК, используется LeadRouter'ом).
'delivered_today',
// Billing v2 Spec C: флаг точечной блокировки проекта по преfflight (NULL = не заблокирован).
'preflight_blocked_at',
];
protected function casts(): array
@@ -79,6 +81,8 @@ class Project extends Model
'delivery_days_mask' => 'integer',
'ttfr_target_minutes' => 'integer',
'effective_limit_calculated_at' => 'datetime',
// Billing v2 Spec C: флаг преfflight-блокировки проекта.
'preflight_blocked_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
// Supplier integration:
+16
View File
@@ -44,6 +44,7 @@ class Tenant extends Model
'delivered_in_month',
'api_key_limit',
'limits',
'frozen_by_balance_at',
];
protected function casts(): array
@@ -61,6 +62,8 @@ class Tenant extends Model
'limits' => 'array',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
// Billing v2 Spec C: флаг заморозки по балансу (NULL = не заморожен).
'frozen_by_balance_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
@@ -79,6 +82,19 @@ class Tenant extends Model
return $this->hasMany(Project::class);
}
/**
* Сумма daily_limit_target активных проектов «сколько лидов клиент хочет в день».
* Используется преfflight'ом (Billing v2 Spec C §3.3) как requiredLeads.
*
* NB: фильтр по `is_active` (boolean), не `status` у projects нет колонки status.
*/
public function requiredLeadsForTomorrow(): int
{
return (int) $this->projects()
->where('is_active', true)
->sum('daily_limit_target');
}
/** @return BelongsTo<TariffPlan, $this> */
public function tariff(): BelongsTo
{
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\PricingTier;
use Illuminate\Database\Eloquent\Collection;
/**
* Pure: проходит ли клиент преfflight хватает ли баланса на ПОЛНЫЙ дневной
* лимит всех его eligible-проектов по текущему тарифу.
*
* Сравнение в ЛИДАХ (capacity vs required), не в рублях переиспользует
* BalanceToLeadsConverter::convert, который учитывает 7 ступеней и накопленный
* месячный объём (deliveredInMonth).
*
* Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
*/
final class BalancePreflightService
{
public function __construct(
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
) {}
/**
* @param Collection<int, PricingTier> $tiers
*/
public function evaluate(
string $balanceRub,
int $deliveredInMonth,
int $requiredLeads,
Collection $tiers,
): PreflightResult {
if ($requiredLeads <= 0) {
return new PreflightResult(true, 0, 0, 0);
}
$capacity = (int) $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['leads'];
$passes = $capacity >= $requiredLeads;
return new PreflightResult(
passes: $passes,
requiredLeads: $requiredLeads,
capacityLeads: $capacity,
deficitLeads: $passes ? 0 : ($requiredLeads - $capacity),
);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
/**
* Результат преfflight-проверки платёжеспособности тенанта (Billing v2 Spec C).
*
* Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
*/
final class PreflightResult
{
public function __construct(
public readonly bool $passes,
public readonly int $requiredLeads,
public readonly int $capacityLeads,
public readonly int $deficitLeads,
) {}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Всё DDL через pgsql_supplier — избегаем deadlock при смешивании соединений.
// Laravel оборачивает миграцию в транзакцию на дефолтном pgsql; ALTER TABLE tenants
// на pgsql + CREATE TABLE с FK на tenants через pgsql_supplier = взаимная блокировка.
// На dev pgsql_supplier = postgres superuser (те же права), на prod — явные GRANT'ы ниже.
$supplier = DB::connection('pgsql_supplier');
// Флаги заморозки. tenant-level — пассивный износ; project-level — точечная перегрузка.
$supplier->statement('ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL');
$supplier->statement('ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL');
$supplier->statement('CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL');
$supplier->statement('CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL');
// Журнал заморозок/разморозок. Создаём через pgsql_supplier (урок Спека B — prod-роли).
$supplier->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS balance_freeze_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
event_type VARCHAR(30) NOT NULL,
triggered_by VARCHAR(30) NOT NULL,
balance_rub_at_event DECIMAL(12,2) NOT NULL,
required_leads INTEGER NOT NULL,
capacity_leads INTEGER NOT NULL,
total_daily_limit INTEGER NOT NULL,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
SQL);
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
$supplier->statement(<<<'SQL'
CREATE POLICY tenant_isolation ON balance_freeze_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
SQL);
$supplier->statement('CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC)');
// Гранты для 4 ролей (mirror webhook_dedup_keys / supplier_lead_deliveries).
foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) {
$supplier->statement(<<<SQL
DO \$\$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO {$role};
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO {$role};
END IF;
END
\$\$
SQL);
}
}
public function down(): void
{
$supplier = DB::connection('pgsql_supplier');
$supplier->statement('DROP TABLE IF EXISTS balance_freeze_log');
$supplier->statement('ALTER TABLE projects DROP COLUMN IF EXISTS preflight_blocked_at');
$supplier->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS frozen_by_balance_at');
}
};
+24
View File
@@ -106,3 +106,27 @@ export async function topup(amountRub: number): Promise<TopupResult> {
const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
return data;
}
/**
* Ответ GET /api/billing/balance-status лёгкий статус баланса для UI префлайта
* (Billing v2 Spec C §3.6): питает баннер заморозки + индикатор ёмкости.
*/
export interface BalanceStatus {
/** ISO-дата заморозки или null (не заморожен). */
frozen_by_balance_at: string | null;
balance_rub: string;
/** Сколько лидов покрывает баланс по текущему тарифу. */
capacity_leads: number;
/** Суммарный дневной заказ активных не-заблокированных проектов. */
required_leads_per_day: number;
/** На сколько лидов заказ превышает ёмкость (0 если хватает). */
deficit_leads: number;
/** Сколько ₽ не хватает, чтобы покрыть дефицит (scale 2, "0.00" если хватает). */
deficit_rub: string;
}
/** GET /api/billing/balance-status — статус для баннера заморозки и индикатора ёмкости. */
export async function getBalanceStatus(): Promise<BalanceStatus> {
const { data } = await apiClient.get<BalanceStatus>('/api/billing/balance-status');
return data;
}
@@ -0,0 +1,68 @@
<script setup lang="ts">
/**
* Постоянная подсказка под балансом (Billing v2 Spec C §3.6, Task 1.10).
*
* Чистый presentational-компонент: показывает, на сколько дней хватит ёмкости
* баланса (в лидах) при текущем дневном заказе всех eligible-проектов.
* Зелёный хватает на 3+ дня; жёлтый меньше 3 дней; красный не хватает.
*/
import { computed } from 'vue';
const props = defineProps<{
/** Баланс в рублях (строка scale 2, например "1000.00"). */
balanceRub: string;
/** Сколько лидов покрывает баланс по текущему тарифу. */
capacityLeads: number;
/** Суммарный дневной заказ всех активных проектов (лидов/день). */
requiredLeadsPerDay: number;
}>();
const daysLeft = computed(() =>
props.requiredLeadsPerDay > 0 ? props.capacityLeads / props.requiredLeadsPerDay : Infinity,
);
const statusClass = computed(() => {
if (props.requiredLeadsPerDay > 0 && props.capacityLeads < props.requiredLeadsPerDay) {
return 'capacity-insufficient';
}
if (daysLeft.value < 3) return 'capacity-warning';
return 'capacity-ok';
});
const daysLabel = computed(() => (Number.isFinite(daysLeft.value) ? daysLeft.value.toFixed(1) : '∞'));
</script>
<template>
<div class="balance-capacity text-body-2" :class="statusClass" data-testid="balance-capacity-indicator">
<div>Баланс: {{ balanceRub }} = до {{ capacityLeads }} лидов по тарифу</div>
<div>Проекты заказывают: {{ requiredLeadsPerDay }} лидов в день</div>
<div v-if="statusClass === 'capacity-insufficient'" class="capacity-note">
Не хватает пополните счёт
</div>
<div v-else-if="statusClass === 'capacity-warning'" class="capacity-note">
Хватит на ~{{ daysLabel }} дн. скоро потребуется пополнение
</div>
<div v-else class="capacity-note"> Хватит на ~{{ daysLabel }} дн.</div>
</div>
</template>
<style scoped>
.balance-capacity {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.4;
}
.capacity-note {
font-weight: 600;
}
.capacity-ok .capacity-note {
color: rgb(var(--v-theme-success));
}
.capacity-warning .capacity-note {
color: rgb(var(--v-theme-warning));
}
.capacity-insufficient .capacity-note {
color: rgb(var(--v-theme-error));
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* Красный баннер заморозки баланса (Billing v2 Spec C §3.6, Task 1.10).
*
* Показывается на всех страницах, когда tenant.frozen_by_balance_at !== null
* (проп `frozen`). Источник данных tenantStore, загружаемый глобально в
* AppLayout. Чистый presentational-компонент.
*/
import { computed } from 'vue';
const props = defineProps<{
frozen: boolean;
/** Сколько ₽ не хватает на дневной заказ (строка scale 2). */
deficitRub?: string;
/** Сколько лидов превышают ёмкость баланса. */
deficitLeads?: number;
}>();
const hasDeficit = computed(() => (props.deficitLeads ?? 0) > 0);
</script>
<template>
<v-alert
v-if="frozen"
type="error"
variant="tonal"
density="comfortable"
rounded="lg"
class="balance-frozen-banner ma-4 mb-0"
data-testid="balance-frozen-banner"
>
<div class="text-subtitle-2 font-weight-bold">Приём лидов приостановлен</div>
<div class="text-body-2 mb-2">
Не хватает баланса на дневной заказ.<span v-if="hasDeficit">
Нужно ещё {{ deficitRub }} (или сократи лимиты на {{ deficitLeads }} лидов).</span>
</div>
<RouterLink to="/billing" data-testid="banner-topup-link" class="banner-link">
Пополнить счёт
</RouterLink>
<RouterLink to="/projects" data-testid="banner-projects-link" class="banner-link ml-4">
Перейти к проектам
</RouterLink>
</v-alert>
</template>
<style scoped>
.banner-link {
font-weight: 600;
color: rgb(var(--v-theme-error));
text-decoration: underline;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
/**
* Диалог перегрузки лимита (Billing v2 Spec C §6.2, Task 1.10).
*
* Открывается, когда POST/PATCH /api/projects вернул 409 `balance_insufficient`.
* Показывает дефицит и предлагает три исхода:
* - «Сохранить и приостановить» save-blocked (родитель пере-сабмитит с
* force_save_blocked=true проект создаётся с preflight_blocked_at);
* - «Поставить лимит 0» set-zero (родитель ставит daily_limit_target=0);
* - «Отмена» закрытие без сохранения.
*/
export interface OverloadPayload {
current_balance_rub: string;
current_capacity_leads: number;
would_be_required_leads: number;
deficit_leads: number;
}
defineProps<{
modelValue: boolean;
payload: OverloadPayload | null;
}>();
defineEmits<{
'update:modelValue': [value: boolean];
'save-blocked': [];
'set-zero': [];
}>();
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="520"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card v-if="payload" data-testid="overload-dialog">
<v-card-title>Лимит превышает баланс</v-card-title>
<v-card-text>
<p>
У тебя {{ payload.current_balance_rub }} =
{{ payload.current_capacity_leads }} лидов по текущему тарифу.
</p>
<p>После сохранения нужно {{ payload.would_be_required_leads }} лидов.</p>
<p class="font-weight-medium">Не хватает: {{ payload.deficit_leads }} лидов.</p>
<p class="text-medium-emphasis mt-2">
Чтобы проект начал работать пополни счёт, поставь его лимит 0
или уменьши лимиты других проектов.
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
Отмена
</v-btn>
<v-btn variant="text" data-testid="overload-set-zero" @click="$emit('set-zero')">
Поставить лимит 0
</v-btn>
<v-btn
color="primary"
variant="flat"
data-testid="overload-save-blocked"
@click="$emit('save-blocked')"
>
Сохранить и приостановить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+15
View File
@@ -14,16 +14,19 @@ import { RouterView, useRoute } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useNotificationsStore } from '../stores/notifications';
import { useRemindersStore } from '../stores/reminders';
import { useTenantStore } from '../stores/tenantStore';
import { usePolling } from '../composables/usePolling';
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import CommandPalette from '../components/layout/CommandPalette.vue';
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
const auth = useAuthStore();
const notifications = useNotificationsStore();
const reminders = useRemindersStore();
const tenant = useTenantStore();
const route = useRoute();
const drawerOpen = ref(true);
@@ -60,12 +63,19 @@ async function loadReminderCounts(): Promise<void> {
await reminders.refreshCounts();
}
async function loadBalanceStatus(): Promise<void> {
if (!auth.user) return;
await tenant.load();
}
onMounted(() => {
void loadNotifications();
void loadReminderCounts();
void loadBalanceStatus();
});
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
</script>
<template>
@@ -74,6 +84,11 @@ usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enab
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
<v-main class="app-main">
<BalanceFrozenBanner
:frozen="tenant.frozen"
:deficit-rub="tenant.deficitRub"
:deficit-leads="tenant.deficitLeads"
/>
<RouterView v-slot="{ Component, route: r }">
<Transition :name="(r.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
<component :is="Component" :key="r.fullPath" />
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import * as billingApi from '../api/billing';
import type { BalanceStatus } from '../api/billing';
/**
* Tenant-store: статус баланса текущего тенанта для UI префлайта (Billing v2
* Spec C Task 1.10). Единый источник для глобального баннера заморозки
* (BalanceFrozenBanner в AppLayout) и индикатора ёмкости (BalanceCapacityIndicator
* в BillingView) чтобы не делать два запроса.
*
* Загружается глобально в AppLayout (load + polling). `load()` молча проглатывает
* ошибку: баннер/индикатор не критичны и не должны валить страницу.
*/
export const useTenantStore = defineStore('tenant', () => {
const status = ref<BalanceStatus | null>(null);
const frozen = computed(() => status.value?.frozen_by_balance_at != null);
const deficitRub = computed(() => status.value?.deficit_rub ?? '0.00');
const deficitLeads = computed(() => status.value?.deficit_leads ?? 0);
const balanceRub = computed(() => status.value?.balance_rub ?? '0.00');
const capacityLeads = computed(() => status.value?.capacity_leads ?? 0);
const requiredLeadsPerDay = computed(() => status.value?.required_leads_per_day ?? 0);
async function load(): Promise<void> {
try {
status.value = await billingApi.getBalanceStatus();
} catch {
// Не критично — оставляем прошлый статус (или null → баннер скрыт).
}
}
return {
status,
frozen,
deficitRub,
deficitLeads,
balanceRub,
capacityLeads,
requiredLeadsPerDay,
load,
};
});
+17 -1
View File
@@ -11,6 +11,7 @@
*/
import { ref, computed, onMounted } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import BalanceCapacityIndicator from '../components/billing/BalanceCapacityIndicator.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
import InvoicesTable from '../components/billing/InvoicesTable.vue';
@@ -19,8 +20,10 @@ import ChargesTab from './billing/ChargesTab.vue';
import { formatPlain, featureLabel } from '../composables/billingFormatters';
import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';
import { useTenantStore } from '../stores/tenantStore';
const activeView = ref<'overview' | 'charges'>('overview');
const tenant = useTenantStore();
const wallet = ref<Wallet | null>(null);
const loading = ref(true);
@@ -59,10 +62,15 @@ async function onTopupSuccess(): Promise<void> {
topupOpen.value = false;
topupSnackbar.value = true;
await loadWallet();
// Пополнение могло снять заморозку обновляем статус баланса (баннер/индикатор).
await tenant.load();
txTableRef.value?.refresh();
}
onMounted(loadWallet);
onMounted(() => {
void loadWallet();
void tenant.load();
});
defineExpose({ loadWallet, wallet, topupOpen });
</script>
@@ -117,6 +125,14 @@ defineExpose({ loadWallet, wallet, topupOpen });
@topup="topupOpen = true"
/>
<BalanceCapacityIndicator
v-if="tenant.status"
class="mt-3"
:balance-rub="tenant.balanceRub"
:capacity-leads="tenant.capacityLeads"
:required-leads-per-day="tenant.requiredLeadsPerDay"
/>
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<TransactionsTable ref="txTableRef" />
@@ -190,6 +190,13 @@
</v-card-actions>
</v-card>
</v-dialog>
<ProjectLimitOverloadDialog
v-model="overloadOpen"
:payload="overloadPayload"
@save-blocked="onOverloadSaveBlocked"
@set-zero="onOverloadSetZero"
/>
</template>
<script setup lang="ts">
@@ -199,6 +206,7 @@ import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
@@ -225,6 +233,16 @@ const errors = reactive<Record<string, string[]>>({});
const saving = ref(false);
const generalError = ref<string | null>(null);
// Spec C §6.2: префлайт баланса диалог перегрузки лимита по 409.
interface OverloadPayloadShape {
current_balance_rub: string;
current_capacity_leads: number;
would_be_required_leads: number;
deficit_leads: number;
}
const overloadOpen = ref(false);
const overloadPayload = ref<OverloadPayloadShape | null>(null);
// Plan 4 Task 4: обязательный выбор региона + явная «Вся РФ» с подтверждением.
// vsyaRf чекбокс выбран; vsyaRfConfirmed подтверждён через предупреждение.
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы гейт намеренно UI-only.
@@ -303,6 +321,37 @@ watch(
{ immediate: true },
);
async function persist(extra: Record<string, unknown> = {}): Promise<void> {
saving.value = true;
try {
await ensureCsrfCookie();
const body = { ...form, ...extra };
if (props.mode === 'edit' && props.project) {
await apiClient.patch(`/api/projects/${props.project.id}`, body);
} else {
await apiClient.post('/api/projects', body);
}
overloadOpen.value = false;
emit('saved');
close();
} catch (e: unknown) {
const err = e as {
response?: { status?: number; data?: { error?: string; errors?: Record<string, string[]> } };
};
// Spec C §6.2: лимит превышает баланс открываем диалог перегрузки.
if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
overloadPayload.value = err.response.data as OverloadPayloadShape;
overloadOpen.value = true;
} else if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else {
generalError.value = extractErrorMessage(e);
}
} finally {
saving.value = false;
}
}
async function submit() {
generalError.value = null;
Object.keys(errors).forEach((k) => delete errors[k]);
@@ -313,26 +362,18 @@ async function submit() {
return;
}
saving.value = true;
try {
await ensureCsrfCookie();
if (props.mode === 'edit' && props.project) {
await apiClient.patch(`/api/projects/${props.project.id}`, { ...form });
} else {
await apiClient.post('/api/projects', { ...form });
}
emit('saved');
close();
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else {
generalError.value = extractErrorMessage(e);
}
} finally {
saving.value = false;
}
await persist();
}
// Spec C §6.2 исходы диалога перегрузки лимита.
async function onOverloadSaveBlocked(): Promise<void> {
await persist({ force_save_blocked: true });
}
async function onOverloadSetZero(): Promise<void> {
form.daily_limit_target = 0;
overloadOpen.value = false;
await persist();
}
function close() {
@@ -0,0 +1,12 @@
Здравствуйте, {{ $tenant->organization_name }}!
Приём лидов по вашим проектам приостановлен на счёте недостаточно средств
на завтрашний дневной заказ.
Сейчас баланса хватает на {{ $result->capacityLeads }} лидов, а ваши проекты
заказывают {{ $result->requiredLeads }} лидов в день. Не хватает: {{ $result->deficitLeads }} лидов.
Чтобы возобновить приём, пополните счёт или уменьшите дневные лимиты проектов
до 18:00 по московскому времени.
С уважением, команда Лидерра.
@@ -0,0 +1,11 @@
Здравствуйте, {{ $tenant->organization_name }}!
Приём лидов по вашим проектам приостановлен уже 3 дня из-за нехватки баланса.
Это последнее напоминание.
Баланса хватает на {{ $result->capacityLeads }} лидов, проекты заказывают
{{ $result->requiredLeads }} в день. Не хватает: {{ $result->deficitLeads }} лидов.
Чтобы возобновить приём пополните счёт или уменьшите лимиты проектов.
С уважением, команда Лидерра.
@@ -0,0 +1,10 @@
Здравствуйте, {{ $tenant->organization_name }}!
Напоминаем: приём лидов по вашим проектам всё ещё приостановлен из-за нехватки баланса.
Баланса хватает на {{ $result->capacityLeads }} лидов, проекты заказывают
{{ $result->requiredLeads }} в день. Не хватает: {{ $result->deficitLeads }} лидов.
Пополните счёт или уменьшите лимиты до 18:00 МСК, чтобы возобновить приём.
С уважением, команда Лидерра.
@@ -0,0 +1,10 @@
Здравствуйте, {{ $tenant->organization_name }}!
Приём лидов по вашим проектам возобновлён баланса снова хватает на дневной заказ.
Текущий запас: {{ $result->capacityLeads }} лидов, проекты заказывают
{{ $result->requiredLeads }} в день.
Заказ поставщику будет сформирован в ближайшем вечернем цикле (18:00 МСК).
С уважением, команда Лидерра.
+22 -1
View File
@@ -64,6 +64,25 @@ Schedule::command('partitions:drop-expired')
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
// Billing v2 Spec C §3.2: преfflight баланса в 18:00 MSK — заморозка/разморозка
// тенантов перед формированием заказа поставщику (без «бедных» клиентов).
// ВАЖНО: идёт ДО SyncSupplierProjectsJob (сдвинут на 18:05) — фильтр frozen-проектов
// должен примениться к расчёту заказа того же вечера.
Schedule::command('billing:preflight-sweep')
->dailyAt('18:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
Schedule::command('billing:frozen-reminder')
->dailyAt('18:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('billing:frozen-reminder', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:frozen-reminder', false, 'Command failed', null));
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
//
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
@@ -83,8 +102,10 @@ Schedule::job(new RefreshSupplierSessionJob)
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
// успел проставить frozen-флаги до формирования заказа поставщику.
Schedule::job(new SyncSupplierProjectsJob)
->dailyAt('18:00')
->dailyAt('18:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
+1
View File
@@ -189,6 +189,7 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
});
-1
View File
@@ -12,7 +12,6 @@ declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Str;
// -------------------------------------------------------------------
// 1. Проверяем исходное состояние
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceUnfrozenMail;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
it('renders frozen mail with deficit', function () {
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Альфа']);
$result = new PreflightResult(false, 30, 20, 10);
$rendered = (new BalanceFrozenMail($tenant, $result))->render();
expect($rendered)->toContain('приостановлен');
expect($rendered)->toContain('10'); // дефицит лидов
expect($rendered)->toContain('ООО Альфа');
});
it('renders unfrozen mail', function () {
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Бета']);
$result = new PreflightResult(true, 25, 40, 0);
$rendered = (new BalanceUnfrozenMail($tenant, $result))->render();
expect($rendered)->toContain('возобновлён');
expect($rendered)->toContain('ООО Бета');
});
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use App\Jobs\Billing\BalanceFrozenReminderJob;
use App\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
// Изоляция: liderra_testing persistent — DatabaseTransactions откатывает default-pgsql,
// SharesSupplierPdo делает pgsql_supplier общим PDO (паттерн Спека B / Task 1.4).
// Per-tenant Mail-фильтры обязательны — DemoSeeder тенанты могут попасть в sweep
// (прецедент idempotent-fixup 55a1bc05).
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
// RLS-контекст (системный tenant 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
PricingTier::query()->create([
'tier_no' => 1,
'leads_in_tier' => null,
'price_per_lead_kopecks' => 5000,
'is_active' => true,
'effective_from' => now(),
]);
});
it('sends reminder ~1 day after freeze', function () {
Mail::fake();
// frozen 25h назад — попадает в окно reminder (24-48h).
Carbon::setTestNow('2026-05-25 12:00:00');
$tenant = Tenant::factory()->create([
'balance_rub' => '0.00',
'frozen_by_balance_at' => Carbon::now()->subHours(25),
]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalanceFrozenReminderJob)->handle();
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
it('sends final ~3 days after freeze', function () {
Mail::fake();
// frozen 73h назад — попадает в окно final (72-96h).
Carbon::setTestNow('2026-05-25 12:00:00');
$tenant = Tenant::factory()->create([
'balance_rub' => '0.00',
'frozen_by_balance_at' => Carbon::now()->subHours(73),
]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalanceFrozenReminderJob)->handle();
Mail::assertQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
it('sends nothing for freshly frozen tenant', function () {
Mail::fake();
// frozen 2h назад — окно ещё не открылось.
Carbon::setTestNow('2026-05-25 12:00:00');
$tenant = Tenant::factory()->create([
'balance_rub' => '0.00',
'frozen_by_balance_at' => Carbon::now()->subHours(2),
]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalanceFrozenReminderJob)->handle();
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
it('is throttled — does not re-send reminder for same tenant in window', function () {
Mail::fake();
Carbon::setTestNow('2026-05-25 12:00:00');
$tenant = Tenant::factory()->create([
'balance_rub' => '0.00',
'frozen_by_balance_at' => Carbon::now()->subHours(25),
]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
// Первый прогон — отправляет reminder.
(new BalanceFrozenReminderJob)->handle();
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
// Сброс fake и второй прогон в том же окне — повторного письма быть не должно.
Mail::fake();
(new BalanceFrozenReminderJob)->handle();
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Jobs\Billing\BalancePreflightSweepJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Mail\BalanceFrozenMail;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
// Изоляция: liderra_testing persistent (RefreshDatabase off). DatabaseTransactions
// откатывает default-pgsql после каждого теста; SharesSupplierPdo делает pgsql_supplier
// общим PDO с pgsql — иначе job-запись balance_freeze_log (pgsql_supplier) не видит
// незакоммиченного tenant и падает на FK (паттерн Спека B / AutoPauseFlowTest).
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
// RLS-контекст (системный tenant 0) — паттерн supplier-тестов.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
PricingTier::query()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true, 'effective_from' => now()]);
});
it('freezes tenant whose balance no longer covers daily limit', function () {
Mail::fake();
// 500₽ / 50₽ = 10 лидов; проекты хотят 25 → заморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
Mail::assertQueued(BalanceFrozenMail::class);
});
it('unfreezes tenant whose balance now covers daily limit', function () {
Mail::fake();
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
});
it('is idempotent — does not re-freeze already frozen tenant', function () {
Mail::fake();
$frozenAt = now()->subDay();
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => $frozenAt]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
// Дата заморозки не перезаписана; для ЭТОГО tenant повторного письма нет.
// NB: per-tenant фильтр, т.к. liderra_testing persistent (DemoSeeder тенанты
// могут попасть в sweep и тоже получить BalanceFrozenMail — не наш ответ).
expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp);
Mail::assertNotQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
// Spec C extension (26.05.2026): freeze/unfreeze дёргают supplier sync в режиме 'online'.
// Привязка к существующему админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
// Online нужен сейчас для отладки (моментальный sync с поставщиком); batch будет рабочим режимом
// при росте числа клиентов (накопленные изменения уезжают одним cut-off-cron'ом в 18:00 MSK).
it('dispatches SyncSupplierProjectJob for each active project on freeze when supplier mode is online', function () {
Mail::fake();
Queue::fake();
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
// 500₽ / 50₽ = 10 лидов; 2 проекта по 15 = 30 → заморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
$p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p1->id);
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p2->id);
});
it('does NOT dispatch SyncSupplierProjectJob on freeze when supplier mode is batch', function () {
Mail::fake();
Queue::fake();
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'batch']);
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
// batch-режим: sync с поставщиком отложен до cut-off 18:00 MSK через SyncSupplierProjectsJob (множественный).
Queue::assertNotPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === Project::query()->where('tenant_id', $tenant->id)->value('id'));
});
it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', function () {
Mail::fake();
Queue::fake();
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
$project = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id);
});
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/**
* GET /api/billing/balance-status статус баланса для UI префлайта (Billing v2
* Spec C Task 1.10): питает баннер заморозки (BalanceFrozenBanner) и индикатор
* ёмкости (BalanceCapacityIndicator).
*
* PricingTierSeeder: ступень 1 100 лидов × 500 (см. BillingOverviewControllerTest).
*/
beforeEach(function () {
$this->seed(PricingTierSeeder::class);
$this->tenant = Tenant::factory()->create([
'balance_rub' => '5000.00',
'delivered_in_month' => 0,
'frozen_by_balance_at' => null,
]);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('GET /api/billing/balance-status: структура ответа', function () {
$this->getJson('/api/billing/balance-status')
->assertOk()
->assertJsonStructure([
'frozen_by_balance_at',
'balance_rub',
'capacity_leads',
'required_leads_per_day',
'deficit_leads',
'deficit_rub',
]);
});
test('balance-status: хватает баланса — deficit=0, не заморожен', function () {
// 5000₽ при ступени 1 (500₽/лид) = 10 лидов ёмкости. Проект лимит 5 — впритык.
Project::factory()->create([
'tenant_id' => $this->tenant->id,
'is_active' => true,
'daily_limit_target' => 5,
]);
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
expect($resp->json('balance_rub'))->toBe('5000.00');
expect($resp->json('capacity_leads'))->toBe(10);
expect($resp->json('required_leads_per_day'))->toBe(5);
expect($resp->json('deficit_leads'))->toBe(0);
expect($resp->json('deficit_rub'))->toBe('0.00');
expect($resp->json('frozen_by_balance_at'))->toBeNull();
});
test('balance-status: не хватает — deficit_leads + deficit_rub точные', function () {
// Ёмкость = 10 лидов. Проект лимит 25 → нужно 25, дефицит 15 лидов.
// minBalanceForLeads(25) = 25 × 500₽ = 12500₽ → deficit_rub = 12500 5000 = 7500.00.
Project::factory()->create([
'tenant_id' => $this->tenant->id,
'is_active' => true,
'daily_limit_target' => 25,
]);
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
expect($resp->json('capacity_leads'))->toBe(10);
expect($resp->json('required_leads_per_day'))->toBe(25);
expect($resp->json('deficit_leads'))->toBe(15);
expect($resp->json('deficit_rub'))->toBe('7500.00');
});
test('balance-status: required исключает inactive и preflight_blocked проекты', function () {
Project::factory()->create([
'tenant_id' => $this->tenant->id, 'is_active' => true, 'daily_limit_target' => 5,
]);
Project::factory()->create([
'tenant_id' => $this->tenant->id, 'is_active' => false, 'daily_limit_target' => 100,
]);
Project::factory()->create([
'tenant_id' => $this->tenant->id, 'is_active' => true, 'daily_limit_target' => 100,
'preflight_blocked_at' => now(),
]);
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
expect($resp->json('required_leads_per_day'))->toBe(5);
});
test('balance-status: возвращает frozen_by_balance_at когда установлен', function () {
$this->tenant->update(['frozen_by_balance_at' => now()]);
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
expect($resp->json('frozen_by_balance_at'))->not->toBeNull();
});
test('GET /api/billing/balance-status без auth: 401', function () {
auth()->logout();
$this->getJson('/api/billing/balance-status')->assertStatus(401);
});
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Mail\BalanceFrozenMail;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
PricingTier::query()->create([
'tier_no' => 1,
'leads_in_tier' => null,
'price_per_lead_kopecks' => 5000,
'is_active' => true,
'effective_from' => now(),
]);
});
it('freezes pre-existing underfunded tenant on first run', function () {
Mail::fake();
// 0₽ + проекты на 25 лидов → должен быть заморожен.
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
$this->artisan('billing:preflight-initial-sweep')->assertSuccessful();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
Mail::assertQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
Queue::fake();
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
PricingTier::query()->create([
'tier_no' => 1,
'leads_in_tier' => null,
'price_per_lead_kopecks' => 5000, // 50₽/лид — capacity = balance/50
'is_active' => true,
'effective_from' => now(),
]);
});
it('returns 409 when new project would overload balance', function () {
// 1000₽ / 50₽ = 20 лидов capacity; запрашиваем daily_limit_target=30 → дефицит 10.
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Перегрузка',
'signal_type' => 'site',
'signal_identifier' => 'overload.ru',
'daily_limit_target' => 30,
'regions' => [],
'delivery_days_mask' => 127,
]);
$response->assertStatus(409);
$response->assertJsonPath('error', 'balance_insufficient');
$response->assertJsonPath('deficit_leads', 10);
$response->assertJsonPath('current_capacity_leads', 20);
$response->assertJsonPath('would_be_required_leads', 30);
expect(Project::where('signal_identifier', 'overload.ru')->exists())->toBeFalse();
});
it('creates blocked project when force_save_blocked=true', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Заблокированный',
'signal_type' => 'site',
'signal_identifier' => 'force-blocked.ru',
'daily_limit_target' => 30,
'regions' => [],
'delivery_days_mask' => 127,
'force_save_blocked' => true,
]);
$response->assertCreated();
$project = Project::where('signal_identifier', 'force-blocked.ru')->first();
expect($project)->not->toBeNull();
expect($project->preflight_blocked_at)->not->toBeNull();
});
it('creates normally when within balance', function () {
// 2000₽ / 50₽ = 40 лидов capacity; daily_limit_target=30 — passes.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'Норма',
'signal_type' => 'site',
'signal_identifier' => 'ok-balance.ru',
'daily_limit_target' => 30,
'regions' => [],
'delivery_days_mask' => 127,
]);
$response->assertCreated();
$project = Project::where('signal_identifier', 'ok-balance.ru')->first();
expect($project->preflight_blocked_at)->toBeNull();
});
it('returns 409 on update when increased limit overloads balance', function () {
// существующий проект на 15 лидов, всё ок (capacity 20).
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 15,
]);
// UPDATE до 30 → suma 30 > capacity 20 → 409.
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'daily_limit_target' => 30,
]);
$response->assertStatus(409);
$response->assertJsonPath('error', 'balance_insufficient');
expect($project->fresh()->daily_limit_target)->toBe(15); // не изменилось
});
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Support\Carbon;
it('sums daily_limit_target of active projects for required leads', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
Project::factory()->for($tenant)->create(['is_active' => false, 'daily_limit_target' => 100]); // не считается
expect($tenant->requiredLeadsForTomorrow())->toBe(25);
});
it('casts frozen_by_balance_at to datetime', function () {
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
expect($tenant->frozen_by_balance_at)->toBeInstanceOf(Carbon::class);
});
it('casts project preflight_blocked_at to datetime', function () {
$project = Project::factory()->create(['preflight_blocked_at' => now()]);
expect($project->preflight_blocked_at)->toBeInstanceOf(Carbon::class);
});
@@ -12,7 +12,9 @@ use Illuminate\Console\Scheduling\Schedule;
* Session refresh на 15 мин раньше sync (17:45).
*/
it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
it('SyncSupplierProjectsJob is scheduled at 18:05 MSK (after billing:preflight-sweep @18:00)', function (): void {
// Billing v2 Spec C §3.8: sync сдвинут с 18:00 на 18:05, чтобы
// billing:preflight-sweep (18:00) успел проставить frozen-флаги.
$schedule = app(Schedule::class);
$events = collect($schedule->events());
@@ -20,7 +22,7 @@ it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
|| str_contains((string) $e->command, 'SyncSupplierProjectsJob'));
expect($sync)->not->toBeNull();
expect($sync->expression)->toBe('0 18 * * *');
expect($sync->expression)->toBe('5 18 * * *');
expect($sync->timezone)->toBe('Europe/Moscow');
});
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
// Изоляция: persistent liderra_testing, RefreshDatabase off. DatabaseTransactions
// откатывает pgsql после теста; SharesSupplierPdo делает pgsql_supplier общим PDO
// с pgsql, иначе query через Project::on('pgsql_supplier') не видит uncommitted
// tenant/project (паттерн Спека B / AutoPauseFlowTest).
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
it('excludes projects of frozen tenants from supplier order', function () {
$frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
$activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
$tenantIds = $eligible->pluck('tenant_id')->unique()->all();
expect($tenantIds)->not->toContain($frozenTenant->id);
expect($tenantIds)->toContain($activeTenant->id);
});
it('excludes individually preflight-blocked projects', function () {
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
$okProject = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30, 'preflight_blocked_at' => null]);
$blocked = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now()]);
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
$ids = $eligible->pluck('id')->all();
expect($ids)->toContain($okProject->id);
expect($ids)->not->toContain($blocked->id);
});
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import BalanceCapacityIndicator from '../../resources/js/components/billing/BalanceCapacityIndicator.vue';
// Billing v2 Spec C Task 1.10 — подсказка под балансом (§3.6). Чистый
// presentational-компонент: статус по соотношению ёмкости и требуемого/день.
describe('BalanceCapacityIndicator', () => {
it('зелёный, когда ёмкости хватает на 3+ дня', () => {
const w = mount(BalanceCapacityIndicator, {
props: { balanceRub: '3000.00', capacityLeads: 90, requiredLeadsPerDay: 25 },
});
expect(w.text()).toContain('90');
expect(w.classes()).toContain('capacity-ok');
});
it('жёлтый, когда ёмкости хватает меньше чем на 3 дня', () => {
const w = mount(BalanceCapacityIndicator, {
props: { balanceRub: '1000.00', capacityLeads: 30, requiredLeadsPerDay: 25 },
});
expect(w.classes()).toContain('capacity-warning');
});
it('красный, когда ёмкости меньше требуемого', () => {
const w = mount(BalanceCapacityIndicator, {
props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 25 },
});
expect(w.classes()).toContain('capacity-insufficient');
});
it('зелёный (бесконечный запас), когда проекты ничего не заказывают', () => {
const w = mount(BalanceCapacityIndicator, {
props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 0 },
});
expect(w.classes()).toContain('capacity-ok');
});
it('показывает баланс, ёмкость и дневной заказ', () => {
const w = mount(BalanceCapacityIndicator, {
props: { balanceRub: '1000.00', capacityLeads: 30, requiredLeadsPerDay: 25 },
});
expect(w.text()).toContain('1000.00');
expect(w.text()).toContain('30');
expect(w.text()).toContain('25');
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import BalanceFrozenBanner from '../../resources/js/components/billing/BalanceFrozenBanner.vue';
// Billing v2 Spec C Task 1.10 — красный баннер заморозки (§3.6). Показывается
// на всех страницах, когда tenant.frozen_by_balance_at !== null.
const routerLinkStub = {
RouterLink: {
props: ['to'],
inheritAttrs: false,
template: '<a v-bind="$attrs" :href="to"><slot /></a>',
},
};
const mountBanner = (props: { frozen: boolean; deficitRub?: string; deficitLeads?: number }) =>
mount(BalanceFrozenBanner, {
props,
global: { plugins: [createVuetify()], stubs: routerLinkStub },
});
describe('BalanceFrozenBanner', () => {
it('не заморожен — баннер не рендерится', () => {
const w = mountBanner({ frozen: false });
expect(w.find('[data-testid="balance-frozen-banner"]').exists()).toBe(false);
});
it('заморожен — красный баннер с текстом «приостановлен»', () => {
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
const banner = w.find('[data-testid="balance-frozen-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('приостановлен');
});
it('показывает дефицит в рублях и лидах', () => {
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
const text = w.find('[data-testid="balance-frozen-banner"]').text();
expect(text).toContain('380');
expect(text).toContain('10');
});
it('две ссылки — на /billing и на /projects', () => {
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
expect(w.find('[data-testid="banner-topup-link"]').attributes('href')).toBe('/billing');
expect(w.find('[data-testid="banner-projects-link"]').attributes('href')).toBe('/projects');
});
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import BillingView from '../../resources/js/views/BillingView.vue';
import * as billingApi from '../../resources/js/api/billing';
@@ -31,13 +32,14 @@ function makeWallet(): Wallet {
describe('BillingView.vue', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.mocked(billingApi.getWallet).mockResolvedValue(makeWallet());
});
const factory = () =>
mount(BillingView, {
global: {
plugins: [vuetify],
plugins: [vuetify, createPinia()],
stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
},
});
@@ -104,4 +104,42 @@ describe('NewProjectDialog', () => {
await flushPromises();
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
});
it('409 balance_insufficient → открывает диалог перегрузки; save-blocked пере-сабмитит с force_save_blocked', async () => {
// Spec C §6.2 (Task 1.10): первый POST падает 409, второй (после выбора
// «Сохранить и приостановить») уходит с force_save_blocked=true.
(apiClient.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
response: {
status: 409,
data: {
error: 'balance_insufficient',
current_balance_rub: '1000.00',
current_capacity_leads: 30,
would_be_required_leads: 50,
deficit_leads: 20,
},
},
});
const wrapper = factory();
await flushPromises();
// Проходим гейт обязательного региона через подтверждённую «Вся РФ».
(wrapper.vm as unknown as { confirmVsyaRf: () => void }).confirmVsyaRf();
await (wrapper.vm as unknown as { submit: () => Promise<void> }).submit();
await flushPromises();
const overload = wrapper.find('[data-testid="overload-dialog"]');
expect(overload.exists()).toBe(true);
expect(overload.text()).toContain('20');
await wrapper.find('[data-testid="overload-save-blocked"]').trigger('click');
await flushPromises();
expect(apiClient.post).toHaveBeenCalledTimes(2);
expect(apiClient.post).toHaveBeenLastCalledWith(
'/api/projects',
expect.objectContaining({ force_save_blocked: true }),
);
expect(wrapper.emitted('saved')).toBeTruthy();
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ProjectLimitOverloadDialog from '../../resources/js/components/projects/ProjectLimitOverloadDialog.vue';
// Billing v2 Spec C Task 1.10 — диалог перегрузки лимита (§6.2). Принимает
// 409-payload `balance_insufficient`, эмитит решение пользователя.
const payload = {
current_balance_rub: '1000.00',
current_capacity_leads: 30,
would_be_required_leads: 50,
deficit_leads: 20,
};
const mountDialog = () =>
mount(ProjectLimitOverloadDialog, {
props: { modelValue: true, payload },
global: {
plugins: [createVuetify()],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
describe('ProjectLimitOverloadDialog', () => {
it('рендерит данные 409-payload', () => {
const w = mountDialog();
const text = w.text();
expect(text).toContain('1000.00');
expect(text).toContain('30');
expect(text).toContain('50');
expect(text).toContain('20');
});
it('«Сохранить и приостановить» эмитит save-blocked', async () => {
const w = mountDialog();
await w.find('[data-testid="overload-save-blocked"]').trigger('click');
expect(w.emitted('save-blocked')).toBeTruthy();
});
it('«Поставить лимит 0» эмитит set-zero', async () => {
const w = mountDialog();
await w.find('[data-testid="overload-set-zero"]').trigger('click');
expect(w.emitted('set-zero')).toBeTruthy();
});
it('«Отмена» закрывает диалог (update:modelValue=false)', async () => {
const w = mountDialog();
await w.find('[data-testid="overload-cancel"]').trigger('click');
expect(w.emitted('update:modelValue')?.[0]).toEqual([false]);
});
it('payload=null — без падения, ничего не рендерит', () => {
const w = mount(ProjectLimitOverloadDialog, {
props: { modelValue: true, payload: null },
global: { plugins: [createVuetify()], stubs: { VDialog: { template: '<div><slot /></div>' } } },
});
expect(w.find('[data-testid="overload-save-blocked"]').exists()).toBe(false);
});
});
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Services\Billing\BalancePreflightService;
use Illuminate\Database\Eloquent\Collection;
function makeTiers(): Collection
{
// 1 ступень: цена 50₽/лид (5000 копеек), безлимитная.
return new Collection([
new PricingTier(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]),
]);
}
it('passes when balance covers full daily limit', function () {
$service = new BalancePreflightService;
// 2000₽ / 50₽ = 40 лидов capacity; нужно 30 → проходит.
$result = $service->evaluate(
balanceRub: '2000.00',
deliveredInMonth: 0,
requiredLeads: 30,
tiers: makeTiers(),
);
expect($result->passes)->toBeTrue();
expect($result->capacityLeads)->toBe(40);
expect($result->requiredLeads)->toBe(30);
expect($result->deficitLeads)->toBe(0);
});
it('fails when balance below full daily limit', function () {
$service = new BalancePreflightService;
// 1000₽ / 50₽ = 20 лидов capacity; нужно 30 → не проходит, дефицит 10.
$result = $service->evaluate(
balanceRub: '1000.00',
deliveredInMonth: 0,
requiredLeads: 30,
tiers: makeTiers(),
);
expect($result->passes)->toBeFalse();
expect($result->capacityLeads)->toBe(20);
expect($result->deficitLeads)->toBe(10);
});
it('passes trivially when requiredLeads is zero', function () {
$service = new BalancePreflightService;
$result = $service->evaluate('0.00', 0, 0, makeTiers());
expect($result->passes)->toBeTrue();
});
+1
View File
@@ -1744,3 +1744,4 @@ uniqid
брейнсторме
префлайт
Префлайт
матченным
+15 -2
View File
@@ -1,8 +1,20 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит записи в обратном хронологическом порядке (v8.36 → v8.35 → v8.34 → v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
## v8.36 (2026-05-25) — Billing v2 Spec C: флаги заморозки + balance_freeze_log
- **+колонка `tenants.frozen_by_balance_at TIMESTAMP NULL`** — флаг заморозки тенанта по балансу. Не NULL = баланс < стоимость дня лидов. Устанавливается `PreflightBalanceService`.
- **+колонка `projects.preflight_blocked_at TIMESTAMP NULL`** — флаг точечной блокировки проекта по preflight-проверке.
- **+таблица `balance_freeze_log`** (BIGSERIAL PK, FK tenants(id), INSERT-only аудит-журнал заморозок/разморозок; RLS `tenant_isolation`; fields: event_type/triggered_by/balance_rub_at_event/required_leads/capacity_leads/total_daily_limit/details/created_at).
- **+индекс `tenants_frozen_by_balance_idx`** — частичный WHERE NOT NULL (sparse).
- **+индекс `projects_preflight_blocked_idx`** — частичный WHERE NOT NULL (sparse).
- **+индекс `balance_freeze_log_tenant_idx`** — по (tenant_id, created_at DESC).
- Миграция: `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects`
- Метрики: +1 таблица, +3 индекса, +1 RLS-политика, +2 колонки. (Сверять с header `db/schema.sql`.)
- **NB:** номер версии v8.36 (поверх v8.35 legacy webhook removal) — ветка `feat/billing-v2-spec-c` ребейзнута через merge main 25.05.2026.
## v8.35 (2026-05-24) — legacy direct webhook removal
@@ -32,6 +44,7 @@
**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts`
**Связанные изменения кода:**
- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log`
- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log`
@@ -0,0 +1,55 @@
-- =============================================================================
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
-- (Billing v2 Spec C). INSERT-only таблица: записи не обновляются, только
-- добавляются. Используется PreflightBalanceService для аудит-трейла.
-- =============================================================================
CREATE TABLE IF NOT EXISTS balance_freeze_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
event_type VARCHAR(30) NOT NULL,
triggered_by VARCHAR(30) NOT NULL,
balance_rub_at_event DECIMAL(12,2) NOT NULL,
required_leads INTEGER NOT NULL,
capacity_leads INTEGER NOT NULL,
total_daily_limit INTEGER NOT NULL,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON balance_freeze_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
-- Флаги заморозки на tenants и projects
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
-- Явные GRANT'ы для 4 ролей: на prod таблица создаётся через pgsql_supplier
-- (default privileges от postgres-superuser не наследуются на чужие creator-role).
-- Mirror supplier_lead_deliveries grant pattern. DO block — idempotent + dev-safe
-- (на dev ролей нет → silent skip). balance_freeze_log: SELECT+INSERT только
-- (INSERT-only по дизайну — не UPDATE, не DELETE).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_admin_user;
END IF;
END $$;
+60 -6
View File
@@ -1,13 +1,14 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
-- Версия: v8.36 (25.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at)
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 6 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
-- Метрики: 73 базовые таблицы (65 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 120 индексов / 40 RLS-политик / 5 функций / 15 триггеров
-- Метрики: 74 базовые таблицы (66 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 123 индекса / 41 RLS-политика / 5 функций / 15 триггеров
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
@@ -674,12 +675,17 @@ CREATE TABLE tenants (
-- Метаданные
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5)
deleted_at TIMESTAMPTZ, -- soft delete (раздел 4.5)
-- v8.35 (Billing v2 Spec C): флаг заморозки по балансу.
-- Не NULL = тенант заморожен (баланс < стоимость дня лидов). PreflightBalanceService.
frozen_by_balance_at TIMESTAMPTZ NULL
);
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal)
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
-- v8.36: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
-- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id).
@@ -845,6 +851,9 @@ CREATE TABLE projects (
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
-- v8.35 (Billing v2 Spec C): флаг точечной блокировки проекта по preflight.
-- Не NULL = проект заблокирован (не хватает баланса на ближайшие N лидов).
preflight_blocked_at TIMESTAMPTZ NULL,
UNIQUE (tenant_id, name),
CONSTRAINT chk_projects_daily_limit_positive
CHECK (daily_limit_target > 0),
@@ -876,6 +885,8 @@ CREATE INDEX idx_projects_tenant_signal
ON projects(tenant_id, signal_type, signal_identifier);
-- v8.20 (Plan 6): GIN-индекс для outbound regions queries.
CREATE INDEX idx_projects_regions ON projects USING GIN (regions);
-- v8.35: частичный индекс для preflight-блокировки (sparse — большинство проектов не заблокированы)
CREATE INDEX projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
COMMENT ON COLUMN projects.daily_limit_target IS
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
@@ -3310,6 +3321,49 @@ ALTER TABLE lead_charges
DEFERRABLE INITIALLY DEFERRED;
-- =============================================================================
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
-- (Billing v2 Spec C, v8.35). INSERT-only: записи не обновляются.
-- =============================================================================
CREATE TABLE IF NOT EXISTS balance_freeze_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
event_type VARCHAR(30) NOT NULL,
triggered_by VARCHAR(30) NOT NULL,
balance_rub_at_event DECIMAL(12,2) NOT NULL,
required_leads INTEGER NOT NULL,
capacity_leads INTEGER NOT NULL,
total_daily_limit INTEGER NOT NULL,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON balance_freeze_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_admin;
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_admin;
END IF;
END $$;
-- =============================================================================
-- КОНЕЦ schema.sql
-- =============================================================================
@@ -26,6 +26,7 @@ scope P2. Решение заказчика — `disabled` + tooltip (как 5A
ловит pointer-события → активатор tooltip навешивается на оборачивающий `<span>`.
**Files:**
- Modify: `app/resources/js/components/billing/BalanceCard.vue`
- Create: `app/tests/Frontend/BalanceCard.spec.ts`
@@ -144,6 +145,7 @@ git commit -m "feat(billing): E2 — disabled+tooltip на кнопках Авт
заказчика — убрать баннер и файл `mockBilling.ts` целиком.
**Files:**
- Modify: `app/resources/js/views/BillingView.vue`
- Delete: `app/resources/js/composables/mockBilling.ts`
- Modify: `app/tests/Frontend/BillingView.spec.ts`
@@ -214,6 +216,7 @@ CSRF — латентный баг для прода). Остальные адм
ванный `api/admin.ts` + `apiClient`. Задача — вынести вызовы pricing-tiers/suppliers в `api/admin.ts`.
**Files:**
- Modify: `app/resources/js/api/admin.ts`
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue`
@@ -295,11 +298,13 @@ export async function updateAdminSupplier(
`scheduled: ref<Record<string, AdminPricingTier[]>>({})`, `editor: ref<PricingTierEditorRow[]>(...)`,
`defaultEditor: PricingTierEditorRow[]`).
4. `load()` — заменить тело:
```ts
const data = await getPricingTiers();
active.value = data.active;
scheduled.value = data.scheduled;
```
5. `submit()` — заменить `await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });` на
`await createPricingTiers(editor.value);`.
6. `confirmDelete()` — заменить `await axios.delete(\`/api/admin/pricing-tiers/scheduled/${effectiveFrom}\`);`
@@ -319,8 +324,10 @@ export async function updateAdminSupplier(
- [ ] **Step 4: Переписать `AdminPricingTiersView.spec.ts` на мок api/admin**
Эталон паттерна — `app/tests/Frontend/AdminBillingViewActions.spec.ts`. Ключевые правки:
1. Убрать `import axios from 'axios';` и `vi.mock('axios');`.
2. Добавить partial-мок:
```ts
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
@@ -328,19 +335,24 @@ export async function updateAdminSupplier(
});
const adminApi = await import('../../resources/js/api/admin');
```
3. Добавить хелпер ошибки (копия из эталона):
```ts
function makeAxiosError(message: string, status = 422): unknown {
return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } });
}
```
4. `mockTiers` — оставить (это `AdminPricingTier[]`).
5. Первый `describe` `beforeEach`:
```ts
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
```
6. Тест `submits POST ...``expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]));`
7. Тест `confirmDelete triggers DELETE ...``expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');` (`window.confirm = vi.fn(() => true)` — оставить, T5 уберёт).
8. `describe` error handling — убрать `axios.isAxiosError` блок; в каждом тесте заменить
@@ -351,6 +363,7 @@ export async function updateAdminSupplier(
- [ ] **Step 5: Переписать `AdminSupplierPricesView.spec.ts` на мок api/admin**
Аналогично Step 4:
1. Убрать axios; `vi.mock('../../resources/js/api/admin', ...)` с `getAdminSuppliers`/`updateAdminSupplier` как `vi.fn()`.
2. `makeAxiosError` хелпер.
3. `beforeEach`: `vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);`
@@ -386,6 +399,7 @@ backend `AdminPricingTiersController@store:92` хардкодит `startOfMonth(
показывает `nextMonthStart` в кнопке и заголовке диалога. G7 — дать админу выбрать дату.
**Files:**
- Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php`
- Modify: `app/resources/js/api/admin.ts`
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
@@ -430,14 +444,17 @@ Expected: FAIL — `effective_from` сейчас игнорируется (пе
- [ ] **Step 3: Backend — принять `effective_from` в `store()`**
В `AdminPricingTiersController@store`:
1. Перед `$request->validate([...])` вычислить `$todayMsk = Carbon::now('Europe/Moscow')->toDateString();`
2. В массив правил добавить:
`'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],`
3. Заменить строку `$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();` на:
```php
$effectiveFrom = $request->input('effective_from')
?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
```
(`$todayMsk` из шага 1 переиспользуется правилом валидации; вычислять до `validate`.)
- [ ] **Step 4: Прогнать Pest — убедиться, что проходит**
@@ -465,9 +482,11 @@ export async function createPricingTiers(
- [ ] **Step 6: Frontend — date-picker в редакторе сетки**
В `AdminPricingTiersView.vue`:
1. Добавить ref после `nextMonthStart` computed:
`const effectiveFrom = ref<string>(nextMonthStart.value);`
2. Добавить computed для `min` (завтра):
```ts
const minEffectiveFrom = computed(() => {
const d = new Date();
@@ -475,7 +494,9 @@ export async function createPricingTiers(
return d.toISOString().slice(0, 10);
});
```
3. В диалоге-редакторе перед `<table class="editor-table">` добавить поле:
```vue
<v-text-field
v-model="effectiveFrom"
@@ -488,6 +509,7 @@ export async function createPricingTiers(
data-testid="effective-from-input"
/>
```
4. Заголовок диалога: `Новая сетка (effective_from = {{ effectiveFrom }})` (вместо `nextMonthStart`).
Кнопку открытия редактора `Редактировать сетку (с {{ nextMonthStart }})` — оставить
`nextMonthStart` (это дефолтная подсказка до открытия диалога).
@@ -548,6 +570,7 @@ confirm()», но в `AdminBillingView` `confirm()` уже нет — Sprint 3D
`v-dialog`'и; фактический оставшийся браузерный confirm в админ-биллинге — здесь, в pricing-tiers.)
**Files:**
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
@@ -591,15 +614,19 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не
- [ ] **Step 3: Заменить `window.confirm` на `v-dialog`-flow**
В `AdminPricingTiersView.vue`:
1. Добавить state: `const deleteDialogOpen = ref(false);` и `const deleteTarget = ref<string | null>(null);`
2. Заменить функцию `confirmDelete` — теперь только открывает диалог:
```ts
function confirmDelete(effectiveFrom: string): void {
deleteTarget.value = effectiveFrom;
deleteDialogOpen.value = true;
}
```
3. Добавить `performDelete` — фактическое удаление (тело — бывший `confirmDelete` без `window.confirm`):
```ts
async function performDelete(): Promise<void> {
const effectiveFrom = deleteTarget.value;
@@ -619,7 +646,9 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не
}
}
```
4. В `<template>` после диалога-редактора добавить confirm-диалог:
```vue
<v-dialog v-model="deleteDialogOpen" max-width="440">
<v-card>
@@ -636,6 +665,7 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не
</v-card>
</v-dialog>
```
5. `defineExpose` — добавить `deleteDialogOpen`, `deleteTarget`, `performDelete`.
- [ ] **Step 4: Прогнать FE-тест — убедиться, что проходит**
@@ -666,4 +696,4 @@ markdownlint, cspell, lychee, gitleaks) и `superpowers:finishing-a-development-
**Ожидаемые изменения относительно базы `345d14d`:** 5 feat/refactor-коммитов + этот plan-коммит.
Файлы: `BalanceCard.vue`, `BillingView.vue`, `mockBilling.ts` (удалён), `api/admin.ts`,
`AdminPricingTiersView.vue`, `AdminSupplierPricesView.vue`, `AdminPricingTiersController.php`,
+ 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
- 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
@@ -7,6 +7,7 @@
**Goal:** Закрыть журнал входа `auth_log` на все остальные auth-события (выход, 2FA setup/verify/recovery, password reset, регистрация) и заполнять `user_id`/`ip_address`/`user_agent` во **всех** `ActivityLog::create` (сейчас все 8 точек проставляют NULL).
**Architecture:**
1. Существующая приватная `logAuthEvent()` в `AuthController` ([:416-435](../../../app/app/Http/Controllers/Api/AuthController.php#L416)) выносится в трейт `App\Http\Controllers\Concerns\WritesAuthLog`. Подключается в `AuthController`, `TwoFactorController`, `TwoFactorSetupController`, `PasswordResetController` — единая точка записи (решение E=a).
2. Все `ActivityLog::create` в `DealController` (4 точки) и `DealBulkActionController` (3 точки) получают `user_id` из `$request->user()->id`, плюс `ip_address` и `user_agent`. Прошлое не бэкфилим (решение B=a).
3. Hash-chain trigger на `auth_log` уже стоит ([db/schema.sql:3032](../../../db/schema.sql#L3032)) — новые записи защищены автоматически.
@@ -18,12 +19,14 @@
## File Structure
**New:**
- `app/app/Http/Controllers/Concerns/WritesAuthLog.php` — трейт.
- `app/tests/Unit/Concerns/WritesAuthLogTest.php`
- `app/tests/Feature/Auth/AuthLogCoverageTest.php` — все auth-события.
- `app/tests/Feature/Deals/ActivityLogAttributionTest.php` — автор/IP в `activity_log`.
**Modified:**
- `app/app/Http/Controllers/Api/AuthController.php``logout`, `registerVerify`; убрать локальную `logAuthEvent`, использовать трейт.
- `app/app/Http/Controllers/Api/TwoFactorController.php``verifyTwoFactor` (успех+неудача), `useRecoveryCode` (успех+неудача).
- `app/app/Http/Controllers/Api/TwoFactorSetupController.php``init`, `confirm`, `disable`, `regenerateRecoveryCodes`.
@@ -36,6 +39,7 @@
## Task 1 — `WritesAuthLog` трейт
**Files:**
- Create: `app/app/Http/Controllers/Concerns/WritesAuthLog.php`
- Test: `app/tests/Unit/Concerns/WritesAuthLogTest.php`
@@ -130,6 +134,7 @@ git commit -m "feat(auth): WritesAuthLog trait — shared auth_log writer"
## Task 2 — AuthController → use trait, log `logout` + `register_success`
**Files:**
- Modify: `app/app/Http/Controllers/Api/AuthController.php`
- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` (NEW, накапливается)
@@ -191,6 +196,7 @@ class AuthController extends Controller
## Task 3 — TwoFactorController → log verify (success+fail) + recovery (success+fail)
**Files:**
- Modify: `app/app/Http/Controllers/Api/TwoFactorController.php:41,110`
- [ ] **Step 1: failing test (4 кейса)**`2fa_verify_success`, `2fa_verify_failed`, `2fa_recovery_used`, `2fa_recovery_failed` (с правильным `failure_reason`).
@@ -223,6 +229,7 @@ $this->logAuthEvent('2fa_recovery_failed', $user->id, $user->tenant_id, $user->e
## Task 4 — TwoFactorSetupController → log init/confirm/disable/regen
**Files:**
- Modify: `app/app/Http/Controllers/Api/TwoFactorSetupController.php:39,80,133,163`
- [ ] **Step 1: failing test (4 кейса)**`2fa_setup_init`, `2fa_setup_confirmed`, `2fa_disabled`, `2fa_recovery_regenerated`. Для disable — отдельно неудачный пароль = `2fa_disable_failed` (failure_reason='invalid_password').
@@ -255,6 +262,7 @@ $this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $us
## Task 5 — PasswordResetController → log forgot/reset (success+fail)
**Files:**
- Modify: `app/app/Http/Controllers/Api/PasswordResetController.php:57,94`
- [ ] **Step 1: failing test (3 кейса)**`password_reset_requested` (всегда пишется, даже если email неизвестен — anti-enumeration на UI остаётся, но в журнале фиксируется), `password_reset_completed` (на success Password::reset), `password_reset_failed` (на статусе != PASSWORD_RESET).
@@ -300,6 +308,7 @@ class PasswordResetController extends Controller
## Task 6 — DealController: автор/IP в 4 ActivityLog::create
**Files:**
- Modify: `app/app/Http/Controllers/Api/DealController.php:387,400,412,523`
- Test: `app/tests/Feature/Deals/ActivityLogAttributionTest.php` (NEW)
@@ -350,6 +359,7 @@ git commit -m "feat(audit): activity_log captures actor user_id + ip + UA in Dea
## Task 7 — DealBulkActionController: автор/IP в 3 ActivityLog::insert
**Files:**
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php:99-112,170-179,234-243`
- [ ] **Step 1: failing test (3 кейса: bulk transition, bulk destroy, bulk restore)** — для каждой записи в logRows ожидаем `user_id = $request->user()->id, ip_address = '...'`.
@@ -383,6 +393,7 @@ git commit -m "feat(audit): activity_log captures actor in bulk deal actions"
## Task 8 — Integration: full auth-flow coverage
**Files:**
- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` — финальный E2E прогон
- [ ] **Step 1: test — единый сценарий «полный auth-flow одного user'а»**
@@ -7,6 +7,7 @@
**Goal:** Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и **авто-наполнение `incidents_log`** на основе порога падений (решение D=a: cron-watcher).
**Architecture:**
1. Новый журнал `tenant_operations_log` — для мутаций тенант-уровня вне сделок (проекты, API-ключи, webhook-URL). По структуре повторяет `activity_log`, но без `deal_id NOT NULL`. Защищён теми же `audit_chain_hash()` и `audit_block_mutation()` триггерами.
2. Сервис `App\Services\Audit\OperationsLogger` — единственный писатель `tenant_operations_log`.
3. Admin supplier-integration действия пишутся в существующий `saas_admin_audit_log` (структура подходит).
@@ -20,6 +21,7 @@
## File Structure
**New (миграция + код + тесты):**
- `db/migrations/2026_05_22_<seq>_tenant_operations_log.sql` (raw SQL — паттерн схемы Лидерры) + дополнения к `db/schema.sql`.
- `app/app/Services/Audit/OperationsLogger.php`
- `app/app/Models/TenantOperationsLog.php` (Eloquent для чтения, INSERT через сервис).
@@ -33,6 +35,7 @@
- `app/tests/Feature/Console/IncidentsWatchFailuresTest.php`
**Modified:**
- `db/schema.sql` — добавить определение `tenant_operations_log` + индексы + RLS + триггеры hash-chain.
- `db/CHANGELOG_schema.md` — запись v8.X.
- `app/app/Services/Project/ProjectService.php` — create/update/delete/bulk → запись.
@@ -47,6 +50,7 @@
## Task 1 — Миграция `tenant_operations_log`
**Files:**
- Modify: `db/schema.sql` (вставить новый раздел).
- Create: `db/migrations/2026_05_22_001_tenant_operations_log.sql`
- Modify: `db/CHANGELOG_schema.md` — запись.
@@ -130,6 +134,7 @@ git commit -m "feat(schema): tenant_operations_log table with hash-chain protect
## Task 2 — `OperationsLogger` сервис
**Files:**
- Create: `app/app/Services/Audit/OperationsLogger.php`
- Test: `app/tests/Unit/Services/Audit/OperationsLoggerTest.php`
@@ -195,6 +200,7 @@ final class OperationsLogger
## Task 3 — ProjectService мутации → `tenant_operations_log`
**Files:**
- Modify: `app/app/Services/Project/ProjectService.php` (create, update, delete, bulk*)
- Test: `app/tests/Feature/Projects/ProjectMutationsAuditTest.php` (NEW)
@@ -267,6 +273,7 @@ class ProjectService
## Task 4 — ApiKeyController.regenerate → `tenant_operations_log`
**Files:**
- Modify: `app/app/Http/Controllers/Api/ApiKeyController.php:41-72`
- Test: `app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php` (NEW)
@@ -299,6 +306,7 @@ public function regenerate(Request $request, \App\Services\Audit\OperationsLogge
## Task 5 — WebhookSettingsController.update → `tenant_operations_log`
**Files:**
- Modify: `app/app/Http/Controllers/Api/WebhookSettingsController.php:50-86`
- Test: `app/tests/Feature/Security/WebhookUrlChangeAuditTest.php` (NEW)
@@ -314,6 +322,7 @@ public function regenerate(Request $request, \App\Services\Audit\OperationsLogge
## Task 6 — AdminSupplierIntegrationController (3 mutating action) → `saas_admin_audit_log`
**Files:**
- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php:89,158,234`
- Test: `app/tests/Feature/Admin/SupplierIntegrationAuditTest.php` (NEW)
@@ -365,6 +374,7 @@ SaasAdminAuditLog::create([
## Task 7 — SupplierWebhookController.receive → `webhook_log` (success + отказы)
**Files:**
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php:47-114`
- Test: `app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php` (NEW)
@@ -422,6 +432,7 @@ $this->logSupplierWebhook($request, $lead->id, 'received', null);
## Task 8 — Cron-watcher `incidents:watch-failures`
**Files:**
- Create: `app/app/Console/Commands/IncidentsWatchFailures.php`
- Modify: `app/routes/console.php` — добавить расписание.
- Test: `app/tests/Feature/Console/IncidentsWatchFailuresTest.php` (NEW)
@@ -538,6 +549,7 @@ git commit -m "feat(incidents): cron-watcher auto-populates incidents_log on fai
## Task 9 — Integration: полный operational-flow
**Files:**
- Test: `app/tests/Feature/Audit/OperationalFullFlowTest.php`
- [ ] **Step 1: test «полный сценарий»**
@@ -7,6 +7,7 @@
**Goal:** Закрыть журнал `pd_processing_log` во всех точках обработки ПДн (created/viewed/exported/deleted) и защищённый аудит impersonation (`saas_admin_audit_log` + ПДн-след) — соответствие 152-ФЗ ст.18 ч.2.
**Architecture:**
1. Сервис `App\Services\Pd\PdAuditLogger` — единственная точка записи в `pd_processing_log`. Через DI внедряется в контроллеры/джобы/команды; явные вызовы в местах операций.
2. Hash-chain и append-only защита стоит триггерами схемы ([db/schema.sql:3046-3051](../../../db/schema.sql#L3046)) — сервис только формирует строку, БД гарантирует целостность.
3. Impersonation использует `App\Services\Pd\ImpersonationAuditService` — пишет `saas_admin_audit_log` на init/verify/end и `pd_processing_log` один раз на сессию (гибрид C=c из решений: session-level + per-export если экспорт идёт изнутри impersonation).
@@ -21,6 +22,7 @@
## File Structure
**New (10 файлов):**
- `app/app/Services/Pd/PdAuditLogger.php` — запись в `pd_processing_log`.
- `app/app/Services/Pd/ImpersonationAuditService.php` — оркестратор impersonation-событий в оба журнала.
- `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php`
@@ -33,6 +35,7 @@
- `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php`
**Modified:**
- `app/app/Http/Controllers/Api/DealController.php``show()` + `store()`.
- `app/app/Http/Controllers/Api/DealExportController.php``export()`.
- `app/app/Http/Controllers/Api/ReportJobController.php``destroy()`.
@@ -47,6 +50,7 @@
## Task 1 — `PdAuditLogger` service
**Files:**
- Create: `app/app/Services/Pd/PdAuditLogger.php`
- Test: `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php`
@@ -96,6 +100,7 @@ it('rejects two-actor row (chk_pd_actor violation)', function () {
```bash
cd app && php artisan test --filter=PdAuditLoggerTest
```
Expected: FAIL (`Class "App\Services\Pd\PdAuditLogger" not found`).
- [ ] **Step 3: implement**
@@ -148,6 +153,7 @@ final class PdAuditLogger
```bash
cd app && php artisan test --filter=PdAuditLoggerTest
```
Expected: 3/3 PASS.
- [ ] **Step 5: commit**
@@ -162,6 +168,7 @@ git commit -m "feat(pd): PdAuditLogger service (152-ФЗ pd_processing_log write
## Task 2 — DealController.show → pd 'viewed'
**Files:**
- Modify: `app/app/Http/Controllers/Api/DealController.php:244-315`
- Test: `app/tests/Feature/Pd/DealViewAccessLogTest.php` (NEW)
@@ -206,6 +213,7 @@ it('does not write pd_processing_log for 404 lookups', function () {
```bash
cd app && php artisan test --filter=DealViewAccessLogTest
```
Expected: FAIL.
- [ ] **Step 3: implement — inject logger + добавить вызов в `DealController::show()` после `if ($deal === null) return 404`**
@@ -239,6 +247,7 @@ public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonRespo
```bash
cd app && php artisan test --filter=DealViewAccessLogTest
```
Expected: 2/2 PASS.
- [ ] **Step 5: commit**
@@ -253,6 +262,7 @@ git commit -m "feat(pd): pd_processing_log 'viewed' on deal card open (152-ФЗ)
## Task 3 — Deal-creation paths → pd 'created' (3 точки)
**Files:**
- Modify: `app/app/Http/Controllers/Api/DealController.php:523` (manual store)
- Modify: `app/app/Jobs/ProcessWebhookJob.php:147`, `:232` (webhook + duplicate)
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:285`, `:308` (supplier route + duplicate)
@@ -324,6 +334,7 @@ app(PdAuditLogger::class)->record(
```bash
cd app && php artisan test --filter=DealCreatePdLogTest
```
Expected: 3/3 PASS.
- [ ] **Step 5: commit**
@@ -338,6 +349,7 @@ git commit -m "feat(pd): pd_processing_log 'created' on deal creation (manual/we
## Task 4 — DealExportController → pd 'exported'
**Files:**
- Modify: `app/app/Http/Controllers/Api/DealExportController.php:43-127`
- Test: `app/tests/Feature/Pd/DealExportPdLogTest.php` (NEW)
@@ -398,6 +410,7 @@ git commit -m "feat(pd): pd_processing_log 'exported' on deals export (152-ФЗ)
## Task 5 — ReportJobController.destroy → pd 'deleted'
**Files:**
- Modify: `app/app/Http/Controllers/Api/ReportJobController.php:308-343`
- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (NEW)
@@ -430,6 +443,7 @@ app(\App\Services\Pd\PdAuditLogger::class)->record(
## Task 6 — ReportsCleanupExpired (cron) → pd 'deleted' (per file)
**Files:**
- Modify: `app/app/Console/Commands/ReportsCleanupExpired.php:60-75`
- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (расширить)
@@ -458,6 +472,7 @@ if (! $dryRun) {
## Task 7 — HistoricalImportService → pd 'created' (per row)
**Files:**
- Modify: `app/app/Services/Import/HistoricalImportService.php:250-270`
- Test: `app/tests/Feature/Pd/DealCreatePdLogTest.php` (расширить — кейс «импорт N лидов → N pd-строк action=created, purpose='lead_create_import_'.$importLogId»).
@@ -484,6 +499,7 @@ $this->pdLog->record(
## Task 8 — `ImpersonationAuditService` (unit-tested)
**Files:**
- Create: `app/app/Services/Pd/ImpersonationAuditService.php`
- Test: `app/tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php`
@@ -591,6 +607,7 @@ final class ImpersonationAuditService
## Task 9 — Wire `ImpersonationController::init`
**Files:**
- Modify: `app/app/Http/Controllers/Api/ImpersonationController.php:94-141`
- Test: `app/tests/Feature/Pd/ImpersonationAuditTest.php` (NEW)
@@ -640,6 +657,7 @@ $audit->recordEnd($token, adminId: $token->requested_by, ip: $request->ip());
## Task 12 — Integration test: полный ПДн-цикл
**Files:**
- Create: `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php`
- [ ] **Step 1: test — сценарий «вебхук → создание сделки → просмотр → экспорт → удаление отчёта»**
@@ -11,6 +11,7 @@
**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md` + amendment 2026-05-24 (Task 0a/0b + chain governance).
**Прошлые этапы:**
- Этап 1 ✅ закрыт 2026-05-23 (реестр 83 узла + 16 chains).
- Этап 2 ✅ закрыт 2026-05-24 (3 среза измерений, baseline зафиксирован, классификация-map переключена на реестр).
@@ -72,6 +73,7 @@ Expected: `200 ...{"content":[{"text":"OK"...`. Если 401 — ENV var отс
## File Structure
**Создаём:**
- `tools/router-classifier.mjs` — pure module (regex Layer 1 + LLM Layer 2 + cache + budget guard).
- `tools/router-classifier.test.mjs` — unit-тесты обоих слоёв.
- `tools/router-prehook.mjs` — UserPromptSubmit hook, вызывает classifier и пишет state.
@@ -82,6 +84,7 @@ Expected: `200 ...{"content":[{"text":"OK"...`. Если 401 — ENV var отс
- `tools/router-accuracy-runner.mjs` — pure script: прогоняет 20 промптов через classifier, выдаёт accuracy report.
**Модифицируем:**
- `docs/registry/nodes.yaml` — добавить keyword-триггеры на 30+ доменных скилов (Task 0a Step A).
- `tools/observer-stop-hook.mjs` — добавить обновление `chain_progress` в state-файле + запись в эпизод (Task 7 chain tracking).
- `tools/brain-retro-analyzer.mjs` — две новых оси (domain-hit-rate + chain-completion-rate).
@@ -92,6 +95,7 @@ Expected: `200 ...{"content":[{"text":"OK"...`. Если 401 — ENV var отс
- `docs/observer/active-projects.md` + memory `project_router_overhaul.md` (continuity).
**Не трогаем (это этап 4):**
- Pravila / CLAUDE.md / PSR_v1 / Tooling / ADR.
- `docs/router-procedure.md` (v1.4 → v2.0 — этап 4).
- Существующие economy/skill-discipline хуки (трогаются ТОЛЬКО для совместимости-проверок).
@@ -107,6 +111,7 @@ Expected: `200 ...{"content":[{"text":"OK"...`. Если 401 — ENV var отс
**Зачем.** Layer 1 regex ищет совпадения keyword'ов узла с промптом. Без доменных keyword'ов на специализированных скилах (#62 billing-audit, #71 pdn-152fz-audit, #74 marketing и т. д.) regex не выберет правильный узел.
**Files:**
- Modify: `docs/registry/nodes.yaml`
**Маппинг доменных keyword'ов** (минимум 5 на узел, всего ~30 узлов):
@@ -212,6 +217,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** Pure-функция, которая по тексту промпта возвращает `{taskType, micro, recommendedNode, source: 'regex'}`. Layer 2 (LLM) — отдельно в Task 3.
**Files:**
- Create: `tools/router-classifier.mjs`
- Test: `tools/router-classifier.test.mjs`
@@ -465,6 +471,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Зачем.** Если Layer 1 confidence < 0.7 — эскалируем в Sonnet с реестром в prompt'е. Кэш per-prompt-hash + бюджет ≤200 вызовов/день.
**Files:**
- Modify: `tools/router-classifier.mjs` (добавить exports `classifyByLLM` + `classify`)
- Modify: `tools/router-classifier.test.mjs` (новые describe)
@@ -710,6 +717,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** Когда заказчик отправляет промпт, прехук вызывает classifier и пишет state в `~/.claude/runtime/router-state-<session>.json` для текущего хода.
**Files:**
- Create: `tools/router-prehook.mjs`
- Test: `tools/router-prehook.test.mjs`
@@ -914,6 +922,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** Замер точности classifier'а ДО регистрации в settings.json. Decision gate Phase A.
**Files:**
- Create: `tools/router-test-prompts.json` — 20 промптов с ground truth.
- Create: `tools/router-accuracy-runner.mjs` — pure script.
@@ -1039,6 +1048,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
## CHECKPOINT A — Заказчик ревьюит Phase A
**После Task 5** — пауза. Заказчик смотрит на:
1. Accuracy report (% по типу, узлу, micro).
2. Список failures (что классифицируется неправильно).
3. Принимает решение:
@@ -1059,6 +1069,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** PreToolUse hook читает state из Task 4 и решает: блокировать или нет. **Первая итерация — warn-only**: пишет предупреждение в stderr, но не блокирует. Это даёт сутки наблюдения «как часто гейт сработал бы, если бы был включён» без реальных блокировок.
**Files:**
- Create: `tools/router-tool-gate.mjs`
- Test: `tools/router-tool-gate.test.mjs`
@@ -1312,6 +1323,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** После каждого хода Stop-гейт смотрит «какие скилы были вызваны?» и обновляет `chainProgress` в state-файле. Когда `chainProgress.length === chain.sequence.length` — цепочка завершена.
**Files:**
- Modify: `tools/observer-stop-hook.mjs` (или создать `tools/router-stop-gate.mjs` рядом)
- [ ] **Step 1: Прочитать существующий observer-stop-hook.mjs**
@@ -1491,6 +1503,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** Подключить 3 хука к Claude Code, режим **warn-only**. Никакой реальной блокировки — только diagnostic warnings в stderr.
**Files:**
- Modify: `.claude/settings.json`
- [ ] **Step 1: Прочитать текущий `.claude/settings.json`**
@@ -1576,16 +1589,19 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Между Task 8 и Task 9** — пауза **минимум 24 часа** реальной работы.
В этот период:
- Сторож не блокирует ничего.
- В stderr пишется когда сторож **сработал бы**.
- В журнале наблюдателя пишется фактическое решение классификатора по каждому промпту.
**Метрика готовности к enforce:** прогон `/brain-retro` за это окно покажет:
- Сколько раз сторож был активен (количество warnings).
- На каком количестве из этих — я вызвал нужный навык, на каком — нет.
- Сколько ложных срабатываний (warning на задачу где навык объективно не нужен).
**Решение заказчика:**
- Warnings адекватны (≥80% правильные) → переключаем в `enforce` (Task 9).
- Warnings шумные (>20% ложных) → возвращаемся к Task 1-3, поправляем разметку или regex.
- Совсем не работает — выключаем хуки (rollback за 5 минут).
@@ -1597,6 +1613,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
**Цель.** После согласия заказчика — переключаем mode → enforce. Заодно расширяем STATUS.md новыми метриками.
**Files:**
- Modify: `~/.claude/runtime/router-gate-mode.json`
- Modify: `tools/brain-retro-analyzer.mjs` (+ две оси)
- Modify: `tools/brain-retro-analyzer.test.mjs`
@@ -1738,6 +1755,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
### Task 10: Continuity + memory update + final regression + push
**Files:**
- Modify: `docs/observer/active-projects.md`
- Modify: outside-repo `memory/project_router_overhaul.md` (controller)
- Modify: outside-repo `memory/MEMORY.md` (controller)
@@ -1815,6 +1833,7 @@ git push origin <branch>:feat/router-overhaul-stage-3-enforcement
## Self-Review
**1. Spec coverage** (включая amendment Task 0a/0b/chain governance):
- ✅ Task 0a (доменная разметка) — Task 1 + Task 2 (classifier ищет по keyword).
- ✅ Task 0b (цепочки) — buildLLMPrompt включает chains, classifier возвращает recommendedChain, Task 7 chain progress, Task 9 chain completion rate.
- ✅ Chain governance — упомянуто в spec amendment, в плане НЕ создаётся (это не код, а правила правок реестра — продолжают действовать).
@@ -1828,11 +1847,13 @@ git push origin <branch>:feat/router-overhaul-stage-3-enforcement
- ✅ Откатываемость ≤5 минут — Task 8/9 (settings.json + mode file).
**2. Placeholders:**
- ✅ Нет «TBD», «implement later», «handle edge cases» без кода.
- ✅ Чёткое distinction: ручные шаги (smoke-test после settings.json) явно помечены «не для субагента».
- ⚠️ Task 9 Step 1 expectedNode сравнения зависят от точного фактического значения `chain_progress` в эпизодах после Task 7 деплоя — тесты валидны на artificial fixtures.
**3. Type consistency:**
- `classifyByRegex``{taskType, micro, recommendedNode, confidence, source}` — везде то же.
- `classify` (async) — тот же shape + `recommendedChain` + опционально `llmError`.
- State в router-state-<session>.json — `{sessionId, promptHash, classification, skillInvokedThisTurn, chainProgress, enforcementRequired, timestamp}`. Consistent в Task 4, 6, 7.
@@ -1840,6 +1861,7 @@ git push origin <branch>:feat/router-overhaul-stage-3-enforcement
- Mode file format — `{mode: 'warn-only' | 'enforce'}` — consistent в Task 6 и Task 9.
**4. Риски и митигации:**
- LLM down → fallback на regex result (Task 3 classify()).
- Любая ошибка прехука → silent fallback, проход (Task 4 main()).
- ENV var ANTHROPIC_API_KEY missing → Pre-flight Step 5 ловит ДО старта работы.
@@ -436,4 +436,3 @@
- ✅ Цепочки governance явно зафиксированы — Claude не имеет права автоматически менять.
- ✅ Откатываемость сохранена (хуки в settings.json + revert коммитов).
- ⚠️ Расширение scope этапа 3 ≈ +3 часа работы (Task 0a 1.5ч + Task 0b 1.5ч). Принято заказчиком 24.05.