Files
portal/app/app/Http/Controllers/Api/DashboardController.php
T
Дмитрий 7ac9af7c79
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat: убрать лимит по числу проектов — ограничение только по балансу/лидам
Правило продукта: ограничений по количеству проектов нет, лимит только
по балансу и заказанным лидам. Убран гейт tenants.limits.max_projects
в ProjectService::create и показ лимита проектов на дашборде. Поле limits
оставлено как резерв; max_users и api_rps в коде не используются.

Заодно фикс типа в EditProjectDialog.spec: sampleProject типизирован
настоящим Project, source_locked больше не краснит vue-tsc.

Тесты: ProjectsStore 13/13, DashboardSummary 11/11, DashboardView 8/8,
EditProjectDialog 7/7; vue-tsc чисто; pint чисто; vite build ок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:47:49 +03:00

172 lines
8.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\RunwayCalculator;
use Carbon\Carbon;
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
{
// Go-live (audit J3): tenant_id из authed-user (auth:sanctum + tenant
// middleware), НЕ из параметра запроса — закрывает кросс-tenant утечку KPI.
$tenantId = (int) $request->user()->tenant_id;
$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: % статуса 'won' в окне ---
$curPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$windowStart, $now])->count();
$prevPaid = (clone $base())->where('status', 'won')
->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)
->where('is_active', true)
->count();
// --- 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 (F3, 17.06.2026: единый источник с биллингом) ---
// Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
// Теперь — affordable leads от рублёвого баланса по тарифу
// (BalanceToLeadsConverter) + общий RunwayCalculator.
$activeTiers = app(PricingTierRepository::class)
->activeAt(Carbon::now('Europe/Moscow'));
$conversion = app(BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
(int) ($tenant->delivered_in_month ?? 0),
$activeTiers,
);
$affordableLeads = (int) $conversion['leads'];
// B1-2 (UX-аудит 25.06): null (нет активных проектов) НЕ приводим к 0 —
// иначе дашборд врал «хватит на 0 дней» при полном балансе, расходясь с
// биллингом «∞». null → фронт показывает «нет активных проектов».
$runwayDays = app(RunwayCalculator::class)
->daysLeft($tenantId, $affordableLeads);
// --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм
// за окно периода. Только charge_source='rub' (у prepaid цена 0 по CHECK —
// иначе среднее занижается); источник тот же, что у карточки сделки (F2).
// null, если в окне нет rub-списаний (ничего ещё не списано).
$avgKopecks = DB::table('lead_charges')
->where('tenant_id', $tenantId)
->where('charge_source', 'rub')
->whereBetween('charged_at', [$windowStart, $now])
->avg('price_per_lead_kopecks');
$avgLeadCostRub = $avgKopecks !== null ? round((float) $avgKopecks / 100, 2) : null;
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],
'balance' => [
'amount_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
'runway_leads' => $affordableLeads,
],
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
'funnel' => (object) $funnel,
'avg_lead_cost_rub' => $avgLeadCostRub,
];
});
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];
}
}