Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 602
0.00% covered (danger)
0.00%
0 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
FinanceController
0.00% covered (danger)
0.00%
0 / 602
0.00% covered (danger)
0.00%
0 / 32
19460
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 / 36
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
 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
 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 / 70
0.00% covered (danger)
0.00%
0 / 1
462
 load_resumen
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
90
 list_report_semanal
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 1
756
 load_report_semanal
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
156
 list_recipients
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 create_recipient
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 update_recipient
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 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
91            // Madrid (company_id = 18)
92            49 => ['nombre' => 'Alcarrena',              'company_id' => 18, 'sede_id' => 49],
93            50 => ['nombre' => 'Anin',                   'company_id' => 18, 'sede_id' => 50],
94            54 => ['nombre' => 'EnFire',                 'company_id' => 18, 'sede_id' => 54],
95            55 => ['nombre' => 'ExConin',                'company_id' => 18, 'sede_id' => 55],
96            56 => ['nombre' => 'ExFire',                 'company_id' => 18, 'sede_id' => 56],
97            66 => ['nombre' => 'Grupo Fire Guadalajara', 'company_id' => 18, 'sede_id' => 66],
98            68 => ['nombre' => 'Grupo Fire Madrid',      'company_id' => 18, 'sede_id' => 68],
99            71 => ['nombre' => 'ICF',                    'company_id' => 18, 'sede_id' => 71],
100            76 => ['nombre' => 'Montoya',                'company_id' => 18, 'sede_id' => 76],
101            80 => ['nombre' => 'Precoin',                'company_id' => 18, 'sede_id' => 80],
102            83 => ['nombre' => 'Rosegur',                'company_id' => 18, 'sede_id' => 83],
103            87 => ['nombre' => 'Segurtrex',              'company_id' => 18, 'sede_id' => 87],
104
105            // Valencia (company_id = 30)
106            45 => ['nombre' => 'Guipons',      'company_id' => 30, 'sede_id' => 45],
107            47 => ['nombre' => 'AirFeu',       'company_id' => 30, 'sede_id' => 47],
108            58 => ['nombre' => 'Extinfuego',   'company_id' => 30, 'sede_id' => 58],
109            90 => ['nombre' => 'Vivó',         'company_id' => 30, 'sede_id' => 90],
110
111            // Andalucía (company_id = 21)
112            53 => ['nombre' => 'Drago',              'company_id' => 21, 'sede_id' => 53],
113            61 => ['nombre' => 'Grupo Fire Almeria', 'company_id' => 21, 'sede_id' => 61],
114            81 => ['nombre' => 'Robles',             'company_id' => 21, 'sede_id' => 81],
115
116            // Castilla y León (company_id = 23)
117            52 => ['nombre' => 'Crespo', 'company_id' => 23, 'sede_id' => 52],
118
119            // Aragón (company_id = 9)
120            79 => ['nombre' => 'Oasys', 'company_id' => 9, 'sede_id' => 79],
121
122            // Baleares (company_id = 33)
123            77 => ['nombre' => 'Ni Foc Ni Fum', 'company_id' => 33, 'sede_id' => 77],
124            85 => ['nombre' => 'SeguCor',        'company_id' => 33, 'sede_id' => 85],
125        ];
126    }
127
128    /**
129     * Convierte el sk_sucursal de Zenital a sede_id en tbl_finance_sedes.
130     * Usa el mapa explícito; si no existe, devuelve el ID tal cual.
131     */
132    private function zenitalIdToSedeId(int $zenitalId): int
133    {
134        $map = $this->getZenitalSucursalesMap();
135
136        return $map[$zenitalId]['sede_id'] ?? $zenitalId;
137    }
138
139    /**
140     * Inverso: dado un sede_id, devuelve el zenital sk_sucursal.
141     */
142    private function sedeIdToZenitalId(int $sedeId): int
143    {
144        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
145            if ($info['sede_id'] === $sedeId) {
146                return $zenitalId;
147            }
148        }
149
150        return $sedeId;
151    }
152
153    /**
154     * Devuelve el company_id correspondiente a un sede_id.
155     */
156    private function getCompanyIdBySedeId(int $sedeId): ?int
157    {
158        foreach ($this->getZenitalSucursalesMap() as $info) {
159            if ($info['sede_id'] === $sedeId) {
160                return $info['company_id'];
161            }
162        }
163
164        return null;
165    }
166
167    /**
168     * Devuelve el mapa [sk_sucursal_zenital => sede_id] filtrado
169     * por los company_ids accesibles.
170     */
171    private function buildZenitalSedeMap(array $companyIds): array
172    {
173        $result = [];
174        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
175            if (in_array($info['company_id'], $companyIds)) {
176                $result[$zenitalId] = $info['sede_id'];
177            }
178        }
179
180        return $result;
181    }
182
183    /**
184     * Sincroniza las regiones y sedes del mapa Zenital a las tablas correspondientes.
185     * Primero crea las regiones (tbl_finance_regions) y luego las sedes (tbl_finance_sedes).
186     * Debe llamarse antes de cualquier operación de insert/update en budget o prevision.
187     */
188    private function ensureSedesExist(): void
189    {
190        $map = $this->getZenitalSucursalesMap();
191
192        // Paso 1: Obtener company_ids únicos y crear regiones
193        $companyIds = array_unique(array_column($map, 'company_id'));
194        $companies = TblCompanies::whereIn('company_id', $companyIds)
195            ->get(['company_id', 'region'])
196            ->keyBy('company_id');
197
198        foreach ($companyIds as $companyId) {
199            $regionName = $companies[$companyId]->region ?? "Region $companyId";
200
201            // Verificar si ya existe
202            $exists = DB::table('tbl_finance_regions')->where('id', $companyId)->exists();
203
204            if (! $exists) {
205                // Insertar con ID específico
206                DB::table('tbl_finance_regions')->insert([
207                    'id' => $companyId,
208                    'company_id' => $companyId,
209                    'name' => $regionName,
210                    'code' => (string) $companyId,
211                    'is_active' => 1,
212                    'created_at' => now(),
213                    'updated_at' => now(),
214                ]);
215            } else {
216                // Actualizar si ya existe
217                DB::table('tbl_finance_regions')->where('id', $companyId)->update([
218                    'company_id' => $companyId,
219                    'name' => $regionName,
220                    'code' => (string) $companyId,
221                    'is_active' => 1,
222                    'updated_at' => now(),
223                ]);
224            }
225        }
226
227        // Paso 2: Crear sedes con referencias correctas a region_id
228        foreach ($map as $zenitalId => $info) {
229            $sedeId = $this->zenitalIdToSedeId($zenitalId);
230
231            // Verificar si ya existe
232            $exists = DB::table('tbl_finance_sedes')->where('id', $sedeId)->exists();
233
234            if (! $exists) {
235                // Insertar con ID específico
236                DB::table('tbl_finance_sedes')->insert([
237                    'id' => $sedeId,
238                    'company_id' => $info['company_id'],
239                    'region_id' => $info['company_id'], // FK a tbl_finance_regions.id
240                    'name' => $info['nombre'],
241                    'code' => (string) $zenitalId,
242                    'is_active' => 1,
243                    'created_at' => now(),
244                    'updated_at' => now(),
245                ]);
246            } else {
247                // Actualizar si ya existe
248                DB::table('tbl_finance_sedes')->where('id', $sedeId)->update([
249                    'company_id' => $info['company_id'],
250                    'region_id' => $info['company_id'],
251                    'name' => $info['nombre'],
252                    'code' => (string) $zenitalId,
253                    'is_active' => 1,
254                    'updated_at' => now(),
255                ]);
256            }
257        }
258    }
259
260    // -------------------------------------------------------
261    // SEDES & REGIONES  (derivadas dinámicamente del mapa Zenital)
262    // -------------------------------------------------------
263
264    /**
265     * GET /finance/regions
266     * Las regiones son los company_ids únicos del mapa Zenital que el usuario
267     * tiene acceso. El nombre se obtiene de TblCompanies.region.
268     */
269    public function list_regions(Request $request)
270    {
271        try {
272            $companyIds = $this->getCompanyIds($request);
273
274            $data = TblFinanceRegions::whereIn('company_id', $companyIds)
275                ->where('is_active', 1)
276                ->orderBy('name')
277                ->get(['id', 'name', 'company_id']);
278
279            return response(['message' => 'OK', 'data' => $data]);
280        } catch (\Exception $e) {
281            /** @disregard P1014 */
282            $e->exceptionCode = 'LIST_FINANCE_REGIONS_EXCEPTION';
283            report($e);
284
285            return response(['message' => 'KO', 'error' => $e->getMessage()]);
286        }
287    }
288
289    /**
290     * GET /finance/sedes
291     * Las sedes son las sucursales del mapa Zenital accesibles por el usuario.
292     * El id es el sk_sucursal de Zenital (convertido a positivo si es negativo).
293     * region_id = company_id (coincide con el id de list_regions).
294     */
295    public function list_sedes(Request $request)
296    {
297        try {
298            $companyIds = $this->getCompanyIds($request);
299
300            $data = TblFinanceSedes::whereIn('company_id', $companyIds)
301                ->where('is_active', 1)
302                ->orderBy('name')
303                ->get(['id', 'name', 'company_id', 'region_id']);
304
305            return response(['message' => 'OK', 'data' => $data]);
306        } catch (\Exception $e) {
307            /** @disregard P1014 */
308            $e->exceptionCode = 'LIST_FINANCE_SEDES_EXCEPTION';
309            report($e);
310
311            return response(['message' => 'KO', 'error' => $e->getMessage()]);
312        }
313    }
314
315    // -------------------------------------------------------
316    // BUDGET ANUAL
317    // -------------------------------------------------------
318
319    /**
320     * GET /finance/budget?year=2025
321     * Devuelve todas las filas de Budget para el año dado,
322     * agrupadas por sede y región.
323     */
324    public function list_budget(Request $request)
325    {
326        try {
327            $companyIds = $this->getCompanyIds($request);
328            $year = $request->query('year', date('Y'));
329
330            $data = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
331                ->where('year', $year)
332                ->orderBy('sede_id')
333                ->orderBy('month')
334                ->get(['company_id', 'sede_id', 'year', 'month', 'amount']);
335
336            return response(['message' => 'OK', 'data' => $data]);
337        } catch (\Exception $e) {
338            /** @disregard P1014 */
339            $e->exceptionCode = 'LIST_BUDGET_EXCEPTION';
340            report($e);
341
342            return response(['message' => 'KO', 'error' => $e->getMessage()]);
343        }
344    }
345
346    /**
347     * POST /finance/budget
348     * Body: { sede_id, year, month, amount }
349     * sede_id = zenitalIdToSedeId(sk_sucursal)
350     */
351    public function upsert_budget(Request $request)
352    {
353        try {
354            $this->ensureSedesExist();
355
356            $data = $request->all();
357            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
358
359            if (! $companyId) {
360                return response(['message' => 'KO', 'error' => 'Sede no encontrada en el mapa de Zenital']);
361            }
362
363            TblFinanceBudgetAnual::updateOrCreate(
364                [
365                    'company_id' => $companyId,
366                    'sede_id' => $data['sede_id'],
367                    'year' => $data['year'],
368                    'month' => $data['month'],
369                ],
370                ['amount' => $data['amount']]
371            );
372
373            return response(['message' => 'OK']);
374        } catch (\Exception $e) {
375            /** @disregard P1014 */
376            $e->exceptionCode = 'UPSERT_BUDGET_EXCEPTION';
377            report($e);
378
379            return response(['message' => 'KO', 'error' => $e->getMessage()]);
380        }
381    }
382
383    /**
384     * POST /finance/budget/bulk
385     * Body: { year, rows: [{ sede_id, month, amount }, ...] }
386     */
387    public function bulk_upsert_budget(Request $request)
388    {
389        try {
390            $this->ensureSedesExist();
391
392            $year = $request->input('year');
393            $rows = $request->input('rows', []);
394
395            DB::transaction(function () use ($year, $rows) {
396                foreach ($rows as $row) {
397                    $companyId = $this->getCompanyIdBySedeId((int) $row['sede_id']);
398                    if (! $companyId) {
399                        continue;
400                    }
401
402                    TblFinanceBudgetAnual::updateOrCreate(
403                        [
404                            'company_id' => $companyId,
405                            'sede_id' => $row['sede_id'],
406                            'year' => $year,
407                            'month' => $row['month'],
408                        ],
409                        ['amount' => $row['amount']]
410                    );
411                }
412            });
413
414            return response(['message' => 'OK']);
415        } catch (\Exception $e) {
416            /** @disregard P1014 */
417            $e->exceptionCode = 'BULK_UPSERT_BUDGET_EXCEPTION';
418            report($e);
419
420            return response(['message' => 'KO', 'error' => $e->getMessage()]);
421        }
422    }
423
424    // -------------------------------------------------------
425    // PREVISIÓN ANUAL
426    // -------------------------------------------------------
427
428    public function list_prevision(Request $request)
429    {
430        try {
431            $companyIds = $this->getCompanyIds($request);
432            $year = $request->query('year', date('Y'));
433
434            $data = TblFinancePrevisionAnual::whereIn('company_id', $companyIds)
435                ->where('year', $year)
436                ->orderBy('sede_id')
437                ->orderBy('month')
438                ->get(['company_id', 'sede_id', 'year', 'month', 'amount']);
439
440            return response(['message' => 'OK', 'data' => $data]);
441        } catch (\Exception $e) {
442            /** @disregard P1014 */
443            $e->exceptionCode = 'LIST_PREVISION_EXCEPTION';
444            report($e);
445
446            return response(['message' => 'KO', 'error' => $e->getMessage()]);
447        }
448    }
449
450    public function upsert_prevision(Request $request)
451    {
452        try {
453            $this->ensureSedesExist();
454
455            $data = $request->all();
456            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
457
458            if (! $companyId) {
459                return response(['message' => 'KO', 'error' => 'Sede no encontrada en el mapa de Zenital']);
460            }
461
462            TblFinancePrevisionAnual::updateOrCreate(
463                [
464                    'company_id' => $companyId,
465                    'sede_id' => $data['sede_id'],
466                    'year' => $data['year'],
467                    'month' => $data['month'],
468                ],
469                ['amount' => $data['amount']]
470            );
471
472            return response(['message' => 'OK']);
473        } catch (\Exception $e) {
474            /** @disregard P1014 */
475            $e->exceptionCode = 'UPSERT_PREVISION_EXCEPTION';
476            report($e);
477
478            return response(['message' => 'KO', 'error' => $e->getMessage()]);
479        }
480    }
481
482    public function bulk_upsert_prevision(Request $request)
483    {
484        try {
485            $this->ensureSedesExist();
486
487            $year = $request->input('year');
488            $rows = $request->input('rows', []);
489
490            DB::transaction(function () use ($year, $rows) {
491                foreach ($rows as $row) {
492                    $companyId = $this->getCompanyIdBySedeId((int) $row['sede_id']);
493                    if (! $companyId) {
494                        continue;
495                    }
496
497                    TblFinancePrevisionAnual::updateOrCreate(
498                        [
499                            'company_id' => $companyId,
500                            'sede_id' => $row['sede_id'],
501                            'year' => $year,
502                            'month' => $row['month'],
503                        ],
504                        ['amount' => $row['amount']]
505                    );
506                }
507            });
508
509            return response(['message' => 'OK']);
510        } catch (\Exception $e) {
511            /** @disregard P1014 */
512            $e->exceptionCode = 'BULK_UPSERT_PREVISION_EXCEPTION';
513            report($e);
514
515            return response(['message' => 'KO', 'error' => $e->getMessage()]);
516        }
517    }
518
519    // -------------------------------------------------------
520    // RESUMEN ANUAL (solo lectura desde front, escritura automática)
521    // -------------------------------------------------------
522
523    /**
524     * GET /finance/resumen?year=2025
525     * Actuals, n-1 y n-2 se calculan en tiempo real desde Zenital (facturacion).
526     * Budget se lee de tbl_finance_budget_anual (MySQL).
527     * sede_id = zenitalIdToSedeId(sk_sucursal).
528     */
529    public function list_resumen(Request $request)
530    {
531        try {
532            $companyIds = $this->getCompanyIds($request);
533            $year = (int) $request->query('year', date('Y'));
534
535            // Mapa [sk_sucursal_zenital => sede_id] para los company_ids accesibles
536            $zenitalToSede = $this->buildZenitalSedeMap($companyIds);
537
538            if (empty($zenitalToSede)) {
539                return response(['message' => 'OK', 'data' => []]);
540            }
541
542            $zenitalIds = array_keys($zenitalToSede);
543
544            // Facturación desde data warehouse para año actual, n-1 y n-2
545            $idx = [];
546            try {
547                $facturacion = DB::connection('zenital')
548                    ->table('fact_facturacion as f')
549                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
550                    ->whereIn('f.sk_sucursal', $zenitalIds)
551                    ->whereIn('d.ano', [$year, $year - 1, $year - 2])
552                    ->selectRaw('f.sk_sucursal, d.ano, d.num_mes as mes, SUM(f.base_imponible) as total')
553                    ->groupBy('f.sk_sucursal', 'd.ano', 'd.num_mes')
554                    ->get();
555
556                foreach ($facturacion as $row) {
557                    $idx[$row->sk_sucursal][$row->ano][$row->mes] = (float) $row->total;
558                }
559            } catch (\Exception $e) {
560                Log::channel('third-party')->warning('Zenital connection failed in list_resumen, using local data only: '.$e->getMessage());
561            }
562
563            // Budget de MySQL: [sede_id][mes] = amount
564            $budgets = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
565                ->where('year', $year)
566                ->get(['sede_id', 'month', 'amount']);
567            $budgetIdx = [];
568            foreach ($budgets as $b) {
569                $budgetIdx[$b->sede_id][$b->month] = (float) $b->amount;
570            }
571
572            // Construir resultado
573            $data = [];
574            foreach ($zenitalToSede as $zenitalId => $sedeId) {
575                for ($month = 1; $month <= 12; $month++) {
576                    $actuals = $idx[$zenitalId][$year][$month] ?? null;
577                    $n1 = $idx[$zenitalId][$year - 1][$month] ?? null;
578                    $n2 = $idx[$zenitalId][$year - 2][$month] ?? null;
579                    $budget = $budgetIdx[$sedeId][$month] ?? null;
580
581                    if ($actuals === null && $n1 === null && $n2 === null && $budget === null) {
582                        continue;
583                    }
584
585                    $data[] = [
586                        'sede_id' => $sedeId,
587                        'month' => $month,
588                        'actuals' => $actuals,
589                        'actuals_n1' => $n1,
590                        'actuals_n2' => $n2,
591                        'budget' => $budget,
592                        'deviation_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
593                        'deviation_vs_n2' => ($n2 && $actuals !== null) ? (($actuals - $n2) / $n2) : null,
594                        'deviation_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
595                    ];
596                }
597            }
598
599            // Merge locally stored data for sedes not already covered by Zenital
600            $localData = TblFinanceResumenAnual::whereIn('company_id', $companyIds)
601                ->where('year', $year)
602                ->get();
603
604            $coveredSedes = [];
605            foreach ($data as $d) {
606                $coveredSedes[$d['sede_id'].'-'.$d['month']] = true;
607            }
608            foreach ($localData as $row) {
609                if (isset($coveredSedes[$row->sede_id.'-'.$row->month])) {
610                    continue;
611                }
612
613                $data[] = [
614                    'sede_id' => $row->sede_id,
615                    'month' => $row->month,
616                    'actuals' => $row->actuals,
617                    'actuals_n1' => $row->actuals_n1,
618                    'actuals_n2' => $row->actuals_n2,
619                    'budget' => $row->budget,
620                    'deviation_vs_n1' => $row->deviation_vs_n1,
621                    'deviation_vs_n2' => $row->deviation_vs_n2,
622                    'deviation_vs_budget' => $row->deviation_vs_budget,
623                ];
624            }
625
626            return response(['message' => 'OK', 'data' => $data]);
627        } catch (\Exception $e) {
628            /** @disregard P1014 */
629            $e->exceptionCode = 'LIST_RESUMEN_EXCEPTION';
630            report($e);
631
632            return response(['message' => 'KO', 'error' => $e->getMessage()]);
633        }
634    }
635
636    /**
637     * POST /finance/resumen/load
638     * Llamado desde el job automático del día 1 de cada mes.
639     * Body: { year, month, rows: [{ sede_id, actuals, actuals_n1, actuals_n2 }] }
640     */
641    public function load_resumen(Request $request)
642    {
643        try {
644            $company = $this->getCompany($request);
645            $year = $request->input('year');
646            $month = $request->input('month');
647            $rows = $request->input('rows', []);
648
649            DB::transaction(function () use ($company, $year, $month, $rows) {
650                foreach ($rows as $row) {
651                    $budget = TblFinanceBudgetAnual::where([
652                        'company_id' => $company->company_id,
653                        'sede_id' => $row['sede_id'],
654                        'year' => $year,
655                        'month' => $month,
656                    ])->value('amount');
657
658                    $actuals = $row['actuals'];
659                    $n1 = $row['actuals_n1'] ?? null;
660                    $n2 = $row['actuals_n2'] ?? null;
661
662                    TblFinanceResumenAnual::updateOrCreate(
663                        [
664                            'company_id' => $company->company_id,
665                            'sede_id' => $row['sede_id'],
666                            'year' => $year,
667                            'month' => $month,
668                        ],
669                        [
670                            'actuals' => $actuals,
671                            'actuals_n1' => $n1,
672                            'actuals_n2' => $n2,
673                            'budget' => $budget,
674                            'deviation_vs_n1' => ($n1 && $n1 != 0) ? (($actuals - $n1) / $n1) : null,
675                            'deviation_vs_n2' => ($n2 && $n2 != 0) ? (($actuals - $n2) / $n2) : null,
676                            'deviation_vs_budget' => ($budget && $budget != 0) ? (($actuals - $budget) / $budget) : null,
677                            'loaded_at' => now(),
678                        ]
679                    );
680                }
681            });
682
683            return response(['message' => 'OK']);
684        } catch (\Exception $e) {
685            /** @disregard P1014 */
686            $e->exceptionCode = 'LOAD_RESUMEN_EXCEPTION';
687            report($e);
688
689            return response(['message' => 'KO', 'error' => $e->getMessage()]);
690        }
691    }
692
693    // -------------------------------------------------------
694    // REPORT SEMANAL (solo lectura desde front, escritura automática)
695    // -------------------------------------------------------
696
697    /**
698     * GET /finance/report-semanal?year=2025&month=10
699     * YTD (meses 1..month) calculado en tiempo real desde Zenital.
700     * Budget y Previsión YTD se leen de MySQL.
701     * sede_id = zenitalIdToSedeId(sk_sucursal).
702     */
703    public function list_report_semanal(Request $request)
704    {
705        try {
706            $companyIds = $this->getCompanyIds($request);
707            $year = (int) $request->query('year', date('Y'));
708            $month = (int) $request->query('month', date('n'));
709
710            // Mapa [sk_sucursal_zenital => sede_id] para los company_ids accesibles
711            $zenitalToSede = $this->buildZenitalSedeMap($companyIds);
712
713            if (empty($zenitalToSede)) {
714                return response(['message' => 'OK', 'data' => []]);
715            }
716
717            $zenitalIds = array_keys($zenitalToSede);
718
719            // Datos mensuales desde data warehouse (año actual y año anterior, solo el mes seleccionado)
720            $ytdIdx = [];
721            $latestDates = collect();
722            try {
723                $facturacion = DB::connection('zenital')
724                    ->table('fact_facturacion as f')
725                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
726                    ->whereIn('f.sk_sucursal', $zenitalIds)
727                    ->whereIn('d.ano', [$year, $year - 1])
728                    ->where('d.num_mes', $month)
729                    ->selectRaw('f.sk_sucursal, d.ano, SUM(f.base_imponible) as total')
730                    ->groupBy('f.sk_sucursal', 'd.ano')
731                    ->get();
732
733                foreach ($facturacion as $row) {
734                    $ytdIdx[$row->sk_sucursal][$row->ano] = (float) $row->total;
735                }
736
737                // Fecha más reciente de emisión por sucursal (como "week_date")
738                $latestDates = DB::connection('zenital')
739                    ->table('fact_facturacion as f')
740                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
741                    ->whereIn('f.sk_sucursal', $zenitalIds)
742                    ->where('d.ano', $year)
743                    ->where('d.num_mes', $month)
744                    ->selectRaw('f.sk_sucursal, MAX(d.fecha) as latest_date')
745                    ->groupBy('f.sk_sucursal')
746                    ->get()
747                    ->keyBy('sk_sucursal');
748            } catch (\Exception $e) {
749                Log::channel('third-party')->warning('Zenital connection failed in list_report_semanal, using local data only: '.$e->getMessage());
750            }
751
752            // Budget mensual de MySQL: solo el mes seleccionado
753            $budgets = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
754                ->where('year', $year)
755                ->where('month', $month)
756                ->get(['sede_id', 'amount']);
757            $budgetYtd = [];
758            foreach ($budgets as $b) {
759                $budgetYtd[$b->sede_id] = (float) $b->amount;
760            }
761
762            // Previsión mensual de MySQL: solo el mes seleccionado
763            $previsions = TblFinancePrevisionAnual::whereIn('company_id', $companyIds)
764                ->where('year', $year)
765                ->where('month', $month)
766                ->get(['sede_id', 'amount']);
767            $previsionYtd = [];
768            foreach ($previsions as $p) {
769                $previsionYtd[$p->sede_id] = (float) $p->amount;
770            }
771
772            // Construir resultado
773            $data = [];
774            foreach ($zenitalToSede as $zenitalId => $sedeId) {
775                $actuals = $ytdIdx[$zenitalId][$year] ?? null;
776                $n1 = $ytdIdx[$zenitalId][$year - 1] ?? null;
777                $budget = $budgetYtd[$sedeId] ?? null;
778                $prevision = $previsionYtd[$sedeId] ?? null;
779                $weekDate = $latestDates[$zenitalId]->latest_date ?? null;
780
781                if ($actuals === null && $n1 === null && $budget === null && $prevision === null) {
782                    continue;
783                }
784
785                $data[] = [
786                    'sede_id' => $sedeId,
787                    'year' => $year,
788                    'month' => $month,
789                    'week_date' => $weekDate,
790                    'actuals' => $actuals,
791                    'actuals_n1' => $n1,
792                    'budget' => $budget,
793                    'prevision' => $prevision,
794                    'deviation_vs_n1' => ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null,
795                    'deviation_pct_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
796                    'deviation_vs_budget' => ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null,
797                    'deviation_pct_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
798                    'deviation_vs_prevision' => ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null,
799                    'deviation_pct_vs_prevision' => ($prevision && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null,
800                ];
801            }
802
803            // Merge locally stored data for sedes not already covered by Zenital
804            $localData = TblFinanceReportSemanal::whereIn('company_id', $companyIds)
805                ->where('year', $year)
806                ->where('month', $month)
807                ->get();
808
809            $coveredSedes = [];
810            foreach ($data as $d) {
811                $coveredSedes[$d['sede_id']] = true;
812            }
813            foreach ($localData as $row) {
814                if (isset($coveredSedes[$row->sede_id])) {
815                    continue;
816                }
817
818                $data[] = [
819                    'sede_id' => $row->sede_id,
820                    'year' => $row->year,
821                    'month' => $row->month,
822                    'week_date' => $row->week_date,
823                    'actuals' => $row->actuals,
824                    'actuals_n1' => $row->actuals_n1,
825                    'budget' => $row->budget,
826                    'prevision' => $row->prevision,
827                    'deviation_vs_n1' => $row->deviation_vs_n1,
828                    'deviation_pct_vs_n1' => $row->deviation_pct_vs_n1,
829                    'deviation_vs_budget' => $row->deviation_vs_budget,
830                    'deviation_pct_vs_budget' => $row->deviation_pct_vs_budget,
831                    'deviation_vs_prevision' => $row->deviation_vs_prevision,
832                    'deviation_pct_vs_prevision' => $row->deviation_pct_vs_prevision,
833                ];
834            }
835
836            return response(['message' => 'OK', 'data' => $data]);
837        } catch (\Exception $e) {
838            /** @disregard P1014 */
839            $e->exceptionCode = 'LIST_REPORT_SEMANAL_EXCEPTION';
840            report($e);
841
842            return response(['message' => 'KO', 'error' => $e->getMessage()]);
843        }
844    }
845
846    /**
847     * POST /finance/report-semanal/load
848     * Llamado desde el job automático de cada sábado.
849     * Body: { week_date, year, month, rows: [{ sede_id, actuals, actuals_n1, budget, prevision }] }
850     */
851    public function load_report_semanal(Request $request)
852    {
853        try {
854            $company = $this->getCompany($request);
855            $weekDate = $request->input('week_date');
856            $year = $request->input('year');
857            $month = $request->input('month');
858            $rows = $request->input('rows', []);
859
860            DB::transaction(function () use ($company, $weekDate, $year, $month, $rows) {
861                foreach ($rows as $row) {
862                    $actuals = $row['actuals'];
863                    $n1 = $row['actuals_n1'] ?? null;
864                    $budget = $row['budget'] ?? null;
865                    $prevision = $row['prevision'] ?? null;
866
867                    TblFinanceReportSemanal::updateOrCreate(
868                        [
869                            'company_id' => $company->company_id,
870                            'sede_id' => $row['sede_id'],
871                            'week_date' => $weekDate,
872                        ],
873                        [
874                            'year' => $year,
875                            'month' => $month,
876                            'actuals' => $actuals,
877                            'actuals_n1' => $n1,
878                            'budget' => $budget,
879                            'prevision' => $prevision,
880                            'deviation_vs_n1' => ($n1 !== null) ? ($actuals - $n1) : null,
881                            'deviation_pct_vs_n1' => ($n1 && $n1 != 0) ? (($actuals - $n1) / $n1) : null,
882                            'deviation_vs_budget' => ($budget !== null) ? ($actuals - $budget) : null,
883                            'deviation_pct_vs_budget' => ($budget && $budget != 0) ? (($actuals - $budget) / $budget) : null,
884                            'deviation_vs_prevision' => ($prevision !== null) ? ($actuals - $prevision) : null,
885                            'deviation_pct_vs_prevision' => ($prevision && $prevision != 0) ? (($actuals - $prevision) / $prevision) : null,
886                            'loaded_at' => now(),
887                        ]
888                    );
889                }
890            });
891
892            return response(['message' => 'OK']);
893        } catch (\Exception $e) {
894            /** @disregard P1014 */
895            $e->exceptionCode = 'LOAD_REPORT_SEMANAL_EXCEPTION';
896            report($e);
897
898            return response(['message' => 'KO', 'error' => $e->getMessage()]);
899        }
900    }
901
902    // -------------------------------------------------------
903    // DESTINATARIOS REPORTE SEMANAL
904    // -------------------------------------------------------
905
906    public function list_recipients(Request $request)
907    {
908        try {
909            $companyIds = $this->getCompanyIds($request);
910            $data = TblFinanceReportRecipients::whereIn('company_id', $companyIds)
911                ->orderBy('name')
912                ->get();
913
914            return response(['message' => 'OK', 'data' => $data]);
915        } catch (\Exception $e) {
916            /** @disregard P1014 */
917            $e->exceptionCode = 'LIST_RECIPIENTS_EXCEPTION';
918            report($e);
919
920            return response(['message' => 'KO', 'error' => $e->getMessage()]);
921        }
922    }
923
924    public function create_recipient(Request $request)
925    {
926        try {
927            $company = $this->getCompany($request);
928            $data = $request->all();
929
930            $recipient = TblFinanceReportRecipients::create([
931                'company_id' => $company->company_id,
932                'name' => $data['name'],
933                'email' => $data['email'],
934                'is_active' => $data['is_active'] ?? 1,
935                'days_before_delete_errors' => $data['days_before_delete_errors'] ?? 7,
936            ]);
937
938            return response(['message' => 'OK', 'data' => $recipient]);
939        } catch (\Exception $e) {
940            /** @disregard P1014 */
941            $e->exceptionCode = 'CREATE_RECIPIENT_EXCEPTION';
942            report($e);
943
944            return response(['message' => 'KO', 'error' => $e->getMessage()]);
945        }
946    }
947
948    public function update_recipient(Request $request, $id)
949    {
950        try {
951            $company = $this->getCompany($request);
952            $data = $request->all();
953
954            TblFinanceReportRecipients::where('id', $id)
955                ->where('company_id', $company->company_id)
956                ->update($data);
957
958            return response(['message' => 'OK']);
959        } catch (\Exception $e) {
960            /** @disregard P1014 */
961            $e->exceptionCode = 'UPDATE_RECIPIENT_EXCEPTION';
962            report($e);
963
964            return response(['message' => 'KO', 'error' => $e->getMessage()]);
965        }
966    }
967
968    public function delete_recipient($id)
969    {
970        try {
971            TblFinanceReportRecipients::where('id', $id)->delete();
972
973            return response(['message' => 'OK']);
974        } catch (\Exception $e) {
975            /** @disregard P1014 */
976            $e->exceptionCode = 'DELETE_RECIPIENT_EXCEPTION';
977            report($e);
978
979            return response(['message' => 'KO', 'error' => $e->getMessage()]);
980        }
981    }
982
983    public function send_report(Request $request)
984    {
985        try {
986            $params = [];
987            if ($request->input('month')) {
988                $params['--month'] = (int) $request->input('month');
989            }
990            if ($request->input('type')) {
991                $params['--type'] = $request->input('type');
992            }
993            $exitCode = \Illuminate\Support\Facades\Artisan::call('finance:send-report', $params);
994
995            return response([
996                'message' => 'OK',
997                'output' => \Illuminate\Support\Facades\Artisan::output(),
998                'exit_code' => $exitCode,
999            ]);
1000        } catch (\Exception $e) {
1001            /** @disregard P1014 */
1002            $e->exceptionCode = 'SEND_FINANCE_REPORT_EXCEPTION';
1003            report($e);
1004
1005            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1006        }
1007    }
1008
1009    public function test_report(Request $request)
1010    {
1011        try {
1012            $params = ['--test' => true];
1013            if ($request->query('month')) {
1014                $params['--month'] = (int) $request->query('month');
1015            }
1016            if ($request->query('type')) {
1017                $params['--type'] = $request->query('type');
1018            }
1019            $exitCode = \Illuminate\Support\Facades\Artisan::call('finance:send-report', $params);
1020
1021            return response([
1022                'message' => 'OK',
1023                'output' => \Illuminate\Support\Facades\Artisan::output(),
1024                'exit_code' => $exitCode,
1025            ]);
1026        } catch (\Exception $e) {
1027            /** @disregard P1014 */
1028            $e->exceptionCode = 'TEST_FINANCE_REPORT_EXCEPTION';
1029            report($e);
1030
1031            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1032        }
1033    }
1034
1035    // -------------------------------------------------------
1036    // GOOGLE DRIVE IMPORT
1037    // -------------------------------------------------------
1038
1039    public function import_from_drive()
1040    {
1041        try {
1042            $exitCode = \Illuminate\Support\Facades\Artisan::call('finance:import-drive');
1043
1044            return response([
1045                'message' => 'OK',
1046                'output' => \Illuminate\Support\Facades\Artisan::output(),
1047                'exit_code' => $exitCode,
1048            ]);
1049        } catch (\Exception $e) {
1050            $e->exceptionCode = 'IMPORT_FINANCE_DRIVE_EXCEPTION';
1051            report($e);
1052
1053            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1054        }
1055    }
1056
1057    // -------------------------------------------------------
1058    // SEDES CRUD
1059    // -------------------------------------------------------
1060
1061    public function create_sede(Request $request)
1062    {
1063        try {
1064            $data = $request->all();
1065
1066            $sede = TblFinanceSedes::create([
1067                'name' => $data['name'],
1068                'company_id' => $data['company_id'],
1069                'region_id' => $data['region_id'],
1070                'code' => $data['code'] ?? null,
1071                'is_active' => 1,
1072            ]);
1073
1074            return response(['message' => 'OK', 'data' => $sede]);
1075        } catch (\Exception $e) {
1076            /** @disregard P1014 */
1077            $e->exceptionCode = 'CREATE_SEDE_EXCEPTION';
1078            report($e);
1079
1080            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1081        }
1082    }
1083
1084    public function delete_sede(Request $request, $id)
1085    {
1086        try {
1087            $sede = TblFinanceSedes::findOrFail($id);
1088            $sede->update(['is_active' => 0]);
1089
1090            return response(['message' => 'OK']);
1091        } catch (\Exception $e) {
1092            /** @disregard P1014 */
1093            $e->exceptionCode = 'DELETE_SEDE_EXCEPTION';
1094            report($e);
1095
1096            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1097        }
1098    }
1099
1100    // -------------------------------------------------------
1101    // Month Config (billing close dates)
1102    // -------------------------------------------------------
1103
1104    /**
1105     * GET /finance-month-config?year=2026
1106     * Returns all month config rows for the given year.
1107     */
1108    public function list_month_config(Request $request)
1109    {
1110        try {
1111            $year = (int) $request->query('year', date('Y'));
1112
1113            $data = TblFinanceMonthConfig::where('year', $year)
1114                ->orderBy('month')
1115                ->get();
1116
1117            return response(['message' => 'OK', 'data' => $data]);
1118        } catch (\Exception $e) {
1119            /** @disregard P1014 */
1120            $e->exceptionCode = 'LIST_MONTH_CONFIG_EXCEPTION';
1121            report($e);
1122
1123            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1124        }
1125    }
1126
1127    /**
1128     * PUT /finance-month-config/{id}
1129     * Updates close_date and/or force_report_month for a row.
1130     */
1131    public function update_month_config(Request $request, $id)
1132    {
1133        try {
1134            $config = TblFinanceMonthConfig::findOrFail($id);
1135
1136            $config->update($request->only(['close_date', 'force_report_month']));
1137
1138            return response(['message' => 'OK', 'data' => $config->fresh()]);
1139        } catch (\Exception $e) {
1140            /** @disregard P1014 */
1141            $e->exceptionCode = 'UPDATE_MONTH_CONFIG_EXCEPTION';
1142            report($e);
1143
1144            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1145        }
1146    }
1147}