FranchisorDashboardService.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. <?php
  2. namespace App\Services;
  3. use App\Enums\ReceivableStatus;
  4. use Illuminate\Support\Carbon;
  5. use Illuminate\Support\Facades\DB;
  6. /**
  7. * Agrega os indicadores da tela inicial (Dashboard) da visão do Franqueador.
  8. *
  9. * Premissas de cálculo:
  10. * - "Matrículas por Período" = quantidade de alunos cadastrados por mês (created_at),
  11. * nos últimos 6 meses.
  12. * - "Aniversariantes do Mês" = alunos cujo mês de nascimento é o mês corrente.
  13. * - "Tickets Abertos" = chamados de suporte com status 'in_progress'.
  14. * - "Tarefas Pendentes" = cartões do Kanban que ainda não foram concluídos.
  15. * - "Receita Geral" = total cobrado das franquias (franchisee_account_receives),
  16. * pago + em aberto; a legenda traz a quantidade de pagamentos pendentes.
  17. * - "Estoque Geral de Produtos" = soma das quantidades em estoque dos produtos.
  18. *
  19. * Todas as métricas por unidade respeitam o filtro `unitIds` (vazio = rede inteira);
  20. * o estoque de produtos é do catálogo do franqueador (não filtra por unidade).
  21. */
  22. class FranchisorDashboardService
  23. {
  24. private const SETTLED = [
  25. ReceivableStatus::PAID->value,
  26. ReceivableStatus::CANCELLED->value,
  27. ];
  28. public function __construct(
  29. private readonly TbrCalculationService $tbrCalculationService,
  30. ) {}
  31. public function overview(array $unitIds = [], ?Carbon $startDate = null, ?Carbon $endDate = null): array
  32. {
  33. $startDate ??= Carbon::now()->startOfMonth();
  34. $endDate ??= Carbon::now()->endOfMonth();
  35. return [
  36. 'enrollments' => $this->enrollments($unitIds),
  37. 'birthdays' => $this->birthdays($unitIds),
  38. 'attendance' => $this->attendance($unitIds, $startDate, $endDate),
  39. 'open_tickets' => $this->openTickets($unitIds),
  40. 'tickets' => $this->tickets($unitIds),
  41. 'pending_tasks' => $this->pendingTasks($unitIds),
  42. 'general_revenue' => $this->generalRevenue($unitIds),
  43. 'product_stock' => $this->productStock(),
  44. 'products' => $this->products(),
  45. 'units_summary' => $this->unitsSummary($unitIds, $startDate, $endDate),
  46. ];
  47. }
  48. /**
  49. * Receita usa a mesma base do cálculo do TBR. O status é derivado da
  50. * existência de contrato de franquia vigente na data atual.
  51. */
  52. private function unitsSummary(array $unitIds, Carbon $startDate, Carbon $endDate): array
  53. {
  54. $units = DB::table('units')
  55. ->whereNull('deleted_at')
  56. ->when(!empty($unitIds), fn ($q) => $q->whereIn('id', $unitIds));
  57. $filteredUnitIds = (clone $units)->pluck('id')->map(fn ($id) => (int) $id)->all();
  58. $total = count($filteredUnitIds);
  59. $today = Carbon::today()->toDateString();
  60. $activeContracts = $total === 0
  61. ? 0
  62. : (int) DB::table('franchisee_contracts')
  63. ->whereNull('deleted_at')
  64. ->whereIn('unit_id', $filteredUnitIds)
  65. ->whereNotNull('start_date')
  66. ->where('start_date', '<=', $today)
  67. ->where(function ($q) use ($today) {
  68. $q->whereNull('end_date')->orWhere('end_date', '>=', $today);
  69. })
  70. ->distinct()
  71. ->count('unit_id');
  72. $revenue = $total === 0
  73. ? 0.0
  74. : $this->tbrCalculationService->resolveRevenueBetween($filteredUnitIds, $startDate, $endDate);
  75. return [
  76. 'total' => $total,
  77. 'revenue' => round($revenue, 2),
  78. 'active_contracts' => $activeContracts,
  79. 'without_active_contract' => max(0, $total - $activeContracts),
  80. ];
  81. }
  82. /**
  83. * Matrículas (alunos cadastrados) por mês nos últimos 6 meses.
  84. */
  85. private function enrollments(array $unitIds): array
  86. {
  87. $start = Carbon::now()->startOfMonth()->subMonthsNoOverflow(5);
  88. // Agrupa por ano-mês de forma portável (independente do SGBD): puxa apenas
  89. // a data de cadastro no período e conta os meses em memória.
  90. $createdAt = DB::table('students')
  91. ->whereNull('deleted_at')
  92. ->where('created_at', '>=', $start)
  93. ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
  94. ->pluck('created_at');
  95. $counts = [];
  96. foreach ($createdAt as $date) {
  97. $ym = Carbon::parse($date)->format('Y-m');
  98. $counts[$ym] = ($counts[$ym] ?? 0) + 1;
  99. }
  100. $labels = [];
  101. $data = [];
  102. $months = ['JAN', 'FEV', 'MAR', 'ABR', 'MAI', 'JUN', 'JUL', 'AGO', 'SET', 'OUT', 'NOV', 'DEZ'];
  103. for ($i = 0; $i < 6; $i++) {
  104. $month = $start->copy()->addMonthsNoOverflow($i);
  105. $labels[] = $months[$month->month - 1];
  106. $data[] = (int) ($counts[$month->format('Y-m')] ?? 0);
  107. }
  108. return ['labels' => $labels, 'data' => $data];
  109. }
  110. /**
  111. * Aniversariantes do mês corrente (dia + nome).
  112. */
  113. private function birthdays(array $unitIds): array
  114. {
  115. return DB::table('students')
  116. ->whereNull('deleted_at')
  117. ->whereMonth('birth_date', Carbon::now()->month)
  118. ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
  119. ->get(['name', 'birth_date'])
  120. ->map(fn ($s) => [
  121. 'day' => (int) Carbon::parse($s->birth_date)->day,
  122. 'name' => $s->name,
  123. ])
  124. ->sortBy('day')
  125. ->values()
  126. ->all();
  127. }
  128. private function openTickets(array $unitIds): int
  129. {
  130. return (int) $this->openTicketsQuery($unitIds)->count();
  131. }
  132. private function tickets(array $unitIds): array
  133. {
  134. return $this->openTicketsQuery($unitIds)
  135. ->orderByDesc('created_at')
  136. ->get(['id', 'severity', 'created_at', 'sector', 'status', 'title'])
  137. ->map(fn ($ticket) => [
  138. 'id' => (int) $ticket->id,
  139. 'number' => '#' . $ticket->id,
  140. 'title' => $ticket->title,
  141. 'priority' => $ticket->severity,
  142. 'date' => Carbon::parse($ticket->created_at)->format('d/m/Y'),
  143. 'sector' => $ticket->sector ?: 'Não informado',
  144. 'status' => $ticket->status,
  145. ])
  146. ->all();
  147. }
  148. private function openTicketsQuery(array $unitIds)
  149. {
  150. return DB::table('support_tickets')
  151. ->whereNull('deleted_at')
  152. ->where('status', 'in_progress')
  153. ->when(!empty($unitIds), fn ($q) => $q->where(function ($units) use ($unitIds) {
  154. $units->whereIn('applicant_unit_id', $unitIds)
  155. ->orWhereIn('target_unit_id', $unitIds)
  156. ->orWhereIn('unit_id', $unitIds);
  157. }));
  158. }
  159. private function attendance(array $unitIds, Carbon $startDate, Carbon $endDate): array
  160. {
  161. $rows = DB::table('units as u')
  162. ->leftJoin('classes as c', function ($join) use ($startDate, $endDate) {
  163. $join->on('c.unit_id', '=', 'u.id')
  164. ->whereNull('c.deleted_at')
  165. ->whereBetween('c.date_time_start', [$startDate, $endDate]);
  166. })
  167. ->leftJoin('class_attendances as ca', function ($join) {
  168. $join->on('ca.class_id', '=', 'c.id')->whereNull('ca.deleted_at');
  169. })
  170. ->whereNull('u.deleted_at')
  171. ->when(!empty($unitIds), fn ($q) => $q->whereIn('u.id', $unitIds))
  172. ->groupBy('u.id', 'u.fantasy_name')
  173. ->orderBy('u.fantasy_name')
  174. ->get([
  175. 'u.id',
  176. 'u.fantasy_name',
  177. DB::raw('COUNT(ca.id) as attendance_count'),
  178. DB::raw('SUM(CASE WHEN ca.in_class THEN 1 ELSE 0 END) as present_count'),
  179. ]);
  180. $total = (int) $rows->sum('attendance_count');
  181. $present = (int) $rows->sum('present_count');
  182. return [
  183. 'average' => $total > 0 ? round(($present / $total) * 100, 1) : 0,
  184. 'units' => $rows->map(function ($row) {
  185. $count = (int) $row->attendance_count;
  186. $average = $count > 0
  187. ? round(((int) $row->present_count / $count) * 100, 1)
  188. : 0;
  189. return [
  190. 'id' => (int) $row->id,
  191. 'unit' => $row->fantasy_name,
  192. 'frequency' => $average,
  193. 'status' => $count === 0
  194. ? 'Sem dados'
  195. : ($average >= 70 ? 'Alto' : 'Baixa'),
  196. ];
  197. })->all(),
  198. ];
  199. }
  200. private function pendingTasks(array $unitIds): int
  201. {
  202. return (int) DB::table('kanbans')
  203. ->whereNull('deleted_at')
  204. ->where('phase', '!=', 'concluido')
  205. ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
  206. ->count();
  207. }
  208. /**
  209. * Receita Geral = total das cobranças às franquias (pago + em aberto),
  210. * com a contagem das que ainda estão pendentes.
  211. */
  212. private function generalRevenue(array $unitIds): array
  213. {
  214. $base = DB::table('franchisee_account_receives')
  215. ->whereNull('deleted_at')
  216. ->whereNotNull('unit_id')
  217. ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds));
  218. $value = (float) (clone $base)->sum('value');
  219. $pending = (int) (clone $base)
  220. ->whereNotIn('status', self::SETTLED)
  221. ->count();
  222. return ['value' => $value, 'pending_count' => $pending];
  223. }
  224. private function productStock(): int
  225. {
  226. return (int) DB::table('products')
  227. ->whereNull('deleted_at')
  228. ->sum('quantity');
  229. }
  230. private function products(): array
  231. {
  232. return DB::table('products')
  233. ->whereNull('deleted_at')
  234. ->orderBy('name')
  235. ->get(['id', 'name', 'quantity'])
  236. ->map(function ($product) {
  237. $quantity = (int) $product->quantity;
  238. return [
  239. 'id' => (int) $product->id,
  240. 'name' => $product->name,
  241. 'quantity' => $quantity,
  242. 'status' => $quantity <= 15
  243. ? 'Baixo'
  244. : ($quantity <= 30 ? 'Limitado' : 'Alto'),
  245. ];
  246. })->all();
  247. }
  248. }