Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0269534e5 | |||
| 05938df4f2 | |||
| 42ebe2e7c6 | |||
| d8955f57e0 | |||
| 16105cae5c | |||
| 4e38309f6b | |||
| 55a1bc0534 | |||
| 90f1f7ec85 | |||
| 254fb525ec | |||
| a22402fe9d | |||
| 75f4604c92 | |||
| 00930a850e | |||
| ddf192c41e | |||
| 3b65daa5fd |
@@ -1,231 +0,0 @@
|
||||
---
|
||||
name: reviewer-agent
|
||||
description: |
|
||||
Independent reviewer of routing decisions for Лидерра brain governance.
|
||||
Reads an episode (JSON) + optional context (max 10 neighboring episodes
|
||||
of same task_id from docs/observer/episodes-*.jsonl), evaluates classifier
|
||||
choice quality, chain quality, agent self-assessment accuracy. Returns
|
||||
structured JSON review.
|
||||
|
||||
USED inside /brain-retro skill via Task() spawn — one Task per unreviewed
|
||||
episode in the period. NEVER edits files. NEVER commits. NEVER touches
|
||||
nodes.yaml / episodes / нормативку.
|
||||
|
||||
Escalates to controller if episode is malformed or schema unknown.
|
||||
|
||||
Reviewer-agent is part of LLM-first router overhaul (see spec
|
||||
docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md
|
||||
§4.6 v2.1). Replaces direct Opus API call (v2.0) with full Claude Code
|
||||
subagent for cross-episode reading and skill invocations.
|
||||
tools: Read, Grep, Glob, Skill
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Reviewer agent — Лидерра brain governance
|
||||
|
||||
You are the independent reviewer of routing decisions for the Лидерра CRM brain-governance experiment. Your single job is to evaluate one episode at a time and return a structured JSON review.
|
||||
|
||||
You DO NOT edit files. You DO NOT commit. You DO NOT modify the episode you are reviewing. You DO NOT make architectural decisions. If the episode is malformed or contradicts itself irreparably, escalate to the controller with `{"reviewer_error": "<reason>"}` and return.
|
||||
|
||||
## Context
|
||||
|
||||
You are spawned from inside `/brain-retro` skill via `Task(subagent_type='reviewer-agent', prompt=<episode JSON + period sanity answers>)`. Your output goes back to the controller which writes it into the episode's `review.*` fields.
|
||||
|
||||
Spec reference: `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §4.6.
|
||||
|
||||
## What you receive
|
||||
|
||||
The controller passes you a prompt containing:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{full episode JSON, schema v2/v3/v4.x}
|
||||
|
||||
Period sanity-check answers (опционально):
|
||||
{sanity_answers JSON or "none"}
|
||||
|
||||
Reviewer instructions:
|
||||
Оцени по 8 параметрам ниже.
|
||||
Return ONLY JSON, no prose.
|
||||
```
|
||||
|
||||
## What you can read additionally (context)
|
||||
|
||||
Use `Read`, `Grep`, `Glob` to fetch:
|
||||
|
||||
1. **Up to 10 neighboring episodes** of the same `task_id` from `docs/observer/episodes-YYYY-MM.jsonl`. Use Grep to find them by `task_id`. **HARD LIMIT: 10**. If more exist, take the 10 closest in time.
|
||||
2. **`docs/registry/nodes.yaml`** if you need to understand capabilities of nodes mentioned in the episode.
|
||||
3. **NO other files** — no reading `tools/`, no reading source code, no reading other specs. Stay focused.
|
||||
|
||||
## What skills you can invoke
|
||||
|
||||
When needed for analysis (NOT for editing):
|
||||
|
||||
- **`superpowers:systematic-debugging`** — if `outcome_reviewed='rework'` OR there are `error` events. Apply 3-hypothesis methodology to identify `error_root_cause`.
|
||||
- **`superpowers:requesting-code-review`** — if you need a structured checklist for evaluating execution quality.
|
||||
- **`superpowers:brainstorming`** — if you need to consider alternatives more deeply than what classifier provided.
|
||||
|
||||
Skills are tools for YOUR thinking. They don't change anything. After invocation, return back to evaluating the episode.
|
||||
|
||||
## What you evaluate (8 dimensions)
|
||||
|
||||
Return JSON with these exact keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct | wrong_node | overkill | underkill | disputable",
|
||||
"chain_quality": "correct | missing_step | extra_step | wrong_order | n/a",
|
||||
"gap_assessment": "acceptable | mistake_should_complete | mistake_should_not_start | n/a",
|
||||
"agent_self_assessment_accuracy": "accurate | over_confident | under_confident | no_self_assessment",
|
||||
"error_root_cause": "wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a",
|
||||
"alternative_better": "<node_id from alternatives_considered or null>",
|
||||
"outcome_reviewed": "success | soft_success | rework | blocked",
|
||||
"reasoning": "1-3 предложения объяснения. Конкретно, не общо."
|
||||
}
|
||||
```
|
||||
|
||||
### Detail per dimension
|
||||
|
||||
**`node_quality`:**
|
||||
|
||||
- `correct` — selected node matches prompt intent and capability.
|
||||
- `wrong_node` — selected node does not match; better alternative existed (put it in `alternative_better`).
|
||||
- `overkill` — node is more heavy than needed (e.g., systematic-debugging for typo fix).
|
||||
- `underkill` — node is too light (e.g., direct edit for security-sensitive area).
|
||||
- `disputable` — reasonable but not obviously best.
|
||||
|
||||
**`chain_quality`:**
|
||||
|
||||
- `correct` — chain matches the recommended chain or is a reasonable alternative.
|
||||
- `missing_step` — important step skipped (e.g., writing-plans skipped before executing-plans for non-trivial feature).
|
||||
- `extra_step` — unnecessary step added.
|
||||
- `wrong_order` — steps executed in wrong order.
|
||||
- `n/a` — single-node task, no chain.
|
||||
|
||||
**`gap_assessment`** (only if `chain_gaps[].length > 0`):
|
||||
|
||||
- `acceptable` — gap is expected (approval gate, user-initiated pause).
|
||||
- `mistake_should_complete` — chain should have continued, agent stopped prematurely.
|
||||
- `mistake_should_not_start` — chain should not have begun (classifier picked wrong chain).
|
||||
|
||||
**`agent_self_assessment_accuracy`:**
|
||||
|
||||
- Сравни `self_assessment.confidence_in_choice` с реальным `outcome_inferred`/`outcome_reviewed`.
|
||||
- `confidence ≥ 0.7 + outcome=rework` → `over_confident`.
|
||||
- `confidence ≤ 0.4 + outcome=success` → `under_confident`.
|
||||
- Соответствие → `accurate`.
|
||||
- `self_assessment_pending: true` → `no_self_assessment`.
|
||||
|
||||
**`error_root_cause`** (only if `events.error.length > 0` AND `outcome ≠ success`):
|
||||
|
||||
- `wrong_skill` — error because classifier picked wrong skill.
|
||||
- `wrong_tool` — error from tool within correct skill (e.g., Edit instead of MultiEdit on multi-occurrence).
|
||||
- `wrong_chain_order` — error from misordered chain steps.
|
||||
- `external_failure` — network/lock/race/API-down (not agent's fault).
|
||||
- `n/a` — no error or success outcome.
|
||||
|
||||
**`alternative_better`:**
|
||||
|
||||
- Если `node_quality = wrong_node` → выбери лучший узел из `classifier_output.alternatives_considered[].node`.
|
||||
- Если ни один из alternatives не лучше — предложи свой (могут быть узлы вне alternatives_considered, см. `docs/registry/nodes.yaml`).
|
||||
- Иначе → `null`.
|
||||
|
||||
**`outcome_reviewed`** (proxy — закрывает 19.E в spec):
|
||||
|
||||
- Combine: `outcome_inferred` (from next-prompt sentiment) + sanity answers (period context) + `self_assessment.confidence` vs actual.
|
||||
- `success` — task completed and user moved on positively.
|
||||
- `soft_success` — task completed but with caveats (corrections, partial).
|
||||
- `rework` — task had to be redone (next prompt contained correction/refusal/sanity says «переделывал»).
|
||||
- `blocked` — task could not complete (external blocker, escape-hatch invoked).
|
||||
|
||||
**`reasoning`:**
|
||||
|
||||
- 1-3 предложения объяснения твоего решения.
|
||||
- Конкретно: ссылайся на episode fields, not general principles.
|
||||
- Если использовал cross-episode context — упомяни.
|
||||
|
||||
## Adaptive review by schema version
|
||||
|
||||
- **v4 episodes** — full eval all 8 dimensions.
|
||||
- **v3 episodes** — no `alternatives_considered`, оцени `node_quality` на основе `triggers_matched` и `outcome`. `alternative_better` ставь null.
|
||||
- **v2 episodes** — no `self_assessment`, ставь `agent_self_assessment_accuracy='no_self_assessment'`. Остальное как обычно.
|
||||
- **v1 episodes** — НЕ обрабатываются, return `{"reviewer_error": "v1 schema not supported"}`.
|
||||
|
||||
## What you DON'T do
|
||||
|
||||
- Не редактируешь episode (controller сам пишет review.* поля по твоему JSON output).
|
||||
- Не правишь nodes.yaml.
|
||||
- Не правишь spec.
|
||||
- Не делаешь коммиты.
|
||||
- Не общаешься с пользователем — твой output идёт controller'у.
|
||||
- Не читаешь больше 10 соседних эпизодов (cost cap).
|
||||
- Не читаешь tools/* / source code — это вне scope review.
|
||||
|
||||
## Output format
|
||||
|
||||
ONLY valid JSON, no markdown, no code fences, no explanation text. Controller парсит твой output напрямую как JSON.
|
||||
|
||||
Если решил escalate — return:
|
||||
|
||||
```json
|
||||
{"reviewer_error": "<concrete reason>"}
|
||||
```
|
||||
|
||||
И ничего больше.
|
||||
|
||||
## Example
|
||||
|
||||
Input от controller:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{
|
||||
"schema_version": 4,
|
||||
"task_id": "abc-123",
|
||||
"classifier_output": {
|
||||
"task_type": "feature",
|
||||
"recommended_node": "superpowers:brainstorming",
|
||||
"recommended_chain": ["superpowers:brainstorming", "superpowers:writing-plans"],
|
||||
"alternatives_considered": [
|
||||
{"node": "superpowers:writing-plans", "match_score": 0.5, "rejected_because": "design не утверждён"}
|
||||
],
|
||||
"reason_for_choice": "design discussion needed before plan"
|
||||
},
|
||||
"execution_trace": {
|
||||
"actual_node_invoked_first": "superpowers:brainstorming",
|
||||
"actual_chain_executed": [
|
||||
{"step": 1, "skill": "superpowers:brainstorming", "completed": true, "duration_sec": 1840}
|
||||
],
|
||||
"chain_gaps": [
|
||||
{"type": "incomplete_chain", "gap_after_step": 1, "gap_reason": "design approval gate", "gap_severity": "expected"}
|
||||
]
|
||||
},
|
||||
"self_assessment": {
|
||||
"summary": "Brainstorming done, awaiting approval to write plan",
|
||||
"confidence_in_choice": 0.85
|
||||
},
|
||||
"outcome_inferred": "soft_success",
|
||||
"events": []
|
||||
}
|
||||
```
|
||||
|
||||
Output (что ты возвращаешь):
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct",
|
||||
"chain_quality": "n/a",
|
||||
"gap_assessment": "acceptable",
|
||||
"agent_self_assessment_accuracy": "accurate",
|
||||
"error_root_cause": "n/a",
|
||||
"alternative_better": null,
|
||||
"outcome_reviewed": "soft_success",
|
||||
"reasoning": "Brainstorming first для feature-задачи — каноничный L1-старт. Gap after step 1 ожидаем: дизайн нуждается в approval. Self-assessment confidence=0.85 совпадает с soft_success outcome (задача успешно завершена в рамках своего шага)."
|
||||
}
|
||||
```
|
||||
|
||||
## Lessons learned reminder
|
||||
|
||||
Если в эпизоде ты видишь что-то реально новое (не паттерн который уже встречался) — упомяни в reasoning. Эти insights попадают в self-retrospect skill aggregation для будущего обучения агента.
|
||||
|
||||
Но НЕ делай self-retrospect сам — это отдельный skill.
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -11,26 +11,28 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТОПГЭП (2026-05-25): защита боевой админ-зоны (/admin + /api/admin/*)
|
||||
* перенесена на уровень nginx — отдельный HTTP Basic Auth с собственным
|
||||
* паролем (`/etc/nginx/.htpasswd-admin`, location ^~ /admin и ^~ /api/admin).
|
||||
* Поэтому middleware больше не закрывает зону на проде: дверь держит nginx.
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Ранее (Sprint 3F) здесь был fail-closed 503 вне dev/testing — он закрывал
|
||||
* всю админку на проде наглухо, т.к. настоящий saas-admin SSO (Yandex 360)
|
||||
* ещё не готов (гейтится Б-1 + DO-4). Замок 503 снят осознанно: оголять
|
||||
* /api/admin/* в интернет нельзя, но nginx-пароль её прикрывает.
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* admin_user_id для audit-trail по-прежнему резолвится трейтом
|
||||
* ResolvesAdminUserId (стаб super_admin) — это отдельная зона.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
|
||||
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -126,15 +126,11 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
|
||||
$recoveredCount = 0;
|
||||
$unparseableCount = 0;
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
|
||||
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
|
||||
// Считаем такие строки отдельно, чтобы исключить из формулы drift'а
|
||||
// (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
|
||||
$unparseableCount++;
|
||||
Log::info('csv_reconcile.unparseable_project_skipped', [
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
@@ -165,14 +161,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$matchedCount = $totalCsvRows - count($missing);
|
||||
// drift считается только по «реальным» пропускам (parseable, не junk):
|
||||
// real_missing = count(missing) - unparseable (всегда ≥ 0)
|
||||
// parseable_tot = total_csv_rows - unparseable
|
||||
// Это убирает класс «поставщик кладёт телефон/URL в поле project →
|
||||
// строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
|
||||
$realMissing = max(0, count($missing) - $unparseableCount);
|
||||
$parseableTotal = max(0, $totalCsvRows - $unparseableCount);
|
||||
$driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
|
||||
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
|
||||
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
|
||||
|
||||
$update = [
|
||||
@@ -180,7 +169,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'total_csv_rows' => $totalCsvRows,
|
||||
'matched_count' => $matchedCount,
|
||||
'recovered_count' => $recoveredCount,
|
||||
'unparseable_count' => $unparseableCount,
|
||||
'drift_ratio' => $driftRatio,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
+69
@@ -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');
|
||||
}
|
||||
};
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
|
||||
* за окно reconcile, у которых поле «project» не парсится в платформу
|
||||
* (поставщик иногда кладёт телефон/URL в Name → extractPlatform = null,
|
||||
* строка скипается в csv_reconcile.unparseable_project_skipped).
|
||||
*
|
||||
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
|
||||
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
|
||||
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
|
||||
*
|
||||
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
|
||||
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
|
||||
* (BYPASSRLS) — pgsql_supplier connection.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE supplier_csv_reconcile_log
|
||||
ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE supplier_csv_reconcile_log
|
||||
DROP COLUMN IF EXISTS unparseable_count;
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -5,25 +5,21 @@ declare(strict_types=1);
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — гейт SaaS-admin зоны.
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: пропускает запрос во ВСЕХ окружениях.
|
||||
* Защита боевой админ-зоны (/admin + /api/admin/*) перенесена на nginx
|
||||
* (HTTP Basic Auth, отдельный пароль — /etc/nginx/.htpasswd-admin), потому
|
||||
* что настоящий saas-admin SSO (Yandex 360) ещё не готов (Б-1 + DO-4).
|
||||
* Ранее middleware fail-closed 503 вне dev/testing — это закрывало всю
|
||||
* админку на проде наглухо; стопгэп заменил замок на nginx-дверь.
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении', function () {
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* пропускается и на production (замок 503 снят, дверь держит nginx)', function () {
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
// Раньше тут был 503. Теперь приложение зону не закрывает — её держит
|
||||
// nginx basic-auth (стопгэп до реального Yandex 360 SSO).
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -257,80 +257,3 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
|
||||
expect($log->status)->toBe('failed');
|
||||
expect($log->error_message)->toContain('500');
|
||||
});
|
||||
|
||||
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
|
||||
// 100 нормальных webhook-лидов.
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 840000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
|
||||
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 10; $j++) {
|
||||
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(110);
|
||||
expect((int) $log->matched_count)->toBe(100);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect((int) $log->unparseable_count)->toBe(10);
|
||||
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
|
||||
expect((float) $log->drift_ratio)->toBe(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 850000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 5; $j++) {
|
||||
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(103);
|
||||
expect((int) $log->matched_count)->toBe(95);
|
||||
expect((int) $log->recovered_count)->toBe(3);
|
||||
expect((int) $log->unparseable_count)->toBe(5);
|
||||
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
|
||||
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
|
||||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
-11
@@ -1744,14 +1744,4 @@ uniqid
|
||||
брейнсторме
|
||||
префлайт
|
||||
Префлайт
|
||||
скоупа
|
||||
unreviewed
|
||||
|
||||
# admin-zone nginx-gate + drift-fix (25.05.2026 день+1)
|
||||
стопгэп
|
||||
досылает
|
||||
creds
|
||||
опкэш
|
||||
гэп
|
||||
misowned
|
||||
деплоями
|
||||
матченным
|
||||
|
||||
+11
-26
@@ -1,35 +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.36, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
|
||||
## v8.36 (2026-05-25) — Billing v2 Spec C: флаги заморозки + balance_freeze_log
|
||||
|
||||
Поставщик `crm.bp-gr.ru` периодически кладёт телефон/URL в поле «project» CSV-выгрузки
|
||||
«Запрос номеров». Парсер `CsvReconcileJob` корректно их скипает (`extractPlatform()` → `null`),
|
||||
но раньше эти строки попадали и в числитель `count($missing)`, и в знаменатель `total_csv_rows`
|
||||
формулы drift'а → стабильный false-positive `drift_alert` ~40-50% при каждом hourly-запуске
|
||||
(на проде 10 запусков подряд → admin-блок «Здоровье резервного канала» показывал «down»).
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **Колонка `supplier_csv_reconcile_log.unparseable_count` INTEGER NOT NULL DEFAULT 0** — кол-во
|
||||
CSV-строк за окно, у которых `project` не парсится в платформу B1/B2/B3.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- `CsvReconcileJob`: считает `$unparseableCount` отдельно, новая формула
|
||||
`drift_ratio = max(0, missing − unparseable) / max(1, total − unparseable)` —
|
||||
только «реальные» пропуски от parseable-строк, без вклада junk'а.
|
||||
|
||||
**Метрики:** +1 колонка. (Сверять с header `db/schema.sql`.) Таблиц / индексов / RLS — без изменений.
|
||||
|
||||
**Миграция:** `2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log` (idempotent
|
||||
`ADD COLUMN IF NOT EXISTS` на `pgsql_supplier` connection — Спек B pattern).
|
||||
|
||||
**Тесты:** `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — +2 кейса (100 matched +
|
||||
10 junk → status=ok / mixed 95+5junk+3real → drift по реальным). Существующие 7 кейсов — без изменений (drift при unparseable=0 идентичен старой формуле).
|
||||
- **+колонка `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
|
||||
|
||||
|
||||
@@ -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 $$;
|
||||
+59
-11
@@ -1,14 +1,14 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Версия: 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 лимитов)
|
||||
@@ -675,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).
|
||||
@@ -846,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),
|
||||
@@ -877,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
|
||||
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
|
||||
@@ -1138,11 +1148,6 @@ CREATE TABLE supplier_csv_reconcile_log (
|
||||
total_csv_rows INTEGER,
|
||||
matched_count INTEGER,
|
||||
recovered_count INTEGER,
|
||||
-- Кол-во CSV-строк, у которых поле «project» не парсится в платформу B1/B2/B3
|
||||
-- (поставщик иногда кладёт телефон/URL в «Name» вместо названия проекта).
|
||||
-- Используется CsvReconcileJob для корректного расчёта drift'а — без вычитания
|
||||
-- этих строк формула стабильно даёт false-positive drift_alert ~40-50%.
|
||||
unparseable_count INTEGER NOT NULL DEFAULT 0,
|
||||
drift_ratio NUMERIC(5,4),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running','ok','drift_alert','failed')),
|
||||
@@ -3316,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
|
||||
-- =============================================================================
|
||||
|
||||
+14
-14
@@ -1,22 +1,22 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-25T04:31:41.337Z
|
||||
Last updated: 2026-05-24T13:01:24.122Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| C2 Cross-ref consistency | 🔴 | Update cross-refs in offending files. |
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ⚠️ | 341 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C5 Observer-coverage | ⚠️ | 135 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 341 episodes this month, 0 observer_error markers, 31 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 202
|
||||
- Last /brain-retro: 0 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
- Observer evidence: 135 episodes this month, 0 observer_error markers, 6 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 11
|
||||
- Last /brain-retro: 1 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 17. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
|
||||
@@ -24,17 +24,17 @@ Baseline дисциплины роутера (этап 2 router discipline overh
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| analysis | 15 | 46.7% | 26.7% |
|
||||
| monitoring | 12 | 0.0% | 0.0% |
|
||||
| bugfix | 10 | 40.0% | 40.0% |
|
||||
| planning | 9 | 11.1% | 22.2% |
|
||||
| feature | 9 | 22.2% | 0.0% |
|
||||
| bugfix | 7 | 28.6% | 42.9% |
|
||||
| feature | 5 | 0.0% | 0.0% |
|
||||
| analysis | 4 | 0.0% | 25.0% |
|
||||
| planning | 2 | 0.0% | 0.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| cleanup | 1 | 0.0% | 0.0% |
|
||||
| monitoring | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 139, 2: 118, 3: 37, 5: 42
|
||||
Router step distribution: 1: 55, 2: 45, 3: 12, 5: 18
|
||||
|
||||
Boundaries applied (ADR / границы): 47 of 336 эпизодов (14.0%).
|
||||
Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.0%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
|
||||
@@ -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 ловит ДО старта работы.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -436,4 +436,3 @@
|
||||
- ✅ Цепочки governance явно зафиксированы — Claude не имеет права автоматически менять.
|
||||
- ✅ Откатываемость сохранена (хуки в settings.json + revert коммитов).
|
||||
- ⚠️ Расширение scope этапа 3 ≈ +3 часа работы (Task 0a 1.5ч + Task 0b 1.5ч). Принято заказчиком 24.05.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -198,24 +198,10 @@ export function shouldEscalate(regexResult) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// LLM Layer 2 ходит через реселлера ProxyAPI (официальный api.anthropic.com
|
||||
// недоступен из РФ). Базовый URL переопределяется ROUTER_LLM_BASE_URL — на
|
||||
// случай смены реселлера или возврата на официальный эндпоинт.
|
||||
const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic';
|
||||
|
||||
export async function callAnthropicAPI(prompt, {
|
||||
apiKey,
|
||||
baseUrl = DEFAULT_LLM_BASE_URL,
|
||||
model = 'claude-haiku-4-5',
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`;
|
||||
const r = await fetchImpl(url, {
|
||||
export async function callAnthropicAPI(prompt, { apiKey, model = 'claude-haiku-4-5-20251001', fetchImpl = fetch }) {
|
||||
const r = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// ProxyAPI ждёт Bearer, официальный API — x-api-key. Шлём оба:
|
||||
// каждый эндпоинт берёт нужный заголовок и игнорирует чужой.
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
@@ -227,7 +213,7 @@ export async function callAnthropicAPI(prompt, {
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Router LLM ${r.status}: ${await r.text()}`);
|
||||
throw new Error(`Anthropic API ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
return data.content?.[0]?.text || '';
|
||||
@@ -253,16 +239,8 @@ export async function classify(prompt, registry, options = {}) {
|
||||
}
|
||||
|
||||
const llmCall = options.llmCall || (async () => {
|
||||
// Ключ берём из ОТДЕЛЬНОЙ переменной ROUTER_LLM_KEY, НЕ из ANTHROPIC_API_KEY:
|
||||
// иначе ключ перехватит сам Claude Code и уведёт основную сессию с подписки
|
||||
// на платный API. Нет ключа → Layer 2 выключен, тихо остаёмся на regex.
|
||||
const apiKey = process.env.ROUTER_LLM_KEY;
|
||||
if (!apiKey) return null;
|
||||
const llmPrompt = buildLLMPrompt(prompt, registry);
|
||||
const text = await callAnthropicAPI(llmPrompt, {
|
||||
apiKey,
|
||||
baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined,
|
||||
});
|
||||
const text = await callAnthropicAPI(llmPrompt, { apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
return parseLLMResponse(text);
|
||||
});
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('classifyByRegex — confidence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify, callAnthropicAPI } from './router-classifier.mjs';
|
||||
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify } from './router-classifier.mjs';
|
||||
|
||||
describe('buildLLMPrompt', () => {
|
||||
it('serializes active nodes with id+name+top-3 triggers', () => {
|
||||
@@ -178,61 +178,3 @@ describe('classify — full integration (with mock LLM)', () => {
|
||||
expect(calls).toBe(1); // Second hit cache.
|
||||
});
|
||||
});
|
||||
|
||||
describe('callAnthropicAPI — ProxyAPI wiring', () => {
|
||||
it('posts to ProxyAPI base by default with Bearer auth', async () => {
|
||||
let captured;
|
||||
const fetchImpl = async (url, opts) => {
|
||||
captured = { url, opts };
|
||||
return { ok: true, json: async () => ({ content: [{ text: '{"taskType":"question"}' }] }) };
|
||||
};
|
||||
const text = await callAnthropicAPI('hi', { apiKey: 'sk-test', fetchImpl });
|
||||
expect(captured.url).toBe('https://api.proxyapi.ru/anthropic/v1/messages');
|
||||
expect(captured.opts.headers.authorization).toBe('Bearer sk-test');
|
||||
expect(text).toContain('question');
|
||||
});
|
||||
|
||||
it('honors a custom baseUrl and strips trailing slash', async () => {
|
||||
let capturedUrl;
|
||||
const fetchImpl = async (url) => {
|
||||
capturedUrl = url;
|
||||
return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) };
|
||||
};
|
||||
await callAnthropicAPI('hi', { apiKey: 'k', baseUrl: 'https://example.test/', fetchImpl });
|
||||
expect(capturedUrl).toBe('https://example.test/v1/messages');
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'Invalid API Key' });
|
||||
await expect(callAnthropicAPI('hi', { apiKey: 'bad', fetchImpl })).rejects.toThrow(/401/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify — isolation from Claude Code auth', () => {
|
||||
it('skips LLM and falls back to regex when ROUTER_LLM_KEY is absent', async () => {
|
||||
const saved = process.env.ROUTER_LLM_KEY;
|
||||
delete process.env.ROUTER_LLM_KEY;
|
||||
try {
|
||||
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
||||
expect(r.source).toBe('regex');
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.ROUTER_LLM_KEY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT read ANTHROPIC_API_KEY (would hijack the main session)', async () => {
|
||||
const savedRouter = process.env.ROUTER_LLM_KEY;
|
||||
const savedAnthropic = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ROUTER_LLM_KEY;
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-should-not-be-used';
|
||||
try {
|
||||
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
||||
// No ROUTER_LLM_KEY → must stay on regex even though ANTHROPIC_API_KEY is set.
|
||||
expect(r.source).toBe('regex');
|
||||
} finally {
|
||||
if (savedRouter !== undefined) process.env.ROUTER_LLM_KEY = savedRouter;
|
||||
if (savedAnthropic !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropic;
|
||||
else delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user