Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 801
0.00% covered (danger)
0.00%
0 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
FinanceController
0.00% covered (danger)
0.00%
0 / 801
0.00% covered (danger)
0.00%
0 / 36
52212
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCompany
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCompanyIds
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getZenitalSucursalesMap
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
2
 zenitalIdToSedeId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 sedeIdToZenitalId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 isZenitalSede
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getCompanyIdBySedeId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 buildZenitalSedeMap
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 resolveZenitalSedeMap
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 ensureSedesExist
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
30
 list_regions
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 list_sedes
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 list_budget
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 upsert_budget
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 bulk_upsert_budget
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 list_prevision
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 upsert_prevision
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 bulk_upsert_prevision
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 list_resumen
0.00% covered (danger)
0.00%
0 / 112
0.00% covered (danger)
0.00%
0 / 1
1406
 load_resumen
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
132
 list_report_semanal
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 1
2070
 load_report_semanal
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
210
 upsert_resumen_cell
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
182
 upsert_report_semanal_cell
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
552
 list_recipients
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 create_recipient
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 update_recipient
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 delete_recipient
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 send_report
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 test_report
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 import_from_drive
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 create_sede
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 delete_sede
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 list_month_config
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 update_month_config
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\TblCompanies;
6use App\Models\TblCompanyUsers;
7use App\Models\TblFinanceBudgetAnual;
8use App\Models\TblFinancePrevisionAnual;
9use App\Models\TblFinanceMonthConfig;
10use App\Models\TblFinanceReportRecipients;
11use App\Models\TblFinanceReportSemanal;
12use App\Models\TblFinanceResumenAnual;
13use App\Models\TblFinanceRegions;
14use App\Models\TblFinanceSedes;
15use Illuminate\Http\Request;
16use Illuminate\Support\Facades\App;
17use Illuminate\Support\Facades\DB;
18use Illuminate\Support\Facades\Log;
19
20class FinanceController extends Controller
21{
22    public function __construct()
23    {
24        App::setLocale(request()->header('Locale-Id'));
25    }
26
27    // -------------------------------------------------------
28    // Helpers
29    // -------------------------------------------------------
30
31    /** Para escritura: devuelve la empresa de la región activa */
32    private function getCompany(Request $request)
33    {
34        $region = urldecode($request->header('Region'));
35
36        return TblCompanies::where('region', $region)->firstOrFail();
37    }
38
39    /**
40     * Para lectura: devuelve los company_ids del usuario.
41     * Si region = "All" o vacío → todas las empresas del usuario.
42     */
43    private function getCompanyIds(Request $request): array
44    {
45        $region = urldecode($request->header('Region'));
46        $userId = intval($request->header('User-ID'));
47
48        if ($region === 'All' || empty($region)) {
49            return TblCompanyUsers::where('user_id', $userId)
50                ->pluck('company_id')
51                ->toArray();
52        }
53
54        $company = TblCompanies::where('region', $region)->first();
55
56        return $company ? [$company->company_id] : [];
57    }
58
59    // -------------------------------------------------------
60    // Zenital: mapa estático de sucursales
61    // -------------------------------------------------------
62
63    /**
64     * Mapa hardcodeado de sucursales de Zenital.
65     * Fuente: tabla sucursales de Zenital + normalización de región a company_id interno.
66     * [sk_sucursal => ['nombre' => ..., 'company_id' => ...]]
67     */
68    /**
69     * Mapa de sucursales Zenital DWH (sk_sucursal) → sede FST.
70     * Cada entrada: zenital_id => [nombre, company_id, sede_id]
71     * donde sede_id es el ID en tbl_finance_sedes.
72     *
73     * Actualizado 2026-04-02 con nuevos IDs del DWH Zenital (migración de 1-25/111-125 a 45-90).
74     */
75    private function getZenitalSucursalesMap(): array
76    {
77        return [
78            // Cataluña (company_id = 19)
79            59 => ['nombre' => 'Extintores Clemente',  'company_id' => 19, 'sede_id' => 111],
80            73 => ['nombre' => 'Josmafoc',             'company_id' => 19, 'sede_id' => 112],
81            72 => ['nombre' => 'Ingesfoc',             'company_id' => 19, 'sede_id' => 113],
82            84 => ['nombre' => 'Sat Valles',           'company_id' => 19, 'sede_id' => 114],
83            78 => ['nombre' => 'NioExtin',             'company_id' => 19, 'sede_id' => 115],
84            51 => ['nombre' => 'Cisemex',              'company_id' => 19, 'sede_id' => 116],
85            67 => ['nombre' => 'Grupo Fire MOF',       'company_id' => 19, 'sede_id' => 119],
86            46 => ['nombre' => 'Master Centella',      'company_id' => 19, 'sede_id' => 120],
87            60 => ['nombre' => 'Gallex',               'company_id' => 19, 'sede_id' => 121],
88            57 => ['nombre' => 'Externo MOF',          'company_id' => 19, 'sede_id' => 122],
89            62 => ['nombre' => 'Grupo Fire Cataluña',  'company_id' => 19, 'sede_id' => 125],
90            74 => ['nombre' => 'Lluis_Moff',           'company_id' => 19, 'sede_id' => 4],
91
92            // La Mancha (company_id = 22)
93            49 => ['nombre' => 'Alcarrena',              'company_id' => 22, 'sede_id' => 14],
94
95            // Madrid (company_id = 18)
96            50 => ['nombre' => 'Anin',                   'company_id' => 18, 'sede_id' => 5004],
97            54 => ['nombre' => 'EnFire',                 'company_id' => 18, 'sede_id' => 5001],
98            55 => ['nombre' => 'ExConin',                'company_id' => 18, 'sede_id' => 12],
99            56 => ['nombre' => 'ExFire',                 'company_id' => 18, 'sede_id' => 5003],
100            66 => ['nombre' => 'Grupo Fire Guadalajara', 'company_id' => 18, 'sede_id' => 15],
101            68 => ['nombre' => 'Grupo Fire Madrid',      'company_id' => 18, 'sede_id' => 13],
102            71 => ['nombre' => 'ICF',                    'company_id' => 18, 'sede_id' => 22],
103            76 => ['nombre' => 'Montoya',                'company_id' => 18, 'sede_id' => 5002],
104            80 => ['nombre' => 'Precoin',                'company_id' => 18, 'sede_id' => 5000],
105            83 => ['nombre' => 'Rosegur',                'company_id' => 18, 'sede_id' => 11],
106            87 => ['nombre' => 'Segurtrex',              'company_id' => 18, 'sede_id' => 17],
107
108            // Valencia (company_id = 30) — sede_ids match actual DB ids
109            45 => ['nombre' => 'Guipons',      'company_id' => 30, 'sede_id' => 23],
110            47 => ['nombre' => 'AirFeu',       'company_id' => 30, 'sede_id' => 24],
111            58 => ['nombre' => 'Extinfuego',   'company_id' => 30, 'sede_id' => 58],
112            90 => ['nombre' => 'Vivó',         'company_id' => 30, 'sede_id' => 25],
113
114            // Andalucía (company_id = 21)
115            53 => ['nombre' => 'Drago',              'company_id' => 21, 'sede_id' => 16],
116            61 => ['nombre' => 'Grupo Fire Almeria', 'company_id' => 21, 'sede_id' => 201],
117            81 => ['nombre' => 'Robles',             'company_id' => 21, 'sede_id' => 202],
118            82 => ['nombre' => 'Robles Legacy',      'company_id' => 21, 'sede_id' => 202],
119
120            // Castilla y León (company_id = 23)
121            52 => ['nombre' => 'Crespo',  'company_id' => 23, 'sede_id' => 1000010],
122            88 => ['nombre' => 'Togasa',  'company_id' => 23, 'sede_id' => 999],
123
124            // Aragón (company_id = 9)
125            79 => ['nombre' => 'Oasys', 'company_id' => 9, 'sede_id' => 1000030],
126
127            // Baleares (company_id = 33)
128            77 => ['nombre' => 'Ni Foc Ni Fum', 'company_id' => 33, 'sede_id' => 402],
129            85 => ['nombre' => 'SeguCor',        'company_id' => 33, 'sede_id' => 401],
130            86 => ['nombre' => 'SeguCor Legacy', 'company_id' => 33, 'sede_id' => 401],
131        ];
132    }
133
134    /**
135     * Convierte el sk_sucursal de Zenital a sede_id en tbl_finance_sedes.
136     * Usa el mapa explícito; si no existe, devuelve el ID tal cual.
137     */
138    private function zenitalIdToSedeId(int $zenitalId): int
139    {
140        $map = $this->getZenitalSucursalesMap();
141
142        return $map[$zenitalId]['sede_id'] ?? $zenitalId;
143    }
144
145    /**
146     * Inverso: dado un sede_id, devuelve el zenital sk_sucursal.
147     */
148    private function sedeIdToZenitalId(int $sedeId): int
149    {
150        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
151            if ($info['sede_id'] === $sedeId) {
152                return $zenitalId;
153            }
154        }
155
156        return $sedeId;
157    }
158
159    /**
160     * FIRE-1027: true if the given sede_id is mapped to a Zenital sucursal,
161     * which means its data flows from the DWH and is owned by Zenital. The
162     * UI must not be able to write to such sedes — `list_resumen` pass 1
163     * covers them and never reads from `tbl_finance_resumen_anual`, so any
164     * row we create for them would be a stray write.
165     *
166     * Also resolves the local sede_id mapping the same way `resolveZenital
167     * SedeMap` does (the Zenital map stores hardcoded sede_ids that may not
168     * match the local PK on every install).
169     */
170    private function isZenitalSede(int $sedeId): bool
171    {
172        $localSedeName = TblFinanceSedes::where('id', $sedeId)->value('name');
173
174        foreach ($this->getZenitalSucursalesMap() as $info) {
175            if ($info['sede_id'] === $sedeId) {
176                return true;
177            }
178            if ($localSedeName && $info['nombre'] === $localSedeName) {
179                return true;
180            }
181        }
182
183        return false;
184    }
185
186    /**
187     * Devuelve el company_id correspondiente a un sede_id.
188     */
189    private function getCompanyIdBySedeId(int $sedeId): ?int
190    {
191        foreach ($this->getZenitalSucursalesMap() as $info) {
192            if ($info['sede_id'] === $sedeId) {
193                return $info['company_id'];
194            }
195        }
196
197        // Fallback: look up directly from the database for UI-created sedes
198        return TblFinanceSedes::where('id', $sedeId)->value('company_id');
199    }
200
201    /**
202     * Devuelve el mapa [sk_sucursal_zenital => sede_id] filtrado
203     * por los company_ids accesibles.
204     */
205    private function buildZenitalSedeMap(array $companyIds): array
206    {
207        $result = [];
208        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
209            if (in_array($info['company_id'], $companyIds)) {
210                $result[$zenitalId] = $info['sede_id'];
211            }
212        }
213
214        return $result;
215    }
216
217    /**
218     * Same as buildZenitalSedeMap but translates each hardcoded sede_id to
219     * the actual tbl_finance_sedes.id by matching company_id + name. The
220     * frontend identifies sedes by the local PK (not the Zenital hardcoded
221     * id) — without this translation, resumen/semanal responses come back
222     * with sede_ids the frontend can't link to any sede row.
223     */
224    private function resolveZenitalSedeMap(array $companyIds): array
225    {
226        $rawMap = $this->buildZenitalSedeMap($companyIds);
227        if (empty($rawMap)) {
228            return [];
229        }
230
231        $localSedes = TblFinanceSedes::whereIn('company_id', $companyIds)
232            ->where('is_active', 1)
233            ->get(['id', 'name', 'company_id']);
234
235        // [company_id][lowercased name] => local tbl_finance_sedes.id
236        $byCompanyName = [];
237        foreach ($localSedes as $s) {
238            $byCompanyName[$s->company_id][mb_strtolower((string) $s->name)] = $s->id;
239        }
240
241        $zenitalMap = $this->getZenitalSucursalesMap();
242        $result = [];
243        foreach ($rawMap as $zenitalId => $fallbackSedeId) {
244            $info = $zenitalMap[$zenitalId] ?? null;
245            if ($info) {
246                $localId = $byCompanyName[$info['company_id']][mb_strtolower((string) $info['nombre'])] ?? null;
247                $result[$zenitalId] = $localId ?? $fallbackSedeId;
248            } else {
249                $result[$zenitalId] = $fallbackSedeId;
250            }
251        }
252
253        return $result;
254    }
255
256    /**
257     * Sincroniza las regiones y sedes del mapa Zenital a las tablas correspondientes.
258     * Primero crea las regiones (tbl_finance_regions) y luego las sedes (tbl_finance_sedes).
259     * Debe llamarse antes de cualquier operación de insert/update en budget o prevision.
260     */
261    private function ensureSedesExist(): void
262    {
263        $map = $this->getZenitalSucursalesMap();
264
265        // Paso 1: Obtener company_ids únicos y crear regiones
266        $companyIds = array_unique(array_column($map, 'company_id'));
267        $companies = TblCompanies::whereIn('company_id', $companyIds)
268            ->get(['company_id', 'region'])
269            ->keyBy('company_id');
270
271        foreach ($companyIds as $companyId) {
272            $regionName = $companies[$companyId]->region ?? "Region $companyId";
273
274            // Verificar si ya existe
275            $exists = DB::table('tbl_finance_regions')->where('id', $companyId)->exists();
276
277            if (! $exists) {
278                // Insertar con ID específico
279                DB::table('tbl_finance_regions')->insert([
280                    'id' => $companyId,
281                    'company_id' => $companyId,
282                    'name' => $regionName,
283                    'code' => (string) $companyId,
284                    'is_active' => 1,
285                    'created_at' => now(),
286                    'updated_at' => now(),
287                ]);
288            } else {
289                // Actualizar si ya existe
290                DB::table('tbl_finance_regions')->where('id', $companyId)->update([
291                    'company_id' => $companyId,
292                    'name' => $regionName,
293                    'code' => (string) $companyId,
294                    'is_active' => 1,
295                    'updated_at' => now(),
296                ]);
297            }
298        }
299
300        // Paso 2: Crear sedes con referencias correctas a region_id
301        foreach ($map as $zenitalId => $info) {
302            $sedeId = $this->zenitalIdToSedeId($zenitalId);
303
304            // Verificar si ya existe
305            $exists = DB::table('tbl_finance_sedes')->where('id', $sedeId)->exists();
306
307            if (! $exists) {
308                // Insertar con ID específico
309                DB::table('tbl_finance_sedes')->insert([
310                    'id' => $sedeId,
311                    'company_id' => $info['company_id'],
312                    'region_id' => $info['company_id'], // FK a tbl_finance_regions.id
313                    'name' => $info['nombre'],
314                    'code' => (string) $zenitalId,
315                    'is_active' => 1,
316                    'created_at' => now(),
317                    'updated_at' => now(),
318                ]);
319            } else {
320                // Actualizar si ya existe
321                DB::table('tbl_finance_sedes')->where('id', $sedeId)->update([
322                    'company_id' => $info['company_id'],
323                    'region_id' => $info['company_id'],
324                    'name' => $info['nombre'],
325                    'code' => (string) $zenitalId,
326                    'is_active' => 1,
327                    'updated_at' => now(),
328                ]);
329            }
330        }
331    }
332
333    // -------------------------------------------------------
334    // SEDES & REGIONES  (derivadas dinámicamente del mapa Zenital)
335    // -------------------------------------------------------
336
337    /**
338     * GET /finance/regions
339     * Las regiones son los company_ids únicos del mapa Zenital que el usuario
340     * tiene acceso. El nombre se obtiene de TblCompanies.region.
341     */
342    public function list_regions(Request $request)
343    {
344        try {
345            $companyIds = $this->getCompanyIds($request);
346
347            // Return the real tbl_finance_regions.id so regions that share a
348            // company_id (e.g. Valencia, Alicante, Castellón all under
349            // company_id=30) don't collapse into a single frontend bucket.
350            // tbl_finance_sedes.region_id now points to this PK for all rows.
351            $data = TblFinanceRegions::whereIn('company_id', $companyIds)
352                ->where('is_active', 1)
353                ->orderBy('name')
354                ->get(['id', 'name', 'company_id']);
355
356            return response(['message' => 'OK', 'data' => $data]);
357        } catch (\Exception $e) {
358            /** @disregard P1014 */
359            $e->exceptionCode = 'LIST_FINANCE_REGIONS_EXCEPTION';
360            report($e);
361
362            return response(['message' => 'KO', 'error' => $e->getMessage()]);
363        }
364    }
365
366    /**
367     * GET /finance/sedes
368     * Las sedes son las sucursales del mapa Zenital accesibles por el usuario.
369     * El id es el sk_sucursal de Zenital (convertido a positivo si es negativo).
370     * region_id = company_id (coincide con el id de list_regions).
371     */
372    public function list_sedes(Request $request)
373    {
374        try {
375            $companyIds = $this->getCompanyIds($request);
376
377            $data = TblFinanceSedes::whereIn('company_id', $companyIds)
378                ->where('is_active', 1)
379                ->orderBy('name')
380                ->get(['id', 'name', 'company_id', 'region_id']);
381
382            return response(['message' => 'OK', 'data' => $data]);
383        } catch (\Exception $e) {
384            /** @disregard P1014 */
385            $e->exceptionCode = 'LIST_FINANCE_SEDES_EXCEPTION';
386            report($e);
387
388            return response(['message' => 'KO', 'error' => $e->getMessage()]);
389        }
390    }
391
392    // -------------------------------------------------------
393    // BUDGET ANUAL
394    // -------------------------------------------------------
395
396    /**
397     * GET /finance/budget?year=2025
398     * Devuelve todas las filas de Budget para el año dado,
399     * agrupadas por sede y región.
400     */
401    public function list_budget(Request $request)
402    {
403        try {
404            $companyIds = $this->getCompanyIds($request);
405            $year = $request->query('year', date('Y'));
406
407            $data = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
408                ->where('year', $year)
409                ->orderBy('sede_id')
410                ->orderBy('month')
411                ->get(['company_id', 'sede_id', 'year', 'month', 'amount']);
412
413            return response(['message' => 'OK', 'data' => $data]);
414        } catch (\Exception $e) {
415            /** @disregard P1014 */
416            $e->exceptionCode = 'LIST_BUDGET_EXCEPTION';
417            report($e);
418
419            return response(['message' => 'KO', 'error' => $e->getMessage()]);
420        }
421    }
422
423    /**
424     * POST /finance/budget
425     * Body: { sede_id, year, month, amount }
426     * sede_id = zenitalIdToSedeId(sk_sucursal)
427     */
428    public function upsert_budget(Request $request)
429    {
430        try {
431            $this->ensureSedesExist();
432
433            $data = $request->all();
434            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
435
436            if (! $companyId) {
437                return response(['message' => 'KO', 'error' => 'Sede no encontrada en el mapa de Zenital']);
438            }
439
440            TblFinanceBudgetAnual::updateOrCreate(
441                [
442                    'company_id' => $companyId,
443                    'sede_id' => $data['sede_id'],
444                    'year' => $data['year'],
445                    'month' => $data['month'],
446                ],
447                ['amount' => $data['amount']]
448            );
449
450            return response(['message' => 'OK']);
451        } catch (\Exception $e) {
452            /** @disregard P1014 */
453            $e->exceptionCode = 'UPSERT_BUDGET_EXCEPTION';
454            report($e);
455
456            return response(['message' => 'KO', 'error' => $e->getMessage()]);
457        }
458    }
459
460    /**
461     * POST /finance/budget/bulk
462     * Body: { year, rows: [{ sede_id, month, amount }, ...] }
463     */
464    public function bulk_upsert_budget(Request $request)
465    {
466        try {
467            $this->ensureSedesExist();
468
469            $year = $request->input('year');
470            $rows = $request->input('rows', []);
471
472            DB::transaction(function () use ($year, $rows) {
473                foreach ($rows as $row) {
474                    $companyId = $this->getCompanyIdBySedeId((int) $row['sede_id']);
475                    if (! $companyId) {
476                        continue;
477                    }
478
479                    TblFinanceBudgetAnual::updateOrCreate(
480                        [
481                            'company_id' => $companyId,
482                            'sede_id' => $row['sede_id'],
483                            'year' => $year,
484                            'month' => $row['month'],
485                        ],
486                        ['amount' => $row['amount']]
487                    );
488                }
489            });
490
491            return response(['message' => 'OK']);
492        } catch (\Exception $e) {
493            /** @disregard P1014 */
494            $e->exceptionCode = 'BULK_UPSERT_BUDGET_EXCEPTION';
495            report($e);
496
497            return response(['message' => 'KO', 'error' => $e->getMessage()]);
498        }
499    }
500
501    // -------------------------------------------------------
502    // PREVISIÓN ANUAL
503    // -------------------------------------------------------
504
505    public function list_prevision(Request $request)
506    {
507        try {
508            $companyIds = $this->getCompanyIds($request);
509            $year = $request->query('year', date('Y'));
510
511            $data = TblFinancePrevisionAnual::whereIn('company_id', $companyIds)
512                ->where('year', $year)
513                ->orderBy('sede_id')
514                ->orderBy('month')
515                ->get(['company_id', 'sede_id', 'year', 'month', 'amount']);
516
517            return response(['message' => 'OK', 'data' => $data]);
518        } catch (\Exception $e) {
519            /** @disregard P1014 */
520            $e->exceptionCode = 'LIST_PREVISION_EXCEPTION';
521            report($e);
522
523            return response(['message' => 'KO', 'error' => $e->getMessage()]);
524        }
525    }
526
527    public function upsert_prevision(Request $request)
528    {
529        try {
530            $this->ensureSedesExist();
531
532            $data = $request->all();
533            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
534
535            if (! $companyId) {
536                return response(['message' => 'KO', 'error' => 'Sede no encontrada en el mapa de Zenital']);
537            }
538
539            TblFinancePrevisionAnual::updateOrCreate(
540                [
541                    'company_id' => $companyId,
542                    'sede_id' => $data['sede_id'],
543                    'year' => $data['year'],
544                    'month' => $data['month'],
545                ],
546                ['amount' => $data['amount']]
547            );
548
549            return response(['message' => 'OK']);
550        } catch (\Exception $e) {
551            /** @disregard P1014 */
552            $e->exceptionCode = 'UPSERT_PREVISION_EXCEPTION';
553            report($e);
554
555            return response(['message' => 'KO', 'error' => $e->getMessage()]);
556        }
557    }
558
559    public function bulk_upsert_prevision(Request $request)
560    {
561        try {
562            $this->ensureSedesExist();
563
564            $year = $request->input('year');
565            $rows = $request->input('rows', []);
566
567            DB::transaction(function () use ($year, $rows) {
568                foreach ($rows as $row) {
569                    $companyId = $this->getCompanyIdBySedeId((int) $row['sede_id']);
570                    if (! $companyId) {
571                        continue;
572                    }
573
574                    TblFinancePrevisionAnual::updateOrCreate(
575                        [
576                            'company_id' => $companyId,
577                            'sede_id' => $row['sede_id'],
578                            'year' => $year,
579                            'month' => $row['month'],
580                        ],
581                        ['amount' => $row['amount']]
582                    );
583                }
584            });
585
586            return response(['message' => 'OK']);
587        } catch (\Exception $e) {
588            /** @disregard P1014 */
589            $e->exceptionCode = 'BULK_UPSERT_PREVISION_EXCEPTION';
590            report($e);
591
592            return response(['message' => 'KO', 'error' => $e->getMessage()]);
593        }
594    }
595
596    // -------------------------------------------------------
597    // RESUMEN ANUAL (solo lectura desde front, escritura automática)
598    // -------------------------------------------------------
599
600    /**
601     * GET /finance/resumen?year=2025
602     * Actuals, n-1 y n-2 se calculan en tiempo real desde Zenital (facturacion).
603     * Budget se lee de tbl_finance_budget_anual (MySQL).
604     * sede_id = zenitalIdToSedeId(sk_sucursal).
605     */
606    public function list_resumen(Request $request)
607    {
608        try {
609            $companyIds = $this->getCompanyIds($request);
610            $year = (int) $request->query('year', date('Y'));
611
612            // Map [sk_sucursal_zenital => sede_id]. FIX: the static map
613            // returns the hardcoded sede_id from getZenitalSucursalesMap
614            // (e.g. 111 for "Extintores Clemente"), but the frontend keys
615            // sedes by tbl_finance_sedes.id (e.g. 32). Translate by name
616            // so the resumen data's sede_id matches the list_sedes output.
617            $zenitalToSede = $this->resolveZenitalSedeMap($companyIds);
618
619            if (empty($zenitalToSede)) {
620                return response(['message' => 'OK', 'data' => []]);
621            }
622
623            $zenitalIds = array_keys($zenitalToSede);
624
625            // Facturación desde data warehouse para año actual, n-1 y n-2
626            $idx = [];
627            try {
628                $facturacion = DB::connection('zenital')
629                    ->table('fact_facturacion as f')
630                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
631                    ->whereIn('f.sk_sucursal', $zenitalIds)
632                    ->whereIn('d.ano', [$year, $year - 1, $year - 2])
633                    ->selectRaw('f.sk_sucursal, d.ano, d.num_mes as mes, SUM(f.base_imponible) as total')
634                    ->groupBy('f.sk_sucursal', 'd.ano', 'd.num_mes')
635                    ->get();
636
637                foreach ($facturacion as $row) {
638                    $sedeId = $zenitalToSede[$row->sk_sucursal] ?? $row->sk_sucursal;
639                    $idx[$sedeId][$row->ano][$row->mes] = ($idx[$sedeId][$row->ano][$row->mes] ?? 0) + (float) $row->total;
640                }
641            } catch (\Exception $e) {
642                Log::channel('third-party')->warning('Zenital connection failed in list_resumen, using local data only: '.$e->getMessage());
643            }
644
645            // Budget de MySQL: [sede_id][mes] = amount
646            $budgets = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
647                ->where('year', $year)
648                ->get(['sede_id', 'month', 'amount']);
649            $budgetIdx = [];
650            foreach ($budgets as $b) {
651                $budgetIdx[$b->sede_id][$b->month] = (float) $b->amount;
652            }
653
654            // Construir resultado (keyed by sede_id, handles multiple sk_sucursals per sede)
655            $data = [];
656            $processedSedes = [];
657            foreach ($zenitalToSede as $zenitalId => $sedeId) {
658                if (isset($processedSedes[$sedeId])) {
659                    continue;
660                }
661                $processedSedes[$sedeId] = true;
662
663                for ($month = 1; $month <= 12; $month++) {
664                    $actuals = $idx[$sedeId][$year][$month] ?? null;
665                    $n1 = $idx[$sedeId][$year - 1][$month] ?? null;
666                    $n2 = $idx[$sedeId][$year - 2][$month] ?? null;
667                    $budget = $budgetIdx[$sedeId][$month] ?? null;
668
669                    if ($actuals === null && $n1 === null && $n2 === null && $budget === null) {
670                        continue;
671                    }
672
673                    $data[] = [
674                        'sede_id' => $sedeId,
675                        'month' => $month,
676                        'actuals' => $actuals,
677                        'actuals_n1' => $n1,
678                        'actuals_n2' => $n2,
679                        'budget' => $budget,
680                        'deviation_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
681                        'deviation_vs_n2' => ($n2 && $actuals !== null) ? (($actuals - $n2) / $n2) : null,
682                        'deviation_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
683                        // FIRE-1027: tag the row source so the frontend knows
684                        // whether the cells are editable. 'zenital' rows come
685                        // from the DWH and stay read-only.
686                        'source' => 'zenital',
687                    ];
688                }
689            }
690
691            // Merge locally stored data for sedes not already covered by Zenital
692            $localData = TblFinanceResumenAnual::whereIn('company_id', $companyIds)
693                ->where('year', $year)
694                ->get();
695
696            $coveredSedes = [];
697            foreach ($data as $d) {
698                $coveredSedes[$d['sede_id'].'-'.$d['month']] = true;
699            }
700            foreach ($localData as $row) {
701                if (isset($coveredSedes[$row->sede_id.'-'.$row->month])) {
702                    continue;
703                }
704
705                $data[] = [
706                    'sede_id' => $row->sede_id,
707                    'month' => $row->month,
708                    'actuals' => $row->actuals,
709                    'actuals_n1' => $row->actuals_n1,
710                    'actuals_n2' => $row->actuals_n2,
711                    'budget' => $row->budget,
712                    'deviation_vs_n1' => $row->deviation_vs_n1,
713                    'deviation_vs_n2' => $row->deviation_vs_n2,
714                    'deviation_vs_budget' => $row->deviation_vs_budget,
715                    // FIRE-1027: surface the stored source so the frontend
716                    // can tell apart zenital / google_drive / manual_override
717                    // rows and gate editability accordingly.
718                    'source' => $row->source,
719                ];
720                $coveredSedes[$row->sede_id.'-'.$row->month] = true;
721            }
722
723            // Jorge's request: the Reporte Semanal tab picks up Google-Drive
724            // imported actuals (Aeroextinción, Cano Lopera, etc.) but Resumen
725            // Anual was ignoring them. Aggregate tbl_finance_report_semanal
726            // by (sede, year, month) for year / year-1 / year-2 and fold in
727            // any sede+month the loops above didn't already cover.
728            $weeklyAgg = TblFinanceReportSemanal::whereIn('company_id', $companyIds)
729                ->whereIn('year', [$year, $year - 1, $year - 2])
730                ->selectRaw('sede_id, year, month, SUM(actuals) as total_actuals')
731                ->groupBy('sede_id', 'year', 'month')
732                ->get();
733
734            $weeklyIdx = [];
735            foreach ($weeklyAgg as $row) {
736                if ($row->total_actuals === null) {
737                    continue;
738                }
739                $weeklyIdx[$row->sede_id][(int) $row->year][(int) $row->month] = (float) $row->total_actuals;
740            }
741
742            foreach ($weeklyIdx as $sedeId => $perYear) {
743                for ($month = 1; $month <= 12; $month++) {
744                    $key = $sedeId.'-'.$month;
745                    if (isset($coveredSedes[$key])) {
746                        continue;
747                    }
748                    $actuals = $perYear[$year][$month] ?? null;
749                    $n1 = $perYear[$year - 1][$month] ?? null;
750                    $n2 = $perYear[$year - 2][$month] ?? null;
751                    $budget = $budgetIdx[$sedeId][$month] ?? null;
752
753                    if ($actuals === null && $n1 === null && $n2 === null && $budget === null) {
754                        continue;
755                    }
756
757                    $data[] = [
758                        'sede_id' => $sedeId,
759                        'month' => $month,
760                        'actuals' => $actuals,
761                        'actuals_n1' => $n1,
762                        'actuals_n2' => $n2,
763                        'budget' => $budget,
764                        'deviation_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
765                        'deviation_vs_n2' => ($n2 && $actuals !== null) ? (($actuals - $n2) / $n2) : null,
766                        'deviation_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
767                        // FIRE-1027: this pass synthesises rows from the
768                        // weekly Drive imports — flag them so the UI shows
769                        // them as editable even though no resumen row exists
770                        // yet (one will be created on the user's first edit).
771                        'source' => 'google_drive',
772                    ];
773                    $coveredSedes[$key] = true;
774                }
775            }
776
777            return response(['message' => 'OK', 'data' => $data]);
778        } catch (\Exception $e) {
779            /** @disregard P1014 */
780            $e->exceptionCode = 'LIST_RESUMEN_EXCEPTION';
781            report($e);
782
783            return response(['message' => 'KO', 'error' => $e->getMessage()]);
784        }
785    }
786
787    /**
788     * POST /finance/resumen/load
789     * Llamado desde el job automático del día 1 de cada mes.
790     * Body: { year, month, rows: [{ sede_id, actuals, actuals_n1, actuals_n2 }] }
791     */
792    public function load_resumen(Request $request)
793    {
794        try {
795            $company = $this->getCompany($request);
796            $year = $request->input('year');
797            $month = $request->input('month');
798            $rows = $request->input('rows', []);
799
800            DB::transaction(function () use ($company, $year, $month, $rows) {
801                foreach ($rows as $row) {
802                    // FIRE-1027: don't clobber rows the user has manually
803                    // edited. The Zenital cron only owns rows whose source
804                    // is 'zenital' or NULL. 'google_drive' (Drive imports)
805                    // and 'manual_override' (UI edits) are user-managed.
806                    $existing = TblFinanceResumenAnual::where([
807                        'company_id' => $company->company_id,
808                        'sede_id' => $row['sede_id'],
809                        'year' => $year,
810                        'month' => $month,
811                    ])->first();
812                    if ($existing && in_array($existing->source, ['google_drive', 'manual_override'], true)) {
813                        continue;
814                    }
815
816                    $budget = TblFinanceBudgetAnual::where([
817                        'company_id' => $company->company_id,
818                        'sede_id' => $row['sede_id'],
819                        'year' => $year,
820                        'month' => $month,
821                    ])->value('amount');
822
823                    $actuals = $row['actuals'];
824                    $n1 = $row['actuals_n1'] ?? null;
825                    $n2 = $row['actuals_n2'] ?? null;
826
827                    TblFinanceResumenAnual::updateOrCreate(
828                        [
829                            'company_id' => $company->company_id,
830                            'sede_id' => $row['sede_id'],
831                            'year' => $year,
832                            'month' => $month,
833                        ],
834                        [
835                            'actuals' => $actuals,
836                            'actuals_n1' => $n1,
837                            'actuals_n2' => $n2,
838                            'budget' => $budget,
839                            'deviation_vs_n1' => ($n1 && $n1 != 0) ? (($actuals - $n1) / $n1) : null,
840                            'deviation_vs_n2' => ($n2 && $n2 != 0) ? (($actuals - $n2) / $n2) : null,
841                            'deviation_vs_budget' => ($budget && $budget != 0) ? (($actuals - $budget) / $budget) : null,
842                            'source' => 'zenital',
843                            'loaded_at' => now(),
844                        ]
845                    );
846                }
847            });
848
849            return response(['message' => 'OK']);
850        } catch (\Exception $e) {
851            /** @disregard P1014 */
852            $e->exceptionCode = 'LOAD_RESUMEN_EXCEPTION';
853            report($e);
854
855            return response(['message' => 'KO', 'error' => $e->getMessage()]);
856        }
857    }
858
859    // -------------------------------------------------------
860    // REPORT SEMANAL (solo lectura desde front, escritura automática)
861    // -------------------------------------------------------
862
863    /**
864     * GET /finance/report-semanal?year=2025&month=10
865     * YTD (meses 1..month) calculado en tiempo real desde Zenital.
866     * Budget y Previsión YTD se leen de MySQL.
867     * sede_id = zenitalIdToSedeId(sk_sucursal).
868     */
869    public function list_report_semanal(Request $request)
870    {
871        try {
872            $companyIds = $this->getCompanyIds($request);
873            $year = (int) $request->query('year', date('Y'));
874            $month = (int) $request->query('month', date('n'));
875
876            // Map [sk_sucursal_zenital => tbl_finance_sedes.id]. See the note
877            // in list_resumen — buildZenitalSedeMap returns the hardcoded id,
878            // we translate to the local sede id so the frontend can match.
879            $zenitalToSede = $this->resolveZenitalSedeMap($companyIds);
880
881            if (empty($zenitalToSede)) {
882                return response(['message' => 'OK', 'data' => []]);
883            }
884
885            $zenitalIds = array_keys($zenitalToSede);
886
887            // Datos mensuales desde data warehouse (año actual y año anterior, solo el mes seleccionado)
888            $ytdIdx = [];
889            $latestDates = collect();
890            try {
891                $facturacion = DB::connection('zenital')
892                    ->table('fact_facturacion as f')
893                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
894                    ->whereIn('f.sk_sucursal', $zenitalIds)
895                    ->whereIn('d.ano', [$year, $year - 1])
896                    ->where('d.num_mes', $month)
897                    ->selectRaw('f.sk_sucursal, d.ano, SUM(f.base_imponible) as total')
898                    ->groupBy('f.sk_sucursal', 'd.ano')
899                    ->get();
900
901                foreach ($facturacion as $row) {
902                    $ytdIdx[$row->sk_sucursal][$row->ano] = (float) $row->total;
903                }
904
905                // Fecha más reciente de emisión por sucursal (como "week_date")
906                $latestDates = DB::connection('zenital')
907                    ->table('fact_facturacion as f')
908                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
909                    ->whereIn('f.sk_sucursal', $zenitalIds)
910                    ->where('d.ano', $year)
911                    ->where('d.num_mes', $month)
912                    ->selectRaw('f.sk_sucursal, MAX(d.fecha) as latest_date')
913                    ->groupBy('f.sk_sucursal')
914                    ->get()
915                    ->keyBy('sk_sucursal');
916            } catch (\Exception $e) {
917                Log::channel('third-party')->warning('Zenital connection failed in list_report_semanal, using local data only: '.$e->getMessage());
918            }
919
920            // Budget mensual de MySQL: solo el mes seleccionado
921            $budgets = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
922                ->where('year', $year)
923                ->where('month', $month)
924                ->get(['sede_id', 'amount']);
925            $budgetYtd = [];
926            foreach ($budgets as $b) {
927                $budgetYtd[$b->sede_id] = (float) $b->amount;
928            }
929
930            // Previsión mensual de MySQL: solo el mes seleccionado
931            $previsions = TblFinancePrevisionAnual::whereIn('company_id', $companyIds)
932                ->where('year', $year)
933                ->where('month', $month)
934                ->get(['sede_id', 'amount']);
935            $previsionYtd = [];
936            foreach ($previsions as $p) {
937                $previsionYtd[$p->sede_id] = (float) $p->amount;
938            }
939
940            // Construir resultado
941            $data = [];
942            foreach ($zenitalToSede as $zenitalId => $sedeId) {
943                $actuals = $ytdIdx[$zenitalId][$year] ?? null;
944                $n1 = $ytdIdx[$zenitalId][$year - 1] ?? null;
945                $budget = $budgetYtd[$sedeId] ?? null;
946                $prevision = $previsionYtd[$sedeId] ?? null;
947                $weekDate = $latestDates[$zenitalId]->latest_date ?? null;
948
949                if ($actuals === null && $n1 === null && $budget === null && $prevision === null) {
950                    continue;
951                }
952
953                $data[] = [
954                    'sede_id' => $sedeId,
955                    'year' => $year,
956                    'month' => $month,
957                    'week_date' => $weekDate,
958                    'actuals' => $actuals,
959                    'actuals_n1' => $n1,
960                    'budget' => $budget,
961                    'prevision' => $prevision,
962                    'deviation_vs_n1' => ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null,
963                    'deviation_pct_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
964                    'deviation_vs_budget' => ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null,
965                    'deviation_pct_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
966                    'deviation_vs_prevision' => ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null,
967                    'deviation_pct_vs_prevision' => ($prevision && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null,
968                    // FIRE-1027: zenital-sourced rows are read-only.
969                    'source' => 'zenital',
970                ];
971            }
972
973            // Merge Drive-imported data for sedes not already covered by Zenital.
974            // The import stores one row per week; aggregate to a single row per
975            // sede (summing actuals) so the UI renders exactly like a Zenital
976            // sede. n-1 comes from the same table for the prior year.
977            $coveredSedes = [];
978            foreach ($data as $d) {
979                $coveredSedes[$d['sede_id']] = true;
980            }
981
982            $localAgg = TblFinanceReportSemanal::whereIn('company_id', $companyIds)
983                ->where('month', $month)
984                ->whereIn('year', [$year, $year - 1])
985                ->selectRaw('sede_id, year, SUM(actuals) as total_actuals, MAX(week_date) as latest_week')
986                ->groupBy('sede_id', 'year')
987                ->get();
988
989            $localIdx = [];
990            foreach ($localAgg as $row) {
991                $localIdx[$row->sede_id][(int) $row->year] = [
992                    'total' => $row->total_actuals !== null ? (float) $row->total_actuals : null,
993                    'latest_week' => $row->latest_week,
994                ];
995            }
996
997            foreach ($localIdx as $sedeId => $perYear) {
998                if (isset($coveredSedes[$sedeId])) {
999                    continue;
1000                }
1001                $actuals = $perYear[$year]['total'] ?? null;
1002                $n1 = $perYear[$year - 1]['total'] ?? null;
1003                $budget = $budgetYtd[$sedeId] ?? null;
1004                $prevision = $previsionYtd[$sedeId] ?? null;
1005
1006                if ($actuals === null && $n1 === null && $budget === null && $prevision === null) {
1007                    continue;
1008                }
1009
1010                $data[] = [
1011                    'sede_id' => $sedeId,
1012                    'year' => $year,
1013                    'month' => $month,
1014                    'week_date' => $perYear[$year]['latest_week'] ?? null,
1015                    'actuals' => $actuals,
1016                    'actuals_n1' => $n1,
1017                    'budget' => $budget,
1018                    'prevision' => $prevision,
1019                    'deviation_vs_n1' => ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null,
1020                    'deviation_pct_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
1021                    'deviation_vs_budget' => ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null,
1022                    'deviation_pct_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
1023                    'deviation_vs_prevision' => ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null,
1024                    'deviation_pct_vs_prevision' => ($prevision && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null,
1025                    // FIRE-1027: drive-imported aggregation, editable.
1026                    'source' => 'google_drive',
1027                ];
1028            }
1029
1030            return response(['message' => 'OK', 'data' => $data]);
1031        } catch (\Exception $e) {
1032            /** @disregard P1014 */
1033            $e->exceptionCode = 'LIST_REPORT_SEMANAL_EXCEPTION';
1034            report($e);
1035
1036            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1037        }
1038    }
1039
1040    /**
1041     * POST /finance/report-semanal/load
1042     * Llamado desde el job automático de cada sábado.
1043     * Body: { week_date, year, month, rows: [{ sede_id, actuals, actuals_n1, budget, prevision }] }
1044     */
1045    public function load_report_semanal(Request $request)
1046    {
1047        try {
1048            $company = $this->getCompany($request);
1049            $weekDate = $request->input('week_date');
1050            $year = $request->input('year');
1051            $month = $request->input('month');
1052            $rows = $request->input('rows', []);
1053
1054            DB::transaction(function () use ($company, $weekDate, $year, $month, $rows) {
1055                foreach ($rows as $row) {
1056                    // FIRE-1027: skip rows the user has manually edited
1057                    // (source='manual_override') or that came from the Drive
1058                    // weekly Excel (source='google_drive') — same guard as
1059                    // load_resumen above.
1060                    $existing = TblFinanceReportSemanal::where([
1061                        'company_id' => $company->company_id,
1062                        'sede_id' => $row['sede_id'],
1063                        'week_date' => $weekDate,
1064                    ])->first();
1065                    if ($existing && in_array($existing->source, ['google_drive', 'manual_override'], true)) {
1066                        continue;
1067                    }
1068
1069                    $actuals = $row['actuals'];
1070                    $n1 = $row['actuals_n1'] ?? null;
1071                    $budget = $row['budget'] ?? null;
1072                    $prevision = $row['prevision'] ?? null;
1073
1074                    TblFinanceReportSemanal::updateOrCreate(
1075                        [
1076                            'company_id' => $company->company_id,
1077                            'sede_id' => $row['sede_id'],
1078                            'week_date' => $weekDate,
1079                        ],
1080                        [
1081                            'year' => $year,
1082                            'month' => $month,
1083                            'actuals' => $actuals,
1084                            'actuals_n1' => $n1,
1085                            'budget' => $budget,
1086                            'prevision' => $prevision,
1087                            'deviation_vs_n1' => ($n1 !== null) ? ($actuals - $n1) : null,
1088                            'deviation_pct_vs_n1' => ($n1 && $n1 != 0) ? (($actuals - $n1) / $n1) : null,
1089                            'deviation_vs_budget' => ($budget !== null) ? ($actuals - $budget) : null,
1090                            'deviation_pct_vs_budget' => ($budget && $budget != 0) ? (($actuals - $budget) / $budget) : null,
1091                            'deviation_vs_prevision' => ($prevision !== null) ? ($actuals - $prevision) : null,
1092                            'deviation_pct_vs_prevision' => ($prevision && $prevision != 0) ? (($actuals - $prevision) / $prevision) : null,
1093                            'source' => 'zenital',
1094                            'loaded_at' => now(),
1095                        ]
1096                    );
1097                }
1098            });
1099
1100            return response(['message' => 'OK']);
1101        } catch (\Exception $e) {
1102            /** @disregard P1014 */
1103            $e->exceptionCode = 'LOAD_REPORT_SEMANAL_EXCEPTION';
1104            report($e);
1105
1106            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1107        }
1108    }
1109
1110    // -------------------------------------------------------
1111    // EDICIÓN CELDA-A-CELDA (FIRE-1027)
1112    // -------------------------------------------------------
1113
1114    /**
1115     * POST /finance/resumen/upsert-cell
1116     *
1117     * FIRE-1027: per-cell write for sedes whose data flows from the manual
1118     * Excel rather than Zenital. Berta needs to be able to type the N-1 /
1119     * N-2 numbers directly for those sedes. Refuses to clobber a Zenital
1120     * row — that data is owned by the DWH and should never be edited from
1121     * the UI.
1122     *
1123     * Body: { sede_id, year, month, field, value }
1124     *   field ∈ {actuals, actuals_n1, actuals_n2, budget}
1125     *   value: number | null  (null clears the cell)
1126     */
1127    public function upsert_resumen_cell(Request $request)
1128    {
1129        try {
1130            $data = $request->validate([
1131                'sede_id' => 'required|integer',
1132                'year'    => 'required|integer',
1133                'month'   => 'required|integer|between:1,12',
1134                'field'   => 'required|in:actuals,actuals_n1,actuals_n2,budget',
1135                'value'   => 'nullable|numeric',
1136            ]);
1137
1138            // FIRE-1027: resolve company from the sede directly. We can't use
1139            // `getCompany($request)` because the user often has "Todas las
1140            // regiones" selected (Region header = "All"), and Drive-imported
1141            // sedes don't even live in the currently-selected region — every
1142            // sede already knows its own company_id.
1143            $sede = TblFinanceSedes::find($data['sede_id']);
1144            if (! $sede) {
1145                return response(['message' => 'KO', 'error' => 'sede_not_found', 'sede_id' => $data['sede_id']], 404);
1146            }
1147
1148            // FIRE-1027: hard-refuse writes to Zenital-mapped sedes. Their
1149            // data is synthesised from the DWH at list time so a row in
1150            // tbl_finance_resumen_anual is never read for them — but we
1151            // shouldn't allow a stray manual_override row to accumulate
1152            // either. The frontend should never send these, but enforce
1153            // server-side too.
1154            if ($this->isZenitalSede($data['sede_id'])) {
1155                return response(['message' => 'KO', 'error' => 'cannot_edit_zenital_row'], 403);
1156            }
1157
1158            $row = TblFinanceResumenAnual::firstOrNew([
1159                'company_id' => $sede->company_id,
1160                'sede_id'    => $data['sede_id'],
1161                'year'       => $data['year'],
1162                'month'      => $data['month'],
1163            ]);
1164
1165            $row->{$data['field']} = $data['value'];
1166            $row->source = 'manual_override';
1167            $row->loaded_at = now();
1168
1169            // Recompute deviations against the (possibly newly set) values.
1170            $actuals = $row->actuals;
1171            $n1 = $row->actuals_n1;
1172            $n2 = $row->actuals_n2;
1173            $budget = $row->budget;
1174            $row->deviation_vs_n1 = ($n1 && $n1 != 0 && $actuals !== null) ? (($actuals - $n1) / $n1) : null;
1175            $row->deviation_vs_n2 = ($n2 && $n2 != 0 && $actuals !== null) ? (($actuals - $n2) / $n2) : null;
1176            $row->deviation_vs_budget = ($budget && $budget != 0 && $actuals !== null) ? (($actuals - $budget) / $budget) : null;
1177
1178            $row->save();
1179
1180            return response(['message' => 'OK', 'data' => $row]);
1181        } catch (\Exception $e) {
1182            /** @disregard P1014 */
1183            $e->exceptionCode = 'UPSERT_RESUMEN_CELL_EXCEPTION';
1184            report($e);
1185
1186            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1187        }
1188    }
1189
1190    /**
1191     * POST /finance/report-semanal/upsert-cell
1192     *
1193     * FIRE-1027: per-cell write for the weekly report. Same shape as
1194     * upsert_resumen_cell but for tbl_finance_report_semanal, which is
1195     * keyed by (company_id, sede_id, week_date).
1196     *
1197     * The Drive importer stores one row per week; this endpoint edits
1198     * whichever week the caller specifies. If `week_date` isn't supplied,
1199     * it falls back to the most recent week_date for that sede in the
1200     * given year/month — which matches what list_report_semanal renders.
1201     *
1202     * Body: { sede_id, year, month, week_date?, field, value }
1203     *   field ∈ {actuals, actuals_n1, budget, prevision}
1204     */
1205    public function upsert_report_semanal_cell(Request $request)
1206    {
1207        try {
1208            $data = $request->validate([
1209                'sede_id'   => 'required|integer',
1210                'year'      => 'required|integer',
1211                'month'     => 'required|integer|between:1,12',
1212                'week_date' => 'nullable|date',
1213                'field'     => 'required|in:actuals,actuals_n1,budget,prevision',
1214                'value'     => 'nullable|numeric',
1215            ]);
1216
1217            // FIRE-1027: resolve company from the sede (not the Region
1218            // header) — same reason as upsert_resumen_cell.
1219            $sede = TblFinanceSedes::find($data['sede_id']);
1220            if (! $sede) {
1221                return response(['message' => 'KO', 'error' => 'sede_not_found', 'sede_id' => $data['sede_id']], 404);
1222            }
1223
1224            // FIRE-1027: same hard guard as upsert_resumen_cell.
1225            if ($this->isZenitalSede($data['sede_id'])) {
1226                return response(['message' => 'KO', 'error' => 'cannot_edit_zenital_row'], 403);
1227            }
1228
1229            // Resolve week_date if the caller didn't supply one. The list
1230            // view aggregates SUM(actuals) per (sede, year, month) and tags
1231            // the response with the latest week_date — match that.
1232            $weekDate = $data['week_date'] ?? null;
1233            if (!$weekDate) {
1234                $weekDate = TblFinanceReportSemanal::where([
1235                        'company_id' => $sede->company_id,
1236                        'sede_id'    => $data['sede_id'],
1237                        'year'       => $data['year'],
1238                        'month'      => $data['month'],
1239                    ])
1240                    ->orderByDesc('week_date')
1241                    ->value('week_date');
1242            }
1243            if (!$weekDate) {
1244                // No row for this sede/month yet — anchor to the first day
1245                // of the month so the upsert has a deterministic key.
1246                $weekDate = sprintf('%04d-%02d-01', $data['year'], $data['month']);
1247            }
1248
1249            $row = TblFinanceReportSemanal::firstOrNew([
1250                'company_id' => $sede->company_id,
1251                'sede_id'    => $data['sede_id'],
1252                'week_date'  => $weekDate,
1253            ]);
1254
1255            if ($row->exists && $row->source === 'zenital') {
1256                return response(['message' => 'KO', 'error' => 'cannot_edit_zenital_row'], 403);
1257            }
1258
1259            $row->year = $data['year'];
1260            $row->month = $data['month'];
1261            $row->{$data['field']} = $data['value'];
1262            $row->source = 'manual_override';
1263            $row->loaded_at = now();
1264
1265            $actuals = $row->actuals;
1266            $n1 = $row->actuals_n1;
1267            $budget = $row->budget;
1268            $prevision = $row->prevision;
1269            $row->deviation_vs_n1 = ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null;
1270            $row->deviation_pct_vs_n1 = ($n1 && $n1 != 0 && $actuals !== null) ? (($actuals - $n1) / $n1) : null;
1271            $row->deviation_vs_budget = ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null;
1272            $row->deviation_pct_vs_budget = ($budget && $budget != 0 && $actuals !== null) ? (($actuals - $budget) / $budget) : null;
1273            $row->deviation_vs_prevision = ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null;
1274            $row->deviation_pct_vs_prevision = ($prevision && $prevision != 0 && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null;
1275
1276            $row->save();
1277
1278            return response(['message' => 'OK', 'data' => $row]);
1279        } catch (\Exception $e) {
1280            /** @disregard P1014 */
1281            $e->exceptionCode = 'UPSERT_REPORT_SEMANAL_CELL_EXCEPTION';
1282            report($e);
1283
1284            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1285        }
1286    }
1287
1288    // -------------------------------------------------------
1289    // DESTINATARIOS REPORTE SEMANAL
1290    // -------------------------------------------------------
1291
1292    public function list_recipients(Request $request)
1293    {
1294        try {
1295            $companyIds = $this->getCompanyIds($request);
1296            // "Todas las regiones" recipients are stored with company_id = NULL
1297            // and must appear in every region's listing — otherwise adding one
1298            // looks like the save failed silently (it actually saved but the
1299            // reload couldn't find it).
1300            $data = TblFinanceReportRecipients::where(function ($q) use ($companyIds) {
1301                    $q->whereIn('company_id', $companyIds)
1302                      ->orWhereNull('company_id');
1303                })
1304                ->orderBy('name')
1305                ->get();
1306
1307            return response(['message' => 'OK', 'data' => $data]);
1308        } catch (\Exception $e) {
1309            /** @disregard P1014 */
1310            $e->exceptionCode = 'LIST_RECIPIENTS_EXCEPTION';
1311            report($e);
1312
1313            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1314        }
1315    }
1316
1317    public function create_recipient(Request $request)
1318    {
1319        try {
1320            $data = $request->all();
1321
1322            // company_id comes from the modal's region selector. 'all' = NULL (todas
1323            // las regiones). The old implementation forced the header region, which
1324            // broke when the user was viewing "Todas las regiones" or picked a
1325            // different region in the modal.
1326            $companyId = $data['company_id'] ?? null;
1327            if ($companyId === 'all' || $companyId === '') {
1328                $companyId = null;
1329            }
1330
1331            $recipient = TblFinanceReportRecipients::create([
1332                'company_id' => $companyId,
1333                'name' => $data['name'],
1334                'email' => $data['email'],
1335                'is_active' => $data['is_active'] ?? 1,
1336                'days_before_delete_errors' => $data['days_before_delete_errors'] ?? 7,
1337            ]);
1338
1339            return response(['message' => 'OK', 'data' => $recipient]);
1340        } catch (\Exception $e) {
1341            /** @disregard P1014 */
1342            $e->exceptionCode = 'CREATE_RECIPIENT_EXCEPTION';
1343            report($e);
1344
1345            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1346        }
1347    }
1348
1349    public function update_recipient(Request $request, $id)
1350    {
1351        try {
1352            $data = $request->all();
1353
1354            if (array_key_exists('company_id', $data)
1355                && ($data['company_id'] === 'all' || $data['company_id'] === '')) {
1356                $data['company_id'] = null;
1357            }
1358
1359            // Whitelist updatable fields so unrelated payload keys (e.g. zone)
1360            // don't leak into the update.
1361            $update = array_intersect_key($data, array_flip([
1362                'company_id', 'name', 'email', 'is_active', 'days_before_delete_errors',
1363            ]));
1364
1365            TblFinanceReportRecipients::where('id', $id)->update($update);
1366
1367            return response(['message' => 'OK']);
1368        } catch (\Exception $e) {
1369            /** @disregard P1014 */
1370            $e->exceptionCode = 'UPDATE_RECIPIENT_EXCEPTION';
1371            report($e);
1372
1373            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1374        }
1375    }
1376
1377    public function delete_recipient($id)
1378    {
1379        try {
1380            TblFinanceReportRecipients::where('id', $id)->delete();
1381
1382            return response(['message' => 'OK']);
1383        } catch (\Exception $e) {
1384            /** @disregard P1014 */
1385            $e->exceptionCode = 'DELETE_RECIPIENT_EXCEPTION';
1386            report($e);
1387
1388            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1389        }
1390    }
1391
1392    public function send_report(Request $request)
1393    {
1394        try {
1395            $params = [];
1396            if ($request->input('month')) {
1397                $params['--month'] = (int) $request->input('month');
1398            }
1399            if ($request->input('type')) {
1400                $params['--type'] = $request->input('type');
1401            }
1402            $exitCode = \Illuminate\Support\Facades\Artisan::call('finance:send-report', $params);
1403
1404            return response([
1405                'message' => 'OK',
1406                'output' => \Illuminate\Support\Facades\Artisan::output(),
1407                'exit_code' => $exitCode,
1408            ]);
1409        } catch (\Exception $e) {
1410            /** @disregard P1014 */
1411            $e->exceptionCode = 'SEND_FINANCE_REPORT_EXCEPTION';
1412            report($e);
1413
1414            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1415        }
1416    }
1417
1418    public function test_report(Request $request)
1419    {
1420        try {
1421            $params = ['--test' => true];
1422            if ($request->query('month')) {
1423                $params['--month'] = (int) $request->query('month');
1424            }
1425            if ($request->query('type')) {
1426                $params['--type'] = $request->query('type');
1427            }
1428            $exitCode = \Illuminate\Support\Facades\Artisan::call('finance:send-report', $params);
1429
1430            return response([
1431                'message' => 'OK',
1432                'output' => \Illuminate\Support\Facades\Artisan::output(),
1433                'exit_code' => $exitCode,
1434            ]);
1435        } catch (\Exception $e) {
1436            /** @disregard P1014 */
1437            $e->exceptionCode = 'TEST_FINANCE_REPORT_EXCEPTION';
1438            report($e);
1439
1440            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1441        }
1442    }
1443
1444    // -------------------------------------------------------
1445    // GOOGLE DRIVE IMPORT
1446    // -------------------------------------------------------
1447
1448    public function import_from_drive()
1449    {
1450        try {
1451            $exitCode = \Illuminate\Support\Facades\Artisan::call('finance:import-drive');
1452
1453            return response([
1454                'message' => 'OK',
1455                'output' => \Illuminate\Support\Facades\Artisan::output(),
1456                'exit_code' => $exitCode,
1457            ]);
1458        } catch (\Exception $e) {
1459            $e->exceptionCode = 'IMPORT_FINANCE_DRIVE_EXCEPTION';
1460            report($e);
1461
1462            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1463        }
1464    }
1465
1466    // -------------------------------------------------------
1467    // SEDES CRUD
1468    // -------------------------------------------------------
1469
1470    public function create_sede(Request $request)
1471    {
1472        try {
1473            $data = $request->all();
1474
1475            $sede = TblFinanceSedes::create([
1476                'name' => $data['name'],
1477                'company_id' => $data['company_id'],
1478                'region_id' => $data['region_id'],
1479                'code' => $data['code'] ?? null,
1480                'is_active' => 1,
1481            ]);
1482
1483            return response(['message' => 'OK', 'data' => $sede]);
1484        } catch (\Exception $e) {
1485            /** @disregard P1014 */
1486            $e->exceptionCode = 'CREATE_SEDE_EXCEPTION';
1487            report($e);
1488
1489            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1490        }
1491    }
1492
1493    public function delete_sede(Request $request, $id)
1494    {
1495        try {
1496            $sede = TblFinanceSedes::findOrFail($id);
1497            $sede->update(['is_active' => 0]);
1498
1499            return response(['message' => 'OK']);
1500        } catch (\Exception $e) {
1501            /** @disregard P1014 */
1502            $e->exceptionCode = 'DELETE_SEDE_EXCEPTION';
1503            report($e);
1504
1505            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1506        }
1507    }
1508
1509    // -------------------------------------------------------
1510    // Month Config (billing close dates)
1511    // -------------------------------------------------------
1512
1513    /**
1514     * GET /finance-month-config?year=2026
1515     * Returns all month config rows for the given year.
1516     */
1517    public function list_month_config(Request $request)
1518    {
1519        try {
1520            $year = (int) $request->query('year', date('Y'));
1521
1522            $data = TblFinanceMonthConfig::where('year', $year)
1523                ->orderBy('month')
1524                ->get();
1525
1526            return response(['message' => 'OK', 'data' => $data]);
1527        } catch (\Exception $e) {
1528            /** @disregard P1014 */
1529            $e->exceptionCode = 'LIST_MONTH_CONFIG_EXCEPTION';
1530            report($e);
1531
1532            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1533        }
1534    }
1535
1536    /**
1537     * PUT /finance-month-config/{id}
1538     * Updates close_date and/or force_report_month for a row.
1539     */
1540    public function update_month_config(Request $request, $id)
1541    {
1542        try {
1543            $config = TblFinanceMonthConfig::findOrFail($id);
1544
1545            $config->update($request->only(['close_date', 'force_report_month']));
1546
1547            return response(['message' => 'OK', 'data' => $config->fresh()]);
1548        } catch (\Exception $e) {
1549            /** @disregard P1014 */
1550            $e->exceptionCode = 'UPDATE_MONTH_CONFIG_EXCEPTION';
1551            report($e);
1552
1553            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1554        }
1555    }
1556}