147 lines
6.5 KiB
PHP
147 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Tenant;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
|
*
|
|
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
|
*
|
|
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
|
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
|
*
|
|
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
|
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
|
*/
|
|
class DashboardController extends Controller
|
|
{
|
|
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
|
|
|
public function summary(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->query('tenant_id', '0');
|
|
if ($tenantId < 1) {
|
|
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
|
}
|
|
|
|
$tenant = Tenant::find($tenantId);
|
|
if ($tenant === null) {
|
|
return response()->json(['message' => 'Тенант не найден.'], 404);
|
|
}
|
|
|
|
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
|
? (string) $request->query('range')
|
|
: '7d';
|
|
|
|
// 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 ---
|
|
// 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];
|
|
}
|
|
}
|