Files
portal/app/app/Http/Controllers/Api/DashboardController.php
T

170 lines
8.1 KiB
PHP
Raw Normal View History

<?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();
$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 (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'];
$runwayDays = app(RunwayCalculator::class)
->daysLeft($tenantId, $affordableLeads) ?? 0;
// --- средняя стоимость лида (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, 'limit' => $maxProjects],
'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];
}
}