Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 490
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SendFinanceReport
0.00% covered (danger)
0.00%
0 / 490
0.00% covered (danger)
0.00%
0 / 8
14762
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 1
552
 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 / 71
0.00% covered (danger)
0.00%
0 / 1
132
 getZenitalSucursalesMap
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
2
 aggregateAllReports
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 buildReportHtml
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 1
650
 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 / 77
0.00% covered (danger)
0.00%
0 / 1
552
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\TblFinanceSedes;
11use Carbon\Carbon;
12use Illuminate\Console\Command;
13use Illuminate\Support\Facades\DB;
14use Illuminate\Support\Facades\Log;
15
16class SendFinanceReport extends Command
17{
18    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}';
19
20    protected $description = 'Send the weekly finance report to all active recipients';
21
22    private $months = [
23        1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril',
24        5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto',
25        9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre',
26    ];
27
28    public function handle(): int
29    {
30        $year = (int) date('Y');
31        $month = $this->option('month')
32            ? (int) $this->option('month')
33            : $this->getReportMonth();
34        $type = $this->option('type') ?? 'weekly';
35        $isTest = $this->option('test');
36
37        $recipients = TblFinanceReportRecipients::where('is_active', 1)->get();
38
39        if ($recipients->isEmpty()) {
40            $this->info('No active recipients found.');
41
42            return 0;
43        }
44
45        $companyIds = $recipients->pluck('company_id')->filter()->unique()->toArray();
46
47        // If any recipient has NULL company_id (all regions), include all finance companies
48        $hasAllRegionRecipient = $recipients->contains(fn ($r) => $r->company_id === null);
49        if ($hasAllRegionRecipient) {
50            $financeCompanyIds = TblFinanceSedes::where('is_active', 1)
51                ->pluck('company_id')
52                ->unique()
53                ->toArray();
54            $companyIds = array_values(array_unique(array_merge($companyIds, $financeCompanyIds)));
55        }
56
57        $reportsByCompany = [];
58        foreach ($companyIds as $companyId) {
59            $company = TblCompanies::find($companyId);
60            if (! $company) {
61                continue;
62            }
63
64            $data = $this->getResumenData($companyId, $year, $month);
65
66            if (empty($data['actuals_ytd']) && empty($data['by_sede'])) {
67                continue;
68            }
69
70            $reportsByCompany[$companyId] = [
71                'company' => $company,
72                'data' => $data,
73            ];
74        }
75
76        if (empty($reportsByCompany)) {
77            $this->info('No finance data available for current year/month.');
78
79            return 0;
80        }
81
82        // Separate "all regions" recipients (NULL company_id) from company-specific ones
83        $allRegionRecipients = $recipients->filter(fn ($r) => $r->company_id === null);
84        $companyRecipients = $recipients->filter(fn ($r) => $r->company_id !== null);
85
86        $recipientsByCompany = $companyRecipients->groupBy('company_id');
87
88        $sent = 0;
89        foreach ($recipientsByCompany as $companyId => $companyGroupRecipients) {
90            if (! isset($reportsByCompany[$companyId])) {
91                continue;
92            }
93
94            $report = $reportsByCompany[$companyId];
95
96            if ($type === 'monthly') {
97                $html = $this->buildMonthlyReportHtml($report, $year, $month);
98                $subject = "Cierre Facturación - {$report['company']->name} - {$this->months[$month]} {$year}";
99            } else {
100                $html = $this->buildReportHtml($report, $year, $month);
101                $subject = "Reporte Financiero - {$report['company']->name} - {$this->months[$month]} {$year}";
102            }
103
104            if ($isTest) {
105                $this->info("--- Report for {$report['company']->name} (recipients: {$companyGroupRecipients->pluck('email')->implode(', ')}) ---");
106                $sent += $companyGroupRecipients->count();
107
108                continue;
109            }
110
111            foreach ($companyGroupRecipients as $recipient) {
112                try {
113                    $sendgrid = new \SendGrid(config('services.sendgrid.api_key'));
114                    $email = new \SendGrid\Mail\Mail;
115                    $email->setFrom('fire@fire.es', 'Fire Service Titan');
116                    $email->setSubject($subject);
117                    $email->addTo($recipient->email, $recipient->name);
118                    $email->addContent('text/html', $html);
119
120                    $response = $sendgrid->send($email);
121
122                    if ($response->statusCode() == 202) {
123                        $sent++;
124                        $this->info("Sent to {$recipient->email}");
125                    } else {
126                        $this->error("Failed to send to {$recipient->email}".$response->body());
127                    }
128                } catch (\Exception $e) {
129                    $this->error("Error sending to {$recipient->email}".$e->getMessage());
130                    Log::channel('third-party')->error("finance:send-report — Error sending to {$recipient->email}".$e->getMessage());
131                }
132            }
133        }
134
135        // Send aggregated report to "all regions" recipients
136        if ($allRegionRecipients->isNotEmpty() && ! empty($reportsByCompany)) {
137            $aggregatedData = $this->aggregateAllReports($reportsByCompany);
138            $aggregatedReport = ['company' => (object) ['name' => 'Grupo Fire (Todas las regiones)'], 'data' => $aggregatedData];
139
140            if ($type === 'monthly') {
141                $aggregatedHtml = $this->buildAllRegionsReportHtml($aggregatedReport, $year, $month, 'Cierre Mensual');
142                $aggregatedSubject = "Cierre Facturación - Grupo Fire - {$this->months[$month]} {$year}";
143            } else {
144                $aggregatedHtml = $this->buildAllRegionsReportHtml($aggregatedReport, $year, $month, 'Report Semanal');
145                $aggregatedSubject = "Reporte Financiero - Grupo Fire - {$this->months[$month]} {$year}";
146            }
147
148            if ($isTest) {
149                $this->info('--- Aggregated Report for Grupo Fire (Todas las regiones) ---');
150                $this->info('Recipients: '.$allRegionRecipients->pluck('email')->implode(', '));
151                $this->line($aggregatedHtml);
152                $sent += $allRegionRecipients->count();
153            } else {
154                foreach ($allRegionRecipients as $recipient) {
155                    try {
156                        $sendgrid = new \SendGrid(config('services.sendgrid.api_key'));
157                        $email = new \SendGrid\Mail\Mail;
158                        $email->setFrom('fire@fire.es', 'Fire Service Titan');
159                        $email->setSubject($aggregatedSubject);
160                        $email->addTo($recipient->email, $recipient->name);
161                        $email->addContent('text/html', $aggregatedHtml);
162
163                        $response = $sendgrid->send($email);
164
165                        if ($response->statusCode() == 202) {
166                            $sent++;
167                            $this->info("Sent to {$recipient->email}");
168                        } else {
169                            $this->error("Failed to send to {$recipient->email}".$response->body());
170                        }
171                    } catch (\Exception $e) {
172                        $this->error("Error sending to {$recipient->email}".$e->getMessage());
173                        Log::channel('third-party')->error("finance:send-report — Error sending to {$recipient->email}".$e->getMessage());
174                    }
175                }
176            }
177        }
178
179        $this->info("Finance report sent to {$sent} recipients.");
180        Log::channel('third-party')->info("finance:send-report — Sent to {$sent} recipients.");
181
182        return 0;
183    }
184
185    /**
186     * Determine which month to report on.
187     *
188     * Priority:
189     * 1. force_report_month override from tbl_finance_month_config (current month row)
190     * 2. Previous month's close_date — if today <= close_date, still report previous month
191     * 3. Default WD+5 logic: if <= 5 working days have passed, report on previous month
192     */
193    private function getReportMonth(): int
194    {
195        $today = Carbon::now();
196        $year = $today->year;
197        $currentMonth = $today->month;
198
199        // Check for force override on current month
200        $config = TblFinanceMonthConfig::where('year', $year)
201            ->where('month', $currentMonth)
202            ->first();
203
204        if ($config && $config->force_report_month) {
205            return (int) $config->force_report_month;
206        }
207
208        // Check previous month's close_date
209        $prevMonth = $currentMonth === 1 ? 12 : $currentMonth - 1;
210        $prevYear = $currentMonth === 1 ? $year - 1 : $year;
211        $prevConfig = TblFinanceMonthConfig::where('year', $prevYear)
212            ->where('month', $prevMonth)
213            ->first();
214
215        if ($prevConfig && $prevConfig->close_date) {
216            // If today is before the close date, still report on previous month
217            if ($today->lte(Carbon::parse($prevConfig->close_date))) {
218                return $prevMonth;
219            }
220
221            return $currentMonth;
222        }
223
224        // Default: WD+5 logic
225        $workingDays = 0;
226        for ($day = 1; $day <= $today->day; $day++) {
227            $date = Carbon::create($today->year, $currentMonth, $day);
228            if ($date->isWeekday()) {
229                $workingDays++;
230            }
231        }
232
233        return $workingDays <= 5 ? $prevMonth : $currentMonth;
234    }
235
236    /**
237     * Fetch resumen data from Zenital + MySQL (same logic as FinanceController::list_resumen).
238     */
239    private function getResumenData(int $companyId, int $year, int $month): array
240    {
241        // Build Zenital sede map for this company, translating to local tbl_finance_sedes IDs
242        $zenitalMap = $this->getZenitalSucursalesMap();
243        $localSedes = TblFinanceSedes::where('company_id', $companyId)
244            ->where('is_active', 1)
245            ->pluck('id', 'name')
246            ->toArray();
247
248        $zenitalToSede = [];
249        foreach ($zenitalMap as $zenitalId => $info) {
250            if ($info['company_id'] === $companyId) {
251                // Map to local sede ID by name match, fallback to Zenital sede_id
252                $zenitalToSede[$zenitalId] = $localSedes[$info['nombre']] ?? $info['sede_id'];
253            }
254        }
255
256        // Query Zenital for actuals (current year + n-1)
257        $idx = [];
258        if (! empty($zenitalToSede)) {
259            try {
260                $facturacion = DB::connection('zenital')
261                    ->table('fact_facturacion as f')
262                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
263                    ->whereIn('f.sk_sucursal', array_keys($zenitalToSede))
264                    ->whereIn('d.ano', [$year, $year - 1])
265                    ->where('d.num_mes', '<=', $month)
266                    ->selectRaw('f.sk_sucursal, d.ano, SUM(f.base_imponible) as total')
267                    ->groupBy('f.sk_sucursal', 'd.ano')
268                    ->get();
269
270                foreach ($facturacion as $row) {
271                    $sedeId = $zenitalToSede[$row->sk_sucursal] ?? $row->sk_sucursal;
272                    $idx[$sedeId][$row->ano] = (float) $row->total;
273                }
274            } catch (\Exception $e) {
275                Log::channel('third-party')->warning("finance:send-report — Zenital query failed: {$e->getMessage()}");
276            }
277        }
278
279        // Budget YTD from MySQL
280        $budgetBySede = TblFinanceBudgetAnual::where('company_id', $companyId)
281            ->where('year', $year)
282            ->where('month', '<=', $month)
283            ->get()
284            ->groupBy('sede_id')
285            ->map(fn ($rows) => (float) $rows->sum('amount'));
286
287        // Prevision YTD from MySQL
288        $previsionBySede = TblFinancePrevisionAnual::where('company_id', $companyId)
289            ->where('year', $year)
290            ->where('month', '<=', $month)
291            ->get()
292            ->groupBy('sede_id')
293            ->map(fn ($rows) => (float) $rows->sum('amount'));
294
295        // Merge all sede_ids
296        $allSedeIds = array_unique(array_merge(
297            array_keys($idx),
298            $budgetBySede->keys()->toArray(),
299            $previsionBySede->keys()->toArray()
300        ));
301
302        $actualsYtd = 0;
303        $actualsN1Ytd = 0;
304        $budgetYtd = 0;
305        $previsionYtd = 0;
306        $bySede = [];
307
308        foreach ($allSedeIds as $sedeId) {
309            $actuals = $idx[$sedeId][$year] ?? 0;
310            $n1 = $idx[$sedeId][$year - 1] ?? 0;
311            $budget = $budgetBySede[$sedeId] ?? 0;
312            $prevision = $previsionBySede[$sedeId] ?? 0;
313
314            if ($actuals == 0 && $n1 == 0 && $budget == 0 && $prevision == 0) {
315                continue;
316            }
317
318            $actualsYtd += $actuals;
319            $actualsN1Ytd += $n1;
320            $budgetYtd += $budget;
321            $previsionYtd += $prevision;
322
323            $bySede[$sedeId] = [
324                'actuals' => $actuals,
325                'actuals_n1' => $n1,
326                'budget' => $budget,
327                'prevision' => $prevision,
328            ];
329        }
330
331        return [
332            'actuals_ytd' => $actualsYtd,
333            'actuals_n1_ytd' => $actualsN1Ytd,
334            'budget_ytd' => $budgetYtd,
335            'prevision_ytd' => $previsionYtd,
336            'by_sede' => $bySede,
337        ];
338    }
339
340    /**
341     * Same Zenital map as FinanceController — must stay in sync.
342     */
343    private function getZenitalSucursalesMap(): array
344    {
345        return [
346            // Cataluña (company_id = 19)
347            59 => ['nombre' => 'Extintores Clemente',  'company_id' => 19, 'sede_id' => 111],
348            73 => ['nombre' => 'Josmafoc',             'company_id' => 19, 'sede_id' => 112],
349            72 => ['nombre' => 'Ingesfoc',             'company_id' => 19, 'sede_id' => 113],
350            84 => ['nombre' => 'Sat Valles',           'company_id' => 19, 'sede_id' => 114],
351            78 => ['nombre' => 'NioExtin',             'company_id' => 19, 'sede_id' => 115],
352            51 => ['nombre' => 'Cisemex',              'company_id' => 19, 'sede_id' => 116],
353            67 => ['nombre' => 'Grupo Fire MOF',       'company_id' => 19, 'sede_id' => 119],
354            46 => ['nombre' => 'Master Centella',      'company_id' => 19, 'sede_id' => 120],
355            60 => ['nombre' => 'Gallex',               'company_id' => 19, 'sede_id' => 121],
356            57 => ['nombre' => 'Externo MOF',          'company_id' => 19, 'sede_id' => 122],
357            62 => ['nombre' => 'Grupo Fire Cataluña',  'company_id' => 19, 'sede_id' => 125],
358            // Madrid (company_id = 18)
359            49 => ['nombre' => 'Alcarrena',              'company_id' => 18, 'sede_id' => 49],
360            50 => ['nombre' => 'Anin',                   'company_id' => 18, 'sede_id' => 50],
361            54 => ['nombre' => 'EnFire',                 'company_id' => 18, 'sede_id' => 54],
362            55 => ['nombre' => 'ExConin',                'company_id' => 18, 'sede_id' => 55],
363            56 => ['nombre' => 'ExFire',                 'company_id' => 18, 'sede_id' => 56],
364            66 => ['nombre' => 'Grupo Fire Guadalajara', 'company_id' => 18, 'sede_id' => 66],
365            68 => ['nombre' => 'Grupo Fire Madrid',      'company_id' => 18, 'sede_id' => 68],
366            71 => ['nombre' => 'ICF',                    'company_id' => 18, 'sede_id' => 71],
367            76 => ['nombre' => 'Montoya',                'company_id' => 18, 'sede_id' => 76],
368            80 => ['nombre' => 'Precoin',                'company_id' => 18, 'sede_id' => 80],
369            83 => ['nombre' => 'Rosegur',                'company_id' => 18, 'sede_id' => 83],
370            87 => ['nombre' => 'Segurtrex',              'company_id' => 18, 'sede_id' => 87],
371            // Valencia (company_id = 30)
372            45 => ['nombre' => 'Guipons',      'company_id' => 30, 'sede_id' => 45],
373            47 => ['nombre' => 'AirFeu',       'company_id' => 30, 'sede_id' => 47],
374            58 => ['nombre' => 'Extinfuego',   'company_id' => 30, 'sede_id' => 58],
375            90 => ['nombre' => 'Vivó',         'company_id' => 30, 'sede_id' => 90],
376            // Andalucía (company_id = 21)
377            53 => ['nombre' => 'Drago',              'company_id' => 21, 'sede_id' => 53],
378            61 => ['nombre' => 'Grupo Fire Almeria', 'company_id' => 21, 'sede_id' => 61],
379            81 => ['nombre' => 'Robles',             'company_id' => 21, 'sede_id' => 81],
380            // Castilla y León (company_id = 23)
381            52 => ['nombre' => 'Crespo', 'company_id' => 23, 'sede_id' => 52],
382            // Aragón (company_id = 9)
383            79 => ['nombre' => 'Oasys', 'company_id' => 9, 'sede_id' => 79],
384            // Baleares (company_id = 33)
385            77 => ['nombre' => 'Ni Foc Ni Fum', 'company_id' => 33, 'sede_id' => 77],
386            85 => ['nombre' => 'SeguCor',        'company_id' => 33, 'sede_id' => 85],
387        ];
388    }
389
390    private function aggregateAllReports(array $reportsByCompany): array
391    {
392        $actualsYtd = 0;
393        $actualsN1Ytd = 0;
394        $budgetYtd = 0;
395        $previsionYtd = 0;
396        $byRegion = [];
397
398        foreach ($reportsByCompany as $companyId => $report) {
399            $data = $report['data'];
400            $actualsYtd += $data['actuals_ytd'];
401            $actualsN1Ytd += $data['actuals_n1_ytd'];
402            $budgetYtd += $data['budget_ytd'];
403            $previsionYtd += $data['prevision_ytd'];
404
405            $byRegion[$companyId] = [
406                'name' => $report['company']->name,
407                'actuals' => $data['actuals_ytd'],
408                'actuals_n1' => $data['actuals_n1_ytd'],
409                'budget' => $data['budget_ytd'],
410                'prevision' => $data['prevision_ytd'],
411            ];
412        }
413
414        return [
415            'actuals_ytd' => $actualsYtd,
416            'actuals_n1_ytd' => $actualsN1Ytd,
417            'budget_ytd' => $budgetYtd,
418            'prevision_ytd' => $previsionYtd,
419            'by_region' => $byRegion,
420        ];
421    }
422
423    private function buildReportHtml(array $report, int $year, int $month): string
424    {
425        $company = $report['company'];
426        $data = $report['data'];
427        $monthName = $this->months[$month];
428
429        $actualsYtd = $data['actuals_ytd'];
430        $actualsN1Ytd = $data['actuals_n1_ytd'];
431        $budgetYtd = $data['budget_ytd'];
432        $previsionYtd = $data['prevision_ytd'];
433
434        $diffN1 = $actualsYtd - $actualsN1Ytd;
435        $pctN1 = $actualsN1Ytd != 0 ? ($diffN1 / $actualsN1Ytd) * 100 : 0;
436        $diffBudget = $actualsYtd - $budgetYtd;
437        $pctBudget = $budgetYtd != 0 ? ($diffBudget / $budgetYtd) * 100 : 0;
438        $diffPrevision = $actualsYtd - $previsionYtd;
439        $pctPrevision = $previsionYtd != 0 ? ($diffPrevision / $previsionYtd) * 100 : 0;
440
441        $signN1 = $diffN1 >= 0 ? '+' : '';
442        $signBudget = $diffBudget >= 0 ? '+' : '';
443        $signPrevision = $diffPrevision >= 0 ? '+' : '';
444
445        $colorN1 = $diffN1 >= 0 ? '#34c38f' : '#f46a6a';
446        $colorBudget = $diffBudget >= 0 ? '#34c38f' : '#f46a6a';
447        $colorPrevision = $diffPrevision >= 0 ? '#34c38f' : '#f46a6a';
448
449        $fmt = fn ($v) => number_format($v, 2, ',', '.');
450        $fmtPct = fn ($v) => number_format($v, 1, ',', '.');
451
452        // Load sede names
453        $sedeNames = TblFinanceSedes::pluck('name', 'id')->toArray();
454        // Also map Zenital sede_ids to names
455        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
456            $sedeNames[$info['sede_id']] = $sedeNames[$info['sede_id']] ?? $info['nombre'];
457            $sedeNames[$zenitalId] = $info['nombre'];
458        }
459
460        $html = "
461        <div style='font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; color: #333;'>
462            <p>Hola a todos,</p>
463            <p>Os comparto el resumen de la facturación del mes de <strong>{$monthName}</strong> hasta la fecha</p>
464
465            <div style='background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;'>
466                <h3 style='margin: 0 0 12px 0; color: #495057;'>Acumulado {$monthName} {$year} YTD</h3>
467                <ul style='list-style: none; padding: 0; margin: 0; line-height: 2;'>
468                    <li>
469                        <strong>Facturación total acumulada {$monthName} {$year}:</strong>
470                        <span style='font-size: 16px; font-weight: bold;'>{$fmt($actualsYtd)} €</span>
471                    </li>
472                    <li>
473                        vs. YTD {$monthName} ".($year - 1).":
474                        <span style='color: {$colorN1}; font-weight: bold;'>{$signN1}{$fmt($diffN1)} € ({$signN1}{$fmtPct($pctN1)}%)</span>
475                    </li>
476                    <li>
477                        vs. Budget {$monthName} {$year}:
478                        <span style='color: {$colorBudget}; font-weight: bold;'>{$signBudget}{$fmt($diffBudget)} € ({$signBudget}{$fmtPct($pctBudget)}%)</span>
479                        respecto al objetivo
480                    </li>
481                    <li>
482                        vs. Previsión {$monthName} {$year}:
483                        <span style='color: {$colorPrevision}; font-weight: bold;'>{$signPrevision}{$fmt($diffPrevision)} € ({$signPrevision}{$fmtPct($pctPrevision)}%)</span>
484                        respecto al objetivo
485                    </li>
486                </ul>
487            </div>
488
489            <h3 style='color: #495057; margin-top: 30px;'>Detalle por sede — Report Semanal</h3>
490            <table style='border-collapse: collapse; width: 100%; font-size: 13px;'>
491                <thead>
492                    <tr style='background-color: #f8f9fa;'>
493                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: left;'>Sede</th>
494                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Actuals</th>
495                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>n-1</th>
496                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Budget</th>
497                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Previsión</th>
498                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs n-1</th>
499                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Budget</th>
500                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Previsión</th>
501                    </tr>
502                </thead>
503                <tbody>";
504
505        $bySede = $data['by_sede'];
506        uasort($bySede, fn($a, $b) => ($b['actuals'] ?? 0) <=> ($a['actuals'] ?? 0) ?: ($b['budget'] ?? 0) <=> ($a['budget'] ?? 0));
507
508        foreach ($bySede as $sedeId => $vals) {
509            $sedeName = $sedeNames[$sedeId] ?? "Sede {$sedeId}";
510            $sedeActuals = $vals['actuals'];
511            $sedeN1 = $vals['actuals_n1'];
512            $sedeBudget = $vals['budget'];
513            $sedePrevision = $vals['prevision'] ?? 0;
514
515            $sedeDiffN1 = $sedeActuals - $sedeN1;
516            $sedeDiffBudget = $sedeActuals - $sedeBudget;
517            $sedeDiffPrevision = $sedeActuals - $sedePrevision;
518
519            $colorSN1 = $sedeDiffN1 >= 0 ? '#34c38f' : '#f46a6a';
520            $colorSB = $sedeDiffBudget >= 0 ? '#34c38f' : '#f46a6a';
521            $colorSP = $sedeDiffPrevision >= 0 ? '#34c38f' : '#f46a6a';
522            $signSN1 = $sedeDiffN1 >= 0 ? '+' : '';
523            $signSB = $sedeDiffBudget >= 0 ? '+' : '';
524            $signSP = $sedeDiffPrevision >= 0 ? '+' : '';
525
526            $html .= "<tr>
527                <td style='border: 1px solid #dee2e6; padding: 6px 8px;'>{$sedeName}</td>
528                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeActuals)} €</td>
529                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeN1)} €</td>
530                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeBudget)} €</td>
531                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedePrevision)} €</td>
532                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSN1};'>{$signSN1}{$fmt($sedeDiffN1)} €</td>
533                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSB};'>{$signSB}{$fmt($sedeDiffBudget)} €</td>
534                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSP};'>{$signSP}{$fmt($sedeDiffPrevision)} €</td>
535            </tr>";
536        }
537
538        // Grand total row
539        $totalDiffN1 = $actualsYtd - $actualsN1Ytd;
540        $totalDiffBudget = $actualsYtd - $budgetYtd;
541        $totalDiffPrevision = $actualsYtd - $previsionYtd;
542
543        $html .= "<tr style='background-color: #fff3cd; font-weight: bold;'>
544            <td style='border: 1px solid #dee2e6; padding: 8px;'>TOTAL</td>
545            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsYtd)} €</td>
546            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsN1Ytd)} €</td>
547            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($budgetYtd)} €</td>
548            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($previsionYtd)} €</td>
549            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffN1 >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffN1 >= 0 ? '+' : '')."{$fmt($totalDiffN1)} €</td>
550            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffBudget >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffBudget >= 0 ? '+' : '')."{$fmt($totalDiffBudget)} €</td>
551            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffPrevision >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffPrevision >= 0 ? '+' : '')."{$fmt($totalDiffPrevision)} €</td>
552        </tr>";
553
554        $html .= '</tbody></table>';
555        $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>";
556        $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>';
557        $html .= '</div>';
558
559        return $html;
560    }
561
562    private function buildMonthlyReportHtml(array $report, int $year, int $month): string
563    {
564        $company = $report['company'];
565        $data = $report['data'];
566        $monthName = $this->months[$month];
567
568        $actualsYtd = $data['actuals_ytd'];
569        $actualsN1Ytd = $data['actuals_n1_ytd'];
570        $budgetYtd = $data['budget_ytd'];
571        $previsionYtd = $data['prevision_ytd'];
572
573        $diffN1 = $actualsYtd - $actualsN1Ytd;
574        $pctN1 = $actualsN1Ytd != 0 ? ($diffN1 / $actualsN1Ytd) * 100 : 0;
575        $diffBudget = $actualsYtd - $budgetYtd;
576        $pctBudget = $budgetYtd != 0 ? ($diffBudget / $budgetYtd) * 100 : 0;
577        $diffPrevision = $actualsYtd - $previsionYtd;
578        $pctPrevision = $previsionYtd != 0 ? ($diffPrevision / $previsionYtd) * 100 : 0;
579
580        $signN1 = $diffN1 >= 0 ? '+' : '';
581        $signBudget = $diffBudget >= 0 ? '+' : '';
582        $signPrevision = $diffPrevision >= 0 ? '+' : '';
583
584        $colorN1 = $diffN1 >= 0 ? '#34c38f' : '#f46a6a';
585        $colorBudget = $diffBudget >= 0 ? '#34c38f' : '#f46a6a';
586        $colorPrevision = $diffPrevision >= 0 ? '#34c38f' : '#f46a6a';
587
588        $fmt = fn ($v) => number_format($v, 2, ',', '.');
589        $fmtPct = fn ($v) => number_format($v, 1, ',', '.');
590
591        // Load sede names
592        $sedeNames = TblFinanceSedes::pluck('name', 'id')->toArray();
593        // Also map Zenital sede_ids to names
594        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
595            $sedeNames[$info['sede_id']] = $sedeNames[$info['sede_id']] ?? $info['nombre'];
596            $sedeNames[$zenitalId] = $info['nombre'];
597        }
598
599        // Part 1: Monthly summary (without "hasta la fecha")
600        $html = "
601        <div style='font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; color: #333;'>
602            <p>Hola a todos,</p>
603            <p>Os comparto el resumen de la facturación del mes de <strong>{$monthName}</strong></p>
604
605            <div style='background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;'>
606                <h3 style='margin: 0 0 12px 0; color: #495057;'>Acumulado {$monthName} {$year} YTD</h3>
607                <ul style='list-style: none; padding: 0; margin: 0; line-height: 2;'>
608                    <li>
609                        <strong>Facturación total acumulada {$monthName} {$year}:</strong>
610                        <span style='font-size: 16px; font-weight: bold;'>{$fmt($actualsYtd)} €</span>
611                    </li>
612                    <li>
613                        vs. Facturación acumulada {$monthName} ".($year - 1).":
614                        <span style='color: {$colorN1}; font-weight: bold;'>{$signN1}{$fmt($diffN1)} € ({$signN1}{$fmtPct($pctN1)}%)</span>
615                    </li>
616                    <li>
617                        vs. Budget {$monthName} {$year}:
618                        <span style='color: {$colorBudget}; font-weight: bold;'>{$signBudget}{$fmt($diffBudget)} € ({$signBudget}{$fmtPct($pctBudget)}%)</span>
619                        respecto al objetivo
620                    </li>
621                    <li>
622                        vs. Previsión {$monthName} {$year}:
623                        <span style='color: {$colorPrevision}; font-weight: bold;'>{$signPrevision}{$fmt($diffPrevision)} € ({$signPrevision}{$fmtPct($pctPrevision)}%)</span>
624                        respecto al objetivo
625                    </li>
626                </ul>
627            </div>
628
629            <h3 style='color: #495057; margin-top: 30px;'>Detalle por sede — Cierre Mensual</h3>
630            <table style='border-collapse: collapse; width: 100%; font-size: 13px;'>
631                <thead>
632                    <tr style='background-color: #f8f9fa;'>
633                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: left;'>Sede</th>
634                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Actuals</th>
635                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>n-1</th>
636                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Budget</th>
637                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Previsión</th>
638                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs n-1</th>
639                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Budget</th>
640                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Previsión</th>
641                    </tr>
642                </thead>
643                <tbody>";
644
645        $bySede = $data['by_sede'];
646        uasort($bySede, fn($a, $b) => ($b['actuals'] ?? 0) <=> ($a['actuals'] ?? 0) ?: ($b['budget'] ?? 0) <=> ($a['budget'] ?? 0));
647
648        foreach ($bySede as $sedeId => $vals) {
649            $sedeName = $sedeNames[$sedeId] ?? "Sede {$sedeId}";
650            $sedeActuals = $vals['actuals'];
651            $sedeN1 = $vals['actuals_n1'];
652            $sedeBudget = $vals['budget'];
653            $sedePrevision = $vals['prevision'] ?? 0;
654
655            $sedeDiffN1 = $sedeActuals - $sedeN1;
656            $sedeDiffBudget = $sedeActuals - $sedeBudget;
657            $sedeDiffPrevision = $sedeActuals - $sedePrevision;
658
659            $colorSN1 = $sedeDiffN1 >= 0 ? '#34c38f' : '#f46a6a';
660            $colorSB = $sedeDiffBudget >= 0 ? '#34c38f' : '#f46a6a';
661            $colorSP = $sedeDiffPrevision >= 0 ? '#34c38f' : '#f46a6a';
662            $signSN1 = $sedeDiffN1 >= 0 ? '+' : '';
663            $signSB = $sedeDiffBudget >= 0 ? '+' : '';
664            $signSP = $sedeDiffPrevision >= 0 ? '+' : '';
665
666            $html .= "<tr>
667                <td style='border: 1px solid #dee2e6; padding: 6px 8px;'>{$sedeName}</td>
668                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeActuals)} €</td>
669                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeN1)} €</td>
670                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedeBudget)} €</td>
671                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($sedePrevision)} €</td>
672                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSN1};'>{$signSN1}{$fmt($sedeDiffN1)} €</td>
673                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSB};'>{$signSB}{$fmt($sedeDiffBudget)} €</td>
674                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$colorSP};'>{$signSP}{$fmt($sedeDiffPrevision)} €</td>
675            </tr>";
676        }
677
678        // Grand total row
679        $totalDiffN1 = $actualsYtd - $actualsN1Ytd;
680        $totalDiffBudget = $actualsYtd - $budgetYtd;
681        $totalDiffPrevision = $actualsYtd - $previsionYtd;
682
683        $html .= "<tr style='background-color: #fff3cd; font-weight: bold;'>
684            <td style='border: 1px solid #dee2e6; padding: 8px;'>TOTAL</td>
685            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsYtd)} €</td>
686            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsN1Ytd)} €</td>
687            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($budgetYtd)} €</td>
688            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($previsionYtd)} €</td>
689            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffN1 >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffN1 >= 0 ? '+' : '')."{$fmt($totalDiffN1)} €</td>
690            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffBudget >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffBudget >= 0 ? '+' : '')."{$fmt($totalDiffBudget)} €</td>
691            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffPrevision >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffPrevision >= 0 ? '+' : '')."{$fmt($totalDiffPrevision)} €</td>
692        </tr>";
693
694        $html .= '</tbody></table>';
695        $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>";
696        $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>';
697        $html .= '</div>';
698
699        return $html;
700    }
701
702    private function buildAllRegionsReportHtml(array $report, int $year, int $month, string $reportLabel): string
703    {
704        $data = $report['data'];
705        $monthName = $this->months[$month];
706
707        $actualsYtd = $data['actuals_ytd'];
708        $actualsN1Ytd = $data['actuals_n1_ytd'];
709        $budgetYtd = $data['budget_ytd'];
710        $previsionYtd = $data['prevision_ytd'];
711
712        $diffN1 = $actualsYtd - $actualsN1Ytd;
713        $pctN1 = $actualsN1Ytd != 0 ? ($diffN1 / $actualsN1Ytd) * 100 : 0;
714        $diffBudget = $actualsYtd - $budgetYtd;
715        $pctBudget = $budgetYtd != 0 ? ($diffBudget / $budgetYtd) * 100 : 0;
716        $diffPrevision = $actualsYtd - $previsionYtd;
717        $pctPrevision = $previsionYtd != 0 ? ($diffPrevision / $previsionYtd) * 100 : 0;
718
719        $signN1 = $diffN1 >= 0 ? '+' : '';
720        $signBudget = $diffBudget >= 0 ? '+' : '';
721        $signPrevision = $diffPrevision >= 0 ? '+' : '';
722
723        $colorN1 = $diffN1 >= 0 ? '#34c38f' : '#f46a6a';
724        $colorBudget = $diffBudget >= 0 ? '#34c38f' : '#f46a6a';
725        $colorPrevision = $diffPrevision >= 0 ? '#34c38f' : '#f46a6a';
726
727        $fmt = fn ($v) => number_format($v, 2, ',', '.');
728        $fmtPct = fn ($v) => number_format($v, 1, ',', '.');
729
730        $html = "
731        <div style='font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; color: #333;'>
732            <p>Hola a todos,</p>
733            <p>Os comparto el resumen de la facturación del mes de <strong>{$monthName}</strong> — Todas las regiones</p>
734
735            <div style='background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;'>
736                <h3 style='margin: 0 0 12px 0; color: #495057;'>Acumulado {$monthName} {$year} YTD</h3>
737                <ul style='list-style: none; padding: 0; margin: 0; line-height: 2;'>
738                    <li>
739                        <strong>Facturación total acumulada {$monthName} {$year}:</strong>
740                        <span style='font-size: 16px; font-weight: bold;'>{$fmt($actualsYtd)} €</span>
741                    </li>
742                    <li>
743                        vs. YTD {$monthName} ".($year - 1).":
744                        <span style='color: {$colorN1}; font-weight: bold;'>{$signN1}{$fmt($diffN1)} € ({$signN1}{$fmtPct($pctN1)}%)</span>
745                    </li>
746                    <li>
747                        vs. Budget {$monthName} {$year}:
748                        <span style='color: {$colorBudget}; font-weight: bold;'>{$signBudget}{$fmt($diffBudget)} € ({$signBudget}{$fmtPct($pctBudget)}%)</span>
749                        respecto al objetivo
750                    </li>
751                    <li>
752                        vs. Previsión {$monthName} {$year}:
753                        <span style='color: {$colorPrevision}; font-weight: bold;'>{$signPrevision}{$fmt($diffPrevision)} € ({$signPrevision}{$fmtPct($pctPrevision)}%)</span>
754                        respecto al objetivo
755                    </li>
756                </ul>
757            </div>
758
759            <h3 style='color: #495057; margin-top: 30px;'>Detalle por región — {$reportLabel}</h3>
760            <table style='border-collapse: collapse; width: 100%; font-size: 13px;'>
761                <thead>
762                    <tr style='background-color: #f8f9fa;'>
763                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: left;'>Región</th>
764                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Actuals</th>
765                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>n-1</th>
766                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Budget</th>
767                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>Previsión</th>
768                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs n-1</th>
769                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Budget</th>
770                        <th style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>vs Previsión</th>
771                    </tr>
772                </thead>
773                <tbody>";
774
775        $byRegion = $data['by_region'];
776        uasort($byRegion, fn ($a, $b) => ($b['actuals'] ?? 0) <=> ($a['actuals'] ?? 0));
777
778        foreach ($byRegion as $companyId => $vals) {
779            $regionName = $vals['name'];
780            $rActuals = $vals['actuals'];
781            $rN1 = $vals['actuals_n1'];
782            $rBudget = $vals['budget'];
783            $rPrevision = $vals['prevision'];
784
785            $rDiffN1 = $rActuals - $rN1;
786            $rDiffBudget = $rActuals - $rBudget;
787            $rDiffPrevision = $rActuals - $rPrevision;
788
789            $cN1 = $rDiffN1 >= 0 ? '#34c38f' : '#f46a6a';
790            $cB = $rDiffBudget >= 0 ? '#34c38f' : '#f46a6a';
791            $cP = $rDiffPrevision >= 0 ? '#34c38f' : '#f46a6a';
792            $sN1 = $rDiffN1 >= 0 ? '+' : '';
793            $sB = $rDiffBudget >= 0 ? '+' : '';
794            $sP = $rDiffPrevision >= 0 ? '+' : '';
795
796            $html .= "<tr>
797                <td style='border: 1px solid #dee2e6; padding: 6px 8px; font-weight: bold;'>{$regionName}</td>
798                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rActuals)} €</td>
799                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rN1)} €</td>
800                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rBudget)} €</td>
801                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right;'>{$fmt($rPrevision)} €</td>
802                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$cN1};'>{$sN1}{$fmt($rDiffN1)} €</td>
803                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$cB};'>{$sB}{$fmt($rDiffBudget)} €</td>
804                <td style='border: 1px solid #dee2e6; padding: 6px 8px; text-align: right; color: {$cP};'>{$sP}{$fmt($rDiffPrevision)} €</td>
805            </tr>";
806        }
807
808        // Grand total row
809        $totalDiffN1 = $actualsYtd - $actualsN1Ytd;
810        $totalDiffBudget = $actualsYtd - $budgetYtd;
811        $totalDiffPrevision = $actualsYtd - $previsionYtd;
812
813        $html .= "<tr style='background-color: #fff3cd; font-weight: bold;'>
814            <td style='border: 1px solid #dee2e6; padding: 8px;'>TOTAL GRUPO FIRE</td>
815            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsYtd)} €</td>
816            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($actualsN1Ytd)} €</td>
817            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($budgetYtd)} €</td>
818            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right;'>{$fmt($previsionYtd)} €</td>
819            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffN1 >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffN1 >= 0 ? '+' : '')."{$fmt($totalDiffN1)} €</td>
820            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffBudget >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffBudget >= 0 ? '+' : '')."{$fmt($totalDiffBudget)} €</td>
821            <td style='border: 1px solid #dee2e6; padding: 8px; text-align: right; color: ".($totalDiffPrevision >= 0 ? '#34c38f' : '#f46a6a').";'>".($totalDiffPrevision >= 0 ? '+' : '')."{$fmt($totalDiffPrevision)} €</td>
822        </tr>";
823
824        $html .= '</tbody></table>';
825        $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>";
826        $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>';
827        $html .= '</div>';
828
829        return $html;
830    }
831}