Compare commits
86 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 | |||
| 73d2733522 | |||
| 8b9d9fb029 | |||
| 9db66e6f27 | |||
| 9b6fa50c4c | |||
| d6f0ff868f | |||
| 9929b4a599 | |||
| d84127eaa5 | |||
| 2def31eea9 | |||
| e6556e5a97 | |||
| 4d807fb9f2 | |||
| 68f341191b | |||
| 91c64cde70 | |||
| b027a3cfee | |||
| ab23baa1d5 | |||
| 086fc1a903 | |||
| bd9b8e84fa | |||
| 550e8949d6 | |||
| 4bd419654f | |||
| b163d8a5ca | |||
| 6e35193f3b | |||
| 2504f1b9ec | |||
| ed61bae482 | |||
| bf7f70a5d4 | |||
| cadaecdaf8 | |||
| 283db070e1 | |||
| 7705f022c1 | |||
| 18f132d035 | |||
| e64eb4dbe0 | |||
| c5261a0b22 | |||
| 425d58f2a9 | |||
| 2f267f15f7 |
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
||||
*
|
||||
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
||||
*
|
||||
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
||||
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
||||
*
|
||||
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
||||
? (string) $request->query('range')
|
||||
: '7d';
|
||||
|
||||
// MSK: activity-бакеты и range-границы должны совпадать с SQL
|
||||
// `AT TIME ZONE 'Europe/Moscow'`. config('app.timezone') = UTC.
|
||||
$now = CarbonImmutable::now('Europe/Moscow');
|
||||
[$windowStart, $prevStart] = match ($range) {
|
||||
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
|
||||
'30d' => [$now->subDays(30), $now->subDays(60)],
|
||||
default => [$now->subDays(7), $now->subDays(14)],
|
||||
};
|
||||
|
||||
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$base = fn () => DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false);
|
||||
|
||||
// --- leads received: текущее + предыдущее окно ---
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
$byDay = (clone $base())
|
||||
->where('received_at', '>=', $activityStart)
|
||||
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$points = [];
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$day = $now->subDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
$points[] = (int) ($byDay[$key] ?? 0);
|
||||
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
|
||||
}
|
||||
$maxPoint = max(0, ...$points);
|
||||
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
|
||||
|
||||
// --- funnel: текущий снимок по статусам ---
|
||||
$funnel = (clone $base())
|
||||
->selectRaw('status, COUNT(*) AS c')
|
||||
->groupBy('status')
|
||||
->pluck('c', 'status')
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway ---
|
||||
// runway опирается на приток за фиксированное 7-дневное окно,
|
||||
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
|
||||
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
|
||||
$avgDaily = $leads7d / 7.0;
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/** Процентная дельта current vs previous; 0.0 если previous=0. */
|
||||
private static function pctDelta(float $cur, float $prev): float
|
||||
{
|
||||
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/** Блок {value, <deltaKey>, delta_dir}. */
|
||||
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
|
||||
{
|
||||
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
|
||||
|
||||
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -20,6 +19,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* bulk + export + helpers). Этот класс отвечает только за многоразовые
|
||||
* массовые операции; single-resource действия остаются в DealController.
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-01: N+1 устранён.
|
||||
*
|
||||
* transition: сначала SELECT всех сделок tenant'а из ids, чтобы отфильтровать
|
||||
@@ -41,23 +42,19 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* POST /api/deals/transition — bulk status-update.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...], status: slug}.
|
||||
* Body: {ids: [int...], status: slug}.
|
||||
* Response: {updated, requested, status} (updated = реально изменённых,
|
||||
* без NO-OP).
|
||||
*/
|
||||
public function transition(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'status' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
|
||||
if (! $statusExists) {
|
||||
@@ -67,14 +64,14 @@ class DealBulkActionController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
|
||||
// чтобы (а) отфильтровать NO-OP и (б) сохранить prev в context.from.
|
||||
// Defense-in-depth where(tenant_id) — защита от кросс-tenant id.
|
||||
$rows = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->get(['id', 'status']);
|
||||
|
||||
@@ -88,7 +85,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
// Фаза 2: bulk-UPDATE 1 запросом вместо N.
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $changedIds)
|
||||
->update([
|
||||
'status' => $validated['status'],
|
||||
@@ -100,7 +97,7 @@ class DealBulkActionController extends Controller
|
||||
// массив сериализуем в JSON руками, остальные scalar-поля передаём
|
||||
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
|
||||
$logRows = $changed->map(fn (Deal $d) => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $d->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
@@ -127,7 +124,7 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* DELETE /api/deals — bulk soft-delete.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Body: {ids: [int...]}.
|
||||
* Response: {deleted, requested}.
|
||||
*
|
||||
* Soft-delete сохраняется (см. документацию в DealController.destroy на
|
||||
@@ -137,23 +134,19 @@ class DealBulkActionController extends Controller
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$deleted = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
|
||||
// в activity_log по списку реально удаляемых (NO-OP idempotency).
|
||||
$targetIds = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
@@ -166,7 +159,7 @@ class DealBulkActionController extends Controller
|
||||
$now = now();
|
||||
|
||||
Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $targetIds)
|
||||
->whereNull('deleted_at')
|
||||
->update([
|
||||
@@ -175,7 +168,7 @@ class DealBulkActionController extends Controller
|
||||
]);
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_DELETED,
|
||||
@@ -197,30 +190,26 @@ class DealBulkActionController extends Controller
|
||||
/**
|
||||
* POST /api/deals/restore — bulk restore soft-deleted.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...]}.
|
||||
* Body: {ids: [int...]}.
|
||||
* Response: {restored, requested}.
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$restored = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// withTrashed обходит SoftDeletes global scope; whereNotNull —
|
||||
// NO-OP idempotency для уже живых.
|
||||
$targetIds = Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->whereNotNull('deleted_at')
|
||||
->pluck('id')
|
||||
@@ -234,7 +223,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
Deal::query()
|
||||
->withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $targetIds)
|
||||
->whereNotNull('deleted_at')
|
||||
->update([
|
||||
@@ -243,7 +232,7 @@ class DealBulkActionController extends Controller
|
||||
]);
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'deal_id' => $id,
|
||||
'event' => ActivityLog::EVENT_DEAL_RESTORED,
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,9 +26,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
||||
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
||||
*
|
||||
* На MVP без auth-middleware (multi-tenant контекст резолвится по
|
||||
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
|
||||
* → tenant_id из request()->user()->tenant_id; user ID для manager/audit.
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* Manual-create отличается от webhook'а:
|
||||
* - source_crm_id = NULL (не из webhook).
|
||||
@@ -42,7 +39,7 @@ use Illuminate\Support\Facades\DB;
|
||||
class DealController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||||
* GET /api/deals?status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||||
*
|
||||
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
|
||||
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
|
||||
@@ -53,20 +50,10 @@ class DealController extends Controller
|
||||
* (received_at, id)).
|
||||
*
|
||||
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
|
||||
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
|
||||
* tenant_id (без auth — на MVP, на prod — middleware).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Reports API (schema §13.5). Все endpoint'ы под `auth:sanctum`.
|
||||
@@ -340,6 +342,68 @@ class ReportJobController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= — скачать
|
||||
* готовый файл отчёта (F2, OPEN-И-20).
|
||||
*
|
||||
* Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token.
|
||||
* `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а).
|
||||
* Подпись покрывает все query-параметры — `tenant`/`id` подделать нельзя.
|
||||
*/
|
||||
public function download(Request $request, int $id): Response
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant', '0');
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId): Response {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$job = ReportJob::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if ($job === null) {
|
||||
return response()->json(['message' => 'Отчёт не найден.'], 404);
|
||||
}
|
||||
|
||||
if ($job->status !== ReportJob::STATUS_DONE
|
||||
|| $job->file_path === null
|
||||
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410);
|
||||
}
|
||||
|
||||
if (! Storage::disk('local')->exists($job->file_path)) {
|
||||
return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404);
|
||||
}
|
||||
|
||||
$extension = pathinfo($job->file_path, PATHINFO_EXTENSION);
|
||||
|
||||
return Storage::disk('local')->download(
|
||||
$job->file_path,
|
||||
sprintf('report-%d.%s', $job->id, $extension)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или
|
||||
* после истечения retention (file_path обнулён cron'ом reports:cleanup-expired).
|
||||
*/
|
||||
private function downloadUrl(ReportJob $job): ?string
|
||||
{
|
||||
if ($job->status !== ReportJob::STATUS_DONE
|
||||
|| $job->file_path === null
|
||||
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'reports.download',
|
||||
Carbon::now()->addHours(24),
|
||||
['id' => $job->id, 'tenant' => $job->tenant_id],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function toResource(ReportJob $job): array
|
||||
{
|
||||
@@ -358,6 +422,7 @@ class ReportJobController extends Controller
|
||||
'is_expired' => $job->expires_at !== null && $job->expires_at->isPast(),
|
||||
'retry_count' => (int) ($job->parameters['retry_count'] ?? 0),
|
||||
'retry_max' => self::RETRY_MAX_ATTEMPTS,
|
||||
'download_url' => $this->downloadUrl($job),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Резолв saas_admin_users.id для audit-trail на MVP (saas-admin SSO ⏸ Б-1).
|
||||
*
|
||||
* Берёт admin_user_id из request-параметра; при отсутствии валидного —
|
||||
* создаёт/переиспользует системный стаб-аккаунт (не loginable, is_active=false),
|
||||
* чтобы соблюсти NOT NULL + FK на saas_admin_users в saas_admin_audit_log.
|
||||
*
|
||||
* Паттерн ранее дублировался в AdminPricingTiersController /
|
||||
* AdminSystemSettingsController; новый код использует этот трейт.
|
||||
*/
|
||||
trait ResolvesAdminUserId
|
||||
{
|
||||
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
|
||||
{
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$existingId = DB::table('saas_admin_users')->where('email', $stubEmail)->value('id');
|
||||
if ($existingId !== null) {
|
||||
return (int) $existingId;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => $stubName,
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* billing_summary — агрегат balance_transactions по типу операции (audit F1).
|
||||
*
|
||||
* Группировка по balance_transactions.type; count + SUM(amount_rub). Тип
|
||||
* операции переводится в человекочитаемую метку. parameters: date_from,
|
||||
* date_to (Y-m-d) — фильтр по created_at.
|
||||
*
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (balance_transactions имеет RLS
|
||||
* tenant_isolation) + явный where('tenant_id') — паттерн BillingController.
|
||||
*/
|
||||
class BillingSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
/** Канон-типы balance_transactions.type → RU-метка (schema §7.6 CHECK). */
|
||||
private const TYPE_LABELS = [
|
||||
'trial_bonus' => 'Стартовый бонус',
|
||||
'topup' => 'Пополнение',
|
||||
'lead_charge' => 'Списание за лиды',
|
||||
'refund' => 'Возврат',
|
||||
'manual_adjustment' => 'Ручная корректировка',
|
||||
'historical_import' => 'Импорт истории',
|
||||
'chargeback_writedown' => 'Chargeback — списание в долг',
|
||||
'chargeback_repayment' => 'Chargeback — погашение долга',
|
||||
];
|
||||
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Тип операции', 'Количество', 'Сумма (₽)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('balance_transactions')
|
||||
->where('tenant_id', $job->tenant_id)
|
||||
->whereBetween('created_at', [$dateFrom, $dateTo])
|
||||
->groupBy('type')
|
||||
->orderBy('type')
|
||||
->selectRaw('type, COUNT(*) AS cnt, COALESCE(SUM(amount_rub), 0) AS sum_rub')
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$label = self::TYPE_LABELS[$row->type] ?? (string) $row->type;
|
||||
|
||||
return [$label, (int) $row->cnt, (string) $row->sum_rub];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'billing';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* managers_summary — агрегат сделок по менеджерам за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
|
||||
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
|
||||
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
|
||||
* SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
*/
|
||||
class ManagersSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('deals')
|
||||
->leftJoin('users', 'deals.manager_id', '=', 'users.id')
|
||||
->where('deals.tenant_id', $job->tenant_id)
|
||||
->whereNull('deals.deleted_at')
|
||||
->where('deals.is_test', false)
|
||||
->whereBetween('deals.received_at', [$dateFrom, $dateTo])
|
||||
->groupBy('deals.manager_id', 'users.first_name', 'users.last_name', 'users.email')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('deals.manager_id')
|
||||
->selectRaw(
|
||||
"deals.manager_id,
|
||||
users.first_name, users.last_name, users.email,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
|
||||
if ($name === '') {
|
||||
$name = (string) ($row->email ?? '');
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = 'Не назначен';
|
||||
}
|
||||
$total = (int) $row->total;
|
||||
$paid = (int) $row->paid;
|
||||
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
|
||||
|
||||
return [$name, $total, $paid, $conversion];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'managers';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Providers;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
|
||||
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
|
||||
* paid / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
|
||||
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
*/
|
||||
class SourcesSummaryProvider implements ReportDataProvider
|
||||
{
|
||||
public function headers(): array
|
||||
{
|
||||
return ['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
|
||||
}
|
||||
|
||||
public function rows(ReportJob $job): array
|
||||
{
|
||||
$params = $job->parameters ?? [];
|
||||
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
|
||||
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
|
||||
|
||||
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
|
||||
|
||||
$rows = DB::table('deals')
|
||||
->where('tenant_id', $job->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false)
|
||||
->whereBetween('received_at', [$dateFrom, $dateTo])
|
||||
->groupBy('utm_source')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('utm_source')
|
||||
->selectRaw(
|
||||
"utm_source,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
return $rows->map(function ($row): array {
|
||||
$source = $row->utm_source !== null && trim((string) $row->utm_source) !== ''
|
||||
? (string) $row->utm_source
|
||||
: 'Прямые / без метки';
|
||||
$total = (int) $row->total;
|
||||
$paid = (int) $row->paid;
|
||||
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
|
||||
|
||||
return [$source, $total, $paid, $conversion];
|
||||
})->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'sources';
|
||||
}
|
||||
}
|
||||
@@ -10,23 +10,28 @@ use App\Services\Reports\Formatters\JsonFormatter;
|
||||
use App\Services\Reports\Formatters\PdfStubFormatter;
|
||||
use App\Services\Reports\Formatters\ReportFormatter;
|
||||
use App\Services\Reports\Formatters\XlsxFormatter;
|
||||
use App\Services\Reports\Providers\BillingSummaryProvider;
|
||||
use App\Services\Reports\Providers\DealsExportProvider;
|
||||
use App\Services\Reports\Providers\ManagersSummaryProvider;
|
||||
use App\Services\Reports\Providers\ReportDataProvider;
|
||||
use App\Services\Reports\Providers\SourcesSummaryProvider;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Резолвит ReportDataProvider по `type` и ReportFormatter по `format`.
|
||||
*
|
||||
* Этап 2 (текущий): 1 provider × 4 formatter = 4 комбинации
|
||||
* (deals_export × csv|xlsx|json|pdf-stub).
|
||||
*
|
||||
* Этап 2b расширит до 4 × 4 = 16 (managers_summary, sources_summary,
|
||||
* billing_summary). Для PDF на MVP — stub, fallback'ит в RuntimeException.
|
||||
* 4 provider'а (deals_export, managers_summary, sources_summary,
|
||||
* billing_summary) × 4 formatter'а (csv, xlsx, json, pdf). PDF на MVP —
|
||||
* stub: PdfStubFormatter кидает RuntimeException → GenerateReportJob
|
||||
* ловит → failed-job (intended, Post-MVP).
|
||||
*/
|
||||
class ReportGeneratorRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DealsExportProvider $dealsExport,
|
||||
private readonly ManagersSummaryProvider $managersSummary,
|
||||
private readonly SourcesSummaryProvider $sourcesSummary,
|
||||
private readonly BillingSummaryProvider $billingSummary,
|
||||
private readonly CsvFormatter $csv,
|
||||
private readonly XlsxFormatter $xlsx,
|
||||
private readonly JsonFormatter $json,
|
||||
@@ -37,6 +42,9 @@ class ReportGeneratorRegistry
|
||||
{
|
||||
return match ($type) {
|
||||
'deals_export' => $this->dealsExport,
|
||||
'managers_summary' => $this->managersSummary,
|
||||
'sources_summary' => $this->sourcesSummary,
|
||||
'billing_summary' => $this->billingSummary,
|
||||
default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"),
|
||||
};
|
||||
}
|
||||
@@ -54,18 +62,10 @@ class ReportGeneratorRegistry
|
||||
|
||||
public function isSupported(string $type, string $format): bool
|
||||
{
|
||||
if (! in_array($type, ReportJob::TYPES, true) || ! in_array($format, ReportJob::FORMATS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Этап 2: только deals_export (этап 2b добавит остальные).
|
||||
$supportedTypes = ['deals_export'];
|
||||
if (! in_array($type, $supportedTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// PDF — stub: validates, но генерация даёт failed-job (intended).
|
||||
// Считаем «поддерживается» — пусть GenerateReportJob сам catch'ит RuntimeException.
|
||||
return true;
|
||||
// Все 4 типа ReportJob::TYPES реализованы (F1, 2026-05-16).
|
||||
// PDF валидируется, но PdfStubFormatter кидает RuntimeException →
|
||||
// GenerateReportJob ловит → failed-job (intended, Post-MVP).
|
||||
return in_array($type, ReportJob::TYPES, true)
|
||||
&& in_array($format, ReportJob::FORMATS, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
@@ -18,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
]);
|
||||
|
||||
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
|
||||
|
||||
@@ -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',
|
||||
|
||||
+302
-14
@@ -180,12 +180,54 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/AdminBillingActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
path: tests/Feature/AdminBillingIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/AdminIncidentRknNotifyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/AdminIncidentRknNotifyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/AdminIncidentShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/AdminIncidentShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -642,10 +684,28 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
path: tests/Feature/DashboardSummaryTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 37
|
||||
count: 15
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
@@ -669,7 +729,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 11
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
@@ -711,13 +783,25 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 50
|
||||
count: 30
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 22
|
||||
count: 21
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -735,7 +819,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 18
|
||||
count: 9
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealRestoreTest.php
|
||||
|
||||
-
|
||||
@@ -771,19 +867,31 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
count: 6
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 13
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
count: 7
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -801,7 +909,19 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
count: 7
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
@@ -825,19 +945,31 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 10
|
||||
count: 9
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 24
|
||||
count: 15
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
count: 9
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
@@ -876,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
|
||||
@@ -906,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
|
||||
@@ -1047,7 +1269,49 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 25
|
||||
count: 9
|
||||
path: tests/Feature/Reports/BillingSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/ReportDownloadTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 31
|
||||
path: tests/Feature/Reports/ReportJobControllerTest.php
|
||||
|
||||
-
|
||||
@@ -1071,7 +1335,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 12
|
||||
count: 14
|
||||
path: tests/Feature/Reports/ReportJobControllerTest.php
|
||||
|
||||
-
|
||||
@@ -1116,6 +1380,18 @@ parameters:
|
||||
count: 12
|
||||
path: tests/Feature/Reports/ReportLifecycleTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 12
|
||||
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project1Id\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1140,6 +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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
|
||||
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
|
||||
*/
|
||||
|
||||
export type DeltaDir = 'up' | 'down' | 'neutral';
|
||||
export type DashboardRange = 'today' | '7d' | '30d';
|
||||
|
||||
export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
balance: { amount_rub: string; runway_days: number; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
|
||||
params: { tenant_id: tenantId, range },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export interface ApiReportJob {
|
||||
parameters: ApiReportParameters;
|
||||
status: ApiReportStatus;
|
||||
file_path: string | null;
|
||||
download_url: string | null;
|
||||
file_size: number | null;
|
||||
generation_seconds: number | null;
|
||||
error_message: string | null;
|
||||
|
||||
@@ -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 '';
|
||||
@@ -56,8 +58,8 @@ function formatRelative(iso: string | null): string {
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// На MVP — push на DealsView (deep-link на конкретный drawer — отдельный коммит).
|
||||
await router.push('/deals');
|
||||
// Audit F3: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -98,7 +98,8 @@ function canRetry(job: ReportJob): boolean {
|
||||
</v-chip>
|
||||
<div class="job-actions">
|
||||
<v-btn
|
||||
v-if="job.status === 'done'"
|
||||
v-if="job.status === 'done' && job.downloadUrl"
|
||||
:href="job.downloadUrl"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
|
||||
@@ -45,4 +45,5 @@ export interface ReportJob {
|
||||
progress: number | null; // 0..100 для running
|
||||
attempt: number; // 1..3
|
||||
error: string | null;
|
||||
downloadUrl: string | null; // signed URL (24ч) скачивания готового файла; null для не-готовых
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function mapApiReportJob(api: ApiReportJob, now: Date = new Date()): Repo
|
||||
progress: api.status === 'processing' ? 50 : null,
|
||||
attempt: api.retry_count + 1,
|
||||
error: api.error_message,
|
||||
downloadUrl: api.download_url,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -1,67 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Дашборд — стартовая страница для авторизованных пользователей.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
|
||||
* MVP: page-head + 4 KPI-cards (получено лидов / конверсия / активные проекты /
|
||||
* баланс). Графики (Активность по дням, Воронка из 14 статусов).
|
||||
*
|
||||
* Все числа сейчас mock'и — TODO: GET /api/dashboard/summary с tenant-context'ом
|
||||
* по middleware SetTenantContext (фаза backend).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split на DashboardPageHead + DashboardKpiRow +
|
||||
* DashboardBalance (audit O-refactor-04 закрытие). State (range, kpis, balance)
|
||||
* остаётся в parent ради единого mock-data flow и future API-fetch'а.
|
||||
*
|
||||
* Примечание: «recent deals list» в Phase B/3 plan'е — на текущем дашборде нет
|
||||
* (есть только charts row); если будет добавлено в будущем — выносится в
|
||||
* DashboardRecentDeals.vue по аналогии.
|
||||
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
||||
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
||||
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import ActivityChart from '../components/charts/ActivityChart.vue';
|
||||
import FunnelChart from '../components/charts/FunnelChart.vue';
|
||||
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
|
||||
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
|
||||
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
|
||||
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const range = ref<'today' | '7d' | '30d' | 'custom'>('7d');
|
||||
const auth = useAuthStore();
|
||||
const range = ref<DashboardRange | 'custom'>('7d');
|
||||
|
||||
const kpis: Kpi[] = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: '247',
|
||||
delta: { dir: 'up', text: '12.3%' },
|
||||
sub: 'vs предыдущие 7 дней',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: '18.4',
|
||||
unit: '%',
|
||||
delta: { dir: 'up', text: '2.1pp' },
|
||||
sub: 'vs предыдущие 7 дней',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: '8',
|
||||
unit: '/ 10',
|
||||
delta: { dir: 'neutral', text: '2 свободно' },
|
||||
sub: 'тариф «Команда»',
|
||||
},
|
||||
// runwayMax — display-константа полосы (7 сегментов), не из API.
|
||||
const RUNWAY_MAX = 7;
|
||||
|
||||
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
|
||||
const MOCK_KPIS: Kpi[] = [
|
||||
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
|
||||
];
|
||||
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
|
||||
|
||||
const balance: Balance = {
|
||||
amount: '14 250',
|
||||
runwayDays: 4,
|
||||
runwayMax: 7,
|
||||
runwayLeads: 285,
|
||||
};
|
||||
const kpis = ref<Kpi[]>(MOCK_KPIS);
|
||||
const balance = ref<Balance>(MOCK_BALANCE);
|
||||
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
|
||||
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
|
||||
const activityMax = ref(60);
|
||||
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
|
||||
const fetchError = ref(false);
|
||||
|
||||
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
|
||||
function formatRub(raw: string): string {
|
||||
const n = parseFloat(raw);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
const int = Math.round(n).toString();
|
||||
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
|
||||
function applySummary(s: DashboardSummary): void {
|
||||
kpis.value = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: String(s.leads_received.value),
|
||||
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: String(s.conversion.value),
|
||||
unit: '%',
|
||||
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: String(s.active_projects.active),
|
||||
unit: `/ ${s.active_projects.limit}`,
|
||||
delta: { dir: 'neutral', text: '' },
|
||||
sub: 'лимит тарифа',
|
||||
},
|
||||
];
|
||||
balance.value = {
|
||||
amount: formatRub(s.balance.amount_rub),
|
||||
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
|
||||
runwayMax: RUNWAY_MAX,
|
||||
runwayLeads: s.balance.runway_leads,
|
||||
};
|
||||
activityPoints.value = s.activity.points;
|
||||
activityLabels.value = s.activity.labels;
|
||||
activityMax.value = s.activity.max;
|
||||
funnelCounts.value = s.funnel;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tenantId = auth.user?.tenant_id;
|
||||
if (!tenantId || range.value === 'custom') return;
|
||||
try {
|
||||
applySummary(await getDashboardSummary(tenantId, range.value as DashboardRange));
|
||||
fetchError.value = false;
|
||||
} catch {
|
||||
fetchError.value = true; // оставляем последнее значение / mock
|
||||
}
|
||||
}
|
||||
|
||||
watch(range, load);
|
||||
load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="dashboard pa-6">
|
||||
<DashboardPageHead v-model="range" />
|
||||
|
||||
<div class="ld-meta mt-2">
|
||||
<div v-show="!fetchError" class="ld-meta mt-2">
|
||||
<span class="ld-pulse" aria-hidden="true"></span>
|
||||
<span>Live · обновлено только что</span>
|
||||
</div>
|
||||
@@ -71,12 +107,23 @@ const balance: Balance = {
|
||||
<DashboardBalance :balance="balance" />
|
||||
</v-row>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="dashboard-fetch-error"
|
||||
>
|
||||
Не удалось обновить данные дашборда — показаны последние известные значения.
|
||||
</v-alert>
|
||||
|
||||
<v-row class="charts-row mt-4">
|
||||
<v-col cols="12" md="7">
|
||||
<ActivityChart />
|
||||
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<FunnelChart />
|
||||
<FunnelChart :counts="funnelCounts" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* Источник статусов — composables/leadStatuses.ts (snapshot из db/schema.sql:2130).
|
||||
*/
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
|
||||
import { mapApiDeal } from '../composables/dealsApiMapper';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
@@ -94,6 +95,7 @@ function toggleManagerDraft(name: string): void {
|
||||
: [...managerMenuDraft.value, name];
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
@@ -165,11 +167,17 @@ async function applyBulkRestoreFromTrash() {
|
||||
deleteToastOpen.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
void leadStatusesStore.load();
|
||||
void loadDeals();
|
||||
await loadDeals();
|
||||
openDealFromQuery();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.openId,
|
||||
() => openDealFromQuery(),
|
||||
);
|
||||
|
||||
// Polling — каждые 30 сек авто-refresh dealsState. Pause при скрытой вкладке.
|
||||
// Включается только при наличии auth.user (без auth listDeals = no-op anyway).
|
||||
usePolling(loadDeals);
|
||||
@@ -218,6 +226,16 @@ function openDeal(deal: MockDeal) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
|
||||
function openDealFromQuery(): void {
|
||||
const raw = route.query.openId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
if (selectedDeal.value?.id === id) return;
|
||||
const deal = dealsState.find((d) => d.id === id);
|
||||
if (deal) openDeal(deal);
|
||||
}
|
||||
|
||||
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
|
||||
const ids = [...selected.value];
|
||||
statusMenuOpen.value = false;
|
||||
@@ -327,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;
|
||||
@@ -354,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(
|
||||
@@ -380,6 +415,7 @@ defineExpose({
|
||||
applyBulkStatus,
|
||||
applyBulkDelete,
|
||||
applyBulkExport,
|
||||
exportAllFiltered,
|
||||
exportToastOpen,
|
||||
exportToastText,
|
||||
onDealCreated,
|
||||
@@ -410,6 +446,8 @@ defineExpose({
|
||||
clearManagerDraft,
|
||||
toggleProjectDraft,
|
||||
toggleManagerDraft,
|
||||
drawerOpen,
|
||||
selectedDeal,
|
||||
});
|
||||
|
||||
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
||||
@@ -493,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"
|
||||
@@ -564,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 />
|
||||
@@ -611,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>
|
||||
@@ -68,8 +68,8 @@ async function executeComplete(id: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
void dealId; // на MVP — без deep-link на конкретный drawer.
|
||||
await router.push('/deals');
|
||||
// Audit C8: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '..
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { usePolling } from '../../composables/usePolling';
|
||||
import * as adminApi from '../../api/admin';
|
||||
import type { AdminTariffPlan } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const search = ref('');
|
||||
|
||||
@@ -95,7 +97,92 @@ async function loadBilling() {
|
||||
onMounted(loadBilling);
|
||||
usePolling(loadBilling);
|
||||
|
||||
defineExpose({ rowsState, summary, loading, fetchError, loadBilling });
|
||||
// === Row-actions state (Sprint 3D G4) ===
|
||||
|
||||
const actionDialog = ref<null | 'status' | 'refund' | 'tariff'>(null);
|
||||
const actionRow = ref<BillingRow | null>(null);
|
||||
const actionReason = ref('');
|
||||
const actionAmount = ref<number | null>(null);
|
||||
const actionTariffId = ref<number | null>(null);
|
||||
const actionLoading = ref(false);
|
||||
const actionError = ref('');
|
||||
const tariffPlans = ref<AdminTariffPlan[]>([]);
|
||||
|
||||
async function openAction(type: 'status' | 'refund' | 'tariff', row: BillingRow) {
|
||||
actionDialog.value = type;
|
||||
actionRow.value = row;
|
||||
actionReason.value = '';
|
||||
actionAmount.value = null;
|
||||
actionTariffId.value = null;
|
||||
actionError.value = '';
|
||||
|
||||
if (type === 'tariff') {
|
||||
try {
|
||||
tariffPlans.value = await adminApi.listAdminTariffPlans();
|
||||
} catch (e) {
|
||||
actionError.value = extractErrorMessage(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAction() {
|
||||
// Validate
|
||||
if (actionReason.value.trim().length < 10) {
|
||||
actionError.value = 'Укажите основание (минимум 10 символов).';
|
||||
return;
|
||||
}
|
||||
if (actionDialog.value === 'refund' && (actionAmount.value === null || !Number.isFinite(actionAmount.value) || actionAmount.value <= 0)) {
|
||||
actionError.value = 'Укажите сумму возврата больше нуля.';
|
||||
return;
|
||||
}
|
||||
if (actionDialog.value === 'refund' && actionAmount.value! > actionRow.value!.balance_rub) {
|
||||
actionError.value = 'Сумма возврата превышает баланс тенанта.';
|
||||
return;
|
||||
}
|
||||
if (actionDialog.value === 'tariff' && actionTariffId.value === null) {
|
||||
actionError.value = 'Выберите тарифный план.';
|
||||
return;
|
||||
}
|
||||
|
||||
const row = actionRow.value!;
|
||||
actionLoading.value = true;
|
||||
actionError.value = '';
|
||||
|
||||
try {
|
||||
if (actionDialog.value === 'status') {
|
||||
const newStatus = row.status === 'suspended' ? 'active' : 'suspended';
|
||||
await adminApi.updateTenantStatus(row.id, newStatus, actionReason.value.trim());
|
||||
} else if (actionDialog.value === 'refund') {
|
||||
await adminApi.refundTenant(row.id, actionAmount.value!, actionReason.value.trim());
|
||||
} else if (actionDialog.value === 'tariff') {
|
||||
await adminApi.changeTenantTariff(row.id, actionTariffId.value!, actionReason.value.trim());
|
||||
}
|
||||
await loadBilling();
|
||||
actionDialog.value = null;
|
||||
} catch (e) {
|
||||
actionError.value = extractErrorMessage(e);
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
rowsState,
|
||||
summary,
|
||||
loading,
|
||||
fetchError,
|
||||
loadBilling,
|
||||
actionDialog,
|
||||
actionRow,
|
||||
actionReason,
|
||||
actionAmount,
|
||||
actionTariffId,
|
||||
actionError,
|
||||
actionLoading,
|
||||
tariffPlans,
|
||||
openAction,
|
||||
confirmAction,
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ title: 'Тенант', key: 'name', sortable: true },
|
||||
@@ -105,6 +192,7 @@ const headers = [
|
||||
{ title: 'Списания за мес', key: 'monthly_charges_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'MRR', key: 'mrr_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'Статус', key: 'status', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
|
||||
];
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
@@ -257,8 +345,189 @@ function tariffLabel(t: string): string {
|
||||
{{ statusInfo(item.status).label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-dots-vertical"
|
||||
:data-testid="`row-actions-${item.id}`"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Действия с тенантом"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
:title="item.status === 'suspended' ? 'Разблокировать' : 'Приостановить'"
|
||||
prepend-icon="mdi-account-cancel"
|
||||
@click="openAction('status', item)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Возврат средств"
|
||||
prepend-icon="mdi-cash-refund"
|
||||
@click="openAction('refund', item)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Сменить тариф"
|
||||
prepend-icon="mdi-swap-horizontal"
|
||||
@click="openAction('tariff', item)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Dialog: suspend / activate -->
|
||||
<v-dialog :model-value="actionDialog === 'status'" max-width="480" @update:model-value="actionDialog = null">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ actionRow?.status === 'suspended' ? 'Разблокировать тенанта' : 'Приостановить тенанта' }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Тенант: <strong>{{ actionRow?.name }}</strong>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="action-error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
<v-textarea
|
||||
v-model="actionReason"
|
||||
label="Основание"
|
||||
placeholder="Минимум 10 символов"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
data-testid="action-reason"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
|
||||
<v-btn
|
||||
:loading="actionLoading"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="confirmAction"
|
||||
>
|
||||
Подтвердить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog: refund -->
|
||||
<v-dialog :model-value="actionDialog === 'refund'" max-width="480" @update:model-value="actionDialog = null">
|
||||
<v-card>
|
||||
<v-card-title>Возврат средств</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Тенант: <strong>{{ actionRow?.name }}</strong>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="action-error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
<v-text-field
|
||||
v-model.number="actionAmount"
|
||||
type="number"
|
||||
label="Сумма возврата, ₽"
|
||||
:hint="actionRow ? `доступно к возврату: ${formatRub(actionRow.balance_rub)}` : ''"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="refund-amount"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="actionReason"
|
||||
label="Основание"
|
||||
placeholder="Минимум 10 символов"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
data-testid="action-reason"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
|
||||
<v-btn
|
||||
:loading="actionLoading"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="confirmAction"
|
||||
>
|
||||
Выполнить возврат
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog: change tariff -->
|
||||
<v-dialog :model-value="actionDialog === 'tariff'" max-width="480" @update:model-value="actionDialog = null">
|
||||
<v-card>
|
||||
<v-card-title>Сменить тариф</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Тенант: <strong>{{ actionRow?.name }}</strong>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="action-error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
<v-select
|
||||
v-model="actionTariffId"
|
||||
:items="tariffPlans"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Тарифный план"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="tariff-select"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="actionReason"
|
||||
label="Основание"
|
||||
placeholder="Минимум 10 символов"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
data-testid="action-reason"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
|
||||
<v-btn
|
||||
:loading="actionLoading"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="confirmAction"
|
||||
>
|
||||
Сменить тариф
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Карточка инцидента (drill-down из AdminIncidentsView).
|
||||
*
|
||||
* Sprint 3D G5/G6: детальный просмотр инцидента + кнопка «Уведомить РКН»
|
||||
* (152-ФЗ — обязательное уведомление РКН для data_breach за 24ч).
|
||||
*
|
||||
* Маршрут: /admin/incidents/:id
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getAdminIncidentDetail, notifyIncidentRkn } from '../../api/admin';
|
||||
import type { ApiAdminIncidentDetail } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = computed(() => Number(route.params.id));
|
||||
|
||||
const incident = ref<ApiAdminIncidentDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const notFound = ref(false);
|
||||
const fetchError = ref<string | null>(null);
|
||||
const rknError = ref('');
|
||||
const rknLoading = ref(false);
|
||||
const rknDialog = ref(false);
|
||||
|
||||
async function loadIncident(): Promise<void> {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
notFound.value = false;
|
||||
try {
|
||||
incident.value = await getAdminIncidentDetail(id.value);
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 404) {
|
||||
notFound.value = true;
|
||||
incident.value = null;
|
||||
} else {
|
||||
fetchError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadIncident());
|
||||
watch(id, () => void loadIncident());
|
||||
|
||||
async function confirmRkn(): Promise<void> {
|
||||
rknLoading.value = true;
|
||||
rknError.value = '';
|
||||
try {
|
||||
incident.value = await notifyIncidentRkn(id.value);
|
||||
rknDialog.value = false;
|
||||
} catch (e: unknown) {
|
||||
rknError.value = extractErrorMessage(e);
|
||||
// dialog stays open so error is visible
|
||||
} finally {
|
||||
rknLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
void router.push({ name: 'admin-incidents' });
|
||||
}
|
||||
|
||||
// Helpers (copied from AdminIncidentsView for self-containment)
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
open: { label: 'Открыт', color: 'error' },
|
||||
investigating: { label: 'Расследуется', color: 'warning' },
|
||||
resolved: { label: 'Решён', color: 'info' },
|
||||
closed: { label: 'Закрыт', color: 'success' },
|
||||
};
|
||||
function statusInfo(s: string) {
|
||||
return statusMap[s] ?? { label: s, color: 'default' };
|
||||
}
|
||||
|
||||
const severityMap: Record<string, { label: string; color: string }> = {
|
||||
critical: { label: 'Critical', color: 'error' },
|
||||
high: { label: 'High', color: 'warning' },
|
||||
medium: { label: 'Medium', color: 'info' },
|
||||
low: { label: 'Low', color: 'success' },
|
||||
};
|
||||
function severityInfo(s: string) {
|
||||
return severityMap[s] ?? { label: s, color: 'default' };
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
incident,
|
||||
loading,
|
||||
notFound,
|
||||
fetchError,
|
||||
rknError,
|
||||
rknLoading,
|
||||
rknDialog,
|
||||
loadIncident,
|
||||
confirmRkn,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading -->
|
||||
<v-container v-if="loading" fluid class="pa-6" data-testid="incident-loading">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<span class="ml-3 text-medium-emphasis">Загрузка…</span>
|
||||
</v-container>
|
||||
|
||||
<!-- Not found -->
|
||||
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="incident-not-found">
|
||||
<v-alert type="error" variant="tonal" class="mb-4">
|
||||
Инцидент <strong>#{{ id }}</strong> не найден.
|
||||
</v-alert>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">К списку инцидентов</v-btn>
|
||||
</v-container>
|
||||
|
||||
<!-- Fetch error -->
|
||||
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="incident-fetch-error">
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
Не удалось загрузить инцидент: {{ fetchError }}
|
||||
</v-alert>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadIncident">Повторить</v-btn>
|
||||
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<!-- Content -->
|
||||
<v-container v-else-if="incident" fluid class="incident-detail pa-6">
|
||||
<!-- Header -->
|
||||
<header class="d-flex justify-space-between align-start mb-4 flex-wrap ga-2">
|
||||
<div>
|
||||
<div class="d-flex align-center ga-2 mb-1">
|
||||
<span class="font-mono text-caption text-medium-emphasis">{{ incident.incident_id }}</span>
|
||||
<v-chip :color="severityInfo(incident.severity).color" size="x-small" variant="tonal">
|
||||
{{ severityInfo(incident.severity).label }}
|
||||
</v-chip>
|
||||
<v-chip :color="statusInfo(incident.status).color" size="x-small" variant="tonal">
|
||||
{{ statusInfo(incident.status).label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<h1 class="text-h5 font-weight-medium">{{ incident.summary }}</h1>
|
||||
</div>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">Назад</v-btn>
|
||||
</header>
|
||||
|
||||
<v-row>
|
||||
<!-- Main details -->
|
||||
<v-col cols="12" md="8">
|
||||
<v-card variant="outlined" class="pa-4 mb-4">
|
||||
<h2 class="text-h6 mb-3">Детали инцидента</h2>
|
||||
|
||||
<div v-if="incident.root_cause" class="mb-3">
|
||||
<div class="text-caption text-medium-emphasis">Корневая причина</div>
|
||||
<div>{{ incident.root_cause }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="incident.postmortem_url" class="mb-3">
|
||||
<div class="text-caption text-medium-emphasis">Postmortem</div>
|
||||
<a :href="incident.postmortem_url" target="_blank" rel="noopener">
|
||||
{{ incident.postmortem_url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption text-medium-emphasis">Начался</div>
|
||||
<div>{{ formatDate(incident.started_at) }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption text-medium-emphasis">Обнаружен</div>
|
||||
<div>{{ formatDate(incident.detected_at) }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6" class="mt-2">
|
||||
<div class="text-caption text-medium-emphasis">Решён</div>
|
||||
<div>{{ formatDate(incident.resolved_at) }}</div>
|
||||
</v-col>
|
||||
<v-col v-if="incident.affected_users_count !== null" cols="6" class="mt-2">
|
||||
<div class="text-caption text-medium-emphasis">Затронуто пользователей</div>
|
||||
<div>{{ incident.affected_users_count }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<!-- Affected tenants -->
|
||||
<v-card variant="outlined" class="pa-4 mb-4">
|
||||
<h2 class="text-h6 mb-3">Затронутые тенанты ({{ incident.affected_tenants.length }})</h2>
|
||||
<div v-if="incident.affected_tenants.length === 0" class="text-medium-emphasis text-body-2">
|
||||
Нет данных
|
||||
</div>
|
||||
<v-list v-else density="compact">
|
||||
<v-list-item
|
||||
v-for="t in incident.affected_tenants"
|
||||
:key="t.id"
|
||||
:title="t.organization_name"
|
||||
:subtitle="`ID: ${t.id}`"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- РКН section -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card v-if="incident.type === 'data_breach'" variant="outlined" class="pa-4 mb-4">
|
||||
<h2 class="text-h6 mb-3">Уведомление РКН (152-ФЗ)</h2>
|
||||
|
||||
<div v-if="incident.rkn_notified">
|
||||
<v-icon color="success" class="mr-1">mdi-check-circle</v-icon>
|
||||
РКН уведомлён {{ formatDate(incident.rkn_notified_at) }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="incident.rkn_deadline_at" class="mb-3">
|
||||
<div class="text-caption text-medium-emphasis">Дедлайн</div>
|
||||
<div class="text-error font-weight-medium">{{ formatDate(incident.rkn_deadline_at) }}</div>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
data-testid="rkn-notify-btn"
|
||||
color="error"
|
||||
:loading="rknLoading"
|
||||
block
|
||||
@click="rknDialog = true"
|
||||
>
|
||||
Уведомить РКН
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-alert
|
||||
v-if="rknError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="rkn-error"
|
||||
>
|
||||
{{ rknError }}
|
||||
</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Admin meta -->
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<h2 class="text-h6 mb-3">Служебная информация</h2>
|
||||
<div v-if="incident.created_by_admin" class="mb-2">
|
||||
<div class="text-caption text-medium-emphasis">Создал</div>
|
||||
<div>{{ incident.created_by_admin }}</div>
|
||||
</div>
|
||||
<div v-if="incident.closed_by_admin" class="mb-2">
|
||||
<div class="text-caption text-medium-emphasis">Закрыл</div>
|
||||
<div>{{ incident.closed_by_admin }}</div>
|
||||
</div>
|
||||
<div v-if="incident.created_at">
|
||||
<div class="text-caption text-medium-emphasis">Создан</div>
|
||||
<div>{{ formatDate(incident.created_at) }}</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- РКН confirm dialog -->
|
||||
<v-dialog v-model="rknDialog" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Подтверждение уведомления РКН</v-card-title>
|
||||
<v-card-text>
|
||||
Это юридически значимое действие. После подтверждения будет зафиксировано время уведомления
|
||||
регулятора (152-ФЗ). Продолжить?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="rknDialog = false">Отмена</v-btn>
|
||||
<v-btn color="error" :loading="rknLoading" @click="confirmRkn">Подтвердить</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.incident-detail {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePolling } from '../../composables/usePolling';
|
||||
import * as adminApi from '../../api/admin';
|
||||
|
||||
@@ -29,6 +30,8 @@ interface IncidentRow {
|
||||
rkn_deadline_at: string | null;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const filterStatus = ref<string>('all');
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
@@ -210,7 +213,14 @@ function formatDate(iso: string): string {
|
||||
</div>
|
||||
|
||||
<v-list lines="three" class="incidents-list">
|
||||
<v-list-item v-for="row in filteredRows" :key="row.id" class="incident-row">
|
||||
<v-list-item
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
class="incident-row"
|
||||
:data-testid="`incident-row-${row.id}`"
|
||||
style="cursor: pointer"
|
||||
@click="router.push({ name: 'admin-incident-detail', params: { id: row.id } })"
|
||||
>
|
||||
<div class="incident-header">
|
||||
<span class="font-mono text-caption text-medium-emphasis">{{ row.incident_id }}</span>
|
||||
<v-chip :color="severityInfo(row.severity).color" size="x-small" variant="tonal" class="ml-2">
|
||||
|
||||
@@ -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>
|
||||
+106
-58
@@ -71,52 +71,82 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')->grou
|
||||
Route::delete('/{id}', 'App\Http\Controllers\Api\ReportJobController@destroy')->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
|
||||
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
|
||||
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
|
||||
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
|
||||
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
|
||||
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
|
||||
// F2 (audit): скачивание готового файла отчёта по signed URL (24 ч, OPEN-И-20).
|
||||
// НЕ под auth:sanctum — подпись URL = capability-token (генерируется только
|
||||
// в ReportJobController::toResource() для отчётов своего тенанта).
|
||||
Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')
|
||||
->where('id', '[0-9]+')
|
||||
->name('reports.download')
|
||||
->middleware('signed');
|
||||
|
||||
// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
|
||||
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
|
||||
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
|
||||
// стаб не меняет — это отдельная зона ответственности.
|
||||
Route::middleware('saas-admin')->group(function () {
|
||||
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
|
||||
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
|
||||
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
|
||||
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
|
||||
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
|
||||
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
|
||||
});
|
||||
|
||||
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
|
||||
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
|
||||
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
|
||||
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
|
||||
->where('id', '[0-9]+');
|
||||
Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund')
|
||||
->where('id', '[0-9]+');
|
||||
Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
|
||||
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
|
||||
|
||||
// Sprint 3D (G5): SaaS-admin incident detail-view drill-down.
|
||||
Route::get('/api/admin/incidents/{id}', 'App\Http\Controllers\Api\AdminIncidentsController@show')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// Sprint 3D (G6): РКН-notify endpoint (152-ФЗ).
|
||||
Route::post('/api/admin/incidents/{id}/rkn-notify', 'App\Http\Controllers\Api\AdminIncidentsController@notifyRkn')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
|
||||
// На MVP без auth-middleware (admin_user_id параметром); production: middleware('auth:saas-admin').
|
||||
Route::prefix('/api/admin/system-settings')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
|
||||
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
|
||||
});
|
||||
|
||||
// Plan 4: SaaS-admin pricing-tiers editor.
|
||||
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
|
||||
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
|
||||
Route::prefix('/api/admin/pricing-tiers')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
|
||||
Route::delete('/scheduled/{effective_from}',
|
||||
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
|
||||
->where('effective_from', '\d{4}-\d{2}-\d{2}');
|
||||
});
|
||||
|
||||
// Plan 4 Task 10: SaaS-admin supplier prices editor.
|
||||
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
|
||||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
|
||||
// Без auth (saas-admin SSO ⏸ Б-1).
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
|
||||
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
|
||||
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
|
||||
|
||||
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
|
||||
// На MVP без auth-middleware (admin_user_id параметром); production: middleware('auth:saas-admin').
|
||||
Route::prefix('/api/admin/system-settings')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
|
||||
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
|
||||
});
|
||||
|
||||
// Plan 4: SaaS-admin pricing-tiers editor.
|
||||
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
|
||||
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
|
||||
Route::prefix('/api/admin/pricing-tiers')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
|
||||
Route::delete('/scheduled/{effective_from}',
|
||||
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
|
||||
->where('effective_from', '\d{4}-\d{2}-\d{2}');
|
||||
});
|
||||
|
||||
// Plan 4 Task 10: SaaS-admin supplier prices editor.
|
||||
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
|
||||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
|
||||
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
|
||||
@@ -147,21 +177,38 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||||
});
|
||||
|
||||
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
|
||||
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
|
||||
//
|
||||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD остаётся в
|
||||
// DealController, bulk-операции (transition/destroy/restore) — в
|
||||
// DealBulkActionController, export — в DealExportController. URL и shape
|
||||
// payload'ов сохранены, только controller@method обновлён.
|
||||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
|
||||
// DealController, bulk (transition/destroy/restore) — в
|
||||
// DealBulkActionController, export — в DealExportController.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||||
});
|
||||
|
||||
// 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');
|
||||
@@ -228,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');
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Вспомогательная функция: создать сделку с заданными параметрами.
|
||||
*
|
||||
* Фабрика Deal::factory() по умолчанию: received_at = now() (текущий месяц,
|
||||
* партиция deals_2026_05 существует). is_test = false, deleted_at = null.
|
||||
* Для тестовых дат subDays(1..6) — всё в мае 2026, партиция есть.
|
||||
*/
|
||||
function makeDashboardDeal(
|
||||
Tenant $tenant,
|
||||
Project $project,
|
||||
string $status,
|
||||
Carbon|CarbonImmutable $receivedAt,
|
||||
?Carbon $deletedAt = null,
|
||||
bool $isTest = false,
|
||||
): Deal {
|
||||
return Deal::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'status' => $status,
|
||||
'received_at' => $receivedAt,
|
||||
'deleted_at' => $deletedAt,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
}
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'limits' => ['max_projects' => 10],
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonPath('balance.amount_rub', '14250.00')
|
||||
->assertJsonStructure([
|
||||
'range',
|
||||
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
|
||||
'conversion' => ['value', 'delta_pp', 'delta_dir'],
|
||||
'active_projects' => ['active', 'limit'],
|
||||
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
|
||||
'activity' => ['points', 'labels', 'max'],
|
||||
'funnel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
});
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
});
|
||||
|
||||
it('runway_days использует фикс. 7д-окно независимо от range', function () {
|
||||
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
|
||||
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
for ($i = 0; $i <= 6; $i++) {
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
|
||||
}
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today")
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.runway_days', 70);
|
||||
});
|
||||
@@ -18,11 +18,12 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_leads' => 100,
|
||||
]);
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('POST /api/deals создаёт сделку с manual source + project firstOrCreate', function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'Окна Москва',
|
||||
'phone' => '+7 (999) 123-45-67',
|
||||
'contact_name' => 'Тест Тестов',
|
||||
@@ -57,7 +58,6 @@ test('POST /api/deals использует существующий project (н
|
||||
]);
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'Натяжные потолки',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -74,7 +74,6 @@ test('POST /api/deals использует существующий project (н
|
||||
|
||||
test('POST /api/deals пишет ActivityLog с context.source=manual', function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -90,21 +89,20 @@ test('POST /api/deals пишет ActivityLog с context.source=manual', function
|
||||
test('POST /api/deals 422 без обязательных полей', function () {
|
||||
$r = $this->postJson('/api/deals', []);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKeys(['tenant_id', 'project_name', 'phone']);
|
||||
expect($r->json('errors'))->toHaveKeys(['project_name', 'phone']);
|
||||
});
|
||||
|
||||
test('POST /api/deals 404 при unknown tenant_id', function () {
|
||||
test('POST /api/deals 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => 999999,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
$r->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals дефолтный status = new если не передан', function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -117,7 +115,6 @@ test('POST /api/deals с manager_id → assigned_at = NOW()', function () {
|
||||
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'manager_id' => $manager->id,
|
||||
@@ -133,7 +130,6 @@ test('POST /api/deals manual НЕ списывает баланс tenant\'а', f
|
||||
$balanceBefore = $this->tenant->balance_leads;
|
||||
|
||||
$this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
])->assertStatus(201);
|
||||
@@ -170,7 +166,6 @@ test('POST /api/deals manual создаёт SupplierLeadCost если у про
|
||||
]);
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'WithSupplier',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -188,7 +183,6 @@ test('POST /api/deals manual создаёт SupplierLeadCost если у про
|
||||
|
||||
test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без SupplierLeadCost (graceful skip)", function () {
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'NoSupplier',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
]);
|
||||
@@ -204,20 +198,17 @@ test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без
|
||||
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
|
||||
// Создаём 2 сделки через store endpoint (получаем реальные id).
|
||||
$r1 = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$r2 = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$r1['id'], $r2['id']],
|
||||
]);
|
||||
|
||||
@@ -239,38 +230,33 @@ test('POST /api/deals/export возвращает CSV с правильными
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 без ids', function () {
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
]);
|
||||
$r = $this->postJson('/api/deals/export', []);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKey('ids');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 404 unknown tenant', function () {
|
||||
test('POST /api/deals/export 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => 999999,
|
||||
'ids' => [1, 2, 3],
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
$r->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
|
||||
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -286,14 +272,12 @@ test('POST /api/deals/export фильтрует только запрошенн
|
||||
|
||||
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
@@ -310,14 +294,12 @@ test('POST /api/deals/export?format=xlsx возвращает binary с корр
|
||||
|
||||
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 333-33-33',
|
||||
'contact_name' => 'Кириллов',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
@@ -348,7 +330,6 @@ test('POST /api/deals/export?format=xlsx содержит данные сдел
|
||||
|
||||
test('POST /api/deals/export 422 на неизвестный format', function () {
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [1],
|
||||
'format' => 'pdf',
|
||||
]);
|
||||
@@ -358,14 +339,12 @@ test('POST /api/deals/export 422 на неизвестный format', function (
|
||||
|
||||
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 444-44-44',
|
||||
'contact_name' => 'Test',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -15,6 +16,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
@@ -23,19 +27,15 @@ test('DELETE /api/deals 422 без обязательных полей', functio
|
||||
$this->deleteJson('/api/deals', [])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('DELETE /api/deals 404 на unknown tenant', function () {
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => 999999,
|
||||
'ids' => [1],
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
test('DELETE /api/deals 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->deleteJson('/api/deals', ['ids' => [1]])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('DELETE /api/deals soft-удаляет сделки + пишет deal.deleted ActivityLog', function () {
|
||||
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
]);
|
||||
|
||||
@@ -65,7 +65,6 @@ test('DELETE /api/deals defense-in-depth не удаляет чужие сдел
|
||||
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
|
||||
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$own->id, $foreign->id],
|
||||
]);
|
||||
|
||||
@@ -88,13 +87,11 @@ test('DELETE /api/deals NO-OP на уже удалённых', function () {
|
||||
|
||||
// Первое удаление
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200)->assertJson(['deleted' => 1]);
|
||||
|
||||
// Повтор — уже удалена, NO-OP.
|
||||
$r = $this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
]);
|
||||
$r->assertStatus(200)->assertJson(['deleted' => 0, 'requested' => 1]);
|
||||
@@ -110,11 +107,10 @@ test('GET /api/deals НЕ возвращает soft-deleted сделки', funct
|
||||
|
||||
// Удаляем одну
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deleted->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
$ids = collect($r->json('deals'))->pluck('id')->all();
|
||||
expect($ids)->toContain($alive->id);
|
||||
expect($ids)->not->toContain($deleted->id);
|
||||
@@ -125,17 +121,15 @@ test('GET /api/deals/{id} 404 для soft-deleted сделки', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [$deal->id],
|
||||
])->assertStatus(200);
|
||||
|
||||
$this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id)
|
||||
$this->getJson('/api/deals/'.$deal->id)
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('DELETE /api/deals 422 пустой массив ids', function () {
|
||||
$this->deleteJson('/api/deals', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'ids' => [],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*
|
||||
* Покрывает: фильтры (status_in, project_id, manager_id, search), сортировку
|
||||
* по received_at DESC, RLS-изоляцию между tenant'ами, относительные поля
|
||||
* (project_name, manager_name/initials), 422/404, пагинацию (limit/offset).
|
||||
* (project_name, manager_name/initials), 401/404, пагинацию (limit/offset).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -22,6 +22,9 @@ beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->otherTenant = Tenant::factory()->create();
|
||||
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
|
||||
$this->project2 = Project::factory()->for($this->tenant)->create(['name' => 'Натяжные потолки']);
|
||||
@@ -33,16 +36,13 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 422 без tenant_id', function () {
|
||||
$this->getJson('/api/deals')->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 404 для unknown tenant_id', function () {
|
||||
$this->getJson('/api/deals?tenant_id=999999')->assertStatus(404);
|
||||
test('GET /api/deals возвращает 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/deals')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает пустой список для tenant без сделок', function () {
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
$r->assertStatus(200)
|
||||
->assertJson(['deals' => [], 'total' => 0, 'limit' => 100, 'offset' => 0]);
|
||||
@@ -59,7 +59,7 @@ test('GET /api/deals возвращает сделки tenant\'а с проек
|
||||
'manager_id' => $this->manager->id,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('total'))->toBe(1);
|
||||
@@ -79,7 +79,7 @@ test('GET /api/deals не возвращает сделки чужого tenant\
|
||||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||||
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.tenant_id'))->toBe($this->tenant->id);
|
||||
@@ -96,7 +96,7 @@ test('GET /api/deals сортирует по received_at DESC', function () {
|
||||
'received_at' => now()->subHours(1),
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('deals.0.id'))->toBe($newest->id);
|
||||
expect($r->json('deals.1.id'))->toBe($middle->id);
|
||||
@@ -108,7 +108,7 @@ test('GET /api/deals фильтрует по status_in[]', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&status_in[]=new&status_in[]=paid');
|
||||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
|
||||
|
||||
expect($r->json('total'))->toBe(2);
|
||||
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
|
||||
@@ -120,7 +120,7 @@ test('GET /api/deals фильтрует по project_id', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
Deal::factory()->for($this->tenant)->for($this->project2)->create();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&project_id='.$this->project2->id);
|
||||
$r = $this->getJson('/api/deals?project_id='.$this->project2->id);
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.project_name'))->toBe('Натяжные потолки');
|
||||
@@ -133,7 +133,7 @@ test('GET /api/deals фильтрует по manager_id', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $other->id]);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&manager_id='.$this->manager->id);
|
||||
$r = $this->getJson('/api/deals?manager_id='.$this->manager->id);
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.manager_id'))->toBe($this->manager->id);
|
||||
@@ -149,13 +149,13 @@ test('GET /api/deals фильтрует по search (phone + contact_name, ILIKE
|
||||
'contact_name' => 'Дмитрий Петров',
|
||||
]);
|
||||
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=Соколова')
|
||||
expect($this->getJson('/api/deals?search=Соколова')
|
||||
->json('total'))->toBe(1);
|
||||
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=903')
|
||||
expect($this->getJson('/api/deals?search=903')
|
||||
->json('total'))->toBe(1);
|
||||
|
||||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=сокол') // case-insensitive ILIKE
|
||||
expect($this->getJson('/api/deals?search=сокол') // case-insensitive ILIKE
|
||||
->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ test('GET /api/deals поддерживает limit + offset', function () {
|
||||
]);
|
||||
}
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&offset=1');
|
||||
$r = $this->getJson('/api/deals?limit=2&offset=1');
|
||||
|
||||
expect($r->json('total'))->toBe(5);
|
||||
expect($r->json('limit'))->toBe(2);
|
||||
@@ -181,7 +181,7 @@ test('GET /api/deals?only_deleted=true возвращает только soft-de
|
||||
$deleted1->delete();
|
||||
$deleted2->delete();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&only_deleted=true');
|
||||
$r = $this->getJson('/api/deals?only_deleted=true');
|
||||
|
||||
expect($r->json('total'))->toBe(2);
|
||||
$ids = collect($r->json('deals'))->pluck('id')->all();
|
||||
@@ -195,7 +195,7 @@ test('GET /api/deals (без only_deleted) НЕ возвращает soft-delete
|
||||
$deleted = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$deleted->delete();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.id'))->toBe($alive->id);
|
||||
@@ -211,7 +211,7 @@ test('GET /api/deals?only_deleted=true изолирует чужие удалё
|
||||
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$own->delete();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&only_deleted=true');
|
||||
$r = $this->getJson('/api/deals?only_deleted=true');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
expect($r->json('deals.0.id'))->toBe($own->id);
|
||||
@@ -220,7 +220,7 @@ test('GET /api/deals?only_deleted=true изолирует чужие удалё
|
||||
test('GET /api/deals возвращает manager_name/initials = null если manager_id null', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('deals.0.manager_id'))->toBeNull();
|
||||
expect($r->json('deals.0.manager_name'))->toBeNull();
|
||||
@@ -244,7 +244,7 @@ test('GET /api/deals с cursor возвращает следующую стра
|
||||
}
|
||||
|
||||
// Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
|
||||
$r1 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
|
||||
$r1 = $this->getJson('/api/deals?limit=2');
|
||||
$r1->assertStatus(200);
|
||||
expect($r1->json('deals'))->toHaveLength(2);
|
||||
expect($r1->json('deals.0.id'))->toBe($ids[4]);
|
||||
@@ -256,7 +256,7 @@ test('GET /api/deals с cursor возвращает следующую стра
|
||||
'i' => $r1->json('deals.1.id'),
|
||||
]));
|
||||
|
||||
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
|
||||
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
|
||||
$r2->assertStatus(200);
|
||||
expect($r2->json('deals'))->toHaveLength(2);
|
||||
expect($r2->json('deals.0.id'))->toBe($ids[2]);
|
||||
@@ -264,7 +264,7 @@ test('GET /api/deals с cursor возвращает следующую стра
|
||||
});
|
||||
|
||||
test('GET /api/deals с невалидным cursor возвращает 422', function () {
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&cursor=not-base64-json');
|
||||
$r = $this->getJson('/api/deals?cursor=not-base64-json');
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('message'))->toBeString();
|
||||
});
|
||||
@@ -278,14 +278,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,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Reports\Providers\BillingSummaryProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
});
|
||||
|
||||
function seedBillingTx(int $tenantId, string $type, float $amount, ?Carbon $at = null): void
|
||||
{
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => $type,
|
||||
'amount_rub' => $amount,
|
||||
'amount_leads' => 0,
|
||||
'description' => 'test',
|
||||
'created_at' => $at ?? Carbon::now()->startOfMonth()->addDays(10),
|
||||
]);
|
||||
}
|
||||
|
||||
function billingJob(int $tenantId): ReportJob
|
||||
{
|
||||
return new ReportJob([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'billing_summary',
|
||||
'parameters' => [
|
||||
'format' => 'csv',
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('headers: 3 колонки', function () {
|
||||
expect((new BillingSummaryProvider)->headers())
|
||||
->toBe(['Тип операции', 'Количество', 'Сумма (₽)']);
|
||||
});
|
||||
|
||||
test('slug = billing', function () {
|
||||
expect((new BillingSummaryProvider)->slug())->toBe('billing');
|
||||
});
|
||||
|
||||
test('агрегирует balance_transactions по типу: count + сумма', function () {
|
||||
seedBillingTx($this->tenant->id, 'topup', 5000);
|
||||
seedBillingTx($this->tenant->id, 'topup', 3000);
|
||||
seedBillingTx($this->tenant->id, 'lead_charge', -250);
|
||||
|
||||
$rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));
|
||||
|
||||
// ORDER BY type → 'lead_charge' < 'topup'.
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[0])->toBe(['Списание за лиды', 1, '-250.00']);
|
||||
expect($rows[1])->toBe(['Пополнение', 2, '8000.00']);
|
||||
});
|
||||
|
||||
test('исключает транзакции вне периода', function () {
|
||||
seedBillingTx($this->tenant->id, 'topup', 5000);
|
||||
seedBillingTx($this->tenant->id, 'topup', 1000, Carbon::now()->subMonths(3));
|
||||
|
||||
$rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0])->toBe(['Пополнение', 1, '5000.00']);
|
||||
});
|
||||
|
||||
test('изолирует по tenant_id', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
seedBillingTx($other->id, 'topup', 9999);
|
||||
|
||||
$rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Reports\Providers\ManagersSummaryProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
});
|
||||
|
||||
/** Вставляет сделку напрямую (deals партиционирована, фабрики нет). */
|
||||
function seedManagerDeal(int $tenantId, int $projectId, array $overrides = []): void
|
||||
{
|
||||
DB::table('deals')->insert(array_merge([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'phone' => '+7999'.random_int(1000000, 9999999),
|
||||
'status' => 'new',
|
||||
'received_at' => Carbon::now()->startOfMonth()->addDays(10),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** ReportJob без сохранения — провайдер читает только tenant_id + parameters. */
|
||||
function managersJob(int $tenantId): ReportJob
|
||||
{
|
||||
return new ReportJob([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'managers_summary',
|
||||
'parameters' => [
|
||||
'format' => 'csv',
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('headers: 4 колонки', function () {
|
||||
expect((new ManagersSummaryProvider)->headers())
|
||||
->toBe(['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)']);
|
||||
});
|
||||
|
||||
test('slug = managers', function () {
|
||||
expect((new ManagersSummaryProvider)->slug())->toBe('managers');
|
||||
});
|
||||
|
||||
test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
|
||||
$manager = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||||
]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0])->toBe(['Иван Петров', 3, 2, 66.7]);
|
||||
});
|
||||
|
||||
test('сделки без менеджера → строка «Не назначен»', function () {
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => null, 'status' => 'new']);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][0])->toBe('Не назначен');
|
||||
});
|
||||
|
||||
test('исключает soft-deleted и тестовые сделки', function () {
|
||||
$manager = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'deleted_at' => Carbon::now()]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'is_test' => true]);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][1])->toBe(1);
|
||||
});
|
||||
|
||||
test('изолирует по tenant_id', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
$otherProject = Project::factory()->create(['tenant_id' => $other->id]);
|
||||
seedManagerDeal($other->id, $otherProject->id);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
function doneReportJob(int $tenantId, int $userId, array $overrides = []): ReportJob
|
||||
{
|
||||
return ReportJob::create(array_merge([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
'expires_at' => Carbon::now()->addDays(30),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function signedDownloadUrl(ReportJob $job, ?Carbon $expiry = null): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'reports.download',
|
||||
$expiry ?? Carbon::now()->addHours(24),
|
||||
['id' => $job->id, 'tenant' => $job->tenant_id],
|
||||
);
|
||||
}
|
||||
|
||||
test('download: success → 200 + attachment файла', function () {
|
||||
$path = "reports/{$this->tenant->id}/1.csv";
|
||||
Storage::disk('local')->put($path, "col\r\nval\r\n");
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$response = $this->get(signedDownloadUrl($job));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload("report-{$job->id}.csv");
|
||||
});
|
||||
|
||||
test('download: невалидная подпись → 403', function () {
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/x/1.csv']);
|
||||
|
||||
$this->get("/api/reports/jobs/{$job->id}/file?tenant={$this->tenant->id}&expires=9999999999&signature=deadbeef")
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('download: просроченная подпись → 403', function () {
|
||||
$path = "reports/{$this->tenant->id}/2.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$this->get(signedDownloadUrl($job, Carbon::now()->subHour()))->assertStatus(403);
|
||||
});
|
||||
|
||||
test('download: file_path=NULL (истёк) → 410', function () {
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => null]);
|
||||
|
||||
$this->get(signedDownloadUrl($job))->assertStatus(410);
|
||||
});
|
||||
|
||||
test('download: файл отсутствует на диске → 404', function () {
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/missing/9.csv']);
|
||||
|
||||
$this->get(signedDownloadUrl($job))->assertStatus(404);
|
||||
});
|
||||
|
||||
test('download: несуществующий job → 404', function () {
|
||||
$ghost = new ReportJob(['tenant_id' => $this->tenant->id]);
|
||||
$ghost->id = 999999;
|
||||
$this->get(signedDownloadUrl($ghost))->assertStatus(404);
|
||||
});
|
||||
|
||||
test('toResource (GET /jobs/{id}): done-job содержит download_url', function () {
|
||||
$path = "reports/{$this->tenant->id}/3.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$url = $response->json('job.download_url');
|
||||
expect($url)->toContain("/api/reports/jobs/{$job->id}/file");
|
||||
expect($url)->toContain('signature=');
|
||||
});
|
||||
|
||||
test('toResource: pending-job → download_url = null', function () {
|
||||
$job = ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'status' => ReportJob::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");
|
||||
|
||||
expect($response->json('job.download_url'))->toBeNull();
|
||||
});
|
||||
|
||||
test('download: expires_at в прошлом → 410 (до cron-очистки file_path)', function () {
|
||||
$path = "reports/{$this->tenant->id}/4.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, [
|
||||
'file_path' => $path,
|
||||
'expires_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->get(signedDownloadUrl($job))->assertStatus(410);
|
||||
});
|
||||
|
||||
test('toResource: expired done-job → download_url = null', function () {
|
||||
$path = "reports/{$this->tenant->id}/5.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, [
|
||||
'file_path' => $path,
|
||||
'expires_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");
|
||||
|
||||
expect($response->json('job.download_url'))->toBeNull();
|
||||
});
|
||||
|
||||
test('download: подмена tenant в signed URL ломает подпись → 403', function () {
|
||||
$path = "reports/{$this->tenant->id}/6.csv";
|
||||
Storage::disk('local')->put($path, 'data');
|
||||
$job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);
|
||||
|
||||
$url = signedDownloadUrl($job);
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
// Подменяем tenant=<own> на tenant=<other> в готовом signed URL — подпись
|
||||
// покрывает query-параметры, поэтому ValidateSignature вернёт 403.
|
||||
$tampered = str_replace(
|
||||
"tenant={$this->tenant->id}",
|
||||
"tenant={$otherTenant->id}",
|
||||
$url,
|
||||
);
|
||||
|
||||
$this->get($tampered)->assertStatus(403);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateReportJob;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
@@ -336,16 +337,103 @@ test('POST /api/reports/jobs (sync queue): pdf → failed (Post-MVP)', function
|
||||
expect($job->error_message)->toContain('Post-MVP');
|
||||
});
|
||||
|
||||
test('POST /api/reports/jobs (sync queue): managers_summary не реализован → failed', function () {
|
||||
test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', function () {
|
||||
config()->set('queue.default', 'sync');
|
||||
|
||||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$manager = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||||
]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'manager_id' => $manager->id,
|
||||
'phone' => '+79990001122',
|
||||
'status' => 'paid',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/reports/jobs', [
|
||||
'type' => 'managers_summary', 'format' => 'csv',
|
||||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'parameters' => [
|
||||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$job = ReportJob::find($response->json('job.id'));
|
||||
expect($job->status)->toBe('failed');
|
||||
expect($job->error_message)->toContain('Неподдерживаемая');
|
||||
expect($job->status)->toBe('done');
|
||||
expect($job->file_path)->toEndWith('.csv');
|
||||
|
||||
$content = Storage::disk('local')->get($job->file_path);
|
||||
expect($content)->toContain('Менеджер');
|
||||
expect($content)->toContain('Иван Петров');
|
||||
});
|
||||
|
||||
test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', function () {
|
||||
config()->set('queue.default', 'sync');
|
||||
|
||||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '+79990002233',
|
||||
'status' => 'paid',
|
||||
'utm_source' => 'yandex',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/reports/jobs', [
|
||||
'type' => 'sources_summary', 'format' => 'csv',
|
||||
'parameters' => [
|
||||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$job = ReportJob::find($response->json('job.id'));
|
||||
expect($job->status)->toBe('done');
|
||||
expect($job->file_path)->toEndWith('.csv');
|
||||
|
||||
$content = Storage::disk('local')->get($job->file_path);
|
||||
expect($content)->toContain('Источник');
|
||||
expect($content)->toContain('yandex');
|
||||
});
|
||||
|
||||
test('POST /api/reports/jobs (sync queue): billing_summary → done с CSV', function () {
|
||||
config()->set('queue.default', 'sync');
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'type' => 'topup',
|
||||
'amount_rub' => 5000,
|
||||
'amount_leads' => 0,
|
||||
'description' => 'test topup',
|
||||
'created_at' => Carbon::now()->startOfMonth()->addDays(5),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/reports/jobs', [
|
||||
'type' => 'billing_summary', 'format' => 'csv',
|
||||
'parameters' => [
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$job = ReportJob::find($response->json('job.id'));
|
||||
expect($job->status)->toBe('done');
|
||||
expect($job->file_path)->toEndWith('.csv');
|
||||
|
||||
$content = Storage::disk('local')->get($job->file_path);
|
||||
expect($content)->toContain('Тип операции');
|
||||
expect($content)->toContain('Пополнение');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Reports\Providers\SourcesSummaryProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
});
|
||||
|
||||
function seedSourceDeal(int $tenantId, int $projectId, array $overrides = []): void
|
||||
{
|
||||
DB::table('deals')->insert(array_merge([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'phone' => '+7999'.random_int(1000000, 9999999),
|
||||
'status' => 'new',
|
||||
'received_at' => Carbon::now()->startOfMonth()->addDays(10),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function sourcesJob(int $tenantId): ReportJob
|
||||
{
|
||||
return new ReportJob([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => 'sources_summary',
|
||||
'parameters' => [
|
||||
'format' => 'csv',
|
||||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('headers: 4 колонки', function () {
|
||||
expect((new SourcesSummaryProvider)->headers())
|
||||
->toBe(['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)']);
|
||||
});
|
||||
|
||||
test('slug = sources', function () {
|
||||
expect((new SourcesSummaryProvider)->slug())->toBe('sources');
|
||||
});
|
||||
|
||||
test('агрегирует сделки по utm_source', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
// ORDER BY COUNT(*) DESC → yandex (2) первый.
|
||||
expect($rows[0])->toBe(['yandex', 2, 1, 50.0]);
|
||||
expect($rows[1])->toBe(['vk', 1, 1, 100.0]);
|
||||
});
|
||||
|
||||
test('сделки без utm_source → «Прямые / без метки»', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => null]);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][0])->toBe('Прямые / без метки');
|
||||
});
|
||||
|
||||
test('исключает soft-deleted и тестовые сделки', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'deleted_at' => Carbon::now()]);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'is_test' => true]);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0][1])->toBe(1);
|
||||
});
|
||||
|
||||
test('изолирует по tenant_id', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
$otherProject = Project::factory()->create(['tenant_id' => $other->id]);
|
||||
seedSourceDeal($other->id, $otherProject->id, ['utm_source' => 'yandex']);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
expect($rows)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue';
|
||||
import type { ApiAdminBillingTenant } from '../../resources/js/api/admin';
|
||||
|
||||
/**
|
||||
* Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
|
||||
* с нужным `response.data.message`.
|
||||
*/
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), {
|
||||
isAxiosError: true,
|
||||
response: { status, data: { message } },
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return {
|
||||
...orig,
|
||||
listAdminBilling: vi.fn(),
|
||||
listAdminTariffPlans: vi.fn(),
|
||||
updateTenantStatus: vi.fn(),
|
||||
refundTenant: vi.fn(),
|
||||
changeTenantTariff: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeApiBillingTenant(overrides: Partial<ApiAdminBillingTenant> = {}): ApiAdminBillingTenant {
|
||||
return {
|
||||
id: 42,
|
||||
subdomain: 'acme',
|
||||
organization_name: 'Acme ООО',
|
||||
contact_email: 'admin@acme.io',
|
||||
status: 'active',
|
||||
balance_rub: '5000.00',
|
||||
tariff_id: 1,
|
||||
tariff_name: 'Команда',
|
||||
mrr_rub: '990.00',
|
||||
monthly_topups_rub: '10000.00',
|
||||
monthly_charges_rub: '8000.00',
|
||||
last_payment_at: '2026-05-01T10:00:00Z',
|
||||
chargeback_unrecovered_rub: '0.00',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBillingResponse(tenants: ApiAdminBillingTenant[]) {
|
||||
return {
|
||||
tenants,
|
||||
summary: {
|
||||
total_mrr_rub: '990.00',
|
||||
monthly_revenue_rub: '10000.00',
|
||||
overdue_count: 0,
|
||||
refunds_count_30d: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () =>
|
||||
mount(AdminBillingView, {
|
||||
global: { plugins: [createVuetify()] },
|
||||
});
|
||||
|
||||
describe('AdminBillingView — row-actions menu (G4)', () => {
|
||||
it('каждая строка содержит кнопку действий [data-testid="row-actions-{id}"]', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="row-actions-42"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('openAction("status", row) устанавливает actionDialog="status" и actionRow', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(vm.actionDialog).toBe('status');
|
||||
expect(vm.actionRow).toBe(row);
|
||||
expect(vm.actionReason).toBe('');
|
||||
expect(vm.actionError).toBe('');
|
||||
});
|
||||
|
||||
it('confirmAction() вызывает updateTenantStatus и затем loadBilling', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42, status: 'active' })]),
|
||||
);
|
||||
vi.mocked(adminApi.updateTenantStatus).mockResolvedValueOnce({ id: 42, status: 'suspended' });
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
vm.actionReason = 'Основание для блокировки тенанта';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.updateTenantStatus).toHaveBeenCalledWith(42, 'suspended', 'Основание для блокировки тенанта');
|
||||
expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(2); // mount + after action
|
||||
expect(vm.actionDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('confirmAction() вызывает refundTenant с суммой и причиной', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.refundTenant).mockResolvedValueOnce({
|
||||
id: 42,
|
||||
balance_rub: '4500.00',
|
||||
transaction_id: 999,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('refund', row);
|
||||
vm.actionAmount = 500;
|
||||
vm.actionReason = 'Возврат по заявке клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.refundTenant).toHaveBeenCalledWith(42, 500, 'Возврат по заявке клиента');
|
||||
expect(vm.actionDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('openAction("tariff") вызывает listAdminTariffPlans и заполняет tariffPlans', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
|
||||
{ id: 1, name: 'Старт', price_monthly: '490.00' },
|
||||
{ id: 2, name: 'Команда', price_monthly: '990.00' },
|
||||
]);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('tariff', row);
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.listAdminTariffPlans).toHaveBeenCalledTimes(1);
|
||||
expect(vm.tariffPlans).toHaveLength(2);
|
||||
expect(vm.tariffPlans[0].name).toBe('Старт');
|
||||
});
|
||||
|
||||
it('confirmAction("tariff") вызывает changeTenantTariff', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
|
||||
{ id: 2, name: 'Команда', price_monthly: '990.00' },
|
||||
]);
|
||||
vi.mocked(adminApi.changeTenantTariff).mockResolvedValueOnce({
|
||||
id: 42,
|
||||
tariff_id: 2,
|
||||
tariff_name: 'Команда',
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('tariff', row);
|
||||
await flushPromises();
|
||||
|
||||
vm.actionTariffId = 2;
|
||||
vm.actionReason = 'Смена тарифа по просьбе клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.changeTenantTariff).toHaveBeenCalledWith(42, 2, 'Смена тарифа по просьбе клиента');
|
||||
expect(vm.actionDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('API-ошибка в confirmAction — actionError содержит backend-сообщение, диалог остаётся открытым', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.updateTenantStatus).mockRejectedValueOnce(
|
||||
makeAxiosError('Нельзя заблокировать — есть долги'),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
vm.actionReason = 'Причина блокировки тенанта';
|
||||
|
||||
await vm.confirmAction();
|
||||
await flushPromises();
|
||||
|
||||
// Dialog stays open, exact backend message surfaces, loading cleared
|
||||
expect(vm.actionDialog).toBe('status');
|
||||
expect(vm.actionError).toBe('Нельзя заблокировать — есть долги');
|
||||
expect(vm.actionLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('openAction("tariff") — ошибка загрузки тарифов устанавливает actionError, диалог остаётся открытым', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
vi.mocked(adminApi.listAdminTariffPlans).mockRejectedValueOnce(
|
||||
makeAxiosError('Тарифы временно недоступны', 503),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('tariff', row);
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.actionDialog).toBe('tariff');
|
||||
expect(vm.actionError).toBe('Тарифы временно недоступны');
|
||||
});
|
||||
|
||||
it('возврат суммы больше баланса → actionError, refundTenant не вызывается', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42, balance_rub: '1000.00' })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0]; // balance_rub = 1000
|
||||
|
||||
await vm.openAction('refund', row);
|
||||
vm.actionAmount = 1500; // exceeds balance
|
||||
vm.actionReason = 'Возврат по заявке клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
|
||||
expect(adminApi.refundTenant).not.toHaveBeenCalled();
|
||||
expect(vm.actionError).toBe('Сумма возврата превышает баланс тенанта.');
|
||||
expect(vm.actionDialog).toBe('refund');
|
||||
});
|
||||
|
||||
it('NaN в сумме возврата → actionError, refundTenant не вызывается', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('refund', row);
|
||||
vm.actionAmount = NaN; // non-numeric input from v-model.number
|
||||
vm.actionReason = 'Возврат по заявке клиента';
|
||||
|
||||
await vm.confirmAction();
|
||||
|
||||
expect(adminApi.refundTenant).not.toHaveBeenCalled();
|
||||
expect(vm.actionError).toBeTruthy();
|
||||
expect(vm.actionDialog).toBe('refund');
|
||||
});
|
||||
|
||||
it('короткая причина (<10 символов) → confirmAction ставит actionError, не вызывает API', async () => {
|
||||
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
||||
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
||||
);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
const row = vm.rowsState[0];
|
||||
|
||||
await vm.openAction('status', row);
|
||||
vm.actionReason = 'Коротко'; // < 10 chars
|
||||
|
||||
await vm.confirmAction();
|
||||
|
||||
expect(adminApi.updateTenantStatus).not.toHaveBeenCalled();
|
||||
expect(vm.actionError).toBeTruthy();
|
||||
expect(vm.actionDialog).toBe('status'); // stays open
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AdminIncidentDetailView from '../../resources/js/views/admin/AdminIncidentDetailView.vue';
|
||||
import type { ApiAdminIncidentDetail } from '../../resources/js/api/admin';
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return {
|
||||
...orig,
|
||||
getAdminIncidentDetail: vi.fn(),
|
||||
notifyIncidentRkn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeDetail(overrides: Partial<ApiAdminIncidentDetail> = {}): ApiAdminIncidentDetail {
|
||||
return {
|
||||
id: 7,
|
||||
incident_id: 'INC-2026-0516-0007',
|
||||
type: 'data_breach',
|
||||
severity: 'high',
|
||||
summary: 'Утечка данных тенантов',
|
||||
root_cause: 'Неправильная RLS-политика',
|
||||
postmortem_url: 'https://example.com/postmortem',
|
||||
started_at: '2026-05-16T10:00:00Z',
|
||||
detected_at: '2026-05-16T10:30:00Z',
|
||||
resolved_at: null,
|
||||
status: 'investigating',
|
||||
affected_tenants: [
|
||||
{ id: 1, organization_name: 'Окна Москва ООО' },
|
||||
{ id: 2, organization_name: 'ИП Петров' },
|
||||
],
|
||||
affected_users_count: 42,
|
||||
notification_sent_at: null,
|
||||
rkn_notified: false,
|
||||
rkn_notified_at: null,
|
||||
rkn_deadline_at: '2026-05-17T10:30:00Z',
|
||||
created_by_admin: 'admin@liderra.ru',
|
||||
closed_by_admin: null,
|
||||
created_at: '2026-05-16T10:35:00Z',
|
||||
updated_at: '2026-05-16T10:35:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const buildRouter = (id: number) => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/admin/incidents', name: 'admin-incidents', component: { template: '<div />' } },
|
||||
{
|
||||
path: '/admin/incidents/:id',
|
||||
name: 'admin-incident-detail',
|
||||
component: AdminIncidentDetailView,
|
||||
},
|
||||
],
|
||||
});
|
||||
return router.push({ name: 'admin-incident-detail', params: { id } }).then(() => router);
|
||||
};
|
||||
|
||||
const mountDetail = async (id: number) => {
|
||||
const router = await buildRouter(id);
|
||||
await router.isReady();
|
||||
const wrapper = mount(AdminIncidentDetailView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { teleport: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('AdminIncidentDetailView.vue', () => {
|
||||
it('вызывает getAdminIncidentDetail с id из route и рендерит summary/incident_id/severity', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(adminApi.getAdminIncidentDetail).toHaveBeenCalledWith(7);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('INC-2026-0516-0007');
|
||||
expect(text).toContain('Утечка данных тенантов');
|
||||
expect(text).toContain('High');
|
||||
});
|
||||
|
||||
it('404 от API → data-testid="incident-not-found"', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const wrapper = await mountDetail(999);
|
||||
expect(wrapper.find('[data-testid="incident-not-found"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('500 от API → data-testid="incident-fetch-error" + кнопка Повторить', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Backend error' } },
|
||||
});
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="incident-fetch-error"]').exists()).toBe(true);
|
||||
// retry button calls loadIncident
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
|
||||
const retryBtn = wrapper.find('[data-testid="incident-fetch-error"] button, [data-testid="incident-fetch-error"] .v-btn');
|
||||
expect(retryBtn.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('data_breach + rkn_notified=false → data-testid="rkn-notify-btn" видна', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail({ type: 'data_breach', rkn_notified: false }));
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('клик rkn-notify → confirm → вызывает notifyIncidentRkn, карточка обновляется (rkn_notified=true, кнопка исчезает)', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'data_breach', rkn_notified: false }),
|
||||
);
|
||||
const notified = makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-16T11:00:00Z' });
|
||||
vi.mocked(adminApi.notifyIncidentRkn).mockResolvedValue(notified);
|
||||
|
||||
const wrapper = await mountDetail(7);
|
||||
// open dialog via btn
|
||||
await wrapper.find('[data-testid="rkn-notify-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// call confirmRkn directly via defineExpose
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmRkn: () => Promise<void>;
|
||||
incident: ApiAdminIncidentDetail | null;
|
||||
rknDialog: boolean;
|
||||
};
|
||||
await vm.confirmRkn();
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.notifyIncidentRkn).toHaveBeenCalledWith(7);
|
||||
expect(vm.incident?.rkn_notified).toBe(true);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('type !== data_breach → кнопка РКН-notify отсутствует', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'service_outage', rkn_notified: false }),
|
||||
);
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('rkn_notified=true → показывает "РКН уведомлён", кнопки нет', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-17T08:00:00Z' }),
|
||||
);
|
||||
const wrapper = await mountDetail(7);
|
||||
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('РКН уведомлён');
|
||||
});
|
||||
|
||||
it('ошибка от notifyIncidentRkn → data-testid="rkn-error" виден', async () => {
|
||||
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
|
||||
makeDetail({ type: 'data_breach', rkn_notified: false }),
|
||||
);
|
||||
vi.mocked(adminApi.notifyIncidentRkn).mockRejectedValue(
|
||||
new Error('РКН endpoint недоступен'),
|
||||
);
|
||||
|
||||
const wrapper = await mountDetail(7);
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmRkn: () => Promise<void>;
|
||||
rknError: string;
|
||||
};
|
||||
await vm.confirmRkn();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="rkn-error"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
@@ -7,23 +7,24 @@ import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsVie
|
||||
const mountView = async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/admin/incidents', component: AdminIncidentsView }],
|
||||
routes: [
|
||||
{ path: '/admin/incidents', name: 'admin-incidents', component: AdminIncidentsView },
|
||||
{ path: '/admin/incidents/:id', name: 'admin-incident-detail', component: { template: '<div />' } },
|
||||
],
|
||||
});
|
||||
await router.push('/admin/incidents');
|
||||
await router.isReady();
|
||||
return mount(AdminIncidentsView, {
|
||||
global: { plugins: [createVuetify(), router] },
|
||||
});
|
||||
return { wrapper: mount(AdminIncidentsView, { global: { plugins: [createVuetify(), router] } }), router };
|
||||
};
|
||||
|
||||
describe('AdminIncidentsView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Инциденты»', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
expect(wrapper.text()).toContain('Инциденты');
|
||||
});
|
||||
|
||||
it('содержит 3 stats: Открыто / Расследуется / РКН-уведомлений', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Открыто');
|
||||
expect(text).toContain('Расследуется');
|
||||
@@ -31,7 +32,7 @@ describe('AdminIncidentsView.vue', () => {
|
||||
});
|
||||
|
||||
it('содержит фильтр-toggle по статусам (5 значений)', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Все');
|
||||
expect(text).toContain('Открыты');
|
||||
@@ -40,16 +41,28 @@ describe('AdminIncidentsView.vue', () => {
|
||||
});
|
||||
|
||||
it('показывает PDN-breach с РКН pending chip', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Утечка ПДн');
|
||||
expect(text).toContain('РКН pending');
|
||||
});
|
||||
|
||||
it('содержит incident_id в формате INC-YYYY-MMDD-NNNN', async () => {
|
||||
const wrapper = await mountView();
|
||||
const { wrapper } = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('INC-2026-0507-0034');
|
||||
expect(text).toContain('INC-2026-0506-0028');
|
||||
});
|
||||
|
||||
it('клик по строке инцидента вызывает router.push на admin-incident-detail', async () => {
|
||||
const { wrapper, router } = await mountView();
|
||||
const pushSpy = vi.spyOn(router, 'push');
|
||||
// get first row — mock data has id from ADMIN_INCIDENTS[0]
|
||||
const vm = wrapper.vm as unknown as { rowsState: Array<{ id: number }> };
|
||||
const firstId = vm.rowsState[0].id;
|
||||
const row = wrapper.find(`[data-testid="incident-row-${firstId}"]`);
|
||||
expect(row.exists()).toBe(true);
|
||||
await row.trigger('click');
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'admin-incident-detail', params: { id: firstId } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,47 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DashboardView from '../../resources/js/views/DashboardView.vue';
|
||||
import type { DashboardSummary } from '../../resources/js/api/dashboard';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
|
||||
describe('DashboardView.vue', () => {
|
||||
const factory = () =>
|
||||
mount(DashboardView, {
|
||||
global: { plugins: [createVuetify()] },
|
||||
});
|
||||
vi.mock('../../resources/js/api/dashboard', () => ({
|
||||
getDashboardSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
it('монтируется и содержит приветствие', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.text()).toContain('Доброе утро');
|
||||
const mockUser: AuthUser = {
|
||||
id: 1,
|
||||
email: 'user@liderra.ru',
|
||||
first_name: 'Иван',
|
||||
last_name: 'Петров',
|
||||
tenant_id: 1,
|
||||
totp_enabled: false,
|
||||
last_login_at: null,
|
||||
};
|
||||
|
||||
const dashboardApi = await import('../../resources/js/api/dashboard');
|
||||
|
||||
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
|
||||
return {
|
||||
range: '7d',
|
||||
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
||||
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () => {
|
||||
setActivePinia(createPinia());
|
||||
useAuthStore().user = mockUser;
|
||||
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
|
||||
it('getDashboardSummary вызывается на mount', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('содержит range-toggle с 4 опциями', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Сегодня');
|
||||
expect(text).toContain('7 дней');
|
||||
expect(text).toContain('30 дней');
|
||||
expect(text).toContain('Период');
|
||||
});
|
||||
|
||||
it('содержит 3 KPI-cards (получено лидов / конверсия / активные проекты)', () => {
|
||||
const wrapper = factory();
|
||||
it('успех — KPI и баланс из API видны', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
|
||||
);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Получено лидов');
|
||||
expect(text).toContain('Конверсия в оплату');
|
||||
expect(text).toContain('Активные проекты');
|
||||
});
|
||||
|
||||
it('содержит balance-card с suммой и runway', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Баланс');
|
||||
expect(text).toContain('14 250');
|
||||
expect(text).toContain('LIVE');
|
||||
expect(text).toContain('хватит на');
|
||||
expect(text).toContain('99 000');
|
||||
expect(wrapper.text()).toContain('12.3%');
|
||||
});
|
||||
|
||||
it('runway-bar содержит 7 сегментов (по числу runwayMax)', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.findAll('.runway-fill')).toHaveLength(7);
|
||||
it('ошибка API — fallback на mock, view не падает', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Получено лидов');
|
||||
expect(wrapper.find('.runway-fill').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="dashboard-fetch-error"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('смена range перезапрашивает summary', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
(wrapper.vm as unknown as { range: string }).range = '30d';
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user