Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 345d14d285 | |||
| bc24420ad4 | |||
| 788c7ab336 | |||
| eb41b65dad | |||
| 095032a231 | |||
| adb5d87d1d | |||
| 8b3ea3ed2e | |||
| d3746406a6 | |||
| 1a3a1df604 | |||
| 4b0809a82d | |||
| cefb71f5fa | |||
| fef9499e1a | |||
| 72c8cad963 | |||
| aa77814206 | |||
| fcf8626c26 | |||
| be51c97dce | |||
| 4a1663b426 | |||
| 17d9f16b7d | |||
| efb0dea5ed | |||
| 120a386f05 | |||
| c64be74992 | |||
| 6a3593de7a | |||
| de066145d3 | |||
| 96cb64f33a | |||
| 59dac9be56 | |||
| 7f05c4ab16 | |||
| 5d64ca552e | |||
| a7038367e4 | |||
| 15b53a9b2b | |||
| 952263b3e5 | |||
| 5416f809a3 | |||
| 0b9d73018d | |||
| 29a4d01ff4 | |||
| 8f2b82405a | |||
| 424987bedb | |||
| ef4df2925f | |||
| 8bc8c53a3b | |||
| 98549c52be | |||
| 70f8b210f4 | |||
| 4937225da3 | |||
| da4d46b0d8 | |||
| f9f9fec97d | |||
| e74e8aa6d6 | |||
| 447ef593fa | |||
| 9f70d89046 | |||
| 42a246d633 | |||
| ca0c4d9318 | |||
| 3269434746 | |||
| 5e12126d71 | |||
| 8e3e06f3a4 | |||
| c85424968e | |||
| 00f6611bc1 | |||
| adabcf15a4 | |||
| 3ea86d62ff | |||
| 9a25e658b3 |
@@ -56,3 +56,29 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
## Демо-данные (dev)
|
||||
|
||||
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
|
||||
`php artisan migrate --seed` в окружениях `local` и `testing`
|
||||
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
|
||||
|
||||
**Учётные данные демо-входа:**
|
||||
|
||||
- URL: `/login`
|
||||
- Email: `admin@demo.local`
|
||||
- Пароль: `password`
|
||||
|
||||
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
|
||||
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
|
||||
|
||||
**Пере-сидировать демо-данные** (идемпотентно — повторный запуск не создаёт дублей):
|
||||
|
||||
```bash
|
||||
composer demo:seed
|
||||
```
|
||||
|
||||
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
|
||||
|
||||
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
|
||||
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
|
||||
@@ -30,14 +30,7 @@ class PartitionsCreateMonths extends Command
|
||||
/** @var string */
|
||||
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
|
||||
|
||||
/**
|
||||
* Список таблиц, которые партиционируются по received_at помесячно.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
|
||||
public function handle(): int
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
{
|
||||
$ahead = max(1, (int) $this->option('ahead'));
|
||||
$now = Carbon::now()->startOfMonth();
|
||||
@@ -47,27 +40,17 @@ class PartitionsCreateMonths extends Command
|
||||
|
||||
for ($i = 0; $i <= $ahead; $i++) {
|
||||
$monthStart = $now->copy()->addMonths($i);
|
||||
$monthEnd = $monthStart->copy()->addMonth();
|
||||
|
||||
foreach (self::PARTITIONED_TABLES as $table) {
|
||||
foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
|
||||
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
|
||||
|
||||
if ($this->partitionExists($partitionName)) {
|
||||
if ($manager->ensureMonth($table, $monthStart)) {
|
||||
$created++;
|
||||
$this->info(" create <fg=green>{$partitionName}</>");
|
||||
} else {
|
||||
$skipped++;
|
||||
$this->line(" skip <fg=gray>{$partitionName}</> (already exists)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partitionName,
|
||||
$table,
|
||||
$monthStart->format('Y-m-d'),
|
||||
$monthEnd->format('Y-m-d'),
|
||||
));
|
||||
$created++;
|
||||
$this->info(" create <fg=green>{$partitionName}</> [{$monthStart->format('Y-m-d')} → {$monthEnd->format('Y-m-d')})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,17 +59,4 @@ class PartitionsCreateMonths extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка существования партиции через pg_class (быстрее information_schema).
|
||||
*/
|
||||
private function partitionExists(string $name): bool
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
"SELECT 1 AS exists FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
[$name],
|
||||
);
|
||||
|
||||
return $row !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -75,6 +62,7 @@ class DealController extends Controller
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
$onlyDeleted = $request->boolean('only_deleted');
|
||||
$countOnly = $request->boolean('count_only');
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
|
||||
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
|
||||
@@ -93,7 +81,7 @@ class DealController extends Controller
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor) {
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
|
||||
@@ -128,6 +116,12 @@ class DealController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
|
||||
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
|
||||
if ($countOnly) {
|
||||
return [collect(), $query->count(), null];
|
||||
}
|
||||
|
||||
if ($cursor !== null) {
|
||||
// Keyset: PG row constructor через индекс на (received_at DESC, id DESC).
|
||||
// Не считаем total (дорого без COUNT(*); клиент при необходимости
|
||||
@@ -172,6 +166,10 @@ class DealController extends Controller
|
||||
return [$rows, $total, $next];
|
||||
});
|
||||
|
||||
if ($countOnly) {
|
||||
return response()->json(['total' => $total]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'deals' => $deals->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
@@ -203,7 +201,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 +211,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 +281,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 +299,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 +321,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 +333,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 +349,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 +362,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 +374,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 +411,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 +419,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 +441,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 +481,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) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ResolveUnknownStatusesRequest;
|
||||
use App\Http\Requests\StoreImportRequest;
|
||||
use App\Jobs\ImportLeadsJob;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* CSV-импорт исторических лидов из crm.bp-gr.ru (ТЗ §6).
|
||||
*
|
||||
* Все маршруты — под auth:sanctum + tenant (RLS-контекст задан middleware).
|
||||
* tenant_id берётся из авторизованного пользователя, не из запроса.
|
||||
*/
|
||||
class ImportController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/imports — загрузка CSV, создание import_log, dispatch job'а.
|
||||
*/
|
||||
public function store(StoreImportRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$file = $request->file('file');
|
||||
$storedName = Str::uuid()->toString().'.csv';
|
||||
$path = $file->storeAs("imports/{$tenantId}", $storedName, 'local');
|
||||
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $request->user()->id,
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'status' => 'pending',
|
||||
'entity_type' => 'leads',
|
||||
'source_system' => 'crm.bp-gr.ru',
|
||||
'dry_run' => $request->boolean('dry_run'),
|
||||
]);
|
||||
|
||||
ImportLeadsJob::dispatch($log->id, $tenantId);
|
||||
|
||||
return response()->json(['data' => $this->toResource($log)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/imports — история импортов тенанта (RLS отфильтрует по tenant).
|
||||
*
|
||||
* Defense-in-depth: явный where(tenant_id) поверх RLS — на dev через
|
||||
* `postgres` superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
|
||||
* изоляцию (паттерн из DealController).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$logs = ImportLog::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('id')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn (ImportLog $log) => $this->toResource($log));
|
||||
|
||||
return response()->json(['data' => $logs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/imports/{importLog} — прогресс одного импорта (для polling'а).
|
||||
*
|
||||
* Defense-in-depth: явная проверка tenant_id на принадлежность поверх RLS.
|
||||
*/
|
||||
public function show(Request $request, ImportLog $importLog): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
abort_if($importLog->tenant_id !== $tenantId, 403, 'Доступ к импорту другого тенанта запрещён.');
|
||||
|
||||
return response()->json(['data' => $this->toResource($importLog)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/imports/unknown-statuses — незамапленные статусы (вход wizard'а §6.6).
|
||||
*
|
||||
* Defense-in-depth: явный where(tenant_id) поверх RLS.
|
||||
*/
|
||||
public function unknownStatuses(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$rows = ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->unresolved()
|
||||
->orderByDesc('occurrences')
|
||||
->get()
|
||||
->map(fn (ImportUnknownStatus $s) => [
|
||||
'id' => $s->id,
|
||||
'status_ru' => $s->status_ru,
|
||||
'occurrences' => $s->occurrences,
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/imports/unknown-statuses/resolve — ручной маппинг статусов.
|
||||
*
|
||||
* Defense-in-depth: явный where(tenant_id) поверх RLS.
|
||||
*/
|
||||
public function resolveUnknownStatuses(ResolveUnknownStatusesRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
DB::transaction(function () use ($request, $tenantId, $userId): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
foreach ($request->validated()['mappings'] as $mapping) {
|
||||
ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_ru', $mapping['status_ru'])
|
||||
->update([
|
||||
'mapped_to_slug' => $mapping['slug'],
|
||||
'resolved_at' => now(),
|
||||
'resolved_by' => $userId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json(['data' => ['resolved' => count($request->validated()['mappings'])]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toResource(ImportLog $log): array
|
||||
{
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'filename' => $log->filename,
|
||||
'status' => $log->status,
|
||||
'rows_total' => $log->rows_total,
|
||||
'rows_added' => $log->rows_added,
|
||||
'rows_updated' => $log->rows_updated,
|
||||
'rows_skipped' => $log->rows_skipped,
|
||||
'unknown_statuses_count' => $log->unknown_statuses_count,
|
||||
'dry_run' => $log->dry_run,
|
||||
'error_message' => $log->error_message,
|
||||
'started_at' => $log->started_at?->toIso8601String(),
|
||||
'finished_at' => $log->finished_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Валидация ручного маппинга неизвестных статусов воронки (§6.4 wizard).
|
||||
*/
|
||||
class ResolveUnknownStatusesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'mappings' => ['required', 'array', 'min:1'],
|
||||
'mappings.*.status_ru' => ['required', 'string', 'max:100'],
|
||||
'mappings.*.slug' => ['required', 'string', Rule::exists('lead_statuses', 'slug')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация загрузки CSV-файла импорта (ТЗ §6.2).
|
||||
*/
|
||||
class StoreImportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// mimes csv,txt — экспорт crm.bp-gr.ru отдаётся как text/csv или text/plain.
|
||||
'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
|
||||
'dry_run' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Mail\ImportCompletedNotification;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Асинхронная обработка CSV-импорта исторических лидов (ТЗ §6.6).
|
||||
*
|
||||
* Жизненный цикл import_log: pending → processing → done | failed.
|
||||
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
|
||||
* вне middleware-контекста — паритет с ProcessWebhookJob).
|
||||
*/
|
||||
class ImportLeadsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
public int $importLogId,
|
||||
public int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(HistoricalImportService $service, CsvLeadsParser $parser): void
|
||||
{
|
||||
$log = $this->loadLog();
|
||||
if ($log === null) {
|
||||
Log::error('import.log_not_found', ['import_log_id' => $this->importLogId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateLog($log->id, ['status' => 'processing', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
if (! Storage::disk('local')->exists($log->file_path)) {
|
||||
throw new RuntimeException("Файл импорта не найден: {$log->file_path}");
|
||||
}
|
||||
|
||||
$content = (string) Storage::disk('local')->get($log->file_path);
|
||||
$parsed = $parser->parse($content);
|
||||
|
||||
$result = $service->import($this->tenantId, $log->user_id, $log, $parsed->rows);
|
||||
|
||||
$this->updateLog($log->id, [
|
||||
'status' => 'done',
|
||||
'rows_total' => count($parsed->rows) + count($parsed->errors),
|
||||
'rows_added' => $result->added,
|
||||
'rows_updated' => $result->updated,
|
||||
'rows_skipped' => count($parsed->errors) + $result->skipped,
|
||||
'unknown_statuses_count' => count($result->unknownStatuses),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$this->notify($log->user_id, 'done');
|
||||
} catch (Throwable $e) {
|
||||
Log::error('import.job_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
|
||||
$this->updateLog($log->id, [
|
||||
'status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->notify($log->user_id, 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
private function loadLog(): ?ImportLog
|
||||
{
|
||||
return DB::transaction(function (): ?ImportLog {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
return ImportLog::query()->find($this->importLogId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
private function updateLog(int $logId, array $attributes): void
|
||||
{
|
||||
DB::transaction(function () use ($logId, $attributes): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
ImportLog::query()->whereKey($logId)->update($attributes);
|
||||
});
|
||||
}
|
||||
|
||||
private function notify(int $userId, string $outcome): void
|
||||
{
|
||||
$log = $this->loadLog();
|
||||
$user = DB::transaction(function () use ($userId): ?User {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
return User::query()->find($userId);
|
||||
});
|
||||
|
||||
if ($log === null || $user === null || $user->email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($user->email)->send(new ImportCompletedNotification($log, $outcome));
|
||||
} catch (Throwable $e) {
|
||||
// Отказ почтового канала не должен валить успешный импорт.
|
||||
Log::warning('import.mail_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Финальный callback после исчерпания ретраев ($tries=1).
|
||||
*/
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
$this->updateLog($this->importLogId, [
|
||||
'status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
Log::error('import.job_failed_permanently', [
|
||||
'import_log_id' => $this->importLogId,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Уведомление о завершении CSV-импорта исторических лидов (ТЗ §6.6).
|
||||
*/
|
||||
class ImportCompletedNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @param string $outcome 'done' | 'failed'
|
||||
*/
|
||||
public function __construct(
|
||||
public ImportLog $log,
|
||||
public string $outcome,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->outcome === 'done'
|
||||
? 'Импорт данных завершён — Лидерра'
|
||||
: 'Импорт данных не удался — Лидерра';
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.import-completed',
|
||||
with: [
|
||||
'log' => $this->log,
|
||||
'outcome' => $this->outcome,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ImportLogFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Журнал CSV-импорта (schema §6.7, Sprint 4).
|
||||
*
|
||||
* Tenant-aware модель с RLS: tenant_isolation по current_setting('app.current_tenant_id').
|
||||
* Sprint 4 enrichment: entity_type / source_system / mapping_config / unknown_statuses_count / dry_run.
|
||||
*
|
||||
* @mixin IdeHelperImportLog
|
||||
*/
|
||||
class ImportLog extends Model
|
||||
{
|
||||
/** @use HasFactory<ImportLogFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
public const CREATED_AT = null;
|
||||
|
||||
protected $table = 'import_log';
|
||||
|
||||
/** Зеркало DB DEFAULT'ов: Laravel не читает их из БД после INSERT без refresh(). */
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
'entity_type' => 'leads',
|
||||
'source_system' => 'crm.bp-gr.ru',
|
||||
'dry_run' => false,
|
||||
'unknown_statuses_count' => 0,
|
||||
'rows_total' => 0,
|
||||
'rows_added' => 0,
|
||||
'rows_updated' => 0,
|
||||
'rows_skipped' => 0,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'filename',
|
||||
'file_path',
|
||||
'rows_total',
|
||||
'rows_added',
|
||||
'rows_updated',
|
||||
'rows_skipped',
|
||||
'status',
|
||||
'error_message',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'entity_type',
|
||||
'source_system',
|
||||
'mapping_config',
|
||||
'unknown_statuses_count',
|
||||
'dry_run',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'rows_total' => 'integer',
|
||||
'rows_added' => 'integer',
|
||||
'rows_updated' => 'integer',
|
||||
'rows_skipped' => 'integer',
|
||||
'unknown_statuses_count' => 'integer',
|
||||
'dry_run' => 'boolean',
|
||||
'mapping_config' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ImportUnknownStatusFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Неизвестный статус воронки из CSV-импорта (schema §6.4, Sprint 4 H1).
|
||||
*
|
||||
* Tenant-aware модель с RLS. UNIQUE (tenant_id, status_ru): повторный импорт
|
||||
* инкрементит occurrences и переиспользует ранее проставленный mapped_to_slug.
|
||||
*
|
||||
* @mixin IdeHelperImportUnknownStatus
|
||||
*/
|
||||
class ImportUnknownStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<ImportUnknownStatusFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'import_log_id',
|
||||
'status_ru',
|
||||
'occurrences',
|
||||
'mapped_to_slug',
|
||||
'resolved_at',
|
||||
'resolved_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'import_log_id' => 'integer',
|
||||
'occurrences' => 'integer',
|
||||
'resolved_by' => 'integer',
|
||||
'resolved_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Незамапленные статусы (mapped_to_slug IS NULL) — вход для wizard'а §6.6.
|
||||
*
|
||||
* @param Builder<ImportUnknownStatus> $query
|
||||
* @return Builder<ImportUnknownStatus>
|
||||
*/
|
||||
public function scopeUnresolved(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('mapped_to_slug');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Парсер CSV-выгрузки лидов из crm.bp-gr.ru (ТЗ §6.2/§6.3).
|
||||
*
|
||||
* Формат: UTF-8 с BOM, разделитель — запятая, дата `Y/m/d H:i:s`,
|
||||
* телефон `7XXXXXXXXXX`. Заголовок:
|
||||
* id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
|
||||
*
|
||||
* Невалидные строки не роняют парсинг — собираются в errors[].
|
||||
* Файл целиком загружается в память (MVP: ожидаемый объём — единицы тысяч строк).
|
||||
*/
|
||||
final class CsvLeadsParser
|
||||
{
|
||||
private const EXPECTED_COLUMNS = 9;
|
||||
|
||||
private const DATE_FORMAT = 'Y/m/d H:i:s';
|
||||
|
||||
public function parse(string $content): CsvParseResult
|
||||
{
|
||||
// Срезаем UTF-8 BOM.
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
$content = substr($content, 3);
|
||||
}
|
||||
|
||||
$lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: [];
|
||||
$rows = [];
|
||||
$errors = [];
|
||||
|
||||
// Строка 1 — заголовок, пропускаем. dataLine — абсолютный номер строки файла (заголовок = 1).
|
||||
foreach (array_slice($lines, 1) as $index => $rawLine) {
|
||||
$dataLine = $index + 2; // +2: пропущен заголовок (index 0 → строка 2)
|
||||
|
||||
if (trim($rawLine) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cells = str_getcsv($rawLine);
|
||||
|
||||
if (count($cells) < self::EXPECTED_COLUMNS) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Ожидалось '.self::EXPECTED_COLUMNS.' колонок, получено '.count($cells)];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->parseRow($cells, $dataLine, $errors);
|
||||
if ($parsed !== null) {
|
||||
$rows[] = $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return new CsvParseResult($rows, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $cells
|
||||
* @param array<int, array{line: int, message: string}> $errors
|
||||
*/
|
||||
private function parseRow(array $cells, int $dataLine, array &$errors): ?ParsedLeadRow
|
||||
{
|
||||
[$id, $project, $tag, $phone, $createdAt, $reminder, $comment, $status, $name] = $cells;
|
||||
|
||||
$phone = trim($phone);
|
||||
if (preg_match('/^7\d{10}$/', $phone) !== 1) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => "Невалидный телефон: '{$phone}'"];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$receivedAt = $this->parseDate($createdAt);
|
||||
if ($receivedAt === null) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Создано': '{$createdAt}'"];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$reminderAt = trim($reminder) === '' ? null : $this->parseDate($reminder);
|
||||
if (trim($reminder) !== '' && $reminderAt === null) {
|
||||
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Напоминание': '{$reminder}'"];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = trim($status);
|
||||
if ($status === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое поле «Состояние»'];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
|
||||
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
|
||||
if ($projectName === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ParsedLeadRow(
|
||||
sourceCrmId: (int) trim($id),
|
||||
projectName: $projectName,
|
||||
projectTag: trim($tag) === '' ? null : trim($tag),
|
||||
phone: $phone,
|
||||
receivedAt: $receivedAt,
|
||||
reminderAt: $reminderAt,
|
||||
comment: trim($comment) === '' ? null : trim($comment),
|
||||
statusRu: $status,
|
||||
contactName: trim($name) === '' ? null : trim($name),
|
||||
);
|
||||
}
|
||||
|
||||
private function parseDate(string $value): ?CarbonImmutable
|
||||
{
|
||||
try {
|
||||
$date = CarbonImmutable::createFromFormat(self::DATE_FORMAT, trim($value));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// createFromFormat возвращает false при несовпадении формата.
|
||||
return $date instanceof CarbonImmutable ? $date : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Результат парсинга CSV: валидные строки + ошибки по номеру строки.
|
||||
*/
|
||||
final readonly class CsvParseResult
|
||||
{
|
||||
/**
|
||||
* @param array<int, ParsedLeadRow> $rows
|
||||
* @param array<int, array{line: int, message: string}> $errors
|
||||
*/
|
||||
public function __construct(
|
||||
public array $rows,
|
||||
public array $errors,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Оркестрация исторической миграции лидов из CSV crm.bp-gr.ru (ТЗ §6).
|
||||
*
|
||||
* Идемпотентность — через webhook_dedup_keys (та же advisory-lock логика, что
|
||||
* ProcessWebhookJob). Баланс НЕ списывается: исторические данные не являются
|
||||
* новыми лидами (ТЗ §6.5) — фиксируется одна транзакция типа historical_import.
|
||||
*/
|
||||
final class HistoricalImportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MonthlyPartitionManager $partitions,
|
||||
private readonly StatusRuToSlugMapper $statusMapper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, ParsedLeadRow> $rows
|
||||
*/
|
||||
public function import(int $tenantId, int $userId, ImportLog $log, array $rows): ImportResult
|
||||
{
|
||||
$dryRun = $log->dry_run;
|
||||
|
||||
if ($rows === []) {
|
||||
return new ImportResult(0, 0, 0, [], []);
|
||||
}
|
||||
|
||||
// Партиции deals под исторический диапазон дат CSV (один раз заранее).
|
||||
if (! $dryRun) {
|
||||
$dates = array_map(fn (ParsedLeadRow $r) => $r->receivedAt, $rows);
|
||||
$this->partitions->ensureRange(
|
||||
'deals',
|
||||
min($dates),
|
||||
max($dates),
|
||||
);
|
||||
}
|
||||
|
||||
// Tenant-резолвленные переопределения неизвестных статусов.
|
||||
$overrides = $this->loadStatusOverrides($tenantId);
|
||||
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$unknown = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = $this->resolveStatus($row->statusRu, $overrides, $unknown);
|
||||
|
||||
if ($dryRun) {
|
||||
$added++; // проекция: для dry-run не различаем add/update
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
|
||||
$wasCreated ? $added++ : $updated++;
|
||||
} catch (Throwable $e) {
|
||||
$skipped++;
|
||||
$errors[] = ['source_crm_id' => $row->sourceCrmId, 'message' => $e->getMessage()];
|
||||
Log::warning('import.row_failed', ['source_crm_id' => $row->sourceCrmId, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->persistUnknownStatuses($tenantId, $log->id, $unknown);
|
||||
$this->recordHistoricalTransaction($tenantId, $added + $updated);
|
||||
}
|
||||
|
||||
return new ImportResult($added, $updated, $skipped, $unknown, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> status_ru => slug (только resolved)
|
||||
*/
|
||||
private function loadStatusOverrides(int $tenantId): array
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Явный where(tenant_id) — defense-in-depth: queue worker на prod
|
||||
// (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует
|
||||
// (00_create_roles.sql §5). Без фильтра — cross-tenant утечка маппинга.
|
||||
return ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('mapped_to_slug')
|
||||
->pluck('mapped_to_slug', 'status_ru')
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппит статус: каноническая таблица §6.4 → tenant-override → fallback 'new'.
|
||||
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
|
||||
*
|
||||
* @param array<string, string> $overrides
|
||||
* @param array<string, int> $unknown
|
||||
*/
|
||||
private function resolveStatus(string $statusRu, array $overrides, array &$unknown): string
|
||||
{
|
||||
$slug = $this->statusMapper->toSlug($statusRu);
|
||||
if ($slug !== null) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$key = trim($statusRu);
|
||||
if (isset($overrides[$key])) {
|
||||
return $overrides[$key];
|
||||
}
|
||||
|
||||
$unknown[$key] = ($unknown[$key] ?? 0) + 1;
|
||||
|
||||
return 'new';
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентный upsert одной строки в собственной транзакции.
|
||||
* Возвращает true — создана новая сделка, false — обновлена существующая.
|
||||
*/
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
['tenant_id' => $tenantId, 'name' => $row->projectName],
|
||||
['tag' => $row->projectTag, 'type' => 'import'],
|
||||
);
|
||||
|
||||
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5).
|
||||
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
|
||||
$existing = DB::selectOne(
|
||||
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
|
||||
[$tenantId, $row->sourceCrmId],
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
$deal = Deal::query()
|
||||
->where('id', $existing->deal_id)
|
||||
->where('received_at', $existing->deal_received_at)
|
||||
->firstOrFail();
|
||||
// §6.5 стадия 3a: для исторической миграции status перезаписывается.
|
||||
$deal->update([
|
||||
'status' => $slug,
|
||||
'contact_name' => $row->contactName,
|
||||
'comment' => $row->comment,
|
||||
]);
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'source_crm_id' => $row->sourceCrmId,
|
||||
'project_id' => $project->id,
|
||||
'phone' => $row->phone,
|
||||
'status' => $slug,
|
||||
'contact_name' => $row->contactName,
|
||||
'comment' => $row->comment,
|
||||
'received_at' => $row->receivedAt,
|
||||
]);
|
||||
|
||||
DB::table('webhook_dedup_keys')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'source_crm_id' => $row->sourceCrmId,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт reminders-строку для непустого «Напоминание» (ТЗ §6.3 — поле
|
||||
* deals.reminder_at удалено в v8.3, заменено таблицей reminders).
|
||||
* Идемпотентно: не дублирует напоминание при повторном импорте.
|
||||
*/
|
||||
private function syncReminder(int $tenantId, int $userId, Deal $deal, ParsedLeadRow $row): void
|
||||
{
|
||||
if ($row->reminderAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Reminder::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->where('remind_at', $row->reminderAt)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reminder::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'deal_id' => $deal->id,
|
||||
'text' => 'Импортировано из crm.bp-gr.ru',
|
||||
'remind_at' => $row->reminderAt,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert import_unknown_statuses: инкремент occurrences, маппинг не трогаем.
|
||||
*
|
||||
* @param array<string, int> $unknown
|
||||
*/
|
||||
private function persistUnknownStatuses(int $tenantId, int $importLogId, array $unknown): void
|
||||
{
|
||||
if ($unknown === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $importLogId, $unknown): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
foreach ($unknown as $statusRu => $count) {
|
||||
// Явный where(tenant_id) — defense-in-depth под BYPASSRLS queue worker
|
||||
// (00_create_roles.sql §5): иначе increment мог бы попасть в строку
|
||||
// чужого тенанта с тем же status_ru.
|
||||
$existing = ImportUnknownStatus::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_ru', $statusRu)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
$existing->increment('occurrences', $count);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'import_log_id' => $importLogId,
|
||||
'status_ru' => $statusRu,
|
||||
'occurrences' => $count,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна информационная транзакция historical_import (баланс не меняется, ТЗ §6.5).
|
||||
*/
|
||||
private function recordHistoricalTransaction(int $tenantId, int $count): void
|
||||
{
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $count): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
DB::table('balance_transactions')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'historical_import',
|
||||
'amount_rub' => 0,
|
||||
'amount_leads' => 0,
|
||||
'description' => "Импортировано {$count} исторических сделок (баланс не списан)",
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Итог импорта одного файла.
|
||||
*/
|
||||
final readonly class ImportResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $unknownStatuses статус_ru => количество вхождений
|
||||
* @param array<int, array{source_crm_id: int, message: string}> $errors ошибки upsert'а по строке (идентификатор — source_crm_id)
|
||||
*/
|
||||
public function __construct(
|
||||
public int $added,
|
||||
public int $updated,
|
||||
public int $skipped,
|
||||
public array $unknownStatuses,
|
||||
public array $errors,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Одна валидная строка CSV-импорта лидов (ТЗ §6.3).
|
||||
*/
|
||||
final readonly class ParsedLeadRow
|
||||
{
|
||||
public function __construct(
|
||||
public int $sourceCrmId,
|
||||
public string $projectName,
|
||||
public ?string $projectTag,
|
||||
public string $phone,
|
||||
public CarbonImmutable $receivedAt,
|
||||
public ?CarbonImmutable $reminderAt,
|
||||
public ?string $comment,
|
||||
public string $statusRu,
|
||||
public ?string $contactName,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
|
||||
*
|
||||
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
|
||||
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
|
||||
*/
|
||||
class StatusRuToSlugMapper
|
||||
{
|
||||
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
|
||||
private const STATUS_RU_TO_SLUG = [
|
||||
'Новые' => 'new',
|
||||
'Просмотрено' => 'viewed',
|
||||
'Проработан' => 'worked',
|
||||
'База' => 'base',
|
||||
'Недозвон' => 'missed',
|
||||
'Переговоры' => 'negotiations',
|
||||
'Ожидаем оплаты' => 'waiting_payment',
|
||||
'Партнерка' => 'partnership',
|
||||
'Оплачено' => 'paid',
|
||||
'Закрыто и не реализовано' => 'closed',
|
||||
'Тест драйв' => 'test_drive',
|
||||
'Горячий' => 'hot',
|
||||
'На замену' => 'replacement',
|
||||
'Конечный недозвон' => 'final_missed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Возвращает slug или null, если статус не входит в каноническую таблицу.
|
||||
*/
|
||||
public function toSlug(string $statusRu): ?string
|
||||
{
|
||||
return self::STATUS_RU_TO_SLUG[trim($statusRu)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная каноническая таблица — для UI wizard'а (показать варианты).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function map(): array
|
||||
{
|
||||
return self::STATUS_RU_TO_SLUG;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
|
||||
*
|
||||
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
|
||||
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
|
||||
*
|
||||
* Используется:
|
||||
* - cron `partitions:create-months` — N месяцев вперёд;
|
||||
* - HistoricalImportService — под исторический диапазон дат CSV.
|
||||
*/
|
||||
class MonthlyPartitionManager
|
||||
{
|
||||
/** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
|
||||
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
|
||||
/**
|
||||
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
|
||||
* пересекающих [$from, $to] включительно.
|
||||
*
|
||||
* @return int Сколько партиций реально создано (0 — все уже были).
|
||||
*/
|
||||
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
$last = $to->copy()->startOfMonth();
|
||||
$created = 0;
|
||||
|
||||
while ($month->lessThanOrEqualTo($last)) {
|
||||
$created += $this->ensureMonth($table, $month) ? 1 : 0;
|
||||
$month = $month->addMonth();
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт одну месячную партицию. Возвращает true, если партиция создана,
|
||||
* false — если уже существовала.
|
||||
*/
|
||||
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
$end = $start->copy()->addMonth();
|
||||
$partition = sprintf('%s_%s', $table, $start->format('Y_m'));
|
||||
|
||||
$exists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
[$partition],
|
||||
);
|
||||
|
||||
if ($exists !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partition,
|
||||
$table,
|
||||
$start->format('Y-m-d'),
|
||||
$end->format('Y-m-d'),
|
||||
));
|
||||
|
||||
return 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}) не должен требовать
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
|
||||
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
|
||||
"ide-helper": [
|
||||
"@php artisan ide-helper:generate",
|
||||
"@php artisan ide-helper:meta"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<ImportLog> */
|
||||
class ImportLogFactory extends Factory
|
||||
{
|
||||
protected $model = ImportLog::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'filename' => 'leads-export.csv',
|
||||
'file_path' => 'imports/1/'.$this->faker->uuid().'.csv',
|
||||
'status' => 'pending',
|
||||
'entity_type' => 'leads',
|
||||
'source_system' => 'crm.bp-gr.ru',
|
||||
'dry_run' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<ImportUnknownStatus> */
|
||||
class ImportUnknownStatusFactory extends Factory
|
||||
{
|
||||
protected $model = ImportUnknownStatus::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'status_ru' => $this->faker->unique()->word(),
|
||||
'occurrences' => $this->faker->numberBetween(1, 20),
|
||||
'mapped_to_slug' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sprint 4 (H1+H2) — историческая миграция лидов §6.
|
||||
*
|
||||
* H1: новая таблица import_unknown_statuses (tenant-level resolved mappings).
|
||||
* H2: enrichment import_log — +5 колонок.
|
||||
*
|
||||
* Guard'ы: migrate:fresh грузит schema.sql v8.21+ (где delta уже есть) до миграций,
|
||||
* поэтому каждый кусок применяется только при отсутствии.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach ([
|
||||
'entity_type' => "ALTER TABLE import_log ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'leads' CHECK (entity_type IN ('leads','projects'))",
|
||||
'source_system' => "ALTER TABLE import_log ADD COLUMN source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru'",
|
||||
'mapping_config' => 'ALTER TABLE import_log ADD COLUMN mapping_config JSONB',
|
||||
'unknown_statuses_count' => 'ALTER TABLE import_log ADD COLUMN unknown_statuses_count INT NOT NULL DEFAULT 0',
|
||||
'dry_run' => 'ALTER TABLE import_log ADD COLUMN dry_run BOOLEAN NOT NULL DEFAULT FALSE',
|
||||
] as $column => $ddl) {
|
||||
if (! Schema::hasColumn('import_log', $column)) {
|
||||
DB::statement($ddl);
|
||||
}
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('import_unknown_statuses')) {
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE TABLE import_unknown_statuses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
|
||||
status_ru VARCHAR(100) NOT NULL,
|
||||
occurrences INT NOT NULL DEFAULT 0,
|
||||
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE (tenant_id, status_ru)
|
||||
)
|
||||
SQL);
|
||||
|
||||
DB::statement(
|
||||
'CREATE INDEX idx_import_unknown_statuses_unresolved
|
||||
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL'
|
||||
);
|
||||
|
||||
DB::statement('ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY');
|
||||
DB::statement(
|
||||
"CREATE POLICY tenant_isolation ON import_unknown_statuses
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// down() не симметричен: на проекте rollback применяется только после
|
||||
// migrate:fresh (см. add_archived_at_to_projects). Для отката v8.21 —
|
||||
// отдельный schema-bump, не эта миграция.
|
||||
DB::statement('DROP TABLE IF EXISTS import_unknown_statuses');
|
||||
|
||||
foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
|
||||
if (Schema::hasColumn('import_log', $column)) {
|
||||
Schema::table('import_log', fn ($table) => $table->dropColumn($column));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,16 @@ class DemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// DemoSeeder создаёт демо-данные и НЕ должен исполняться в production.
|
||||
// DatabaseSeeder вызывает его только в local/testing — этот guard
|
||||
// дополнительно защищает прямой вызов `db:seed --class=DemoSeeder`
|
||||
// (в т.ч. через `composer demo:seed`).
|
||||
if (app()->isProduction()) {
|
||||
$this->command->warn('DemoSeeder пропущен: запрещён в production.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->where('subdomain', 'demo')->first()
|
||||
?? Tenant::factory()->create([
|
||||
'subdomain' => 'demo',
|
||||
|
||||
+240
-12
@@ -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
|
||||
@@ -651,7 +693,19 @@ parameters:
|
||||
-
|
||||
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
|
||||
|
||||
-
|
||||
@@ -675,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
|
||||
|
||||
-
|
||||
@@ -717,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
|
||||
|
||||
-
|
||||
@@ -741,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
|
||||
|
||||
-
|
||||
@@ -777,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
|
||||
|
||||
-
|
||||
@@ -807,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
|
||||
|
||||
-
|
||||
@@ -831,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
|
||||
|
||||
-
|
||||
@@ -882,6 +1008,90 @@ parameters:
|
||||
count: 17
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#'
|
||||
identifier: property.notFound
|
||||
count: 10
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 23
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportCompletedNotificationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportCompletedNotificationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -912,6 +1122,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
|
||||
@@ -1200,6 +1416,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;
|
||||
}
|
||||
|
||||
@@ -233,3 +233,14 @@ export async function listProjects(tenantId: number): Promise<ApiProject[]> {
|
||||
});
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Лёгкий count-only запрос для бейджа «Сделки» в AppSidebar (audit B2).
|
||||
* Backend пропускает SELECT строк — отдаёт только COUNT(*).
|
||||
*/
|
||||
export async function fetchDealsCount(tenantId: number): Promise<number> {
|
||||
const { data } = await apiClient.get<{ total: number }>('/api/deals', {
|
||||
params: { tenant_id: tenantId, count_only: 1 },
|
||||
});
|
||||
return data.total;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент исторической миграции лидов (ТЗ §6).
|
||||
* Эндпоинты: POST/GET /api/imports, /api/imports/unknown-statuses, /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
|
||||
export interface ImportLogResource {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: 'pending' | 'processing' | 'done' | 'failed';
|
||||
rows_total: number;
|
||||
rows_added: number;
|
||||
rows_updated: number;
|
||||
rows_skipped: number;
|
||||
unknown_statuses_count: number;
|
||||
dry_run: boolean;
|
||||
error_message: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface UnknownStatus {
|
||||
id: number;
|
||||
status_ru: string;
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
export interface StatusMapping {
|
||||
status_ru: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/** POST /api/imports — загрузить CSV. */
|
||||
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (dryRun) {
|
||||
form.append('dry_run', '1');
|
||||
}
|
||||
const { data } = await apiClient.post<{ data: ImportLogResource }>('/api/imports', form);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports — история импортов. */
|
||||
export async function listImports(): Promise<ImportLogResource[]> {
|
||||
const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports/{id} — прогресс одного импорта. */
|
||||
export async function getImport(id: number): Promise<ImportLogResource> {
|
||||
const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports/unknown-statuses — незамапленные статусы. */
|
||||
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
|
||||
const { data } = await apiClient.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
|
||||
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
|
||||
await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings });
|
||||
}
|
||||
@@ -42,7 +42,8 @@ async function loadLookups(tenantId: number) {
|
||||
managerIdByName.value = map;
|
||||
}
|
||||
} catch {
|
||||
// Молчаливый fallback на mock — UI пользователь всё равно увидит.
|
||||
// Audit C6: фиксируем провал — UI покажет degradation-alert.
|
||||
lookupsFailed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +77,9 @@ const errors = ref<Record<string, string>>({});
|
||||
const submitError = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
// Audit C6: loadLookups упал → показываем degradation-alert (списки = mock).
|
||||
const lookupsFailed = ref(false);
|
||||
|
||||
// Регенерируем ID на каждое создание для local-mode. На API — backend SERIAL.
|
||||
function nextId(): number {
|
||||
return Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1000);
|
||||
@@ -91,6 +95,7 @@ function reset() {
|
||||
errors.value = {};
|
||||
submitError.value = null;
|
||||
busy.value = false;
|
||||
lookupsFailed.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -170,6 +175,8 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ lookupsFailed });
|
||||
|
||||
function close() {
|
||||
dialogOpen.value = false;
|
||||
}
|
||||
@@ -190,6 +197,17 @@ function close() {
|
||||
>
|
||||
{{ submitError }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="lookupsFailed"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="lookups-error-alert"
|
||||
>
|
||||
Не удалось загрузить списки проектов и менеджеров — показаны примерные значения. Проверьте выбор
|
||||
перед сохранением.
|
||||
</v-alert>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
|
||||
*
|
||||
* Для каждого незамапленного русского статуса пользователь выбирает один из
|
||||
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
statuses: UnknownStatus[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
resolved: [];
|
||||
}>();
|
||||
|
||||
/** 14 канонических статусов воронки (ТЗ §6.4). */
|
||||
const STATUS_OPTIONS: { value: string; title: string }[] = [
|
||||
{ value: 'new', title: 'Новые' },
|
||||
{ value: 'viewed', title: 'Просмотрено' },
|
||||
{ value: 'worked', title: 'Проработан' },
|
||||
{ value: 'base', title: 'База' },
|
||||
{ value: 'missed', title: 'Недозвон' },
|
||||
{ value: 'negotiations', title: 'Переговоры' },
|
||||
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
|
||||
{ value: 'partnership', title: 'Партнерка' },
|
||||
{ value: 'paid', title: 'Оплачено' },
|
||||
{ value: 'closed', title: 'Закрыто и не реализовано' },
|
||||
{ value: 'test_drive', title: 'Тест драйв' },
|
||||
{ value: 'hot', title: 'Горячий' },
|
||||
{ value: 'replacement', title: 'На замену' },
|
||||
{ value: 'final_missed', title: 'Конечный недозвон' },
|
||||
];
|
||||
|
||||
const selection = reactive<Record<string, string | null>>({});
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const dialogOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const allMapped = computed(
|
||||
() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
|
||||
);
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!allMapped.value) {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const mappings: StatusMapping[] = props.statuses.map((s) => ({
|
||||
status_ru: s.status_ru,
|
||||
slug: selection[s.status_ru] as string,
|
||||
}));
|
||||
await resolveUnknownStatuses(mappings);
|
||||
emit('resolved');
|
||||
} catch {
|
||||
error.value = 'Не удалось сохранить маппинг. Повторите попытку.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ selection, save });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialogOpen" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Эти статусы из CSV не входят в стандартную воронку. Выберите
|
||||
соответствие — повторный импорт применит маппинг автоматически.
|
||||
</p>
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="d-flex align-center ga-3 mb-3"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{{ status.status_ru }}</strong>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
({{ status.occurrences }} шт.)
|
||||
</span>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="selection[status.status_ru]"
|
||||
:items="STATUS_OPTIONS"
|
||||
label="Статус воронки"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 280px"
|
||||
/>
|
||||
</div>
|
||||
<v-alert v-if="error" type="error" variant="tonal" class="mt-2">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="dialogOpen = false">Отмена</v-btn>
|
||||
<v-btn
|
||||
data-test="save-mappings"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
:disabled="!allMapped"
|
||||
@click="save"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -5,11 +5,14 @@
|
||||
* + active-marker pseudo-element + JetBrains Mono badges.
|
||||
*
|
||||
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
|
||||
* Counts для «Сделки» — mock.
|
||||
* Count для «Сделки» — live из API (dealsCount-store, audit B2).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Kbd from '../ui/Kbd.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useDealsCountStore } from '../../stores/dealsCount';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -26,15 +29,31 @@ interface NavGroup {
|
||||
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const dealsCount = useDealsCountStore();
|
||||
const { openPalette } = useCommandPalette();
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
|
||||
});
|
||||
|
||||
const navGroups = computed<NavGroup[]>(() => [
|
||||
{
|
||||
eyebrow: 'Работа',
|
||||
items: [
|
||||
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
|
||||
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
|
||||
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
|
||||
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
|
||||
{
|
||||
title: 'Сделки',
|
||||
icon: 'mdi-format-list-bulleted',
|
||||
to: '/deals',
|
||||
count: dealsCount.count ?? undefined,
|
||||
countKey: 'deals',
|
||||
},
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -63,7 +82,15 @@ defineExpose({ navGroups });
|
||||
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
|
||||
</div>
|
||||
|
||||
<div class="ld-cmdk-stub" role="button" tabindex="0">
|
||||
<div
|
||||
class="ld-cmdk-stub"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="cmdk-stub"
|
||||
@click="openPalette"
|
||||
@keydown.enter="openPalette"
|
||||
@keydown.space.prevent="openPalette"
|
||||
>
|
||||
<span class="ld-cmdk-stub__placeholder">Поиск, команды…</span>
|
||||
<Kbd dark>⌘K</Kbd>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useNotificationsStore } from '../../stores/notifications';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
|
||||
defineProps<{
|
||||
pageTitle: string;
|
||||
@@ -20,6 +21,7 @@ const emit = defineEmits<{
|
||||
const auth = useAuthStore();
|
||||
const notifications = useNotificationsStore();
|
||||
const router = useRouter();
|
||||
const { openPalette } = useCommandPalette();
|
||||
|
||||
const unreadDisplay = computed(() => {
|
||||
if (notifications.unreadCount === 0) return '';
|
||||
@@ -87,11 +89,7 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
|
||||
<v-app-bar-nav-icon
|
||||
class="d-md-none"
|
||||
aria-label="Открыть меню навигации"
|
||||
@click="emit('toggle-drawer')"
|
||||
/>
|
||||
<v-app-bar-nav-icon class="d-md-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
|
||||
|
||||
<div class="crumb">
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
@@ -99,7 +97,14 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn variant="outlined" size="small" prepend-icon="mdi-magnify" class="searchbar mr-2" disabled>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="searchbar mr-2"
|
||||
data-testid="topbar-search-btn"
|
||||
@click="openPalette"
|
||||
>
|
||||
Поиск
|
||||
<template #append>
|
||||
<kbd class="search-kbd">⌘K</kbd>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Минимальная command-palette (audit B3). Открывается по ⌘K / Ctrl+K, кликом
|
||||
* на плашку в AppSidebar или кнопку «Поиск» в AppTopbar. Список — навигация
|
||||
* по 8 разделам портала; фильтр по подстроке; Enter → первый результат.
|
||||
* Монтируется один раз в AppLayout.
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
|
||||
interface PaletteItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: PaletteItem[] = [
|
||||
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
|
||||
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals' },
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
|
||||
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
|
||||
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
|
||||
];
|
||||
|
||||
const { open, closePalette } = useCommandPalette();
|
||||
const router = useRouter();
|
||||
|
||||
const query = ref('');
|
||||
|
||||
const filteredItems = computed<PaletteItem[]>(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (q === '') return NAV_ITEMS;
|
||||
return NAV_ITEMS.filter((i) => i.title.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Сброс query при каждом открытии.
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) query.value = '';
|
||||
});
|
||||
|
||||
function selectItem(item: PaletteItem): void {
|
||||
closePalette();
|
||||
void router.push(item.to);
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
const first = filteredItems.value[0];
|
||||
if (first) selectItem(first);
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent): void {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
if (open.value) return;
|
||||
e.preventDefault();
|
||||
open.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
|
||||
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
|
||||
|
||||
defineExpose({ query, filteredItems, selectItem, onSubmit });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="open" :max-width="520" data-testid="command-palette">
|
||||
<v-card class="cmdk-card">
|
||||
<v-text-field
|
||||
v-model="query"
|
||||
autofocus
|
||||
placeholder="Поиск разделов…"
|
||||
variant="plain"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
class="cmdk-input px-3 pt-2"
|
||||
data-testid="command-palette-input"
|
||||
@keydown.enter="onSubmit"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list density="compact" class="cmdk-list" data-testid="command-palette-list">
|
||||
<v-list-item
|
||||
v-for="item in filteredItems"
|
||||
:key="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
data-testid="command-palette-item"
|
||||
@click="selectItem(item)"
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="filteredItems.length === 0"
|
||||
class="text-medium-emphasis"
|
||||
title="Ничего не найдено"
|
||||
data-testid="command-palette-empty"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cmdk-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmdk-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Глобальное состояние command-palette (⌘K, audit B3). Module-level singleton
|
||||
* ref — AppSidebar/AppTopbar открывают палитру без prop-drilling, CommandPalette
|
||||
* (смонтирована один раз в AppLayout) использует тот же ref как v-model.
|
||||
*/
|
||||
const open = ref(false);
|
||||
|
||||
export function useCommandPalette() {
|
||||
return {
|
||||
open,
|
||||
openPalette: (): void => {
|
||||
open.value = true;
|
||||
},
|
||||
closePalette: (): void => {
|
||||
open.value = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { usePolling } from '../composables/usePolling';
|
||||
import AppSidebar from '../components/layout/AppSidebar.vue';
|
||||
import AppTopbar from '../components/layout/AppTopbar.vue';
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
import CommandPalette from '../components/layout/CommandPalette.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const notifications = useNotificationsStore();
|
||||
@@ -73,6 +74,7 @@ usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
|
||||
</RouterView>
|
||||
</v-main>
|
||||
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
||||
<CommandPalette />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -180,6 +180,19 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Напоминания',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
name: 'import',
|
||||
component: () => import('../views/ImportView.vue'),
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Импорт данных',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 29,
|
||||
devLabel: 'Импорт данных',
|
||||
},
|
||||
},
|
||||
// Админка SaaS — отдельный layout с под-брендом ADMIN.
|
||||
// TODO: дополнительный role-guard на super_admin.
|
||||
{
|
||||
@@ -210,6 +223,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',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { fetchDealsCount } from '../api/deals';
|
||||
|
||||
/**
|
||||
* Счётчик сделок tenant'а для бейджа «Сделки» в AppSidebar (audit B2).
|
||||
* count=null до загрузки или на fail → бейдж скрыт (resolveCount → 0).
|
||||
*/
|
||||
export const useDealsCountStore = defineStore('dealsCount', () => {
|
||||
const count = ref<number | null>(null);
|
||||
|
||||
async function load(tenantId: number): Promise<void> {
|
||||
try {
|
||||
count.value = await fetchDealsCount(tenantId);
|
||||
} catch {
|
||||
count.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { count, load };
|
||||
});
|
||||
@@ -345,25 +345,42 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
|
||||
exportToastOpen.value = true;
|
||||
return;
|
||||
}
|
||||
await exportDealIds([...selected.value], format);
|
||||
}
|
||||
|
||||
// С tenant_id — backend (RLS-фильтрация чужих id). На fail — fallback на
|
||||
// local CSV (даже если запросили xlsx — без backend'а xlsx не построим).
|
||||
// Audit C3: экспорт всех отфильтрованных сделок — кнопка «Экспорт» в page-head.
|
||||
async function exportAllFiltered(format: 'xlsx' | 'csv' = 'xlsx') {
|
||||
const ids = filteredDeals.value.map((d) => d.id);
|
||||
if (ids.length === 0) {
|
||||
exportToastText.value = 'Список пуст — нечего экспортировать.';
|
||||
exportToastOpen.value = true;
|
||||
return;
|
||||
}
|
||||
await exportDealIds(ids, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Общий экспорт по списку id. С tenant_id — backend (RLS-фильтрация чужих id).
|
||||
* На fail / без tenant — fallback на локальный CSV.
|
||||
*/
|
||||
async function exportDealIds(ids: number[], format: 'xlsx' | 'csv') {
|
||||
exportToastText.value = '';
|
||||
if (auth.user?.tenant_id) {
|
||||
try {
|
||||
if (format === 'xlsx') {
|
||||
const blob = await dealsApi.exportDealsXlsx({
|
||||
tenant_id: auth.user.tenant_id,
|
||||
ids: selected.value,
|
||||
ids,
|
||||
});
|
||||
triggerBlobDownload(blob, `deals_export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
exportToastText.value = `Экспортировано ${selected.value.length} сделок в XLSX.`;
|
||||
exportToastText.value = `Экспортировано ${ids.length} сделок в XLSX.`;
|
||||
} else {
|
||||
const csv = await dealsApi.exportDeals({
|
||||
tenant_id: auth.user.tenant_id,
|
||||
ids: selected.value,
|
||||
ids,
|
||||
});
|
||||
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
exportToastText.value = `Экспортировано ${selected.value.length} сделок в CSV.`;
|
||||
exportToastText.value = `Экспортировано ${ids.length} сделок в CSV.`;
|
||||
}
|
||||
exportToastOpen.value = true;
|
||||
return;
|
||||
@@ -372,11 +389,11 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
|
||||
}
|
||||
}
|
||||
|
||||
buildLocalCsv();
|
||||
buildLocalCsv(ids);
|
||||
}
|
||||
|
||||
function buildLocalCsv() {
|
||||
const idSet = new Set(selected.value);
|
||||
function buildLocalCsv(ids: number[]) {
|
||||
const idSet = new Set(ids);
|
||||
const rows = dealsState.filter((d) => idSet.has(d.id));
|
||||
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
|
||||
const csv = buildCsvString(
|
||||
@@ -398,6 +415,7 @@ defineExpose({
|
||||
applyBulkStatus,
|
||||
applyBulkDelete,
|
||||
applyBulkExport,
|
||||
exportAllFiltered,
|
||||
exportToastOpen,
|
||||
exportToastText,
|
||||
onDealCreated,
|
||||
@@ -513,7 +531,15 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
>
|
||||
{{ trashMode ? 'К сделкам' : 'Корзина' }}
|
||||
</v-btn>
|
||||
<v-btn v-if="!trashMode" variant="outlined" prepend-icon="mdi-download">Экспорт</v-btn>
|
||||
<v-btn
|
||||
v-if="!trashMode"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-download"
|
||||
data-testid="export-all-btn"
|
||||
@click="exportAllFiltered()"
|
||||
>
|
||||
Экспорт
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!trashMode"
|
||||
color="primary"
|
||||
@@ -584,7 +610,12 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-3 pb-2">
|
||||
<v-btn variant="text" size="small" data-testid="project-menu-clear" @click="clearProjectDraft">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
data-testid="project-menu-clear"
|
||||
@click="clearProjectDraft"
|
||||
>
|
||||
Очистить
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
@@ -631,7 +662,12 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-3 pb-2">
|
||||
<v-btn variant="text" size="small" data-testid="manager-menu-clear" @click="clearManagerDraft">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
data-testid="manager-menu-clear"
|
||||
@click="clearManagerDraft"
|
||||
>
|
||||
Очистить
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
|
||||
*
|
||||
* Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
|
||||
* Неизвестные статусы маппятся через UnknownStatusesDialog.
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
getImport,
|
||||
getUnknownStatuses,
|
||||
listImports,
|
||||
uploadImport,
|
||||
type ImportLogResource,
|
||||
type UnknownStatus,
|
||||
} from '../api/imports';
|
||||
import UnknownStatusesDialog from '../components/import/UnknownStatusesDialog.vue';
|
||||
|
||||
const file = ref<File | null>(null);
|
||||
const dryRun = ref(false);
|
||||
const uploading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const history = ref<ImportLogResource[]>([]);
|
||||
const activeImport = ref<ImportLogResource | null>(null);
|
||||
const unknownStatuses = ref<UnknownStatus[]>([]);
|
||||
const wizardOpen = ref(false);
|
||||
|
||||
/** Интервал опроса прогресса активного импорта, мс. */
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const canUpload = computed(() => file.value !== null && !uploading.value);
|
||||
const isProcessing = computed(
|
||||
() =>
|
||||
activeImport.value?.status === 'pending' ||
|
||||
activeImport.value?.status === 'processing',
|
||||
);
|
||||
|
||||
async function refreshHistory(): Promise<void> {
|
||||
try {
|
||||
history.value = await listImports();
|
||||
} catch {
|
||||
// история — не критично, тихо игнорируем
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUnknown(): Promise<void> {
|
||||
try {
|
||||
unknownStatuses.value = await getUnknownStatuses();
|
||||
} catch {
|
||||
unknownStatuses.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOnce(id: number): Promise<void> {
|
||||
try {
|
||||
activeImport.value = await getImport(id);
|
||||
if (!isProcessing.value) {
|
||||
stopPolling();
|
||||
await refreshHistory();
|
||||
await refreshUnknown();
|
||||
}
|
||||
} catch {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(id: number): void {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(() => {
|
||||
void pollOnce(id);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (file.value === null) {
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
activeImport.value = await uploadImport(file.value, dryRun.value);
|
||||
startPolling(activeImport.value.id);
|
||||
file.value = null;
|
||||
} catch {
|
||||
errorMessage.value = 'Не удалось загрузить файл. Проверьте формат (CSV, до 10 МБ).';
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onWizardResolved(): Promise<void> {
|
||||
wizardOpen.value = false;
|
||||
await refreshUnknown();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshHistory();
|
||||
await refreshUnknown();
|
||||
});
|
||||
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="import-view pa-6">
|
||||
<header class="page-head mb-4">
|
||||
<h1 class="text-h4 mb-2">Импорт данных</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Перенос исторических лидов из crm.bp-gr.ru. Формат — CSV-выгрузка (UTF-8).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<v-alert
|
||||
v-if="unknownStatuses.length > 0"
|
||||
data-test="unknown-banner"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
Найдено {{ unknownStatuses.length }} неизвестных статусов воронки — замапьте вручную.
|
||||
<template #append>
|
||||
<v-btn size="small" variant="flat" @click="wizardOpen = true">Замапить</v-btn>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<v-card variant="outlined" class="pa-6 mb-6">
|
||||
<v-file-input
|
||||
v-model="file"
|
||||
label="CSV-файл выгрузки лидов"
|
||||
accept=".csv,text/csv"
|
||||
prepend-icon="mdi-database-import-outline"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:disabled="uploading"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="dryRun"
|
||||
label="Пробный прогон (проверить файл без записи сделок)"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
<v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
<div class="mt-4">
|
||||
<v-btn
|
||||
data-test="upload-btn"
|
||||
color="primary"
|
||||
:loading="uploading"
|
||||
:disabled="!canUpload"
|
||||
@click="submit"
|
||||
>
|
||||
Загрузить
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="activeImport" variant="outlined" class="pa-6 mb-6">
|
||||
<h2 class="text-h6 mb-3">Текущий импорт — {{ activeImport.filename }}</h2>
|
||||
<v-progress-linear v-if="isProcessing" indeterminate color="primary" class="mb-3" />
|
||||
<div data-test="active-status" class="text-body-2">
|
||||
Статус: <strong>{{ activeImport.status }}</strong>
|
||||
</div>
|
||||
<v-table v-if="!isProcessing" density="compact" class="mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Добавлено</td>
|
||||
<td>{{ activeImport.rows_added }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Обновлено</td>
|
||||
<td>{{ activeImport.rows_updated }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Пропущено</td>
|
||||
<td>{{ activeImport.rows_skipped }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Неизвестных статусов</td>
|
||||
<td>{{ activeImport.unknown_statuses_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<v-alert
|
||||
v-if="activeImport.status === 'failed'"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mt-3"
|
||||
>
|
||||
{{ activeImport.error_message }}
|
||||
</v-alert>
|
||||
</v-card>
|
||||
|
||||
<v-card variant="outlined" class="pa-6">
|
||||
<h2 class="text-h6 mb-3">История импортов</h2>
|
||||
<v-table v-if="history.length > 0" density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Файл</th>
|
||||
<th>Статус</th>
|
||||
<th>Добавлено</th>
|
||||
<th>Обновлено</th>
|
||||
<th>Пропущено</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in history" :key="row.id">
|
||||
<td>{{ row.filename }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.rows_added }}</td>
|
||||
<td>{{ row.rows_updated }}</td>
|
||||
<td>{{ row.rows_skipped }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
|
||||
</v-card>
|
||||
|
||||
<UnknownStatusesDialog
|
||||
v-model="wizardOpen"
|
||||
:statuses="unknownStatuses"
|
||||
@resolved="onWizardResolved"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-view {
|
||||
max-width: 1100px;
|
||||
}
|
||||
</style>
|
||||
@@ -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">
|
||||
|
||||
@@ -104,7 +104,18 @@ async function handleSubmit() {
|
||||
<span class="text-caption text-medium-emphasis">или</span>
|
||||
</v-divider>
|
||||
|
||||
<v-btn block size="large" variant="outlined"> Войти через Yandex 360 </v-btn>
|
||||
<v-tooltip
|
||||
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<div v-bind="props" class="yandex-sso-wrap">
|
||||
<v-btn block size="large" variant="outlined" disabled>
|
||||
Войти через Yandex 360
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -126,4 +137,8 @@ async function handleSubmit() {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.yandex-sso-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,6 +37,17 @@ const canSubmit = computed(
|
||||
password.value === passwordConfirmation.value,
|
||||
);
|
||||
|
||||
/**
|
||||
* Ошибка поля подтверждения: client-side проверка совпадения +
|
||||
* проброс backend-ошибки `password_confirmation` если придёт с 422.
|
||||
*/
|
||||
const confirmationError = computed<string[]>(() => {
|
||||
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
|
||||
return ['Пароли не совпадают'];
|
||||
}
|
||||
return errors.value.password_confirmation ?? [];
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
errors.value = {};
|
||||
try {
|
||||
@@ -115,6 +126,7 @@ async function handleSubmit() {
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="confirmationError"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { extractValidationErrors } from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const code = ref(['', '', '', '', '', '']);
|
||||
@@ -27,12 +27,32 @@ const router = useRouter();
|
||||
|
||||
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
|
||||
|
||||
/**
|
||||
* TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд.
|
||||
* Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»).
|
||||
* Значение 30..1 секунд, формат «00:NN».
|
||||
*/
|
||||
function totpWindowLeft(): number {
|
||||
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
||||
}
|
||||
const totpSecondsLeft = ref(totpWindowLeft());
|
||||
const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`);
|
||||
let totpTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
|
||||
// прямой URL без login → отправляем на /login.
|
||||
onMounted(() => {
|
||||
if (!auth.requires2fa && !auth.isAuthenticated) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
totpTimer = setInterval(() => {
|
||||
totpSecondsLeft.value = totpWindowLeft();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (totpTimer) clearInterval(totpTimer);
|
||||
});
|
||||
|
||||
function onInput(index: number, event: Event) {
|
||||
@@ -126,7 +146,12 @@ async function handleSubmit() {
|
||||
<RouterLink to="/recovery-use" class="text-body-2 text-primary">
|
||||
Использовать резервный код
|
||||
</RouterLink>
|
||||
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
|
||||
<span
|
||||
class="text-caption text-medium-emphasis font-mono"
|
||||
:title="`До смены кода в приложении: ${totpCountdown}`"
|
||||
data-testid="totp-countdown"
|
||||
>{{ totpCountdown }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<x-mail::message>
|
||||
@if ($outcome === 'done')
|
||||
# Импорт завершён
|
||||
|
||||
Импорт файла **{{ $log->filename }}** успешно завершён.
|
||||
|
||||
| Показатель | Значение |
|
||||
|:-----------|---------:|
|
||||
| Добавлено сделок | {{ $log->rows_added }} |
|
||||
| Обновлено сделок | {{ $log->rows_updated }} |
|
||||
| Пропущено строк | {{ $log->rows_skipped }} |
|
||||
| Неизвестных статусов | {{ $log->unknown_statuses_count }} |
|
||||
|
||||
@if ($log->unknown_statuses_count > 0)
|
||||
Обнаружены неизвестные статусы воронки — замапьте их вручную на экране «Импорт данных».
|
||||
@endif
|
||||
@else
|
||||
# Импорт не удался
|
||||
|
||||
Импорт файла **{{ $log->filename }}** завершился ошибкой:
|
||||
|
||||
> {{ $log->error_message }}
|
||||
|
||||
Проверьте формат файла и повторите загрузку на экране «Импорт данных».
|
||||
@endif
|
||||
|
||||
<x-mail::button :url="config('app.url').'/import'">
|
||||
Открыть «Импорт данных»
|
||||
</x-mail::button>
|
||||
|
||||
С уважением,<br>
|
||||
Лидерра
|
||||
</x-mail::message>
|
||||
+94
-58
@@ -79,52 +79,74 @@ Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobCon
|
||||
->name('reports.download')
|
||||
->middleware('signed');
|
||||
|
||||
// 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');
|
||||
// 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.
|
||||
@@ -159,21 +181,34 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
|
||||
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
|
||||
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
|
||||
// Сделки — 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');
|
||||
});
|
||||
|
||||
// Sprint 4 — CSV-импорт исторических лидов (ТЗ §6).
|
||||
// ВАЖНО: /unknown-statuses и /unknown-statuses/resolve объявлены ДО
|
||||
// /{importLog}, иначе литеральный сегмент перехватывается параметром.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/imports/unknown-statuses', 'App\Http\Controllers\Api\ImportController@unknownStatuses');
|
||||
Route::post('/api/imports/unknown-statuses/resolve', 'App\Http\Controllers\Api\ImportController@resolveUnknownStatuses');
|
||||
Route::get('/api/imports', 'App\Http\Controllers\Api\ImportController@index');
|
||||
Route::post('/api/imports', 'App\Http\Controllers\Api\ImportController@store');
|
||||
Route::get('/api/imports/{importLog}', 'App\Http\Controllers\Api\ImportController@show');
|
||||
});
|
||||
|
||||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
@@ -240,6 +275,7 @@ Route::view('/billing', 'welcome');
|
||||
Route::view('/settings', 'welcome');
|
||||
Route::view('/reports', 'welcome');
|
||||
Route::view('/reminders', 'welcome');
|
||||
Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт исторических лидов §6
|
||||
Route::view('/admin', 'welcome');
|
||||
Route::view('/admin/tenants', 'welcome');
|
||||
Route::view('/admin/billing', 'welcome');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,43 @@ 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();
|
||||
});
|
||||
|
||||
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
|
||||
$r = $this->getJson('/api/deals?count_only=1');
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('total'))->toBe(2);
|
||||
expect($r->json('deals'))->toBeNull();
|
||||
});
|
||||
|
||||
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
|
||||
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
|
||||
});
|
||||
|
||||
test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||||
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
|
||||
|
||||
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DemoSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('DemoSeeder идемпотентен — повторный запуск не дублирует demo-tenant и admin', function () {
|
||||
$this->seed(DemoSeeder::class);
|
||||
$this->seed(DemoSeeder::class);
|
||||
|
||||
// tenant + admin покрывают оба create-пути сидера (first()??create / updateOrCreate);
|
||||
// projects/deals используют updateOrInsert + skip-guard — тот же класс идемпотентности.
|
||||
expect(Tenant::query()->where('subdomain', 'demo')->count())->toBe(1)
|
||||
->and(User::query()->where('email', 'admin@demo.local')->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Reminder;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create(['balance_leads' => 5]);
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->service = app(HistoricalImportService::class);
|
||||
});
|
||||
|
||||
function importLog(Tenant $tenant, User $user, bool $dryRun = false): ImportLog
|
||||
{
|
||||
return ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
}
|
||||
|
||||
function parseFixture(string $body): array
|
||||
{
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
|
||||
return (new CsvLeadsParser)->parse($header."\n".$body)->rows;
|
||||
}
|
||||
|
||||
test('импортирует исторические лиды, создавая партиции под старые даты', function (): void {
|
||||
$rows = parseFixture(
|
||||
'5001,Окна,окна,79161112233,2023/07/10 10:00:00,,Комментарий,Переговоры,Иван'
|
||||
);
|
||||
|
||||
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
expect($result->added)->toBe(1)
|
||||
->and($result->updated)->toBe(0);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
|
||||
expect($deal->status)->toBe('negotiations')
|
||||
->and($deal->phone)->toBe('79161112233')
|
||||
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
|
||||
});
|
||||
|
||||
test('баланс лидов не списывается, фиксируется транзакция historical_import', function (): void {
|
||||
$rows = parseFixture(
|
||||
'5002,Окна,окна,79161112234,2023/07/11 10:00:00,,,Новые,'
|
||||
);
|
||||
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
expect($this->tenant->fresh()->balance_leads)->toBe(5); // не изменился
|
||||
|
||||
$tx = DB::table('balance_transactions')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->where('type', 'historical_import')
|
||||
->first();
|
||||
expect($tx)->not->toBeNull()
|
||||
->and((int) $tx->amount_leads)->toBe(0);
|
||||
});
|
||||
|
||||
test('повторный импорт того же файла не создаёт дублей (idempotent UPDATE)', function (): void {
|
||||
$rows = parseFixture(
|
||||
'5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Старый,Новые,'
|
||||
);
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
$rows2 = parseFixture(
|
||||
'5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Обновлённый,Оплачено,Пётр'
|
||||
);
|
||||
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows2);
|
||||
|
||||
expect($result->added)->toBe(0)
|
||||
->and($result->updated)->toBe(1)
|
||||
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
|
||||
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
|
||||
->and($deal->contact_name)->toBe('Пётр')
|
||||
->and($deal->comment)->toBe('Обновлённый');
|
||||
});
|
||||
|
||||
test('непустое «Напоминание» создаёт строку reminders', function (): void {
|
||||
$rows = parseFixture(
|
||||
'5004,Окна,окна,79161112236,2023/09/01 10:00:00,2023/09/05 14:00:00,,Новые,'
|
||||
);
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5004)->firstOrFail();
|
||||
$reminder = Reminder::query()->where('deal_id', $deal->id)->firstOrFail();
|
||||
expect($reminder->remind_at->format('Y-m-d H:i'))->toBe('2023-09-05 14:00')
|
||||
->and($reminder->created_by)->toBe($this->user->id);
|
||||
});
|
||||
|
||||
test('неизвестный статус → сделка new + запись в import_unknown_statuses', function (): void {
|
||||
$log = importLog($this->tenant, $this->user);
|
||||
$rows = parseFixture(
|
||||
"5005,Окна,окна,79161112237,2023/10/01 10:00:00,,,Архив,\n".
|
||||
'5006,Окна,окна,79161112238,2023/10/02 10:00:00,,,Архив,'
|
||||
);
|
||||
$result = $this->service->import($this->tenant->id, $this->user->id, $log, $rows);
|
||||
|
||||
expect($result->unknownStatuses)->toBe(['Архив' => 2])
|
||||
->and(Deal::query()->where('source_crm_id', 5005)->firstOrFail()->status)->toBe('new');
|
||||
|
||||
$unknown = ImportUnknownStatus::query()->where('status_ru', 'Архив')->firstOrFail();
|
||||
expect($unknown->occurrences)->toBe(2)
|
||||
->and($unknown->mapped_to_slug)->toBeNull();
|
||||
});
|
||||
|
||||
test('resolved-маппинг tenant-а применяется к ранее неизвестному статусу', function (): void {
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
$rows = parseFixture(
|
||||
'5007,Окна,окна,79161112239,2023/11/01 10:00:00,,,Архив,'
|
||||
);
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
|
||||
});
|
||||
|
||||
test('dry_run не пишет сделки, но считает проекцию', function (): void {
|
||||
$rows = parseFixture(
|
||||
'5008,Окна,окна,79161112240,2023/12/01 10:00:00,,,Новые,'
|
||||
);
|
||||
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user, dryRun: true), $rows);
|
||||
|
||||
expect($result->added)->toBe(1)
|
||||
->and(Deal::query()->where('source_crm_id', 5008)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('неизвестные статусы и resolved-маппинг изолированы по тенантам', function (): void {
|
||||
// Тенант B уже резолвил «Архив» → closed и накопил 9 вхождений.
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $otherTenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 9,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
// Тенант A (this) импортирует лид со статусом «Архив». Под BYPASSRLS queue
|
||||
// worker'ом без явного where(tenant_id) сервис подхватил бы маппинг тенанта B
|
||||
// и инкрементировал бы его строку — это и проверяется.
|
||||
$rows = parseFixture(
|
||||
'6001,Окна,окна,79161119999,2023/06/01 10:00:00,,,Архив,'
|
||||
);
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
// Сделка тенанта A — 'new': маппинг тенанта B НЕ применён.
|
||||
expect(Deal::query()->where('source_crm_id', 6001)->firstOrFail()->status)->toBe('new');
|
||||
|
||||
// У тенанта A — собственная запись import_unknown_statuses, occurrences=1.
|
||||
$ownRow = ImportUnknownStatus::query()
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->where('status_ru', 'Архив')
|
||||
->firstOrFail();
|
||||
expect($ownRow->occurrences)->toBe(1);
|
||||
|
||||
// Строка тенанта B не тронута (occurrences остался 9).
|
||||
$otherRow = ImportUnknownStatus::query()
|
||||
->where('tenant_id', $otherTenant->id)
|
||||
->where('status_ru', 'Архив')
|
||||
->firstOrFail();
|
||||
expect($otherRow->occurrences)->toBe(9);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\ImportCompletedNotification;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
});
|
||||
|
||||
test('письмо об успешном импорте содержит счётчики', function (): void {
|
||||
$log = ImportLog::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => 'done',
|
||||
'rows_added' => 120,
|
||||
'rows_updated' => 8,
|
||||
'rows_skipped' => 2,
|
||||
]);
|
||||
|
||||
$rendered = (new ImportCompletedNotification($log, 'done'))->render();
|
||||
|
||||
expect($rendered)->toContain('120')
|
||||
->and($rendered)->toContain('Импорт завершён');
|
||||
});
|
||||
|
||||
test('письмо о неуспешном импорте сообщает об ошибке', function (): void {
|
||||
$log = ImportLog::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => 'failed',
|
||||
'error_message' => 'Файл повреждён',
|
||||
]);
|
||||
|
||||
$mailable = new ImportCompletedNotification($log, 'failed');
|
||||
$rendered = $mailable->render();
|
||||
|
||||
expect($rendered)->toContain('Файл повреждён')
|
||||
->and($mailable->envelope()->subject)->toContain('не удался');
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ImportLeadsJob;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
// Устанавливаем контекст тенанта на уровне outer-транзакции DatabaseTransactions.
|
||||
// Middleware SetTenantContext использует SET LOCAL внутри savepoint'а — без этой
|
||||
// строки RLS-фильтрация активна только внутри HTTP-запроса, но прямые DB-запросы
|
||||
// в тестах (count, factory) видят все тенанты. Паттерн из DealIndexTest.php.
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('POST /api/imports принимает CSV, создаёт import_log, диспатчит job', function (): void {
|
||||
Queue::fake();
|
||||
Storage::fake('local');
|
||||
|
||||
$csv = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя'."\n".
|
||||
'9001,Окна,окна,79161112233,2023/05/10 10:00:00,,,Новые,';
|
||||
|
||||
$response = $this->postJson('/api/imports', [
|
||||
'file' => UploadedFile::fake()->createWithContent('leads.csv', $csv),
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('data.status', 'pending')
|
||||
->assertJsonPath('data.filename', 'leads.csv');
|
||||
|
||||
Queue::assertPushed(ImportLeadsJob::class);
|
||||
// Defense-in-depth: superuser на dev обходит RLS (BYPASSRLS), поэтому явно
|
||||
// фильтруем по tenant_id — паттерн из DealIndexTest / DealController.
|
||||
expect(ImportLog::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('POST /api/imports отвергает не-CSV файл', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$response = $this->postJson('/api/imports', [
|
||||
'file' => UploadedFile::fake()->create('image.png', 10, 'image/png'),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrorFor('file');
|
||||
});
|
||||
|
||||
test('POST /api/imports требует авторизации', function (): void {
|
||||
app('auth')->forgetGuards();
|
||||
|
||||
$this->postJson('/api/imports', [])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/imports возвращает только import_log своего тенанта', function (): void {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
ImportLog::factory()->count(2)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/imports')
|
||||
->assertStatus(200)
|
||||
->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
test('GET /api/imports/{id} отдаёт прогресс', function (): void {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$log = ImportLog::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => 'processing',
|
||||
'rows_added' => 10,
|
||||
]);
|
||||
|
||||
$this->getJson("/api/imports/{$log->id}")
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('data.status', 'processing')
|
||||
->assertJsonPath('data.rows_added', 10);
|
||||
});
|
||||
|
||||
test('GET /api/imports/unknown-statuses возвращает незамапленные статусы', function (): void {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 3,
|
||||
]);
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed', 'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
$this->getJson('/api/imports/unknown-statuses')
|
||||
->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.status_ru', 'Архив');
|
||||
});
|
||||
|
||||
test('POST /api/imports/unknown-statuses/resolve проставляет маппинг', function (): void {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$unknown = ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 3,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/imports/unknown-statuses/resolve', [
|
||||
'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
|
||||
])->assertStatus(200);
|
||||
|
||||
expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
|
||||
->and($unknown->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('resolve отвергает несуществующий slug', function (): void {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
|
||||
$this->postJson('/api/imports/unknown-statuses/resolve', [
|
||||
'mappings' => [['status_ru' => 'Архив', 'slug' => 'нет-такого']],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/imports/{id} отвергает import_log чужого тенанта (403)', function (): void {
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherUser = User::factory()->for($otherTenant)->create();
|
||||
$foreignLog = ImportLog::factory()->create([
|
||||
'tenant_id' => $otherTenant->id,
|
||||
'user_id' => $otherUser->id,
|
||||
]);
|
||||
|
||||
// Авторизован пользователь $this->tenant; запрашиваем чужой import_log.
|
||||
// abort_if(tenant_id mismatch, 403) в ImportController::show — defense-in-depth.
|
||||
$this->getJson("/api/imports/{$foreignLog->id}")->assertStatus(403);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ImportLeadsJob;
|
||||
use App\Mail\ImportCompletedNotification;
|
||||
use App\Models\Deal;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
Mail::fake();
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
function storedCsv(int $tenantId, string $body): string
|
||||
{
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
$path = "imports/{$tenantId}/test.csv";
|
||||
Storage::disk('local')->put($path, $header."\n".$body);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function runImportJob(int $logId, int $tenantId): void
|
||||
{
|
||||
(new ImportLeadsJob($logId, $tenantId))->handle(
|
||||
app(HistoricalImportService::class),
|
||||
app(CsvLeadsParser::class),
|
||||
);
|
||||
}
|
||||
|
||||
test('job импортирует лиды и переводит import_log в done', function (): void {
|
||||
$path = storedCsv($this->tenant->id,
|
||||
'7001,Окна,окна,79161112233,2023/05/10 10:00:00,,,Новые,Иван'
|
||||
);
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => $path,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
runImportJob($log->id, $this->tenant->id);
|
||||
|
||||
$log->refresh();
|
||||
expect($log->status)->toBe('done')
|
||||
->and($log->rows_added)->toBe(1)
|
||||
->and($log->finished_at)->not->toBeNull()
|
||||
->and(Deal::query()->where('source_crm_id', 7001)->exists())->toBeTrue();
|
||||
|
||||
Mail::assertSent(ImportCompletedNotification::class);
|
||||
});
|
||||
|
||||
test('job переводит import_log в failed при отсутствии файла', function (): void {
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/missing.csv',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
runImportJob($log->id, $this->tenant->id);
|
||||
|
||||
expect($log->refresh()->status)->toBe('failed')
|
||||
->and($log->error_message)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('job пишет unknown_statuses_count и rows_skipped', function (): void {
|
||||
$path = storedCsv($this->tenant->id,
|
||||
"7002,Окна,окна,79161112234,2023/05/11 10:00:00,,,Архив,\n".
|
||||
'7003,Окна,окна,BADPHONE,2023/05/12 10:00:00,,,Новые,'
|
||||
);
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => $path,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
runImportJob($log->id, $this->tenant->id);
|
||||
|
||||
$log->refresh();
|
||||
expect($log->status)->toBe('done')
|
||||
->and($log->unknown_statuses_count)->toBe(1) // «Архив»
|
||||
->and($log->rows_skipped)->toBe(1); // битый телефон
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
});
|
||||
|
||||
test('ImportLog создаётся с дефолтами и кастует mapping_config/dry_run', function (): void {
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/1/uuid.csv',
|
||||
'mapping_config' => ['status' => ['Новые' => 'new']],
|
||||
'dry_run' => true,
|
||||
]);
|
||||
|
||||
expect($log->status)->toBe('pending')
|
||||
->and($log->entity_type)->toBe('leads')
|
||||
->and($log->dry_run)->toBeTrue()
|
||||
->and($log->mapping_config)->toBe(['status' => ['Новые' => 'new']]);
|
||||
});
|
||||
|
||||
test('ImportUnknownStatus хранит маппинг и фильтруется scope unresolved', function (): void {
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 3,
|
||||
]);
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Спам',
|
||||
'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
expect(ImportUnknownStatus::unresolved()->count())->toBe(1)
|
||||
->and(ImportUnknownStatus::unresolved()->first()->status_ru)->toBe('Архив');
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('import_log имеет 5 новых колонок enrichment', function (): void {
|
||||
foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
|
||||
expect(Schema::hasColumn('import_log', $column))->toBeTrue("import_log.$column отсутствует");
|
||||
}
|
||||
});
|
||||
|
||||
test('import_unknown_statuses существует с RLS', function (): void {
|
||||
expect(Schema::hasTable('import_unknown_statuses'))->toBeTrue();
|
||||
|
||||
$rls = DB::selectOne(
|
||||
"SELECT relrowsecurity FROM pg_class WHERE relname = 'import_unknown_statuses'"
|
||||
);
|
||||
expect($rls)->not->toBeNull('pg_class row for import_unknown_statuses не найден');
|
||||
/** @var object{relrowsecurity: bool} $rls */
|
||||
expect($rls->relrowsecurity)->toBeTrue('RLS не включён на import_unknown_statuses');
|
||||
|
||||
$policy = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_policies WHERE tablename = 'import_unknown_statuses' AND policyname = 'tenant_isolation'"
|
||||
);
|
||||
expect($policy)->not->toBeNull('Политика tenant_isolation отсутствует');
|
||||
});
|
||||
|
||||
test('import_unknown_statuses имеет UNIQUE именно по (tenant_id, status_ru)', function (): void {
|
||||
$row = DB::selectOne(
|
||||
"SELECT array_to_string(array_agg(a.attname ORDER BY a.attname), ',') AS cols
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'import_unknown_statuses'::regclass AND c.contype = 'u'"
|
||||
);
|
||||
expect($row)->not->toBeNull('UNIQUE-ограничение отсутствует');
|
||||
/** @var object{cols: string} $row */
|
||||
expect($row->cols)->toBe('status_ru,tenant_id');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function partitionExists(string $name): bool
|
||||
{
|
||||
return DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
[$name],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
test('ensureRange создаёт месячные партиции deals под диапазон', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
|
||||
$created = $manager->ensureRange(
|
||||
'deals',
|
||||
Carbon::parse('2024-02-15'),
|
||||
Carbon::parse('2024-04-03'),
|
||||
);
|
||||
|
||||
expect($created)->toBeGreaterThanOrEqual(3)
|
||||
->and(partitionExists('deals_2024_02'))->toBeTrue()
|
||||
->and(partitionExists('deals_2024_03'))->toBeTrue()
|
||||
->and(partitionExists('deals_2024_04'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureRange идемпотентна — повторный вызов не падает', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
|
||||
$manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
|
||||
$secondRun = $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
|
||||
|
||||
expect($secondRun)->toBe(0); // всё уже существует
|
||||
});
|
||||
|
||||
test('ensureRange отвергает неизвестную таблицу', function (): void {
|
||||
app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now());
|
||||
})->throws(InvalidArgumentException::class);
|
||||
@@ -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,
|
||||
|
||||
@@ -59,24 +59,25 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.19 has correct metrics — 62 base tables, 117 indexes, 39 RLS policies', function () {
|
||||
it('schema.sql v8.21 has correct metrics — 63 base tables, 118 indexes, 40 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.19.
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.21.
|
||||
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
$schema = file_get_contents($schemaPath);
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
// 62 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
// 63 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(62);
|
||||
expect($baseTables)->toBe(63);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(117);
|
||||
expect($createIndexes)->toBe(118);
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(39);
|
||||
expect($createPolicies)->toBe(40);
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import * as notificationsApi from '../../resources/js/api/notifications';
|
||||
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import { useNotificationsStore } from '../../resources/js/stores/notifications';
|
||||
import { useDealsCountStore } from '../../resources/js/stores/dealsCount';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
@@ -45,6 +46,8 @@ const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockU
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = user;
|
||||
// B2: init deals count so badge renders (replaces hardcoded 247 in AppSidebar).
|
||||
useDealsCountStore().count = 247;
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, test } from 'vitest';
|
||||
import { useCommandPalette } from '../../resources/js/composables/useCommandPalette';
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter, type Router } from 'vue-router';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AppSidebar from '../../resources/js/components/layout/AppSidebar.vue';
|
||||
import { useDealsCountStore } from '../../resources/js/stores/dealsCount';
|
||||
|
||||
async function setup(initialRoute = '/deals'): Promise<{ wrapper: VueWrapper; router: Router }> {
|
||||
setActivePinia(createPinia());
|
||||
// B2: default count=5 so badge renders in non-B2 tests (replaces hardcoded 247).
|
||||
useDealsCountStore().count = 5;
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
@@ -63,3 +67,32 @@ describe('AppSidebar — redesigned shell', () => {
|
||||
expect(active).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('B2: бейдж «Сделки» рендерит count из dealsCount-store', async () => {
|
||||
const { wrapper } = await setup();
|
||||
const store = useDealsCountStore();
|
||||
store.count = 42;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const badge = wrapper.find('[data-testid="nav-count-deals"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe('42');
|
||||
});
|
||||
|
||||
test('B2: бейдж «Сделки» скрыт пока count=null', async () => {
|
||||
const { wrapper } = await setup();
|
||||
const store = useDealsCountStore();
|
||||
store.count = null;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="nav-count-deals"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('B3: клик на ⌘K-плашку открывает command-palette', async () => {
|
||||
const { open, closePalette } = useCommandPalette();
|
||||
closePalette();
|
||||
const { wrapper } = await setup();
|
||||
|
||||
await wrapper.find('[data-testid="cmdk-stub"]').trigger('click');
|
||||
expect(open.value).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import CommandPalette from '../../resources/js/components/layout/CommandPalette.vue';
|
||||
import { useCommandPalette } from '../../resources/js/composables/useCommandPalette';
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function mountPalette() {
|
||||
return mount(CommandPalette, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('CommandPalette (B3)', () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
useCommandPalette().closePalette(); // сброс singleton между тестами
|
||||
});
|
||||
|
||||
test('по умолчанию показывает все 8 разделов', () => {
|
||||
const wrapper = mountPalette();
|
||||
expect(wrapper.vm.filteredItems).toHaveLength(8);
|
||||
});
|
||||
|
||||
test('фильтрует по подстроке (case-insensitive)', () => {
|
||||
const wrapper = mountPalette();
|
||||
wrapper.vm.query = 'КАНБ';
|
||||
expect(wrapper.vm.filteredItems).toHaveLength(1);
|
||||
expect(wrapper.vm.filteredItems[0].to).toBe('/kanban');
|
||||
});
|
||||
|
||||
test('пустой результат при отсутствии совпадений', () => {
|
||||
const wrapper = mountPalette();
|
||||
wrapper.vm.query = 'zzzнеттакого';
|
||||
expect(wrapper.vm.filteredItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('selectItem навигирует и закрывает палитру', () => {
|
||||
const { open } = useCommandPalette();
|
||||
open.value = true;
|
||||
const wrapper = mountPalette();
|
||||
wrapper.vm.selectItem({ title: 'Сделки', icon: 'x', to: '/deals' });
|
||||
expect(pushMock).toHaveBeenCalledWith('/deals');
|
||||
expect(open.value).toBe(false);
|
||||
});
|
||||
|
||||
test('onSubmit навигирует на первый отфильтрованный результат', () => {
|
||||
const wrapper = mountPalette();
|
||||
wrapper.vm.query = 'отч';
|
||||
wrapper.vm.onSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith('/reports');
|
||||
});
|
||||
|
||||
test('рендерит 8 пунктов списка по умолчанию', () => {
|
||||
const wrapper = mountPalette();
|
||||
expect(wrapper.findAll('[data-testid="command-palette-item"]')).toHaveLength(8);
|
||||
});
|
||||
|
||||
test('показывает пустое состояние при отсутствии совпадений', async () => {
|
||||
const wrapper = mountPalette();
|
||||
wrapper.vm.query = 'zzzнеттакого';
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="command-palette-empty"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, test, expect, vi, afterEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
import * as dealsApi from '../../resources/js/api/deals';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
|
||||
// Smoke-тесты DealsView с mock-данными.
|
||||
|
||||
@@ -318,3 +321,55 @@ describe('DealsView.vue', () => {
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
});
|
||||
|
||||
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
|
||||
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
|
||||
// Установить auth.user с tenant_id чтобы exportDealIds пошёл в backend
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
|
||||
// activeTab по умолчанию 'active' — установить 'all' чтобы filteredDeals === dealsState
|
||||
const vm = wrapper.vm as unknown as {
|
||||
activeTab: string;
|
||||
dealsState: Array<{ id: number }>;
|
||||
exportAllFiltered: () => Promise<void>;
|
||||
exportToastOpen: boolean;
|
||||
};
|
||||
vm.activeTab = 'all';
|
||||
await flushPromises();
|
||||
|
||||
await vm.exportAllFiltered();
|
||||
|
||||
expect(xlsxSpy).toHaveBeenCalledTimes(1);
|
||||
const callArg = xlsxSpy.mock.calls[0][0];
|
||||
expect(callArg.ids).toEqual(vm.dealsState.map((d) => d.id));
|
||||
expect(vm.exportToastOpen).toBe(true);
|
||||
});
|
||||
|
||||
test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
|
||||
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
activeTab: string;
|
||||
dealsState: Array<{ id: number }>;
|
||||
exportAllFiltered: () => Promise<void>;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
// Очистить список и поставить tab='all' чтобы filteredDeals тоже пустой
|
||||
vm.activeTab = 'all';
|
||||
vm.dealsState.splice(0, vm.dealsState.length);
|
||||
await flushPromises();
|
||||
|
||||
await vm.exportAllFiltered();
|
||||
|
||||
expect(xlsxSpy).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
@@ -95,4 +95,18 @@ describe('ForgotPasswordView.vue', () => {
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.text()).toContain('10 мин');
|
||||
});
|
||||
|
||||
it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => {
|
||||
// forgotPassword отклоняется обычной ошибкой; extractValidationErrors и
|
||||
// extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла).
|
||||
vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error'));
|
||||
|
||||
const wrapper = await mountForgot();
|
||||
await wrapper.find('input[type="email"]').setValue('user@example.ru');
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
const messages = wrapper.findAll('.v-messages__message').map((m) => m.text());
|
||||
expect(messages.join(' ')).toContain('Произошла ошибка. Попробуйте позже.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
|
||||
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
|
||||
return { ...orig };
|
||||
});
|
||||
|
||||
const importsApi = await import('../../resources/js/api/imports');
|
||||
const ImportView = (await import('../../resources/js/views/ImportView.vue')).default;
|
||||
|
||||
const vuetify = createVuetify({ components, directives });
|
||||
|
||||
function mountView() {
|
||||
return mount(ImportView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { UnknownStatusesDialog: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ImportView', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(importsApi, 'listImports').mockResolvedValue([]);
|
||||
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('грузит историю импортов при монтировании', async () => {
|
||||
const spy = vi.spyOn(importsApi, 'listImports').mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
filename: 'leads.csv',
|
||||
status: 'done',
|
||||
rows_total: 5,
|
||||
rows_added: 5,
|
||||
rows_updated: 0,
|
||||
rows_skipped: 0,
|
||||
unknown_statuses_count: 0,
|
||||
dry_run: false,
|
||||
error_message: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
},
|
||||
]);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain('leads.csv');
|
||||
});
|
||||
|
||||
it('кнопка загрузки заблокирована без выбранного файла', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const uploadBtn = wrapper.find('[data-test="upload-btn"]');
|
||||
expect(uploadBtn.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('показывает баннер о неизвестных статусах', async () => {
|
||||
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([
|
||||
{ id: 1, status_ru: 'Архив', occurrences: 3 },
|
||||
]);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="unknown-banner"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -80,4 +80,11 @@ describe('LoginView.vue', () => {
|
||||
expect(alert.text()).toContain('10 мин');
|
||||
expect(alert.text()).toContain('Слишком много попыток');
|
||||
});
|
||||
|
||||
it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => {
|
||||
const wrapper = await mountLoginView();
|
||||
const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));
|
||||
expect(ssoBtn).toBeDefined();
|
||||
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,4 +287,28 @@ describe('NewDealDialog.vue', () => {
|
||||
const closeEmits = wrapper.emitted('update:modelValue');
|
||||
expect(closeEmits === undefined || !closeEmits.some((e) => e[0] === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('C6: при провале loadLookups показывает degradation-alert', async () => {
|
||||
vi.spyOn(dealsApi, 'listProjects').mockRejectedValue(new Error('network'));
|
||||
vi.spyOn(dealsApi, 'listManagers').mockRejectedValue(new Error('network'));
|
||||
|
||||
const wrapper = factory({ modelValue: true, tenantId: 7 });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.lookupsFailed).toBe(true);
|
||||
expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('C6: при успешном loadLookups alert отсутствует', async () => {
|
||||
vi.mocked(dealsApi.listProjects).mockResolvedValue([{ id: 1, name: 'P', tag: null, type: 'manual' }]);
|
||||
vi.mocked(dealsApi.listManagers).mockResolvedValue([
|
||||
{ id: 1, email: 'a@b.c', first_name: 'A', last_name: 'B', name: 'A B', initials: 'AB' },
|
||||
]);
|
||||
|
||||
const wrapper = factory({ modelValue: true, tenantId: 7 });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.lookupsFailed).toBe(false);
|
||||
expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,4 +103,13 @@ describe('ResetPasswordView.vue', () => {
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.text()).toContain('10 мин');
|
||||
});
|
||||
|
||||
it('A4: показывает ошибку при несовпадении пароля и подтверждения', async () => {
|
||||
const wrapper = await mountReset();
|
||||
const pwInputs = wrapper.findAll('input[type="password"]');
|
||||
await pwInputs[0].setValue('new-strong-pass-1234');
|
||||
await pwInputs[1].setValue('different-pass-9999');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain('Пароли не совпадают');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
@@ -51,4 +51,25 @@ describe('TwoFactorView.vue', () => {
|
||||
const links = wrapper.findAll('a').map((a) => a.text());
|
||||
expect(links.some((t) => t.includes('резервный код'))).toBe(true);
|
||||
});
|
||||
|
||||
it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20
|
||||
try {
|
||||
const wrapper = await mountTwoFactor();
|
||||
const el = wrapper.find('[data-testid="totp-countdown"]');
|
||||
expect(el.exists()).toBe(true);
|
||||
expect(el.text()).toBe('00:20');
|
||||
|
||||
// Устанавливаем время так, чтобы после срабатывания интервала (+1000ms)
|
||||
// Date.now() оказался на 15000 ms: 15000 - 1000 = 14000.
|
||||
// epoch 15 c → 30 - (15 % 30) = 15
|
||||
vi.setSystemTime(new Date(14_000));
|
||||
vi.advanceTimersByTime(1000); // interval fires, Date.now() → 15000
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(el.text()).toBe('00:15');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
|
||||
return {
|
||||
...orig,
|
||||
resolveUnknownStatuses: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const importsApi = await import('../../resources/js/api/imports');
|
||||
const UnknownStatusesDialog = (await import('../../resources/js/components/import/UnknownStatusesDialog.vue')).default;
|
||||
|
||||
// VDialog в JSDOM не рендерит через teleport — стаб делает <slot/> доступным
|
||||
// для wrapper.text() / find(). Паттерн из EditProjectDialog.spec.ts.
|
||||
function mountDialog() {
|
||||
return mount(UnknownStatusesDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
statuses: [
|
||||
{ id: 1, status_ru: 'Архив', occurrences: 3 },
|
||||
{ id: 2, status_ru: 'Спам', occurrences: 1 },
|
||||
],
|
||||
},
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VDialog: {
|
||||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('UnknownStatusesDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(importsApi.resolveUnknownStatuses).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('рендерит строку на каждый неизвестный статус', async () => {
|
||||
const wrapper = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Архив');
|
||||
expect(wrapper.text()).toContain('Спам');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('кнопка сохранения заблокирована пока не выбраны все маппинги', async () => {
|
||||
const wrapper = mountDialog();
|
||||
await flushPromises();
|
||||
const saveBtn = wrapper.find('[data-test="save-mappings"]');
|
||||
expect(saveBtn.exists()).toBe(true);
|
||||
expect(saveBtn.attributes('disabled')).toBeDefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('сохраняет маппинги и эмитит resolved', async () => {
|
||||
const spy = vi.mocked(importsApi.resolveUnknownStatuses);
|
||||
const wrapper = mountDialog();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selection['Архив'] = 'closed';
|
||||
vm.selection['Спам'] = 'closed';
|
||||
await flushPromises();
|
||||
await vm.save();
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith([
|
||||
{ status_ru: 'Архив', slug: 'closed' },
|
||||
{ status_ru: 'Спам', slug: 'closed' },
|
||||
]);
|
||||
expect(wrapper.emitted('resolved')).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,4 +133,12 @@ describe('router/index.ts', () => {
|
||||
expect(router.currentRoute.value.name).toBe('reset-password');
|
||||
expect(router.currentRoute.value.params.token).toBe('abc123-token-xyz');
|
||||
});
|
||||
|
||||
it('маршрут /import зарегистрирован с layout app', () => {
|
||||
const importRoute = router.getRoutes().find((r) => r.name === 'import');
|
||||
expect(importRoute, 'route import not found').toBeDefined();
|
||||
expect(importRoute?.path).toBe('/import');
|
||||
expect(importRoute?.meta.layout).toBe('app');
|
||||
expect(importRoute?.meta.requiresAuth).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
|
||||
function csvLeads(string $body, bool $withBom = true): string
|
||||
{
|
||||
$header = 'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
|
||||
return ($withBom ? "\xEF\xBB\xBF" : '').$header."\n".$body;
|
||||
}
|
||||
|
||||
test('парсит валидную строку в ParsedLeadRow', function (): void {
|
||||
$result = (new CsvLeadsParser)->parse(csvLeads(
|
||||
'1001,B1_Окна,окна,79161234567,2024/03/15 10:30:00,,Тёплый клиент,Переговоры,Иван'
|
||||
));
|
||||
|
||||
expect($result->rows)->toHaveCount(1)
|
||||
->and($result->errors)->toBeEmpty();
|
||||
|
||||
$row = $result->rows[0];
|
||||
expect($row->sourceCrmId)->toBe(1001)
|
||||
->and($row->projectName)->toBe('Окна') // префикс B1_ срезан
|
||||
->and($row->projectTag)->toBe('окна')
|
||||
->and($row->phone)->toBe('79161234567')
|
||||
->and($row->statusRu)->toBe('Переговоры')
|
||||
->and($row->contactName)->toBe('Иван')
|
||||
->and($row->reminderAt)->toBeNull()
|
||||
->and($row->receivedAt->format('Y-m-d H:i:s'))->toBe('2024-03-15 10:30:00');
|
||||
});
|
||||
|
||||
test('срезает BOM и не считает заголовок строкой данных', function (): void {
|
||||
$result = (new CsvLeadsParser)->parse(csvLeads(
|
||||
'1,Проект,тег,79990001122,2024/01/01 00:00:00,,,Новые,'
|
||||
));
|
||||
|
||||
expect($result->rows)->toHaveCount(1)
|
||||
->and($result->rows[0]->sourceCrmId)->toBe(1);
|
||||
});
|
||||
|
||||
test('парсит напоминание когда оно непустое', function (): void {
|
||||
$result = (new CsvLeadsParser)->parse(csvLeads(
|
||||
'2,П,т,79990001122,2024/01/01 09:00:00,2024/01/05 12:00:00,,Новые,'
|
||||
));
|
||||
|
||||
expect($result->rows[0]->reminderAt?->format('Y-m-d H:i:s'))->toBe('2024-01-05 12:00:00');
|
||||
});
|
||||
|
||||
test('собирает ошибки невалидного телефона и даты, не роняя парсинг', function (): void {
|
||||
$result = (new CsvLeadsParser)->parse(csvLeads(
|
||||
"3,П,т,8916123,2024/01/01 00:00:00,,,Новые,\n".
|
||||
"4,П,т,79990001122,НЕ-ДАТА,,,Новые,\n".
|
||||
'5,П,т,79990001133,2024/01/02 00:00:00,,,Новые,'
|
||||
));
|
||||
|
||||
expect($result->rows)->toHaveCount(1) // только строка 5 валидна
|
||||
->and($result->rows[0]->sourceCrmId)->toBe(5)
|
||||
->and($result->errors)->toHaveCount(2);
|
||||
|
||||
expect($result->errors[0]['line'])->toBe(2); // 1-я data-строка (после header)
|
||||
});
|
||||
|
||||
test('обрабатывает кавычки и запятые внутри поля', function (): void {
|
||||
$result = (new CsvLeadsParser)->parse(csvLeads(
|
||||
'6,П,т,79990001122,2024/01/01 00:00:00,,"Комментарий, с запятой",Новые,Пётр'
|
||||
));
|
||||
|
||||
expect($result->rows[0]->comment)->toBe('Комментарий, с запятой');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Import\StatusRuToSlugMapper;
|
||||
|
||||
test('маппит все 14 канонических статусов §6.4', function (): void {
|
||||
$mapper = new StatusRuToSlugMapper;
|
||||
|
||||
expect($mapper->toSlug('Новые'))->toBe('new')
|
||||
->and($mapper->toSlug('Оплачено'))->toBe('paid')
|
||||
->and($mapper->toSlug('Конечный недозвон'))->toBe('final_missed')
|
||||
->and($mapper->map())->toHaveCount(14);
|
||||
});
|
||||
|
||||
test('тримит пробелы вокруг значения', function (): void {
|
||||
expect((new StatusRuToSlugMapper)->toSlug(' Переговоры '))->toBe('negotiations');
|
||||
});
|
||||
|
||||
test('возвращает null для неизвестного статуса', function (): void {
|
||||
expect((new StatusRuToSlugMapper)->toSlug('Архив'))->toBeNull()
|
||||
->and((new StatusRuToSlugMapper)->toSlug(''))->toBeNull();
|
||||
});
|
||||
@@ -1286,3 +1286,25 @@ recollage
|
||||
пуш
|
||||
изм
|
||||
рерайтов
|
||||
|
||||
# Sprint 3F — API middleware J1/J2 plan (2026-05-16) — Russian IT-slang
|
||||
роутов
|
||||
стейджить
|
||||
фронтенд
|
||||
стаб
|
||||
гейт
|
||||
гвард
|
||||
гварда
|
||||
вестигиальным
|
||||
спеков
|
||||
|
||||
# Sprint 4 — historical import (2026-05-16) — domain vocab
|
||||
замапьте
|
||||
замапить
|
||||
замапите
|
||||
замапил
|
||||
замапили
|
||||
замаплен
|
||||
замапленного
|
||||
замапленных
|
||||
замапленные
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит девятнадцать записей в обратном хронологическом порядке (v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать записей в обратном хронологическом порядке (v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.20, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.21, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
**История записей:**
|
||||
|
||||
## v8.21 — 2026-05-16 — Sprint 4 (историческая миграция лидов §6)
|
||||
|
||||
- **+1 таблица** `import_unknown_statuses` (tenant-level маппинг неизвестных статусов CSV; RLS `tenant_isolation`; UNIQUE `(tenant_id, status_ru)`; partial index `idx_import_unknown_statuses_unresolved`).
|
||||
- **+5 колонок** в `import_log`: `entity_type`, `source_system`, `mapping_config`, `unknown_statuses_count`, `dry_run`.
|
||||
- GRANTs: `import_unknown_statuses` покрыта umbrella `GRANT ... ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES` (`db/02_grants.sql`) — явный per-table grant не требуется (как у `import_log`).
|
||||
- Миграция: `2026_05_16_120000_sprint4_historical_import_schema.php` (guard'ы `hasTable`/`hasColumn`).
|
||||
|
||||
## v8.20 (11.05.2026 — Plan 5)
|
||||
|
||||
**Added:**
|
||||
|
||||
+35
-3
@@ -1,7 +1,8 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
-- Метрики: 63 базовые таблицы (61 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 117 индексов / 39 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Версия: v8.21 (16.05.2026 — Sprint 4: import_unknown_statuses + import_log enrichment (+5 колонок))
|
||||
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 118 индексов / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
|
||||
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
|
||||
-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK projects.supplier_b{1,2,3}_project_id → supplier_projects (ON DELETE SET NULL) + 3 partial index + CHECK chk_projects_b1_not_for_sms (defense-in-depth дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне). Закрывает code-review BLOCKER#1 + WARNING#3 от 10.05.2026 поздний вечер)
|
||||
@@ -1489,9 +1490,38 @@ CREATE TABLE import_log (
|
||||
CHECK (status IN ('pending','processing','done','failed')),
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
finished_at TIMESTAMPTZ
|
||||
finished_at TIMESTAMPTZ,
|
||||
-- Sprint 4 (H2): enrichment-колонки для исторической миграции лидов (раздел 6.4)
|
||||
entity_type VARCHAR(20) NOT NULL DEFAULT 'leads'
|
||||
CHECK (entity_type IN ('leads','projects')),
|
||||
source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru',
|
||||
mapping_config JSONB,
|
||||
unknown_statuses_count INT NOT NULL DEFAULT 0,
|
||||
dry_run BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- import_unknown_statuses — tenant-level маппинг неизвестных статусов CSV (раздел 6.4)
|
||||
-- Sprint 4 (H1): русский статус из CSV, не найденный в STATUS_RU_TO_SLUG, пишется сюда.
|
||||
-- Wizard (§6.6) проставляет mapped_to_slug; повторный импорт применяет маппинг.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE import_unknown_statuses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
|
||||
status_ru VARCHAR(100) NOT NULL,
|
||||
occurrences INT NOT NULL DEFAULT 0,
|
||||
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE (tenant_id, status_ru)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_import_unknown_statuses_unresolved
|
||||
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. DEALS — партиционированная по received_at (раздел 7.3)
|
||||
@@ -2703,6 +2733,7 @@ ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE comment_templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE deal_tags ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE import_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
|
||||
@@ -2743,6 +2774,7 @@ CREATE POLICY tenant_isolation ON push_subscriptions USING (tenant_id = cur
|
||||
CREATE POLICY tenant_isolation ON comment_templates USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
+528
-56
@@ -90,6 +90,34 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(253,246,227,0.4);
|
||||
color: #fdf6e3;
|
||||
}
|
||||
|
||||
/* ── Панель «Разделы» (функциональная квалификация) ── */
|
||||
#legend-sections-content { display: flex; flex-direction: column; gap: 10px; }
|
||||
#legend-sections-title { font-size: 15px; font-weight: 600; color: #fdf6e3; }
|
||||
.sect-bucket-h { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #b58900; font-weight: 600; margin: 8px 0 2px; }
|
||||
.sect-row { background: #073642; border-radius: 6px; padding: 7px 10px; }
|
||||
.sect-row.sect-empty { opacity: 0.5; }
|
||||
.sect-name { font-size: 12px; color: #eee8d5; font-weight: 600; }
|
||||
.sect-name .sect-id { color: #839496; font-weight: 400; }
|
||||
.sect-cnt { font-size: 11px; color: #839496; }
|
||||
.sect-empty-mark { font-size: 11px; color: #586e75; font-style: italic; margin-top: 3px; }
|
||||
.sect-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
|
||||
.sect-chip { font-size: 10px; color: #93a1a1; background: #002b36; border: 1px solid #586e75; border-radius: 3px; padding: 1px 5px; cursor: pointer; }
|
||||
.sect-chip:hover { background: #0d4a5a; color: #fdf6e3; }
|
||||
|
||||
/* ── Панель «Хотелки» (отложенный backlog развития мозга) ── */
|
||||
#legend-wishlist-content { display: flex; flex-direction: column; gap: 10px; }
|
||||
#legend-wishlist-title { font-size: 15px; font-weight: 600; color: #fdf6e3; }
|
||||
.wish-row { background: #073642; border-radius: 6px; padding: 8px 10px; border-left: 3px solid #586e75; }
|
||||
.wish-row.wish-next { border-left-color: #859900; }
|
||||
.wish-row.wish-blocked { border-left-color: #b58900; }
|
||||
.wish-row.wish-idea { border-left-color: #586e75; }
|
||||
.wish-head { font-size: 12px; color: #eee8d5; font-weight: 600; }
|
||||
.wish-head .wish-id { color: #839496; font-weight: 400; }
|
||||
.wish-status { font-size: 11px; margin-top: 3px; }
|
||||
.wish-note { font-size: 11px; color: #93a1a1; margin-top: 4px; line-height: 1.5; }
|
||||
.wish-sect { font-size: 10px; color: #586e75; margin-top: 4px; }
|
||||
.wish-legend { font-size: 10px; color: #586e75; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -116,6 +144,7 @@
|
||||
<h4>📇 Паспорт узла</h4>
|
||||
<p><span class="pp-k">Внедрён:</span> <span id="ld-since">—</span></p>
|
||||
<p><span class="pp-k">Последнее изменение:</span> <span id="ld-changed">—</span></p>
|
||||
<p><span class="pp-k">Раздел:</span> <span id="ld-section">—</span></p>
|
||||
<p><span class="pp-k">Использований за 7 дней:</span> <span id="ld-uses">—</span></p>
|
||||
<p id="ld-dup-row" style="display:none;"><span class="pp-k">Дубль:</span> <span id="ld-dup">—</span></p>
|
||||
</div>
|
||||
@@ -141,6 +170,23 @@
|
||||
<div class="legend-section"><h4>Обязательность</h4><p id="le-mandatory">—</p></div>
|
||||
<div class="legend-section"><h4>Регламент</h4><p id="le-rule">—</p></div>
|
||||
</div>
|
||||
|
||||
<div id="legend-sections-content" style="display:none;">
|
||||
<div id="legend-sections-title">📂 Разделы деятельности Лидерры</div>
|
||||
<div class="legend-section">
|
||||
<p>Узлы карты распределены по функциональным разделам. Пустые разделы — будущие домены «мозга», под которые в карте dev-автоматики ещё нет узлов (playbook не наполнен).</p>
|
||||
</div>
|
||||
<div id="sect-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="legend-wishlist-content" style="display:none;">
|
||||
<div id="legend-wishlist-title">💡 Хотелки — отложенный backlog</div>
|
||||
<div class="legend-section">
|
||||
<p>Отложенные «хотелки» развития мозга и портала — то, что решили сделать позже, чтобы не забыть. Источник правды — массив WISHLIST в этом HTML-файле; новая хотелка = новый объект.</p>
|
||||
<div class="wish-legend"><span>▶ к работе</span><span>⏸ ждёт зависимости</span><span>💭 идея</span></div>
|
||||
</div>
|
||||
<div id="wish-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +208,8 @@
|
||||
<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>
|
||||
<button class="cat-ctl" id="cat-ctl-sect" title="Показать функциональные разделы и распределение узлов по ним">📂 Разделы</button>
|
||||
<button class="cat-ctl" id="cat-ctl-wish" title="Показать отложенные хотелки — backlog развития мозга и портала">💡 Хотелки</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -180,17 +228,21 @@ 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) ── второе кольцо ───────────────
|
||||
// ── ПЛАГИНЫ (9) ── второе кольцо ───────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
@@ -208,16 +260,24 @@ const NODES = [
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (2) — W sector (RLS) ─────────
|
||||
// ── СКИЛЫ ПРОЕКТА (3) — W sector (RLS) ─────────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
|
||||
// ── ХУКИ (5) — S+infra ────────────────────────
|
||||
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'hooks', size: 20, ring: 4, ...pos(4, 165) },
|
||||
|
||||
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
|
||||
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
|
||||
@@ -253,7 +313,7 @@ const NODES = [
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
|
||||
// ── MEMORY FILES (15) — внешнее кольцо ──────────
|
||||
// ── MEMORY FILES (23) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
@@ -269,6 +329,14 @@ const NODES = [
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
@@ -396,7 +464,7 @@ const EDGES = [
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 72/77\nиспользует агент'),
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
@@ -405,6 +473,24 @@ const EDGES = [
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1:\nвнешний инструмент'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1:\nвнешний инструмент'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1:\nвнешний инструмент'),
|
||||
E('psr_v1', 'context7', 'R10.1:\nвнешний инструмент'),
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
@@ -412,11 +498,11 @@ const EDGES = [
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'hookify может перезаписать существующий хук', 'RED'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Browser is already in use (квирк #2)', 'BLACK'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Гонка в Redis при Pest --parallel из подкаталога (квирк 72)', 'BLACK'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
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)
|
||||
@@ -432,18 +518,18 @@ 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'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -478,8 +564,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 в цепочке приоритетов' },
|
||||
@@ -487,15 +573,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 — единственный канал правок' }
|
||||
],
|
||||
[
|
||||
@@ -505,7 +591,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 в цепочке' }],
|
||||
@@ -518,7 +604,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.',
|
||||
[
|
||||
@@ -533,14 +619,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), паттерны компонентов для Лидерры.',
|
||||
@@ -764,24 +850,24 @@ 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' }]
|
||||
),
|
||||
|
||||
// ── АГЕНТЫ ───────────────────────────────────────
|
||||
ag_pest: nd(
|
||||
'Разбирает падения тестов Pest --parallel: отличает настоящую ошибку от одного из пяти известных квирков (72/73/77...).',
|
||||
'Разбирает падения тестов Pest --parallel: отличает настоящую ошибку от известных квирков (73/77 и др.; квирк 72 устранён 16.05.2026 — commit 0fa1a73).',
|
||||
'При падении Pest --parallel ИЛИ при дымовом тесте (быстрой проверке функциональности) только из подкаталога (как в аудите Phase 3 SyncSupplierProjectsJobTest).',
|
||||
'READ-ONLY (только чтение — только читает код, ничего не правит). Каждую гипотезу подтверждает реальным запуском, а не «похоже на квирк».',
|
||||
[{ name: 'CLAUDE.md §6', cond: 'описывает когда вызывать' }],
|
||||
[],
|
||||
[{ name: 'MCP-сервер redis', cond: 'читает Redis для отладки квирка 72 (гонка supplier:session)' }],
|
||||
[
|
||||
{ name: 'MCP-сервер redis', desc: 'Pest --parallel — гонка (race condition) с кэшем Redis при запуске из подкаталога (квирк 72)', type: 'BLACK' },
|
||||
{ name: 'демон ruflo', desc: 'Фоновый демон ruflo (PM2) worker-jitter усиливает частоту Pest --parallel квирка 72 (гонка в Redis). ruflo не трогает Redis :6379 — лишь timing-amplifier. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93).', type: 'BLACK' }
|
||||
{ name: 'MCP-сервер redis', desc: 'Квирк 72 (гонка с кэшем Redis при Pest --parallel из подкаталога) устранён 16.05.2026 — commit 0fa1a73, array-стор в тестах. Конфликт закрыт.', type: 'GREEN' },
|
||||
{ name: 'демон ruflo', desc: 'Worker-jitter фонового демона ruflo (PM2) усиливает частоту Pest-квирков 73/77. Квирк 72 устранён 16.05 — его jitter больше не усиливает. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93, переоценён).', type: 'BLACK' }
|
||||
]
|
||||
),
|
||||
ag_rls: nd(
|
||||
@@ -910,12 +996,12 @@ const NODE_DETAILS = {
|
||||
),
|
||||
mcp_redis: nd(
|
||||
'Читает Redis/Memurai — ключи, очереди, кэш для отладки гонок (race conditions). СТРОГО READ-ONLY.',
|
||||
'При отладке очередей Redis (Pest --parallel квирк 72), при анализе инвалидации кэша.',
|
||||
'При отладке очередей Redis (Pest --parallel квирки 73/77), при анализе инвалидации кэша.',
|
||||
'**СТРОГО READ-ONLY** — никаких DEL/FLUSHDB/SET/LPUSH из Claude (только GET/KEYS/LIST). Источник Anthropic устарел — миграция post-MVP.',
|
||||
[{ name: 'CLAUDE.md §3.3 #35', cond: 'вне основных фаз (для отладки во время работы); PSR_v1 R10.1 блок 3' }],
|
||||
[],
|
||||
[{ name: 'pest-parallel-debugger агент', cond: 'агент использует для квирка 72 (гонка в Redis)' }],
|
||||
[{ name: 'агент pest-parallel-debugger', desc: 'Гонка в Redis при Pest --parallel при запуске из подкаталога (квирк 72)', type: 'BLACK' }]
|
||||
[{ name: 'агент pest-parallel-debugger', desc: 'Квирк 72 (гонка с кэшем Redis при Pest --parallel из подкаталога) устранён 16.05.2026 — commit 0fa1a73. Конфликт закрыт.', type: 'GREEN' }]
|
||||
),
|
||||
mcp_21st: nd(
|
||||
'Генератор стартовых шаблонов UI-компонентов через LLM. Активация только через процедуру R14.4.',
|
||||
@@ -1033,12 +1119,12 @@ const NODE_DETAILS = {
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{ name: 'pest-parallel-debugger агент', cond: 'квирки 72/77 используются агентом' },
|
||||
{ name: 'pest-parallel-debugger агент', cond: 'квирки 73/77 используются агентом' },
|
||||
{ name: 'SessionStart хук', cond: 'читается при старте' }
|
||||
]
|
||||
),
|
||||
mem_sp: nd(
|
||||
'Правило §12 (sub-policy под ruflo Queen-led routing) + архитектура хука economy из 6 компонентов — дисциплина вызова скилов.',
|
||||
'Правило §12 (hard-rule уровня 0) + архитектура хука economy из 6 компонентов — дисциплина вызова скилов.',
|
||||
'При работе со скилами — для соответствия обязательному правилу §12 Pravila.',
|
||||
'Описывает архитектуру хука economy (6 компонентов) — менять только при изменении самого хука.',
|
||||
[],
|
||||
@@ -1137,10 +1223,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/ — не задействовано' },
|
||||
@@ -1151,7 +1240,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 «ролей» (Архитектор/Кодер/…) — таких ролей в рантайме не существует.',
|
||||
@@ -1164,11 +1253,11 @@ const NODE_DETAILS = {
|
||||
ruflo_daemon: nd(
|
||||
'Фоновый демон ruflo под управлением PM2 (процесс `ruflo-daemon`). По расписанию запускает 5 воркеров: map (каждые 15 мин), audit (10 мин), optimize (15 мин), consolidate (30 мин), testgaps (20 мин). Переживает перезагрузку через планировщик задач Windows.',
|
||||
'Работает постоянно в фоне.',
|
||||
'Инспекция рантайма 15.05.2026: воркеры audit / optimize / testgaps пытаются запустить `claude` и КАЖДЫЙ РАЗ падают с ошибкой «файл не найден» (spawn claude ENOENT) — результат пустой (за сутки: audit 29 запусков, optimize 19, testgaps 14 — все пустые). Журнал демона при этом помечает их «успешными» — расхождение метрики и факта. Локально работают только воркеры map и consolidate (без вызова `claude`). Worker-jitter усиливает частоту Pest-квирка 72 — на baseline-регрессии нужно `pm2 stop ruflo-daemon`.',
|
||||
'Инспекция рантайма 15.05.2026: воркеры audit / optimize / testgaps пытаются запустить `claude` и КАЖДЫЙ РАЗ падают с ошибкой «файл не найден» (spawn claude ENOENT) — результат пустой (за сутки: audit 29 запусков, optimize 19, testgaps 14 — все пустые). Журнал демона при этом помечает их «успешными» — расхождение метрики и факта. Локально работают только воркеры map и consolidate (без вызова `claude`). Worker-jitter усиливает частоту Pest-квирков 73/77 (квирк 72 устранён 16.05) — на baseline-регрессии нужно `pm2 stop ruflo-daemon`.',
|
||||
[],
|
||||
[],
|
||||
[{ name: 'память ruflo', cond: 'воркер consolidate обращается к хранилищу' }],
|
||||
[{ name: 'агент pest-parallel-debugger', desc: 'Фоновый демон ruflo (PM2) worker-jitter усиливает частоту Pest --parallel квирка 72 (гонка в Redis). ruflo не трогает Redis :6379 — лишь timing-amplifier. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93).', type: 'BLACK' }]
|
||||
[{ name: 'агент pest-parallel-debugger', desc: 'Worker-jitter фонового демона ruflo (PM2) усиливает частоту Pest-квирков 73/77. Квирк 72 устранён 16.05 (commit 0fa1a73) — его jitter больше не усиливает. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93, переоценён).', type: 'BLACK' }]
|
||||
),
|
||||
ruflo_mcp: nd(
|
||||
'MCP-сервер ruflo (внешний сервис-инструмент Клода) — отдельный процесс `ruflo mcp start`, режим stdio, 7-й сервер в `.mcp.json`. Экспонирует ~210 инструментов (агенты / память / рой / хуки / нейросеть и др.).',
|
||||
@@ -1234,6 +1323,159 @@ const NODE_DETAILS = {
|
||||
[],
|
||||
[{ name: 'ruflo Queen', cond: 'документирует ruflo-интеграцию' }]
|
||||
),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 ────────────────
|
||||
skill_creator: nd(
|
||||
'Плагин Anthropic для создания новых скилов — eval-driven подход: датасеты сценариев, train/test split, бенчмарк-цикл.',
|
||||
'При формализации повторяющегося процесса в скил с проверяемым выводом (генерация кода, преобразование файлов).',
|
||||
'Включён в настройках (~/.claude/settings.json). Для discipline-скилов (TDD-типа) предпочтительнее скил writing-skills плагина Superpowers — у них разные философии.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[],
|
||||
[{ name: 'скил writing-skills', cond: 'обе создают скилы — skill-creator eval-driven, writing-skills через TDD' }]
|
||||
),
|
||||
claude_setup: nd(
|
||||
'Плагин Anthropic — рекомендатель автоматизаций (claude-automation-recommender): анализирует репозиторий и советует, какие MCP-серверы, скилы, хуки, суб-агентов добавить.',
|
||||
'При настройке/ревизии автоматизации проекта — «чего не хватает в тулчейне».',
|
||||
'Включён в настройках (~/.claude/settings.json). Рекомендации — совещательные, решение за заказчиком.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[],
|
||||
[]
|
||||
),
|
||||
plugin_dev: nd(
|
||||
'Плагин Anthropic для разработки плагинов Claude Code — 7 скилов (структура плагина, разработка скилов / агентов / хуков / команд, интеграция MCP, настройки).',
|
||||
'При создании или правке плагина и его компонентов.',
|
||||
'Включён в настройках. Содержит 3 агента, уже представленные на карте (agent-creator / plugin-validator / skill-reviewer).',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[
|
||||
{ name: 'агент plugin-dev:agent-creator', cond: 'входит в плагин' },
|
||||
{ name: 'агент plugin-dev:plugin-validator', cond: 'входит в плагин' },
|
||||
{ name: 'агент plugin-dev:skill-reviewer', cond: 'входит в плагин' }
|
||||
],
|
||||
[]
|
||||
),
|
||||
context7: nd(
|
||||
'Плагин Anthropic — актуальная документация библиотек / фреймворков / API через MCP-инструменты query-docs и resolve-library-id.',
|
||||
'При вопросах по библиотеке / фреймворку / SDK / CLI — синтаксис API, конфигурация, миграция версий. Предпочтительнее веб-поиска для документации библиотек.',
|
||||
'Включён в настройках. Не для рефакторинга / отладки бизнес-логики / ревью — только документация.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[],
|
||||
[]
|
||||
),
|
||||
hk_self_check: nd(
|
||||
'Хук SessionStart — economy-self-check.py: при старте сессии восстанавливает уровень экономии из файла состояния и проверяет согласованность.',
|
||||
'SessionStart — единожды при инициализации сессии.',
|
||||
'Часть архитектуры economy-хука из 6 компонентов (memory feedback_superpowers_hard_rule). §12 economy-режим не отменяет.',
|
||||
[{ name: '~/.claude/settings.json', cond: 'хук SessionStart (user-level)' }],
|
||||
[],
|
||||
[{ name: 'хук economy-mode', cond: 'оба — часть системы экономии' }]
|
||||
),
|
||||
hk_skill_marker: nd(
|
||||
'Хук PreToolUse на вызов Skill — skill-marker.py: фиксирует факт инвокации скила в состоянии сессии (пара со skill-check).',
|
||||
'PreToolUse, matcher Skill — перед каждым вызовом скила.',
|
||||
'Часть пары skill-marker / skill-check — runtime-энфорсмент §12 (скил инвокируется первым).',
|
||||
[{ name: '~/.claude/settings.json', cond: 'хук PreToolUse (user-level)' }],
|
||||
[],
|
||||
[{ name: 'хук skill-check', cond: 'пара: marker фиксирует вызов скила, check проверяет' }]
|
||||
),
|
||||
hk_skill_check: nd(
|
||||
'Хук PreToolUse на Edit/Write/MultiEdit — skill-check.py: проверяет, был ли инвокирован подходящий скил перед правкой кода (§12 дисциплина).',
|
||||
'PreToolUse, matcher Edit|Write|MultiEdit.',
|
||||
'Пара со skill-marker. Энфорсит §12 — нельзя править код, минуя обязательный скил.',
|
||||
[{ name: '~/.claude/settings.json', cond: 'хук PreToolUse (user-level)' }],
|
||||
[],
|
||||
[{ name: 'хук skill-marker', cond: 'пара skill-marker / skill-check' }]
|
||||
),
|
||||
hk_state_guard: nd(
|
||||
'Хук PreToolUse на Edit/Write/MultiEdit/Bash/Agent — economy-state-guard.py: ловит обходы режима экономии (в т.ч. Bash-обход Edit и наследование суб-агентами).',
|
||||
'PreToolUse, matcher Edit|Write|MultiEdit|Bash|Agent.',
|
||||
'Часть системы экономии из 6 компонентов. Закрывает bypass-пути (Bash вместо Edit, суб-агенты).',
|
||||
[{ name: '~/.claude/settings.json', cond: 'хук PreToolUse (user-level)' }],
|
||||
[],
|
||||
[{ name: 'хук economy-mode', cond: 'оба — система экономии' }]
|
||||
),
|
||||
hk_postcompact: nd(
|
||||
'Хук PostCompact — economy-postcompact.py: после компактификации сессии переинжектит состояние режима экономии в контекст.',
|
||||
'PostCompact — после сжатия истории сессии.',
|
||||
'Часть системы экономии из 6 компонентов — гарантирует, что режим переживает компакт.',
|
||||
[{ name: '~/.claude/settings.json', cond: 'хук PostCompact (user-level)' }],
|
||||
[],
|
||||
[]
|
||||
),
|
||||
hk_verifier: nd(
|
||||
'Хук Stop — агент-верификатор (модель Sonnet 4.6): после ответа проверяет соответствие режиму экономии — заявления «готово» без тестов, правки без тестов, выборочные результаты.',
|
||||
'Stop — после каждого ответа Claude (кроме тривиальных Q&A-ходов).',
|
||||
'Решение decision:block при нарушении. На уровне экономии 5 — short-circuit {compliant:true}. Стоит денег (вызовы Sonnet).',
|
||||
[{ name: '~/.claude/settings.json', cond: 'хук Stop типа agent (user-level)' }],
|
||||
[],
|
||||
[{ name: 'скил verification-before-completion', cond: 'верификатор энфорсит то, что требует скил' }]
|
||||
),
|
||||
hk_ruflo_queen: nd(
|
||||
'Хук UserPromptSubmit — ruflo-queen-hook.mjs: при триггер-словах queen/королева в промпте инжектит жёсткую директиву маршрутизации задачи через ruflo Queen (Pravila §14).',
|
||||
'UserPromptSubmit — перед каждым промптом; срабатывает на queen / королева.',
|
||||
'Энфорсит §14 Pravila (hard-rule). Перед платным спавном роя — превью. Зарегистрирован в project .claude/settings.json.',
|
||||
[
|
||||
{ name: '.claude/settings.json', cond: 'хук UserPromptSubmit (project-level)' },
|
||||
{ name: 'Pravila §14', cond: 'энфорсит queen-триггер' }
|
||||
],
|
||||
[],
|
||||
[{ name: 'ruflo Queen', cond: 'хук маршрутизирует queen-задачи на Queen' }]
|
||||
),
|
||||
sk_regression: nd(
|
||||
'Проектный скил /regression — единый прогон регрессии: Pest --parallel + Vitest + сборка, парсинг результатов (JSON-first для pest --parallel, см. квирк 94).',
|
||||
'Перед коммитом/пушем или при запросе полной регрессии — единая сводка по тестам.',
|
||||
'Реализация — .claude/skills/regression/ (SKILL.md + run.mjs + run.test.mjs). parsePest: JSON.parse строки {"tool":"pest"}, regex — fallback.',
|
||||
[],
|
||||
[],
|
||||
[{ name: 'агент pest-parallel-debugger', cond: 'при падениях Pest --parallel передаёт разбор агенту' }]
|
||||
),
|
||||
mem_audit_b: nd(
|
||||
'Memory-файл audit_B_status — статус и находки аудитов D и B (закрыты, 6 коммитов, 34/34 RLS после хотфикса).',
|
||||
'При вопросах про аудиты D / B — историческая запись, аудит не запускать повторно.',
|
||||
'Снимок-история. Не редактировать — новые аудиты в новые файлы.',
|
||||
[], [], []
|
||||
),
|
||||
mem_audit_c: nd(
|
||||
'Memory-файл audit_C_pending — аудит C, полностью реализован 07.05 (4 коммита).',
|
||||
'При вопросах про аудит C — историческая запись, не запускать повторно.',
|
||||
'Снимок-история. Детали — реестр §13.10, CHANGELOG §Y.',
|
||||
[], [], []
|
||||
),
|
||||
mem_suppliercrm: nd(
|
||||
'Memory-файл supplier_crm — бизнес-модель поставщика лидов crm.bp-gr.ru: B1/B2/B3 = платформы-источники, проекты = каналы, сделки = лиды, 14 статусов.',
|
||||
'При работе с интеграцией поставщика — для доменной модели.',
|
||||
'Доменное описание, синхронизировано со схемой Лидерры.',
|
||||
[], [], []
|
||||
),
|
||||
mem_audit12: nd(
|
||||
'Memory-файл full_audit_2026-05-12 — аудит портала 12.05 + post-audit + закрытия Q.DEFER.003/004.',
|
||||
'При вопросах про аудит 12.05 и его последствия.',
|
||||
'Снимок-история. Содержит уроки про axe-core race-condition и mis-attribution квирка 72.',
|
||||
[], [], []
|
||||
),
|
||||
mem_audit14: nd(
|
||||
'Memory-файл full_audit_2026-05-14 — аудит портала #3 (14 фаз, 26 находок, вердикт зелёный).',
|
||||
'При вопросах про аудит #3 от 14.05.',
|
||||
'Снимок-история. Не редактировать при последующих аудитах.',
|
||||
[], [], []
|
||||
),
|
||||
mem_sprint1: nd(
|
||||
'Memory-файл sprint1_p0_closure — закрытие 5 P0 UI-находок аудита (DealsView, Kanban DnD, BulkActionsBar, Admin-экраны).',
|
||||
'При вопросах про Sprint 1 / P0-фиксы портала.',
|
||||
'Снимок-история спринта. 10 атомарных коммитов.',
|
||||
[], [], []
|
||||
),
|
||||
mem_sprint2: nd(
|
||||
'Memory-файл sprint2_p1_progress — Sprint 2 P1 wave 1: планы A (Auth) / B (Settings) / C (Billing) — закрыты и запушены.',
|
||||
'При вопросах про Sprint 2 / P1-фиксы.',
|
||||
'Снимок-история. Содержит коррекцию: SyncSupplierProjectsJobTest — реальный баг времени, не квирк 72.',
|
||||
[], [], []
|
||||
),
|
||||
mem_sprint3: nd(
|
||||
'Memory-файл sprint3_progress — Sprint 3 (P1 wave 2): под-планы 3A-3F, 3A-3D закрыты и запушены, 3E-3F в ожидании.',
|
||||
'При вопросах про Sprint 3 / P1 wave 2.',
|
||||
'Снимок-история, обновляется по ходу спринта.',
|
||||
[], [], []
|
||||
),
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1246,7 +1488,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 ядро)' },
|
||||
@@ -1335,10 +1577,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' },
|
||||
@@ -1346,10 +1588,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' },
|
||||
@@ -1369,10 +1612,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: '—' },
|
||||
@@ -1460,7 +1703,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: 'инспекция',
|
||||
@@ -1475,6 +1718,29 @@ const NODE_META = {
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
mem_ruflo: { since: '15.05.2026', changed: '16.05.2026', uses: 18, usesSrc: 'memory-чтение' },
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — узлы добавлены по полному аудиту карты ──
|
||||
// uses новых узлов по транскриптам не измерялись (null = нет данных).
|
||||
skill_creator: { since: '11.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
claude_setup: { since: '11.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
plugin_dev: { since: '—', changed: '—', uses: null, usesSrc: '—' },
|
||||
context7: { since: '—', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_self_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_skill_marker: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_skill_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_state_guard: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_postcompact: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_verifier: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_ruflo_queen: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
sk_regression: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_audit_b: { since: '08.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_audit_c: { since: '07.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_suppliercrm: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_audit12: { since: '12.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_audit14: { since: '14.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_sprint1: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_sprint2: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_sprint3: { since: '16.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
};
|
||||
|
||||
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
|
||||
@@ -1501,6 +1767,138 @@ DUPLICATE_GROUPS.forEach(g => g.members.forEach(m => {
|
||||
}));
|
||||
const DUP_NODE_SET = new Set(DUP_BY_NODE.keys()); // 12 узлов-членов парных дублей
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3.5: ФУНКЦИОНАЛЬНЫЕ РАЗДЕЛЫ (функциональная квалификация)
|
||||
// ════════════════════════════════════════════════════
|
||||
// Разделы деятельности Лидерры. Каждый узел карты отнесён к одному разделу
|
||||
// (NODE_SECTION). Часть разделов пока пустая — это бизнес-домены, под которые
|
||||
// в карте dev-автоматики ещё нет узлов. Основа будущего «мозга»: 1 раздел =
|
||||
// 1 playbook «как и что делать».
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 83 узла карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
};
|
||||
// Производные индексы для рендера панели и Паспорта.
|
||||
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
|
||||
const SECTION_NODES = new Map(SECTIONS.map(s => [s.id, []]));
|
||||
NODES.forEach(n => {
|
||||
const sid = NODE_SECTION[n.id];
|
||||
if (sid && SECTION_NODES.has(sid)) SECTION_NODES.get(sid).push(n.id);
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3.5: WISHLIST — отложенный backlog (todo-лист хотелок)
|
||||
// ════════════════════════════════════════════════════
|
||||
// Редактируемый список отложенных «хотелок» развития мозга/портала.
|
||||
// Добавить хотелку = добавить объект. status: 'next' | 'blocked' | 'idea'.
|
||||
const WISH_STATUS = {
|
||||
next: { emoji: '▶', label: 'к работе', color: '#859900' },
|
||||
blocked: { emoji: '⏸', label: 'ждёт зависимости', color: '#b58900' },
|
||||
idea: { emoji: '💭', label: 'идея', color: '#586e75' },
|
||||
};
|
||||
const WISHLIST = [
|
||||
{ id: 'W1', status: 'next', section: 'E8',
|
||||
title: 'K7-spike — починка embeddings ruflo',
|
||||
note: 'Tensor.location / onnxruntime version sync. Гейт жизнеспособности моста claude-mem→ReasoningBank. ~1-2 ч, systematic-debugging, ≥3 гипотезы.' },
|
||||
{ id: 'W2', status: 'blocked', section: 'E8',
|
||||
title: 'Мост claude-mem → ReasoningBank',
|
||||
note: 'Замкнутый self-learning loop: захват (claude-mem) → адаптер+LLM-трансформ → сток (ruflo memory) → recall. Gated на W1.' },
|
||||
{ id: 'W3', status: 'blocked', section: 'E8',
|
||||
title: 'claude-mem #1 — установка плагином',
|
||||
note: 'Слой авто-захвата моста. Ставить как плагин (не npx — риск перезаписи settings.json). Роль решается после W1.' },
|
||||
{ id: 'W4', status: 'blocked', section: 'E8',
|
||||
title: 'Двухуровневый ремонтник моста',
|
||||
note: 'Tier 1 — auto-heal операционки (рестарт демона, re-run h7-patch, retry). Tier 2 — circuit-breaker на семантику (halt, не чинить). Часть W2.' },
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS INIT
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1560,6 +1958,8 @@ function renderLegendItem(item) {
|
||||
function showNodeLegend(nodeId) {
|
||||
document.getElementById('legend-node-content').style.display = '';
|
||||
document.getElementById('legend-edge-content').style.display = 'none';
|
||||
document.getElementById('legend-sections-content').style.display = 'none';
|
||||
document.getElementById('legend-wishlist-content').style.display = 'none';
|
||||
const node = NODES.find(n => n.id === nodeId);
|
||||
const details = NODE_DETAILS[nodeId];
|
||||
const panel = document.getElementById('legend-panel');
|
||||
@@ -1577,6 +1977,8 @@ function showNodeLegend(nodeId) {
|
||||
const meta = NODE_META[nodeId] || { since: '—', changed: '—', uses: null, usesSrc: '—' };
|
||||
document.getElementById('ld-since').textContent = meta.since || '—';
|
||||
document.getElementById('ld-changed').textContent = meta.changed || '—';
|
||||
const _sec = NODE_SECTION[nodeId] ? SECTION_BY_ID.get(NODE_SECTION[nodeId]) : null;
|
||||
document.getElementById('ld-section').textContent = _sec ? `${_sec.id} · ${_sec.label}` : '—';
|
||||
|
||||
const usesEl = document.getElementById('ld-uses');
|
||||
if (meta.uses === null || meta.uses === undefined) {
|
||||
@@ -1650,6 +2052,8 @@ function showEdgeLegend(edgeId) {
|
||||
const panel = document.getElementById('legend-panel');
|
||||
document.getElementById('legend-node-content').style.display = 'none';
|
||||
document.getElementById('legend-edge-content').style.display = '';
|
||||
document.getElementById('legend-sections-content').style.display = 'none';
|
||||
document.getElementById('legend-wishlist-content').style.display = 'none';
|
||||
|
||||
const fromNode = NODES.find(n => n.id === edge.from);
|
||||
const toNode = NODES.find(n => n.id === edge.to);
|
||||
@@ -1689,6 +2093,59 @@ function showEdgeLegend(edgeId) {
|
||||
panel.classList.add('visible');
|
||||
}
|
||||
|
||||
// ── Панель «Разделы» — функциональная квалификация (3-й режим легенды) ──
|
||||
function showSectionsLegend() {
|
||||
document.getElementById('legend-node-content').style.display = 'none';
|
||||
document.getElementById('legend-edge-content').style.display = 'none';
|
||||
document.getElementById('legend-wishlist-content').style.display = 'none';
|
||||
document.getElementById('legend-sections-content').style.display = '';
|
||||
let html = '';
|
||||
for (const bucket of SECTION_BUCKETS) {
|
||||
html += `<div class="sect-bucket-h">${bucket.id}. ${bucket.label}</div>`;
|
||||
for (const sec of SECTIONS.filter(s => s.bucket === bucket.id)) {
|
||||
const nodeIds = SECTION_NODES.get(sec.id) || [];
|
||||
const empty = nodeIds.length === 0;
|
||||
html += `<div class="sect-row${empty ? ' sect-empty' : ''}">`;
|
||||
html += `<div><span class="sect-name"><span class="sect-id">${sec.id}</span> ${sec.label}</span> <span class="sect-cnt">· ${nodeIds.length}</span></div>`;
|
||||
if (empty) {
|
||||
html += `<div class="sect-empty-mark">— пусто (playbook ещё не наполнен) —</div>`;
|
||||
} else {
|
||||
html += '<div class="sect-chips">' + nodeIds.map(id => {
|
||||
const node = NODES.find(n => n.id === id);
|
||||
const lbl = node ? node.label.replace(/\n/g, ' ') : id;
|
||||
return `<span class="sect-chip" data-node="${id}">${lbl}</span>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
document.getElementById('sect-list').innerHTML = html;
|
||||
document.getElementById('legend-panel').classList.add('visible');
|
||||
}
|
||||
|
||||
// ── Панель «Хотелки» — отложенный backlog (режим легенды) ──
|
||||
function showWishlistLegend() {
|
||||
document.getElementById('legend-node-content').style.display = 'none';
|
||||
document.getElementById('legend-edge-content').style.display = 'none';
|
||||
document.getElementById('legend-sections-content').style.display = 'none';
|
||||
document.getElementById('legend-wishlist-content').style.display = '';
|
||||
let html = '';
|
||||
for (const w of WISHLIST) {
|
||||
const st = WISH_STATUS[w.status] || WISH_STATUS.idea;
|
||||
html += `<div class="wish-row wish-${w.status}">`;
|
||||
html += `<div class="wish-head"><span class="wish-id">${w.id}</span> ${w.title}</div>`;
|
||||
html += `<div class="wish-status" style="color:${st.color}">${st.emoji} ${st.label}</div>`;
|
||||
html += `<div class="wish-note">${w.note}</div>`;
|
||||
if (w.section) {
|
||||
const sec = SECTION_BY_ID.get(w.section);
|
||||
html += `<div class="wish-sect">Раздел: ${w.section}${sec ? ' · ' + sec.label : ''}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
document.getElementById('wish-list').innerHTML = html;
|
||||
document.getElementById('legend-panel').classList.add('visible');
|
||||
}
|
||||
|
||||
network.on('click', params => {
|
||||
if (params.nodes.length === 1) {
|
||||
const id = params.nodes[0];
|
||||
@@ -1999,6 +2456,8 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
// iter6 — клик по кнопке режима heat/dup
|
||||
const ctl = e.target.closest('.cat-ctl');
|
||||
if (ctl) {
|
||||
if (ctl.id === 'cat-ctl-sect') { showSectionsLegend(); return; }
|
||||
if (ctl.id === 'cat-ctl-wish') { showWishlistLegend(); return; }
|
||||
setViewMode(ctl.id === 'cat-ctl-heat' ? 'heat' : 'dup');
|
||||
applyHighlight();
|
||||
updateLegendVisuals();
|
||||
@@ -2022,6 +2481,19 @@ const HIGHLIGHT = (function setupHighlight() {
|
||||
};
|
||||
})();
|
||||
|
||||
// Клик по чипу узла в панели «Разделы» — открыть паспорт узла + сфокусировать граф.
|
||||
document.getElementById('sect-list').addEventListener('click', e => {
|
||||
const chip = e.target.closest('.sect-chip');
|
||||
if (!chip) return;
|
||||
const id = chip.dataset.node;
|
||||
if (HIGHLIGHT.state.viewMode === null) {
|
||||
HIGHLIGHT.setSelectedNode(id);
|
||||
HIGHLIGHT.applyHighlight();
|
||||
}
|
||||
network.focus(id, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
|
||||
showNodeLegend(id);
|
||||
});
|
||||
|
||||
window.addEventListener('DOMContentLoaded', restoreLegendWidth);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
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 молча игнорирует.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
# Sprint 5A — Auth polish 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:** Закрыть 5 P2-эпиков подсистемы Auth из portal-wide аудита (A1, A4, A5, A6, A8) — Sprint 5, под-план A.
|
||||
|
||||
**Architecture:** Точечные правки 4 auth-view'ов (Vue 3 + Vuetify 3) + DemoSeeder-тулинг. Каждый эпик — отдельная атомарная задача с TDD-циклом (failing test → impl → green → commit). A5 — задача-характеризация (regression-тест), т.к. находка аудита не воспроизводится против текущего кода.
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (frontend), Pest 4 (backend), Laravel 13.
|
||||
|
||||
**Источник:** [docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md) §3 Sprint 5 + §4 раздел A.
|
||||
|
||||
**Исполнять в изолированном worktree** off `origin/main` (`c64be74`): текущая ветка `feat/sprint3f-api-middleware` отстала от origin/main и содержит несвязанный staged WIP (`automation-graph.html`, `dev-indices.json`) — его НЕ трогать. Ветка спринта: `feat/sprint5a-auth-polish`.
|
||||
|
||||
---
|
||||
|
||||
## Эпики (из аудита §4.A)
|
||||
|
||||
| ID | Объект | Находка | Решение |
|
||||
|---|---|---|---|
|
||||
| A1 | `LoginView.vue` Yandex 360 SSO | dead stub без `:disabled` | disabled + tooltip «после Б-1» |
|
||||
| A4 | `ResetPasswordView.vue` поле подтверждения | нет `:error-messages` на несовпадение | computed-ошибка «Пароли не совпадают» |
|
||||
| A5 | `ForgotPasswordView.vue` catch | аудит: «fallback недостижим» | **не воспроизводится** → regression-тест, фиксирующий достижимость |
|
||||
| A6 | `TwoFactorView.vue` таймер | хардкод «02:34» | реальный обратный отсчёт TOTP-окна (30 с) |
|
||||
| A8 | DemoSeeder | «422 при логине» (не пере-сидирован) | `composer demo:seed` + раздел в README + idempotency-тест |
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Задача |
|
||||
|---|---|---|
|
||||
| `app/resources/js/views/auth/LoginView.vue` | экран входа — SSO-кнопка в disabled-tooltip обёртке | T1 (A1) |
|
||||
| `app/resources/js/views/auth/ResetPasswordView.vue` | экран нового пароля — computed-ошибка подтверждения | T2 (A4) |
|
||||
| `app/resources/js/views/auth/ForgotPasswordView.vue` | без правок — только regression-тест | T3 (A5) |
|
||||
| `app/resources/js/views/auth/TwoFactorView.vue` | экран 2FA — таймер TOTP-окна на `setInterval` | T4 (A6) |
|
||||
| `app/composer.json` | +script `demo:seed` | T5 (A8) |
|
||||
| `app/README.md` | +раздел «Демо-данные» | T5 (A8) |
|
||||
| `app/tests/Feature/DemoSeederTest.php` | новый — idempotency DemoSeeder | T5 (A8) |
|
||||
| `app/tests/Frontend/{LoginView,ResetPasswordView,ForgotPasswordView,TwoFactorView}.spec.ts` | +по 1 тесту на эпик | T1-T4 |
|
||||
|
||||
**Команды (запускать из `app/`):**
|
||||
- Один Vitest-файл: `npx vitest run tests/Frontend/<File>.spec.ts`
|
||||
- Один Vitest-тест: `npx vitest run tests/Frontend/<File>.spec.ts -t "<имя>"`
|
||||
- Pest-файл: `php artisan test tests/Feature/DemoSeederTest.php`
|
||||
- Полная регрессия: `npm run test:vue`, `php artisan test`, `npm run type-check`, `npm run lint:vue`, `composer pint`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: A1 — LoginView Yandex 360 SSO disabled + tooltip
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/views/auth/LoginView.vue:107` (SSO-кнопка) + `<style>` блок
|
||||
- Test: `app/tests/Frontend/LoginView.spec.ts` (+1 тест)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/LoginView.spec.ts` внутрь `describe('LoginView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => {
|
||||
const wrapper = await mountLoginView();
|
||||
const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));
|
||||
expect(ssoBtn).toBeDefined();
|
||||
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/LoginView.spec.ts -t "A1"`
|
||||
Expected: FAIL — кнопка не disabled, класс `v-btn--disabled` отсутствует.
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/auth/LoginView.vue` заменить строку 107:
|
||||
|
||||
```html
|
||||
<v-btn block size="large" variant="outlined"> Войти через Yandex 360 </v-btn>
|
||||
```
|
||||
|
||||
на (disabled-кнопка не ловит hover — tooltip вешаем на обёртку-`div`):
|
||||
|
||||
```html
|
||||
<v-tooltip
|
||||
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<div v-bind="props" class="yandex-sso-wrap">
|
||||
<v-btn block size="large" variant="outlined" disabled>
|
||||
Войти через Yandex 360
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
```
|
||||
|
||||
В `<style scoped>` добавить после блока `.login-form { ... }`:
|
||||
|
||||
```css
|
||||
.yandex-sso-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/LoginView.spec.ts`
|
||||
Expected: PASS — все тесты файла (старые 6 + новый A1). Старый тест «содержит ... Yandex 360 SSO» проходит — текст кнопки сохранён.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/LoginView.vue app/tests/Frontend/LoginView.spec.ts
|
||||
git commit -m "feat(auth): A1 — Yandex 360 SSO disabled + tooltip (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: A4 — ResetPasswordView ошибка несовпадения паролей
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/views/auth/ResetPasswordView.vue` (script + поле подтверждения, строки 110-118)
|
||||
- Test: `app/tests/Frontend/ResetPasswordView.spec.ts` (+1 тест)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/ResetPasswordView.spec.ts` внутрь `describe('ResetPasswordView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A4: показывает ошибку при несовпадении пароля и подтверждения', async () => {
|
||||
const wrapper = await mountReset();
|
||||
const pwInputs = wrapper.findAll('input[type="password"]');
|
||||
await pwInputs[0].setValue('new-strong-pass-1234');
|
||||
await pwInputs[1].setValue('different-pass-9999');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain('Пароли не совпадают');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts -t "A4"`
|
||||
Expected: FAIL — текст «Пароли не совпадают» отсутствует (у поля подтверждения нет `:error-messages`).
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/auth/ResetPasswordView.vue` в `<script setup>` добавить после объявления `canSubmit` (после строки 38, перед `async function handleSubmit`):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Ошибка поля подтверждения: client-side проверка совпадения +
|
||||
* проброс backend-ошибки `password_confirmation` если придёт с 422.
|
||||
*/
|
||||
const confirmationError = computed<string[]>(() => {
|
||||
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
|
||||
return ['Пароли не совпадают'];
|
||||
}
|
||||
return errors.value.password_confirmation ?? [];
|
||||
});
|
||||
```
|
||||
|
||||
В `<template>` заменить блок поля «Повторите пароль» (строки 110-118):
|
||||
|
||||
```html
|
||||
<v-text-field
|
||||
v-model="passwordConfirmation"
|
||||
label="Повторите пароль"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
/>
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```html
|
||||
<v-text-field
|
||||
v-model="passwordConfirmation"
|
||||
label="Повторите пароль"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="confirmationError"
|
||||
/>
|
||||
```
|
||||
|
||||
(`computed` уже импортирован — строка 17: `import { computed, ref } from 'vue';`.)
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts`
|
||||
Expected: PASS — старые 5 тестов + новый A4. Тест «успешный submit» проходит: при совпадающих паролях `confirmationError` пустой.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/ResetPasswordView.vue app/tests/Frontend/ResetPasswordView.spec.ts
|
||||
git commit -m "feat(auth): A4 — ResetPassword ошибка несовпадения паролей (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: A5 — ForgotPasswordView regression-тест generic fallback
|
||||
|
||||
> **NB:** Находка аудита A5 «fallback недостижим» **не воспроизводится** против текущего кода (`extractValidationErrors` возвращает строго `Record|null`; store сбрасывает `lockoutSeconds=null` в начале запроса). Эта задача — не TDD-фикс, а **характеризационный regression-тест**, фиксирующий, что generic-fallback показывается на не-валидационной/не-429 ошибке. Код view НЕ меняется.
|
||||
|
||||
**Files:**
|
||||
- Test: `app/tests/Frontend/ForgotPasswordView.spec.ts` (+1 тест)
|
||||
- (правок в `ForgotPasswordView.vue` не предполагается)
|
||||
|
||||
- [ ] **Step 1: Написать характеризационный тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/ForgotPasswordView.spec.ts` внутрь `describe('ForgotPasswordView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => {
|
||||
// forgotPassword отклоняется обычной ошибкой; extractValidationErrors и
|
||||
// extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла).
|
||||
vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error'));
|
||||
|
||||
const wrapper = await mountForgot();
|
||||
await wrapper.find('input[type="email"]').setValue('user@example.ru');
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('Произошла ошибка. Попробуйте позже.');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts -t "A5"`
|
||||
Expected: **PASS** — поведение fallback корректно, находка не воспроизводится.
|
||||
|
||||
- [ ] **Step 3: Развилка по результату Step 2**
|
||||
|
||||
- **Если PASS** (ожидаемо) → A5 классифицируется как «verified — not reproduced»; код view не меняется; переходим к Step 4.
|
||||
- **Если FAIL** (неожиданно — реальный баг) → применить фикс в `app/resources/js/views/auth/ForgotPasswordView.vue`, заменив блок `catch` (строки 32-39):
|
||||
|
||||
```ts
|
||||
} catch (error: unknown) {
|
||||
const validationErrors = extractValidationErrors(error);
|
||||
if (validationErrors && Object.keys(validationErrors).length > 0) {
|
||||
errors.value = validationErrors;
|
||||
} else if (auth.lockoutSeconds === null) {
|
||||
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Перезапустить Step 2 — добиться PASS, и `git add` также файл view.
|
||||
|
||||
- [ ] **Step 4: Запустить весь файл — убедиться, что все проходят**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts`
|
||||
Expected: PASS — старые 5 тестов + новый A5.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/ForgotPasswordView.spec.ts
|
||||
git commit -m "test(auth): A5 — regression generic fallback ForgotPassword (Sprint 5A)"
|
||||
```
|
||||
|
||||
(При срабатывании развилки FAIL — добавить в `git add` также `app/resources/js/views/auth/ForgotPasswordView.vue` и заменить тип коммита на `fix(auth): A5 — ...`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 4: A6 — TwoFactorView реальный обратный отсчёт TOTP-окна
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/views/auth/TwoFactorView.vue` (script: import + countdown-логика + onMounted/onUnmounted; template строка 129)
|
||||
- Test: `app/tests/Frontend/TwoFactorView.spec.ts` (+1 тест с fake timers)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/TwoFactorView.spec.ts` внутрь `describe('TwoFactorView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20
|
||||
try {
|
||||
const wrapper = await mountTwoFactor();
|
||||
const el = wrapper.find('[data-testid="totp-countdown"]');
|
||||
expect(el.exists()).toBe(true);
|
||||
expect(el.text()).toBe('00:20');
|
||||
|
||||
vi.setSystemTime(new Date(15_000)); // epoch 15 c → 30 - 15 = 15
|
||||
vi.advanceTimersByTime(1000);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(el.text()).toBe('00:15');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
В первой строке файла дополнить импорт vitest — `vi` сейчас не импортируется:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
```
|
||||
|
||||
заменить на:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts -t "A6"`
|
||||
Expected: FAIL — элемент `[data-testid="totp-countdown"]` не существует (сейчас хардкод `02:34` без testid).
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/auth/TwoFactorView.vue` заменить строку 14 (импорт):
|
||||
|
||||
```ts
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
||||
```
|
||||
|
||||
Заменить блок (строки 28-36) — `userEmail` + `onMounted`:
|
||||
|
||||
```ts
|
||||
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
|
||||
|
||||
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
|
||||
// прямой URL без login → отправляем на /login.
|
||||
onMounted(() => {
|
||||
if (!auth.requires2fa && !auth.isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
|
||||
|
||||
/**
|
||||
* TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд.
|
||||
* Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»).
|
||||
* Значение 30..1 секунд, формат «00:NN».
|
||||
*/
|
||||
function totpWindowLeft(): number {
|
||||
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
||||
}
|
||||
const totpSecondsLeft = ref(totpWindowLeft());
|
||||
const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`);
|
||||
let totpTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
|
||||
// прямой URL без login → отправляем на /login.
|
||||
onMounted(() => {
|
||||
if (!auth.requires2fa && !auth.isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
totpTimer = setInterval(() => {
|
||||
totpSecondsLeft.value = totpWindowLeft();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (totpTimer) clearInterval(totpTimer);
|
||||
});
|
||||
```
|
||||
|
||||
В `<template>` заменить строку 129:
|
||||
|
||||
```html
|
||||
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```html
|
||||
<span
|
||||
class="text-caption text-medium-emphasis font-mono"
|
||||
:title="`До смены кода в приложении: ${totpCountdown}`"
|
||||
data-testid="totp-countdown"
|
||||
>{{ totpCountdown }}</span
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts`
|
||||
Expected: PASS — старые 3 теста + новый A6. Старые тесты используют реальные таймеры; `setInterval` чистится через `onUnmounted` при teardown.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/TwoFactorView.vue app/tests/Frontend/TwoFactorView.spec.ts
|
||||
git commit -m "feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: A8 — DemoSeeder re-seed script + README + idempotency-тест
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/DemoSeederTest.php`
|
||||
- Modify: `app/composer.json` (блок `scripts`)
|
||||
- Modify: `app/README.md` (+раздел «Демо-данные»)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Создать `app/tests/Feature/DemoSeederTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DemoSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('DemoSeeder идемпотентен — повторный запуск не дублирует demo-tenant и admin', function () {
|
||||
$this->seed(DemoSeeder::class);
|
||||
$this->seed(DemoSeeder::class);
|
||||
|
||||
expect(Tenant::query()->where('subdomain', 'demo')->count())->toBe(1)
|
||||
->and(User::query()->where('email', 'admin@demo.local')->count())->toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что он есть и проходит/падает осознанно**
|
||||
|
||||
Run: `php artisan test tests/Feature/DemoSeederTest.php`
|
||||
Expected: **PASS** — DemoSeeder уже идемпотентен (`updateOrCreate`/`updateOrInsert`). Тест фиксирует это как регрессионную защиту «re-seed скрипта».
|
||||
Если FAIL — значит сидер не идемпотентен; это реальный баг, исправить `DemoSeeder.php` (привести вставки к `updateOrInsert` с ключом `tenant_id`+`name`) и добиться PASS.
|
||||
|
||||
- [ ] **Step 3: Добавить composer-script `demo:seed`**
|
||||
|
||||
В `app/composer.json` в блоке `"scripts"` добавить строку после `"audit-offline"`:
|
||||
|
||||
Заменить:
|
||||
|
||||
```json
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"ide-helper": [
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```json
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
|
||||
"ide-helper": [
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Добавить раздел в `app/README.md`**
|
||||
|
||||
Дописать в конец файла `app/README.md` раздел:
|
||||
|
||||
```markdown
|
||||
## Демо-данные (dev)
|
||||
|
||||
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
|
||||
`php artisan migrate --seed` в окружениях `local` и `testing`
|
||||
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
|
||||
|
||||
**Учётные данные демо-входа:**
|
||||
|
||||
- URL: `/login`
|
||||
- Email: `admin@demo.local`
|
||||
- Пароль: `password`
|
||||
|
||||
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
|
||||
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
|
||||
|
||||
**Пере-сидировать демо-данные** (идемпотентно, существующие записи обновляются,
|
||||
дублей не создаётся):
|
||||
|
||||
```bash
|
||||
composer demo:seed
|
||||
```
|
||||
|
||||
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
|
||||
|
||||
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
|
||||
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Проверить `demo:seed` вручную + запустить тест**
|
||||
|
||||
Run: `composer demo:seed`
|
||||
Expected: вывод содержит `Demo tenant id=... subdomain=demo` и `Login: admin@demo.local / password`.
|
||||
|
||||
Run: `php artisan test tests/Feature/DemoSeederTest.php`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/DemoSeederTest.php app/composer.json app/README.md
|
||||
git commit -m "feat(dev): A8 — composer demo:seed + README демо-данные + idempotency-тест (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Регрессия Sprint 5A
|
||||
|
||||
**Files:** нет правок — финальный gate.
|
||||
|
||||
- [ ] **Step 1: Полный Vitest**
|
||||
|
||||
Run (из `app/`): `npm run test:vue`
|
||||
Expected: все файлы зелёные, 0 failed (4 новых теста A1/A4/A5/A6 + дельта).
|
||||
|
||||
- [ ] **Step 2: Полный Pest**
|
||||
|
||||
Run (из `app/`): `php artisan test`
|
||||
Expected: 0 failed (новый `DemoSeederTest` зелёный).
|
||||
|
||||
- [ ] **Step 3: Type-check + lint + формат**
|
||||
|
||||
Run (из `app/`):
|
||||
```
|
||||
npm run type-check
|
||||
npm run lint:vue
|
||||
composer pint
|
||||
```
|
||||
Expected: vue-tsc 0 ошибок; ESLint 0 ошибок; Pint без изменений (или авто-формат закоммитить отдельным `style:`-коммитом).
|
||||
|
||||
- [ ] **Step 4: Зафиксировать результат**
|
||||
|
||||
Выписать в финальный отчёт фактические числа Pest/Vitest (passed/failed/skipped) с указанием дельты. Если что-то красное — НЕ заявлять Sprint 5A закрытым; чинить по `superpowers:systematic-debugging`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:** A1 ✅ T1 / A4 ✅ T2 / A5 ✅ T3 (re-classified verify) / A6 ✅ T4 / A8 ✅ T5. Все 5 эпиков раздела A из аудита §3 Sprint 5 покрыты.
|
||||
|
||||
**2. Placeholder scan:** код приведён полностью в каждом шаге; команды и Expected — конкретны. Развилка T3 Step 3 содержит готовый фикс-код, не «TODO». Раздел README — полный текст.
|
||||
|
||||
**3. Type consistency:** `confirmationError` (T2) — `computed<string[]>`, совместим с `:error-messages`. `totpWindowLeft()`/`totpSecondsLeft`/`totpCountdown`/`totpTimer` (T4) — имена консистентны между script и template (`data-testid="totp-countdown"` ↔ тест T4 Step 1). `mountLoginView`/`mountReset`/`mountForgot`/`mountTwoFactor` — существующие хелперы spec-файлов, не переопределяются.
|
||||
|
||||
**Известное ограничение:** A5 — задача-характеризация, не фикс (находка аудита не воспроизводится). При закрытии Sprint 5A зафиксировать статус A5 как «verified — not reproduced» в отчёте/реестре.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user