Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 600
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SendFinanceReport
0.00% covered (danger)
0.00%
0 / 600
0.00% covered (danger)
0.00%
0 / 11
25122
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 1
812
 getReportMonth
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
132
 getResumenData
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 1
306
 getZenitalSucursalesMap
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
2
 aggregateAllReports
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 buildReportHtml
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
756
 buildMonthlyReportHtml
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 1
650
 buildAllRegionsReportHtml
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 1
1482
 buildCombinedReportHtml
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 buildCombinedAllRegionsReportHtml
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 extractReportBody
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TblCompanies;
6use App\Models\TblFinanceBudgetAnual;
7use App\Models\TblFinanceMonthConfig;
8use App\Models\TblFinancePrevisionAnual;
9use App\Models\TblFinanceReportRecipients;
10use App\Models\TblFinanceReportSemanal;
11use App\Models\TblFinanceSedes;
12use Carbon\Carbon;
13use Illuminate\Console\Command;
14use Illuminate\Support\Facades\DB;
15use Illuminate\Support\Facades\Log;
16
17class SendFinanceReport extends Command
18{
19    protected $signature = 'finance:send-report {--test : Output to console instead of sending email} {--month= : Override month (1-12)} {--type=weekly : Report type: weekly or monthly}';
20
21    protected $description = 'Send the weekly finance report to all active recipients';
22
23    private $months = [
24        1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril',
25        5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto',
26        9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre',
27    ];
28
29    public function handle(): int
30    {
31        $type = $this->option('type') ?? 'weekly';
32        $isTest = $this->option('test');
33
34        // Determine the target month based on report type:
35        // - weekly: current month (month-to-date view, sent every Saturday)
36        // - monthly: previous month closed (sent on WD+5 of the following month)
37        // Manual --month overrides both defaults (used for preview/testing).
38        if ($this->option('month')) {
39            $month = (int) $this->option('month');
40            $year = (int) date('Y');
41        } elseif ($type === 'monthly') {
42            // Previous month closed
43            $prevMonth = Carbon::now()->subMonth();
44            $month = $prevMonth->month;
45            $year = $prevMonth->year;
46        } else {
47            // Current month for weekly MTD
48            $month = (int) date('n');
49            $year = (int) date('Y');
50        }
51
52        $recipients = TblFinanceReportRecipients::where('is_active', 1)->get();
53
54        if ($recipients->isEmpty()) {
55            $this->info('No active recipients found.');
56
57            return 0;
58        }
59
60        $companyIds = $recipients->pluck('company_id')->filter()->unique()->toArray();
61
62        // If any recipient has NULL company_id (all regions), include all finance companies
63        $hasAllRegionRecipient = $recipients->contains(fn ($r) => $r->company_id === null);
64        if ($hasAllRegionRecipient) {
65            $financeCompanyIds = TblFinanceSedes::where('is_active', 1)
66                ->pluck('company_id')
67                ->unique()
68                ->toArray();
69            $companyIds = array_values(array_unique(array_merge($companyIds, $financeCompanyIds)));
70        }
71
72        $reportsByCompany = [];
73        foreach ($companyIds as $companyId) {
74            $company = TblCompanies::find($companyId);
75            if (! $company) {
76                continue;
77            }
78
79            // weekly = MTD (single month). monthly = single month + YTD
80            $monthData = $this->getResumenData($companyId, $year, $month, 'weekly');
81            $ytdData = $type === 'monthly'
82                ? $this->getResumenData($companyId, $year, $month, 'monthly')
83                : null;
84
85            $hasData = !empty($monthData['actuals_ytd']) || !empty($monthData['by_sede'])
86                || ($ytdData && (!empty($ytdData['actuals_ytd']) || !empty($ytdData['by_sede'])));
87            if (!$hasData) {
88                continue;
89            }
90
91            $reportsByCompany[$companyId] = [
92                'company' => $company,
93                'weekly' => $monthData,
94                'monthly' => $ytdData,
95            ];
96        }
97
98        if (empty($reportsByCompany)) {
99            $this->info('No finance data available for current year/month.');
100
101            return 0;
102        }
103
104        // Separate "all regions" recipients (NULL company_id) from company-specific ones
105        $allRegionRecipients = $recipients->filter(fn ($r) => $r->company_id === null);
106        $companyRecipients = $recipients->filter(fn ($r) => $r->company_id !== null);
107
108        $recipientsByCompany = $companyRecipients->groupBy('company_id');
109
110        $sent = 0;
111        foreach ($recipientsByCompany as $companyId => $companyGroupRecipients) {
112            if (! isset($reportsByCompany[$companyId])) {
113                continue;
114            }
115
116            $report = $reportsByCompany[$companyId];
117            if ($type === 'monthly') {
118                $html = $this->buildCombinedReportHtml($report, $year, $month, true);
119                $subject = "Cierre Facturación - {$report['company']->name} - {$this->months[$month]} {$year}";
120            } else {
121                $singleReport = ['company' => $report['company'], 'data' => $report['weekly']];
122                $html = $this->buildReportHtml($singleReport, $year, $month);
123                $subject = "Reporte Semanal - {$report['company']->name} - {$this->months[$month]} {$year}";
124            }
125
126            if ($isTest) {
127                $this->info("--- Report for {$report['company']->name} (recipients: {$companyGroupRecipients->pluck('email')->implode(', ')}) ---");
128                $sent += $companyGroupRecipients->count();
129
130                continue;
131            }
132
133            foreach ($companyGroupRecipients as $recipient) {
134                try {
135                    $sendgrid = new \SendGrid(config('services.sendgrid.api_key'));
136                    $email = new \SendGrid\Mail\Mail;
137                    $email->setFrom('fire@fire.es', 'Fire Service Titan');
138                    $email->setSubject($subject);
139                    $email->addTo($recipient->email, $recipient->name);
140                    $email->addContent('text/html', $html);
141
142                    $response = $sendgrid->send($email);
143
144                    if ($response->statusCode() == 202) {
145                        $sent++;
146                        $this->info("Sent to {$recipient->email}");
147                    } else {
148                        $this->error("Failed to send to {$recipient->email}".$response->body());
149                    }
150                } catch (\Exception $e) {
151                    $this->error("Error sending to {$recipient->email}".$e->getMessage());
152                    Log::channel('third-party')->error("finance:send-report — Error sending to {$recipient->email}".$e->getMessage());
153                }
154            }
155        }
156
157        // Send aggregated report to "all regions" recipients
158        if ($allRegionRecipients->isNotEmpty() && ! empty($reportsByCompany)) {
159            $aggregatedData = $this->aggregateAllReports($reportsByCompany, $type);
160            $aggregatedReport = ['company' => (object) ['name' => 'Grupo Fire (Todas las regiones)'], 'data' => $aggregatedData];
161
162            if ($type === 'monthly') {
163                $aggregatedHtml = $this->buildCombinedAllRegionsReportHtml($aggregatedReport, $year, $month, true);
164                $aggregatedSubject = "Cierre Facturación - Grupo Fire - {$this->months[$month]} {$year}";
165            } else {
166                $singleAgg = ['company' => $aggregatedReport['company'], 'data' => $aggregatedData['weekly']];
167                $aggregatedHtml = $this->buildAllRegionsReportHtml($singleAgg, $year, $month, 'Report Semanal');
168                $aggregatedSubject = "Reporte Semanal - Grupo Fire - {$this->months[$month]} {$year}";
169            }
170
171            if ($isTest) {
172                $this->info('--- Aggregated Report for Grupo Fire (Todas las regiones) ---');
173                $this->info('Recipients: '.$allRegionRecipients->pluck('email')->implode(', '));
174                $this->line($aggregatedHtml);
175                $sent += $allRegionRecipients->count();
176            } else {
177                foreach ($allRegionRecipients as $recipient) {
178                    try {
179                        $sendgrid = new \SendGrid(config('services.sendgrid.api_key'));
180                        $email = new \SendGrid\Mail\Mail;
181                        $email->setFrom('fire@fire.es', 'Fire Service Titan');
182                        $email->setSubject($aggregatedSubject);
183                        $email->addTo($recipient->email, $recipient->name);
184                        $email->addContent('text/html', $aggregatedHtml);
185
186                        $response = $sendgrid->send($email);
187
188                        if ($response->statusCode() == 202) {
189                            $sent++;
190                            $this->info("Sent to {$recipient->email}");
191                        } else {
192                            $this->error("Failed to send to {$recipient->email}".$response->body());
193                        }
194                    } catch (\Exception $e) {
195                        $this->error("Error sending to {$recipient->email}".$e->getMessage());
196                        Log::channel('third-party')->error("finance:send-report — Error sending to {$recipient->email}".$e->getMessage());
197                    }
198                }
199            }
200        }
201
202        $this->info("Finance report sent to {$sent} recipients.");
203        Log::channel('third-party')->info("finance:send-report — Sent to {$sent} recipients.");
204
205        return 0;
206    }
207
208    /**
209     * Determine which month to report on.
210     *
211     * Priority:
212     * 1. force_report_month override from tbl_finance_month_config (current month row)
213     * 2. Previous month's close_date — if today <= close_date, still report previous month
214     * 3. Default WD+5 logic: if <= 5 working days have passed, report on previous month
215     */
216    private function getReportMonth(): int
217    {
218        $today = Carbon::now();
219        $year = $today->year;
220        $currentMonth = $today->month;
221
222        // Check for force override on current month
223        $config = TblFinanceMonthConfig::where('year', $year)
224            ->where('month', $currentMonth)
225            ->first();
226
227        if ($config && $config->force_report_month) {
228            return (int) $config->force_report_month;
229        }
230
231        // Check previous month's close_date
232        $prevMonth = $currentMonth === 1 ? 12 : $currentMonth - 1;
233        $prevYear = $currentMonth === 1 ? $year - 1 : $year;
234        $prevConfig = TblFinanceMonthConfig::where('year', $prevYear)
235            ->where('month', $prevMonth)
236            ->first();
237
238        if ($prevConfig && $prevConfig->close_date) {
239            // If today is before the close date, still report on previous month
240            if ($today->lte(Carbon::parse($prevConfig->close_date))) {
241                return $prevMonth;
242            }
243
244            return $currentMonth;
245        }
246
247        // Default: WD+5 logic
248        $workingDays = 0;
249        for ($day = 1; $day <= $today->day; $day++) {
250            $date = Carbon::create($today->year, $currentMonth, $day);
251            if ($date->isWeekday()) {
252                $workingDays++;
253            }
254        }
255
256        return $workingDays <= 5 ? $prevMonth : $currentMonth;
257    }
258
259    /**
260     * Fetch resumen data from Zenital + MySQL (same logic as FinanceController::list_resumen).
261     */
262    private function getResumenData(int $companyId, int $year, int $month, string $type = 'monthly'): array
263    {
264        $isWeekly = $type === 'weekly';
265
266        // Build Zenital sede map for this company, translating to local tbl_finance_sedes IDs
267        $zenitalMap = $this->getZenitalSucursalesMap();
268        $localSedes = TblFinanceSedes::where('company_id', $companyId)
269            ->where('is_active', 1)
270            ->pluck('id', 'name')
271            ->toArray();
272
273        $zenitalToSede = [];
274        foreach ($zenitalMap as $zenitalId => $info) {
275            if ($info['company_id'] === $companyId) {
276                // Map to local sede ID by name match, fallback to Zenital sede_id
277                $zenitalToSede[$zenitalId] = $localSedes[$info['nombre']] ?? $info['sede_id'];
278            }
279        }
280
281        // Query Zenital for actuals (current year + n-1)
282        // Weekly = current month only; Monthly = year-to-date
283        $idx = [];
284        if (! empty($zenitalToSede)) {
285            try {
286                $query = DB::connection('zenital')
287                    ->table('fact_facturacion as f')
288                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
289                    ->whereIn('f.sk_sucursal', array_keys($zenitalToSede))
290                    ->whereIn('d.ano', [$year, $year - 1]);
291
292                if ($isWeekly) {
293                    $query->where('d.num_mes', '=', $month);
294                } else {
295                    $query->where('d.num_mes', '<=', $month);
296                }
297
298                $facturacion = $query
299                    ->selectRaw('f.sk_sucursal, d.ano, SUM(f.base_imponible) as total')
300                    ->groupBy('f.sk_sucursal', 'd.ano')
301                    ->get();
302
303                foreach ($facturacion as $row) {
304                    $sedeId = $zenitalToSede[$row->sk_sucursal] ?? $row->sk_sucursal;
305                    $idx[$sedeId][$row->ano] = ($idx[$sedeId][$row->ano] ?? 0) + (float) $row->total;
306                }
307            } catch (\Exception $e) {
308                Log::channel('third-party')->warning("finance:send-report — Zenital query failed: {$e->getMessage()}");
309            }
310        }
311
312        // Budget from MySQL — weekly = current month only; monthly = YTD
313        $budgetQuery = TblFinanceBudgetAnual::where('company_id', $companyId)
314            ->where('year', $year);
315        if ($isWeekly) {
316            $budgetQuery->where('month', '=', $month);
317        } else {
318            $budgetQuery->where('month', '<=', $month);
319        }
320        $budgetBySede = $budgetQuery->get()
321            ->groupBy('sede_id')
322            ->map(fn ($rows) => (float) $rows->sum('amount'));
323
324        // Prevision from MySQL — weekly = current month only; monthly = YTD
325        $previsionQuery = TblFinancePrevisionAnual::where('company_id', $companyId)
326            ->where('year', $year);
327        if ($isWeekly) {
328            $previsionQuery->where('month', '=', $month);
329        } else {
330            $previsionQuery->where('month', '<=', $month);
331        }
332        $previsionBySede = $previsionQuery->get()
333            ->groupBy('sede_id')
334            ->map(fn ($rows) => (float) $rows->sum('amount'));
335
336        // Google-Drive-imported actuals for sedes outside Zenital (Aeroextinción,
337        // Cano Lopera, Cuenfa, Extincas, etc). Aggregated from tbl_finance_report_semanal,
338        // matches the Reporte Semanal UI. Local key space is sede_id (not sk_sucursal)
339        // so it doesn't collide with the Zenital-keyed $idx above.
340        $localActualsQuery = TblFinanceReportSemanal::where('company_id', $companyId)
341            ->whereIn('year', [$year, $year - 1]);
342        if ($isWeekly) {
343            $localActualsQuery->where('month', '=', $month);
344        } else {
345            $localActualsQuery->where('month', '<=', $month);
346        }
347        $localBySede = [];
348        foreach ($localActualsQuery->get() as $row) {
349            if ($row->actuals === null) {
350                continue;
351            }
352            $localBySede[$row->sede_id][(int) $row->year] = ($localBySede[$row->sede_id][(int) $row->year] ?? 0) + (float) $row->actuals;
353        }
354
355        // Merge all sede_ids
356        $allSedeIds = array_unique(array_merge(
357            array_keys($idx),
358            $budgetBySede->keys()->toArray(),
359            $previsionBySede->keys()->toArray(),
360            array_keys($localBySede)
361        ));
362
363        $actualsYtd = 0;
364        $actualsN1Ytd = 0;
365        $budgetYtd = 0;
366        $previsionYtd = 0;
367        $bySede = [];
368
369        foreach ($allSedeIds as $sedeId) {
370            // Zenital data is keyed by sk_sucursal; Drive-imported data is keyed
371            // by sede_id. Fall back to the Drive value when Zenital has nothing
372            // for this sede — Zenital is authoritative when present.
373            $actuals = $idx[$sedeId][$year] ?? $localBySede[$sedeId][$year] ?? 0;
374            $n1 = $idx[$sedeId][$year - 1] ?? $localBySede[$sedeId][$year - 1] ?? 0;
375            $budget = $budgetBySede[$sedeId] ?? 0;
376            $prevision = $previsionBySede[$sedeId] ?? 0;
377
378            if ($actuals == 0 && $n1 == 0 && $budget == 0 && $prevision == 0) {
379                continue;
380            }
381
382            $actualsYtd += $actuals;
383            $actualsN1Ytd += $n1;
384            $budgetYtd += $budget;
385            $previsionYtd += $prevision;
386
387            $bySede[$sedeId] = [
388                'actuals' => $actuals,
389                'actuals_n1' => $n1,
390                'budget' => $budget,
391                'prevision' => $prevision,
392            ];
393        }
394
395        return [
396            'actuals_ytd' => $actualsYtd,
397            'actuals_n1_ytd' => $actualsN1Ytd,
398            'budget_ytd' => $budgetYtd,
399            'prevision_ytd' => $previsionYtd,
400            'by_sede' => $bySede,
401        ];
402    }
403
404    /**
405     * Same Zenital map as FinanceController — must stay in sync.
406     */
407    private function getZenitalSucursalesMap(): array
408    {
409        return [
410            // Cataluña (company_id = 19)
411            59 => ['nombre' => 'Extintores Clemente',  'company_id' => 19, 'sede_id' => 111],
412            73 => ['nombre' => 'Josmafoc',             'company_id' => 19, 'sede_id' => 112],
413            72 => ['nombre' => 'Ingesfoc',             'company_id' => 19, 'sede_id' => 113],
414            84 => ['nombre' => 'Sat Valles',           'company_id' => 19, 'sede_id' => 114],
415            78 => ['nombre' => 'NioExtin',             'company_id' => 19, 'sede_id' => 115],
416            51 => ['nombre' => 'Cisemex',              'company_id' => 19, 'sede_id' => 116],
417            67 => ['nombre' => 'Grupo Fire MOF',       'company_id' => 19, 'sede_id' => 119],
418            46 => ['nombre' => 'Master Centella',      'company_id' => 19, 'sede_id' => 120],
419            60 => ['nombre' => 'Gallex',               'company_id' => 19, 'sede_id' => 121],
420            57 => ['nombre' => 'Externo MOF',          'company_id' => 19, 'sede_id' => 122],
421            62 => ['nombre' => 'Grupo Fire Cataluña',  'company_id' => 19, 'sede_id' => 125],
422            74 => ['nombre' => 'Lluis_Moff',           'company_id' => 19, 'sede_id' => 4],
423            // La Mancha (company_id = 22)
424            49 => ['nombre' => 'Alcarrena',              'company_id' => 22, 'sede_id' => 14],
425
426            // Madrid (company_id = 18) — sede_ids match actual DB ids
427            50 => ['nombre' => 'Anin',                   'company_id' => 18, 'sede_id' => 5004],
428            54 => ['nombre' => 'EnFire',                 'company_id' => 18, 'sede_id' => 5001],
429            55 => ['nombre' => 'ExConin',                'company_id' => 18, 'sede_id' => 12],
430            56 => ['nombre' => 'ExFire',                 'company_id' => 18, 'sede_id' => 5003],
431            66 => ['nombre' => 'Grupo Fire Guadalajara', 'company_id' => 18, 'sede_id' => 15],
432            68 => ['nombre' => 'Grupo Fire Madrid',      'company_id' => 18, 'sede_id' => 13],
433            71 => ['nombre' => 'ICF',                    'company_id' => 18, 'sede_id' => 22],
434            76 => ['nombre' => 'Montoya',                'company_id' => 18, 'sede_id' => 5002],
435            80 => ['nombre' => 'Precoin',                'company_id' => 18, 'sede_id' => 5000],
436            83 => ['nombre' => 'Rosegur',                'company_id' => 18, 'sede_id' => 11],
437            87 => ['nombre' => 'Segurtrex',              'company_id' => 18, 'sede_id' => 17],
438            // Valencia (company_id = 30) — sede_ids match actual DB ids
439            45 => ['nombre' => 'Guipons',      'company_id' => 30, 'sede_id' => 23],
440            47 => ['nombre' => 'AirFeu',       'company_id' => 30, 'sede_id' => 24],
441            58 => ['nombre' => 'Extinfuego',   'company_id' => 30, 'sede_id' => 58],
442            90 => ['nombre' => 'Vivó',         'company_id' => 30, 'sede_id' => 25],
443            // Andalucía (company_id = 21)
444            53 => ['nombre' => 'Drago',              'company_id' => 21, 'sede_id' => 16],
445            61 => ['nombre' => 'Grupo Fire Almeria', 'company_id' => 21, 'sede_id' => 201],
446            81 => ['nombre' => 'Robles',             'company_id' => 21, 'sede_id' => 202],
447            82 => ['nombre' => 'Robles Legacy',      'company_id' => 21, 'sede_id' => 202],
448            // Castilla y León (company_id = 23)
449            52 => ['nombre' => 'Crespo',  'company_id' => 23, 'sede_id' => 1000010],
450            88 => ['nombre' => 'Togasa',  'company_id' => 23, 'sede_id' => 999],
451            // Aragón (company_id = 9)
452            79 => ['nombre' => 'Oasys', 'company_id' => 9, 'sede_id' => 1000030],
453            // Baleares (company_id = 33)
454            77 => ['nombre' => 'Ni Foc Ni Fum', 'company_id' => 33, 'sede_id' => 402],
455            85 => ['nombre' => 'SeguCor',        'company_id' => 33, 'sede_id' => 401],
456            86 => ['nombre' => 'SeguCor Legacy', 'company_id' => 33, 'sede_id' => 401],
457        ];
458    }
459
460    private function aggregateAllReports(array $reportsByCompany, string $type = 'weekly'): array
461    {
462        $sections = $type === 'monthly' ? ['weekly', 'monthly'] : ['weekly'];
463        $result = ['weekly' => [], 'monthly' => []];
464
465        foreach ($sections as $section) {
466            $actualsYtd = 0;
467            $actualsN1Ytd = 0;
468            $budgetYtd = 0;
469            $previsionYtd = 0;
470            $byRegion = [];
471
472            foreach ($reportsByCompany as $companyId => $report) {
473                $data = $report[$section];
474                if (!$data) {
475                    continue;
476                }
477                $actualsYtd += $data['actuals_ytd'];
478                $actualsN1Ytd += $data['actuals_n1_ytd'];
479                $budgetYtd += $data['budget_ytd'];
480                $previsionYtd += $data['prevision_ytd'];
481
482                $byRegion[$companyId] = [
483                    'name' => $report['company']->name,
484                    'actuals' => $data['actuals_ytd'],
485                    'actuals_n1' => $data['actuals_n1_ytd'],
486                    'budget' => $data['budget_ytd'],
487                    'prevision' => $data['prevision_ytd'],
488                ];
489            }
490
491            $result[$section] = [
492                'actuals_ytd' => $actualsYtd,
493                'actuals_n1_ytd' => $actualsN1Ytd,
494                'budget_ytd' => $budgetYtd,
495                'prevision_ytd' => $previsionYtd,
496                'by_region' => $byRegion,
497            ];
498        }
499
500        return $result;
501    }
502
503    private function buildReportHtml(array $report, int $year, int $month, bool $isClosed = false): string
504    {
505        $company = $report['company'];
506        $data = $report['data'];
507        $monthName = $this->months[$month];
508        $monthSubtitle = $isClosed ? 'cierre' : 'mes en curso';
509        $introText = $isClosed
510            ? "Os comparto el cierre de facturación del mes de <strong>{$monthName} {$year}</strong>"
511            : "Os comparto el resumen de la facturación del mes de <strong>{$monthName}</strong> hasta la fecha";
512
513        $actualsYtd = $data['actuals_ytd'];
514        $actualsN1Ytd = $data['actuals_n1_ytd'];
515        $budgetYtd = $data['budget_ytd'];
516        $previsionYtd = $data['prevision_ytd'];
517
518        $diffN1 = $actualsYtd - $actualsN1Ytd;
519        $pctN1 = $actualsN1Ytd != 0 ? ($diffN1 / $actualsN1Ytd) * 100 : 0;
520        $diffBudget = $actualsYtd - $budgetYtd;
521        $pctBudget = $budgetYtd != 0 ? ($diffBudget / $budgetYtd) * 100 : 0;
522        $diffPrevision = $actualsYtd - $previsionYtd;
523        $pctPrevision = $previsionYtd != 0 ? ($diffPrevision / $previsionYtd) * 100 : 0;
524
525        $signN1 = $diffN1 >= 0 ? '+' : '';
526        $signBudget = $diffBudget >= 0 ? '+' : '';
527        $signPrevision = $diffPrevision >= 0 ? '+' : '';
528
529        $colorN1 = $diffN1 >= 0 ? '#34c38f' : '#f46a6a';
530        $colorBudget = $diffBudget >= 0 ? '#34c38f' : '#f46a6a';
531        $colorPrevision = $diffPrevision >= 0 ? '#34c38f' : '#f46a6a';
532
533        $fmt = fn ($v) => number_format($v, 2, ',', '.');
534        $fmtPct = fn ($v) => number_format($v, 1, ',', '.');
535
536        // Load sede names
537        $sedeNames = TblFinanceSedes::pluck('name', 'id')->toArray();
538        // Also map Zenital sede_ids to names
539        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
540            $sedeNames[$info['sede_id']] = $sedeNames[$info['sede_id']] ?? $info['nombre'];
541            $sedeNames[$zenitalId] = $info['nombre'];
542        }
543
544        $html = "
545        <div style='font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; color: #333;'>
546            <p>Hola a todos,</p>
547            <p>{$introText}</p>
548
549            <div style='background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;'>
550                <h3 style='margin: 0 0 12px 0; color: #495057;'>Facturación {$monthName} {$year} ({$monthSubtitle})</h3>
551                <ul style='list-style: none; padding: 0; margin: 0; line-height: 2;'>
552                    <li>
553                        <strong>Facturación {$monthName} {$year}:</strong>
554                        <span style='font-size: 16px; font-weight: bold;'>{$fmt($actualsYtd)}&nbsp;€</span>
555                    </li>
556                    <li>
557                        vs. {$monthName} ".($year - 1).":
558                        <span style='color: {$colorN1}; font-weight: bold;'>{$signN1}{$fmt($diffN1)}&nbsp;€ ({$signN1}{$fmtPct($pctN1)}%)</span>
559                    </li>
560                    <li>
561                        vs. Budget {$monthName} {$year}:
562                        <span style='color: {$colorBudget}; font-weight: bold;'>{$signBudget}{$fmt($diffBudget)}&nbsp;€ ({$signBudget}{$fmtPct($pctBudget)}%)</span>
563                        respecto al objetivo
564                    </li>
565                    <li>
566                        vs. Previsión {$monthName} {$year}:
567                        <span style='color: {$colorPrevision}; font-weight: bold;'>{$signPrevision}{$fmt($diffPrevision)}&nbsp;€ ({$signPrevision}{$fmtPct($pctPrevision)}%)</span>
568                        respecto al objetivo
569                    </li>
570                </ul>
571            </div>
572
573            <h3 style='color: #495057; margin-top: 30px;'>Detalle por sede — Report Semanal</h3>
574            <table style='border-collapse: collapse; width: 100%; font-size: 13px;'>
575                <thead>
576                    <tr style='background-color: #f8f9fa;'>
577                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: left;'>Sede</th>
578                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Actuals</th>
579                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>n-1</th>
580                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Budget</th>
581                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Previsión</th>
582                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs n-1</th>
583                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Budget</th>
584                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Previsión</th>
585                    </tr>
586                </thead>
587                <tbody>";
588
589        $bySede = $data['by_sede'];
590        uasort($bySede, fn($a, $b) => ($b['actuals'] ?? 0) <=> ($a['actuals'] ?? 0) ?: ($b['budget'] ?? 0) <=> ($a['budget'] ?? 0));
591
592        foreach ($bySede as $sedeId => $vals) {
593            $sedeName = $sedeNames[$sedeId] ?? "Sede {$sedeId}";
594            $sedeActuals = $vals['actuals'];
595            $sedeN1 = $vals['actuals_n1'];
596            $sedeBudget = $vals['budget'];
597            $sedePrevision = $vals['prevision'] ?? 0;
598
599            $sedeDiffN1 = $sedeActuals - $sedeN1;
600            $sedeDiffBudget = $sedeActuals - $sedeBudget;
601            $sedeDiffPrevision = $sedeActuals - $sedePrevision;
602
603            $colorSN1 = $sedeDiffN1 >= 0 ? '#34c38f' : '#f46a6a';
604            $colorSB = $sedeDiffBudget >= 0 ? '#34c38f' : '#f46a6a';
605            $colorSP = $sedeDiffPrevision >= 0 ? '#34c38f' : '#f46a6a';
606            $signSN1 = $sedeDiffN1 >= 0 ? '+' : '';
607            $signSB = $sedeDiffBudget >= 0 ? '+' : '';
608            $signSP = $sedeDiffPrevision >= 0 ? '+' : '';
609
610            $html .= "<tr>
611                <td style='border: 1px solid #dee2e6; padding: 6px 8px;'>{$sedeName}</td>
612                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeActuals)}&nbsp;€</td>
613                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeN1)}&nbsp;€</td>
614                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeBudget)}&nbsp;€</td>
615                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedePrevision)}&nbsp;€</td>
616                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSN1};'>{$signSN1}{$fmt($sedeDiffN1)}&nbsp;€</td>
617                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSB};'>{$signSB}{$fmt($sedeDiffBudget)}&nbsp;€</td>
618                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSP};'>{$signSP}{$fmt($sedeDiffPrevision)}&nbsp;€</td>
619            </tr>";
620        }
621
622        // Grand total row
623        $totalDiffN1 = $actualsYtd - $actualsN1Ytd;
624        $totalDiffBudget = $actualsYtd - $budgetYtd;
625        $totalDiffPrevision = $actualsYtd - $previsionYtd;
626
627        $html .= "<tr style='background-color: #fff3cd; font-weight: bold;'>
628            <td style='border: 1px solid #dee2e6; padding: 8px;'>TOTAL</td>
629            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsYtd)}&nbsp;€</td>
630            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsN1Ytd)}&nbsp;€</td>
631            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($budgetYtd)}&nbsp;€</td>
632            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($previsionYtd)}&nbsp;€</td>
633            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffN1 >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffN1 >= 0 ? '+' : '')."{$fmt($totalDiffN1)}&nbsp;€</td>
634            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffBudget >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffBudget >= 0 ? '+' : '')."{$fmt($totalDiffBudget)}&nbsp;€</td>
635            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffPrevision >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffPrevision >= 0 ? '+' : '')."{$fmt($totalDiffPrevision)}&nbsp;€</td>
636        </tr>";
637
638        $html .= '</tbody></table>';
639        $html .= "<p style='margin-top: 20px;'><a href='https://fireservicetitan.com/finance' style='color: #556ee6;'>Podéis ver el detalle de negocio en el siguiente enlace</a></p>";
640        $html .= "<p style='color: #999; font-size: 11px; margin-top: 20px;'>Generado automáticamente por Fire Service Titan el ".Carbon::now()->format('d/m/Y H:i').'</p>';
641        $html .= '</div>';
642
643        return $html;
644    }
645
646    private function buildMonthlyReportHtml(array $report, int $year, int $month): string
647    {
648        $company = $report['company'];
649        $data = $report['data'];
650        $monthName = $this->months[$month];
651
652        $actualsYtd = $data['actuals_ytd'];
653        $actualsN1Ytd = $data['actuals_n1_ytd'];
654        $budgetYtd = $data['budget_ytd'];
655        $previsionYtd = $data['prevision_ytd'];
656
657        $diffN1 = $actualsYtd - $actualsN1Ytd;
658        $pctN1 = $actualsN1Ytd != 0 ? ($diffN1 / $actualsN1Ytd) * 100 : 0;
659        $diffBudget = $actualsYtd - $budgetYtd;
660        $pctBudget = $budgetYtd != 0 ? ($diffBudget / $budgetYtd) * 100 : 0;
661        $diffPrevision = $actualsYtd - $previsionYtd;
662        $pctPrevision = $previsionYtd != 0 ? ($diffPrevision / $previsionYtd) * 100 : 0;
663
664        $signN1 = $diffN1 >= 0 ? '+' : '';
665        $signBudget = $diffBudget >= 0 ? '+' : '';
666        $signPrevision = $diffPrevision >= 0 ? '+' : '';
667
668        $colorN1 = $diffN1 >= 0 ? '#34c38f' : '#f46a6a';
669        $colorBudget = $diffBudget >= 0 ? '#34c38f' : '#f46a6a';
670        $colorPrevision = $diffPrevision >= 0 ? '#34c38f' : '#f46a6a';
671
672        $fmt = fn ($v) => number_format($v, 2, ',', '.');
673        $fmtPct = fn ($v) => number_format($v, 1, ',', '.');
674
675        // Load sede names
676        $sedeNames = TblFinanceSedes::pluck('name', 'id')->toArray();
677        // Also map Zenital sede_ids to names
678        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
679            $sedeNames[$info['sede_id']] = $sedeNames[$info['sede_id']] ?? $info['nombre'];
680            $sedeNames[$zenitalId] = $info['nombre'];
681        }
682
683        // Part 1: Monthly = YTD global photo
684        $html = "
685        <div style='font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; color: #333;'>
686            <p>Hola a todos,</p>
687            <p>Os comparto el resumen global de facturación acumulada hasta <strong>{$monthName} {$year}</strong></p>
688
689            <div style='background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;'>
690                <h3 style='margin: 0 0 12px 0; color: #495057;'>Acumulado Enero – {$monthName} {$year} (YTD)</h3>
691                <ul style='list-style: none; padding: 0; margin: 0; line-height: 2;'>
692                    <li>
693                        <strong>Facturación total acumulada {$monthName} {$year}:</strong>
694                        <span style='font-size: 16px; font-weight: bold;'>{$fmt($actualsYtd)}&nbsp;€</span>
695                    </li>
696                    <li>
697                        vs. Acumulado Enero – {$monthName} ".($year - 1).":
698                        <span style='color: {$colorN1}; font-weight: bold;'>{$signN1}{$fmt($diffN1)}&nbsp;€ ({$signN1}{$fmtPct($pctN1)}%)</span>
699                    </li>
700                    <li>
701                        vs. Budget {$monthName} {$year}:
702                        <span style='color: {$colorBudget}; font-weight: bold;'>{$signBudget}{$fmt($diffBudget)}&nbsp;€ ({$signBudget}{$fmtPct($pctBudget)}%)</span>
703                        respecto al objetivo
704                    </li>
705                    <li>
706                        vs. Previsión {$monthName} {$year}:
707                        <span style='color: {$colorPrevision}; font-weight: bold;'>{$signPrevision}{$fmt($diffPrevision)}&nbsp;€ ({$signPrevision}{$fmtPct($pctPrevision)}%)</span>
708                        respecto al objetivo
709                    </li>
710                </ul>
711            </div>
712
713            <h3 style='color: #495057; margin-top: 30px;'>Detalle por sede — Cierre Mensual</h3>
714            <table style='border-collapse: collapse; width: 100%; font-size: 13px;'>
715                <thead>
716                    <tr style='background-color: #f8f9fa;'>
717                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: left;'>Sede</th>
718                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Actuals</th>
719                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>n-1</th>
720                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Budget</th>
721                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Previsión</th>
722                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs n-1</th>
723                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Budget</th>
724                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Previsión</th>
725                    </tr>
726                </thead>
727                <tbody>";
728
729        $bySede = $data['by_sede'];
730        uasort($bySede, fn($a, $b) => ($b['actuals'] ?? 0) <=> ($a['actuals'] ?? 0) ?: ($b['budget'] ?? 0) <=> ($a['budget'] ?? 0));
731
732        foreach ($bySede as $sedeId => $vals) {
733            $sedeName = $sedeNames[$sedeId] ?? "Sede {$sedeId}";
734            $sedeActuals = $vals['actuals'];
735            $sedeN1 = $vals['actuals_n1'];
736            $sedeBudget = $vals['budget'];
737            $sedePrevision = $vals['prevision'] ?? 0;
738
739            $sedeDiffN1 = $sedeActuals - $sedeN1;
740            $sedeDiffBudget = $sedeActuals - $sedeBudget;
741            $sedeDiffPrevision = $sedeActuals - $sedePrevision;
742
743            $colorSN1 = $sedeDiffN1 >= 0 ? '#34c38f' : '#f46a6a';
744            $colorSB = $sedeDiffBudget >= 0 ? '#34c38f' : '#f46a6a';
745            $colorSP = $sedeDiffPrevision >= 0 ? '#34c38f' : '#f46a6a';
746            $signSN1 = $sedeDiffN1 >= 0 ? '+' : '';
747            $signSB = $sedeDiffBudget >= 0 ? '+' : '';
748            $signSP = $sedeDiffPrevision >= 0 ? '+' : '';
749
750            $html .= "<tr>
751                <td style='border: 1px solid #dee2e6; padding: 6px 8px;'>{$sedeName}</td>
752                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeActuals)}&nbsp;€</td>
753                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeN1)}&nbsp;€</td>
754                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeBudget)}&nbsp;€</td>
755                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedePrevision)}&nbsp;€</td>
756                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSN1};'>{$signSN1}{$fmt($sedeDiffN1)}&nbsp;€</td>
757                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSB};'>{$signSB}{$fmt($sedeDiffBudget)}&nbsp;€</td>
758                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSP};'>{$signSP}{$fmt($sedeDiffPrevision)}&nbsp;€</td>
759            </tr>";
760        }
761
762        // Grand total row
763        $totalDiffN1 = $actualsYtd - $actualsN1Ytd;
764        $totalDiffBudget = $actualsYtd - $budgetYtd;
765        $totalDiffPrevision = $actualsYtd - $previsionYtd;
766
767        $html .= "<tr style='background-color: #fff3cd; font-weight: bold;'>
768            <td style='border: 1px solid #dee2e6; padding: 8px;'>TOTAL</td>
769            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsYtd)}&nbsp;€</td>
770            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsN1Ytd)}&nbsp;€</td>
771            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($budgetYtd)}&nbsp;€</td>
772            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($previsionYtd)}&nbsp;€</td>
773            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffN1 >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffN1 >= 0 ? '+' : '')."{$fmt($totalDiffN1)}&nbsp;€</td>
774            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffBudget >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffBudget >= 0 ? '+' : '')."{$fmt($totalDiffBudget)}&nbsp;€</td>
775            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffPrevision >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffPrevision >= 0 ? '+' : '')."{$fmt($totalDiffPrevision)}&nbsp;€</td>
776        </tr>";
777
778        $html .= '</tbody></table>';
779        $html .= "<p style='margin-top: 20px;'><a href='https://fireservicetitan.com/finance' style='color: #556ee6;'>Podéis ver el detalle de negocio en el siguiente enlace</a></p>";
780        $html .= "<p style='color: #999; font-size: 11px; margin-top: 20px;'>Generado automáticamente por Fire Service Titan el ".Carbon::now()->format('d/m/Y H:i').'</p>';
781        $html .= '</div>';
782
783        return $html;
784    }
785
786    private function buildAllRegionsReportHtml(array $report, int $year, int $month, string $reportLabel, bool $isClosed = false): string
787    {
788        $isMonthly = $reportLabel === 'Cierre Mensual';
789        $data = $report['data'];
790        $monthName = $this->months[$month];
791
792        $actualsYtd = $data['actuals_ytd'];
793        $actualsN1Ytd = $data['actuals_n1_ytd'];
794        $budgetYtd = $data['budget_ytd'];
795        $previsionYtd = $data['prevision_ytd'];
796
797        $diffN1 = $actualsYtd - $actualsN1Ytd;
798        $pctN1 = $actualsN1Ytd != 0 ? ($diffN1 / $actualsN1Ytd) * 100 : 0;
799        $diffBudget = $actualsYtd - $budgetYtd;
800        $pctBudget = $budgetYtd != 0 ? ($diffBudget / $budgetYtd) * 100 : 0;
801        $diffPrevision = $actualsYtd - $previsionYtd;
802        $pctPrevision = $previsionYtd != 0 ? ($diffPrevision / $previsionYtd) * 100 : 0;
803
804        $signN1 = $diffN1 >= 0 ? '+' : '';
805        $signBudget = $diffBudget >= 0 ? '+' : '';
806        $signPrevision = $diffPrevision >= 0 ? '+' : '';
807
808        $colorN1 = $diffN1 >= 0 ? '#34c38f' : '#f46a6a';
809        $colorBudget = $diffBudget >= 0 ? '#34c38f' : '#f46a6a';
810        $colorPrevision = $diffPrevision >= 0 ? '#34c38f' : '#f46a6a';
811
812        $fmt = fn ($v) => number_format($v, 2, ',', '.');
813        $fmtPct = fn ($v) => number_format($v, 1, ',', '.');
814
815        $introText = $isMonthly
816            ? "Os comparto el resumen global de facturación acumulada hasta <strong>{$monthName} {$year}</strong> — Todas las regiones"
817            : ($isClosed
818                ? "Os comparto el cierre de facturación del mes de <strong>{$monthName} {$year}</strong> — Todas las regiones"
819                : "Os comparto el resumen de la facturación del mes de <strong>{$monthName}</strong> hasta la fecha — Todas las regiones");
820        $ytdLabel = $isMonthly
821            ? "Acumulado Enero – {$monthName} {$year} (YTD)"
822            : "Facturación {$monthName} {$year} (" . ($isClosed ? 'cierre' : 'mes en curso') . ")";
823        $vsN1Label = $isMonthly
824            ? "vs. Acumulado Enero – {$monthName} ".($year - 1)
825            : "vs. {$monthName} ".($year - 1);
826        $totalLabel = $isMonthly
827            ? "Facturación total acumulada {$monthName} {$year}"
828            : "Facturación {$monthName} {$year}";
829
830        $html = "
831        <div style='font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; color: #333;'>
832            <p>Hola a todos,</p>
833            <p>{$introText}</p>
834
835            <div style='background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;'>
836                <h3 style='margin: 0 0 12px 0; color: #495057;'>{$ytdLabel}</h3>
837                <ul style='list-style: none; padding: 0; margin: 0; line-height: 2;'>
838                    <li>
839                        <strong>{$totalLabel}:</strong>
840                        <span style='font-size: 16px; font-weight: bold;'>{$fmt($actualsYtd)}&nbsp;€</span>
841                    </li>
842                    <li>
843                        {$vsN1Label}:
844                        " . ($actualsN1Ytd == 0
845                            ? "<span style='color: #999;'>—</span>"
846                            : "<span style='color: {$colorN1}; font-weight: bold;'>" . ($diffN1 >= 0 ? '▲' : '▼') . " {$signN1}{$fmtPct($pctN1)}%</span>") . "
847                    </li>
848                    <li>
849                        vs. Budget {$monthName} {$year}:
850                        " . ($budgetYtd == 0
851                            ? "<span style='color: #999;'>—</span>"
852                            : "<span style='color: {$colorBudget}; font-weight: bold;'>" . ($diffBudget >= 0 ? '▲' : '▼') . " {$signBudget}{$fmtPct($pctBudget)}%</span>") . "
853                    </li>";
854
855        // vs Previsión only shown in the weekly (non-monthly) section
856        if (!$isMonthly) {
857            $html .= "
858                    <li>
859                        vs. Previsión {$monthName} {$year}:
860                        " . ($previsionYtd == 0
861                            ? "<span style='color: #999;'>—</span>"
862                            : "<span style='color: {$colorPrevision}; font-weight: bold;'>" . ($diffPrevision >= 0 ? '▲' : '▼') . " {$signPrevision}{$fmtPct($pctPrevision)}%</span>") . "
863                    </li>";
864        }
865
866        $html .= "
867                </ul>
868            </div>
869
870            <h3 style='color: #495057; margin-top: 30px;'>Detalle por región — {$reportLabel}</h3>
871            <table style='border-collapse: collapse; width: 100%; font-size: 13px;'>
872                <thead>
873                    <tr style='background-color: #f8f9fa;'>
874                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: left;'>Región</th>
875                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Actuals</th>
876                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>n-1</th>
877                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Budget</th>" .
878                        (!$isMonthly ? "<th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Previsión</th>" : "") . "
879                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs n-1</th>
880                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Budget</th>" .
881                        (!$isMonthly ? "<th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Previsión</th>" : "") . "
882                    </tr>
883                </thead>
884                <tbody>";
885
886        $byRegion = $data['by_region'];
887        uasort($byRegion, fn ($a, $b) => ($b['actuals'] ?? 0) <=> ($a['actuals'] ?? 0));
888
889        // Render a % delta cell: if baseline is 0 (no data), show "—" in gray
890        $pctCell = function ($actual, $baseline) use ($fmtPct) {
891            if ($baseline == 0) {
892                return "<td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: #999;'>—</td>";
893            }
894            $pct = (($actual - $baseline) / $baseline) * 100;
895            $color = $pct >= 0 ? '#34c38f' : '#f46a6a';
896            $arrow = $pct >= 0 ? '▲' : '▼';
897            $sign = $pct >= 0 ? '+' : '';
898            return "<td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$color}; font-weight: bold;'>{$arrow} {$sign}{$fmtPct($pct)}%</td>";
899        };
900
901        foreach ($byRegion as $companyId => $vals) {
902            $regionName = $vals['name'];
903            $rActuals = $vals['actuals'];
904            $rN1 = $vals['actuals_n1'];
905            $rBudget = $vals['budget'];
906            $rPrevision = $vals['prevision'];
907
908            $html .= "<tr>
909                <td style='border: 1px solid #dee2e6; padding: 6px 8px; font-weight: bold;'>{$regionName}</td>
910                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rActuals)}&nbsp;€</td>
911                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rN1)}&nbsp;€</td>
912                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rBudget)}&nbsp;€</td>" .
913                (!$isMonthly ? "<td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rPrevision)}&nbsp;€</td>" : "") .
914                $pctCell($rActuals, $rN1) .
915                $pctCell($rActuals, $rBudget) .
916                (!$isMonthly ? $pctCell($rActuals, $rPrevision) : "") . "
917            </tr>";
918        }
919
920        // Grand total row — use same pctCell helper
921        $totalPctCell = function ($actual, $baseline) use ($fmtPct) {
922            if ($baseline == 0) {
923                return "<td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: #999;'>—</td>";
924            }
925            $pct = (($actual - $baseline) / $baseline) * 100;
926            $color = $pct >= 0 ? '#34c38f' : '#f46a6a';
927            $arrow = $pct >= 0 ? '▲' : '▼';
928            $sign = $pct >= 0 ? '+' : '';
929            return "<td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: {$color};'>{$arrow} {$sign}{$fmtPct($pct)}%</td>";
930        };
931
932        $html .= "<tr style='background-color: #fff3cd; font-weight: bold;'>
933            <td style='border: 1px solid #dee2e6; padding: 8px;'>TOTAL GRUPO FIRE</td>
934            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsYtd)}&nbsp;€</td>
935            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsN1Ytd)}&nbsp;€</td>
936            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($budgetYtd)}&nbsp;€</td>" .
937            (!$isMonthly ? "<td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($previsionYtd)}&nbsp;€</td>" : "") .
938            $totalPctCell($actualsYtd, $actualsN1Ytd) .
939            $totalPctCell($actualsYtd, $budgetYtd) .
940            (!$isMonthly ? $totalPctCell($actualsYtd, $previsionYtd) : "") . "
941        </tr>";
942
943        $html .= '</tbody></table>';
944        $html .= "<p style='margin-top: 20px;'><a href='https://fireservicetitan.com/finance' style='color: #556ee6;'>Podéis ver el detalle de negocio en el siguiente enlace</a></p>";
945        $html .= "<p style='color: #999; font-size: 11px; margin-top: 20px;'>Generado automáticamente por Fire Service Titan el ".Carbon::now()->format('d/m/Y H:i').'</p>';
946        $html .= '</div>';
947
948        return $html;
949    }
950
951    /**
952     * Combined per-company report: single month table + YTD accumulated table.
953     * @param bool $isClosed true when the reported month is closed (monthly email)
954     */
955    private function buildCombinedReportHtml(array $report, int $year, int $month, bool $isClosed = false): string
956    {
957        $weeklyReport = ['company' => $report['company'], 'data' => $report['weekly']];
958        $monthlyReport = ['company' => $report['company'], 'data' => $report['monthly']];
959
960        $weeklyHtml = $this->buildReportHtml($weeklyReport, $year, $month, $isClosed);
961        $monthlyHtml = $this->buildMonthlyReportHtml($monthlyReport, $year, $month);
962
963        $monthlyBody = $this->extractReportBody($monthlyHtml);
964
965        $footerMarker = "<p style='margin-top: 20px;'><a href='https://fireservicetitan.com/finance'";
966        $combined = str_replace(
967            $footerMarker,
968            "<hr style='border: none; border-top: 2px solid #dee2e6; margin: 40px 0;'>".$monthlyBody.$footerMarker,
969            $weeklyHtml
970        );
971
972        return $combined;
973    }
974
975    /**
976     * Combined all-regions report: single month table + YTD accumulated table.
977     * @param bool $isClosed true when the reported month is closed (monthly email)
978     */
979    private function buildCombinedAllRegionsReportHtml(array $report, int $year, int $month, bool $isClosed = false): string
980    {
981        $weeklyReport = ['company' => $report['company'], 'data' => $report['data']['weekly']];
982        $monthlyReport = ['company' => $report['company'], 'data' => $report['data']['monthly']];
983
984        $weeklyLabel = $isClosed ? 'Cierre' : 'Report Semanal';
985        $weeklyHtml = $this->buildAllRegionsReportHtml($weeklyReport, $year, $month, $weeklyLabel, $isClosed);
986        $monthlyHtml = $this->buildAllRegionsReportHtml($monthlyReport, $year, $month, 'Cierre Mensual');
987
988        $monthlyBody = $this->extractReportBody($monthlyHtml);
989
990        $footerMarker = "<p style='margin-top: 20px;'><a href='https://fireservicetitan.com/finance'";
991        $combined = str_replace(
992            $footerMarker,
993            "<hr style='border: none; border-top: 2px solid #dee2e6; margin: 40px 0;'>".$monthlyBody.$footerMarker,
994            $weeklyHtml
995        );
996
997        return $combined;
998    }
999
1000    /**
1001     * Extract the report body (summary card + table) from a full report HTML,
1002     * stripping the outer div, greeting, and footer.
1003     */
1004    private function extractReportBody(string $html): string
1005    {
1006        // Remove everything before the first summary card
1007        $start = strpos($html, "<div style='background-color: #f8f9fa; border-radius: 8px;");
1008        if ($start === false) {
1009            return $html;
1010        }
1011
1012        // Remove the closing </div> (outer wrapper) and the footer
1013        $footerStart = strpos($html, "<p style='margin-top: 20px;'><a href='https://fireservicetitan.com/finance'");
1014        if ($footerStart === false) {
1015            $footerStart = strrpos($html, '</div>');
1016        }
1017
1018        return substr($html, $start, $footerStart - $start);
1019    }
1020}