53fb7b7760
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
8.1 KiB
PHP
170 lines
8.1 KiB
PHP
<?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];
|
||
}
|
||
}
|