Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 447ef593fa | |||
| 9f70d89046 | |||
| 42a246d633 | |||
| ca0c4d9318 | |||
| 3269434746 | |||
| 5e12126d71 | |||
| 8e3e06f3a4 | |||
| c85424968e | |||
| 00f6611bc1 | |||
| adabcf15a4 | |||
| 3ea86d62ff | |||
| 9a25e658b3 | |||
| 73d2733522 | |||
| 8b9d9fb029 | |||
| 9db66e6f27 | |||
| 9b6fa50c4c | |||
| d6f0ff868f | |||
| 9929b4a599 | |||
| d84127eaa5 | |||
| 2def31eea9 | |||
| e6556e5a97 | |||
| 4d807fb9f2 | |||
| 68f341191b | |||
| 91c64cde70 | |||
| b027a3cfee | |||
| ab23baa1d5 | |||
| 086fc1a903 | |||
| bd9b8e84fa | |||
| 550e8949d6 | |||
| 4bd419654f | |||
| b163d8a5ca | |||
| 6e35193f3b | |||
| 2504f1b9ec | |||
| ed61bae482 | |||
| bf7f70a5d4 | |||
| cadaecdaf8 | |||
| 283db070e1 | |||
| 7705f022c1 | |||
| 18f132d035 | |||
| e64eb4dbe0 | |||
| c5261a0b22 | |||
| 425d58f2a9 | |||
| 2f267f15f7 |
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -19,6 +22,183 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminBillingController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */
|
||||
public function tariffPlans(): JsonResponse
|
||||
{
|
||||
$plans = DB::table('tariff_plans')
|
||||
->select(['id', 'name', 'price_monthly'])
|
||||
->orderBy('price_monthly')
|
||||
->get()
|
||||
->map(fn ($p) => [
|
||||
'id' => (int) $p->id,
|
||||
'name' => $p->name,
|
||||
'price_monthly' => (string) $p->price_monthly,
|
||||
]);
|
||||
|
||||
return response()->json(['plans' => $plans]);
|
||||
}
|
||||
|
||||
/** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */
|
||||
public function updateStatus(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', 'in:active,suspended'],
|
||||
'reason' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
]);
|
||||
|
||||
$tenant = $this->findActiveTenant($id);
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
|
||||
|
||||
DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void {
|
||||
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
|
||||
DB::table('tenants')->where('id', $tenant->id)->update([
|
||||
'status' => $validated['status'],
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $tenant->id,
|
||||
'target_tenant_id' => $tenant->id,
|
||||
'payload_before' => ['status' => $tenant->status],
|
||||
'payload_after' => ['status' => $validated['status']],
|
||||
'reason' => $validated['reason'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['id' => $tenant->id, 'status' => $validated['status']]);
|
||||
}
|
||||
|
||||
/** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */
|
||||
public function refund(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount_rub' => ['required', 'numeric', 'gt:0'],
|
||||
'reason' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
]);
|
||||
|
||||
$this->findActiveTenant($id); // ранний 404; авторитетный баланс перечитывается под локом ниже
|
||||
$amount = number_format((float) $validated['amount_rub'], 2, '.', '');
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
|
||||
|
||||
/** @var array{transaction_id:int, balance_rub:string} $result */
|
||||
$result = DB::transaction(function () use ($id, $amount, $validated, $adminUserId, $request): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
|
||||
|
||||
// Баланс — money-колонка: перечитываем под row-lock внутри транзакции,
|
||||
// защита от lost-update (конвенция LedgerService — lockForUpdate на tenants).
|
||||
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if ($tenant === null) {
|
||||
abort(404, 'tenant not found');
|
||||
}
|
||||
|
||||
$balance = (string) $tenant->balance_rub;
|
||||
if (bccomp($amount, $balance, 2) === 1) {
|
||||
abort(422, 'refund amount exceeds tenant balance');
|
||||
}
|
||||
$newBalance = bcsub($balance, $amount, 2);
|
||||
|
||||
DB::table('tenants')->where('id', $id)->update([
|
||||
'balance_rub' => $newBalance,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $id,
|
||||
'type' => BalanceTransaction::TYPE_REFUND,
|
||||
'amount_rub' => '-'.$amount,
|
||||
'amount_leads' => 0,
|
||||
'balance_rub_after' => $newBalance,
|
||||
'description' => $validated['reason'],
|
||||
'admin_user_id' => $adminUserId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'tenant.refund',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $id,
|
||||
'target_tenant_id' => $id,
|
||||
'payload_before' => ['balance_rub' => $balance],
|
||||
'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id],
|
||||
'reason' => $validated['reason'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return ['transaction_id' => (int) $tx->id, 'balance_rub' => $newBalance];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $id,
|
||||
'balance_rub' => $result['balance_rub'],
|
||||
'transaction_id' => $result['transaction_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */
|
||||
public function changeTariff(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'],
|
||||
'reason' => ['required', 'string', 'min:10', 'max:1000'],
|
||||
]);
|
||||
|
||||
$tenant = $this->findActiveTenant($id);
|
||||
$tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first();
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
|
||||
|
||||
DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void {
|
||||
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
|
||||
DB::table('tenants')->where('id', $tenant->id)->update([
|
||||
'current_tariff_id' => $tariff->id,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'tenant.change_tariff',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $tenant->id,
|
||||
'target_tenant_id' => $tenant->id,
|
||||
'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id],
|
||||
'payload_after' => ['current_tariff_id' => (int) $tariff->id],
|
||||
'reason' => $validated['reason'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $tenant->id,
|
||||
'tariff_id' => (int) $tariff->id,
|
||||
'tariff_name' => $tariff->name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает не-удалённого тенанта либо abort(404).
|
||||
*
|
||||
* @return object{id:int,status:string,balance_rub:string,current_tariff_id:int|null}
|
||||
*/
|
||||
private function findActiveTenant(int $id): object
|
||||
{
|
||||
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first();
|
||||
if ($tenant === null) {
|
||||
abort(404, 'tenant not found');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/** GET /api/admin/billing?search= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -21,6 +23,8 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminIncidentsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -83,6 +87,116 @@ class AdminIncidentsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
|
||||
public function notifyRkn(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('incidents_log')->where('id', $id)->first();
|
||||
if ($row === null) {
|
||||
abort(404, 'incident not found');
|
||||
}
|
||||
if ($row->type !== 'data_breach') {
|
||||
abort(422, 'РКН-уведомление применимо только к инцидентам типа data_breach');
|
||||
}
|
||||
if ($row->rkn_notified_at !== null) {
|
||||
abort(409, 'РКН уже уведомлён по этому инциденту');
|
||||
}
|
||||
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
|
||||
|
||||
DB::transaction(function () use ($row, $adminUserId, $request): void {
|
||||
DB::table('incidents_log')->where('id', $row->id)->update([
|
||||
'rkn_notified_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'incident.rkn_notify',
|
||||
'target_type' => 'incident',
|
||||
'target_id' => $row->id,
|
||||
'payload_before' => ['rkn_notified_at' => null],
|
||||
'payload_after' => ['rkn_notified_at' => now()->toIso8601String()],
|
||||
'reason' => 'Роскомнадзор уведомлён об утечке ПДн через админ-интерфейс (152-ФЗ).',
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->show($id);
|
||||
}
|
||||
|
||||
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('incidents_log')->where('id', $id)->first();
|
||||
if ($row === null) {
|
||||
abort(404, 'incident not found');
|
||||
}
|
||||
|
||||
$tenantIds = is_array($row->affected_tenant_ids)
|
||||
? $row->affected_tenant_ids
|
||||
: ($row->affected_tenant_ids !== null ? $this->parsePgArrayValues((string) $row->affected_tenant_ids) : []);
|
||||
|
||||
$tenants = $tenantIds === []
|
||||
? collect()
|
||||
: DB::table('tenants')->whereIn('id', $tenantIds)
|
||||
->select(['id', 'organization_name'])->get();
|
||||
|
||||
$admins = DB::table('saas_admin_users')
|
||||
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
|
||||
->pluck('full_name', 'id');
|
||||
|
||||
return response()->json([
|
||||
'incident' => [
|
||||
'id' => (int) $row->id,
|
||||
'incident_id' => $this->formatIncidentId($row),
|
||||
'type' => $row->type,
|
||||
'severity' => $row->severity,
|
||||
'summary' => $row->summary,
|
||||
'root_cause' => $row->root_cause,
|
||||
'postmortem_url' => $row->postmortem_url,
|
||||
'started_at' => CarbonImmutable::parse($row->started_at)->toIso8601String(),
|
||||
'detected_at' => CarbonImmutable::parse($row->detected_at)->toIso8601String(),
|
||||
'resolved_at' => $row->resolved_at !== null
|
||||
? CarbonImmutable::parse($row->resolved_at)->toIso8601String() : null,
|
||||
'status' => $this->deriveStatus($row),
|
||||
'affected_tenants' => $tenants->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'organization_name' => $t->organization_name,
|
||||
])->values(),
|
||||
'affected_users_count' => $row->affected_users_count !== null ? (int) $row->affected_users_count : null,
|
||||
'notification_sent_at' => $row->notification_sent_at !== null
|
||||
? CarbonImmutable::parse($row->notification_sent_at)->toIso8601String() : null,
|
||||
'rkn_notified' => $row->rkn_notified_at !== null,
|
||||
'rkn_notified_at' => $row->rkn_notified_at !== null
|
||||
? CarbonImmutable::parse($row->rkn_notified_at)->toIso8601String() : null,
|
||||
'rkn_deadline_at' => $row->type === 'data_breach' && $row->rkn_notified_at === null
|
||||
? CarbonImmutable::parse($row->detected_at)->addHours(24)->toIso8601String() : null,
|
||||
'created_by_admin' => $admins->get($row->created_by_admin_id),
|
||||
'closed_by_admin' => $row->closed_by_admin_id !== null ? $admins->get($row->closed_by_admin_id) : null,
|
||||
'created_at' => $row->created_at !== null
|
||||
? CarbonImmutable::parse($row->created_at)->toIso8601String() : null,
|
||||
'updated_at' => $row->updated_at !== null
|
||||
? CarbonImmutable::parse($row->updated_at)->toIso8601String() : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PG-array literal '{1,2,3}' → массив int.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parsePgArrayValues(string $literal): array
|
||||
{
|
||||
$trimmed = trim($literal, '{}');
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map('intval', explode(',', $trimmed));
|
||||
}
|
||||
|
||||
/** Уникальный человеко-читаемый ID: INC-YYYY-MMDD-NNNN, NNNN = id padded. */
|
||||
private function formatIncidentId(object $row): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
||||
*
|
||||
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
||||
*
|
||||
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
||||
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
||||
*
|
||||
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
||||
? (string) $request->query('range')
|
||||
: '7d';
|
||||
|
||||
// MSK: activity-бакеты и range-границы должны совпадать с SQL
|
||||
// `AT TIME ZONE 'Europe/Moscow'`. config('app.timezone') = UTC.
|
||||
$now = CarbonImmutable::now('Europe/Moscow');
|
||||
[$windowStart, $prevStart] = match ($range) {
|
||||
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
|
||||
'30d' => [$now->subDays(30), $now->subDays(60)],
|
||||
default => [$now->subDays(7), $now->subDays(14)],
|
||||
};
|
||||
|
||||
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$base = fn () => DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false);
|
||||
|
||||
// --- leads received: текущее + предыдущее окно ---
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
$byDay = (clone $base())
|
||||
->where('received_at', '>=', $activityStart)
|
||||
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$points = [];
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$day = $now->subDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
$points[] = (int) ($byDay[$key] ?? 0);
|
||||
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
|
||||
}
|
||||
$maxPoint = max(0, ...$points);
|
||||
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
|
||||
|
||||
// --- funnel: текущий снимок по статусам ---
|
||||
$funnel = (clone $base())
|
||||
->selectRaw('status, COUNT(*) AS c')
|
||||
->groupBy('status')
|
||||
->pluck('c', 'status')
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway ---
|
||||
// runway опирается на приток за фиксированное 7-дневное окно,
|
||||
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
|
||||
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
|
||||
$avgDaily = $leads7d / 7.0;
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/** Процентная дельта current vs previous; 0.0 если previous=0. */
|
||||
private static function pctDelta(float $cur, float $prev): float
|
||||
{
|
||||
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/** Блок {value, <deltaKey>, delta_dir}. */
|
||||
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
|
||||
{
|
||||
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
|
||||
|
||||
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -20,6 +19,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* bulk + export + helpers). Этот класс отвечает только за многоразовые
|
||||
* массовые операции; single-resource действия остаются в DealController.
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-01: N+1 устранён.
|
||||
*
|
||||
* transition: сначала SELECT всех сделок tenant'а из ids, чтобы отфильтровать
|
||||
@@ -41,23 +42,19 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* POST /api/deals/transition — bulk status-update.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...], status: slug}.
|
||||
* Body: {ids: [int...], status: slug}.
|
||||
* Response: {updated, requested, status} (updated = реально изменённых,
|
||||
* без NO-OP).
|
||||
*/
|
||||
public function transition(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'status' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
|
||||
if (! $statusExists) {
|
||||
@@ -67,14 +64,14 @@ class DealBulkActionController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
|
||||
// чтобы (а) отфильтровать NO-OP и (б) сохранить prev в context.from.
|
||||
// Defense-in-depth where(tenant_id) — защита от кросс-tenant id.
|
||||
$rows = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->get(['id', 'status']);
|
||||
|
||||
@@ -88,7 +85,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
// Фаза 2: bulk-UPDATE 1 запросом вместо N.
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $changedIds)
|
||||
->update([
|
||||
'status' => $validated['status'],
|
||||
@@ -100,7 +97,7 @@ class DealBulkActionController extends Controller
|
||||
// массив сериализуем в JSON руками, остальные scalar-поля передаём
|
||||
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
|
||||
$logRows = $changed->map(fn (Deal $d) => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $d->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
@@ -127,7 +124,7 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* DELETE /api/deals — bulk soft-delete.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Body: {ids: [int...]}.
|
||||
* Response: {deleted, requested}.
|
||||
*
|
||||
* Soft-delete сохраняется (см. документацию в DealController.destroy на
|
||||
@@ -137,23 +134,19 @@ class DealBulkActionController extends Controller
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$deleted = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
|
||||
// в activity_log по списку реально удаляемых (NO-OP idempotency).
|
||||
$targetIds = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
@@ -166,7 +159,7 @@ class DealBulkActionController extends Controller
|
||||
$now = now();
|
||||
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $targetIds)
|
||||
->whereNull('deleted_at')
|
||||
->update([
|
||||
@@ -175,7 +168,7 @@ class DealBulkActionController extends Controller
|
||||
]);
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_DELETED,
|
||||
@@ -197,30 +190,26 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* POST /api/deals/restore — bulk restore soft-deleted.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Body: {ids: [int...]}.
|
||||
* Response: {restored, requested}.
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$restored = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// withTrashed обходит SoftDeletes global scope; whereNotNull —
|
||||
// NO-OP idempotency для уже живых.
|
||||
$targetIds = Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNotNull('deleted_at')
|
||||
->pluck('id')
|
||||
@@ -234,7 +223,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $targetIds)
|
||||
->whereNotNull('deleted_at')
|
||||
->update([
|
||||
@@ -243,7 +232,7 @@ class DealBulkActionController extends Controller
|
||||
]);
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_RESTORED,
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,9 +26,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
||||
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
||||
*
|
||||
* На MVP без auth-middleware (multi-tenant контекст резолвится по
|
||||
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
|
||||
* → tenant_id из request()->user()->tenant_id; user ID для manager/audit.
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* Manual-create отличается от webhook'а:
|
||||
* - source_crm_id = NULL (не из webhook).
|
||||
@@ -42,7 +39,7 @@ use Illuminate\Support\Facades\DB;
|
||||
class DealController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||||
* GET /api/deals?status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||||
*
|
||||
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
|
||||
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
|
||||
@@ -53,20 +50,10 @@ class DealController extends Controller
|
||||
* (received_at, id)).
|
||||
*
|
||||
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
|
||||
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
|
||||
* tenant_id (без auth — на MVP, на prod — middleware).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
@@ -203,7 +190,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/deals/{id}?tenant_id={id} — детали сделки + recent activity events.
|
||||
* GET /api/deals/{id} — детали сделки + recent activity events.
|
||||
*
|
||||
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
|
||||
* relations + до 50 последних activity_log событий по этой сделке.
|
||||
@@ -213,15 +200,7 @@ class DealController extends Controller
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
@@ -291,7 +270,7 @@ class DealController extends Controller
|
||||
/**
|
||||
* PATCH /api/deals/{id} — частичное редактирование сделки из DealDetailDrawer.
|
||||
*
|
||||
* Body (все поля optional, должно быть хотя бы одно): {tenant_id, comment?,
|
||||
* Body (все поля optional, должно быть хотя бы одно): {comment?,
|
||||
* manager_id?, status?}.
|
||||
*
|
||||
* Каждое изменение пишется в ActivityLog с правильным event-type:
|
||||
@@ -309,16 +288,12 @@ class DealController extends Controller
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'comment' => 'nullable|string|max:5000',
|
||||
'manager_id' => 'nullable|integer|min:1',
|
||||
'status' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
// Validate status slug если передан.
|
||||
if (array_key_exists('status', $validated) && $validated['status'] !== null) {
|
||||
@@ -335,7 +310,7 @@ class DealController extends Controller
|
||||
if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) {
|
||||
$managerExists = User::query()
|
||||
->where('id', $validated['manager_id'])
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
@@ -347,11 +322,11 @@ class DealController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$deal = DB::transaction(function () use ($validated, $tenant, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$deal = DB::transaction(function () use ($validated, $tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$deal = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
@@ -363,7 +338,7 @@ class DealController extends Controller
|
||||
if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) {
|
||||
$deal->comment = $validated['comment'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => 'deal.commented',
|
||||
@@ -376,7 +351,7 @@ class DealController extends Controller
|
||||
$deal->manager_id = $validated['manager_id'];
|
||||
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
|
||||
@@ -388,7 +363,7 @@ class DealController extends Controller
|
||||
$previousStatus = $deal->status;
|
||||
$deal->status = $validated['status'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
@@ -425,7 +400,6 @@ class DealController extends Controller
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'phone' => 'required|string|max:20',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
@@ -434,17 +408,14 @@ class DealController extends Controller
|
||||
'comment' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
// Manager FK guard: если manager_id передан, он должен принадлежать
|
||||
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
|
||||
if (isset($validated['manager_id'])) {
|
||||
$managerExists = User::query()
|
||||
->where('id', $validated['manager_id'])
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
@@ -459,16 +430,16 @@ class DealController extends Controller
|
||||
$statusSlug = $validated['status'] ?? 'new';
|
||||
|
||||
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
|
||||
$deal = DB::transaction(function () use ($validated, $tenant, $statusSlug) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$deal = DB::transaction(function () use ($validated, $tenantId, $statusSlug) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
['tenant_id' => $tenant->id, 'name' => $validated['project_name']],
|
||||
['tenant_id' => $tenantId, 'name' => $validated['project_name']],
|
||||
['type' => 'manual'],
|
||||
);
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'source_crm_id' => null, // manual
|
||||
'project_id' => $project->id,
|
||||
'phone' => $validated['phone'],
|
||||
@@ -499,7 +470,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null, // на prod — request()->user()->id
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
@@ -21,13 +20,15 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
*
|
||||
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
|
||||
* полный объект .xlsx в памяти (для 10K сделок ≈ 100+ MB). OpenSpout пишет
|
||||
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
|
||||
* по сделкам — пик памяти O(1) от размера экспорта.
|
||||
*
|
||||
* API контракт сохранён:
|
||||
* POST /api/deals/export {tenant_id, ids[], format?: csv|xlsx}
|
||||
* POST /api/deals/export {ids[], format?: csv|xlsx}
|
||||
* Headers Content-Type / Content-Disposition без изменений.
|
||||
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
|
||||
* XLSX: bold-header + auto-size columns.
|
||||
@@ -43,16 +44,12 @@ class DealExportController extends Controller
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:10000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'format' => 'nullable|string|in:csv,xlsx',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
abort(404, 'Тенант не найден.');
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$format = $validated['format'] ?? 'csv';
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
@@ -67,13 +64,13 @@ class DealExportController extends Controller
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
];
|
||||
|
||||
return new StreamedResponse(function () use ($validated, $tenant, $format) {
|
||||
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
|
||||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||||
// фактического SELECT. StreamedResponse callback вызывается уже
|
||||
// после Laravel-response pipeline'а, поэтому открываем транзакцию
|
||||
// прямо здесь.
|
||||
DB::transaction(function () use ($validated, $tenant, $format) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
DB::transaction(function () use ($validated, $tenantId, $format) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$writer = $this->openWriter($format);
|
||||
$writer->openToFile('php://output');
|
||||
@@ -93,7 +90,7 @@ class DealExportController extends Controller
|
||||
// chunkById(500) — keyset-friendly; в нашем DealsView это
|
||||
// редкий тяжёлый action, экспортировать могут до 10K id.
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($deals) use ($writer) {
|
||||
|
||||
@@ -13,7 +13,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Reports API (schema §13.5). Все endpoint'ы под `auth:sanctum`.
|
||||
@@ -340,6 +342,68 @@ class ReportJobController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= — скачать
|
||||
* готовый файл отчёта (F2, OPEN-И-20).
|
||||
*
|
||||
* Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token.
|
||||
* `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а).
|
||||
* Подпись покрывает все query-параметры — `tenant`/`id` подделать нельзя.
|
||||
*/
|
||||
public function download(Request $request, int $id): Response
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant', '0');
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId): Response {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$job = ReportJob::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if ($job === null) {
|
||||
return response()->json(['message' => 'Отчёт не найден.'], 404);
|
||||
}
|
||||
|
||||
if ($job->status !== ReportJob::STATUS_DONE
|
||||
|| $job->file_path === null
|
||||
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410);
|
||||
}
|
||||
|
||||
if (! Storage::disk('local')->exists($job->file_path)) {
|
||||
return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404);
|
||||
}
|
||||
|
||||
$extension = pathinfo($job->file_path, PATHINFO_EXTENSION);
|
||||
|
||||
return Storage::disk('local')->download(
|
||||
$job->file_path,
|
||||
sprintf('report-%d.%s', $job->id, $extension)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или
|
||||
* после истечения retention (file_path обнулён cron'ом reports:cleanup-expired).
|
||||
*/
|
||||
private function downloadUrl(ReportJob $job): ?string
|
||||
{
|
||||
if ($job->status !== ReportJob::STATUS_DONE
|
||||
|| $job->file_path === null
|
||||
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'reports.download',
|
||||
Carbon::now()->addHours(24),
|
||||
['id' => $job->id, 'tenant' => $job->tenant_id],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function toResource(ReportJob $job): array
|
||||
{
|
||||
@@ -358,6 +422,7 @@ class ReportJobController extends Controller
|
||||
'is_expired' => $job->expires_at !== null && $job->expires_at->isPast(),
|
||||
'retry_count' => (int) ($job->parameters['retry_count'] ?? 0),
|
||||
'retry_max' => self::RETRY_MAX_ATTEMPTS,
|
||||
'download_url' => $this->downloadUrl($job),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Резолв saas_admin_users.id для audit-trail на MVP (saas-admin SSO ⏸ Б-1).
|
||||
*
|
||||
* Берёт admin_user_id из request-параметра; при отсутствии валидного —
|
||||
* создаёт/переиспользует системный стаб-аккаунт (не loginable, is_active=false),
|
||||
* чтобы соблюсти NOT NULL + FK на saas_admin_users в saas_admin_audit_log.
|
||||
*
|
||||
* Паттерн ранее дублировался в AdminPricingTiersController /
|
||||
* AdminSystemSettingsController; новый код использует этот трейт.
|
||||
*/
|
||||
trait ResolvesAdminUserId
|
||||
{
|
||||
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
|
||||
{
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$existingId = DB::table('saas_admin_users')->where('email', $stubEmail)->value('id');
|
||||
if ($existingId !== null) {
|
||||
return (int) $existingId;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => $stubName,
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* billing_summary — агрегат balance_transactions по типу операции (audit F1).
|
||||
*
|
||||
* Группировка по balance_transactions.type; count + SUM(amount_rub). Тип
|
||||
* операции переводится в человекочитаемую метку. parameters: date_from,
|
||||
* date_to (Y-m-d) — фильтр по created_at.
|
||||
*
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (balance_transactions имеет RLS
|
||||
* tenant_isolation) + явный where('tenant_id') — паттерн BillingController.
|
||||
*/
|
||||
class BillingSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
/** Канон-типы balance_transactions.type → RU-метка (schema §7.6 CHECK). */
|
||||
private const TYPE_LABELS = [
|
||||
'trial_bonus' => 'Стартовый бонус',
|
||||
'topup' => 'Пополнение',
|
||||
'lead_charge' => 'Списание за лиды',
|
||||
'refund' => 'Возврат',
|
||||
'manual_adjustment' => 'Ручная корректировка',
|
||||
'historical_import' => 'Импорт истории',
|
||||
'chargeback_writedown' => 'Chargeback — списание в долг',
|
||||
'chargeback_repayment' => 'Chargeback — погашение долга',
|
||||
];
|
||||
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Тип операции', 'Количество', 'Сумма (₽)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('balance_transactions')
|
||||
->where('tenant_id', $job->tenant_id)
|
||||
->whereBetween('created_at', [$dateFrom, $dateTo])
|
||||
->groupBy('type')
|
||||
->orderBy('type')
|
||||
->selectRaw('type, COUNT(*) AS cnt, COALESCE(SUM(amount_rub), 0) AS sum_rub')
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$label = self::TYPE_LABELS[$row->type] ?? (string) $row->type;
|
||||
|
||||
return [$label, (int) $row->cnt, (string) $row->sum_rub];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'billing';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* managers_summary — агрегат сделок по менеджерам за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
|
||||
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
|
||||
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
|
||||
* SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
*/
|
||||
class ManagersSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('deals')
|
||||
->leftJoin('users', 'deals.manager_id', '=', 'users.id')
|
||||
->where('deals.tenant_id', $job->tenant_id)
|
||||
->whereNull('deals.deleted_at')
|
||||
->where('deals.is_test', false)
|
||||
->whereBetween('deals.received_at', [$dateFrom, $dateTo])
|
||||
->groupBy('deals.manager_id', 'users.first_name', 'users.last_name', 'users.email')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('deals.manager_id')
|
||||
->selectRaw(
|
||||
"deals.manager_id,
|
||||
users.first_name, users.last_name, users.email,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
|
||||
if ($name === '') {
|
||||
$name = (string) ($row->email ?? '');
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = 'Не назначен';
|
||||
}
|
||||
$total = (int) $row->total;
|
||||
$paid = (int) $row->paid;
|
||||
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
|
||||
|
||||
return [$name, $total, $paid, $conversion];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'managers';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
|
||||
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
|
||||
* paid / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
|
||||
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
*/
|
||||
class SourcesSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('deals')
|
||||
->where('tenant_id', $job->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false)
|
||||
->whereBetween('received_at', [$dateFrom, $dateTo])
|
||||
->groupBy('utm_source')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('utm_source')
|
||||
->selectRaw(
|
||||
"utm_source,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$source = $row->utm_source !== null && trim((string) $row->utm_source) !== ''
|
||||
? (string) $row->utm_source
|
||||
: 'Прямые / без метки';
|
||||
$total = (int) $row->total;
|
||||
$paid = (int) $row->paid;
|
||||
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
|
||||
|
||||
return [$source, $total, $paid, $conversion];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'sources';
|
||||
}
|
||||
}
|
||||
@@ -10,23 +10,28 @@ use App\Services\Reports\Formatters\JsonFormatter;
|
||||
use App\Services\Reports\Formatters\PdfStubFormatter;
|
||||
use App\Services\Reports\Formatters\ReportFormatter;
|
||||
use App\Services\Reports\Formatters\XlsxFormatter;
|
||||
use App\Services\Reports\Providers\BillingSummaryProvider;
|
||||
use App\Services\Reports\Providers\DealsExportProvider;
|
||||
use App\Services\Reports\Providers\ManagersSummaryProvider;
|
||||
use App\Services\Reports\Providers\ReportDataProvider;
|
||||
use App\Services\Reports\Providers\SourcesSummaryProvider;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Резолвит ReportDataProvider по `type` и ReportFormatter по `format`.
|
||||
*
|
||||
* Этап 2 (текущий): 1 provider × 4 formatter = 4 комбинации
|
||||
* (deals_export × csv|xlsx|json|pdf-stub).
|
||||
*
|
||||
* Этап 2b расширит до 4 × 4 = 16 (managers_summary, sources_summary,
|
||||
* billing_summary). Для PDF на MVP — stub, fallback'ит в RuntimeException.
|
||||
* 4 provider'а (deals_export, managers_summary, sources_summary,
|
||||
* billing_summary) × 4 formatter'а (csv, xlsx, json, pdf). PDF на MVP —
|
||||
* stub: PdfStubFormatter кидает RuntimeException → GenerateReportJob
|
||||
* ловит → failed-job (intended, Post-MVP).
|
||||
*/
|
||||
class ReportGeneratorRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DealsExportProvider $dealsExport,
|
||||
private readonly ManagersSummaryProvider $managersSummary,
|
||||
private readonly SourcesSummaryProvider $sourcesSummary,
|
||||
private readonly BillingSummaryProvider $billingSummary,
|
||||
private readonly CsvFormatter $csv,
|
||||
private readonly XlsxFormatter $xlsx,
|
||||
private readonly JsonFormatter $json,
|
||||
@@ -37,6 +42,9 @@ class ReportGeneratorRegistry
|
||||
{
|
||||
return match ($type) {
|
||||
'deals_export' => $this->dealsExport,
|
||||
'managers_summary' => $this->managersSummary,
|
||||
'sources_summary' => $this->sourcesSummary,
|
||||
'billing_summary' => $this->billingSummary,
|
||||
default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"),
|
||||
};
|
||||
}
|
||||
@@ -54,18 +62,10 @@ class ReportGeneratorRegistry
|
||||
|
||||
public function isSupported(string $type, string $format): bool
|
||||
{
|
||||
if (! in_array($type, ReportJob::TYPES, true) || ! in_array($format, ReportJob::FORMATS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Этап 2: только deals_export (этап 2b добавит остальные).
|
||||
$supportedTypes = ['deals_export'];
|
||||
if (! in_array($type, $supportedTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// PDF — stub: validates, но генерация даёт failed-job (intended).
|
||||
// Считаем «поддерживается» — пусть GenerateReportJob сам catch'ит RuntimeException.
|
||||
return true;
|
||||
// Все 4 типа ReportJob::TYPES реализованы (F1, 2026-05-16).
|
||||
// PDF валидируется, но PdfStubFormatter кидает RuntimeException →
|
||||
// GenerateReportJob ловит → failed-job (intended, Post-MVP).
|
||||
return in_array($type, ReportJob::TYPES, true)
|
||||
&& in_array($format, ReportJob::FORMATS, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
@@ -18,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
]);
|
||||
|
||||
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
|
||||
|
||||
+218
-14
@@ -180,12 +180,54 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
path: tests/Feature/AdminBillingIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/AdminIncidentRknNotifyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/AdminIncidentRknNotifyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/AdminIncidentShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/AdminIncidentShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -642,10 +684,28 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
path: tests/Feature/DashboardSummaryTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 37
|
||||
count: 15
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
@@ -669,7 +729,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 11
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
@@ -711,13 +783,25 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 50
|
||||
count: 30
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 22
|
||||
count: 21
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -735,7 +819,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 18
|
||||
count: 9
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
@@ -771,19 +867,31 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
count: 6
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 13
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
count: 7
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -801,7 +909,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
count: 7
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
@@ -825,19 +945,31 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 10
|
||||
count: 9
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 24
|
||||
count: 15
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
count: 9
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
@@ -906,6 +1038,12 @@ parameters:
|
||||
count: 16
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1047,7 +1185,49 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 25
|
||||
count: 9
|
||||
path: tests/Feature/Reports/BillingSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 31
|
||||
path: tests/Feature/Reports/ReportJobControllerTest.php
|
||||
|
||||
-
|
||||
@@ -1071,7 +1251,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 12
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ReportJobControllerTest.php
|
||||
|
||||
-
|
||||
@@ -1116,6 +1296,18 @@ parameters:
|
||||
count: 12
|
||||
path: tests/Feature/Reports/ReportLifecycleTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project1Id\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1140,6 +1332,18 @@ parameters:
|
||||
count: 5
|
||||
path: tests/Feature/RlsSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$app\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/SaasAdminMiddlewareTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/SaasAdminMiddlewareTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
|
||||
@@ -331,3 +331,100 @@ export async function updateSystemSetting(
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
|
||||
|
||||
export interface AdminTariffPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
price_monthly: string;
|
||||
}
|
||||
|
||||
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
|
||||
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
|
||||
return data.plans;
|
||||
}
|
||||
|
||||
export async function updateTenantStatus(
|
||||
id: number,
|
||||
status: 'active' | 'suspended',
|
||||
reason: string,
|
||||
): Promise<{ id: number; status: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ id: number; status: string }>(
|
||||
`/api/admin/billing/tenants/${id}/status`,
|
||||
{ status, reason },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refundTenant(
|
||||
id: number,
|
||||
amountRub: number,
|
||||
reason: string,
|
||||
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
|
||||
`/api/admin/billing/tenants/${id}/refund`,
|
||||
{ amount_rub: amountRub, reason },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function changeTenantTariff(
|
||||
id: number,
|
||||
tariffId: number,
|
||||
reason: string,
|
||||
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
|
||||
`/api/admin/billing/tenants/${id}/tariff`,
|
||||
{ tariff_id: tariffId, reason },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
|
||||
|
||||
export interface ApiIncidentAffectedTenant {
|
||||
id: number;
|
||||
organization_name: string;
|
||||
}
|
||||
|
||||
export interface ApiAdminIncidentDetail {
|
||||
id: number;
|
||||
incident_id: string;
|
||||
type: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
summary: string;
|
||||
root_cause: string | null;
|
||||
postmortem_url: string | null;
|
||||
started_at: string;
|
||||
detected_at: string;
|
||||
resolved_at: string | null;
|
||||
status: 'open' | 'investigating' | 'resolved';
|
||||
affected_tenants: ApiIncidentAffectedTenant[];
|
||||
affected_users_count: number | null;
|
||||
notification_sent_at: string | null;
|
||||
rkn_notified: boolean;
|
||||
rkn_notified_at: string | null;
|
||||
rkn_deadline_at: string | null;
|
||||
created_by_admin: string | null;
|
||||
closed_by_admin: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
|
||||
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
|
||||
return data.incident;
|
||||
}
|
||||
|
||||
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
|
||||
`/api/admin/incidents/${id}/rkn-notify`,
|
||||
{},
|
||||
);
|
||||
return data.incident;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
|
||||
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
|
||||
*/
|
||||
|
||||
export type DeltaDir = 'up' | 'down' | 'neutral';
|
||||
export type DashboardRange = 'today' | '7d' | '30d';
|
||||
|
||||
export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
balance: { amount_rub: string; runway_days: number; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
|
||||
params: { tenant_id: tenantId, range },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export interface ApiReportJob {
|
||||
parameters: ApiReportParameters;
|
||||
status: ApiReportStatus;
|
||||
file_path: string | null;
|
||||
download_url: string | null;
|
||||
file_size: number | null;
|
||||
generation_seconds: number | null;
|
||||
error_message: string | null;
|
||||
|
||||
@@ -56,8 +56,8 @@ function formatRelative(iso: string | null): string {
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// На MVP — push на DealsView (deep-link на конкретный drawer — отдельный коммит).
|
||||
await router.push('/deals');
|
||||
// Audit F3: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ function canRetry(job: ReportJob): boolean {
|
||||
</v-chip>
|
||||
<div class="job-actions">
|
||||
<v-btn
|
||||
v-if="job.status === 'done'"
|
||||
v-if="job.status === 'done' && job.downloadUrl"
|
||||
:href="job.downloadUrl"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
|
||||
@@ -45,4 +45,5 @@ export interface ReportJob {
|
||||
progress: number | null; // 0..100 для running
|
||||
attempt: number; // 1..3
|
||||
error: string | null;
|
||||
downloadUrl: string | null; // signed URL (24ч) скачивания готового файла; null для не-готовых
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function mapApiReportJob(api: ApiReportJob, now: Date = new Date()): Repo
|
||||
progress: api.status === 'processing' ? 50 : null,
|
||||
attempt: api.retry_count + 1,
|
||||
error: api.error_message,
|
||||
downloadUrl: api.download_url,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/admin/AdminIncidentsView.vue'),
|
||||
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
|
||||
},
|
||||
{
|
||||
path: '/admin/incidents/:id',
|
||||
name: 'admin-incident-detail',
|
||||
component: () => import('../views/admin/AdminIncidentDetailView.vue'),
|
||||
meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/system',
|
||||
name: 'admin-system',
|
||||
|
||||
@@ -1,67 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Дашборд — стартовая страница для авторизованных пользователей.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
|
||||
* MVP: page-head + 4 KPI-cards (получено лидов / конверсия / активные проекты /
|
||||
* баланс). Графики (Активность по дням, Воронка из 14 статусов).
|
||||
*
|
||||
* Все числа сейчас mock'и — TODO: GET /api/dashboard/summary с tenant-context'ом
|
||||
* по middleware SetTenantContext (фаза backend).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split на DashboardPageHead + DashboardKpiRow +
|
||||
* DashboardBalance (audit O-refactor-04 закрытие). State (range, kpis, balance)
|
||||
* остаётся в parent ради единого mock-data flow и future API-fetch'а.
|
||||
*
|
||||
* Примечание: «recent deals list» в Phase B/3 plan'е — на текущем дашборде нет
|
||||
* (есть только charts row); если будет добавлено в будущем — выносится в
|
||||
* DashboardRecentDeals.vue по аналогии.
|
||||
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
||||
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
||||
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import ActivityChart from '../components/charts/ActivityChart.vue';
|
||||
import FunnelChart from '../components/charts/FunnelChart.vue';
|
||||
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
|
||||
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
|
||||
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
|
||||
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const range = ref<'today' | '7d' | '30d' | 'custom'>('7d');
|
||||
const auth = useAuthStore();
|
||||
const range = ref<DashboardRange | 'custom'>('7d');
|
||||
|
||||
const kpis: Kpi[] = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: '247',
|
||||
delta: { dir: 'up', text: '12.3%' },
|
||||
sub: 'vs предыдущие 7 дней',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: '18.4',
|
||||
unit: '%',
|
||||
delta: { dir: 'up', text: '2.1pp' },
|
||||
sub: 'vs предыдущие 7 дней',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: '8',
|
||||
unit: '/ 10',
|
||||
delta: { dir: 'neutral', text: '2 свободно' },
|
||||
sub: 'тариф «Команда»',
|
||||
},
|
||||
// runwayMax — display-константа полосы (7 сегментов), не из API.
|
||||
const RUNWAY_MAX = 7;
|
||||
|
||||
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
|
||||
const MOCK_KPIS: Kpi[] = [
|
||||
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
|
||||
];
|
||||
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
|
||||
|
||||
const balance: Balance = {
|
||||
amount: '14 250',
|
||||
runwayDays: 4,
|
||||
runwayMax: 7,
|
||||
runwayLeads: 285,
|
||||
};
|
||||
const kpis = ref<Kpi[]>(MOCK_KPIS);
|
||||
const balance = ref<Balance>(MOCK_BALANCE);
|
||||
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
|
||||
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
|
||||
const activityMax = ref(60);
|
||||
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
|
||||
const fetchError = ref(false);
|
||||
|
||||
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
|
||||
function formatRub(raw: string): string {
|
||||
const n = parseFloat(raw);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
const int = Math.round(n).toString();
|
||||
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
|
||||
function applySummary(s: DashboardSummary): void {
|
||||
kpis.value = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: String(s.leads_received.value),
|
||||
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: String(s.conversion.value),
|
||||
unit: '%',
|
||||
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: String(s.active_projects.active),
|
||||
unit: `/ ${s.active_projects.limit}`,
|
||||
delta: { dir: 'neutral', text: '' },
|
||||
sub: 'лимит тарифа',
|
||||
},
|
||||
];
|
||||
balance.value = {
|
||||
amount: formatRub(s.balance.amount_rub),
|
||||
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
|
||||
runwayMax: RUNWAY_MAX,
|
||||
runwayLeads: s.balance.runway_leads,
|
||||
};
|
||||
activityPoints.value = s.activity.points;
|
||||
activityLabels.value = s.activity.labels;
|
||||
activityMax.value = s.activity.max;
|
||||
funnelCounts.value = s.funnel;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tenantId = auth.user?.tenant_id;
|
||||
if (!tenantId || range.value === 'custom') return;
|
||||
try {
|
||||
applySummary(await getDashboardSummary(tenantId, range.value as DashboardRange));
|
||||
fetchError.value = false;
|
||||
} catch {
|
||||
fetchError.value = true; // оставляем последнее значение / mock
|
||||
}
|
||||
}
|
||||
|
||||
watch(range, load);
|
||||
load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="dashboard pa-6">
|
||||
<DashboardPageHead v-model="range" />
|
||||
|
||||
<div class="ld-meta mt-2">
|
||||
<div v-show="!fetchError" class="ld-meta mt-2">
|
||||
<span class="ld-pulse" aria-hidden="true"></span>
|
||||
<span>Live · обновлено только что</span>
|
||||
</div>
|
||||
@@ -71,12 +107,23 @@ const balance: Balance = {
|
||||
<DashboardBalance :balance="balance" />
|
||||
</v-row>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="dashboard-fetch-error"
|
||||
>
|
||||
Не удалось обновить данные дашборда — показаны последние известные значения.
|
||||
</v-alert>
|
||||
|
||||
<v-row class="charts-row mt-4">
|
||||
<v-col cols="12" md="7">
|
||||
<ActivityChart />
|
||||
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<FunnelChart />
|
||||
<FunnelChart :counts="funnelCounts" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* Источник статусов — composables/leadStatuses.ts (snapshot из db/schema.sql:2130).
|
||||
*/
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
|
||||
import { mapApiDeal } from '../composables/dealsApiMapper';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
@@ -94,6 +95,7 @@ function toggleManagerDraft(name: string): void {
|
||||
: [...managerMenuDraft.value, name];
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
@@ -165,11 +167,17 @@ async function applyBulkRestoreFromTrash() {
|
||||
deleteToastOpen.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
void leadStatusesStore.load();
|
||||
void loadDeals();
|
||||
await loadDeals();
|
||||
openDealFromQuery();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.openId,
|
||||
() => openDealFromQuery(),
|
||||
);
|
||||
|
||||
// Polling — каждые 30 сек авто-refresh dealsState. Pause при скрытой вкладке.
|
||||
// Включается только при наличии auth.user (без auth listDeals = no-op anyway).
|
||||
usePolling(loadDeals);
|
||||
@@ -218,6 +226,16 @@ function openDeal(deal: MockDeal) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
|
||||
function openDealFromQuery(): void {
|
||||
const raw = route.query.openId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
if (selectedDeal.value?.id === id) return;
|
||||
const deal = dealsState.find((d) => d.id === id);
|
||||
if (deal) openDeal(deal);
|
||||
}
|
||||
|
||||
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
|
||||
const ids = [...selected.value];
|
||||
statusMenuOpen.value = false;
|
||||
@@ -410,6 +428,8 @@ defineExpose({
|
||||
clearManagerDraft,
|
||||
toggleProjectDraft,
|
||||
toggleManagerDraft,
|
||||
drawerOpen,
|
||||
selectedDeal,
|
||||
});
|
||||
|
||||
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
||||
|
||||
@@ -68,8 +68,8 @@ async function executeComplete(id: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
void dealId; // на MVP — без deep-link на конкретный drawer.
|
||||
await router.push('/deals');
|
||||
// Audit C8: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '..
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { usePolling } from '../../composables/usePolling';
|
||||
import * as adminApi from '../../api/admin';
|
||||
import type { AdminTariffPlan } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const search = ref('');
|
||||
|
||||
@@ -95,7 +97,92 @@ async function loadBilling() {
|
||||
onMounted(loadBilling);
|
||||
usePolling(loadBilling);
|
||||
|
||||
defineExpose({ rowsState, summary, loading, fetchError, loadBilling });
|
||||
// === Row-actions state (Sprint 3D G4) ===
|
||||
|
||||
const actionDialog = ref<null | 'status' | 'refund' | 'tariff'>(null);
|
||||
const actionRow = ref<BillingRow | null>(null);
|
||||
const actionReason = ref('');
|
||||
const actionAmount = ref<number | null>(null);
|
||||
const actionTariffId = ref<number | null>(null);
|
||||
const actionLoading = ref(false);
|
||||
const actionError = ref('');
|
||||
const tariffPlans = ref<AdminTariffPlan[]>([]);
|
||||
|
||||
async function openAction(type: 'status' | 'refund' | 'tariff', row: BillingRow) {
|
||||
actionDialog.value = type;
|
||||
actionRow.value = row;
|
||||
actionReason.value = '';
|
||||
actionAmount.value = null;
|
||||
actionTariffId.value = null;
|
||||
actionError.value = '';
|
||||
|
||||
if (type === 'tariff') {
|
||||
try {
|
||||
tariffPlans.value = await adminApi.listAdminTariffPlans();
|
||||
} catch (e) {
|
||||
actionError.value = extractErrorMessage(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAction() {
|
||||
// Validate
|
||||
if (actionReason.value.trim().length < 10) {
|
||||
actionError.value = 'Укажите основание (минимум 10 символов).';
|
||||
return;
|
||||
}
|
||||
if (actionDialog.value === 'refund' && (actionAmount.value === null || !Number.isFinite(actionAmount.value) || actionAmount.value <= 0)) {
|
||||
actionError.value = 'Укажите сумму возврата больше нуля.';
|
||||
return;
|
||||
}
|
||||
if (actionDialog.value === 'refund' && actionAmount.value! > actionRow.value!.balance_rub) {
|
||||
actionError.value = 'Сумма возврата превышает баланс тенанта.';
|
||||
return;
|
||||
}
|
||||
if (actionDialog.value === 'tariff' && actionTariffId.value === null) {
|
||||
actionError.value = 'Выберите тарифный план.';
|
||||
return;
|
||||
}
|
||||
|
||||
const row = actionRow.value!;
|
||||
actionLoading.value = true;
|
||||
actionError.value = '';
|
||||
|
||||
try {
|
||||
if (actionDialog.value === 'status') {
|
||||
const newStatus = row.status === 'suspended' ? 'active' : 'suspended';
|
||||
await adminApi.updateTenantStatus(row.id, newStatus, actionReason.value.trim());
|
||||
} else if (actionDialog.value === 'refund') {
|
||||
await adminApi.refundTenant(row.id, actionAmount.value!, actionReason.value.trim());
|
||||
} else if (actionDialog.value === 'tariff') {
|
||||
await adminApi.changeTenantTariff(row.id, actionTariffId.value!, actionReason.value.trim());
|
||||
}
|
||||
await loadBilling();
|
||||
actionDialog.value = null;
|
||||
} catch (e) {
|
||||
actionError.value = extractErrorMessage(e);
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
rowsState,
|
||||
summary,
|
||||
loading,
|
||||
fetchError,
|
||||
loadBilling,
|
||||
actionDialog,
|
||||
actionRow,
|
||||
actionReason,
|
||||
actionAmount,
|
||||
actionTariffId,
|
||||
actionError,
|
||||
actionLoading,
|
||||
tariffPlans,
|
||||
openAction,
|
||||
confirmAction,
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ title: 'Тенант', key: 'name', sortable: true },
|
||||
@@ -105,6 +192,7 @@ const headers = [
|
||||
{ title: 'Списания за мес', key: 'monthly_charges_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'MRR', key: 'mrr_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'Статус', key: 'status', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
|
||||
];
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
@@ -257,8 +345,189 @@ function tariffLabel(t: string): string {
|
||||
{{ statusInfo(item.status).label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-dots-vertical"
|
||||
:data-testid="`row-actions-${item.id}`"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Действия с тенантом"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
:title="item.status === 'suspended' ? 'Разблокировать' : 'Приостановить'"
|
||||
prepend-icon="mdi-account-cancel"
|
||||
@click="openAction('status', item)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Возврат средств"
|
||||
prepend-icon="mdi-cash-refund"
|
||||
@click="openAction('refund', item)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Сменить тариф"
|
||||
prepend-icon="mdi-swap-horizontal"
|
||||
@click="openAction('tariff', item)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Dialog: suspend / activate -->
|
||||
<v-dialog :model-value="actionDialog === 'status'" max-width="480" @update:model-value="actionDialog = null">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ actionRow?.status === 'suspended' ? 'Разблокировать тенанта' : 'Приостановить тенанта' }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Тенант: <strong>{{ actionRow?.name }}</strong>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="action-error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
<v-textarea
|
||||
v-model="actionReason"
|
||||
label="Основание"
|
||||
placeholder="Минимум 10 символов"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
data-testid="action-reason"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
|
||||
<v-btn
|
||||
:loading="actionLoading"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="confirmAction"
|
||||
>
|
||||
Подтвердить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog: refund -->
|
||||
<v-dialog :model-value="actionDialog === 'refund'" max-width="480" @update:model-value="actionDialog = null">
|
||||
<v-card>
|
||||
<v-card-title>Возврат средств</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Тенант: <strong>{{ actionRow?.name }}</strong>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="action-error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
<v-text-field
|
||||
v-model.number="actionAmount"
|
||||
type="number"
|
||||
label="Сумма возврата, ₽"
|
||||
:hint="actionRow ? `доступно к возврату: ${formatRub(actionRow.balance_rub)}` : ''"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="refund-amount"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="actionReason"
|
||||
label="Основание"
|
||||
placeholder="Минимум 10 символов"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
data-testid="action-reason"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
|
||||
<v-btn
|
||||
:loading="actionLoading"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="confirmAction"
|
||||
>
|
||||
Выполнить возврат
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog: change tariff -->
|
||||
<v-dialog :model-value="actionDialog === 'tariff'" max-width="480" @update:model-value="actionDialog = null">
|
||||
<v-card>
|
||||
<v-card-title>Сменить тариф</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Тенант: <strong>{{ actionRow?.name }}</strong>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="action-error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
<v-select
|
||||
v-model="actionTariffId"
|
||||
:items="tariffPlans"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Тарифный план"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="tariff-select"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="actionReason"
|
||||
label="Основание"
|
||||
placeholder="Минимум 10 символов"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
data-testid="action-reason"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
|
||||
<v-btn
|
||||
:loading="actionLoading"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="confirmAction"
|
||||
>
|
||||
Сменить тариф
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Карточка инцидента (drill-down из AdminIncidentsView).
|
||||
*
|
||||
* Sprint 3D G5/G6: детальный просмотр инцидента + кнопка «Уведомить РКН»
|
||||
* (152-ФЗ — обязательное уведомление РКН для data_breach за 24ч).
|
||||
*
|
||||
* Маршрут: /admin/incidents/:id
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getAdminIncidentDetail, notifyIncidentRkn } from '../../api/admin';
|
||||
import type { ApiAdminIncidentDetail } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = computed(() => Number(route.params.id));
|
||||
|
||||
const incident = ref<ApiAdminIncidentDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const notFound = ref(false);
|
||||
const fetchError = ref<string | null>(null);
|
||||
const rknError = ref('');
|
||||
const rknLoading = ref(false);
|
||||
const rknDialog = ref(false);
|
||||
|
||||
async function loadIncident(): Promise<void> {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
notFound.value = false;
|
||||
try {
|
||||
incident.value = await getAdminIncidentDetail(id.value);
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 404) {
|
||||
notFound.value = true;
|
||||
incident.value = null;
|
||||
} else {
|
||||
fetchError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadIncident());
|
||||
watch(id, () => void loadIncident());
|
||||
|
||||
async function confirmRkn(): Promise<void> {
|
||||
rknLoading.value = true;
|
||||
rknError.value = '';
|
||||
try {
|
||||
incident.value = await notifyIncidentRkn(id.value);
|
||||
rknDialog.value = false;
|
||||
} catch (e: unknown) {
|
||||
rknError.value = extractErrorMessage(e);
|
||||
// dialog stays open so error is visible
|
||||
} finally {
|
||||
rknLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
void router.push({ name: 'admin-incidents' });
|
||||
}
|
||||
|
||||
// Helpers (copied from AdminIncidentsView for self-containment)
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
open: { label: 'Открыт', color: 'error' },
|
||||
investigating: { label: 'Расследуется', color: 'warning' },
|
||||
resolved: { label: 'Решён', color: 'info' },
|
||||
closed: { label: 'Закрыт', color: 'success' },
|
||||
};
|
||||
function statusInfo(s: string) {
|
||||
return statusMap[s] ?? { label: s, color: 'default' };
|
||||
}
|
||||
|
||||
const severityMap: Record<string, { label: string; color: string }> = {
|
||||
critical: { label: 'Critical', color: 'error' },
|
||||
high: { label: 'High', color: 'warning' },
|
||||
medium: { label: 'Medium', color: 'info' },
|
||||
low: { label: 'Low', color: 'success' },
|
||||
};
|
||||
function severityInfo(s: string) {
|
||||
return severityMap[s] ?? { label: s, color: 'default' };
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
incident,
|
||||
loading,
|
||||
notFound,
|
||||
fetchError,
|
||||
rknError,
|
||||
rknLoading,
|
||||
rknDialog,
|
||||
loadIncident,
|
||||
confirmRkn,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading -->
|
||||
<v-container v-if="loading" fluid class="pa-6" data-testid="incident-loading">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<span class="ml-3 text-medium-emphasis">Загрузка…</span>
|
||||
</v-container>
|
||||
|
||||
<!-- Not found -->
|
||||
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="incident-not-found">
|
||||
<v-alert type="error" variant="tonal" class="mb-4">
|
||||
Инцидент <strong>#{{ id }}</strong> не найден.
|
||||
</v-alert>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">К списку инцидентов</v-btn>
|
||||
</v-container>
|
||||
|
||||
<!-- Fetch error -->
|
||||
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="incident-fetch-error">
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
Не удалось загрузить инцидент: {{ fetchError }}
|
||||
</v-alert>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadIncident">Повторить</v-btn>
|
||||
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<!-- Content -->
|
||||
<v-container v-else-if="incident" fluid class="incident-detail pa-6">
|
||||
<!-- Header -->
|
||||
<header class="d-flex justify-space-between align-start mb-4 flex-wrap ga-2">
|
||||
<div>
|
||||
<div class="d-flex align-center ga-2 mb-1">
|
||||
<span class="font-mono text-caption text-medium-emphasis">{{ incident.incident_id }}</span>
|
||||
<v-chip :color="severityInfo(incident.severity).color" size="x-small" variant="tonal">
|
||||
{{ severityInfo(incident.severity).label }}
|
||||
</v-chip>
|
||||
<v-chip :color="statusInfo(incident.status).color" size="x-small" variant="tonal">
|
||||
{{ statusInfo(incident.status).label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<h1 class="text-h5 font-weight-medium">{{ incident.summary }}</h1>
|
||||
</div>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">Назад</v-btn>
|
||||
</header>
|
||||
|
||||
<v-row>
|
||||
<!-- Main details -->
|
||||
<v-col cols="12" md="8">
|
||||
<v-card variant="outlined" class="pa-4 mb-4">
|
||||
<h2 class="text-h6 mb-3">Детали инцидента</h2>
|
||||
|
||||
<div v-if="incident.root_cause" class="mb-3">
|
||||
<div class="text-caption text-medium-emphasis">Корневая причина</div>
|
||||
<div>{{ incident.root_cause }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="incident.postmortem_url" class="mb-3">
|
||||
<div class="text-caption text-medium-emphasis">Postmortem</div>
|
||||
<a :href="incident.postmortem_url" target="_blank" rel="noopener">
|
||||
{{ incident.postmortem_url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption text-medium-emphasis">Начался</div>
|
||||
<div>{{ formatDate(incident.started_at) }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption text-medium-emphasis">Обнаружен</div>
|
||||
<div>{{ formatDate(incident.detected_at) }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6" class="mt-2">
|
||||
<div class="text-caption text-medium-emphasis">Решён</div>
|
||||
<div>{{ formatDate(incident.resolved_at) }}</div>
|
||||
</v-col>
|
||||
<v-col v-if="incident.affected_users_count !== null" cols="6" class="mt-2">
|
||||
<div class="text-caption text-medium-emphasis">Затронуто пользователей</div>
|
||||
<div>{{ incident.affected_users_count }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<!-- Affected tenants -->
|
||||
<v-card variant="outlined" class="pa-4 mb-4">
|
||||
<h2 class="text-h6 mb-3">Затронутые тенанты ({{ incident.affected_tenants.length }})</h2>
|
||||
<div v-if="incident.affected_tenants.length === 0" class="text-medium-emphasis text-body-2">
|
||||
Нет данных
|
||||
</div>
|
||||
<v-list v-else density="compact">
|
||||
<v-list-item
|
||||
v-for="t in incident.affected_tenants"
|
||||
:key="t.id"
|
||||
:title="t.organization_name"
|
||||
:subtitle="`ID: ${t.id}`"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- РКН section -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card v-if="incident.type === 'data_breach'" variant="outlined" class="pa-4 mb-4">
|
||||
<h2 class="text-h6 mb-3">Уведомление РКН (152-ФЗ)</h2>
|
||||
|
||||
<div v-if="incident.rkn_notified">
|
||||
<v-icon color="success" class="mr-1">mdi-check-circle</v-icon>
|
||||
РКН уведомлён {{ formatDate(incident.rkn_notified_at) }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="incident.rkn_deadline_at" class="mb-3">
|
||||
<div class="text-caption text-medium-emphasis">Дедлайн</div>
|
||||
<div class="text-error font-weight-medium">{{ formatDate(incident.rkn_deadline_at) }}</div>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
data-testid="rkn-notify-btn"
|
||||
color="error"
|
||||
:loading="rknLoading"
|
||||
block
|
||||
@click="rknDialog = true"
|
||||
>
|
||||
Уведомить РКН
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-alert
|
||||
v-if="rknError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="rkn-error"
|
||||
>
|
||||
{{ rknError }}
|
||||
</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Admin meta -->
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<h2 class="text-h6 mb-3">Служебная информация</h2>
|
||||
<div v-if="incident.created_by_admin" class="mb-2">
|
||||
<div class="text-caption text-medium-emphasis">Создал</div>
|
||||
<div>{{ incident.created_by_admin }}</div>
|
||||
</div>
|
||||
<div v-if="incident.closed_by_admin" class="mb-2">
|
||||
<div class="text-caption text-medium-emphasis">Закрыл</div>
|
||||
<div>{{ incident.closed_by_admin }}</div>
|
||||
</div>
|
||||
<div v-if="incident.created_at">
|
||||
<div class="text-caption text-medium-emphasis">Создан</div>
|
||||
<div>{{ formatDate(incident.created_at) }}</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- РКН confirm dialog -->
|
||||
<v-dialog v-model="rknDialog" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Подтверждение уведомления РКН</v-card-title>
|
||||
<v-card-text>
|
||||
Это юридически значимое действие. После подтверждения будет зафиксировано время уведомления
|
||||
регулятора (152-ФЗ). Продолжить?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="rknDialog = false">Отмена</v-btn>
|
||||
<v-btn color="error" :loading="rknLoading" @click="confirmRkn">Подтвердить</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.incident-detail {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePolling } from '../../composables/usePolling';
|
||||
import * as adminApi from '../../api/admin';
|
||||
|
||||
@@ -29,6 +30,8 @@ interface IncidentRow {
|
||||
rkn_deadline_at: string | null;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const filterStatus = ref<string>('all');
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
@@ -210,7 +213,14 @@ function formatDate(iso: string): string {
|
||||
</div>
|
||||
|
||||
<v-list lines="three" class="incidents-list">
|
||||
<v-list-item v-for="row in filteredRows" :key="row.id" class="incident-row">
|
||||
<v-list-item
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
class="incident-row"
|
||||
:data-testid="`incident-row-${row.id}`"
|
||||
style="cursor: pointer"
|
||||
@click="router.push({ name: 'admin-incident-detail', params: { id: row.id } })"
|
||||
>
|
||||
<div class="incident-header">
|
||||
<span class="font-mono text-caption text-medium-emphasis">{{ row.incident_id }}</span>
|
||||
<v-chip :color="severityInfo(row.severity).color" size="x-small" variant="tonal" class="ml-2">
|
||||
|
||||
+94
-58
@@ -71,52 +71,82 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')->grou
|
||||
Route::delete('/{id}', 'App\Http\Controllers\Api\ReportJobController@destroy')->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
|
||||
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
|
||||
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
|
||||
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
|
||||
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
|
||||
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
|
||||
// F2 (audit): скачивание готового файла отчёта по signed URL (24 ч, OPEN-И-20).
|
||||
// НЕ под auth:sanctum — подпись URL = capability-token (генерируется только
|
||||
// в ReportJobController::toResource() для отчётов своего тенанта).
|
||||
Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')
|
||||
->where('id', '[0-9]+')
|
||||
->name('reports.download')
|
||||
->middleware('signed');
|
||||
|
||||
// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
|
||||
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
|
||||
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
|
||||
// стаб не меняет — это отдельная зона ответственности.
|
||||
Route::middleware('saas-admin')->group(function () {
|
||||
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
|
||||
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
|
||||
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
|
||||
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
|
||||
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
|
||||
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
|
||||
});
|
||||
|
||||
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
|
||||
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
|
||||
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
|
||||
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
|
||||
->where('id', '[0-9]+');
|
||||
Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund')
|
||||
->where('id', '[0-9]+');
|
||||
Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
|
||||
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
|
||||
|
||||
// Sprint 3D (G5): SaaS-admin incident detail-view drill-down.
|
||||
Route::get('/api/admin/incidents/{id}', 'App\Http\Controllers\Api\AdminIncidentsController@show')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// Sprint 3D (G6): РКН-notify endpoint (152-ФЗ).
|
||||
Route::post('/api/admin/incidents/{id}/rkn-notify', 'App\Http\Controllers\Api\AdminIncidentsController@notifyRkn')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
|
||||
// На MVP без auth-middleware (admin_user_id параметром); production: middleware('auth:saas-admin').
|
||||
Route::prefix('/api/admin/system-settings')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
|
||||
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
|
||||
});
|
||||
|
||||
// Plan 4: SaaS-admin pricing-tiers editor.
|
||||
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
|
||||
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
|
||||
Route::prefix('/api/admin/pricing-tiers')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
|
||||
Route::delete('/scheduled/{effective_from}',
|
||||
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
|
||||
->where('effective_from', '\d{4}-\d{2}-\d{2}');
|
||||
});
|
||||
|
||||
// Plan 4 Task 10: SaaS-admin supplier prices editor.
|
||||
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
|
||||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
|
||||
// Без auth (saas-admin SSO ⏸ Б-1).
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
|
||||
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
|
||||
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
|
||||
|
||||
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
|
||||
// На MVP без auth-middleware (admin_user_id параметром); production: middleware('auth:saas-admin').
|
||||
Route::prefix('/api/admin/system-settings')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
|
||||
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
|
||||
});
|
||||
|
||||
// Plan 4: SaaS-admin pricing-tiers editor.
|
||||
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
|
||||
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
|
||||
Route::prefix('/api/admin/pricing-tiers')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
|
||||
Route::delete('/scheduled/{effective_from}',
|
||||
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
|
||||
->where('effective_from', '\d{4}-\d{2}-\d{2}');
|
||||
});
|
||||
|
||||
// Plan 4 Task 10: SaaS-admin supplier prices editor.
|
||||
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
|
||||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
|
||||
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
|
||||
@@ -147,21 +177,27 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||||
});
|
||||
|
||||
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
|
||||
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
|
||||
//
|
||||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD остаётся в
|
||||
// DealController, bulk-операции (transition/destroy/restore) — в
|
||||
// DealBulkActionController, export — в DealExportController. URL и shape
|
||||
// payload'ов сохранены, только controller@method обновлён.
|
||||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
|
||||
// DealController, bulk (transition/destroy/restore) — в
|
||||
// DealBulkActionController, export — в DealExportController.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||||
});
|
||||
|
||||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeBillingTenant(array $overrides = []): int
|
||||
{
|
||||
return (int) DB::table('tenants')->insertGetId(array_merge([
|
||||
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Billing Test Co',
|
||||
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'status' => 'active',
|
||||
'balance_rub' => '5000.00',
|
||||
'is_trial' => false,
|
||||
'created_at' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function makeTariffPlan(array $overrides = []): int
|
||||
{
|
||||
return (int) DB::table('tariff_plans')->insertGetId(array_merge([
|
||||
'code' => 'test-'.bin2hex(random_bytes(4)),
|
||||
'name' => 'Test Plan',
|
||||
'billing_model' => 'monthly',
|
||||
'price_monthly' => '999.00',
|
||||
'created_at' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
test('GET tariff-plans возвращает список планов', function () {
|
||||
$planId = makeTariffPlan(['name' => 'Visible Plan', 'price_monthly' => '1500.00']);
|
||||
$r = $this->getJson('/api/admin/billing/tariff-plans');
|
||||
$r->assertOk();
|
||||
$plans = $r->json('plans');
|
||||
expect($plans)->toBeArray();
|
||||
$found = collect($plans)->first(fn ($p) => $p['id'] === $planId);
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found['id'])->toBeInt();
|
||||
expect($found['name'])->toBeString();
|
||||
expect($found['price_monthly'])->toBeString();
|
||||
});
|
||||
|
||||
test('PATCH status suspended меняет статус + пишет audit-log', function () {
|
||||
$id = makeBillingTenant(['status' => 'active']);
|
||||
$r = $this->patchJson("/api/admin/billing/tenants/{$id}/status", [
|
||||
'status' => 'suspended',
|
||||
'reason' => 'Просрочка оплаты более 30 дней.',
|
||||
]);
|
||||
$r->assertOk();
|
||||
expect($r->json('status'))->toBe('suspended');
|
||||
expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('suspended');
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.suspend')->where('target_id', $id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH status active разблокирует', function () {
|
||||
$id = makeBillingTenant(['status' => 'suspended']);
|
||||
$this->patchJson("/api/admin/billing/tenants/{$id}/status", [
|
||||
'status' => 'active', 'reason' => 'Оплата получена, блокировка снята.',
|
||||
])->assertOk();
|
||||
expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('active');
|
||||
});
|
||||
|
||||
test('PATCH status reason короче 10 символов → 422', function () {
|
||||
$id = makeBillingTenant();
|
||||
$this->patchJson("/api/admin/billing/tenants/{$id}/status", ['status' => 'suspended', 'reason' => 'мало'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('PATCH status несуществующий тенант → 404', function () {
|
||||
$this->patchJson('/api/admin/billing/tenants/99999999/status', [
|
||||
'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
test('PATCH status soft-deleted тенант → 404', function () {
|
||||
$id = makeBillingTenant(['deleted_at' => now()]);
|
||||
$this->patchJson("/api/admin/billing/tenants/{$id}/status", [
|
||||
'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
test('POST refund списывает с баланса + создаёт balance_transactions refund', function () {
|
||||
$id = makeBillingTenant(['balance_rub' => '5000.00']);
|
||||
$r = $this->postJson("/api/admin/billing/tenants/{$id}/refund", [
|
||||
'amount_rub' => 1500, 'reason' => 'Возврат по обращению клиента №42.',
|
||||
]);
|
||||
$r->assertOk();
|
||||
expect($r->json('balance_rub'))->toBe('3500.00');
|
||||
expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('3500.00');
|
||||
$tx = BalanceTransaction::where('tenant_id', $id)->where('type', 'refund')->first();
|
||||
expect($tx)->not->toBeNull();
|
||||
expect((string) $tx->amount_rub)->toBe('-1500.00');
|
||||
expect((string) $tx->balance_rub_after)->toBe('3500.00');
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.refund')->where('target_id', $id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST refund больше баланса → 422, баланс не меняется', function () {
|
||||
$id = makeBillingTenant(['balance_rub' => '1000.00']);
|
||||
$this->postJson("/api/admin/billing/tenants/{$id}/refund", [
|
||||
'amount_rub' => 5000, 'reason' => 'Возврат по обращению клиента №7.',
|
||||
])->assertStatus(422);
|
||||
expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('1000.00');
|
||||
expect(BalanceTransaction::where('tenant_id', $id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('POST refund неположительная сумма → 422', function () {
|
||||
$id = makeBillingTenant();
|
||||
$this->postJson("/api/admin/billing/tenants/{$id}/refund", ['amount_rub' => 0, 'reason' => 'Основание длиннее десяти символов.'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('PATCH tariff меняет current_tariff_id + audit-log', function () {
|
||||
$id = makeBillingTenant();
|
||||
$tariffId = makeTariffPlan(['name' => 'Corp Plan', 'price_monthly' => '2500.00']);
|
||||
$r = $this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [
|
||||
'tariff_id' => $tariffId, 'reason' => 'Переход на тариф по договорённости с клиентом.',
|
||||
]);
|
||||
$r->assertOk();
|
||||
expect((int) DB::table('tenants')->where('id', $id)->value('current_tariff_id'))->toBe($tariffId);
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.change_tariff')->where('target_id', $id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH tariff несуществующий tariff_id → 422', function () {
|
||||
$id = makeBillingTenant();
|
||||
$this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [
|
||||
'tariff_id' => 88888888, 'reason' => 'Основание длиннее десяти символов.',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::table('incidents_log')->delete();
|
||||
$this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'rkn-'.bin2hex(random_bytes(3)).'@test',
|
||||
'full_name' => 'RKN Admin',
|
||||
'password_hash' => bcrypt('test1234'),
|
||||
'is_active' => true,
|
||||
'role' => 'support',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
function makeRknIncident(int $adminId, array $overrides = []): int
|
||||
{
|
||||
$started = $overrides['started_at'] ?? now()->subHours(2);
|
||||
|
||||
return (int) DB::table('incidents_log')->insertGetId(array_merge([
|
||||
'type' => 'data_breach',
|
||||
'severity' => 'critical',
|
||||
'started_at' => $started,
|
||||
'detected_at' => $started,
|
||||
'summary' => 'PDN leak test',
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
test('POST rkn-notify проставляет rkn_notified_at + audit-log', function () {
|
||||
$id = makeRknIncident($this->adminId);
|
||||
$r = $this->postJson("/api/admin/incidents/{$id}/rkn-notify");
|
||||
$r->assertOk();
|
||||
expect($r->json('incident.rkn_notified'))->toBeTrue();
|
||||
expect($r->json('incident.rkn_notified_at'))->toBeString();
|
||||
expect(DB::table('incidents_log')->where('id', $id)->value('rkn_notified_at'))->not->toBeNull();
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'incident.rkn_notify')->where('target_id', $id)->exists())
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST rkn-notify несуществующий инцидент → 404', function () {
|
||||
$this->postJson('/api/admin/incidents/99999999/rkn-notify')->assertStatus(404);
|
||||
});
|
||||
|
||||
test('POST rkn-notify не data_breach → 422', function () {
|
||||
$id = makeRknIncident($this->adminId, ['type' => 'service_outage']);
|
||||
$this->postJson("/api/admin/incidents/{$id}/rkn-notify")->assertStatus(422);
|
||||
expect(DB::table('incidents_log')->where('id', $id)->value('rkn_notified_at'))->toBeNull();
|
||||
});
|
||||
|
||||
test('POST rkn-notify повторно → 409', function () {
|
||||
$id = makeRknIncident($this->adminId, ['rkn_notified_at' => now()->subHour()]);
|
||||
$this->postJson("/api/admin/incidents/{$id}/rkn-notify")->assertStatus(409);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::table('incidents_log')->delete();
|
||||
$this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'inc-'.bin2hex(random_bytes(3)).'@test',
|
||||
'full_name' => 'Incident Admin',
|
||||
'password_hash' => bcrypt('test1234'),
|
||||
'is_active' => true,
|
||||
'role' => 'support',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
function makeShowIncident(int $adminId, array $overrides = []): int
|
||||
{
|
||||
$started = $overrides['started_at'] ?? now()->subHours(3);
|
||||
$detected = $overrides['detected_at'] ?? $started;
|
||||
|
||||
return (int) DB::table('incidents_log')->insertGetId(array_merge([
|
||||
'type' => 'service_outage',
|
||||
'severity' => 'high',
|
||||
'started_at' => $started,
|
||||
'detected_at' => $detected,
|
||||
'resolved_at' => null,
|
||||
'summary' => 'Show test incident',
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
test('GET /api/admin/incidents/{id} 200 + полная карточка', function () {
|
||||
$id = makeShowIncident($this->adminId, ['summary' => 'API 502 burst', 'severity' => 'critical']);
|
||||
$r = $this->getJson("/api/admin/incidents/{$id}");
|
||||
$r->assertOk();
|
||||
expect($r->json('incident.id'))->toBe($id);
|
||||
expect($r->json('incident.summary'))->toBe('API 502 burst');
|
||||
expect($r->json('incident.severity'))->toBe('critical');
|
||||
expect($r->json('incident.incident_id'))->toMatch('/^INC-\d{4}-\d{4}-\d{4}$/');
|
||||
expect($r->json('incident.status'))->toBe('investigating');
|
||||
expect($r->json('incident.created_by_admin'))->toBe('Incident Admin');
|
||||
});
|
||||
|
||||
test('GET /api/admin/incidents/{id} несуществующий → 404', function () {
|
||||
$this->getJson('/api/admin/incidents/99999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
test('GET /api/admin/incidents/{id} data_breach без rkn_notified_at → rkn_deadline_at +24ч', function () {
|
||||
$id = makeShowIncident($this->adminId, ['type' => 'data_breach', 'detected_at' => now()->subHour()]);
|
||||
$r = $this->getJson("/api/admin/incidents/{$id}");
|
||||
expect($r->json('incident.rkn_notified'))->toBeFalse();
|
||||
expect($r->json('incident.rkn_deadline_at'))->toBeString();
|
||||
});
|
||||
|
||||
test('GET /api/admin/incidents/{id} разрешает имена affected_tenants', function () {
|
||||
$tenantId = (int) DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Affected Org',
|
||||
'contact_email' => 'a@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
|
||||
$r = $this->getJson("/api/admin/incidents/{$id}");
|
||||
expect($r->json('incident.affected_tenants'))->toHaveCount(1);
|
||||
expect($r->json('incident.affected_tenants.0.organization_name'))->toBe('Affected Org');
|
||||
});
|
||||
|
||||
test('GET /api/admin/incidents/{id} resolved инцидент → status resolved', function () {
|
||||
$id = makeShowIncident($this->adminId, ['resolved_at' => now()]);
|
||||
expect($this->getJson("/api/admin/incidents/{$id}")->json('incident.status'))->toBe('resolved');
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Вспомогательная функция: создать сделку с заданными параметрами.
|
||||
*
|
||||
* Фабрика Deal::factory() по умолчанию: received_at = now() (текущий месяц,
|
||||
* партиция deals_2026_05 существует). is_test = false, deleted_at = null.
|
||||
* Для тестовых дат subDays(1..6) — всё в мае 2026, партиция есть.
|
||||
*/
|
||||
function makeDashboardDeal(
|
||||
Tenant $tenant,
|
||||
Project $project,
|
||||
string $status,
|
||||
Carbon|CarbonImmutable $receivedAt,
|
||||
?Carbon $deletedAt = null,
|
||||
bool $isTest = false,
|
||||
): Deal {
|
||||
return Deal::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'status' => $status,
|
||||
'received_at' => $receivedAt,
|
||||
'deleted_at' => $deletedAt,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
}
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'limits' => ['max_projects' => 10],
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonPath('balance.amount_rub', '14250.00')
|
||||
->assertJsonStructure([
|
||||
'range',
|
||||
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
|
||||
'conversion' => ['value', 'delta_pp', 'delta_dir'],
|
||||
'active_projects' => ['active', 'limit'],
|
||||
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
|
||||
'activity' => ['points', 'labels', 'max'],
|
||||
'funnel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
});
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
});
|
||||
|
||||
it('runway_days использует фикс. 7д-окно независимо от range', function () {
|
||||
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
|
||||
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
for ($i = 0; $i <= 6; $i++) {
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
|
||||
}
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today")
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.runway_days', 70);
|
||||
});
|
||||
@@ -18,11 +18,12 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_leads' => 100,
|
||||
]);
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('POST /api/deals создаёт сделку с manual source + project firstOrCreate', function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'Окна Москва',
|
||||
'phone' => '+7 (999) 123-45-67',
|
||||
'contact_name' => 'Тест Тестов',
|
||||
@@ -57,7 +58,6 @@ test('POST /api/deals использует существующий project (н
|
||||
]);
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'Натяжные потолки',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -74,7 +74,6 @@ test('POST /api/deals использует существующий project (н
|
||||
|
||||
test('POST /api/deals пишет ActivityLog с context.source=manual', function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -90,21 +89,20 @@ test('POST /api/deals пишет ActivityLog с context.source=manual', function
|
||||
test('POST /api/deals 422 без обязательных полей', function () {
|
||||
$r = $this->postJson('/api/deals', []);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKeys(['tenant_id', 'project_name', 'phone']);
|
||||
expect($r->json('errors'))->toHaveKeys(['project_name', 'phone']);
|
||||
});
|
||||
|
||||
test('POST /api/deals 404 при unknown tenant_id', function () {
|
||||
test('POST /api/deals 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => 999999,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
$r->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals дефолтный status = new если не передан', function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -117,7 +115,6 @@ test('POST /api/deals с manager_id → assigned_at = NOW()', function () {
|
||||
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'manager_id' => $manager->id,
|
||||
@@ -133,7 +130,6 @@ test('POST /api/deals manual НЕ списывает баланс tenant\'а', f
|
||||
$balanceBefore = $this->tenant->balance_leads;
|
||||
|
||||
$this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
])->assertStatus(201);
|
||||
@@ -170,7 +166,6 @@ test('POST /api/deals manual создаёт SupplierLeadCost если у про
|
||||
]);
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'WithSupplier',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -188,7 +183,6 @@ test('POST /api/deals manual создаёт SupplierLeadCost если у про
|
||||
|
||||
test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без SupplierLeadCost (graceful skip)", function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'NoSupplier',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -204,20 +198,17 @@ test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без
|
||||
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
|
||||
// Создаём 2 сделки через store endpoint (получаем реальные id).
|
||||
$r1 = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$r2 = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$r1['id'], $r2['id']],
|
||||
]);
|
||||
|
||||
@@ -239,38 +230,33 @@ test('POST /api/deals/export возвращает CSV с правильными
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 без ids', function () {
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
]);
|
||||
$r = $this->postJson('/api/deals/export', []);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKey('ids');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 404 unknown tenant', function () {
|
||||
test('POST /api/deals/export 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => 999999,
|
||||
'ids' => [1, 2, 3],
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
$r->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
|
||||
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -286,14 +272,12 @@ test('POST /api/deals/export фильтрует только запрошенн
|
||||
|
||||
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
@@ -310,14 +294,12 @@ test('POST /api/deals/export?format=xlsx возвращает binary с корр
|
||||
|
||||
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 333-33-33',
|
||||
'contact_name' => 'Кириллов',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
@@ -348,7 +330,6 @@ test('POST /api/deals/export?format=xlsx содержит данные сдел
|
||||
|
||||
test('POST /api/deals/export 422 на неизвестный format', function () {
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [1],
|
||||
'format' => 'pdf',
|
||||
]);
|
||||
@@ -358,14 +339,12 @@ test('POST /api/deals/export 422 на неизвестный format', function (
|
||||
|
||||
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 444-44-44',
|
||||
'contact_name' => 'Test',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -15,6 +16,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
@@ -23,19 +27,15 @@ test('DELETE /api/deals 422 без обязательных полей', functio
|
||||
$this->deleteJson('/api/deals', [])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('DELETE /api/deals 404 на unknown tenant', function () {
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => 999999,
|
||||
'ids' => [1],
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
test('DELETE /api/deals 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->deleteJson('/api/deals', ['ids' => [1]])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('DELETE /api/deals soft-удаляет сделки + пишет deal.deleted ActivityLog', function () {
|
||||
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
]);
|
||||
|
||||
@@ -65,7 +65,6 @@ test('DELETE /api/deals defense-in-depth не удаляет чужие сдел
|
||||
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
|
||||
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$own->id, $foreign->id],
|
||||
]);
|
||||
|
||||
@@ -88,13 +87,11 @@ test('DELETE /api/deals NO-OP на уже удалённых', function () {
|
||||
|
||||
// Первое удаление
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200)->assertJson(['deleted' => 1]);
|
||||
|
||||
// Повтор — уже удалена, NO-OP.
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
]);
|
||||
$r->assertStatus(200)->assertJson(['deleted' => 0, 'requested' => 1]);
|
||||
@@ -110,11 +107,10 @@ test('GET /api/deals НЕ возвращает soft-deleted сделки', funct
|
||||
|
||||
// Удаляем одну
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deleted->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
$ids = collect($r->json('deals'))->pluck('id')->all();
|
||||
expect($ids)->toContain($alive->id);
|
||||
expect($ids)->not->toContain($deleted->id);
|
||||
@@ -125,17 +121,15 @@ test('GET /api/deals/{id} 404 для soft-deleted сделки', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
$this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id)
|
||||
$this->getJson('/api/deals/'.$deal->id)
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('DELETE /api/deals 422 пустой массив ids', function () {
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*
|
||||
* Покрывает: фильтры (status_in, project_id, manager_id, search), сортировку
|
||||
* по received_at DESC, RLS-изоляцию между tenant'ами, относительные поля
|
||||
* (project_name, manager_name/initials), 422/404, пагинацию (limit/offset).
|
||||
* (project_name, manager_name/initials), 401/404, пагинацию (limit/offset).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -22,6 +22,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
|
||||
$this->project2 = Project::factory()->for($this->tenant)->create(['name' => 'Натяжные потолки']);
|
||||
@@ -33,16 +36,13 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 422 без tenant_id', function () {
|
||||
$this->getJson('/api/deals')->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 404 для unknown tenant_id', function () {
|
||||
$this->getJson('/api/deals?tenant_id=999999')->assertStatus(404);
|
||||
test('GET /api/deals возвращает 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/deals')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает пустой список для tenant без сделок', function () {
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
$r->assertStatus(200)
|
||||
->assertJson(['deals' => [], 'total' => 0, 'limit' => 100, 'offset' => 0]);
|
||||
@@ -59,7 +59,7 @@ test('GET /api/deals возвращает сделки tenant\'а с проек
|
||||
'manager_id' => $this->manager->id,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('total'))->toBe(1);
|
||||
@@ -79,7 +79,7 @@ test('GET /api/deals не возвращает сделки чужого tenant\
|
||||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||||
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.tenant_id'))->toBe($this->tenant->id);
|
||||
@@ -96,7 +96,7 @@ test('GET /api/deals сортирует по received_at DESC', function () {
|
||||
'received_at' => now()->subHours(1),
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('deals.0.id'))->toBe($newest->id);
|
||||
expect($r->json('deals.1.id'))->toBe($middle->id);
|
||||
@@ -108,7 +108,7 @@ test('GET /api/deals фильтрует по status_in[]', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&status_in[]=new&status_in[]=paid');
|
||||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
|
||||
|
||||
expect($r->json('total'))->toBe(2);
|
||||
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
|
||||
@@ -120,7 +120,7 @@ test('GET /api/deals фильтрует по project_id', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
Deal::factory()->for($this->tenant)->for($this->project2)->create();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&project_id='.$this->project2->id);
|
||||
$r = $this->getJson('/api/deals?project_id='.$this->project2->id);
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.project_name'))->toBe('Натяжные потолки');
|
||||
@@ -133,7 +133,7 @@ test('GET /api/deals фильтрует по manager_id', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $other->id]);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&manager_id='.$this->manager->id);
|
||||
$r = $this->getJson('/api/deals?manager_id='.$this->manager->id);
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.manager_id'))->toBe($this->manager->id);
|
||||
@@ -149,13 +149,13 @@ test('GET /api/deals фильтрует по search (phone + contact_name, ILIKE
|
||||
'contact_name' => 'Дмитрий Петров',
|
||||
]);
|
||||
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=Соколова')
|
||||
expect($this->getJson('/api/deals?search=Соколова')
|
||||
->json('total'))->toBe(1);
|
||||
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=903')
|
||||
expect($this->getJson('/api/deals?search=903')
|
||||
->json('total'))->toBe(1);
|
||||
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=сокол') // case-insensitive ILIKE
|
||||
expect($this->getJson('/api/deals?search=сокол') // case-insensitive ILIKE
|
||||
->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ test('GET /api/deals поддерживает limit + offset', function () {
|
||||
]);
|
||||
}
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&offset=1');
|
||||
$r = $this->getJson('/api/deals?limit=2&offset=1');
|
||||
|
||||
expect($r->json('total'))->toBe(5);
|
||||
expect($r->json('limit'))->toBe(2);
|
||||
@@ -181,7 +181,7 @@ test('GET /api/deals?only_deleted=true возвращает только soft-de
|
||||
$deleted1->delete();
|
||||
$deleted2->delete();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&only_deleted=true');
|
||||
$r = $this->getJson('/api/deals?only_deleted=true');
|
||||
|
||||
expect($r->json('total'))->toBe(2);
|
||||
$ids = collect($r->json('deals'))->pluck('id')->all();
|
||||
@@ -195,7 +195,7 @@ test('GET /api/deals (без only_deleted) НЕ возвращает soft-delete
|
||||
$deleted = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$deleted->delete();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.id'))->toBe($alive->id);
|
||||
@@ -211,7 +211,7 @@ test('GET /api/deals?only_deleted=true изолирует чужие удалё
|
||||
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$own->delete();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&only_deleted=true');
|
||||
$r = $this->getJson('/api/deals?only_deleted=true');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.id'))->toBe($own->id);
|
||||
@@ -220,7 +220,7 @@ test('GET /api/deals?only_deleted=true изолирует чужие удалё
|
||||
test('GET /api/deals возвращает manager_name/initials = null если manager_id null', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('deals.0.manager_id'))->toBeNull();
|
||||
expect($r->json('deals.0.manager_name'))->toBeNull();
|
||||
@@ -244,7 +244,7 @@ test('GET /api/deals с cursor возвращает следующую стра
|
||||
}
|
||||
|
||||
// Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
|
||||
$r1 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
|
||||
$r1 = $this->getJson('/api/deals?limit=2');
|
||||
$r1->assertStatus(200);
|
||||
expect($r1->json('deals'))->toHaveLength(2);
|
||||
expect($r1->json('deals.0.id'))->toBe($ids[4]);
|
||||
@@ -256,7 +256,7 @@ test('GET /api/deals с cursor возвращает следующую стра
|
||||
'i' => $r1->json('deals.1.id'),
|
||||
]));
|
||||
|
||||
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
|
||||
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
|
||||
$r2->assertStatus(200);
|
||||
expect($r2->json('deals'))->toHaveLength(2);
|
||||
expect($r2->json('deals.0.id'))->toBe($ids[2]);
|
||||
@@ -264,7 +264,7 @@ test('GET /api/deals с cursor возвращает следующую стра
|
||||
});
|
||||
|
||||
test('GET /api/deals с невалидным cursor возвращает 422', function () {
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&cursor=not-base64-json');
|
||||
$r = $this->getJson('/api/deals?cursor=not-base64-json');
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('message'))->toBeString();
|
||||
});
|
||||
@@ -278,14 +278,14 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
|
||||
]);
|
||||
}
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
|
||||
$r = $this->getJson('/api/deals?limit=2');
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('next_cursor'))->toBeString();
|
||||
expect($r->json('next_cursor'))->not->toBeEmpty();
|
||||
|
||||
// Последняя страница: next_cursor = null.
|
||||
$cursor = $r->json('next_cursor');
|
||||
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
|
||||
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
|
||||
$r2->assertStatus(200);
|
||||
expect($r2->json('next_cursor'))->toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -15,6 +16,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
@@ -23,12 +27,9 @@ test('POST /api/deals/restore 422 без обязательных полей', f
|
||||
$this->postJson('/api/deals/restore', [])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('POST /api/deals/restore 404 на unknown tenant', function () {
|
||||
$r = $this->postJson('/api/deals/restore', [
|
||||
'tenant_id' => 999999,
|
||||
'ids' => [1],
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
test('POST /api/deals/restore 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/deals/restore', ['ids' => [1]])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/restore восстанавливает soft-deleted + пишет deal.restored', function () {
|
||||
@@ -36,13 +37,11 @@ test('POST /api/deals/restore восстанавливает soft-deleted + пи
|
||||
|
||||
// Удалим сначала
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
// Восстановим
|
||||
$r = $this->postJson('/api/deals/restore', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
]);
|
||||
$r->assertStatus(200)->assertJson([
|
||||
@@ -64,7 +63,6 @@ test('POST /api/deals/restore NO-OP для не-удалённых (живых)
|
||||
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->postJson('/api/deals/restore', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$alive->id],
|
||||
]);
|
||||
$r->assertStatus(200)->assertJson([
|
||||
@@ -88,7 +86,6 @@ test('POST /api/deals/restore defense-in-depth не восстанавливае
|
||||
$own->delete();
|
||||
|
||||
$r = $this->postJson('/api/deals/restore', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$own->id, $foreign->id],
|
||||
]);
|
||||
$r->assertStatus(200)->assertJson([
|
||||
@@ -110,26 +107,23 @@ test('POST /api/deals/restore — после restore сделка снова в
|
||||
|
||||
// Удалили
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
// GET не возвращает
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(0);
|
||||
expect($this->getJson('/api/deals')->json('total'))->toBe(0);
|
||||
|
||||
// Restore
|
||||
$this->postJson('/api/deals/restore', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
// GET снова возвращает
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(1);
|
||||
expect($this->getJson('/api/deals')->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('POST /api/deals/restore 422 пустой массив ids', function () {
|
||||
$this->postJson('/api/deals/restore', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
|
||||
$this->manager = User::factory()->for($this->tenant)->create([
|
||||
@@ -29,18 +32,14 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('GET /api/deals/{id} 422 без tenant_id', function () {
|
||||
test('GET /api/deals/{id} 401 без auth', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$this->getJson('/api/deals/'.$deal->id)->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/deals/{id} 404 для unknown tenant', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$this->getJson('/api/deals/'.$deal->id.'?tenant_id=999999')->assertStatus(404);
|
||||
auth()->logout();
|
||||
$this->getJson('/api/deals/'.$deal->id)->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/deals/{id} 404 если сделка не существует', function () {
|
||||
$this->getJson('/api/deals/999999?tenant_id='.$this->tenant->id)->assertStatus(404);
|
||||
$this->getJson('/api/deals/999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
test('GET /api/deals/{id} 404 если сделка чужого tenant\'а (defense-in-depth)', function () {
|
||||
@@ -48,8 +47,8 @@ test('GET /api/deals/{id} 404 если сделка чужого tenant\'а (def
|
||||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||||
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
|
||||
|
||||
// Запрашиваем чужую сделку с нашим tenant_id — RLS+app-фильтр скрывают.
|
||||
$this->getJson('/api/deals/'.$foreign->id.'?tenant_id='.$this->tenant->id)
|
||||
// Запрашиваем чужую сделку — RLS+app-фильтр скрывают.
|
||||
$this->getJson('/api/deals/'.$foreign->id)
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
@@ -65,7 +64,7 @@ test('GET /api/deals/{id} возвращает сделку с relations', funct
|
||||
'comment' => 'Заметка менеджера',
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals/'.$deal->id);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deal.id'))->toBe($deal->id);
|
||||
@@ -100,7 +99,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
|
||||
'created_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals/'.$deal->id);
|
||||
|
||||
$r->assertStatus(200);
|
||||
$events = $r->json('events');
|
||||
@@ -137,7 +136,7 @@ test('GET /api/deals/{id} НЕ возвращает чужие activity events (
|
||||
'context' => ['source' => 'webhook'],
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals/'.$deal->id);
|
||||
|
||||
$events = $r->json('events');
|
||||
expect($events)->toHaveCount(1);
|
||||
@@ -159,7 +158,7 @@ test('GET /api/deals/{id} лимит 50 событий', function () {
|
||||
]);
|
||||
}
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals/'.$deal->id);
|
||||
|
||||
expect($r->json('events'))->toHaveCount(50);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*
|
||||
* Покрывает: validation (422 на missing/неизвестный slug), RLS+app-фильтр
|
||||
* (чужие сделки НЕ обновляются), ActivityLog event=deal.status_changed,
|
||||
* 404 unknown tenant, NO-OP не пишет audit entry, partial update (несколько id
|
||||
* 401 без auth, NO-OP не пишет audit entry, partial update (несколько id
|
||||
* принадлежат tenant'у, один — нет → updated < requested).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
@@ -23,6 +24,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
@@ -31,20 +35,15 @@ test('POST /api/deals/transition — 422 без обязательных пол
|
||||
$this->postJson('/api/deals/transition', [])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('POST /api/deals/transition — 404 на unknown tenant', function () {
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'tenant_id' => 999999,
|
||||
'ids' => [1],
|
||||
'status' => 'paid',
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
test('POST /api/deals/transition — 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/transition — 422 на неизвестный status slug', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
'status' => 'not_a_real_slug',
|
||||
]);
|
||||
@@ -61,7 +60,6 @@ test('POST /api/deals/transition — обновляет статус и пише
|
||||
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
'status' => 'paid',
|
||||
]);
|
||||
@@ -92,7 +90,6 @@ test('POST /api/deals/transition — NO-OP не пишет ActivityLog', functio
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
'status' => 'paid',
|
||||
]);
|
||||
@@ -111,9 +108,8 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||||
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
|
||||
|
||||
// Передаём оба id, но tenant_id указываем наш — чужой не должен обновиться.
|
||||
// Передаём оба id — чужой не должен обновиться.
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$own->id, $foreign->id],
|
||||
'status' => 'paid',
|
||||
]);
|
||||
@@ -134,7 +130,6 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
|
||||
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
|
||||
$this->postJson('/api/deals/transition', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [],
|
||||
'status' => 'paid',
|
||||
])->assertStatus(422);
|
||||
|
||||
@@ -16,22 +16,18 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
$this->manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 422 без tenant_id', function () {
|
||||
test('PATCH /api/deals/{id} 401 без auth', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 404 unknown tenant', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => 999999,
|
||||
'comment' => 'X',
|
||||
])->assertStatus(404);
|
||||
auth()->logout();
|
||||
$this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 404 чужая сделка', function () {
|
||||
@@ -40,7 +36,6 @@ test('PATCH /api/deals/{id} 404 чужая сделка', function () {
|
||||
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
|
||||
|
||||
$this->patchJson('/api/deals/'.$foreign->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'comment' => 'leak',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
@@ -49,7 +44,6 @@ test('PATCH /api/deals/{id} обновляет comment + пишет deal.comment
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'comment' => 'Дозвонился, перезвоню после 14:00',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -71,7 +65,6 @@ test('PATCH /api/deals/{id} обновляет manager_id + пишет deal.assi
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'manager_id' => $this->manager->id,
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -90,7 +83,6 @@ test('PATCH /api/deals/{id} обновляет status + пишет deal.status_c
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'paid',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -108,7 +100,6 @@ test('PATCH /api/deals/{id} 422 на неизвестный status slug', functi
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'not_a_real_slug',
|
||||
]);
|
||||
$r->assertStatus(422);
|
||||
@@ -125,7 +116,6 @@ test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', functi
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'manager_id' => $foreignManager->id,
|
||||
]);
|
||||
$r->assertStatus(422);
|
||||
@@ -138,7 +128,6 @@ test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'paid', // не меняем
|
||||
'comment' => 'same', // не меняем
|
||||
]);
|
||||
@@ -155,7 +144,6 @@ test('PATCH /api/deals/{id} комбинированно — comment + status о
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'comment' => 'Заметка',
|
||||
'status' => 'worked',
|
||||
]);
|
||||
|
||||
@@ -65,8 +65,8 @@ test('POST /api/deals 422 если manager_id не принадлежит tenant
|
||||
$otherManager = User::factory()->for($otherTenant)->create(['is_active' => true]);
|
||||
|
||||
// Назначаем чужого менеджера на свою сделку — должен быть 422.
|
||||
$this->actingAs(User::factory()->for($this->tenant)->create());
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'manager_id' => $otherManager->id,
|
||||
@@ -79,8 +79,8 @@ test('POST /api/deals 422 если manager_id не активен (is_active=fal
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$inactive = User::factory()->for($this->tenant)->create(['is_active' => false]);
|
||||
|
||||
$this->actingAs(User::factory()->for($this->tenant)->create());
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'manager_id' => $inactive->id,
|
||||
@@ -92,8 +92,8 @@ test('POST /api/deals принимает manager_id из своего tenant\'а
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs(User::factory()->for($this->tenant)->create());
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'manager_id' => $manager->id,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Reports\Providers\BillingSummaryProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
});
|
||||
|
||||
function seedBillingTx(int $tenantId, string $type, float $amount, ?Carbon $at = null): void
|
||||
{
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => $type,
|
||||
'amount_rub' => $amount,
|
||||
'amount_leads' => 0,
|
||||
'description' => 'test',
|
||||
'created_at' => $at ?? Carbon::now()->startOfMonth()->addDays(10),
|
||||
]);
|
||||
}
|
||||
|
||||
function billingJob(int $tenantId): ReportJob
|
||||
{
|
||||
return new ReportJob([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'billing_summary',
|
||||
'parameters' => [
|
||||
'format' => 'csv',
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('headers: 3 колонки', function () {
|
||||
expect((new BillingSummaryProvider)->headers())
|
||||
->toBe(['Тип операции', 'Количество', 'Сумма (₽)']);
|
||||
});
|
||||
|
||||
test('slug = billing', function () {
|
||||
expect((new BillingSummaryProvider)->slug())->toBe('billing');
|
||||
});
|
||||
|
||||
test('агрегирует balance_transactions по типу: count + сумма', function () {
|
||||
seedBillingTx($this->tenant->id, 'topup', 5000);
|
||||
seedBillingTx($this->tenant->id, 'topup', 3000);
|
||||
seedBillingTx($this->tenant->id, 'lead_charge', -250);
|
||||
|
||||
$rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));
|
||||
|
||||
// ORDER BY type → 'lead_charge' < 'topup'.
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[0])->toBe(['Списание за лиды', 1, '-250.00']);
|
||||
expect($rows[1])->toBe(['Пополнение', 2, '8000.00']);
|
||||
});
|
||||
|
||||
test('исключает транзакции вне периода', function () {
|
||||
seedBillingTx($this->tenant->id, 'topup', 5000);
|
||||
seedBillingTx($this->tenant->id, 'topup', 1000, Carbon::now()->subMonths(3));
|
||||
|
||||
$rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0])->toBe(['Пополнение', 1, '5000.00']);
|
||||
});
|
||||
|
||||
test('изолирует по tenant_id', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
seedBillingTx($other->id, 'topup', 9999);
|
||||
|
||||
$rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Reports\Providers\ManagersSummaryProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
});
|
||||
|
||||
/** Вставляет сделку напрямую (deals партиционирована, фабрики нет). */
|
||||
function seedManagerDeal(int $tenantId, int $projectId, array $overrides = []): void
|
||||
{
|
||||
DB::table('deals')->insert(array_merge([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'phone' => '+7999'.random_int(1000000, 9999999),
|
||||
'status' => 'new',
|
||||
'received_at' => Carbon::now()->startOfMonth()->addDays(10),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** ReportJob без сохранения — провайдер читает только tenant_id + parameters. */
|
||||
function managersJob(int $tenantId): ReportJob
|
||||
{
|
||||
return new ReportJob([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'managers_summary',
|
||||
'parameters' => [
|
||||
'format' => 'csv',
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('headers: 4 колонки', function () {
|
||||
expect((new ManagersSummaryProvider)->headers())
|
||||
->toBe(['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)']);
|
||||
});
|
||||
|
||||
test('slug = managers', function () {
|
||||
expect((new ManagersSummaryProvider)->slug())->toBe('managers');
|
||||
});
|
||||
|
||||
test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
|
||||
$manager = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||||
]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0])->toBe(['Иван Петров', 3, 2, 66.7]);
|
||||
});
|
||||
|
||||
test('сделки без менеджера → строка «Не назначен»', function () {
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => null, 'status' => 'new']);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][0])->toBe('Не назначен');
|
||||
});
|
||||
|
||||
test('исключает soft-deleted и тестовые сделки', function () {
|
||||
$manager = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'deleted_at' => Carbon::now()]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'is_test' => true]);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][1])->toBe(1);
|
||||
});
|
||||
|
||||
test('изолирует по tenant_id', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
$otherProject = Project::factory()->create(['tenant_id' => $other->id]);
|
||||
seedManagerDeal($other->id, $otherProject->id);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
function doneReportJob(int $tenantId, int $userId, array $overrides = []): ReportJob
|
||||
{
|
||||
return ReportJob::create(array_merge([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
'expires_at' => Carbon::now()->addDays(30),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function signedDownloadUrl(ReportJob $job, ?Carbon $expiry = null): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'reports.download',
|
||||
$expiry ?? Carbon::now()->addHours(24),
|
||||
['id' => $job->id, 'tenant' => $job->tenant_id],
|
||||
);
|
||||
}
|
||||
|
||||
test('download: success → 200 + attachment файла', function () {
|
||||
$path = "reports/{$this->tenant->id}/1.csv";
|
||||
Storage::disk('local')->put($path, "col\r\nval\r\n");
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$response = $this->get(signedDownloadUrl($job));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload("report-{$job->id}.csv");
|
||||
});
|
||||
|
||||
test('download: невалидная подпись → 403', function () {
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/x/1.csv']);
|
||||
|
||||
$this->get("/api/reports/jobs/{$job->id}/file?tenant={$this->tenant->id}&expires=9999999999&signature=deadbeef")
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('download: просроченная подпись → 403', function () {
|
||||
$path = "reports/{$this->tenant->id}/2.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$this->get(signedDownloadUrl($job, Carbon::now()->subHour()))->assertStatus(403);
|
||||
});
|
||||
|
||||
test('download: file_path=NULL (истёк) → 410', function () {
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => null]);
|
||||
|
||||
$this->get(signedDownloadUrl($job))->assertStatus(410);
|
||||
});
|
||||
|
||||
test('download: файл отсутствует на диске → 404', function () {
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/missing/9.csv']);
|
||||
|
||||
$this->get(signedDownloadUrl($job))->assertStatus(404);
|
||||
});
|
||||
|
||||
test('download: несуществующий job → 404', function () {
|
||||
$ghost = new ReportJob(['tenant_id' => $this->tenant->id]);
|
||||
$ghost->id = 999999;
|
||||
$this->get(signedDownloadUrl($ghost))->assertStatus(404);
|
||||
});
|
||||
|
||||
test('toResource (GET /jobs/{id}): done-job содержит download_url', function () {
|
||||
$path = "reports/{$this->tenant->id}/3.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$url = $response->json('job.download_url');
|
||||
expect($url)->toContain("/api/reports/jobs/{$job->id}/file");
|
||||
expect($url)->toContain('signature=');
|
||||
});
|
||||
|
||||
test('toResource: pending-job → download_url = null', function () {
|
||||
$job = ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'status' => ReportJob::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");
|
||||
|
||||
expect($response->json('job.download_url'))->toBeNull();
|
||||
});
|
||||
|
||||
test('download: expires_at в прошлом → 410 (до cron-очистки file_path)', function () {
|
||||
$path = "reports/{$this->tenant->id}/4.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, [
|
||||
'file_path' => $path,
|
||||
'expires_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->get(signedDownloadUrl($job))->assertStatus(410);
|
||||
});
|
||||
|
||||
test('toResource: expired done-job → download_url = null', function () {
|
||||
$path = "reports/{$this->tenant->id}/5.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, [
|
||||
'file_path' => $path,
|
||||
'expires_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");
|
||||
|
||||
expect($response->json('job.download_url'))->toBeNull();
|
||||
});
|
||||
|
||||
test('download: подмена tenant в signed URL ломает подпись → 403', function () {
|
||||
$path = "reports/{$this->tenant->id}/6.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$url = signedDownloadUrl($job);
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
// Подменяем tenant=<own> на tenant=<other> в готовом signed URL — подпись
|
||||
// покрывает query-параметры, поэтому ValidateSignature вернёт 403.
|
||||
$tampered = str_replace(
|
||||
"tenant={$this->tenant->id}",
|
||||
"tenant={$otherTenant->id}",
|
||||
$url,
|
||||
);
|
||||
|
||||
$this->get($tampered)->assertStatus(403);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateReportJob;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
@@ -336,16 +337,103 @@ test('POST /api/reports/jobs (sync queue): pdf → failed (Post-MVP)', function
|
||||
expect($job->error_message)->toContain('Post-MVP');
|
||||
});
|
||||
|
||||
test('POST /api/reports/jobs (sync queue): managers_summary не реализован → failed', function () {
|
||||
test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', function () {
|
||||
config()->set('queue.default', 'sync');
|
||||
|
||||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$manager = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||||
]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'manager_id' => $manager->id,
|
||||
'phone' => '+79990001122',
|
||||
'status' => 'paid',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/reports/jobs', [
|
||||
'type' => 'managers_summary', 'format' => 'csv',
|
||||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'parameters' => [
|
||||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$job = ReportJob::find($response->json('job.id'));
|
||||
expect($job->status)->toBe('failed');
|
||||
expect($job->error_message)->toContain('Неподдерживаемая');
|
||||
expect($job->status)->toBe('done');
|
||||
expect($job->file_path)->toEndWith('.csv');
|
||||
|
||||
$content = Storage::disk('local')->get($job->file_path);
|
||||
expect($content)->toContain('Менеджер');
|
||||
expect($content)->toContain('Иван Петров');
|
||||
});
|
||||
|
||||
test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', function () {
|
||||
config()->set('queue.default', 'sync');
|
||||
|
||||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '+79990002233',
|
||||
'status' => 'paid',
|
||||
'utm_source' => 'yandex',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/reports/jobs', [
|
||||
'type' => 'sources_summary', 'format' => 'csv',
|
||||
'parameters' => [
|
||||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$job = ReportJob::find($response->json('job.id'));
|
||||
expect($job->status)->toBe('done');
|
||||
expect($job->file_path)->toEndWith('.csv');
|
||||
|
||||
$content = Storage::disk('local')->get($job->file_path);
|
||||
expect($content)->toContain('Источник');
|
||||
expect($content)->toContain('yandex');
|
||||
});
|
||||
|
||||
test('POST /api/reports/jobs (sync queue): billing_summary → done с CSV', function () {
|
||||
config()->set('queue.default', 'sync');
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'type' => 'topup',
|
||||
'amount_rub' => 5000,
|
||||
'amount_leads' => 0,
|
||||
'description' => 'test topup',
|
||||
'created_at' => Carbon::now()->startOfMonth()->addDays(5),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/reports/jobs', [
|
||||
'type' => 'billing_summary', 'format' => 'csv',
|
||||
'parameters' => [
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$job = ReportJob::find($response->json('job.id'));
|
||||
expect($job->status)->toBe('done');
|
||||
expect($job->file_path)->toEndWith('.csv');
|
||||
|
||||
$content = Storage::disk('local')->get($job->file_path);
|
||||
expect($content)->toContain('Тип операции');
|
||||
expect($content)->toContain('Пополнение');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Reports\Providers\SourcesSummaryProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
});
|
||||
|
||||
function seedSourceDeal(int $tenantId, int $projectId, array $overrides = []): void
|
||||
{
|
||||
DB::table('deals')->insert(array_merge([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'phone' => '+7999'.random_int(1000000, 9999999),
|
||||
'status' => 'new',
|
||||
'received_at' => Carbon::now()->startOfMonth()->addDays(10),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function sourcesJob(int $tenantId): ReportJob
|
||||
{
|
||||
return new ReportJob([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'sources_summary',
|
||||
'parameters' => [
|
||||
'format' => 'csv',
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('headers: 4 колонки', function () {
|
||||
expect((new SourcesSummaryProvider)->headers())
|
||||
->toBe(['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)']);
|
||||
});
|
||||
|
||||
test('slug = sources', function () {
|
||||
expect((new SourcesSummaryProvider)->slug())->toBe('sources');
|
||||
});
|
||||
|
||||
test('агрегирует сделки по utm_source', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
// ORDER BY COUNT(*) DESC → yandex (2) первый.
|
||||
expect($rows[0])->toBe(['yandex', 2, 1, 50.0]);
|
||||
expect($rows[1])->toBe(['vk', 1, 1, 100.0]);
|
||||
});
|
||||
|
||||
test('сделки без utm_source → «Прямые / без метки»', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => null]);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][0])->toBe('Прямые / без метки');
|
||||
});
|
||||
|
||||
test('исключает soft-deleted и тестовые сделки', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'deleted_at' => Carbon::now()]);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'is_test' => true]);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][1])->toBe(1);
|
||||
});
|
||||
|
||||
test('изолирует по tenant_id', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
$otherProject = Project::factory()->create(['tenant_id' => $other->id]);
|
||||
seedSourceDeal($other->id, $otherProject->id, ['utm_source' => 'yandex']);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue';
|
||||
import type { ApiAdminBillingTenant } from '../../resources/js/api/admin';
|
||||
|
||||
/**
|
||||
* Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
|
||||
* с нужным `response.data.message`.
|
||||
*/
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), {
|
||||
isAxiosError: true,
|
||||
response: { status, data: { message } },
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return {
|
||||
...orig,
|
||||
listAdminBilling: vi.fn(),
|
||||
listAdminTariffPlans: vi.fn(),
|
||||
updateTenantStatus: vi.fn(),
|
||||
refundTenant: vi.fn(),
|
||||
changeTenantTariff: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeApiBillingTenant(overrides: Partial<ApiAdminBillingTenant> = {}): ApiAdminBillingTenant {
|
||||
return {
|
||||
id: 42,
|
||||
subdomain: 'acme',
|
||||
organization_name: 'Acme ООО',
|
||||
contact_email: 'admin@acme.io',
|
||||
status: 'active',
|
||||
balance_rub: '5000.00',
|
||||
tariff_id: 1,
|
||||
tariff_name: 'Команда',
|
||||
mrr_rub: '990.00',
|
||||
monthly_topups_rub: '10000.00',
|
||||
monthly_charges_rub: '8000.00',
|
||||
last_payment_at: '2026-05-01T10:00:00Z',
|
||||
chargeback_unrecovered_rub: '0.00',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBillingResponse(tenants: ApiAdminBillingTenant[]) {
|
||||
return {
|
||||
tenants,
|
||||
summary: {
|
||||
total_mrr_rub: '990.00',
|
||||
monthly_revenue_rub: '10000.00',
|
||||
overdue_count: 0,
|
||||
refunds_count_30d: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () =>
|
||||
mount(AdminBillingView, {
|
||||
global: { plugins: [createVuetify()] },
|
||||
});
|
||||
|
||||
describe('AdminBillingView — row-actions menu (G4)', () => {
|
||||
it('каждая строка содержит кнопку действий [data-testid="row-actions-{id}"]', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="row-actions-42"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('openAction("status", row) устанавливает actionDialog="status" и actionRow', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(vm.actionDialog).toBe('status');
|
||||
expect(vm.actionRow).toBe(row);
|
||||
expect(vm.actionReason).toBe('');
|
||||
expect(vm.actionError).toBe('');
|
||||
});
|
||||
|
||||
it('confirmAction() вызывает updateTenantStatus и затем loadBilling', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42, status: 'active' })]),
|
||||
);
|
||||
vi.mocked(adminApi.updateTenantStatus).mockResolvedValueOnce({ id: 42, status: 'suspended' });
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
vm.actionReason = 'Основание для блокировки тенанта';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.updateTenantStatus).toHaveBeenCalledWith(42, 'suspended', 'Основание для блокировки тенанта');
|
||||
expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(2); // mount + after action
|
||||
expect(vm.actionDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('confirmAction() вызывает refundTenant с суммой и причиной', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.refundTenant).mockResolvedValueOnce({
|
||||
id: 42,
|
||||
balance_rub: '4500.00',
|
||||
transaction_id: 999,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('refund', row);
|
||||
vm.actionAmount = 500;
|
||||
vm.actionReason = 'Возврат по заявке клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.refundTenant).toHaveBeenCalledWith(42, 500, 'Возврат по заявке клиента');
|
||||
expect(vm.actionDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('openAction("tariff") вызывает listAdminTariffPlans и заполняет tariffPlans', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
|
||||
{ id: 1, name: 'Старт', price_monthly: '490.00' },
|
||||
{ id: 2, name: 'Команда', price_monthly: '990.00' },
|
||||
]);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('tariff', row);
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.listAdminTariffPlans).toHaveBeenCalledTimes(1);
|
||||
expect(vm.tariffPlans).toHaveLength(2);
|
||||
expect(vm.tariffPlans[0].name).toBe('Старт');
|
||||
});
|
||||
|
||||
it('confirmAction("tariff") вызывает changeTenantTariff', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
|
||||
{ id: 2, name: 'Команда', price_monthly: '990.00' },
|
||||
]);
|
||||
vi.mocked(adminApi.changeTenantTariff).mockResolvedValueOnce({
|
||||
id: 42,
|
||||
tariff_id: 2,
|
||||
tariff_name: 'Команда',
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('tariff', row);
|
||||
await flushPromises();
|
||||
|
||||
vm.actionTariffId = 2;
|
||||
vm.actionReason = 'Смена тарифа по просьбе клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.changeTenantTariff).toHaveBeenCalledWith(42, 2, 'Смена тарифа по просьбе клиента');
|
||||
expect(vm.actionDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('API-ошибка в confirmAction — actionError содержит backend-сообщение, диалог остаётся открытым', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.updateTenantStatus).mockRejectedValueOnce(
|
||||
makeAxiosError('Нельзя заблокировать — есть долги'),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
vm.actionReason = 'Причина блокировки тенанта';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
// Dialog stays open, exact backend message surfaces, loading cleared
|
||||
expect(vm.actionDialog).toBe('status');
|
||||
expect(vm.actionError).toBe('Нельзя заблокировать — есть долги');
|
||||
expect(vm.actionLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('openAction("tariff") — ошибка загрузки тарифов устанавливает actionError, диалог остаётся открытым', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.listAdminTariffPlans).mockRejectedValueOnce(
|
||||
makeAxiosError('Тарифы временно недоступны', 503),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('tariff', row);
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.actionDialog).toBe('tariff');
|
||||
expect(vm.actionError).toBe('Тарифы временно недоступны');
|
||||
});
|
||||
|
||||
it('возврат суммы больше баланса → actionError, refundTenant не вызывается', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42, balance_rub: '1000.00' })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0]; // balance_rub = 1000
|
||||
|
||||
await vm.openAction('refund', row);
|
||||
vm.actionAmount = 1500; // exceeds balance
|
||||
vm.actionReason = 'Возврат по заявке клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
|
||||
expect(adminApi.refundTenant).not.toHaveBeenCalled();
|
||||
expect(vm.actionError).toBe('Сумма возврата превышает баланс тенанта.');
|
||||
expect(vm.actionDialog).toBe('refund');
|
||||
});
|
||||
|
||||
it('NaN в сумме возврата → actionError, refundTenant не вызывается', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('refund', row);
|
||||
vm.actionAmount = NaN; // non-numeric input from v-model.number
|
||||
vm.actionReason = 'Возврат по заявке клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
|
||||
expect(adminApi.refundTenant).not.toHaveBeenCalled();
|
||||
expect(vm.actionError).toBeTruthy();
|
||||
expect(vm.actionDialog).toBe('refund');
|
||||
});
|
||||
|
||||
it('короткая причина (<10 символов) → confirmAction ставит actionError, не вызывает API', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
vm.actionReason = 'Коротко'; // < 10 chars
|
||||
|
||||
await vm.confirmAction();
|
||||
|
||||
expect(adminApi.updateTenantStatus).not.toHaveBeenCalled();
|
||||
expect(vm.actionError).toBeTruthy();
|
||||
expect(vm.actionDialog).toBe('status'); // stays open
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AdminIncidentDetailView from '../../resources/js/views/admin/AdminIncidentDetailView.vue';
|
||||
import type { ApiAdminIncidentDetail } from '../../resources/js/api/admin';
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return {
|
||||
...orig,
|
||||
getAdminIncidentDetail: vi.fn(),
|
||||
notifyIncidentRkn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeDetail(overrides: Partial<ApiAdminIncidentDetail> = {}): ApiAdminIncidentDetail {
|
||||
return {
|
||||
id: 7,
|
||||
incident_id: 'INC-2026-0516-0007',
|
||||
type: 'data_breach',
|
||||
severity: 'high',
|
||||
summary: 'Утечка данных тенантов',
|
||||
root_cause: 'Неправильная RLS-политика',
|
||||
postmortem_url: 'https://example.com/postmortem',
|
||||
started_at: '2026-05-16T10:00:00Z',
|
||||
detected_at: '2026-05-16T10:30:00Z',
|
||||
resolved_at: null,
|
||||
status: 'investigating',
|
||||
affected_tenants: [
|
||||
{ id: 1, organization_name: 'Окна Москва ООО' },
|
||||
{ id: 2, organization_name: 'ИП Петров' },
|
||||
],
|
||||
affected_users_count: 42,
|
||||
notification_sent_at: null,
|
||||
rkn_notified: false,
|
||||
rkn_notified_at: null,
|
||||
rkn_deadline_at: '2026-05-17T10:30:00Z',
|
||||
created_by_admin: 'admin@liderra.ru',
|
||||
closed_by_admin: null,
|
||||
created_at: '2026-05-16T10:35:00Z',
|
||||
updated_at: '2026-05-16T10:35:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const buildRouter = (id: number) => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/admin/incidents', name: 'admin-incidents', component: { template: '<div />' } },
|
||||
{
|
||||
path: '/admin/incidents/:id',
|
||||
name: 'admin-incident-detail',
|
||||
component: AdminIncidentDetailView,
|
||||
},
|
||||
],
|
||||
});
|
||||
return router.push({ name: 'admin-incident-detail', params: { id } }).then(() => router);
|
||||
};
|
||||
|
||||
const mountDetail = async (id: number) => {
|
||||
const router = await buildRouter(id);
|
||||
await router.isReady();
|
||||
const wrapper = mount(AdminIncidentDetailView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { teleport: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('AdminIncidentDetailView.vue', () => {
|
||||
it('вызывает getAdminIncidentDetail с id из route и рендерит summary/incident_id/severity', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(adminApi.getAdminIncidentDetail).toHaveBeenCalledWith(7);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('INC-2026-0516-0007');
|
||||
expect(text).toContain('Утечка данных тенантов');
|
||||
expect(text).toContain('High');
|
||||
});
|
||||
|
||||
it('404 от API → data-testid="incident-not-found"', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const wrapper = await mountDetail(999);
|
||||
expect(wrapper.find('[data-testid="incident-not-found"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('500 от API → data-testid="incident-fetch-error" + кнопка Повторить', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Backend error' } },
|
||||
});
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="incident-fetch-error"]').exists()).toBe(true);
|
||||
// retry button calls loadIncident
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
|
||||
const retryBtn = wrapper.find('[data-testid="incident-fetch-error"] button, [data-testid="incident-fetch-error"] .v-btn');
|
||||
expect(retryBtn.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('data_breach + rkn_notified=false → data-testid="rkn-notify-btn" видна', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail({ type: 'data_breach', rkn_notified: false }));
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('клик rkn-notify → confirm → вызывает notifyIncidentRkn, карточка обновляется (rkn_notified=true, кнопка исчезает)', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'data_breach', rkn_notified: false }),
|
||||
);
|
||||
const notified = makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-16T11:00:00Z' });
|
||||
vi.mocked(adminApi.notifyIncidentRkn).mockResolvedValue(notified);
|
||||
|
||||
const wrapper = await mountDetail(7);
|
||||
// open dialog via btn
|
||||
await wrapper.find('[data-testid="rkn-notify-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// call confirmRkn directly via defineExpose
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmRkn: () => Promise<void>;
|
||||
incident: ApiAdminIncidentDetail | null;
|
||||
rknDialog: boolean;
|
||||
};
|
||||
await vm.confirmRkn();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.notifyIncidentRkn).toHaveBeenCalledWith(7);
|
||||
expect(vm.incident?.rkn_notified).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('type !== data_breach → кнопка РКН-notify отсутствует', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'service_outage', rkn_notified: false }),
|
||||
);
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('rkn_notified=true → показывает "РКН уведомлён", кнопки нет', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-17T08:00:00Z' }),
|
||||
);
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('РКН уведомлён');
|
||||
});
|
||||
|
||||
it('ошибка от notifyIncidentRkn → data-testid="rkn-error" виден', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'data_breach', rkn_notified: false }),
|
||||
);
|
||||
vi.mocked(adminApi.notifyIncidentRkn).mockRejectedValue(
|
||||
new Error('РКН endpoint недоступен'),
|
||||
);
|
||||
|
||||
const wrapper = await mountDetail(7);
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmRkn: () => Promise<void>;
|
||||
rknError: string;
|
||||
};
|
||||
await vm.confirmRkn();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="rkn-error"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
@@ -7,23 +7,24 @@ import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsVie
|
||||
const mountView = async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/admin/incidents', component: AdminIncidentsView }],
|
||||
routes: [
|
||||
{ path: '/admin/incidents', name: 'admin-incidents', component: AdminIncidentsView },
|
||||
{ path: '/admin/incidents/:id', name: 'admin-incident-detail', component: { template: '<div />' } },
|
||||
],
|
||||
});
|
||||
await router.push('/admin/incidents');
|
||||
await router.isReady();
|
||||
return mount(AdminIncidentsView, {
|
||||
global: { plugins: [createVuetify(), router] },
|
||||
});
|
||||
return { wrapper: mount(AdminIncidentsView, { global: { plugins: [createVuetify(), router] } }), router };
|
||||
};
|
||||
|
||||
describe('AdminIncidentsView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Инциденты»', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
expect(wrapper.text()).toContain('Инциденты');
|
||||
});
|
||||
|
||||
it('содержит 3 stats: Открыто / Расследуется / РКН-уведомлений', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Открыто');
|
||||
expect(text).toContain('Расследуется');
|
||||
@@ -31,7 +32,7 @@ describe('AdminIncidentsView.vue', () => {
|
||||
});
|
||||
|
||||
it('содержит фильтр-toggle по статусам (5 значений)', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Все');
|
||||
expect(text).toContain('Открыты');
|
||||
@@ -40,16 +41,28 @@ describe('AdminIncidentsView.vue', () => {
|
||||
});
|
||||
|
||||
it('показывает PDN-breach с РКН pending chip', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Утечка ПДн');
|
||||
expect(text).toContain('РКН pending');
|
||||
});
|
||||
|
||||
it('содержит incident_id в формате INC-YYYY-MMDD-NNNN', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('INC-2026-0507-0034');
|
||||
expect(text).toContain('INC-2026-0506-0028');
|
||||
});
|
||||
|
||||
it('клик по строке инцидента вызывает router.push на admin-incident-detail', async () => {
|
||||
const { wrapper, router } = await mountView();
|
||||
const pushSpy = vi.spyOn(router, 'push');
|
||||
// get first row — mock data has id from ADMIN_INCIDENTS[0]
|
||||
const vm = wrapper.vm as unknown as { rowsState: Array<{ id: number }> };
|
||||
const firstId = vm.rowsState[0].id;
|
||||
const row = wrapper.find(`[data-testid="incident-row-${firstId}"]`);
|
||||
expect(row.exists()).toBe(true);
|
||||
await row.trigger('click');
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'admin-incident-detail', params: { id: firstId } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DashboardView from '../../resources/js/views/DashboardView.vue';
|
||||
import type { DashboardSummary } from '../../resources/js/api/dashboard';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
|
||||
describe('DashboardView.vue', () => {
|
||||
const factory = () =>
|
||||
mount(DashboardView, {
|
||||
global: { plugins: [createVuetify()] },
|
||||
});
|
||||
vi.mock('../../resources/js/api/dashboard', () => ({
|
||||
getDashboardSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
it('монтируется и содержит приветствие', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.text()).toContain('Доброе утро');
|
||||
const mockUser: AuthUser = {
|
||||
id: 1,
|
||||
email: 'user@liderra.ru',
|
||||
first_name: 'Иван',
|
||||
last_name: 'Петров',
|
||||
tenant_id: 1,
|
||||
totp_enabled: false,
|
||||
last_login_at: null,
|
||||
};
|
||||
|
||||
const dashboardApi = await import('../../resources/js/api/dashboard');
|
||||
|
||||
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
|
||||
return {
|
||||
range: '7d',
|
||||
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
||||
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () => {
|
||||
setActivePinia(createPinia());
|
||||
useAuthStore().user = mockUser;
|
||||
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
|
||||
it('getDashboardSummary вызывается на mount', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('содержит range-toggle с 4 опциями', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Сегодня');
|
||||
expect(text).toContain('7 дней');
|
||||
expect(text).toContain('30 дней');
|
||||
expect(text).toContain('Период');
|
||||
});
|
||||
|
||||
it('содержит 3 KPI-cards (получено лидов / конверсия / активные проекты)', () => {
|
||||
const wrapper = factory();
|
||||
it('успех — KPI и баланс из API видны', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
|
||||
);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Получено лидов');
|
||||
expect(text).toContain('Конверсия в оплату');
|
||||
expect(text).toContain('Активные проекты');
|
||||
});
|
||||
|
||||
it('содержит balance-card с suммой и runway', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Баланс');
|
||||
expect(text).toContain('14 250');
|
||||
expect(text).toContain('LIVE');
|
||||
expect(text).toContain('хватит на');
|
||||
expect(text).toContain('99 000');
|
||||
expect(wrapper.text()).toContain('12.3%');
|
||||
});
|
||||
|
||||
it('runway-bar содержит 7 сегментов (по числу runwayMax)', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.findAll('.runway-fill')).toHaveLength(7);
|
||||
it('ошибка API — fallback на mock, view не падает', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Получено лидов');
|
||||
expect(wrapper.find('.runway-fill').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="dashboard-fetch-error"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('смена range перезапрашивает summary', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
(wrapper.vm as unknown as { range: string }).range = '30d';
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,23 @@ const mountDeals = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Audit C8/F3: монтирует DealsView по произвольному пути (с query-параметрами). */
|
||||
const mountDealsViewAt = async (path: string) => {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push(path);
|
||||
await router.isReady();
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('DealsView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Сделки»', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
@@ -271,4 +288,33 @@ describe('DealsView.vue', () => {
|
||||
await flushPromises();
|
||||
expect(vm.selected).toEqual([]);
|
||||
});
|
||||
|
||||
// Audit C8/F3: deep-link /deals?openId=
|
||||
it('route.query.openId открывает drawer соответствующей сделки', async () => {
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
|
||||
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
|
||||
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('навигация на /deals?openId= в смонтированном view открывает drawer (watch)', async () => {
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt('/deals');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
await wrapper.vm.$router.push(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,13 +56,20 @@ describe('DealsView — redesigned', () => {
|
||||
});
|
||||
|
||||
describe('FilterChip popovers (Sprint 1 C2)', () => {
|
||||
it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
|
||||
const wrapper = mount(DealsView, {
|
||||
function setupWithRouter() {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
router.push('/deals');
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createPinia(), createVuetify()],
|
||||
plugins: [router, createVuetify()],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
@@ -75,12 +82,7 @@ describe('FilterChip popovers (Sprint 1 C2)', () => {
|
||||
});
|
||||
|
||||
it('clicking Manager chip toggles managerMenuOpen ref to true', async () => {
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createPinia(), createVuetify()],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
@@ -93,12 +95,7 @@ describe('FilterChip popovers (Sprint 1 C2)', () => {
|
||||
});
|
||||
|
||||
it('applying project selection updates filterProjects and closes menu', async () => {
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createPinia(), createVuetify()],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
|
||||
@@ -126,4 +126,17 @@ describe('RemindersView.vue', () => {
|
||||
await factory();
|
||||
expect(remindersApi.listReminders).toHaveBeenCalledWith(expect.objectContaining({ filter: 'today' }));
|
||||
});
|
||||
|
||||
// Audit C8: openDeal deep-links с query openId
|
||||
it('openDeal(42) navigates на /deals?openId=42', async () => {
|
||||
const wrapper = await factory({
|
||||
items: [mockReminder(1)],
|
||||
counts: { active: 1, today: 1, upcoming: 0, overdue: 0 },
|
||||
});
|
||||
const router = wrapper.vm.$router;
|
||||
const pushSpy = vi.spyOn(router, 'push');
|
||||
const vm = wrapper.vm as unknown as { openDeal: (id: number) => Promise<void> };
|
||||
await vm.openDeal(42);
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: '/deals', query: { openId: 42 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ function makeApiJob(overrides: Partial<ApiReportJob> = {}): ApiReportJob {
|
||||
finished_at: new Date(Date.now() - 30_000).toISOString(),
|
||||
expires_at: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
is_expired: false,
|
||||
download_url: 'http://localhost/api/reports/jobs/1/file?tenant=1&signature=fake',
|
||||
retry_count: 0,
|
||||
retry_max: 3,
|
||||
...overrides,
|
||||
@@ -212,6 +213,21 @@ describe('ReportsView.vue (API integration)', () => {
|
||||
expect(dealsCard!.classes()).toContain('active');
|
||||
});
|
||||
|
||||
it('done-job: кнопка «Скачать» имеет href из download_url', async () => {
|
||||
(reportsApi.listReportJobs as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
jobs: [makeApiJob({ status: 'done', download_url: 'http://localhost/dl?signature=xyz' })],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: { pending: 0, processing: 0, done: 1, failed: 0 },
|
||||
quota: { active: 0, max_active: 3 },
|
||||
} satisfies ListReportJobsResponse);
|
||||
const wrapper = await mountView();
|
||||
const dl = wrapper.find('[aria-label="Скачать"]');
|
||||
expect(dl.exists()).toBe(true);
|
||||
expect(dl.attributes('href')).toBe('http://localhost/dl?signature=xyz');
|
||||
});
|
||||
|
||||
it('Reload-btn триггерит manual reload', async () => {
|
||||
const wrapper = await mountView();
|
||||
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
|
||||
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
listAdminIncidents,
|
||||
listSystemSettings,
|
||||
updateSystemSetting,
|
||||
listAdminTariffPlans,
|
||||
updateTenantStatus,
|
||||
refundTenant,
|
||||
changeTenantTariff,
|
||||
getAdminIncidentDetail,
|
||||
notifyIncidentRkn,
|
||||
} from '../../resources/js/api/admin';
|
||||
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
|
||||
|
||||
@@ -353,4 +359,117 @@ describe('api/admin', () => {
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('500 Server Error'));
|
||||
await expect(listAdminTenants({ status: 'active' })).rejects.toThrow('500 Server Error');
|
||||
});
|
||||
|
||||
// === Sprint 3D G4: billing row-actions ===
|
||||
|
||||
it('listAdminTariffPlans() GET /api/admin/billing/tariff-plans + unwraps data.plans', async () => {
|
||||
const plans = [{ id: 1, name: 'Базовый', price_monthly: '990.00' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { plans } });
|
||||
const r = await listAdminTariffPlans();
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/billing/tariff-plans');
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0].name).toBe('Базовый');
|
||||
});
|
||||
|
||||
it('updateTenantStatus() PATCH /api/admin/billing/tenants/{id}/status + ensureCsrfCookie', async () => {
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 5, status: 'suspended' } });
|
||||
const r = await updateTenantStatus(5, 'suspended', 'Нарушение условий');
|
||||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/5/status', {
|
||||
status: 'suspended',
|
||||
reason: 'Нарушение условий',
|
||||
});
|
||||
expect(r.status).toBe('suspended');
|
||||
});
|
||||
|
||||
it('refundTenant() POST /api/admin/billing/tenants/{id}/refund + ensureCsrfCookie', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
data: { id: 3, balance_rub: '5000.00', transaction_id: 101 },
|
||||
});
|
||||
const r = await refundTenant(3, 1000, 'Возврат по заявке');
|
||||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/billing/tenants/3/refund', {
|
||||
amount_rub: 1000,
|
||||
reason: 'Возврат по заявке',
|
||||
});
|
||||
expect(r.transaction_id).toBe(101);
|
||||
});
|
||||
|
||||
it('changeTenantTariff() PATCH /api/admin/billing/tenants/{id}/tariff + ensureCsrfCookie', async () => {
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({
|
||||
data: { id: 2, tariff_id: 3, tariff_name: 'Команда' },
|
||||
});
|
||||
const r = await changeTenantTariff(2, 3, 'Апгрейд по договорённости');
|
||||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/2/tariff', {
|
||||
tariff_id: 3,
|
||||
reason: 'Апгрейд по договорённости',
|
||||
});
|
||||
expect(r.tariff_name).toBe('Команда');
|
||||
});
|
||||
|
||||
// === Sprint 3D G5/G6: incident detail + РКН-notify ===
|
||||
|
||||
it('getAdminIncidentDetail() GET /api/admin/incidents/{id} + unwraps data.incident', async () => {
|
||||
const incident = {
|
||||
id: 7,
|
||||
incident_id: 'INC-2026-0516-0007',
|
||||
type: 'data_breach',
|
||||
severity: 'high',
|
||||
summary: 'Тест',
|
||||
root_cause: null,
|
||||
postmortem_url: null,
|
||||
started_at: '2026-05-16T10:00:00Z',
|
||||
detected_at: '2026-05-16T10:30:00Z',
|
||||
resolved_at: null,
|
||||
status: 'investigating',
|
||||
affected_tenants: [],
|
||||
affected_users_count: null,
|
||||
notification_sent_at: null,
|
||||
rkn_notified: false,
|
||||
rkn_notified_at: null,
|
||||
rkn_deadline_at: null,
|
||||
created_by_admin: null,
|
||||
closed_by_admin: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { incident } });
|
||||
const r = await getAdminIncidentDetail(7);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/incidents/7');
|
||||
expect(r.incident_id).toBe('INC-2026-0516-0007');
|
||||
expect(r.id).toBe(7);
|
||||
});
|
||||
|
||||
it('notifyIncidentRkn() POST /api/admin/incidents/{id}/rkn-notify + ensureCsrfCookie + unwraps data.incident', async () => {
|
||||
const incident = {
|
||||
id: 7,
|
||||
incident_id: 'INC-2026-0516-0007',
|
||||
type: 'data_breach',
|
||||
severity: 'high',
|
||||
summary: 'Тест',
|
||||
root_cause: null,
|
||||
postmortem_url: null,
|
||||
started_at: '2026-05-16T10:00:00Z',
|
||||
detected_at: '2026-05-16T10:30:00Z',
|
||||
resolved_at: null,
|
||||
status: 'investigating',
|
||||
affected_tenants: [],
|
||||
affected_users_count: null,
|
||||
notification_sent_at: '2026-05-16T11:00:00Z',
|
||||
rkn_notified: true,
|
||||
rkn_notified_at: '2026-05-16T11:00:00Z',
|
||||
rkn_deadline_at: '2026-05-17T10:30:00Z',
|
||||
created_by_admin: null,
|
||||
closed_by_admin: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { incident } });
|
||||
const r = await notifyIncidentRkn(7);
|
||||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/incidents/7/rkn-notify', {});
|
||||
expect(r.rkn_notified).toBe(true);
|
||||
expect(r.rkn_notified_at).toBe('2026-05-16T11:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ const FAKE_JOB = {
|
||||
finished_at: null,
|
||||
expires_at: null,
|
||||
is_expired: false,
|
||||
download_url: null,
|
||||
retry_count: 0,
|
||||
retry_max: 3,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeApi(overrides: Partial<ApiReportJob> = {}): ApiReportJob {
|
||||
finished_at: '2026-04-15T10:01:00Z',
|
||||
expires_at: '2026-05-15T10:01:00Z',
|
||||
is_expired: false,
|
||||
download_url: 'http://localhost/api/reports/jobs/1/file?tenant=1&signature=fake',
|
||||
retry_count: 0,
|
||||
retry_max: 3,
|
||||
...overrides,
|
||||
@@ -117,4 +118,13 @@ describe('reportsMapper', () => {
|
||||
expect(mapApiReportJob(makeApi({ status: 'pending' })).progress).toBeNull();
|
||||
expect(mapApiReportJob(makeApi({ status: 'failed' })).progress).toBeNull();
|
||||
});
|
||||
|
||||
it('downloadUrl берётся из api.download_url', () => {
|
||||
const ui = mapApiReportJob(makeApi({ download_url: 'http://x/file?signature=abc' }));
|
||||
expect(ui.downloadUrl).toBe('http://x/file?signature=abc');
|
||||
});
|
||||
|
||||
it('downloadUrl = null если api.download_url null', () => {
|
||||
expect(mapApiReportJob(makeApi({ download_url: null })).downloadUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1267,3 +1267,33 @@ pvalid
|
||||
|
||||
# economy 5% level — spec/plan (2026-05-16)
|
||||
коммитятся
|
||||
|
||||
# ruflo hierarchy factual recollage spec (2026-05-16) — Russian inflections
|
||||
реколлажирована
|
||||
реколлажем
|
||||
фоллбэк
|
||||
буллет
|
||||
|
||||
# ruflo hierarchy recollage — EN term из имени spec-файла (Pravila §10 row)
|
||||
recollage
|
||||
|
||||
# ruflo hierarchy recollage plan (2026-05-16) — Russian IT-slang + abbreviations
|
||||
буллеты
|
||||
рассинхрон
|
||||
реколлажа
|
||||
реколлаже
|
||||
дотронул
|
||||
пуш
|
||||
изм
|
||||
рерайтов
|
||||
|
||||
# Sprint 3F — API middleware J1/J2 plan (2026-05-16) — Russian IT-slang
|
||||
роутов
|
||||
стейджить
|
||||
фронтенд
|
||||
стаб
|
||||
гейт
|
||||
гвард
|
||||
гварда
|
||||
вестигиальным
|
||||
спеков
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.1)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.2)
|
||||
|
||||
**Дата:** 15.05.2026 (afternoon)
|
||||
**Дата:** 16.05.2026
|
||||
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3).
|
||||
|
||||
**v3.2** — реколлаж R0: sub-policy → top-of-stack gate (ruflo не entry-point по факту рантайма: 0 задач, рой idle). R0 title восстановлен, уровень −1 убран из R0.1 таблицы, R0.2 абзац перед gate-диаграммой возвращён к stack-gate формулировке. Связано: Pravila v1.16, CLAUDE.md v2.2, Tooling v2.2.
|
||||
|
||||
**v3.1** — sync: cross-ref на Pravila v1.15 (новый §14 Ruflo Queen routing hard-rule — триггер queen/королева → безусловный route через ruflo Queen). Содержательных изменений R0–R14: 0.
|
||||
|
||||
**v3.0** — major: R0 stack-gate → sub-policy paired-stack delegation pattern под ruflo Queen-led routing. R0.1 +ruflo level −1; R0.6 +п.11 swarm-pause-without-review (sequential continuation после v2.0 R15 removal). Связано: ruflo v3.7.0-alpha.38 big-bang integration 2026-05-15.
|
||||
|
||||
Снимает запрет CLAUDE.md §5 п.5 на Frontend Design plugin (действовал до v1.77 включительно). Документ — внутренне непротиворечив: 8 первичных конфликтов закрыты в v1.0 + 5 патчей по реальным трениям A–E в v1.1 + 4 новых правила R10–R13 против перекрытий с другими плагинами в v1.2 + 6 уточняющих патчей F–K по найденным трениям второго порядка в v1.3 + 1 новое правило R14 (pipeline внешних UI-генераторов: UPM + 21st Magic MCP) в v1.4 (R15 motion-системы введены в v1.4 и удалены в v2.0) + 5 структурных правок аудита в v1.5 (R10.1 разбит на 3 блока, R0.4.A SoT cross-ref, R10.4/R14.7 tier-метки, R8 +тай-брейкер FD↔21st, R0.1 scope-метка) + 3 правки второго аудита в v1.6 (R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT — устраняет дрейф формулировок; R0.6 hard-стопы пронумерованы 1–11 для надёжности cross-refs «пункт 9/10/11»; R0.1 +Tooling Прил. Н slot уровня 2b) + 1 правка третьего аудита в v1.7 (sync cross-refs на актуальные версии связанных документов после bump'ов CLAUDE.md v1.86 / Tooling v1.15; description-fix описки «уровня 2.5»→«уровня 2b» внутри changelog'а v1.6, сверка с фактическим R0.1) + 1 правка четвёртого аудита в v2.0 (12.05.2026 — снят R15 motion-runtime по решению заказчика; conscious rollback v1.4 audited construction; framer-motion переведён из regulatory hard-запрета в technical-guidance уровень: peerDep на react+react-dom, не работает в Vue физически) + 2 строки R10.1 Блок 3 в v2.1 (13.05.2026 day +1 — debug-runtime MCP формализованы retrospective после PR #3 merge) + R0 major rewrite в v3.0 (stack-gate → sub-policy под ruflo Queen-led routing, 15.05.2026 afternoon).
|
||||
Снимает запрет CLAUDE.md §5 п.5 на Frontend Design plugin (действовал до v1.77 включительно). Документ — внутренне непротиворечив: 8 первичных конфликтов закрыты в v1.0 + 5 патчей по реальным трениям A–E в v1.1 + 4 новых правила R10–R13 против перекрытий с другими плагинами в v1.2 + 6 уточняющих патчей F–K по найденным трениям второго порядка в v1.3 + 1 новое правило R14 (pipeline внешних UI-генераторов: UPM + 21st Magic MCP) в v1.4 (R15 motion-системы введены в v1.4 и удалены в v2.0) + 5 структурных правок аудита в v1.5 (R10.1 разбит на 3 блока, R0.4.A SoT cross-ref, R10.4/R14.7 tier-метки, R8 +тай-брейкер FD↔21st, R0.1 scope-метка) + 3 правки второго аудита в v1.6 (R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT — устраняет дрейф формулировок; R0.6 hard-стопы пронумерованы 1–11 для надёжности cross-refs «пункт 9/10/11»; R0.1 +Tooling Прил. Н slot уровня 2b) + 1 правка третьего аудита в v1.7 (sync cross-refs на актуальные версии связанных документов после bump'ов CLAUDE.md v1.86 / Tooling v1.15; description-fix описки «уровня 2.5»→«уровня 2b» внутри changelog'а v1.6, сверка с фактическим R0.1) + 1 правка четвёртого аудита в v2.0 (12.05.2026 — снят R15 motion-runtime по решению заказчика; conscious rollback v1.4 audited construction; framer-motion переведён из regulatory hard-запрета в technical-guidance уровень: peerDep на react+react-dom, не работает в Vue физически) + 2 строки R10.1 Блок 3 в v2.1 (13.05.2026 day +1 — debug-runtime MCP формализованы retrospective после PR #3 merge) + R0 major rewrite в v3.0 (stack-gate → sub-policy под ruflo Queen-led routing, 15.05.2026 afternoon) + R0 реколлаж в v3.2 (sub-policy → top-of-stack gate — ruflo не entry-point по факту рантайма, 16.05.2026).
|
||||
|
||||
**Принцип-аксиома (v1.2, уточнён в v1.3, расширен в v1.4, переформулирован под ruflo в v3.0):** **Stack (Superpowers + Frontend Design) — головной при решении задач, маршрутизированных в paired-stack sub-policy через ruflo Queen-led routing** (entry-point уровня −1, v3.0+) в части плагинов и поведенческих слоёв. Stack-gate (R0) — **первая точка входа в paired-stack sub-policy** (бывший top-level entry-point до v3.0). Все остальные плагины (ui-ux-pro-max, 21st Magic MCP, claude-md-management, review, security-review, init, simplify и др.) — **инструменты**, инвокируемые **внутри** stack-flow как подзадачи, не как альтернативы или параллельные решатели. Stack **исполняет** Pravila/CLAUDE.md, а не перебивает их (см. R0.1 для точного scope «головенства»). Другие плагины могут получить работу только по делегированию из stack'а или по явному `/имя-плагина` от пользователя.
|
||||
**Принцип-аксиома (v1.2, уточнён в v1.3, расширен в v1.4, актуализирован в v3.2):** **Stack (Superpowers + Frontend Design) — головной** над не-stack плагинами и поведенческими слоями в части плагинов и поведенческих слоёв. Stack-gate (R0) — **первая точка входа** (top-level entry-point среди уровней 3–6). Все остальные плагины (ui-ux-pro-max, 21st Magic MCP, claude-md-management, review, security-review, init, simplify и др.) — **инструменты**, инвокируемые **внутри** stack-flow как подзадачи, не как альтернативы или параллельные решатели. Stack **исполняет** Pravila/CLAUDE.md, а не перебивает их (см. R0.1 для точного scope «головенства»). Другие плагины могут получить работу только по делегированию из stack'а или по явному `/имя-плагина` от пользователя.
|
||||
|
||||
**Связанные документы:**
|
||||
|
||||
- [CLAUDE.md](../CLAUDE.md) **v2.0+** — оперативная карта (ruflo big-bang integration); §1 priority chain, §3.3 строка #33, §5 п.5 ссылается на этот документ (расширенный пул UI-инструментов: FD + UPM + 21st), §5 п.11 cross-ref на Pravila §12.3 SoT, §5 п.12 — резерв (R15 motion-runtime снят в v2.0)
|
||||
- [docs/Pravila_raboty_Claude_v1_1.md](Pravila_raboty_Claude_v1_1.md) **v1.15+** — §12 → sub-policy под ruflo Queen-led routing (v1.14, commit 9c3057b); §14 (new, v1.15) Ruflo Queen routing hard-rule — триггер queen/королева; §12.3 SoT для exclusions, §13 «paired stack + расширенный пул UI-инструментов» + claude-md-management как off-pool, §13.6 hard-rule tier-таблица (§12 + §14 explicit), §13.9/§13.10 hard-link на R10/R14
|
||||
- [docs/Tooling_v8_3.md](Tooling_v8_3.md) **v2.0+** — реестр инструментов (+§4.10 Orchestration layer); #30 Frontend Design + #31 UPM + #32 21st Magic MCP + #33 claude-md-management + §6 +5 конфликтов v1.4 (строка «framer-motion ↔ motion-v» — historical после v2.0) + §7 7-уровневая иерархия (с v1.14 +Tooling explicit slot 2b) + §9.2 motion-runtime guidance (technical-only после v1.16); §10.3 шаг 2 sync (14 skills) с v1.14
|
||||
- [CLAUDE.md](../CLAUDE.md) **v2.2+** — оперативная карта; §1 priority chain, §3.3 строка #33, §5 п.5 ссылается на этот документ (расширенный пул UI-инструментов: FD + UPM + 21st), §5 п.11 cross-ref на Pravila §12.3 SoT, §5 п.12 — резерв (R15 motion-runtime снят в v2.0)
|
||||
- [docs/Pravila_raboty_Claude_v1_1.md](Pravila_raboty_Claude_v1_1.md) **v1.16+** — §12 Superpowers hard rule; §14 Ruflo Queen routing hard-rule (триггер queen/королева); §12.3 SoT для exclusions, §13 «paired stack + расширенный пул UI-инструментов» + claude-md-management как off-pool, §13.6 hard-rule tier-таблица (§12 + §14 explicit), §13.9/§13.10 hard-link на R10/R14
|
||||
- [docs/Tooling_v8_3.md](Tooling_v8_3.md) **v2.2+** — реестр инструментов; #30 Frontend Design + #31 UPM + #32 21st Magic MCP + #33 claude-md-management + §6 +5 конфликтов v1.4 (строка «framer-motion ↔ motion-v» — historical после v2.0) + §7 7-уровневая иерархия (с v1.14 +Tooling explicit slot 2b) + §9.2 motion-runtime guidance (technical-only после v1.16); §10.3 шаг 2 sync (14 skills) с v1.14
|
||||
|
||||
> **Техническая особенность Claude Code:** при первой установке Frontend Design plugin в долгой сессии плагин не появляется в системном списке доступных skills до старта **новой** сессии (новый чат, не reload). Это конструктивная особенность Claude Code (skill list = constant per conversation), не правило и не баг. Файлы плагина доступны на диске сразу, инвокация через `Skill` tool — только в новом чате.
|
||||
|
||||
---
|
||||
|
||||
## Правило 0 — Sub-policy: paired-stack delegation pattern (под ruflo Queen-led routing)
|
||||
## Правило 0 — Stack-gate: paired-stack delegation pattern
|
||||
|
||||
### 0.1. Уровень и головенство
|
||||
|
||||
@@ -33,12 +35,11 @@ Superpowers и Frontend Design — **парный stack одного приор
|
||||
|
||||
| Уровень | Что | Stack головной над этим? |
|
||||
|---|---|---|
|
||||
| **−1** | **ruflo Queen-led routing (entry-point, v3.0+)** | ✅ stack делегирует ruflo — paired-stack pattern становится sub-policy под ruflo routing |
|
||||
| 0 | Pravila §12 (Superpowers hard rule) | ❌ нет — это часть stack'а же, hard rule сильнее |
|
||||
| 1 | Pravila (§§1–13) | ❌ нет — stack **исполняет** Pravila, не перебивает |
|
||||
| 2a | CLAUDE.md (общая оперативная карта) | ❌ нет — stack **исполняет** CLAUDE.md, не перебивает |
|
||||
| 2b | **Tooling Прил. Н** (детальный реестр инструментов) — *добавлен v1.6* | ❌ нет — stack **исполняет** Tooling, не перебивает (alongside CLAUDE.md, оба operational maps; при прямом конфликте между ними — приоритет CLAUDE.md как корневой карты) |
|
||||
| 3 | **PSR_v1 (этот документ)** | sub-policy ruflo routing (paired-stack delegation pattern активируется через ruflo routing-decision) |
|
||||
| 3 | **PSR_v1 (этот документ)** | — (PSR_v1 — сам stack-документ, вопрос неприменим) |
|
||||
| 4 | settings.json (поведенческая часть) | ✅ да |
|
||||
| 5 | memory/*.md | ✅ да |
|
||||
| 6 | прочие плагины (ui-ux-pro-max, review, simplify, …) | ✅ да |
|
||||
@@ -56,7 +57,7 @@ Stack никогда не уступает первенство **уровням
|
||||
|
||||
### 0.2. Обязательный gate
|
||||
|
||||
Каждая задача проходит через ruflo Queen-led routing первой; ruflo либо делегирует в paired-stack sub-policy (Superpowers+Frontend Design + UI/infrastructure/debug-runtime pool), либо handles напрямую через swarm (Architect/Coder/Security/QA/etc.). Stack-gate активируется как sub-policy через ruflo routing-decision, не как top-level entry-point.
|
||||
Каждая задача проходит через stack-gate первой среди слоёв 3–6. Gate всегда обращается к обоим плагинам paired-stack (Superpowers + Frontend Design); какой получает работу — определяет Правило 1.
|
||||
|
||||
```
|
||||
Любой вход (запрос / /команда / follow-up / course-correction)
|
||||
@@ -152,7 +153,6 @@ Stack стоит **ниже** в приоритете, чем:
|
||||
8. Изменение 14 OKLCH-статусов воронки или их slug-маппинга на UI.
|
||||
9. **[v1.4]** Использование 21st Magic MCP (`mcp__magic__21st_magic_component_*`) для генерации компонента из брендового семейства App* (`AppButton`, `AppCard`, `AppDataTable`, `AppDialog` и любых других с префиксом `App` в `resources/js/components/`). Брендовые компоненты проходят полный R12 архитектурный flow.
|
||||
10. **[v1.4]** Использование 21st для генерации компонента, для которого **уже есть Vuetify-эквивалент** (`v-data-table`, `v-text-field`, `v-dialog`, `v-select`, `v-card`, `v-btn` и т.д.) **или существующий компонент** в `resources/js/components/`. Это дублирование стека (нарушение CLAUDE.md §5 п.6 «не два инструмента на одну задачу»).
|
||||
11. **[v3.0]** ruflo Queen routes a task as «autonomous swarm», но human absent для review — pause до human review (даже если ruflo confidence высокий). Override: explicit live-команда от пользователя.
|
||||
|
||||
В этих ситуациях — обязательный стоп + вопрос пользователю, без попытки «middle confidence» обойти. Auto mode не отменяет hard-стоп.
|
||||
|
||||
@@ -590,7 +590,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
### 13.1. Принципы matrix'а
|
||||
|
||||
1. **§12 Pravila применяется только когда задача не вышла в R0.4.A.** Если задача — read-only/тривиальная/справочная, §12 не релевантен (это §12.3 exclusions Pravila).
|
||||
2. **R0.6 hard-стоп — неотменяемый, перекрывает Auto mode.** Никакие «средняя confidence + Auto» не обходят 8 пунктов R0.6.
|
||||
2. **R0.6 hard-стоп — неотменяемый, перекрывает Auto mode.** Никакие «средняя confidence + Auto» не обходят 10 пунктов R0.6.
|
||||
3. **«Высокая уверенность + Auto»** — это разрешение действовать без вопроса, но **не** разрешение пропустить §12 (Superpowers первым) или R6 (стек-фильтр FD) — они применяются автоматически в фоне.
|
||||
4. **«Средняя уверенность» в Auto mode** — фиксирую предположение **одной строкой** перед действием. Не лекция, не вопрос — короткая ремарка для пользователя.
|
||||
|
||||
@@ -747,6 +747,8 @@ Pipeline активируется при одновременном выполн
|
||||
|
||||
## История версий
|
||||
|
||||
- **v3.2 (2026-05-16)** — реколлаж R0: sub-policy → top-of-stack gate (ruflo не entry-point по факту рантайма: 0 задач, рой idle). **Изменено:** R0 title → «Stack-gate: paired-stack delegation pattern»; R0.1 таблица — удалена строка уровня −1 (ruflo entry-point), строка уровня 3 (PSR_v1) → «— (PSR_v1 — сам stack-документ, вопрос неприменим)»; R0.1 преамбула — убраны формулировки sub-policy-под-ruflo, stack снова головной над уровнями 4–6; R0.2 абзац перед диаграммой — возвращён к stack-gate формулировке; шапка cross-refs: CLAUDE.md v2.0+ → v2.2+, Pravila v1.15+ → v1.16+, Tooling v2.0+ → v2.2+. ASCII-диаграмма (STACK GATE) и R0.5 не тронуты. **R0.6 п.11 удалён** (ruflo autonomous-routing hard-stop — висячая ссылка на ruflo как маршрутизатор задач; противоречит реколлажу: ruflo не entry-point, рой idle, 0 задач). Связано: Pravila v1.16 / CLAUDE.md v2.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`.
|
||||
|
||||
- **v3.0 (2026-05-15)** — major: R0 stack-gate → sub-policy paired-stack delegation pattern под ruflo Queen-led routing. R0.1 +ruflo level −1; R0.2 entry-point shifted ruflo→stack-gate-as-sub-policy; R0.6 +п.11 swarm-pause-without-review (sequential continuation после v2.0 R15 removal, не литерал п.12 как в спеке). Связано: Pravila v1.14 (commit 9c3057b), CLAUDE.md v2.0 (commit 5df88a1), Tooling v2.0 (commit f65a8d7), ruflo v3.7.0-alpha.38 integration via spec/plan 2026-05-15 (commits e55572e/18c4463/9bd1bae).
|
||||
|
||||
- **v2.1 от 13.05.2026 (day +1)** — формализация retrospective двух off-phase **debug-runtime MCP** серверов установленных на feat/claude-automation (commits `6f7e7d7` sentry, `bd4ec48` redis), merged в main через PR #3 (`cc5f63b`).
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.15 (утверждена заказчиком 15.05.2026)
|
||||
**Дата:** 15.05.2026
|
||||
**Версия:** v1.16 (утверждена заказчиком 16.05.2026)
|
||||
**Дата:** 16.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.16 относительно v1.15:** реколлаж ruflo к фактическому рантайму: §12 sub-policy → hard-rule (title + абзацы), §12.4 первый буллет → «§9 не применяется», §0 priority note убран ruflo уровень −1 (цепочка начинается с §12 explicit hard-rule), §14.6 cross-ref убран «ruflo — уровень −1» → «ruflo как инструмент (хук + MCP), не уровень иерархии», §13.9/§13.10 PSR_v1 cross-refs «v3.0+, R0 → sub-policy» → «v3.2+, R0 — top-of-stack gate». Связано: CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`.
|
||||
|
||||
**Краткое резюме v1.15:** новый §14 «Ruflo Queen routing — hard rule» (триггер queen/королева → безусловный route через ruflo Queen) + §13.6 tier-таблица +строка §14 + §0 priority note.
|
||||
|
||||
**Краткое резюме v1.14:** §12 hard rule → sub-policy + §5 ПДн execution-layer note + cross-refs к v3.0/v2.0/v2.0 (PSR_v1 / CLAUDE.md / Tooling).
|
||||
@@ -127,7 +129,7 @@
|
||||
|
||||
Это **внутренние правила Claude**, не процессные правила команды. Документ написан для одного читателя — Claude. Заказчик согласовывает содержание; команды/действия не требуются.
|
||||
|
||||
Приоритет правил при конфликте (с v1.14 — sub-policy под ruflo Queen-led routing): **§12 (Superpowers — sub-policy, routing preference для interactive turns)** → **§14 (Ruflo Queen routing — explicit hard-rule, триггер queen/королева)** → §1 (роль) → §2 (что Claude делает сам / спрашивает / не делает) → §3 (формат ответов) → §4 (документация и версии) → §5 (безопасность и ПДн — execution-layer gitleaks compensator работает выше ruflo routing) → §6 (Claude в Chrome) → §7 (открытые вопросы) → §8 (рутины сессии) → §9 (отступления — формально применяется через ruflo routing layer, см. §12.4) → **§11 (Superpowers override §2.2/§4.5/§8.4 при явном вызове)** → **§13 (Frontend Design plugin — paired stack, координация через Plugin_stack_rules_v1 v3.0+)**.
|
||||
Приоритет правил при конфликте: **§12 (Superpowers — explicit hard-rule, инвокация skills первой)** → **§14 (Ruflo Queen routing — explicit hard-rule, триггер queen/королева)** → §1 (роль) → §2 (что Claude делает сам / спрашивает / не делает) → §3 (формат ответов) → §4 (документация и версии) → §5 (безопасность и ПДн) → §6 (Claude в Chrome) → §7 (открытые вопросы) → §8 (рутины сессии) → §9 (отступления) → **§11 (Superpowers override §2.2/§4.5/§8.4 при явном вызове)** → **§13 (Frontend Design plugin — paired stack, координация через Plugin_stack_rules_v1 v3.2+)**.
|
||||
|
||||
> **§11 локальное override-исключение из цепочки (v1.10+):** §11 формально стоит ПОСЛЕ §9 в основной цепочке выше, но при **явном вызове skill'а Superpowers** §11 **локально поднимается выше §2.2/§4.5/§8.4** в этих узлах (см. §11.1 — «приоритет skill'а над §2.2 явное согласование, §4.5 паттерн 3 варианта, §8.4 защита от компакции»). То есть основная цепочка определяет приоритет в общем случае; §11 — точечное override 3 параграфов при триггере skill-инвокации. Это НЕ меняет позицию §11 относительно §1, §3, §5, §6, §7, §10, §12 — там §11 остаётся ниже. Аналогично §13 — расширение через PSR_v1 (paired stack + UI-пул), не override Pravila.
|
||||
>
|
||||
@@ -139,7 +141,7 @@
|
||||
>
|
||||
> При вопросе «приоритет какого правила?» — сначала смотреть **CLAUDE.md §1** (какой файл/слой главный), затем при равенстве — внутрипараграфные приоритеты документа-победителя.
|
||||
|
||||
**Особый статус §12 (с v1.14):** §12 — **sub-policy под ruflo Queen-led routing** (раньше — единственное explicit hard-правило документа в v1.4–v1.13). §9 «Когда Claude отступает» формально применяется через ruflo routing layer (см. §12.4). Дополнительно §13.9 и §13.10 — **transitive** routing-link на нарушения PSR_v1 R10/R14 (см. §13.6 tier-таблицу); их соответствующая жёсткость аналогично подчинена ruflo routing-layer с v1.14. **§14 (с v1.15)** — второе explicit hard-rule документа: триггер queen/королева → безусловный route через ruflo Queen; §9 к §14 не применяется (§14.5). §12 и §14 не конфликтуют — они на разных слоях (§14.6: §12 — слой дисциплины исполнения, §14 — слой маршрутизации); порядок «§12 → §14» в priority chain выше отражает текстовую нумерацию, не иерархию приоритета.
|
||||
**Особый статус §12 и §14:** §12 — **explicit hard-rule** (единственное в v1.4–v1.13; с v1.15 — два explicit hard-rule: §12 + §14). §9 «Когда Claude отступает» к §12 **не применяется** (§12.4). Дополнительно §13.9 и §13.10 — **transitive hard-rule** через hard-link на нарушения PSR_v1 R10/R14 (см. §13.6 tier-таблицу). **§14 (с v1.15)** — второе explicit hard-rule документа: триггер queen/королева → безусловный route через ruflo Queen; §9 к §14 не применяется (§14.5). §12 и §14 не конфликтуют — они на разных слоях (§14.6: §12 — слой дисциплины исполнения, §14 — слой маршрутизации); порядок «§12 → §14» в priority chain выше отражает текстовую нумерацию, не иерархию приоритета.
|
||||
|
||||
---
|
||||
|
||||
@@ -552,6 +554,7 @@ P0 = блокер старта спринта или регуляторного
|
||||
| **v1.11** | **12.05.2026** | **Утверждена заказчиком 12.05.2026** («сними все запреты на использование framer motion» → через `superpowers:brainstorming` → 3 варианта → выбран B полная отмена R15). Sync-уровень после PSR_v1 v1.7 → v2.0 (R15 motion-системы удалены целиком): **§11.5** «v1.6, 16 правил R0–R15» → «v2.0, 15 правил R0–R14»; **§13.2** «v1.6 (16 правил R0–R15)» → «v2.0 (15 правил R0–R14)»; **§13.9** PSR_v1 (v1.6) → (v2.0); **§13.10** PSR_v1 (v1.6) → (v2.0), **содержание §13.10 сохранено** — оно про hard-link на R14 (UPM/21st pipeline), не на R15. Связанные обновления — PSR_v1 v1.7 → v2.0 (R15 целиком + R0.6 п.11 + R8 motion + R11.6 + R13 motion сценарии удалены; framer-motion переведён из regulatory hard-запрета в technical block — это peerDep react+react-dom, не работает в Vue физически), CLAUDE.md v1.87 → v1.88 (§5 п.12 → резерв-маркер; §2 Animation default stack → guidance, не hard-rule), Tooling v1.15 → v1.16 (§9.2 reformulated в technical guidance). Conscious rollback v1.4 audited construction (10.05.2026, R15 двухуровневая motion-конструкция). Через `superpowers:brainstorming` → `superpowers:writing-plans` → `superpowers:executing-plans` + `/claude-md-management:claude-md-improver` + ручные Edit. Архитектурных изменений в §§1–13 (кроме §11.5/§13.2/§13.9/§13.10 sync): 0. |
|
||||
| **v1.14** | **15.05.2026** | Ruflo big-bang sub-policy conversion: §12 hard rule → sub-policy (ruflo routing preference); §5 ПДн +execution-layer note; cross-refs к PSR_v1 v3.0 / CLAUDE.md v2.0 / Tooling v2.0. Связано: ruflo v3.7.0-alpha.38 integration via spec/plan 2026-05-15 (commits e55572e/18c4463/9bd1bae). v1.13 наследие — Task 9 sync after PR #3 (Sentry+Redis MCP). |
|
||||
| **v1.15** | **15.05.2026** | Новый §14 «Ruflo Queen routing — hard rule»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), enforcement-хук `tools/ruflo-queen-hook.mjs`. §13.6 tier-таблица +строка §14 (explicit hard-rule). §0 priority chain +§14 +note. §14.3 — проактивное предложение ruflo-spawn на нетривиальных задачах. Связано: spec/plan 2026-05-15-ruflo-queen-trigger-and-delegation, CLAUDE.md v2.1, PSR_v1 v3.1, Tooling v2.1. Через `superpowers:brainstorming` → `writing-plans` → `subagent-driven-development`. |
|
||||
| **v1.16** | **16.05.2026** | Реколлаж ruflo — приведение декларации к фактическому рантайму: §12 Superpowers переведён из sub-policy обратно в explicit hard-rule; §0 priority note и §14.6 cross-ref — убраны упоминания ruflo как «уровня −1»; §11.5/§13.2/§13.9/§13.10 cross-refs на PSR_v1 v3.2. Связано: CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -584,15 +587,15 @@ P0 = блокер старта спринта или регуляторного
|
||||
|
||||
### 11.5. Координация с Frontend Design plugin
|
||||
|
||||
С v1.5 (09.05.2026) Superpowers — часть paired stack'а с `anthropics/frontend-design` (см. §13). Координация двух плагинов — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (**v3.0+, R0 → sub-policy под ruflo Queen-led routing**; ruflo big-bang integration 15.05.2026). На UI-фичах оба плагина работают по фазам Правила 2 Plugin_stack_rules_v1; на чисто процессных задачах Frontend Design не активируется.
|
||||
С v1.5 (09.05.2026) Superpowers — часть paired stack'а с `anthropics/frontend-design` (см. §13). Координация двух плагинов — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (**v3.2+, R0 — top-of-stack gate**). На UI-фичах оба плагина работают по фазам Правила 2 Plugin_stack_rules_v1; на чисто процессных задачах Frontend Design не активируется.
|
||||
|
||||
---
|
||||
|
||||
## 12. Superpowers — sub-policy (ruflo routing preference)
|
||||
## 12. Superpowers — hard rule (инвокация skills первой)
|
||||
|
||||
Введено 09.05.2026 на явное требование заказчика: **«Создай правило, что ты всегда в первую очередь пользуешься superpowers. При этом ты не можешь игнорировать и обходить это правило.»** Переведено из hard rule в sub-policy 15.05.2026 (v1.14) в рамках ruflo big-bang integration.
|
||||
Введено 09.05.2026 на явное требование заказчика: **«Создай правило, что ты всегда в первую очередь пользуешься superpowers. При этом ты не можешь игнорировать и обходить это правило.»**
|
||||
|
||||
С v1.14 (15.05.2026) §12 — **sub-policy под ruflo Queen-led routing**: prefer Superpowers skills для interactive turns; не absolute block. ruflo может delegate non-Superpowers tools when routing-pattern matches non-interactive criteria. Карта §12.2 (14 типов задач), §12.3 (exclusions SoT) и §12.4 (детали) остаются в силе; меняется только framing — из «hard rule, неотменяемое» в «routing preference на уровне interactive turns».
|
||||
§12 — **explicit hard-rule**: перед содержательной задачей соответствующий Superpowers-skill (карта §12.2) инвокируется первым. §9 «Отступления» к §12 не применяется (§12.4). Карта §12.2, exclusions §12.3 и детали §12.4 — в силе.
|
||||
|
||||
### 12.1. Принцип
|
||||
|
||||
@@ -634,7 +637,7 @@ P0 = блокер старта спринта или регуляторного
|
||||
|
||||
### 12.4. Hard-rule статус
|
||||
|
||||
- ruflo Queen может delegate non-Superpowers tools, если routing-pattern matches non-interactive criteria; §9 «Отступления» формально применяется через ruflo routing layer.
|
||||
- §9 «Отступления» к §12 **не применяется** — §12 explicit hard-rule. Единственная отмена — явный запрос заказчика «не используй superpowers сейчас», только на текущее действие.
|
||||
- §12 имеет приоритет над §1–§11. Это значит, что даже когда §1 (роль) или §11 (override) предписывают определённое поведение, §12 срабатывает раньше — skill инвокируется первым.
|
||||
- Запрос заказчика «не используй superpowers сейчас» — единственная разрешённая отмена правила, и **только** для текущего действия. В следующем действии §12 действует автоматически.
|
||||
- Игнорирование §12 (выбор обычного подхода когда skill доступен) — нарушение того же уровня, что игнорирование §5 (ПДн).
|
||||
@@ -667,7 +670,7 @@ P0 = блокер старта спринта или регуляторного
|
||||
|
||||
### 13.2. Парность со Superpowers + расширенный пул UI-инструментов (v1.8)
|
||||
|
||||
Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный stack одного приоритетного уровня**. Оба плагина подключены к gate stack'а одновременно, между ними нет иерархии. Координация — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) **v3.0+ (R0 → sub-policy под ruflo Queen-led routing; ruflo big-bang integration 15.05.2026; полный детализированный реестр правил в PSR_v1 после ruflo рерайта)**.
|
||||
Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный stack одного приоритетного уровня**. Оба плагина подключены к gate stack'а одновременно, между ними нет иерархии. Координация — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) **v3.2+ (R0 — top-of-stack gate; ruflo big-bang 15.05.2026 + реколлаж 16.05.2026; полный детализированный реестр правил в PSR_v1)**.
|
||||
|
||||
**Расширенный пул UI-инструментов (v1.8)** добавляет к paired-stack ядру два внешних плагина в роли **инструментов** (R10.1 PSR_v1, не решателей):
|
||||
|
||||
@@ -735,7 +738,7 @@ Frontend Design покрывает **a11y-принципы** (контраст,
|
||||
|
||||
### 13.9. Hard-link на R10 PSR_v1 — байпас stack-gate
|
||||
|
||||
**Нарушение Правила 10 [Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (v3.0+, R0 → sub-policy под ruflo Queen-led routing)** (введено в PSR v1.2; формализовано через hard-link в Pravila v1.6, версия ссылки уточнена в Pravila v1.7, обновлена в Pravila v1.8/v1.10/v1.11/v1.14):
|
||||
**Нарушение Правила 10 [Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (v3.2+, R0 — top-of-stack gate)** (введено в PSR v1.2; формализовано через hard-link в Pravila v1.6, версия ссылки уточнена в Pravila v1.7, обновлена в Pravila v1.8/v1.10/v1.11/v1.14):
|
||||
|
||||
Прямой `Skill` tool на не-stack плагин (ui-ux-pro-max, claude-md-management, review, security-review, init, simplify и т.д.) **до прохождения R0 stack-gate**, без явной live-команды `/имя-плагина` от пользователя (R0.4.B PSR_v1) и вне технических исключений R0.4.A PSR_v1 (read-only исследование, тривиальные синки, справочные ответы) = **нарушение §13 этого документа**.
|
||||
|
||||
@@ -749,7 +752,7 @@ Frontend Design покрывает **a11y-принципы** (контраст,
|
||||
|
||||
### 13.10. Hard-link на R14 PSR_v1 — байпас pipeline'а внешних UI-генераторов (v1.8)
|
||||
|
||||
**Нарушение Правила 14 [Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (v3.0+, R0 → sub-policy под ruflo Queen-led routing)** (введено в PSR v1.4 одновременно с формализацией UPM + 21st Magic MCP в `~/.claude/settings.json` и `~/.claude.json`; версия cross-ref'а обновлена до v1.6 в Pravila v1.10, до v2.0 в v1.11, до v3.0+ в v1.14).
|
||||
**Нарушение Правила 14 [Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (v3.2+, R0 — top-of-stack gate)** (введено в PSR v1.4 одновременно с формализацией UPM + 21st Magic MCP в `~/.claude/settings.json` и `~/.claude.json`; версия cross-ref'а обновлена до v1.6 в Pravila v1.10, до v2.0 в v1.11, до v3.0+ в v1.14).
|
||||
|
||||
Использование `ui-ux-pro-max` или `21st.dev Magic MCP` (`mcp__magic__21st_magic_component_*`, `mcp__magic__logo_search`) **вне pipeline'а R14** = нарушение §13 этого документа.
|
||||
|
||||
@@ -799,7 +802,7 @@ Hard-link идёт через цепочку: R14 нарушено → R10.4 «
|
||||
- §14 — слой **маршрутизации**: кто получает задачу (ruflo Queen).
|
||||
- §12 — слой **дисциплины исполнения**: какими Superpowers-skills работать.
|
||||
|
||||
При срабатывании §14 задача уходит в ruflo Queen; декомпозиция Queen и работа spawned-агентов внутри по-прежнему следуют §12. Порядок — по CLAUDE.md §1 priority chain (ruflo — уровень −1, §12 — уровень 0).
|
||||
При срабатывании §14 задача уходит в ruflo Queen; декомпозиция Queen и работа spawned-агентов внутри по-прежнему следуют §12. Порядок — §12 и §14 равноправны: оба explicit hard-rule (см. §0 priority note + §13.6 tier-таблица). ruflo как цель маршрута §14 — инструмент (хук + MCP), не уровень иерархии.
|
||||
|
||||
### 14.7. Enforcement
|
||||
|
||||
|
||||
+13
-10
@@ -1,10 +1,10 @@
|
||||
# Приложение Н — Tooling, скиллы и плагины Claude (v8.3)
|
||||
|
||||
**Дата:** 15.05.2026
|
||||
**Версия:** 2.1 (§4.10 +абзац «Queen trigger»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), explicit hard-rule Pravila §14, enforcement-хук `tools/ruflo-queen-hook.mjs`. Связано: spec/plan `docs/superpowers/{specs,plans}/2026-05-15-ruflo-queen-trigger-and-delegation*`, Pravila v1.15, CLAUDE.md v2.1, PSR_v1 v3.1. **v2.0 наследие:** Ruflo big-bang — major bump: добавлен **orchestration layer (ruflo)** как четвёртая off-phase подкатегория. §0 +ruflo orchestration row: 35 формализованных позиций + 20 ruflo plugins = 55 total; новая §4.10 «Orchestration layer (ruflo)» — ruflo встаёт entry-point'ом уровня −1 над 8-уровневой иерархией Лидерры (архитектурная инверсия). Связано: spec/plan 2026-05-15, Pravila v1.14, PSR_v1 v3.0, CLAUDE.md v2.0.)
|
||||
**Дата:** 16.05.2026
|
||||
**Версия:** 2.2 (§4.10 реколлаж — ruflo переописан из «entry-point иерархии» в «advisory/automation-подсистему» (декларация приведена к рантайму: рой idle, 0 задач); заголовок §4.10 + «Архитектурная роль» переписаны; §0 table row + «Категории off-phase tools» + «Назначение» обновлены; §13 +v2.2 entry. Связано: Pravila v1.16, PSR_v1 v3.2, CLAUDE.md v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`. **v2.1 наследие:** §4.10 +абзац «Queen trigger»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), explicit hard-rule Pravila §14, enforcement-хук `tools/ruflo-queen-hook.mjs`. Связано: spec/plan `docs/superpowers/{specs,plans}/2026-05-15-ruflo-queen-trigger-and-delegation*`, Pravila v1.15, CLAUDE.md v2.1, PSR_v1 v3.1. **v2.0 наследие:** Ruflo big-bang — major bump: добавлен **orchestration layer (ruflo)** как четвёртая off-phase подкатегория. §0 +ruflo orchestration row: 35 формализованных позиций + 20 ruflo plugins = 55 total; новая §4.10 «Orchestration layer (ruflo)». Связано: spec/plan 2026-05-15, Pravila v1.14, PSR_v1 v3.0, CLAUDE.md v2.0.)
|
||||
**Предыдущая версия:** 1.17 (13.05.2026 day +1 — формализация retrospective двух off-phase MCP debug-инструментов установленных на feat/claude-automation `6f7e7d7` + `bd4ec48` после merge PR #3 в main `cc5f63b`: §0 счётчик off-phase 3 → 5, итого 33 → 35; §4.8 новый — #34 Sentry MCP; §4.9 новый — #35 Redis MCP. Категория debug-runtime, отдельная от UI-пула.)
|
||||
**Адресат:** Claude + разработчики проекта Лидерра
|
||||
**Назначение:** единый источник истины по 35 формализованным позициям тулчейна + 20 ruflo orchestration plugins = 55 total (29 «активных» номеров фаз + 5 off-phase инструментов-резерв в категориях UI-пул, инфраструктура, debug-runtime — UPM, 21st, claude-md-management, Sentry MCP, Redis MCP; +1 заменённый PG MCP исторически; +ruflo orchestration layer уровня −1 — 20 plugins, см. §4.10), скиллам Claude Code, MCP-серверам и плагинам, используемым в проекте. Зафиксирован выбор, объяснено, что заменяет что, и в какой фазе вводится каждый инструмент.
|
||||
**Назначение:** единый источник истины по 35 формализованным позициям тулчейна + 20 ruflo orchestration plugins = 55 total (29 «активных» номеров фаз + 5 off-phase инструментов-резерв в категориях UI-пул, инфраструктура, debug-runtime — UPM, 21st, claude-md-management, Sentry MCP, Redis MCP; +1 заменённый PG MCP исторически; +ruflo advisory/automation-подсистема — 20 plugins, см. §4.10), скиллам Claude Code, MCP-серверам и плагинам, используемым в проекте. Зафиксирован выбор, объяснено, что заменяет что, и в какой фазе вводится каждый инструмент.
|
||||
|
||||
> **Связано:**
|
||||
>
|
||||
@@ -82,7 +82,7 @@
|
||||
| **2 — старт frontend** | первый коммит в `resources/js/` (Vue 3 + Vuetify 3) | **24** | +7 (включая #30 Frontend Design plugin, добавлен post-MVP в v1.10) |
|
||||
| **3 — pre-production** | ~спринт 12, перед публичным релизом | **29** | +5 |
|
||||
| **off-phase tools** | по факту включения в `~/.claude/settings.json` / `~/.claude.json` / `.mcp.json` | **+5** | #31 UPM (UI-резерв), #32 21st Magic MCP (UI-генератор), #33 claude-md-management (инфраструктура CLAUDE.md edits), #34 Sentry MCP (debug self-hosted Sentry в Yandex Cloud), #35 Redis MCP (debug Memurai/Redis runtime) |
|
||||
| **ruflo orchestration layer** (off-phase, post-MVP 2026-05-15) | `npx ruflo@latest init` + `.mcp.json` ruflo entry | **+20 plugins** | `ruflo` v3.7.0-alpha.38+ + 20 plugins (`@claude-flow/*`, IPFS-registry) — entry-point уровня −1 над 8-уровневой иерархией Лидерры; orchestration подкатегория off-phase (см. §4.10) |
|
||||
| **ruflo advisory/automation-подсистема** (off-phase, post-MVP 2026-05-15) | `npx ruflo@latest init` + `.mcp.json` ruflo entry | **+20 plugins** | `ruflo` v3.7.0-alpha.38+ + 20 plugins (`@claude-flow/*`, IPFS-registry) — advisory/automation-подсистема; orchestration подкатегория off-phase (см. §4.10) |
|
||||
|
||||
**Итого формализованных позиций:** 35 (29 активных по фазам + 5 off-phase + 1 заменённый PG MCP исторически) + 20 ruflo orchestration plugins = **55 total**. Полный перечень — §2–§5 (по фазам) + §4.5/§4.6/§4.7/§4.8/§4.9 (off-phase) + §4.10 (ruflo orchestration). Карта «когда что использовать» — §7. Что НЕ ставим и почему — §9.
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
|
||||
> **Введено 13.05.2026 day +1 (v1.17 Прил. Н):** формализован как «инструмент-резерв вне фаз, debug-категория». Установлен на feat/claude-automation `bd4ec48` в `.mcp.json`, merged в main через PR #3 (`cc5f63b`); формализован retrospectively в v1.17. Package `@modelcontextprotocol/server-redis@2025.4.25` **deprecated** по статусу npm («Package no longer supported»), но Anthropic source, рабочий. Post-MVP migration на community alternative (e.g., `@easy-mcps/redis-mcp-server@1.0.8` или `@wenit/redis-mcp-server@1.0.3`) когда подтвердим trust.
|
||||
|
||||
**Категории off-phase tools (v2.0):** к трём существующим подкатегориям — **UI-пул** (#31 UPM + #32 21st Magic MCP), **infrastructure** (#33 claude-md-management), **debug-runtime** (#34 Sentry MCP + #35 Redis MCP) — в v2.0 добавлена четвёртая: **orchestration** — ruflo (entry-point уровня −1, см. §4.10). Эта подкатегория не нумеруется в #-реестре (ruflo — внешний оркестратор с 20 собственными plugins, не один инструмент в фазовой раскладке).
|
||||
**Категории off-phase tools (v2.0):** к трём существующим подкатегориям — **UI-пул** (#31 UPM + #32 21st Magic MCP), **infrastructure** (#33 claude-md-management), **debug-runtime** (#34 Sentry MCP + #35 Redis MCP) — в v2.0 добавлена четвёртая: **orchestration** — ruflo (advisory/automation-подсистема, см. §4.10). Эта подкатегория не нумеруется в #-реестре (ruflo — внешний оркестратор с 20 собственными plugins, не один инструмент в фазовой раскладке).
|
||||
|
||||
| # | Инструмент | Установка | Состав | Когда использовать |
|
||||
|---|---|---|---|---|
|
||||
@@ -368,11 +368,11 @@
|
||||
|
||||
**Безопасность:** Локальный Memurai на 6379 **без auth** — это dev-only setup. Если в будущем будут prod Redis с auth — entry `redis-prod` с url через env var `${REDIS_PROD_URL}`, credentials через PowerShell User scope (как Sentry). Сейчас prod нет (зависит от Б-1).
|
||||
|
||||
### 4.10. Orchestration layer (ruflo) — entry-point иерархии (off-phase)
|
||||
### 4.10. ruflo — advisory/automation-подсистема (off-phase)
|
||||
|
||||
**ruflo** (npm `ruflo` v3.7.0-alpha.38+, MIT, репозиторий `ruvnet/claude-flow` — legacy-имя после rename Jan-2026; plugin-namespace `@claude-flow/*`) встаёт «чистым верхом» над 8-уровневой иерархией Лидерры (см. CLAUDE.md §1 priority chain — уровень −1). 20 plugins (IPFS-registry, CID `QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2`, Phase 1 pre-flight verified), ~210 MCP tools, 60+ specialized agents (Queen-led hierarchy: Raft/Byzantine/Gossip consensus protocols), HNSW vector memory, SONA neural routing.
|
||||
**ruflo** (npm `ruflo` v3.7.0-alpha.38+, MIT, репозиторий `ruvnet/claude-flow` — legacy-имя после rename Jan-2026; plugin-namespace `@claude-flow/*`). 20 plugins (IPFS-registry, CID `QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2`, Phase 1 pre-flight verified), ~210 MCP tools, 60+ specialized agents (Queen-led hierarchy: Raft/Byzantine/Gossip consensus protocols), HNSW vector memory, SONA neural routing.
|
||||
|
||||
**Архитектурная роль:** entry-point для ВСЕХ задач (уровень −1 иерархии). Делает первичную классификацию задачи (interactive vs autonomous) и либо delegate в sub-policy paired-stack (Superpowers + Frontend Design + расширенный UI-пул + infrastructure + debug-runtime), либо executes напрямую через swarm.
|
||||
**Архитектурная роль:** параллельная advisory/automation-подсистема, **не** entry-point. Фактический рантайм (инспекция 15.05.2026): hive-mind idle, 0 задач / 0 раундов консенсуса; Claude-сессии работают напрямую. Реально работают: UserPromptSubmit-хуки `ruflo-queen-hook.mjs` (§14 queen-триггер) и `ruflo-recall-hook.mjs` (memory recall) — слой settings.json; daemon (PM2) и memory — фоновая подсистема вне priority chain; MCP-server (~210 tools) — off-phase инструмент.
|
||||
|
||||
**Категория:** off-phase, **orchestration** — четвёртая off-phase подкатегория, отдельная от UI-пула (#31 UPM + #32 21st Magic MCP), infrastructure (#33 claude-md-management), debug-runtime (#34 Sentry MCP + #35 Redis MCP).
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
|
||||
**Plugin discovery риск:** IPFS gateways Pinata + Cloudflare FAILED 2026-05-15; работает только `ipfs.io`. Operational risk #11 (spec §10.3).
|
||||
|
||||
**Связано:** spec [superpowers/specs/2026-05-15-ruflo-integration-design.md](superpowers/specs/2026-05-15-ruflo-integration-design.md) (commit `e55572e`+`a68a0a0`), plan [superpowers/plans/2026-05-15-ruflo-big-bang-integration.md](superpowers/plans/2026-05-15-ruflo-big-bang-integration.md) (commit `18c4463`+`9bd1bae`), map fork `automation-graph-ruflo.html` (commit `796d814`, влит в `docs/automation-graph.html` iter4 и удалён `d18b60f`). Нормативная инверсия: Pravila v1.14 §12 sub-policy (commit `9c3057b`), PSR_v1 v3.0 R0 sub-policy (commit `d30cbeb`), CLAUDE.md v2.0 §1 уровень −1 (commit `5df88a1`).
|
||||
**Связано:** spec [superpowers/specs/2026-05-15-ruflo-integration-design.md](superpowers/specs/2026-05-15-ruflo-integration-design.md) (commit `e55572e`+`a68a0a0`), plan [superpowers/plans/2026-05-15-ruflo-big-bang-integration.md](superpowers/plans/2026-05-15-ruflo-big-bang-integration.md) (commit `18c4463`+`9bd1bae`), map fork `automation-graph-ruflo.html` (commit `796d814`, влит в `docs/automation-graph.html` iter4 и удалён `d18b60f`). Реколлаж декларации: spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`, Pravila v1.16, PSR_v1 v3.2, CLAUDE.md v2.2.
|
||||
|
||||
---
|
||||
|
||||
@@ -674,10 +674,13 @@ Vuetify-тема — `liderraLight` и `liderraDark` — определена в
|
||||
| **v1.17** | 13.05.2026 (day +1) | **Формализация retrospective двух off-phase MCP debug-инструментов** установленных на feat/claude-automation (commits `6f7e7d7` sentry, `bd4ec48` redis), merged в main через PR #3 (`cc5f63b`): **§0 счётчик off-phase 3 → 5; итого формализованных позиций 33 → 35**. **§4.8 (новый)** — #34 Sentry MCP (`@sentry/mcp-server@0.33.0+`, official, pending Sentry instance deployment Б-1). **§4.9 (новый)** — #35 Redis MCP (`@modelcontextprotocol/server-redis@2025.4.25`, deprecated Anthropic source, рабочий с Memurai localhost:6379; migration plan на community alternative post-MVP). Категория **debug-runtime**, отдельная от UI-пула (UPM/21st) и инфраструктурного (claude-md-management) — не попадает в R14 pipeline и не trigger'ит R6.0/R6.1 фильтры. Связано: PSR_v1 v2.0 → v2.1 (R10.1 +sentry+redis); CLAUDE.md v1.91 → v1.92 (§3.3 #34/#35; §0 cross-refs); Pravila v1.12 → v1.13 (§13.2 +Off-phase MCP debug-runtime подсекция). Через manual Edit для Tooling/PSR_v1/Pravila + `/claude-md-management:claude-md-improver` для CLAUDE.md. |
|
||||
| **v2.0** | 15.05.2026 | **Ruflo big-bang:** §0 +ruflo orchestration layer row (35 → 55: 35 формализованных позиций + 20 ruflo plugins); новая §4.10 «Orchestration layer (ruflo)». Major bump reflects architectural inversion — ruflo встаёт entry-point'ом уровня −1 над 8-уровневой иерархией Лидерры (см. CLAUDE.md §1 priority chain). ruflo v3.7.0-alpha.38+ + 20 plugins (`@claude-flow/*`, IPFS-registry — полный CID в §4.10), ~210 MCP tools, 60+ agents (Queen-led: Raft/Byzantine/Gossip), HNSW vector memory, SONA routing. Категория **orchestration** — четвёртая off-phase подкатегория (отдельная от UI-пула, infrastructure, debug-runtime). §4.9 +note «Категории off-phase tools (v2.0)». Runtime state 2026-05-15: scaffold installed + MCP server в `.mcp.json` (7-й MCP); daemon/swarm/memory НЕ активны — opt-in MCP tool, не enforcing overlord. Связано: spec/plan 2026-05-15 (commits `e55572e`/`18c4463`), Pravila v1.14 (`9c3057b`), PSR_v1 v3.0 (`d30cbeb`), CLAUDE.md v2.0 (`5df88a1`). v1.17 наследие — §4.8 Sentry MCP + §4.9 Redis MCP. |
|
||||
| **v2.1** | **15.05.2026** | §4.10 +абзац «Queen trigger»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), explicit hard-rule Pravila §14, enforcement-хук `tools/ruflo-queen-hook.mjs`; footer-колонтитул v2.1. Связано: spec/plan `docs/superpowers/{specs,plans}/2026-05-15-ruflo-queen-trigger-and-delegation*`, Pravila v1.15 / CLAUDE.md v2.1 / PSR_v1 v3.1. |
|
||||
| **v2.2** | **16.05.2026** | **§4.10 реколлаж:** ruflo переописан из «entry-point иерархии» в «advisory/automation-подсистему» (декларация приведена к рантайму: рой idle, 0 задач / 0 раундов консенсуса; Claude-сессии работают напрямую). Заголовок §4.10 изменён («Orchestration layer (ruflo) — entry-point иерархии» → «ruflo — advisory/automation-подсистема»); «Архитектурная роль» переписана; §0 table row обновлён; «Категории off-phase tools» обновлены; «Назначение» обновлено; шапка v2.1 → v2.2, дата 16.05.2026. Связано: Pravila v1.16, PSR_v1 v3.2, CLAUDE.md v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`. |
|
||||
|
||||
---
|
||||
|
||||
*Прил. Н v2.1 от 15.05.2026 — Ruflo big-bang: добавлен orchestration layer (ruflo) как четвёртая off-phase подкатегория (§4.10). 55 позиций (35 формализованных позиций + 20 ruflo plugins). ruflo — entry-point уровня −1 над 8-уровневой иерархией Лидерры. v2.1 — §4.10 +абзац «Queen trigger» (Pravila §14, хук ruflo-queen-hook.mjs).*
|
||||
*Прил. Н v2.2 от 16.05.2026 — §4.10 реколлаж: ruflo переописан из «entry-point иерархии» в «advisory/automation-подсистему» (декларация приведена к рантайму). Связано: Pravila v1.16, PSR_v1 v3.2, CLAUDE.md v2.2.*
|
||||
|
||||
*Прил. Н v2.1 от 15.05.2026 — Ruflo big-bang: добавлен orchestration layer (ruflo) как четвёртая off-phase подкатегория (§4.10). 55 позиций (35 формализованных позиций + 20 ruflo plugins). v2.1 — §4.10 +абзац «Queen trigger» (Pravila §14, хук ruflo-queen-hook.mjs).*
|
||||
|
||||
*Прил. Н v1.17 от 13.05.2026 (day +1) — формализация retrospective off-phase MCP #34 Sentry + #35 Redis (debug-runtime category). 35 позиций (29 активных по фазам + 5 off-phase + 1 заменённый PG MCP исторически).*
|
||||
|
||||
|
||||
+115
-45
@@ -76,6 +76,20 @@
|
||||
/* ── Паспорт узла (iter6) ── */
|
||||
#passport-section p { font-size: 12px; color: #eee8d5; line-height: 1.6; }
|
||||
#passport-section p .pp-k { color: #839496; }
|
||||
|
||||
/* ── Кнопки режимов в футере (iter6) ── */
|
||||
.cat-ctl-sep { width: 1px; align-self: stretch; background: #586e75; margin: 0 4px; }
|
||||
.cat-ctl {
|
||||
background: #002b36; border: 1px solid #586e75; color: #93a1a1;
|
||||
border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer;
|
||||
transition: background 0.12s, box-shadow 0.12s; user-select: none;
|
||||
}
|
||||
.cat-ctl:hover { background: #0d4a5a; color: #fdf6e3; }
|
||||
.cat-ctl.active {
|
||||
background: rgba(253,246,227,0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(253,246,227,0.4);
|
||||
color: #fdf6e3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -145,6 +159,9 @@
|
||||
<div class="cat-item" data-filter-key="conflict:RED"><div class="cat-dot" style="background:#ff5f57; border:1px dashed #ff5f57"></div>🔴 Не закрыт правилом</div>
|
||||
<div class="cat-item" data-filter-key="conflict:BLACK"><div class="cat-dot" style="background:#888888; border:1px dashed #888888"></div>⚫ Возник на практике</div>
|
||||
<div class="cat-item" data-filter-key="conflict:GREEN"><div class="cat-dot" style="background:#859900; border:1px dashed #859900"></div>🟢 Закрыт правилом</div>
|
||||
<span class="cat-ctl-sep"></span>
|
||||
<button class="cat-ctl" id="cat-ctl-heat" title="Подсветить узлы по числу вызовов за 7 дней">🔥 По использованию</button>
|
||||
<button class="cat-ctl" id="cat-ctl-dup" title="Подсветить явные пары дублей (D1–D5, D7)">⧉ Дубли</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -163,10 +180,10 @@ function pos(ring, angleDeg) {
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.14', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.0', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.0', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.0', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'pravila', label: 'Pravila v1.16', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.2', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.2', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.2', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ── второе кольцо ───────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
@@ -399,7 +416,7 @@ const EDGES = [
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — sub-policy под ruflo; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
@@ -415,16 +432,16 @@ const EDGES = [
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// Queen → 4 узла-правила (нормативная декларация уровня −1)
|
||||
E('ruflo_queen', 'pravila', 'перенял\nsub-policy'),
|
||||
E('ruflo_queen', 'claude_md', 'перенял\nsub-policy'),
|
||||
E('ruflo_queen', 'psr_v1', 'перенял\nsub-policy'),
|
||||
E('ruflo_queen', 'tooling', 'перенял\nsub-policy'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Нормативка декларирует ruflo уровнем −1 (overlord) — фактически параллельная подсистема: рой из 10 idle-воркеров, 0 задач', 'RED'),
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Daemon worker-jitter усиливает частоту Pest квирка 72', 'BLACK'),
|
||||
];
|
||||
@@ -461,8 +478,8 @@ const NODE_DETAILS = {
|
||||
pravila: nd(
|
||||
'Главный свод правил работы Клода — кто чем командует, что запрещено, какие обязательные действия.',
|
||||
'Действует всегда — Клод читает его при старте каждой сессии.',
|
||||
'§12 Superpowers стал sub-policy под ruflo Queen-led routing (уровень −1); в уровнях 0–6 цепочки приоритетов §12 остаётся hard-rule (скил инвокируется первым), economy-режим §12 не отменяет. Расходимость с другими документами — нарушение §7.',
|
||||
[{ name: 'ruflo Queen', cond: 'уровень −1 по нормативке — ruflo Queen-led routing над Pravila' }],
|
||||
'§12 Superpowers — hard-rule уровня 0 цепочки приоритетов: скил инвокируется первым, §9 «Отступления» не применяется, economy-режим §12 не отменяет. Расходимость с другими документами — нарушение §7.',
|
||||
[],
|
||||
[
|
||||
{ name: 'CLAUDE.md', cond: 'подчинён, уровень 2a в цепочке приоритетов' },
|
||||
{ name: 'PSR_v1', cond: 'подчинён, уровень 3 в цепочке приоритетов' },
|
||||
@@ -470,15 +487,15 @@ const NODE_DETAILS = {
|
||||
{ name: 'Все компоненты', cond: 'через цепочку приоритетов §1' }
|
||||
],
|
||||
[{ name: 'CLAUDE.md', cond: 'оба читаются при старте сессии' }],
|
||||
[{ name: 'ruflo Queen', desc: 'Нормативка (Pravila §0/§12, CLAUDE.md §1, PSR_v1 R0) декларирует ruflo Queen-led routing уровнем −1 над Pravila — overlord. Фактическая инспекция рантайма 15.05.2026: рой hive-mind — Queen + 10 generic-воркеров, 0 задач и 0 раундов консенсуса за всё время; Клод работает напрямую. Декларация ≠ рантайм, механизма enforcement нет.', type: 'RED' }]
|
||||
[{ name: 'ruflo Queen', desc: 'iter4–iter5: нормативка декларировала ruflo Queen-led routing уровнем −1 (overlord) — расходилось с рантаймом (рой idle, 0 задач). Реколлаж 16.05.2026 (Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2) привёл нормативку к факту: ruflo переописан в advisory/automation-подсистему, уровень −1 убран. Конфликт «декларация ≠ рантайм» закрыт.', type: 'GREEN' }]
|
||||
),
|
||||
claude_md: nd(
|
||||
'Оперативная карта проекта — технологии, команды, фазы, 9-уровневая цепочка приоритетов (§1, уровень −1 — ruflo) и §3.5 orchestration layer, ссылки на документы.',
|
||||
'Оперативная карта проекта — технологии, команды, фазы, 7-уровневая цепочка приоритетов (§1, уровни 0–6) и §3.5 — ruflo как advisory/automation-подсистема, ссылки на документы.',
|
||||
'Читается при старте каждой сессии; обновляется при новом инструменте или новой фазе.',
|
||||
'Править можно только через скил `/claude-md-management:claude-md-improver` или `:revise-claude-md` (правило §5 п.10). Прямые Edit/Write блокируются хуком предупреждения.',
|
||||
[{ name: 'Pravila', cond: 'всегда подчинён (уровень 2a)' }],
|
||||
[
|
||||
{ name: 'Tooling v2.0', cond: 'ссылается как на реестр инструментов' },
|
||||
{ name: 'Tooling v2.2', cond: 'ссылается как на реестр инструментов' },
|
||||
{ name: 'плагин claude-md-management', cond: 'правило §5 п.10 — единственный канал правок' }
|
||||
],
|
||||
[
|
||||
@@ -488,7 +505,7 @@ const NODE_DETAILS = {
|
||||
[{ name: 'PSR_v1', desc: 'Правило §5 п.10 запрещает прямые правки, но PSR_v1 это явно не повторяет — есть риск Edit без скила', type: 'GREEN' }]
|
||||
),
|
||||
psr_v1: nd(
|
||||
'Правила совместной работы плагинов — кто с кем работает, какая процедура обязательна. R0 stack-gate переформулирован в sub-policy paired-stack delegation pattern под ruflo Queen-led routing.',
|
||||
'Правила совместной работы плагинов — кто с кем работает, какая процедура обязательна. R0 — головной фильтр выбора плагинов (с реколлажа 16.05.2026 снова на вершине стека, не sub-policy под ruflo).',
|
||||
'При выборе UI-инструмента (плагин Frontend Design против плагина UI UX Pro Max против MCP-сервера 21st Magic), при координации парных плагинов, при включении дополнительного MCP (внешнего сервиса-инструмента Claude) вне основных фаз.',
|
||||
'Обязательное правило R14.5: плагины UI UX Pro Max, 21st Magic, Frontend Design — нельзя использовать одновременно. Обязательное правило R6.0 (фильтр стека) и R6.1 (палитра Forest) — нужно соблюдать при UI-выводе плагинов.',
|
||||
[{ name: 'Pravila', cond: 'подчинён, уровень 3 в цепочке' }],
|
||||
@@ -501,7 +518,7 @@ const NODE_DETAILS = {
|
||||
[{ name: 'CLAUDE.md', desc: 'CLAUDE.md §5 п.10 требует править только через скил claude-md-management, а PSR_v1 это ограничение не повторяет — риск прямых Edit', type: 'GREEN' }]
|
||||
),
|
||||
tooling: nd(
|
||||
'Реестр 55 позиций — 35 формализованных инструментов + 20 ruflo-плагинов; +§4.10 Orchestration layer. Когда что использовать, команды установки, конфликты.',
|
||||
'Реестр 55 позиций — 35 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
|
||||
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
|
||||
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
|
||||
[
|
||||
@@ -516,14 +533,14 @@ const NODE_DETAILS = {
|
||||
superpowers: nd(
|
||||
'Плагин поведения Клода — 14 скилов для тестов, отладки, планирования, параллельной работы.',
|
||||
'При творческих, отладочных, тестовых и многошаговых задачах: скил brainstorming (продумать варианты) / скил TDD (разработка через тесты — failing test first) / скил systematic-debugging / скил verification-before-completion (обязательная проверка готовности) / скил writing-plans / скил parallel-work / скил worktree / скил finishing-PR (запрос на слияние кода) / скил subagent-driven-development / скил writing-skills (карта типов в §12.2 Pravila).',
|
||||
'§12 Superpowers — sub-policy под ruflo Queen-led routing (уровень −1); в уровнях 0–6 остаётся hard-rule (скил инвокируется первым). Единственная отмена — явная просьба заказчика «не используй superpowers сейчас» на текущее действие; §9 «Отступления» к §12 не применяется; economy-режим §12 не отменяет.',
|
||||
'§12 Superpowers — hard-rule уровня 0 цепочки приоритетов: скил инвокируется первым. Единственная отмена — явная просьба заказчика «не используй superpowers сейчас» на текущее действие; §9 «Отступления» к §12 не применяется; economy-режим §12 не отменяет.',
|
||||
[
|
||||
{ name: 'Pravila §12', cond: 'обязательное правило: скил запускается первым' },
|
||||
{ name: 'PSR_v1', cond: 'координирует как пару с плагином Frontend Design' }
|
||||
],
|
||||
[{ name: 'Все 14 скилов Superpowers', cond: 'содержит' }],
|
||||
[{ name: 'плагин Frontend Design', cond: 'пара — работают вместе при UI-задачах' }],
|
||||
[{ name: 'хук economy-mode', desc: 'Режим экономии 100% теоретически может «сэкономить» запуск скила — §12 (sub-policy под ruflo Queen-led routing) economy-режим не отменяет.', type: 'GREEN' }]
|
||||
[{ name: 'хук economy-mode', desc: 'Режим экономии 100% теоретически может «сэкономить» запуск скила — §12 (hard-rule уровня 0) economy-режим не отменяет.', type: 'GREEN' }]
|
||||
),
|
||||
fd_plugin: nd(
|
||||
'Плагин знаний о UI — Vue, Vuetify, доступность (accessibility), паттерны компонентов для Лидерры.',
|
||||
@@ -747,11 +764,11 @@ const NODE_DETAILS = {
|
||||
hk_economy: nd(
|
||||
'Перед каждым промптом разбирает «экономия X%» и выставляет режим строгости (0% = максимальное качество, 100% = по умолчанию).',
|
||||
'UserPromptSubmit (перед отправкой промпта пользователя) — ищет шаблон /экономия\\s*(\\d+)%/.',
|
||||
'§12 Superpowers — sub-policy под ruflo Queen-led routing, но economy-режим §12 НЕ отменяет ни на каком уровне. Действует только на текущую задачу — следующий промпт разбирается заново.',
|
||||
'§12 Superpowers — hard-rule уровня 0, economy-режим §12 НЕ отменяет ни на каком уровне. Действует только на текущую задачу — следующий промпт разбирается заново.',
|
||||
[{ name: '.claude/settings.json', cond: 'описан как хук UserPromptSubmit' }],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'плагин Superpowers (§12)', desc: 'Экономия=100% теоретически может «сэкономить» вызов скила — §12 (sub-policy под ruflo Queen-led routing) economy-режим не отменяет ни на каком уровне (Pravila §12.4).', type: 'GREEN' }]
|
||||
[{ name: 'плагин Superpowers (§12)', desc: 'Экономия=100% теоретически может «сэкономить» вызов скила — §12 (hard-rule уровня 0) economy-режим не отменяет ни на каком уровне (Pravila §12.4).', type: 'GREEN' }]
|
||||
),
|
||||
|
||||
// ── АГЕНТЫ ───────────────────────────────────────
|
||||
@@ -1021,7 +1038,7 @@ const NODE_DETAILS = {
|
||||
]
|
||||
),
|
||||
mem_sp: nd(
|
||||
'Правило §12 (sub-policy под ruflo Queen-led routing) + архитектура хука economy из 6 компонентов — дисциплина вызова скилов.',
|
||||
'Правило §12 (hard-rule уровня 0) + архитектура хука economy из 6 компонентов — дисциплина вызова скилов.',
|
||||
'При работе со скилами — для соответствия обязательному правилу §12 Pravila.',
|
||||
'Описывает архитектуру хука economy (6 компонентов) — менять только при изменении самого хука.',
|
||||
[],
|
||||
@@ -1120,10 +1137,13 @@ const NODE_DETAILS = {
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (фактический реколлаж iter5) ──
|
||||
ruflo_queen: nd(
|
||||
'Queen оркестратора ruflo v3.7.0-alpha.38 — стратегическая «королева» роя hive-mind (топология hierarchical-mesh, консенсус byzantine). По нормативке (CLAUDE.md §1) — entry-point уровня −1 для всех задач Клода.',
|
||||
'По нормативке — первичная классификация любой задачи и маршрутизация. Фактически рой ни разу не запускался на реальную задачу — Клод работает напрямую.',
|
||||
'Фактическая инспекция рантайма 15.05.2026: Queen активна (term 1, нагрузка 0), но за всё время — 0 задач, 0 раундов консенсуса, 0 общей памяти. ruflo НЕ перехватывает рабочий процесс Claude Code. «Queen-led routing уровня −1» — нормативная декларация, не рантайм. Alpha-версия, LLM API-ключей нет.',
|
||||
[],
|
||||
'Queen оркестратора ruflo v3.7.0-alpha.38 — стратегическая «королева» роя hive-mind (топология hierarchical-mesh, консенсус byzantine). С реколлажа 16.05.2026 (CLAUDE.md §3.5, Tooling §4.10) — advisory/automation-подсистема, не entry-point: рой работает параллельно, Клод — напрямую.',
|
||||
'Запускается только по триггеру queen/королева в промпте (Pravila §14 — hard-rule маршрутизации). Без триггера рой простаивает — Клод работает напрямую.',
|
||||
'Фактическая инспекция рантайма 15.05.2026: Queen активна (term 1, нагрузка 0), но за всё время — 0 задач, 0 раундов консенсуса, 0 общей памяти. Реколлаж 16.05.2026 привёл нормативку к этому факту — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана. Alpha-версия, LLM API-ключей нет.',
|
||||
[
|
||||
{ name: 'Pravila §14', cond: 'queen-триггер — hard-rule маршрутизации задачи через Queen' },
|
||||
{ name: 'CLAUDE.md §3.5 / Tooling §4.10', cond: 'нормативно описан как advisory/automation-подсистема' }
|
||||
],
|
||||
[
|
||||
{ name: '10 воркеров hive-mind', cond: 'координирует рой — все 10 простаивают' },
|
||||
{ name: 'каталог агентов ruflo', cond: 'ruflo init высыпал в .claude/agents/ — не задействовано' },
|
||||
@@ -1134,7 +1154,7 @@ const NODE_DETAILS = {
|
||||
{ name: 'memory:project_ruflo_integration', cond: 'memory-файл документирует интеграцию' },
|
||||
{ name: 'ruflo MCP', cond: 'MCP-сервер экспонирует инструменты управления роем' }
|
||||
],
|
||||
[{ name: 'Pravila', desc: 'Нормативка (Pravila §0/§12, CLAUDE.md §1, PSR_v1 R0) декларирует ruflo Queen-led routing уровнем −1 — overlord над всей иерархией. Фактическая инспекция рантайма 15.05.2026: рой hive-mind — Queen + 10 generic-воркеров, 0 задач и 0 раундов консенсуса за всё время; Клод работает напрямую. Декларация ≠ рантайм, механизма enforcement нет.', type: 'RED' }]
|
||||
[{ name: 'Pravila', desc: 'iter4–iter5: нормативка декларировала ruflo Queen-led routing уровнем −1 (overlord над всей иерархией) — расходилось с рантаймом (рой idle, 0 задач, 0 раундов консенсуса). Реколлаж 16.05.2026 (Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2) привёл нормативку к факту: ruflo переописан в advisory/automation-подсистему, уровень −1 убран. Конфликт «декларация ≠ рантайм» закрыт.', type: 'GREEN' }]
|
||||
),
|
||||
ruflo_workers: nd(
|
||||
'Рабочие агенты роя hive-mind ruflo — 10 штук. Все одного типа (generic worker), без специализации. На карте до iter5 рисовались 9 «ролей» (Архитектор/Кодер/…) — таких ролей в рантайме не существует.',
|
||||
@@ -1229,7 +1249,7 @@ const EDGE_DETAILS = {
|
||||
'pravila->claude_md': { type: 'подчиняет', when: 'всегда — CLAUDE.md уровень ниже Pravila', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §1 (уровень 1→2a)' },
|
||||
'pravila->psr_v1': { type: 'подчиняет', when: 'всегда — PSR_v1 уровень 3 ниже Pravila', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §1 (уровень 1→3)' },
|
||||
'claude_md->tooling': { type: 'документирует', when: 'при правке реестра инструментов', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §0, §3 (ссылка на Прил. Н)' },
|
||||
'pravila->superpowers': { type: 'подчиняет', when: 'задача попадает под карту §12.2 (14 типов)', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12 (sub-policy под ruflo Queen-led routing; в уровнях 0–6 — hard rule, §9 не применяется)' },
|
||||
'pravila->superpowers': { type: 'подчиняет', when: 'задача попадает под карту §12.2 (14 типов)', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12 (hard-rule уровня 0; скил первым, §9 не применяется)' },
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
'psr_v1->superpowers': { type: 'координирует', when: 'paired-stack: процесс/решатель', transfers: 'контроль', mandatory: 'обязательно', rule: 'PSR_v1 R5 (paired stack ядро)' },
|
||||
@@ -1318,10 +1338,10 @@ const EDGE_DETAILS = {
|
||||
'psr_v1->claude_md': { type: 'конфликт', when: 'PSR_v1 уровень 3 vs CLAUDE.md 2a — приоритет CLAUDE.md', transfers: 'контроль', mandatory: 'hard-block', rule: 'CLAUDE.md §1 (priority chain)' },
|
||||
'upm->fd_plugin': { type: 'конфликт', when: 'UPM и FD оба претендуют на UI-решения', transfers: 'coverage', mandatory: 'hard-block', rule: 'PSR_v1 R14.5 (не параллельно)' },
|
||||
'mcp_21st->fd_plugin': { type: 'конфликт', when: '21st Magic и FD оба генераторы UI', transfers: 'coverage', mandatory: 'hard-block', rule: 'PSR_v1 R14.5 (не параллельно)' },
|
||||
'hk_economy->superpowers': { type: 'конфликт', when: 'economy-режим теоретически может «сэкономить» вызов скила — §12 (sub-policy под ruflo) economy не отменяет', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12.4 (только явный «не используй»)' },
|
||||
'hk_economy->superpowers': { type: 'конфликт', when: 'economy-режим теоретически может «сэкономить» вызов скила — §12 (hard-rule уровня 0) economy не отменяет', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12.4 (только явный «не используй»)' },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15) ──
|
||||
'ruflo_queen->ruflo_workers': { type: 'подчиняет', when: 'hive-mind активен, но рой ни разу не получал задач', transfers: 'контроль', mandatory: 'опционально (рой idle)', rule: 'Tooling §4.10 (orchestration layer)' },
|
||||
// ── RUFLO ОРКЕСТРАТОР — реколлаж (iter5 2026-05-15 + нормативный sync 2026-05-16) ──
|
||||
'ruflo_queen->ruflo_workers': { type: 'подчиняет', when: 'hive-mind активен, но рой ни разу не получал задач', transfers: 'контроль', mandatory: 'опционально (рой idle)', rule: 'Tooling §4.10 (ruflo как advisory-подсистема)' },
|
||||
'ruflo_queen->ruflo_agents_catalog': { type: 'артефакт', when: '`ruflo init` высыпал каталог в .claude/agents/', transfers: 'ничего (файлы лежат)', mandatory: 'не задействовано', rule: 'артефакт установки ruflo' },
|
||||
'ruflo_queen->ruflo_commands': { type: 'артефакт', when: '`ruflo init` высыпал команды в .claude/commands/', transfers: 'ничего (файлы лежат)', mandatory: 'не задействовано', rule: 'артефакт установки ruflo' },
|
||||
'ruflo_queen->ruflo_plugins': { type: 'артефакт', when: 'плагины ruflo — 0 установлено из ~20 в реестре', transfers: 'ничего', mandatory: 'не задействовано', rule: 'артефакт установки ruflo' },
|
||||
@@ -1329,10 +1349,11 @@ const EDGE_DETAILS = {
|
||||
'ruflo_recall_hook->ruflo_memory': { type: 'читает', when: 'recall-хук на каждый промпт запускает `ruflo memory search`', transfers: 'данные', mandatory: '«мягко» (fail-open)', rule: '.claude/settings.json (UserPromptSubmit)' },
|
||||
'ruflo_daemon->ruflo_memory': { type: 'читает', when: 'воркер consolidate демона обращается к памяти', transfers: 'данные', mandatory: 'опционально', rule: '.claude-flow daemon' },
|
||||
'ruflo_mcp->ruflo_queen': { type: 'экспонирует', when: 'MCP-сервер отдаёт инструменты hive-mind_*/agent_*/swarm_*', transfers: 'инструменты', mandatory: 'опционально', rule: 'Tooling §4.10' },
|
||||
'ruflo_queen->pravila': { type: 'конфликт', when: 'нормативка декларирует уровень −1, фактически parallel subsystem', transfers: 'контроль', mandatory: 'декларативно', rule: 'нет регламента enforcement (Pravila §0/§12, CLAUDE.md §1, PSR_v1 R0)' },
|
||||
'ruflo_queen->claude_md': { type: 'перенял sub-policy', when: 'нормативная декларация уровня −1 (entry-point)', transfers: 'контроль (декларативно)', mandatory: 'декларативно — без enforcement', rule: 'CLAUDE.md §1 priority chain (уровень −1)' },
|
||||
'ruflo_queen->psr_v1': { type: 'перенял sub-policy', when: 'нормативная декларация уровня −1', transfers: 'контроль (декларативно)', mandatory: 'декларативно — без enforcement', rule: 'PSR_v1 R0 (sub-policy delegation pattern)' },
|
||||
'ruflo_queen->tooling': { type: 'перенял sub-policy', when: 'нормативная декларация уровня −1', transfers: 'контроль (декларативно)', mandatory: 'декларативно — без enforcement', rule: 'Tooling §4.10 (orchestration layer)' },
|
||||
'ruflo_queen->pravila': { type: 'конфликт', when: 'реколлаж 16.05.2026 привёл нормативку к рантайму — конфликт «декларация ≠ рантайм» закрыт', transfers: 'coverage', mandatory: 'закрыто', rule: 'Закрыто реколлажем: Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2' },
|
||||
'pravila->ruflo_queen': { type: 'триггерит', when: 'триггер-слова queen/королева в промпте — задача маршрутизируется через ruflo Queen', transfers: 'триггер', mandatory: 'hard-rule', rule: 'Pravila §14 (queen/королева → hive-mind spawn)' },
|
||||
'claude_md->ruflo_queen': { type: 'документирует', when: 'CLAUDE.md §3.5 описывает ruflo как advisory/automation-подсистему', transfers: 'документация', mandatory: 'рекомендуется', rule: 'CLAUDE.md §3.5' },
|
||||
'psr_v1->ruflo_queen': { type: 'документирует', when: 'PSR_v1 §14 — cross-ref на queen-триггер Pravila §14', transfers: 'документация', mandatory: 'рекомендуется', rule: 'PSR_v1 §14' },
|
||||
'tooling->ruflo_queen': { type: 'документирует', when: 'Tooling §4.10 — реестр ruflo как advisory/automation-подсистемы', transfers: 'документация', mandatory: 'рекомендуется', rule: 'Tooling §4.10' },
|
||||
'mem_ruflo->ruflo_queen': { type: 'документирует', when: 'memory-файл хранит историю ruflo-интеграции', transfers: 'данные', mandatory: 'рекомендуется', rule: 'memory/project_ruflo_integration.md' },
|
||||
'ruflo_memory->mem_state': { type: 'конфликт', when: 'два хранилища памяти не синхронизированы; память ruflo почти пуста', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента синхронизации (alpha-баг HNSW #1122)' },
|
||||
'ruflo_daemon->ag_pest': { type: 'конфликт', when: 'daemon worker-jitter усиливает частоту Pest-квирка 72', transfers: 'coverage', mandatory: 'опционально', rule: 'memory feedback_environment квирк #93' },
|
||||
@@ -1352,10 +1373,10 @@ const META_WINDOW = '09–16.05.2026'; // окно подсчёта испо
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | '—'
|
||||
const NODE_META = {
|
||||
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
|
||||
pravila: { since: '06.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
|
||||
pravila: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ──
|
||||
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -1443,7 +1464,7 @@ const NODE_META = {
|
||||
mem_github: { since: '07.05.2026', changed: '15.05.2026', uses: 33, usesSrc: 'memory-чтение' },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — все внедрены big-bang'ом 15.05.2026 ──
|
||||
ruflo_queen: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_queen: { since: '15.05.2026', changed: '16.05.2026', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_plugins: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_workers: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_agents_catalog: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция',
|
||||
@@ -1675,15 +1696,20 @@ function showEdgeLegend(edgeId) {
|
||||
network.on('click', params => {
|
||||
if (params.nodes.length === 1) {
|
||||
const id = params.nodes[0];
|
||||
HIGHLIGHT.setSelectedNode(id);
|
||||
HIGHLIGHT.applyHighlight();
|
||||
// iter6 — в режиме heat/dup клик открывает паспорт, но не трогает подсветку режима
|
||||
if (HIGHLIGHT.state.viewMode === null) {
|
||||
HIGHLIGHT.setSelectedNode(id);
|
||||
HIGHLIGHT.applyHighlight();
|
||||
}
|
||||
// Right panel still shows details of the clicked node (last-clicked, even after toggle-off)
|
||||
showNodeLegend(id);
|
||||
} else if (params.edges.length === 1) {
|
||||
showEdgeLegend(params.edges[0]);
|
||||
} else if (params.nodes.length === 0 && params.edges.length === 0) {
|
||||
HIGHLIGHT.setSelectedNode(null);
|
||||
HIGHLIGHT.applyHighlight();
|
||||
if (HIGHLIGHT.state.viewMode === null) {
|
||||
HIGHLIGHT.setSelectedNode(null);
|
||||
HIGHLIGHT.applyHighlight();
|
||||
}
|
||||
document.getElementById('legend-panel').classList.remove('visible');
|
||||
}
|
||||
});
|
||||
@@ -1830,8 +1856,35 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
const state = {
|
||||
selectedNode: null,
|
||||
legendFilter: new Set(),
|
||||
viewMode: null, // null | 'heat' | 'dup' — взаимоисключающие режимы (iter6)
|
||||
};
|
||||
|
||||
// ── Теплокарта использования (iter6) — 4 яруса по NODE_META[id].uses ──
|
||||
function heatOpacity(nodeId) {
|
||||
const m = NODE_META[nodeId];
|
||||
const u = m ? m.uses : null;
|
||||
if (u === null || u === undefined) return 0.5; // нет данных — нейтрально
|
||||
if (u >= 21) return 1.0; // часто
|
||||
if (u >= 6) return 0.65; // иногда
|
||||
if (u >= 1) return 0.35; // редко
|
||||
return 0.12; // простаивает (uses === 0)
|
||||
}
|
||||
// Узлы верхнего яруса теплокарты получают акцентную рамку.
|
||||
function heatBorderWidth(nodeId) {
|
||||
if (state.viewMode !== 'heat') return 2;
|
||||
const m = NODE_META[nodeId];
|
||||
const u = m ? m.uses : null;
|
||||
return (typeof u === 'number' && u >= 21) ? 4 : 2;
|
||||
}
|
||||
// Переключатель режима — toggle; включение режима гасит пофильтровую подсветку.
|
||||
function setViewMode(mode) {
|
||||
state.viewMode = (state.viewMode === mode) ? null : mode;
|
||||
if (state.viewMode !== null) {
|
||||
state.legendFilter.clear();
|
||||
state.selectedNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pre-computed indices ──────────────────────────
|
||||
const NODES_BY_ID = new Map();
|
||||
const NEIGHBOURS = new Map();
|
||||
@@ -1861,6 +1914,9 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
|
||||
// ── Opacity computations ──────────────────────────
|
||||
function computeNodeOpacity(nodeId) {
|
||||
// Row 0: view-mode (iter6) — глобальная картина, поверх focus/filter
|
||||
if (state.viewMode === 'heat') return heatOpacity(nodeId);
|
||||
if (state.viewMode === 'dup') return DUP_NODE_SET.has(nodeId) ? OPACITY_FOCUS : OPACITY_DIM;
|
||||
// Row 1: focus
|
||||
if (state.selectedNode !== null) {
|
||||
if (state.selectedNode === nodeId) return OPACITY_FOCUS;
|
||||
@@ -1902,6 +1958,7 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
const nodeUpdates = NODES.map(n => ({
|
||||
id: n.id,
|
||||
opacity: computeNodeOpacity(n.id),
|
||||
borderWidth: heatBorderWidth(n.id), // 4 для верхнего яруса теплокарты, иначе 2 (iter6)
|
||||
}));
|
||||
const edgeUpdates = edgesDS.get().map(e => ({
|
||||
id: e.id,
|
||||
@@ -1925,6 +1982,7 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
function clearAll() {
|
||||
state.selectedNode = null;
|
||||
state.legendFilter.clear();
|
||||
state.viewMode = null;
|
||||
}
|
||||
|
||||
function updateLegendVisuals() {
|
||||
@@ -1934,10 +1992,22 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
if (state.legendFilter.has(key)) item.classList.add('active');
|
||||
else item.classList.remove('active');
|
||||
});
|
||||
const heatBtn = document.getElementById('cat-ctl-heat');
|
||||
const dupBtn = document.getElementById('cat-ctl-dup');
|
||||
if (heatBtn) heatBtn.classList.toggle('active', state.viewMode === 'heat');
|
||||
if (dupBtn) dupBtn.classList.toggle('active', state.viewMode === 'dup');
|
||||
}
|
||||
|
||||
// ── Legend click delegation ───────────────────────
|
||||
document.getElementById('cat-legend').addEventListener('click', e => {
|
||||
// iter6 — клик по кнопке режима heat/dup
|
||||
const ctl = e.target.closest('.cat-ctl');
|
||||
if (ctl) {
|
||||
setViewMode(ctl.id === 'cat-ctl-heat' ? 'heat' : 'dup');
|
||||
applyHighlight();
|
||||
updateLegendVisuals();
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest('.cat-item');
|
||||
if (!item || !item.dataset.filterKey) return;
|
||||
toggleFilter(item.dataset.filterKey);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Реколлаж ruflo в иерархии — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Привести нормативку (Pravila / CLAUDE.md / PSR_v1 / Tooling) к фактическому рантайму ruflo — убрать уровень −1 «entry-point для ВСЕХ задач», вернуть §12 Superpowers и PSR_v1 R0 в hard-rule / top-of-stack gate; §14 queen-триггер сохранить.
|
||||
|
||||
**Architecture:** Координированный реколлаж 4 нормативных markdown-файлов + 4 memory-файлов. Каждый нормативный файл — отдельный атомарный коммит (паттерн ruflo big-bang: «5 commits — 4 normative files»). CLAUDE.md правится только через `claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10). Финальная cross-consistency проверка grep'ом по всем 4 файлам.
|
||||
|
||||
**Tech Stack:** Markdown-нормативка; git lefthook pre-commit (gitleaks + markdownlint + cspell); lychee — pre-push. Кода нет → TDD неприменим; верификация задачи = lint-gates + структурный grep.
|
||||
|
||||
**Спека:** `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md` (commit `6786127`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность правки | Канал | Bump |
|
||||
|---|---|---|---|
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | §0 priority note, §12 (title+¶+§12.4), §13.6/§13.9/§13.10 cross-refs, §14.6 cross-ref, шапка | прямой Edit | v1.15 → v1.16 |
|
||||
| `docs/Plugin_stack_rules_v1.md` | R0 (title+R0.1+R0.2+R0.5), шапка cross-refs | прямой Edit | v3.1 → v3.2 |
|
||||
| `docs/Tooling_v8_3.md` | §4.10 (реколлаж), §7 (verify), §0 cross-refs, шапка | прямой Edit | v2.1 → v2.2 |
|
||||
| `CLAUDE.md` | §1 priority chain, §3 title, §3.5, §0 cross-refs, §6, §9 | `claude-md-management:claude-md-improver` | v2.1 → v2.2 |
|
||||
| `memory/project_state.md`, `reference_archive.md`, `project_ruflo_integration.md`, `MEMORY.md` | sync ruflo-фактов | прямой Write | — |
|
||||
|
||||
**Инвариант после всех задач:** во всех 4 файлах priority chain не содержит уровня −1; §12 и R0 — hard-rule / top-gate; §14 — explicit hard-rule без изменения содержания.
|
||||
|
||||
**Порядок:** Tasks 1-3 независимы (разные файлы) — могут идти параллельно. Task 4 (CLAUDE.md) — после 1-3, чтобы плагин видел согласованную нормативку. Task 5 — после 1-4. Task 6 — финальная проверка.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pravila — реколлаж §0/§12/§13/§14, v1.15 → v1.16
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
|
||||
- [ ] **Step 1: Read целевые секции**
|
||||
|
||||
Прочитать §0 (priority chain note), §12 (строки ~591-660), §13.6/§13.9/§13.10 (~707-770), §14.6 (~795-802), шапку (строки 1-12). Зафиксировать текущие формулировки перед Edit.
|
||||
|
||||
- [ ] **Step 2: §12 title + первые два абзаца → hard-rule**
|
||||
|
||||
Title `## 12. Superpowers — sub-policy (ruflo routing preference)` → `## 12. Superpowers — hard rule (инвокация skills первой)`.
|
||||
|
||||
Абзац-1 (`Введено 09.05.2026...`): убрать предложение `Переведено из hard rule в sub-policy 15.05.2026 (v1.14)...`.
|
||||
|
||||
Абзац-2 (`С v1.14 (15.05.2026) §12 — sub-policy под ruflo...`) заменить на:
|
||||
|
||||
> §12 — **explicit hard-rule**: перед содержательной задачей соответствующий Superpowers-skill (карта §12.2) инвокируется первым. §9 «Отступления» к §12 не применяется (§12.4). Карта §12.2, exclusions §12.3 и детали §12.4 — в силе.
|
||||
|
||||
- [ ] **Step 3: §12.4 первый буллет → откат к hard-rule**
|
||||
|
||||
Буллет `ruflo Queen может delegate non-Superpowers tools, если routing-pattern matches non-interactive criteria; §9 «Отступления» формально применяется через ruflo routing layer.` заменить на:
|
||||
|
||||
> - §9 «Отступления» к §12 **не применяется** — §12 explicit hard-rule. Единственная отмена — явный запрос заказчика «не используй superpowers сейчас», только на текущее действие.
|
||||
|
||||
Остальные буллеты §12.4 (приоритет над §1–§11, запрет рационализации, запрет переформулировки) — без изменений.
|
||||
|
||||
- [ ] **Step 4: §0 priority note — убрать ruflo уровень −1**
|
||||
|
||||
В §0 priority-chain note: убрать любое упоминание ruflo как уровня −1 / entry-point. §12 и §14 остаются двумя explicit hard-rule. Цепочка начинается с §12 (hard-rule) → §1–§13. Если §0 содержит фразу про «ruflo Queen-led routing уровень −1» — удалить; §14 как hard-rule сохраняется (он про маршрут queen-триггера, не про уровень иерархии).
|
||||
|
||||
- [ ] **Step 5: §14.6 cross-ref**
|
||||
|
||||
В §14.6 строка `Порядок — по CLAUDE.md §1 priority chain (ruflo — уровень −1, §12 — уровень 0).` → `Порядок — по CLAUDE.md §1 priority chain: §12 — explicit hard-rule (уровень 0), §14 — explicit hard-rule наравне с §12. ruflo как цель маршрута §14 — инструмент (хук + MCP), не уровень иерархии.`
|
||||
|
||||
Содержание §14.1–§14.5, §14.7, §14.8 — **не трогать**.
|
||||
|
||||
- [ ] **Step 6: §13.9/§13.10 PSR_v1 version cross-refs**
|
||||
|
||||
В §13.9 и §13.10 заменить `(v3.0+, R0 → sub-policy под ruflo Queen-led routing)` → `(v3.2+, R0 — top-of-stack gate)`. §13.6 tier-таблица — проверить: §12 уже значится explicit hard-rule (правок не требует, реколлаж устраняет рассинхрон title↔таблица).
|
||||
|
||||
- [ ] **Step 7: Шапка — v1.15 → v1.16**
|
||||
|
||||
Строка `**Версия:** v1.15` → `v1.16`; дата → `16.05.2026`. Добавить блок `**Что изменилось в v1.16 относительно v1.15:**` — реколлаж ruflo к рантайму: §12 sub-policy → hard-rule, §0/§14.6 убран уровень −1, §13.9/§13.10 cross-refs; связано с CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`.
|
||||
|
||||
- [ ] **Step 8: Verify + commit**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && git add docs/Pravila_raboty_Claude_v1_1.md && git commit -m "docs(rules): Pravila v1.16 — реколлаж ruflo, §12 sub-policy → hard-rule"`
|
||||
Expected: lefthook pre-commit зелёный (gitleaks 0, markdownlint 0, cspell 0); commit создан. Если cspell ругается на новые слова — добавить в `cspell-words.txt` в тот же коммит.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: PSR_v1 — реколлаж R0, v3.1 → v3.2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Plugin_stack_rules_v1.md`
|
||||
|
||||
- [ ] **Step 1: Read целевые секции**
|
||||
|
||||
Прочитать шапку (строки 1-23, cross-refs к CLAUDE.md/Pravila/Tooling), R0 целиком (строки ~24-135).
|
||||
|
||||
- [ ] **Step 2: R0 title**
|
||||
|
||||
`## Правило 0 — Sub-policy: paired-stack delegation pattern (под ruflo Queen-led routing)` → `## Правило 0 — Stack-gate: paired-stack delegation pattern`.
|
||||
|
||||
- [ ] **Step 3: R0.1 таблица — убрать строку −1**
|
||||
|
||||
Удалить строку таблицы `| **−1** | **ruflo Queen-led routing (entry-point, v3.0+)** | ✅ stack делегирует ruflo... |`. Таблица начинается с уровня 0. В строке уровня 3 (`PSR_v1 (этот документ)`) колонку «Stack головной?» вернуть с `sub-policy ruflo routing...` на исходное (PSR_v1 — сам stack-документ; формулировка как до v3.0). Текст-преамбулу R0.1 «Stack — головной... под ruflo Queen-led routing» очистить от ruflo-уровня −1.
|
||||
|
||||
- [ ] **Step 4: R0.2 gate-диаграмма**
|
||||
|
||||
Абзац `Каждая задача проходит через ruflo Queen-led routing первой; ruflo либо делегирует... либо handles напрямую через swarm... Stack-gate активируется как sub-policy через ruflo routing-decision...` заменить на исходную формулировку top-of-stack gate: каждая задача проходит через STACK GATE первой среди слоёв; gate обращается к Superpowers + Frontend Design. ASCII-диаграмма gate — без изменений (она уже про STACK GATE).
|
||||
|
||||
- [ ] **Step 5: R0.5 — проверка**
|
||||
|
||||
R0.5 «Перекрытие нижних слоёв» — убрать упоминание ruflo как слоя выше stack'а, если есть. Stack ниже только: прямые инструкции пользователя + файлы CLAUDE.md/Pravila/Tooling.
|
||||
|
||||
- [ ] **Step 6: Шапка — v3.1 → v3.2 + cross-refs**
|
||||
|
||||
Версия → v3.2, дата 16.05.2026. Cross-refs к Pravila → v1.16, CLAUDE.md → v2.2, Tooling → v2.2. Запись в «История версий»: реколлаж R0 sub-policy → top-of-stack gate (ruflo не entry-point по факту рантайма).
|
||||
|
||||
- [ ] **Step 7: Verify + commit**
|
||||
|
||||
Run: `git add docs/Plugin_stack_rules_v1.md && git commit -m "docs(rules): PSR_v1 v3.2 — реколлаж ruflo, R0 sub-policy → top-of-stack gate"`
|
||||
Expected: lefthook зелёный; commit создан.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tooling — реколлаж §4.10/§7/§0, v2.1 → v2.2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Tooling_v8_3.md`
|
||||
|
||||
- [ ] **Step 1: Read целевые секции**
|
||||
|
||||
Прочитать §4.10 (строки ~371-389), §7 (~428-453), §0 cross-refs, шапку.
|
||||
|
||||
- [ ] **Step 2: §4.10 — реколлаж заголовка и роли**
|
||||
|
||||
Заголовок `### 4.10. Orchestration layer (ruflo) — entry-point иерархии (off-phase)` → `### 4.10. ruflo — advisory/automation-подсистема (off-phase)`.
|
||||
|
||||
Удалить/переписать: фразу `встаёт «чистым верхом» над 8-уровневой иерархией ... уровень −1`; блок `**Архитектурная роль:** entry-point для ВСЕХ задач (уровень −1 иерархии)...delegate в sub-policy...`.
|
||||
|
||||
Целевая «Архитектурная роль»:
|
||||
|
||||
> **Архитектурная роль:** параллельная advisory/automation-подсистема, **не** entry-point. Фактический рантайм (инспекция 15.05.2026): hive-mind idle, 0 задач / 0 раундов консенсуса; Claude-сессии работают напрямую. Реально работают: UserPromptSubmit-хуки `ruflo-queen-hook.mjs` (§14 queen-триггер) и `ruflo-recall-hook.mjs` (memory recall) — уровень settings.json; daemon (PM2) и memory — фоновая подсистема вне priority chain; MCP-server (~210 tools) — off-phase инструмент.
|
||||
|
||||
Блоки «Runtime state», «Queen trigger», «Cost-budget», «Plugin discovery риск», «Связано» — сохранить (фактика), убрать из них формулировки «entry-point»/«уровень −1»/«delegate в sub-policy».
|
||||
|
||||
- [ ] **Step 3: §7 priority chain — verify**
|
||||
|
||||
§7 chain уже без уровня −1 (начинается с `0. Pravila §12`). Проверить grep'ом, что §7 не содержит «ruflo»/«уровень −1» в самой цепочке. Ожидаемо — правок 0; если найдётся ruflo-ссылка — убрать.
|
||||
|
||||
- [ ] **Step 4: §0 cross-refs + шапка — v2.1 → v2.2**
|
||||
|
||||
§0 cross-refs к Pravila → v1.16, PSR_v1 → v3.2, CLAUDE.md → v2.2. Шапка/Прил. Н версия → v2.2, дата 16.05.2026, запись об изменении: §4.10 реколлаж entry-point → advisory-подсистема.
|
||||
|
||||
- [ ] **Step 5: Verify + commit**
|
||||
|
||||
Run: `git add docs/Tooling_v8_3.md && git commit -m "docs(rules): Tooling v2.2 — реколлаж §4.10 ruflo entry-point → advisory-подсистема"`
|
||||
Expected: lefthook зелёный; commit создан.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: CLAUDE.md — реколлаж §1/§3/§3.5/§0/§6/§9, v2.1 → v2.2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `CLAUDE.md` — **только через `claude-md-management:claude-md-improver`** (CLAUDE.md §5 п.10; прямой Edit запрещён).
|
||||
|
||||
- [ ] **Step 1: Инвокировать плагин**
|
||||
|
||||
Вызвать `/claude-md-management:claude-md-improver` с targeted change-set ниже. Плагин синхронно проверяет cross-refs Pravila + Tooling (CLAUDE.md §5 п.7) — к моменту Task 4 они уже v1.16 / v2.2 (Tasks 1-3 закрыты).
|
||||
|
||||
- [ ] **Step 2: §1 priority chain — убрать уровень −1**
|
||||
|
||||
Удалить строку `−1. ruflo Queen-led routing (entry-point для ВСЕХ задач...)` + стрелку `↓` после неё + завершающий абзац `**Уровень −1 ruflo Queen-led routing** — entry-point с v2.0...`. Цепочка начинается с `0. Pravila §12 — Superpowers hard rule`. Строка уровня 0 уже корректна («Superpowers hard rule») — не трогать.
|
||||
|
||||
- [ ] **Step 3: §3 title**
|
||||
|
||||
`## 3. Карта 35 инструментов + ruflo orchestration layer...` → `## 3. Карта 35 инструментов + ruflo advisory-подсистема...`.
|
||||
|
||||
- [ ] **Step 4: §3.5 — реколлаж**
|
||||
|
||||
§3.5 «Off-phase orchestration: ruflo (entry-point иерархии)»: заголовок → `### 3.5. Off-phase: ruflo — advisory/automation-подсистема`. Тело: убрать `entry-point для ВСЕХ задач (level −1)`, `делает первичную классификацию ... delegate в sub-policy`, `встаёт «чистым верхом»`. Заменить роль на: параллельная advisory-подсистема — §14 queen-триггер хук + recall-хук + daemon + memory + MCP-tools; рантайм 0 задач, рой idle. «Runtime state» и «Queen trigger» абзацы — фактику сохранить, формулировки entry-point убрать.
|
||||
|
||||
- [ ] **Step 5: §0 cross-refs**
|
||||
|
||||
§0 row'ы: Pravila → v1.16, PSR_v1 → v3.2, Tooling → v2.2 (с краткими аннотациями реколлажа).
|
||||
|
||||
- [ ] **Step 6: §6 + §9**
|
||||
|
||||
§6 «Текущая фаза» — добавить абзац о реколлаже ruflo (декларация приведена к рантайму). §9 «История версий» — запись v2.2.
|
||||
|
||||
- [ ] **Step 7: Verify + commit**
|
||||
|
||||
Плагин отвечает за качество по своим quality-criteria. После применения:
|
||||
Run: `git add CLAUDE.md docs/Pravila_raboty_Claude_v1_1.md docs/Tooling_v8_3.md` (если плагин синхронно дотронул cross-refs) `&& git commit -m "docs(rules): CLAUDE.md v2.2 — реколлаж ruflo, убран уровень −1"`
|
||||
Expected: lefthook зелёный. Если Pravila/Tooling уже закоммичены в Tasks 1/3 и не менялись — в коммите только `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Memory sync
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `memory/project_state.md`, `memory/reference_archive.md`, `memory/project_ruflo_integration.md`, `memory/MEMORY.md`
|
||||
|
||||
- [ ] **Step 1: Re-Read каждый memory-файл**
|
||||
|
||||
Перед правкой — Read каждого (memory-факты не доверять stale).
|
||||
|
||||
- [ ] **Step 2: Правки**
|
||||
|
||||
- `project_state.md` — NB «ruflo — parallel subsystem, не фактический entry-point» уже честен; добавить факт реколлажа нормативки (Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2) + commit-хеши.
|
||||
- `reference_archive.md` — version refs Pravila/CLAUDE.md/PSR/Tooling → новые версии; пометка «ruflo — advisory-подсистема, не entry-point».
|
||||
- `project_ruflo_integration.md` — добавить S6: реколлаж декларации к рантайму (откат бумажной инверсии §12/R0).
|
||||
- `MEMORY.md` — обновить однострочники-указатели на изменённые файлы.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
`reference_archive.md` / `MEMORY.md` — внутренняя консистентность версий. Memory вне git lefthook scope (вне репо-tracked? — проверить: memory-каталог в `C:\Users\...\.claude\projects\...` вне репозитория проекта, git-коммит не требуется).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Финальная cross-consistency проверка
|
||||
|
||||
**Files:** все 4 нормативных файла (read-only проверка).
|
||||
|
||||
- [ ] **Step 1: Grep остатков уровня −1**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация"` затем grep по `docs/Pravila_raboty_Claude_v1_1.md`, `CLAUDE.md`, `docs/Plugin_stack_rules_v1.md`, `docs/Tooling_v8_3.md` паттернов: `уровень −1`, `уровня −1`, `entry-point для ВСЕХ`, `sub-policy под ruflo`, `Queen-led routing` (как уровня иерархии), `чистым верхом`.
|
||||
Expected: 0 совпадений в значении «ruflo = overlord». Допустимы вхождения в §9/История версий (описание прошлой версии) и §14 (queen-триггер маршрут) — глазами отделить.
|
||||
|
||||
- [ ] **Step 2: Grep консистентности priority chain**
|
||||
|
||||
Проверить, что priority chain во всех 4 файлах начинается с уровня 0 (Pravila §12 hard-rule) и идентичен по уровням 0–6.
|
||||
|
||||
- [ ] **Step 3: Verify §14 не повреждён**
|
||||
|
||||
Grep §14 в Pravila: §14.1–§14.5/§14.7/§14.8 — содержание прежнее; изменён только §14.6 cross-ref. §14 остаётся explicit hard-rule.
|
||||
|
||||
- [ ] **Step 4: lychee (pre-push)**
|
||||
|
||||
Run: `npm run links` — проверка ссылок в изменённых .md (новые spec/plan ссылки, cross-refs).
|
||||
Expected: 0 broken.
|
||||
|
||||
- [ ] **Step 5: Итог**
|
||||
|
||||
`superpowers:verification-before-completion` перед claim «реколлаж завершён». Сводка: 4 коммита нормативки + memory sync; grep-проверки чистые; lychee 0 broken. Push — по явному «пуш» от заказчика.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнен при написании плана)
|
||||
|
||||
- **Spec coverage:** 7 изменений спеки §2.2 → Task 1 (изм. 2,4 §12; часть 4 §14.6), Task 2 (изм. 3 R0), Task 3 (изм. 1 chain в §7 + изм. 5 §4.10), Task 4 (изм. 1 chain §1 + изм. 6 §3.5), Task 5 (изм. 7 memory). Изм. 1 (убрать уровень −1) распределено по всем файлам — покрыто Tasks 1-4 + проверено Task 6. ✓
|
||||
- **Placeholders:** нет TBD/TODO; целевые формулировки даны для title/cross-ref/priority chain; для длинных рерайтов (§4.10/§3.5/§0-note) — структурный спек с required/forbidden фразами + «read current first». ✓
|
||||
- **Type consistency:** версии сквозные — Pravila v1.16, PSR_v1 v3.2, Tooling v2.2, CLAUDE.md v2.2 во всех cross-ref'ах и шапках. ✓
|
||||
- **Ограничение:** §14 содержание не трогается ни в одной задаче (только §14.6 cross-ref); код рантайма ruflo не затрагивается. ✓
|
||||
@@ -0,0 +1,823 @@
|
||||
# Sprint 3B — Dashboard & Deep-links Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть аудит-эпики C1+J3 (живой дашборд через новый backend-эндпоинт) и C8+F3 (deep-link `/deals?openId=` из напоминаний и колокольчика).
|
||||
|
||||
**Architecture:** J3 — новый `DashboardController::summary` с агрегацией по `deals` + `tenants` (RLS-обёртка `SET LOCAL app.current_tenant_id`, паттерн `DealController`). C1 — `DashboardView` фетчит endpoint и пробрасывает данные в уже-prop-driven компоненты (`DashboardKpiRow`/`DashboardBalance`/`ActivityChart`/`FunnelChart`), при ошибке — fallback на mock. C8/F3 — три точки навигации переводятся на `query: { openId }`, а `DealsView` читает `route.query.openId` и открывает drawer найденной сделки.
|
||||
|
||||
**Tech Stack:** PHP 8.3 + Laravel 13, PostgreSQL 16 (партиционированная `deals`), Pest 4; Vue 3 `<script setup>` + Vuetify 3, vue-router 4, Vitest 4 + @vue/test-utils, TypeScript.
|
||||
|
||||
**Source:** [portal-wide audit spec §3 Sprint 3](../specs/2026-05-15-portal-audit-design.md) — эпики C1, J3, C8, F3.
|
||||
|
||||
**Branch:** feature-ветка от origin/main `65381f2` (Sprint 3A уже запушен). Не пушить без явного запроса заказчика.
|
||||
|
||||
---
|
||||
|
||||
## Контекст и факты recon
|
||||
|
||||
- **J3 — эндпоинта нет.** `routes/web.php` имеет только `Route::view('/dashboard','welcome')`; `DashboardController` отсутствует. Паттерн контроллера — `DealController` (`app/app/Http/Controllers/Api/DealController.php`): `DB::transaction` + `DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId)`, `tenant_id` query-параметром (MVP, без auth-middleware), defense-in-depth `where('tenant_id', …)`.
|
||||
- **Схема (`db/schema.sql`):** `deals` — `tenant_id`, `project_id`, `status` (slug), `received_at TIMESTAMPTZ` (ключ партиционирования), `is_test BOOLEAN`, `deleted_at` (soft-delete). `tenants` — `balance_rub DECIMAL(12,2)`, `balance_leads INT`, `limits JSONB` (`{"max_projects":10,...}`). Статус оплаты — slug `paid`.
|
||||
- **`Project` модель** — scope `active()` = `whereNull('archived_at')` (НЕ фильтрует `is_active`). «Активные проекты» дашборда = `archived_at IS NULL AND is_active = true`.
|
||||
- **C1 — все 4 dashboard-компонента уже prop-driven:** `DashboardKpiRow` (prop `kpis: Kpi[]`, тип экспортируется из компонента), `DashboardBalance` (prop `balance: Balance`, тип экспортируется), `ActivityChart` (props `points: number[]`, `labels: string[]`, `max: number`), `FunnelChart` (prop `counts: Record<string, number>`). DashboardView их не трогает — только фетч + проброс.
|
||||
- **C8** — `RemindersView.vue:70-73`: `openDeal(dealId)` → `void dealId; router.push('/deals')` (явно «на MVP без deep-link»).
|
||||
- **F3** — `AppTopbar.vue:56-62`: `handleNotificationClick(id, dealId)` → `markRead` + `if (dealId !== null) router.push('/deals')`. Notification имеет `deal_id: number | null` (`api/notifications.ts:26`).
|
||||
- **Deep-link consumer** — `DealsView.vue` НЕ читает `route.query`. Имеет `openDeal(deal: MockDeal)` → `selectedDeal.value = deal; drawerOpen.value = true`. `dealsState` грузится async (`loadDeals`, limit 200). `useRoute` сейчас не импортируется.
|
||||
- Тесты: backend — `app/tests/Feature/**/*.php` (Pest); frontend — `app/tests/Frontend/**/*.spec.ts` (Vitest). Команды backend — из `app/` (`composer test`, `php artisan test`); frontend — из `app/` (`npx vitest run …`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Действие |
|
||||
|---|---|---|
|
||||
| `app/app/Http/Controllers/Api/DashboardController.php` | агрегат дашборда | Create (Task 1) |
|
||||
| `app/routes/web.php` | маршрут `/api/dashboard/summary` | Modify (Task 1) |
|
||||
| `app/tests/Feature/DashboardSummaryTest.php` | Pest-тесты эндпоинта | Create (Task 1) |
|
||||
| `app/resources/js/api/dashboard.ts` | API-клиент дашборда | Create (Task 2) |
|
||||
| `app/resources/js/views/DashboardView.vue` | фетч + проброс props | Modify (Task 2) |
|
||||
| `app/tests/Frontend/DashboardView.spec.ts` | тесты DashboardView | Modify (Task 2) |
|
||||
| `app/resources/js/views/DealsView.vue` | чтение `route.query.openId` → drawer | Modify (Task 3) |
|
||||
| `app/resources/js/views/RemindersView.vue` | deep-link openDeal | Modify (Task 3) |
|
||||
| `app/resources/js/components/layout/AppTopbar.vue` | deep-link bell | Modify (Task 3) |
|
||||
| `app/tests/Frontend/DealsView.spec.ts` | тест openId-drawer | Modify (Task 3) |
|
||||
| `app/tests/Frontend/RemindersView.spec.ts` | тест deep-link | Modify (Task 3) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: J3 — backend `GET /api/dashboard/summary`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Controllers/Api/DashboardController.php`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Test: `app/tests/Feature/DashboardSummaryTest.php`
|
||||
|
||||
**Endpoint contract** — `GET /api/dashboard/summary?tenant_id=N&range=today|7d|30d` (range default `7d`):
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "7d",
|
||||
"leads_received": { "value": 247, "delta_pct": 12.3, "delta_dir": "up" },
|
||||
"conversion": { "value": 18.4, "delta_pp": 2.1, "delta_dir": "up" },
|
||||
"active_projects":{ "active": 8, "limit": 10 },
|
||||
"balance": { "amount_rub": "14250.00", "runway_days": 4, "runway_leads": 285 },
|
||||
"activity": { "points": [3,5,2,8,6,9,4], "labels": ["сб","вс","пн","вт","ср","чт","сегодня"], "max": 10 },
|
||||
"funnel": { "new": 18, "paid": 45, ... }
|
||||
}
|
||||
```
|
||||
|
||||
`delta_dir` ∈ `up|down|neutral`. Окна: `today` = [startOfDay, now], `7d` = [now−7d, now], `30d` = [now−30d, now]; предыдущее окно — равной длины непосредственно перед текущим. Все агрегаты — `tenant_id` + `deleted_at IS NULL` + `is_test = false`. `funnel` — текущий снимок (вне окна). `runway_leads` = `tenants.balance_leads`; `runway_days` = `floor(balance_leads / avgDailyLeads7d)` (avgDaily = leads за 7д / 7; при avgDaily=0 → 0). `activity` — 7 daily-бакетов по `received_at` в MSK; `max` = `max(10, ceil(maxPoint/10)*10)`.
|
||||
|
||||
- [ ] **Step 1: Изучить factory-паттерн существующих Feature-тестов**
|
||||
|
||||
Прочитать один существующий тест в `app/tests/Feature/` который создаёт `Tenant` + `Deal` (например любой `Deal*Test.php` или supplier-тест) — зафиксировать, как создаются tenant/project/deal (фабрики `Tenant::factory()`, `Project::factory()`, `Deal::factory()` или прямые `::create`), как тест выставляет `received_at`/`status`, и как пользуется RLS (`postgres` superuser на dev — BYPASSRLS). Тест Task 1 должен использовать ровно тот же механизм. Это не плейсхолдер — это обязательная сверка фактического API фабрик перед написанием теста.
|
||||
|
||||
- [ ] **Step 2: Написать падающий Pest-тест**
|
||||
|
||||
Создать `app/tests/Feature/DashboardSummaryTest.php`. Минимум 6 тест-кейсов (синтаксис Pest mirror `tests/Feature/`-паттерна из Step 1):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
|
||||
// helper: создать сделку с заданными status/received_at для тенанта/проекта.
|
||||
// Реализовать через фабрику/способ, зафиксированный в Step 1.
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10], 'balance_rub' => '14250.00', 'balance_leads' => 285]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonStructure([
|
||||
'range',
|
||||
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
|
||||
'conversion' => ['value', 'delta_pp', 'delta_dir'],
|
||||
'active_projects' => ['active', 'limit'],
|
||||
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
|
||||
'activity' => ['points', 'labels', 'max'],
|
||||
'funnel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25.0);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
});
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
});
|
||||
```
|
||||
|
||||
Реализовать `makeDeal($tenant, $project, $status, $receivedAt, $deletedAt = null, $isTest = false)` как локальный helper в файле теста, опираясь на фабрику из Step 1. `deals` партиционирована по `received_at` — убедиться, что партиция для тестовых дат существует (если тестовый bootstrap её не создаёт — использовать `received_at` в пределах мая-июня 2026, для которых партиции в `schema.sql` уже есть, либо вызвать `partitions:create-months`).
|
||||
|
||||
- [ ] **Step 3: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `php artisan test --filter=DashboardSummaryTest`
|
||||
Expected: FAIL — маршрут `/api/dashboard/summary` не существует (404 на всех кейсах).
|
||||
|
||||
- [ ] **Step 4: Добавить маршрут**
|
||||
|
||||
В `app/routes/web.php` рядом с другими `/api/*` (например после блока deals) добавить:
|
||||
|
||||
```php
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Реализовать DashboardController**
|
||||
|
||||
Создать `app/app/Http/Controllers/Api/DashboardController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
||||
*
|
||||
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
||||
*
|
||||
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
||||
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
||||
*
|
||||
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
||||
? (string) $request->query('range')
|
||||
: '7d';
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
[$windowStart, $prevStart] = match ($range) {
|
||||
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
|
||||
'30d' => [$now->subDays(30), $now->subDays(60)],
|
||||
default => [$now->subDays(7), $now->subDays(14)],
|
||||
};
|
||||
|
||||
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$base = fn () => DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false);
|
||||
|
||||
// --- leads received: текущее + предыдущее окно ---
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
$byDay = (clone $base())
|
||||
->where('received_at', '>=', $activityStart)
|
||||
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$points = [];
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$day = $now->subDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
$points[] = (int) ($byDay[$key] ?? 0);
|
||||
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
|
||||
}
|
||||
$maxPoint = $points === [] ? 0 : max($points);
|
||||
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
|
||||
|
||||
// --- funnel: текущий снимок по статусам ---
|
||||
$funnel = (clone $base())
|
||||
->selectRaw('status, COUNT(*) AS c')
|
||||
->groupBy('status')
|
||||
->pluck('c', 'status')
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway ---
|
||||
$avgDaily = $curLeads / 7.0; // средний дневной приток за 7д окно
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/** Процентная дельта current vs previous; 0.0 если previous=0. */
|
||||
private static function pctDelta(float $cur, float $prev): float
|
||||
{
|
||||
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/** Блок {value, <deltaKey>, delta_dir}. */
|
||||
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
|
||||
{
|
||||
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
|
||||
|
||||
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `php artisan test --filter=DashboardSummaryTest`
|
||||
Expected: PASS — все ≥7 кейсов зелёные. Если падает на партициях `deals` — перенести тестовые `received_at` в существующий партиционный диапазон или прогнать `php artisan partitions:create-months`.
|
||||
|
||||
- [ ] **Step 7: Проверка стиля и статанализа**
|
||||
|
||||
Run (из `app/`): `composer pint` и `composer stan`
|
||||
Expected: Pint — без правок (или авто-fix применён), Larastan — 0 ошибок (при новых false-positive по `Request::query` — добавить запись в `phpstan-baseline.neon` через `--generate-baseline`, как принято в проекте).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DashboardController.php app/routes/web.php app/tests/Feature/DashboardSummaryTest.php app/phpstan-baseline.neon
|
||||
git commit -m "feat(dashboard): GET /api/dashboard/summary — агрегат KPI/баланса/активности (audit J3)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
(`phpstan-baseline.neon` добавлять только если он реально изменён.) Lefthook pre-commit прогонит pint/larastan/squawk/gitleaks — если упадёт, чинить причину, не `--no-verify`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: C1 — DashboardView на real API
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/api/dashboard.ts`
|
||||
- Modify: `app/resources/js/views/DashboardView.vue`
|
||||
- Test: `app/tests/Frontend/DashboardView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест API-клиента + view**
|
||||
|
||||
Создать `app/resources/js/api/dashboard.ts` пока НЕ создаём — сначала тест. Полностью переписать `app/tests/Frontend/DashboardView.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DashboardView from '../../resources/js/views/DashboardView.vue';
|
||||
import type { DashboardSummary } from '../../resources/js/api/dashboard';
|
||||
|
||||
vi.mock('../../resources/js/api/dashboard', () => ({
|
||||
getDashboardSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const dashboardApi = await import('../../resources/js/api/dashboard');
|
||||
|
||||
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
|
||||
return {
|
||||
range: '7d',
|
||||
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
||||
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () => {
|
||||
setActivePinia(createPinia());
|
||||
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
|
||||
it('getDashboardSummary вызывается на mount', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('успех — KPI и баланс из API видны', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
|
||||
);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Получено лидов');
|
||||
expect(text).toContain('Конверсия в оплату');
|
||||
expect(text).toContain('Активные проекты');
|
||||
expect(text).toContain('Баланс');
|
||||
expect(text).toContain('99 000');
|
||||
});
|
||||
|
||||
it('ошибка API — fallback на mock, view не падает', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Получено лидов');
|
||||
expect(wrapper.find('.runway-fill').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('смена range перезапрашивает summary', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
(wrapper.vm as unknown as { range: string }).range = '30d';
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DashboardView.spec.ts`
|
||||
Expected: FAIL — `api/dashboard.ts` не существует (import не резолвится).
|
||||
|
||||
- [ ] **Step 3: Создать API-клиент**
|
||||
|
||||
Создать `app/resources/js/api/dashboard.ts`:
|
||||
|
||||
```ts
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
|
||||
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
|
||||
*/
|
||||
|
||||
export type DeltaDir = 'up' | 'down' | 'neutral';
|
||||
export type DashboardRange = 'today' | '7d' | '30d';
|
||||
|
||||
export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
balance: { amount_rub: string; runway_days: number; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
|
||||
params: { tenant_id: tenantId, range },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Переписать DashboardView на фетч + проброс**
|
||||
|
||||
Заменить `<script setup>` в `app/resources/js/views/DashboardView.vue` (template/charts row остаются; KPI-row и balance теперь из reactive-state). Полный новый `<script setup>`:
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
||||
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
||||
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
import ActivityChart from '../components/charts/ActivityChart.vue';
|
||||
import FunnelChart from '../components/charts/FunnelChart.vue';
|
||||
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
|
||||
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
|
||||
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
|
||||
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const range = ref<DashboardRange | 'custom'>('7d');
|
||||
|
||||
// runwayMax — display-константа полосы (7 сегментов), не из API.
|
||||
const RUNWAY_MAX = 7;
|
||||
|
||||
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
|
||||
const MOCK_KPIS: Kpi[] = [
|
||||
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
|
||||
];
|
||||
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
|
||||
|
||||
const kpis = ref<Kpi[]>(MOCK_KPIS);
|
||||
const balance = ref<Balance>(MOCK_BALANCE);
|
||||
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
|
||||
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
|
||||
const activityMax = ref(60);
|
||||
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
|
||||
const fetchError = ref(false);
|
||||
|
||||
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
|
||||
function formatRub(raw: string): string {
|
||||
const int = Math.round(parseFloat(raw)).toString();
|
||||
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
|
||||
function applySummary(s: DashboardSummary): void {
|
||||
kpis.value = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: String(s.leads_received.value),
|
||||
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: String(s.conversion.value),
|
||||
unit: '%',
|
||||
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: String(s.active_projects.active),
|
||||
unit: `/ ${s.active_projects.limit}`,
|
||||
delta: { dir: 'neutral', text: '' },
|
||||
sub: 'лимит тарифа',
|
||||
},
|
||||
];
|
||||
balance.value = {
|
||||
amount: formatRub(s.balance.amount_rub),
|
||||
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
|
||||
runwayMax: RUNWAY_MAX,
|
||||
runwayLeads: s.balance.runway_leads,
|
||||
};
|
||||
activityPoints.value = s.activity.points;
|
||||
activityLabels.value = s.activity.labels;
|
||||
activityMax.value = s.activity.max;
|
||||
funnelCounts.value = s.funnel;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tenantId = auth.user?.tenant_id;
|
||||
if (!tenantId || range.value === 'custom') return;
|
||||
try {
|
||||
applySummary(await getDashboardSummary(tenantId, range.value));
|
||||
fetchError.value = false;
|
||||
} catch {
|
||||
fetchError.value = true; // оставляем последнее значение / mock
|
||||
}
|
||||
}
|
||||
|
||||
watch(range, load);
|
||||
load();
|
||||
</script>
|
||||
```
|
||||
|
||||
Шаблон `<template>` менять минимально — заменить статичные `:kpis="kpis"` / `:balance="balance"` на reactive-ref'ы (Vue разворачивает `.value` в шаблоне автоматически, синтаксис `:kpis="kpis"` не меняется) и пробросить чарт-props + funnel + degradation-alert. Заменить блок `<v-row class="charts-row …">` на:
|
||||
|
||||
```html
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="dashboard-fetch-error"
|
||||
>
|
||||
Не удалось обновить данные дашборда — показаны последние известные значения.
|
||||
</v-alert>
|
||||
|
||||
<v-row class="charts-row mt-4">
|
||||
<v-col cols="12" md="7">
|
||||
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<FunnelChart :counts="funnelCounts" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
```
|
||||
|
||||
(`FunnelChart` prop `counts` опциональный — `undefined` оставит его mock-default; при успехе API передаст реальные counts.)
|
||||
|
||||
- [ ] **Step 5: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DashboardView.spec.ts`
|
||||
Expected: PASS — 4 теста зелёные.
|
||||
|
||||
- [ ] **Step 6: type-check + lint**
|
||||
|
||||
Run (из `app/`): `npm run type-check` и `npm run lint:vue`
|
||||
Expected: 0 ошибок. `ActivityChart` prop `points` non-optional при передаче — ок; `FunnelChart` `counts?: Record<string,number>` принимает `undefined`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/dashboard.ts app/resources/js/views/DashboardView.vue app/tests/Frontend/DashboardView.spec.ts
|
||||
git commit -m "feat(dashboard): DashboardView на real API /api/dashboard/summary (audit C1)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: C8 + F3 — deep-link `/deals?openId=`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/DealsView.vue`
|
||||
- Modify: `app/resources/js/views/RemindersView.vue`
|
||||
- Modify: `app/resources/js/components/layout/AppTopbar.vue`
|
||||
- Test: `app/tests/Frontend/DealsView.spec.ts`, `app/tests/Frontend/RemindersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты**
|
||||
|
||||
**1a.** В `app/tests/Frontend/DealsView.spec.ts` добавить тест: при `route.query.openId` совпадающем с id сделки в `dealsState` — drawer открыт. Использовать установленный в файле паттерн mount'а DealsView с роутером; если файл монтирует без роутера — добавить memory-router с маршрутом `/deals` и `push('/deals?openId=<id>')` до mount. Тест (адаптировать имена под фактический mount-helper файла):
|
||||
|
||||
```ts
|
||||
it('route.query.openId открывает drawer соответствующей сделки', async () => {
|
||||
// mount DealsView на /deals?openId=<существующий id из MOCK_DEALS>
|
||||
// после loadDeals() (flushPromises) — drawerOpen=true, selectedDeal.id === openId
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
|
||||
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
|
||||
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
`drawerOpen` и `selectedDeal` добавить в `defineExpose` DealsView (Step 3).
|
||||
|
||||
**1b.** В `app/tests/Frontend/RemindersView.spec.ts` добавить тест: `openDeal(42)` вызывает `router.push` с `{ path: '/deals', query: { openId: 42 } }`. Использовать `vi.spyOn(router, 'push')` по паттерну файла.
|
||||
|
||||
- [ ] **Step 2: Запустить тесты — убедиться, что падают**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/RemindersView.spec.ts`
|
||||
Expected: FAIL — DealsView не реагирует на `openId`; RemindersView пушит `/deals` без query.
|
||||
|
||||
- [ ] **Step 3: DealsView — читать `route.query.openId`**
|
||||
|
||||
В `app/resources/js/views/DealsView.vue`:
|
||||
|
||||
**3a.** В импортах добавить `useRoute`:
|
||||
|
||||
```ts
|
||||
import { useRoute } from 'vue-router';
|
||||
```
|
||||
|
||||
и в `<script setup>` рядом с другими const'ами:
|
||||
|
||||
```ts
|
||||
const route = useRoute();
|
||||
```
|
||||
|
||||
**3b.** Добавить функцию открытия по id и вызвать её после загрузки. После `function openDeal(deal: MockDeal) { … }` добавить:
|
||||
|
||||
```ts
|
||||
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
|
||||
function openDealFromQuery(): void {
|
||||
const raw = route.query.openId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
const deal = dealsState.find((d) => d.id === id);
|
||||
if (deal) openDeal(deal);
|
||||
}
|
||||
```
|
||||
|
||||
**3c.** В `onMounted` — вызвать после загрузки. Заменить:
|
||||
|
||||
```ts
|
||||
onMounted(() => {
|
||||
void leadStatusesStore.load();
|
||||
void loadDeals();
|
||||
});
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
onMounted(async () => {
|
||||
void leadStatusesStore.load();
|
||||
await loadDeals();
|
||||
openDealFromQuery();
|
||||
});
|
||||
```
|
||||
|
||||
И реагировать на смену query (навигация на `/deals?openId=` когда DealsView уже смонтирован) — добавить watch рядом с другими watch'ами:
|
||||
|
||||
```ts
|
||||
watch(
|
||||
() => route.query.openId,
|
||||
() => openDealFromQuery(),
|
||||
);
|
||||
```
|
||||
|
||||
**3d.** В `defineExpose({ … })` добавить `drawerOpen`, `selectedDeal`, `openDealFromQuery` (для тестов).
|
||||
|
||||
- [ ] **Step 4: RemindersView — deep-link openDeal**
|
||||
|
||||
В `app/resources/js/views/RemindersView.vue` заменить:
|
||||
|
||||
```ts
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
void dealId; // на MVP — без deep-link на конкретный drawer.
|
||||
await router.push('/deals');
|
||||
}
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
// Audit C8: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: AppTopbar — deep-link bell**
|
||||
|
||||
В `app/resources/js/components/layout/AppTopbar.vue` заменить:
|
||||
|
||||
```ts
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// На MVP — push на DealsView (deep-link на конкретный drawer — отдельный коммит).
|
||||
await router.push('/deals');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// Audit F3: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тесты — убедиться, что проходят**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/RemindersView.spec.ts`
|
||||
Expected: PASS — все тесты зелёные.
|
||||
|
||||
- [ ] **Step 7: Полный sweep**
|
||||
|
||||
Run (из `app/`): `npm run test:vue`, `npm run type-check`, `npm run lint:vue`
|
||||
Expected: Vitest 0 failed (выписать точные счётчики), type-check 0, lint 0.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/DealsView.vue app/resources/js/views/RemindersView.vue app/resources/js/components/layout/AppTopbar.vue app/tests/Frontend/DealsView.spec.ts app/tests/Frontend/RemindersView.spec.ts
|
||||
git commit -m "feat(deals): deep-link /deals?openId= из напоминаний и колокольчика (audit C8/F3)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- **J3:** `GET /api/dashboard/summary?tenant_id=N&range=…` возвращает контракт выше; Pest ≥7 кейсов зелёные; Pint/Larastan 0.
|
||||
- **C1:** DashboardView грузит summary на mount + при смене range; KPI/баланс/активность/воронка — из API; ошибка → degradation-alert + последние/mock-значения.
|
||||
- **C8/F3:** клик по напоминанию и по уведомлению-колокольчику с `deal_id` ведёт на `/deals?openId=<id>`; DealsView открывает drawer найденной сделки; openId не найден → no-op без ошибки.
|
||||
- `npm run test:vue` 0 failed; `npm run type-check` 0; `npm run lint:vue` 0; `php artisan test --filter=DashboardSummaryTest` 0 failed.
|
||||
- 3 атомарных коммита (J3 / C1 / C8+F3). Без push до явного запроса.
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**Spec coverage:** C1 → Task 2; J3 → Task 1; C8 → Task 3 (RemindersView + DealsView consumer); F3 → Task 3 (AppTopbar + DealsView consumer). Все 4 ID Sprint 3B покрыты.
|
||||
|
||||
**Placeholder scan:** код приведён полностью. Единственная инструкция-без-кода — Task 1 Step 1 (изучить factory-паттерн существующих Feature-тестов) — это обязательная сверка фактического API фабрик, т.к. точный API `Deal::factory()` не в контексте автора плана; и Task 3 Step 1 (адаптировать mount-helper под фактический DealsView.spec) — оба требуют чтения одного reference-файла, не выдумывания.
|
||||
|
||||
**Type consistency:** `DashboardSummary` (api/dashboard.ts) ↔ контракт эндпоинта J3 ↔ `applySummary` в DashboardView совпадают по полям. `Kpi`/`Balance` импортируются из существующих компонентов без изменения. `getDashboardSummary(tenantId, range)` — единая сигнатура в клиенте, тесте и view.
|
||||
|
||||
**Риск:** партиционирование `deals` по `received_at` — тестовые даты должны попадать в существующие партиции (май-окт 2026 уже в schema.sql) либо `partitions:create-months` (отмечено в Task 1 Step 2/6).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
# Sprint 3F — API middleware (J1/J2) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть аудит-находки J1 (auth+tenant middleware на `/api/deals*`) и J2 (стаб-гейт SaaS-admin зоны `/api/admin/*`) — убрать незащищённые API-эндпоинты, где tenant подставляется параметром запроса.
|
||||
|
||||
**Architecture:** J1 — на 8 роутов `/api/deals*` навешивается `['auth:sanctum','tenant']`; три контроллера (`DealController`, `DealBulkActionController`, `DealExportController`) перестают читать `tenant_id` из запроса и берут его из `auth()->user()->tenant_id`; 8 Pest-файлов мигрируют с `?tenant_id=` на `actingAs($user)`. J2 — новый middleware `EnsureSaasAdmin` (стаб: dev/testing пропускает, production fail-closed 503) вешается на весь блок `/api/admin/*`; реальная Yandex 360 SSO-авторизация — TODO под Б-1+DO-4.
|
||||
|
||||
**Tech Stack:** PHP 8.3 + Laravel 13, Sanctum SPA session auth, PostgreSQL 16 (RLS), Pest 4.
|
||||
|
||||
---
|
||||
|
||||
## Контекст (audit J1/J2)
|
||||
|
||||
Аудит портала ([docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md)), раздел J:
|
||||
|
||||
- **J1** — «CTO-18 — auth+tenant middleware на `/api/deals` (требует Б-1 для prod)». Сейчас 8 роутов `/api/deals*` идут **без middleware**: `tenant_id` берётся параметром. Любой клиент читает/пишет сделки чужого тенанта, подставив `tenant_id`. `auth:sanctum`+`tenant` уже используются на `/api/reminders`, `/api/reports/*`, `/api/billing/*`, `/api/projects` — на dev работают, Б-1 их **не блокирует** (Б-1 блокирует только production-deploy). J1 = применить тот же middleware к `/api/deals*`.
|
||||
- **J2** — «`/api/admin/*` — auth:saas-admin middleware (требует Б-1 + DO-4)». Гварда `saas-admin` в `config/auth.php` **нет** (только `web`); реальный гвард = Yandex 360 SSO, аудит явно пишет «**после Б-1+DO-4**» — оба registry-блокера открыты. Полноценный J2 невозможен. Решение заказчика (2026-05-16): **заготовка-стаб** — middleware-гейт, на dev пропускает, на production fail-closed; production SSO — TODO.
|
||||
|
||||
**Scope J1 — backend + Pest.** Фронтенд (`app/resources/js/api/deals.ts` и 3 view) НЕ трогаем: после рефактора backend игнорирует клиентский `tenant_id` (Laravel `validate()` молча отбрасывает лишние ключи; лишний query-параметр игнорируется), фронт продолжает слать сессионную cookie и работает без изменений. Клиентский `tenant_id` становится вестигиальным безвредным параметром — его удаление косметическое, в аудите J1 не значится, вне scope Sprint 3F. Это устраняет дублирующий риск (8 frontend-файлов + 11 Vitest-спеков) при нулевом выигрыше для безопасности: backend, игнорируя клиентский `tenant_id`, уже закрывает кросс-tenant утечку.
|
||||
|
||||
**Регистровые items.** J1 связан с CTO-18, J2 — с Б-1+DO-4 (все открыты). Sprint 3F реализует **код** находок J1/J2 (что заказчик авторизовал командой «делай 3f»), но **не закрывает** CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` — закрытие требует явного «закрываем» от заказчика. Реестр в этом спринте не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Task 1 (J2):**
|
||||
|
||||
- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php` — стаб-гейт SaaS-admin зоны.
|
||||
- Modify: `app/bootstrap/app.php` — alias `'saas-admin'` в `$middleware->alias([...])`.
|
||||
- Modify: `app/routes/web.php` — обернуть блок `/api/admin/*` (impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) в `Route::middleware('saas-admin')->group(...)`.
|
||||
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php` — passthrough на testing + fail-closed 503 на production.
|
||||
|
||||
**Task 2 (J1):**
|
||||
|
||||
- Modify: `app/routes/web.php` — 8 роутов `/api/deals*` в `Route::middleware(['auth:sanctum','tenant'])->group(...)`.
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php` — `index/show/store/update`: tenant из `auth()->user()`.
|
||||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php` — `transition/destroy/restore`: то же.
|
||||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php` — `export`: то же.
|
||||
- Test (migrate): `app/tests/Feature/DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php` (только 3 `/api/deals`-теста).
|
||||
|
||||
**НЕ трогать:** `app/dev-indices.json` (авто-генерируемый, pre-existing `M` — не стейджить); фронтенд `deals.ts` и deal-views (см. Scope выше); `DealModelTest.php` (модельный unit-тест, HTTP не вызывает); lookup-эндпоинты `/api/managers` и `/api/lead-statuses` (в аудит-находке J1 не значатся — остаются без middleware; `/api/managers`-тесты в `LookupsTest` не трогать).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: J2 — стаб-гейт `EnsureSaasAdmin` на `/api/admin/*`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php`
|
||||
- Modify: `app/bootstrap/app.php`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать failing-тест `SaasAdminMiddlewareTest.php`**
|
||||
|
||||
Создать `app/tests/Feature/SaasAdminMiddlewareTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
|
||||
Expected: FAIL — middleware `EnsureSaasAdmin` ещё не существует, alias `saas-admin` не зарегистрирован, на роуты не навешан; тест «503 вне dev/testing» получит 200.
|
||||
|
||||
- [ ] **Step 3: Создать middleware `EnsureSaasAdmin.php`**
|
||||
|
||||
Создать `app/app/Http/Middleware/EnsureSaasAdmin.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зарегистрировать alias `saas-admin` в `bootstrap/app.php`**
|
||||
|
||||
В `app/bootstrap/app.php` добавить импорт и расширить `$middleware->alias([...])`:
|
||||
|
||||
Импорт (после `use App\Http\Middleware\SetTenantContext;`):
|
||||
|
||||
```php
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
```
|
||||
|
||||
Блок alias заменить на:
|
||||
|
||||
```php
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Навесить `saas-admin` на блок `/api/admin/*` в `routes/web.php`**
|
||||
|
||||
В `app/routes/web.php` весь блок admin-роутов (от комментария `// SaaS-admin impersonation flow (Ю-1)...` до строки с `AdminSuppliersController@update` включительно — это группы impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) обернуть в `Route::middleware('saas-admin')->group(...)`. Структура:
|
||||
|
||||
```php
|
||||
// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
|
||||
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
|
||||
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
|
||||
// стаб не меняет — это отдельная зона ответственности.
|
||||
Route::middleware('saas-admin')->group(function () {
|
||||
// SaaS-admin impersonation flow (Ю-1). ...
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
// ... без изменений ...
|
||||
});
|
||||
|
||||
// ... все остальные admin-роуты без изменений, только с отступом +4 ...
|
||||
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
```
|
||||
|
||||
Содержимое роутов внутри — без изменений (только индентация +4; `composer pint` в Step 7 выровняет, но писать сразу корректно). Роуты `/api/billing/charges`, `/api/billing/*`, `/api/api-keys`, `/api/tenants/me/webhook-settings`, `/api/dashboard/summary` и далее — **вне** этой группы (это tenant-зона, не admin).
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — убедиться, что зелёный**
|
||||
|
||||
Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
|
||||
Expected: PASS — 2/2.
|
||||
|
||||
- [ ] **Step 7: Pint + Larastan + регрессия admin-тестов**
|
||||
|
||||
Run: `cd app && composer pint` → 0 правок или авто-формат применён.
|
||||
Run: `cd app && composer stan` → 0 ошибок (новый файл middleware типизирован; тест использует только реальные методы `TestCase`, динамических свойств нет — baseline regen не требуется).
|
||||
Run: `cd app && composer test -- --filter="Admin"` → 0 failed. Все существующие admin-тесты (AdminBilling/AdminIncidents/AdminTenants/AdminSystemSettings/AdminPricingTiers/AdminSuppliers/Impersonation) проходят: на `testing`-окружении `EnsureSaasAdmin` прозрачен.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Middleware/EnsureSaasAdmin.php app/bootstrap/app.php app/routes/web.php app/tests/Feature/SaasAdminMiddlewareTest.php
|
||||
git commit -m "feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/*"
|
||||
```
|
||||
|
||||
**НЕ стейджить** `app/dev-indices.json`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: J1 — `auth:sanctum`+`tenant` middleware на `/api/deals*`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/routes/web.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php`
|
||||
- Test (migrate): `DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php`
|
||||
|
||||
> **NB про порядок шагов:** миграция атомарна — добавление middleware немедленно «краснит» все 8 deal-тест-файлов (они не аутентифицируются). Поэтому routes+контроллеры+тесты мигрируют в одной задаче/одном коммите; промежуточный red — внутри задачи (Step 3 это фиксирует как TDD-red), green — в Step 6.
|
||||
|
||||
- [ ] **Step 1: Навесить middleware на 8 роутов `/api/deals*` в `routes/web.php`**
|
||||
|
||||
В `app/routes/web.php` блок из 8 deal-роутов (`GET /api/deals`, `GET /api/deals/{id}`, `POST /api/deals`, `POST /api/deals/export`, `POST /api/deals/transition`, `PATCH /api/deals/{id}`, `DELETE /api/deals`, `POST /api/deals/restore`) обернуть в группу. Заменить docblock-комментарий и роуты на:
|
||||
|
||||
```php
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
|
||||
//
|
||||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
|
||||
// DealController, bulk (transition/destroy/restore) — в
|
||||
// DealBulkActionController, export — в DealExportController.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||||
});
|
||||
```
|
||||
|
||||
Lookup-роуты `/api/managers` и `/api/lead-statuses` (идут сразу после) — **вне** группы, без изменений.
|
||||
|
||||
- [ ] **Step 2: Рефактор контроллеров — tenant из `auth()->user()`**
|
||||
|
||||
Универсальное правило для всех 4 методов `DealController` + 3 методов `DealBulkActionController` + `export` `DealExportController`:
|
||||
|
||||
1. Убрать чтение `tenant_id` из запроса: для `index`/`show` — строку `$tenantId = (int) $request->query('tenant_id', '0');` и следующий за ней блок `if ($tenantId < 1) { return ... 422; }`. Для `store`/`update`/`transition`/`destroy`/`restore`/`export` — ключ `'tenant_id' => 'required|integer|min:1',` из массива правил `$request->validate([...])`.
|
||||
2. Убрать резолюцию `Tenant` + 404: блок `$tenant = Tenant::find(...); if ($tenant === null) { return ... 404; }` (в `export` — `abort(404, ...)`).
|
||||
3. Добавить `$tenantId = (int) $request->user()->tenant_id;` (для `index`/`show` — на месте удалённого; для остальных — сразу после `$validated = $request->validate([...]);`).
|
||||
4. Заменить все `$tenant->id` на `$tenantId`, в `use (...)` замыканий `$tenant` → `$tenantId`.
|
||||
5. Убрать `use App\Models\Tenant;` (станет неиспользуемым; `composer pint` подчистит, но убрать явно).
|
||||
6. Внутренние `DB::transaction(...)` + `DB::statement('SET LOCAL app.current_tenant_id = ...')` — **оставить без изменений**. Для write-методов это атомарность; для `DealExportController::export` это **обязательно** — StreamedResponse-замыкание выполняется уже после commit'а транзакции `tenant`-middleware (см. комментарий в `export()` строки про «после Laravel-response pipeline»), tenant-контекст middleware streaming НЕ покрывает.
|
||||
7. Обновить docblock-и: убрать абзацы «На MVP без auth-middleware… `tenant_id` параметром… Production: middleware» — заменить на «J1 (Sprint 3F): `auth:sanctum`+`tenant`, `tenant_id` из `auth()->user()`.»
|
||||
|
||||
Конкретно по `DealController`:
|
||||
|
||||
- `index(Request $request)`: удалить строки `$tenantId = (int) $request->query('tenant_id', '0');` + `if ($tenantId < 1) {...422}` + `$tenant = Tenant::find($tenantId);` + `if ($tenant === null) {...404}`. На их место: `$tenantId = (int) $request->user()->tenant_id;`. Остальное (уже использует `$tenantId`) — без изменений.
|
||||
- `show(Request $request, int $id)`: то же — удалить query/422/Tenant::find/404, поставить `$tenantId = (int) $request->user()->tenant_id;`.
|
||||
- `store(Request $request)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)` замыкания, `SET LOCAL`, `Project::firstOrCreate`, `Deal::create`, `ActivityLog::create`).
|
||||
- `update(Request $request, int $id)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)`, `SET LOCAL`, оба `where('tenant_id', ...)`, три `ActivityLog::create(['tenant_id' => ...])`).
|
||||
|
||||
`DealBulkActionController` — `transition`/`destroy`/`restore` идентично: убрать `tenant_id` из `validate`, убрать `Tenant::find`+404, `$tenantId = (int) $request->user()->tenant_id;`, `$tenant->id` → `$tenantId`, `use ($validated, $tenant)` → `use ($validated, $tenantId)`.
|
||||
|
||||
`DealExportController::export` — убрать `tenant_id` из `validate`, убрать `Tenant::find`+`abort(404)`, `$tenantId = (int) $request->user()->tenant_id;`, в `use (...)` StreamedResponse-замыкания `$tenant` → `$tenantId`, `$tenant->id` → `$tenantId`.
|
||||
|
||||
- [ ] **Step 3: Прогнать deal-тесты — убедиться в массовом red**
|
||||
|
||||
Run: `cd app && composer test -- --filter="Deal"`
|
||||
Expected: FAIL — `DealIndexTest`/`DealShowTest`/`DealCreateTest`/`DealUpdateTest`/`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest` массово красные: запросы без `actingAs` теперь получают `401`. Это подтверждает, что auth-гейт активен (TDD-red).
|
||||
|
||||
- [ ] **Step 4: Мигрировать 8 тест-файлов на `actingAs`**
|
||||
|
||||
Универсальный рецепт для каждого HTTP-теста на `/api/deals*`:
|
||||
|
||||
**(R1) `beforeEach`** — после создания `$this->tenant` добавить:
|
||||
|
||||
```php
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
```
|
||||
|
||||
(`use App\Models\User;` — добавить в импорты файла, если ещё нет.)
|
||||
|
||||
**(R2) URL** — убрать `?tenant_id=...` / `&tenant_id=...`: `'/api/deals?tenant_id='.$this->tenant->id` → `'/api/deals'`; `'/api/deals?tenant_id='.$id.'&status_in[]=new'` → `'/api/deals?status_in[]=new'` (если параметр был первым — следующий `&` становится `?`).
|
||||
|
||||
**(R3) Body** — убрать ключ `'tenant_id' => ...,` из массивов `postJson`/`patchJson`/`deleteJson`.
|
||||
|
||||
**(R4) Тесты «404 unknown tenant_id»** — **удалить целиком**. После J1 tenant берётся из `auth()->user()->tenant_id` (FK-гарантированно валиден), пути «unknown tenant» больше нет. Удаляются: `DealIndexTest` «404 для unknown tenant_id», `DealShowTest` «404 для unknown tenant», `DealUpdateTest` «404 unknown tenant», `DealTransitionTest` «404 на unknown tenant», `DealDestroyTest` «404 на unknown tenant», `DealRestoreTest` «404 на unknown tenant», `DealCreateTest` «404 при unknown tenant_id» и «POST /api/deals/export 404 unknown tenant».
|
||||
|
||||
**(R5) Тесты «422 без tenant_id»** — конвертировать в «401 без auth»: тело — запрос **без** `actingAs` → `401`. Пример (`DealIndexTest`):
|
||||
|
||||
```php
|
||||
test('GET /api/deals возвращает 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/deals')->assertStatus(401);
|
||||
});
|
||||
```
|
||||
|
||||
Конвертируются: `DealIndexTest` «422 без tenant_id», `DealShowTest` «422 без tenant_id», `DealUpdateTest` «422 без tenant_id».
|
||||
|
||||
**(R6) Endpoints без теста «422 без tenant_id»** (transition/destroy/restore/store/export) — добавить по одному новому тесту «401 без auth» (запрос без `actingAs`), чтобы каждый из 8 endpoint'ов имел 401-покрытие. Пример (`DealTransitionTest`):
|
||||
|
||||
```php
|
||||
test('POST /api/deals/transition возвращает 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
|
||||
});
|
||||
```
|
||||
|
||||
**(R7) Тесты пустого body «422»** (`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest`: `postJson('/api/deals/transition', [])->assertStatus(422)` и аналоги) — остаются `422` (поля `ids`/`status` по-прежнему `required`); `actingAs` обеспечивается через `beforeEach` (R1), иначе был бы `401`. Если имя теста содержит «без tenant_id» — переименовать (например «422 на пустой body»).
|
||||
|
||||
**(R8) `DealCreateTest` «422 без обязательных полей»** — остаётся `422` (`project_name`/`phone` по-прежнему `required`), но `assertJson`-проверка ключей: `toHaveKeys(['tenant_id', 'project_name', 'phone'])` → `toHaveKeys(['project_name', 'phone'])` (`tenant_id` больше не валидируемое поле).
|
||||
|
||||
**(R9) Кросс-tenant RLS-тесты** (`DealIndexTest` «не возвращает сделки чужого tenant'а», «изолирует чужие удалённые»; `DealShowTest` «404 чужая сделка»; `DealUpdateTest` «404 чужая сделка»; и т.п.) — **оставить логику**: чужие данные сеются через `DB::statement('SET app.current_tenant_id = ...')`, `actingAs` — пользователь `$this->tenant`. Применить только R2/R3 (убрать `tenant_id` из запроса). Изоляция продолжает проверяться: backend берёт tenant из auth-пользователя.
|
||||
|
||||
**(R10) `LookupsTest.php`** — содержит тесты `/api/managers` (НЕ трогать — endpoint без middleware) и 3 теста `POST /api/deals` (manager-guard). `beforeEach` НЕ менять (тесты `/api/managers` чувствительны к числу users тенанта — лишний `$this->user` сломал бы `toHaveCount(2)`). Вместо этого в каждый из 3 `/api/deals`-тестов («422 если manager_id не принадлежит tenant'у», «422 если manager_id не активен», «принимает manager_id из своего tenant'а») первой строкой добавить `$this->actingAs(User::factory()->for($this->tenant)->create());` и применить R3 (убрать `tenant_id` из body). Файл использует `RefreshDatabase` — created user не протекает между тестами.
|
||||
|
||||
- [ ] **Step 5: Прогнать deal-тесты — убедиться в green**
|
||||
|
||||
Run: `cd app && composer test -- --filter="Deal"`
|
||||
Run: `cd app && composer test -- --filter="LookupsTest"`
|
||||
Expected: PASS — 0 failed в обоих. Точное число тестов — из реального вывода (R4 удалил 8 тестов, R5/R6 добавил/конвертировал 401-тесты).
|
||||
|
||||
- [ ] **Step 6: Pint + Larastan (regen baseline) + полная регрессия Pest**
|
||||
|
||||
Run: `cd app && composer pint` → авто-формат применён (в т.ч. удаление неиспользуемого `use App\Models\Tenant;`).
|
||||
|
||||
Run: `cd app && composer stan` → ожидаются НОВЫЕ ошибки от `$this->user` (новое динамическое свойство в тест-файлах) + сдвиг номеров строк → регенерировать baseline (quirk 25, 3 шага):
|
||||
|
||||
1. В `app/phpstan.neon` временно закомментировать строку `- phpstan-baseline.neon` в `includes:`.
|
||||
2. Run: `cd app && vendor/bin/phpstan analyse --generate-baseline`
|
||||
3. Раскомментировать `- phpstan-baseline.neon` в `app/phpstan.neon`.
|
||||
|
||||
После — повторно `cd app && composer stan` → 0 ошибок.
|
||||
|
||||
Run: `cd app && composer test` → 0 failed (полная регрессия Pest). Базовый объём перед Sprint 3F (origin/main `ca0c4d9`) — 853 tests / 850 passed / 3 skipped / 0 failed; после Sprint 3F число изменится (J2 +2 теста; J1 −8 удалённых «404 unknown» +5..8 «401 без auth») — **точное число из реального вывода, не экстраполировать**.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/routes/web.php app/app/Http/Controllers/Api/DealController.php app/app/Http/Controllers/Api/DealBulkActionController.php app/app/Http/Controllers/Api/DealExportController.php app/app/Http/Controllers/Api/Concerns app/tests/Feature/DealIndexTest.php app/tests/Feature/DealShowTest.php app/tests/Feature/DealCreateTest.php app/tests/Feature/DealUpdateTest.php app/tests/Feature/DealTransitionTest.php app/tests/Feature/DealDestroyTest.php app/tests/Feature/DealRestoreTest.php app/tests/Feature/LookupsTest.php app/phpstan-baseline.neon
|
||||
git commit -m "feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*"
|
||||
```
|
||||
|
||||
(`app/app/Http/Controllers/Api/Concerns` в `git add` — на случай, если рефактор ничего там не создаст, путь просто проигнорируется; основное — 4 backend-файла + 8 тестов + baseline.)
|
||||
|
||||
**НЕ стейджить** `app/dev-indices.json`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** J1 (auth+tenant на `/api/deals*` — 8 роутов, 3 контроллера, 8 тест-файлов) ✅; J2 (стаб-гейт `/api/admin/*`) ✅. CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` не закрываются (нет «закрываем» от заказчика) — реализуется только код находок.
|
||||
- **Placeholder scan:** нет TODO/TBD в коде, кроме намеренного docblock-`TODO` в `EnsureSaasAdmin` (фиксирует, что стаб ждёт реального SSO под Б-1+DO-4 — это документация контракта, не пропуск работы). Тест-миграция задана точным рецептом R1–R10 (механическое преобразование, не placeholder).
|
||||
- **Type consistency:** `$tenantId` — `int` во всех 8 методах (`(int) $request->user()->tenant_id`); alias `'saas-admin'` и класс `EnsureSaasAdmin` совпадают между `bootstrap/app.php` и `routes/web.php`; middleware-массив `['auth:sanctum','tenant']` — порядок как в существующих группах (`/api/reminders` и др.).
|
||||
- **Атомарность J1:** middleware и миграция тестов — один коммит (Step 1–7 одной задачи); промежуточный red зафиксирован Step 3 как TDD-проверка активности auth-гейта.
|
||||
- **Регрессия admin/deal:** J2 на `testing` прозрачен → admin-тесты зелёные без изменений; J1 мигрирует все потребители `/api/deals*` (8 Pest-файлов, включая частично `LookupsTest`) — фронтенд не потребитель backend-тестов, его `tenant_id` backend молча игнорирует.
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
| Тип | Файл сейчас (Лидерра) | Размер | Назначение |
|
||||
|---|---|---|---|
|
||||
| Meta | [CLAUDE.md](../../../../../CLAUDE.md) | 266 строк | Оперативная карта Claude Code |
|
||||
| Meta | [CLAUDE.md](../../../CLAUDE.md) | 266 строк | Оперативная карта Claude Code |
|
||||
| Rules | [docs/Pravila_raboty_Claude_v1_1.md](../../Pravila_raboty_Claude_v1_1.md) | 720 строк | 13 продуктовых правил Claude |
|
||||
| Rules | [docs/Plugin_stack_rules_v1.md](../../Plugin_stack_rules_v1.md) | 916 строк | 16 правил координации плагинов |
|
||||
| Tooling | [docs/Tooling_v8_3.md](../../Tooling_v8_3.md) | 613 строк | Реестр 33 инструментов |
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Реколлаж ruflo в иерархии — приведение декларации к фактическому рантайму
|
||||
|
||||
**Дата:** 2026-05-16
|
||||
**Статус:** дизайн утверждён заказчиком (ответ «да», 16.05.2026)
|
||||
**Тип:** нормативный рефактор (правки документации, без кода)
|
||||
**Связано:** spec/plan ruflo big-bang `docs/superpowers/{specs,plans}/2026-05-15-ruflo-*`; Pravila §0/§12/§14; CLAUDE.md §1/§3.5; PSR_v1 R0; Tooling §4.10/§7
|
||||
|
||||
---
|
||||
|
||||
## 1. Проблема
|
||||
|
||||
15.05.2026 ruflo-интеграция («big-bang») задекларировала ruflo Queen-led routing **уровнем −1** priority chain — entry-point для ВСЕХ задач, «чистый верх» над всей 8-уровневой иерархией. Под эту инверсию §12 Superpowers (hard-rule) и PSR_v1 R0 (top-of-stack gate) переписаны в **sub-policy под ruflo Queen-led routing**.
|
||||
|
||||
Инспекция рантайма 15.05.2026: hive-mind — Queen + ~10 generic-воркеров, **0 задач и 0 раундов консенсуса** за всё время существования; рой idle; Claude-сессии работают **напрямую**, не через Queen. Механизма enforcement «всё идёт через Queen» нет: единственный реальный механизм — UserPromptSubmit-хук `tools/ruflo-queen-hook.mjs` — лишь инжектит директиву на триггер `queen`/`королева` (§14), а не маршрутизирует все задачи.
|
||||
|
||||
**Конфликт:** декларация утверждает факт, которого в рантайме нет — «entry-point для ВСЕХ задач». Это нормативная ложь без enforcement.
|
||||
|
||||
**Прецедент:** карта `docs/automation-graph.html` уже реколлажирована к рантайму (iter5, 15.05.2026) — нормативка просто отстала от карты.
|
||||
|
||||
---
|
||||
|
||||
## 2. Решение: ruflo стоит там, где стоит фактически
|
||||
|
||||
Заказчик: «посмотри где он стоит в иерархии фактически — там он и будет стоять».
|
||||
|
||||
### 2.1. Фактическое размещение компонентов ruflo
|
||||
|
||||
| Компонент ruflo | Рантайм (инспекция 15.05) | Фактический уровень иерархии |
|
||||
|---|---|---|
|
||||
| Queen-routing «entry-point для ВСЕХ задач» | 0 задач, 0 раундов консенсуса, рой idle, Claude работает напрямую | **не существует** — убрать уровень −1 |
|
||||
| §14 queen-триггер `ruflo-queen-hook.mjs` | работает — инжектит директиву на queen/королева | *правило* §14 — Pravila (уровень 1, explicit hard-rule); *хук* — settings.json (**уровень 4**) |
|
||||
| recall-хук `ruflo-recall-hook.mjs` | работает — инжектит memory recall в каждый промпт | settings.json hook — **уровень 4** |
|
||||
| daemon (PM2, 5 workers) | работает — background map/audit/optimize/consolidate/testgaps, 0 LLM-вызовов | фоновая подсистема — **вне priority chain** |
|
||||
| memory (sql.js + embeddings 384-dim) | работает — персистит после H7-патча | данные для recall-хука — вне chain |
|
||||
| MCP-server (~210 tools) | доступен в `.mcp.json`, как playwright/github/boost/semgrep/sentry/redis | инструмент — **уровень 6** (off-phase MCP) |
|
||||
|
||||
**Вывод:** ruflo фактически НЕ на уровне −1. Реально работают: два UserPromptSubmit-хука (уровень 4), daemon + memory (фоновая подсистема, вне priority chain), MCP-server (уровень 6). «Entry-point / overlord над иерархией» в рантайме отсутствует.
|
||||
|
||||
### 2.2. Целевое состояние — 7 изменений
|
||||
|
||||
1. **Убрать уровень −1** из priority chain: CLAUDE.md §1, Pravila §0, PSR_v1 R0.1, Tooling §7. Цепочка возвращается к 8-уровневой `0→6` (форма до 15.05).
|
||||
2. **§12 Superpowers** — `sub-policy под ruflo Queen-led routing` → обратно **explicit hard-rule** (уровень 0). Откат title §12 + первого абзаца + §12.4 Pravila.
|
||||
3. **PSR_v1 R0** — `Sub-policy: paired-stack delegation pattern (под ruflo Queen-led routing)` → обратно **top-of-stack gate**. R0.1 таблица, R0.2 gate-диаграмма, R0.5.
|
||||
4. **§14 — содержание не трогаем.** Единственная честная, работающая, заказчиком-заказанная часть ruflo-интеграции; §14.7/§14.8 уже честно описывают: хук инжектит директиву, alpha-spawn может упасть → прямой фоллбэк. Остаётся explicit hard-rule. **Исключение:** §14.6 cross-ref «ruflo — уровень −1, §12 — уровень 0» правится — уровня −1 больше нет; §14 остаётся в Pravila как explicit hard-rule (уровень 1, наравне с §12 уровня 0), ruflo как цель маршрута §14 — инструмент (хук уровня 4 + MCP уровня 6).
|
||||
5. **Tooling §4.10** — `Orchestration layer (ruflo) — entry-point иерархии` → `ruflo — параллельная advisory/automation-подсистема (off-phase)`. Убрать «entry-point для ВСЕХ задач», «уровень −1», «первичная классификация задач», «delegate в sub-policy». Оставить фактику: §14-хук, recall-хук, daemon, memory, MCP-tools, $-near-zero, alpha-bugs.
|
||||
6. **CLAUDE.md §3.5** — аналогичный реколлаж: ruflo не «чистый верх над 8-уровневой иерархией», а advisory-подсистема (два хука + daemon + memory + MCP). §3 title «Карта 35 инструментов + ruflo orchestration layer» → «Карта 35 инструментов + ruflo advisory-подсистема» (consistency с реколлажем Tooling §4.10). Через `claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10).
|
||||
7. **Memory** — `project_state.md` NB «ruflo — parallel subsystem, не фактический entry-point Claude-сессий» уже честен; синхронизировать остальные ruflo-факты (`reference_archive.md`, `project_ruflo_integration.md`, MEMORY.md version refs).
|
||||
|
||||
---
|
||||
|
||||
## 3. Проверка ограничения «функциональность ruflo и Claude не страдает»
|
||||
|
||||
- **ruflo:** §14-триггер, recall-хук, daemon (PM2 + Task Scheduler reboot-survival), memory (H7-патч), ~210 MCP-tools — всё работает и **остаётся без изменений**. Меняется только ложная нормативная *рамка* «overlord уровня −1», не код и не функциональность.
|
||||
- **Claude:** §12 возвращается из «routing preference» в explicit hard-rule → дисциплина Superpowers **усиливается** (15.05 её номинально понизили). PSR_v1 R0 возвращается в top-gate → классификация задач снова детерминирована. Функциональность Claude улучшается, не страдает.
|
||||
|
||||
---
|
||||
|
||||
## 4. Затрагиваемые файлы и version bumps
|
||||
|
||||
| Файл | Что меняется | Bump | Канал правки |
|
||||
|---|---|---|---|
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | §0 priority note, §12 title + абзац + §12.4, §13.6 tier-table cross-refs, §14.6 cross-ref | v1.15 → v1.16 | прямой Edit |
|
||||
| `CLAUDE.md` | §1 priority chain (убрать −1), §3 title, §3.5, §0 cross-refs, §6 фаза, §9 changelog | v2.1 → v2.2 | `claude-md-management:claude-md-improver` (§5 п.10) |
|
||||
| `docs/Plugin_stack_rules_v1.md` | R0 title + R0.1 таблица + R0.2 + R0.5, шапка cross-refs | v3.1 → v3.2 | прямой Edit |
|
||||
| `docs/Tooling_v8_3.md` | §4.10 (реколлаж), §7 priority chain, §0 cross-refs | v2.1 → v2.2 | прямой Edit |
|
||||
| `memory/*.md` | sync ruflo-фактов | — | прямой Write |
|
||||
|
||||
---
|
||||
|
||||
## 5. Вне scope / non-goals
|
||||
|
||||
- **НЕ удаляем** ruflo, §14, recall-хук, daemon, memory, MCP-server — функциональность сохраняется полностью.
|
||||
- **НЕ строим** реальный routing-runtime: требует API-ключей (заказчик: «стоп агенты руфло без api ключа»), противоречит принципу «$-расход near-zero», упирается в alpha-надёжность `hive-mind spawn`.
|
||||
- **НЕ трогаем** содержание §14 (queen-триггер hard-rule) — кроме cross-ref §14.6 «уровень −1».
|
||||
- **НЕ трогаем** код рантайма ruflo: daemon, H7-патч, хуки `.mjs`, `.mcp.json`, `.env.local` cost-cap — без изменений.
|
||||
- **НЕ удаляем** off-phase категорию ruflo в Tooling — она остаётся, переформулируется из «orchestration entry-point» в «advisory/automation subsystem».
|
||||
|
||||
---
|
||||
|
||||
## 6. Открытые вопросы
|
||||
|
||||
0 — дизайн полностью определён, заказчик утвердил направление и factual-placement принцип.
|
||||
+3
-1
@@ -38,9 +38,11 @@ pre-commit:
|
||||
Запусти `npm run lint:md:fix` или поправь руками.
|
||||
|
||||
# 3. cspell — орфография на staged .md
|
||||
# --no-gitignore: staged-файлы по определению tracked; без флага cspell
|
||||
# (useGitignore:true) игнорирует worktree-коммиты под gitignored .claude/worktrees/.
|
||||
- name: cspell
|
||||
glob: "*.md"
|
||||
run: npx cspell --no-progress --no-summary {staged_files}
|
||||
run: npx cspell --no-progress --no-summary --no-gitignore {staged_files}
|
||||
fail_text: |
|
||||
cspell нашёл слова, отсутствующие в словаре.
|
||||
Если это валидное слово проекта — добавь в cspell-words.txt.
|
||||
|
||||
Reference in New Issue
Block a user