Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1069
0.00% covered (danger)
0.00%
0 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
FinanceController
0.00% covered (danger)
0.00%
0 / 1069
0.00% covered (danger)
0.00%
0 / 39
98910
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 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getZenitalRegionMap
0.00% covered (danger)
0.00%
0 / 1
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 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 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 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 ensureSedesExist
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
56
 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 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 delete_budget
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 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 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 delete_prevision
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 list_resumen
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 1
3782
 load_resumen
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
132
 list_report_semanal
0.00% covered (danger)
0.00%
0 / 256
0.00% covered (danger)
0.00%
0 / 1
9312
 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 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 import_from_drive
0.00% covered (danger)
0.00%
0 / 15
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\TblFinanceMonthConfig;
9use App\Models\TblFinancePrevisionAnual;
10use App\Models\TblFinanceRegions;
11use App\Models\TblFinanceReportRecipients;
12use App\Models\TblFinanceReportSemanal;
13use App\Models\TblFinanceResumenAnual;
14use App\Models\TblFinanceSedes;
15use App\Services\FinanceReportRenderer;
16use App\Services\ZenitalCatalog;
17use Illuminate\Http\Request;
18use Illuminate\Support\Facades\App;
19use Illuminate\Support\Facades\Artisan;
20use Illuminate\Support\Facades\DB;
21use Illuminate\Support\Facades\Log;
22
23class FinanceController extends Controller
24{
25    public function __construct(private ZenitalCatalog $zenital, private FinanceReportRenderer $renderer)
26    {
27        App::setLocale(request()->header('Locale-Id'));
28    }
29
30    // -------------------------------------------------------
31    // Helpers
32    // -------------------------------------------------------
33
34    /** Para escritura: devuelve la empresa de la región activa */
35    private function getCompany(Request $request)
36    {
37        $region = urldecode($request->header('Region'));
38
39        return TblCompanies::where('region', $region)->firstOrFail();
40    }
41
42    /**
43     * Para lectura: devuelve los company_ids del usuario.
44     * Si region = "All" o vacío → todas las empresas del usuario.
45     */
46    private function getCompanyIds(Request $request): array
47    {
48        $region = urldecode($request->header('Region'));
49        $userId = intval($request->header('User-ID'));
50
51        if ($region === 'All' || empty($region)) {
52            return TblCompanyUsers::where('user_id', $userId)
53                ->pluck('company_id')
54                ->toArray();
55        }
56
57        $company = TblCompanies::where('region', $region)->first();
58
59        return $company ? [$company->company_id] : [];
60    }
61
62    // -------------------------------------------------------
63    // Zenital: mapa estático de sucursales
64    // -------------------------------------------------------
65
66    /**
67     * Mapa hardcodeado de sucursales de Zenital.
68     * Fuente: tabla sucursales de Zenital + normalización de región a company_id interno.
69     * [sk_sucursal => ['nombre' => ..., 'company_id' => ...]]
70     */
71    /**
72     * Mapa de sucursales Zenital DWH (sk_sucursal) → sede FST.
73     * Cada entrada: zenital_id => [nombre, company_id, sede_id]
74     *
75     * Construido en runtime a partir de un JOIN nombre↔nombre entre
76     * `tbl_finance_sedes` y `zenital.dim_sucursal`. La versión anterior
77     * tenía los sk_sucursal hardcoded a un esquema viejo del DWH; Zenital
78     * los renumeró (el rango actual es 91+, los IDs viejos se conservan
79     * en `cod_sucursal`) y como nadie tocó este código las semanas
80     * siguientes a la migración, los WHERE IN matcheaban 0 filas y todas
81     * las regiones aparecían con actuals en 0 — el síntoma que Berta
82     * reportó en STG: "Catalunya empty actuals". Resolver por nombre nos
83     * deja inmunes a la próxima renumeración del DWH.
84     */
85    private function getZenitalSucursalesMap(): array
86    {
87        // FIRE-1148: delegates to the shared ZenitalCatalog service, which
88        // caches the result via ResultCache (1h TTL, 'finance' domain) so
89        // every PHP-FPM worker doesn't pay the WAN roundtrip on its first
90        // request. Invalidation flows through ensureSedesExist below.
91        return $this->zenital->sucursalesMap();
92    }
93
94    /**
95     * Runtime sk_region (Zenital) → region_id (Titan) lookup.
96     *
97     * Mirror of `SendFinanceReport::getZenitalRegionMap`. GESTIONA-sourced
98     * invoices in Zenital often arrive with sk_sucursal = -1 ("Desconocido")
99     * and only sk_region populated — Catalunya's entire April 2026
100     * facturación arrived this way (≈ 605 K€ under sk_region 21).
101     * list_report_semanal needs the same lookup to surface that pile in the
102     * UI; otherwise per-region totals diverge from the email preview.
103     *
104     * Most rows match by region name; the small alias map handles Zenital's
105     * sub-entity entries (Oasys / Crespo / Togasa / Extinfuego / Almería)
106     * and the Cataluña/Catalunya accent variance. Cached per-process.
107     */
108    private function getZenitalRegionMap(): array
109    {
110        // FIRE-1148: same delegation pattern as getZenitalSucursalesMap.
111        return $this->zenital->regionMap();
112    }
113
114    /**
115     * Convierte el sk_sucursal de Zenital a sede_id en tbl_finance_sedes.
116     * Usa el mapa explícito; si no existe, devuelve el ID tal cual.
117     */
118    private function zenitalIdToSedeId(int $zenitalId): int
119    {
120        $map = $this->getZenitalSucursalesMap();
121
122        return $map[$zenitalId]['sede_id'] ?? $zenitalId;
123    }
124
125    /**
126     * Inverso: dado un sede_id, devuelve el zenital sk_sucursal.
127     */
128    private function sedeIdToZenitalId(int $sedeId): int
129    {
130        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
131            if ($info['sede_id'] === $sedeId) {
132                return $zenitalId;
133            }
134        }
135
136        return $sedeId;
137    }
138
139    /**
140     * FIRE-1027: true if the given sede_id is mapped to a Zenital sucursal,
141     * which means its data flows from the DWH and is owned by Zenital. The
142     * UI must not be able to write to such sedes — `list_resumen` pass 1
143     * covers them and never reads from `tbl_finance_resumen_anual`, so any
144     * row we create for them would be a stray write.
145     *
146     * Also resolves the local sede_id mapping the same way `resolveZenital
147     * SedeMap` does (the Zenital map stores hardcoded sede_ids that may not
148     * match the local PK on every install).
149     */
150    private function isZenitalSede(int $sedeId): bool
151    {
152        $localSedeName = TblFinanceSedes::where('id', $sedeId)->value('name');
153
154        foreach ($this->getZenitalSucursalesMap() as $info) {
155            if ($info['sede_id'] === $sedeId) {
156                return true;
157            }
158            if ($localSedeName && $info['nombre'] === $localSedeName) {
159                return true;
160            }
161        }
162
163        return false;
164    }
165
166    /**
167     * Devuelve el company_id correspondiente a un sede_id.
168     *
169     * Berta audit follow-up: trust the sede row's actual company_id over
170     * the Zenital map. The map was inheriting `company_id` from
171     * `getZenitalSucursalesMap`, but historical imports occasionally
172     * inserted budget rows with a different company_id (e.g. Lluis_Moff
173     * sede_id=4 has budget rows split between company_id=18 and 19,
174     * because an old Excel import used a different resolver). When the
175     * UI upsert chose 19 (from the map) but the real data sat at
176     * company_id=18, the new write fell into a parallel "shadow" row and
177     * looked like the save was a no-op. By trusting the sede row we
178     * write where the data already lives.
179     */
180    private function getCompanyIdBySedeId(int $sedeId): ?int
181    {
182        $fromSede = TblFinanceSedes::where('id', $sedeId)->value('company_id');
183        if ($fromSede !== null) {
184            return (int) $fromSede;
185        }
186
187        // Sede missing from DB — fall back to the map so a brand-new
188        // sede that ensureSedesExist hasn't inserted yet still resolves.
189        foreach ($this->getZenitalSucursalesMap() as $info) {
190            if ($info['sede_id'] === $sedeId) {
191                return $info['company_id'];
192            }
193        }
194
195        return null;
196    }
197
198    /**
199     * Devuelve el mapa [sk_sucursal_zenital => sede_id] filtrado
200     * por los company_ids accesibles.
201     */
202    private function buildZenitalSedeMap(array $companyIds): array
203    {
204        $result = [];
205        foreach ($this->getZenitalSucursalesMap() as $zenitalId => $info) {
206            if (in_array($info['company_id'], $companyIds)) {
207                $result[$zenitalId] = $info['sede_id'];
208            }
209        }
210
211        return $result;
212    }
213
214    /**
215     * Same as buildZenitalSedeMap but translates each hardcoded sede_id to
216     * the actual tbl_finance_sedes.id by matching company_id + name. The
217     * frontend identifies sedes by the local PK (not the Zenital hardcoded
218     * id) — without this translation, resumen/semanal responses come back
219     * with sede_ids the frontend can't link to any sede row.
220     */
221    private function resolveZenitalSedeMap(array $companyIds): array
222    {
223        $rawMap = $this->buildZenitalSedeMap($companyIds);
224        if (empty($rawMap)) {
225            return [];
226        }
227
228        // ordenamos por id ASC para que, si hay duplicados activos por
229        // (company_id, name) — caso histórico en stg donde el operador
230        // creó la sede manualmente (id bajo, p.ej. 32 Cisemex) y luego
231        // el viejo ensureSedesExist añadió la versión hardcoded del
232        // mapa (id 116) — ganen los ids bajos. Esos son los que tienen
233        // el budget editado por el usuario; los hardcoded suelen estar
234        // vacíos. La migración cleanup_finance_sede_duplicates los
235        // desactiva, pero esta ordenación deja el resolver robusto si
236        // por la razón que sea persisten duplicados en producción.
237        $localSedes = TblFinanceSedes::whereIn('company_id', $companyIds)
238            ->where('is_active', 1)
239            ->orderBy('id', 'asc')
240            ->get(['id', 'name', 'company_id']);
241
242        // [company_id][lowercased name] => local tbl_finance_sedes.id
243        // "First wins": no sobreescribimos si ya hay una entrada para
244        // esa (company_id, name) — preserva el id más bajo.
245        $byCompanyName = [];
246        foreach ($localSedes as $s) {
247            $key = mb_strtolower((string) $s->name);
248            if (! isset($byCompanyName[$s->company_id][$key])) {
249                $byCompanyName[$s->company_id][$key] = $s->id;
250            }
251        }
252
253        $zenitalMap = $this->getZenitalSucursalesMap();
254        $result = [];
255        foreach ($rawMap as $zenitalId => $fallbackSedeId) {
256            $info = $zenitalMap[$zenitalId] ?? null;
257            if ($info) {
258                $localId = $byCompanyName[$info['company_id']][mb_strtolower((string) $info['nombre'])] ?? null;
259                $result[$zenitalId] = $localId ?? $fallbackSedeId;
260            } else {
261                $result[$zenitalId] = $fallbackSedeId;
262            }
263        }
264
265        return $result;
266    }
267
268    /**
269     * Garantiza que las regiones y sedes del mapa Zenital existan en
270     * tbl_finance_regions / tbl_finance_sedes antes de escribir budget o
271     * previsión. Estrictamente aditivo: nunca toca filas existentes.
272     *
273     * El comportamiento anterior reescribía `region_id = company_id` para
274     * cada sede del mapa y creaba una región paralela `id = company_id`.
275     * Eso rompía instalaciones donde un mismo company_id ya tenía varias
276     * regiones activas con ids distintos (p.ej. Valencia/Alicante/Castellón
277     * todas bajo company_id=30, o Catalunya id=1 vs Cataluña id=19) y
278     * dejaba sedes huérfanas bajo regiones duplicadas. Ahora:
279     *   - si ya existe cualquier región activa para el company_id, no se
280     *     crea otra; las sedes nuevas se asocian a la primera región
281     *     existente para ese company_id.
282     *   - si la sede ya existe (por id en el mapa), no se toca: ni nombre,
283     *     ni region_id, ni is_active. Esto también deja de renombrar
284     *     "Robles" → "Robles Legacy" cuando dos entradas del mapa apuntan
285     *     al mismo sede_id.
286     */
287    private function ensureSedesExist(): void
288    {
289        $map = $this->getZenitalSucursalesMap();
290
291        // FIRE-1148: track whether we mutated tbl_finance_regions or
292        // tbl_finance_sedes so we can invalidate the catalog cache only
293        // when something actually changed (avoids unnecessary cache flushes
294        // on every upsert_budget / upsert_prevision call).
295        $mutated = false;
296
297        $companyIds = array_unique(array_column($map, 'company_id'));
298        $companies = TblCompanies::whereIn('company_id', $companyIds)
299            ->get(['company_id', 'region'])
300            ->keyBy('company_id');
301
302        // Paso 1: solo crear una región nueva cuando el company_id no
303        // tenga ya ninguna región activa. No reactivar ni renombrar las
304        // existentes — el operador puede haberlas dividido a propósito.
305        $regionByCompany = [];
306        foreach ($companyIds as $companyId) {
307            $existingRegionId = DB::table('tbl_finance_regions')
308                ->where('company_id', $companyId)
309                ->where('is_active', 1)
310                ->orderBy('id')
311                ->value('id');
312
313            if ($existingRegionId !== null) {
314                $regionByCompany[$companyId] = $existingRegionId;
315
316                continue;
317            }
318
319            $regionName = $companies[$companyId]->region ?? "Region $companyId";
320            $newId = DB::table('tbl_finance_regions')->insertGetId([
321                'company_id' => $companyId,
322                'name' => $regionName,
323                'code' => (string) $companyId,
324                'is_active' => 1,
325                'created_at' => now(),
326                'updated_at' => now(),
327            ]);
328            $regionByCompany[$companyId] = $newId;
329            $mutated = true;
330        }
331
332        // Paso 2: insertar las sedes del mapa que aún no existan.
333        //
334        // Dos checks complementarios:
335        //   - por NOMBRE (company_id + nombre): si el operador ya tiene una
336        //     sede activa con ese mismo nombre creada manualmente vía UI
337        //     (típicamente con id auto-increment bajo, p.ej. id=32 para
338        //     "Cisemex"), NO insertamos la versión hardcoded del mapa
339        //     (sede_id=116). Antes esto creaba pares duplicados en stg
340        //     (id=32 con datos de budget + id=116 con datos de Zenital),
341        //     que se renderizaban como dos filas "Cisemex" idénticas.
342        //   - por ID (sede_id hardcoded del mapa): defensa adicional por
343        //     si el operador tiene una sede inactiva con ese id (la
344        //     migración cleanup_finance_sede_duplicates desactiva las
345        //     duplicadas; respetamos esa desactivación).
346        foreach ($map as $zenitalId => $info) {
347            $sedeId = $this->zenitalIdToSedeId($zenitalId);
348
349            $existsByName = DB::table('tbl_finance_sedes')
350                ->where('company_id', $info['company_id'])
351                ->where('name', $info['nombre'])
352                ->where('is_active', 1)
353                ->exists();
354            if ($existsByName) {
355                continue;
356            }
357
358            if (DB::table('tbl_finance_sedes')->where('id', $sedeId)->exists()) {
359                continue;
360            }
361
362            DB::table('tbl_finance_sedes')->insert([
363                'id' => $sedeId,
364                'company_id' => $info['company_id'],
365                'region_id' => $regionByCompany[$info['company_id']] ?? $info['company_id'],
366                'name' => $info['nombre'],
367                'code' => (string) $zenitalId,
368                'is_active' => 1,
369                'created_at' => now(),
370                'updated_at' => now(),
371            ]);
372            $mutated = true;
373        }
374
375        // FIRE-1148: blow away the cached Zenital catalog so the new
376        // region/sede surface immediately. Skipped when nothing changed
377        // — the common upsert_budget / upsert_prevision path hits this
378        // method on every write but rarely actually inserts.
379        if ($mutated) {
380            $this->zenital->forget();
381        }
382    }
383
384    // -------------------------------------------------------
385    // SEDES & REGIONES  (derivadas dinámicamente del mapa Zenital)
386    // -------------------------------------------------------
387
388    /**
389     * GET /finance/regions
390     * Las regiones son los company_ids únicos del mapa Zenital que el usuario
391     * tiene acceso. El nombre se obtiene de TblCompanies.region.
392     */
393    public function list_regions(Request $request)
394    {
395        try {
396            $companyIds = $this->getCompanyIds($request);
397
398            // Return the real tbl_finance_regions.id so regions that share a
399            // company_id (e.g. Valencia, Alicante, Castellón all under
400            // company_id=30) don't collapse into a single frontend bucket.
401            // tbl_finance_sedes.region_id now points to this PK for all rows.
402            $data = TblFinanceRegions::whereIn('company_id', $companyIds)
403                ->where('is_active', 1)
404                ->orderBy('name')
405                ->get(['id', 'name', 'company_id']);
406
407            return response(['message' => 'OK', 'data' => $data]);
408        } catch (\Exception $e) {
409            /** @disregard P1014 */
410            $e->exceptionCode = 'LIST_FINANCE_REGIONS_EXCEPTION';
411            report($e);
412
413            return response(['message' => 'KO', 'error' => $e->getMessage()]);
414        }
415    }
416
417    /**
418     * GET /finance/sedes
419     * Las sedes son las sucursales del mapa Zenital accesibles por el usuario.
420     * El id es el sk_sucursal de Zenital (convertido a positivo si es negativo).
421     * region_id = company_id (coincide con el id de list_regions).
422     */
423    public function list_sedes(Request $request)
424    {
425        try {
426            $companyIds = $this->getCompanyIds($request);
427
428            $data = TblFinanceSedes::whereIn('company_id', $companyIds)
429                ->where('is_active', 1)
430                ->orderBy('name')
431                ->get(['id', 'name', 'company_id', 'region_id']);
432
433            return response(['message' => 'OK', 'data' => $data]);
434        } catch (\Exception $e) {
435            /** @disregard P1014 */
436            $e->exceptionCode = 'LIST_FINANCE_SEDES_EXCEPTION';
437            report($e);
438
439            return response(['message' => 'KO', 'error' => $e->getMessage()]);
440        }
441    }
442
443    // -------------------------------------------------------
444    // BUDGET ANUAL
445    // -------------------------------------------------------
446
447    /**
448     * GET /finance/budget?year=2025
449     * Devuelve todas las filas de Budget para el año dado,
450     * agrupadas por sede y región.
451     */
452    public function list_budget(Request $request)
453    {
454        try {
455            $companyIds = $this->getCompanyIds($request);
456            $year = $request->query('year', date('Y'));
457
458            $data = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
459                ->where('year', $year)
460                ->orderBy('sede_id')
461                ->orderBy('month')
462                ->get(['company_id', 'sede_id', 'year', 'month', 'amount']);
463
464            return response(['message' => 'OK', 'data' => $data]);
465        } catch (\Exception $e) {
466            /** @disregard P1014 */
467            $e->exceptionCode = 'LIST_BUDGET_EXCEPTION';
468            report($e);
469
470            return response(['message' => 'KO', 'error' => $e->getMessage()]);
471        }
472    }
473
474    /**
475     * POST /finance/budget
476     * Body: { sede_id, year, month, amount }
477     * sede_id = zenitalIdToSedeId(sk_sucursal)
478     */
479    public function upsert_budget(Request $request)
480    {
481        try {
482            $this->ensureSedesExist();
483
484            $data = $request->all();
485            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
486
487            if (! $companyId) {
488                return response(['message' => 'KO', 'error' => 'Sede no encontrada en el mapa de Zenital']);
489            }
490
491            TblFinanceBudgetAnual::updateOrCreate(
492                [
493                    'company_id' => $companyId,
494                    'sede_id' => $data['sede_id'],
495                    'year' => $data['year'],
496                    'month' => $data['month'],
497                ],
498                ['amount' => $data['amount']]
499            );
500
501            return response(['message' => 'OK']);
502        } catch (\Exception $e) {
503            /** @disregard P1014 */
504            $e->exceptionCode = 'UPSERT_BUDGET_EXCEPTION';
505            report($e);
506
507            return response(['message' => 'KO', 'error' => $e->getMessage()]);
508        }
509    }
510
511    /**
512     * POST /finance/budget/bulk
513     * Body: { year, rows: [{ sede_id, month, amount }, ...] }
514     */
515    public function bulk_upsert_budget(Request $request)
516    {
517        try {
518            $this->ensureSedesExist();
519
520            $year = $request->input('year');
521            $rows = $request->input('rows', []);
522
523            // FIRE-1148: prefetch sede→company_id in one query so the foreach
524            // doesn't fire getCompanyIdBySedeId (1 SQL) per row. ensureSedesExist
525            // above guarantees every sede in the payload now exists, so the map
526            // covers all rows; rows whose sede_id isn't in the map are skipped
527            // the same way the previous getCompanyIdBySedeId === null branch did.
528            $sedeIds = array_unique(array_map(fn ($r) => (int) $r['sede_id'], $rows));
529            $sedeToCompany = TblFinanceSedes::whereIn('id', $sedeIds)->pluck('company_id', 'id');
530
531            DB::transaction(function () use ($year, $rows, $sedeToCompany) {
532                foreach ($rows as $row) {
533                    $companyId = $sedeToCompany[$row['sede_id']] ?? null;
534                    if (! $companyId) {
535                        continue;
536                    }
537
538                    TblFinanceBudgetAnual::updateOrCreate(
539                        [
540                            'company_id' => $companyId,
541                            'sede_id' => $row['sede_id'],
542                            'year' => $year,
543                            'month' => $row['month'],
544                        ],
545                        ['amount' => $row['amount']]
546                    );
547                }
548            });
549
550            return response(['message' => 'OK']);
551        } catch (\Exception $e) {
552            /** @disregard P1014 */
553            $e->exceptionCode = 'BULK_UPSERT_BUDGET_EXCEPTION';
554            report($e);
555
556            return response(['message' => 'KO', 'error' => $e->getMessage()]);
557        }
558    }
559
560    /**
561     * DELETE /finance-budget?sede_id=X&year=Y&month=Z
562     *
563     * Berta audit follow-up — Item 2: the Budget tab had no way to clear
564     * a cell. The operator's only option was to type "0" in the modal,
565     * which left a row with amount=0 that still rendered as "0,00 €".
566     * They wanted "blank means no budget for this month, not zero". This
567     * endpoint actually removes the row so the cell renders empty again.
568     */
569    public function delete_budget(Request $request)
570    {
571        try {
572            $data = $request->validate([
573                'sede_id' => 'required|integer',
574                'year' => 'required|integer',
575                'month' => 'required|integer|between:1,12',
576            ]);
577
578            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
579
580            $query = TblFinanceBudgetAnual::where('sede_id', $data['sede_id'])
581                ->where('year', $data['year'])
582                ->where('month', $data['month']);
583            if ($companyId) {
584                $query->where('company_id', $companyId);
585            }
586            $query->delete();
587
588            return response(['message' => 'OK']);
589        } catch (\Exception $e) {
590            /** @disregard P1014 */
591            $e->exceptionCode = 'DELETE_BUDGET_EXCEPTION';
592            report($e);
593
594            return response(['message' => 'KO', 'error' => $e->getMessage()]);
595        }
596    }
597
598    // -------------------------------------------------------
599    // PREVISIÓN ANUAL
600    // -------------------------------------------------------
601
602    public function list_prevision(Request $request)
603    {
604        try {
605            $companyIds = $this->getCompanyIds($request);
606            $year = $request->query('year', date('Y'));
607
608            $data = TblFinancePrevisionAnual::whereIn('company_id', $companyIds)
609                ->where('year', $year)
610                ->orderBy('sede_id')
611                ->orderBy('month')
612                ->get(['company_id', 'sede_id', 'year', 'month', 'amount']);
613
614            return response(['message' => 'OK', 'data' => $data]);
615        } catch (\Exception $e) {
616            /** @disregard P1014 */
617            $e->exceptionCode = 'LIST_PREVISION_EXCEPTION';
618            report($e);
619
620            return response(['message' => 'KO', 'error' => $e->getMessage()]);
621        }
622    }
623
624    public function upsert_prevision(Request $request)
625    {
626        try {
627            $this->ensureSedesExist();
628
629            $data = $request->all();
630            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
631
632            if (! $companyId) {
633                return response(['message' => 'KO', 'error' => 'Sede no encontrada en el mapa de Zenital']);
634            }
635
636            TblFinancePrevisionAnual::updateOrCreate(
637                [
638                    'company_id' => $companyId,
639                    'sede_id' => $data['sede_id'],
640                    'year' => $data['year'],
641                    'month' => $data['month'],
642                ],
643                ['amount' => $data['amount']]
644            );
645
646            return response(['message' => 'OK']);
647        } catch (\Exception $e) {
648            /** @disregard P1014 */
649            $e->exceptionCode = 'UPSERT_PREVISION_EXCEPTION';
650            report($e);
651
652            return response(['message' => 'KO', 'error' => $e->getMessage()]);
653        }
654    }
655
656    public function bulk_upsert_prevision(Request $request)
657    {
658        try {
659            $this->ensureSedesExist();
660
661            $year = $request->input('year');
662            $rows = $request->input('rows', []);
663
664            // FIRE-1148: prefetch sede→company_id — same pattern as
665            // bulk_upsert_budget above. See that method for the rationale.
666            $sedeIds = array_unique(array_map(fn ($r) => (int) $r['sede_id'], $rows));
667            $sedeToCompany = TblFinanceSedes::whereIn('id', $sedeIds)->pluck('company_id', 'id');
668
669            DB::transaction(function () use ($year, $rows, $sedeToCompany) {
670                foreach ($rows as $row) {
671                    $companyId = $sedeToCompany[$row['sede_id']] ?? null;
672                    if (! $companyId) {
673                        continue;
674                    }
675
676                    TblFinancePrevisionAnual::updateOrCreate(
677                        [
678                            'company_id' => $companyId,
679                            'sede_id' => $row['sede_id'],
680                            'year' => $year,
681                            'month' => $row['month'],
682                        ],
683                        ['amount' => $row['amount']]
684                    );
685                }
686            });
687
688            return response(['message' => 'OK']);
689        } catch (\Exception $e) {
690            /** @disregard P1014 */
691            $e->exceptionCode = 'BULK_UPSERT_PREVISION_EXCEPTION';
692            report($e);
693
694            return response(['message' => 'KO', 'error' => $e->getMessage()]);
695        }
696    }
697
698    /**
699     * DELETE /finance-prevision?sede_id=X&year=Y&month=Z — same shape and
700     * rationale as `delete_budget`.
701     */
702    public function delete_prevision(Request $request)
703    {
704        try {
705            $data = $request->validate([
706                'sede_id' => 'required|integer',
707                'year' => 'required|integer',
708                'month' => 'required|integer|between:1,12',
709            ]);
710
711            $companyId = $this->getCompanyIdBySedeId((int) $data['sede_id']);
712
713            $query = TblFinancePrevisionAnual::where('sede_id', $data['sede_id'])
714                ->where('year', $data['year'])
715                ->where('month', $data['month']);
716            if ($companyId) {
717                $query->where('company_id', $companyId);
718            }
719            $query->delete();
720
721            return response(['message' => 'OK']);
722        } catch (\Exception $e) {
723            /** @disregard P1014 */
724            $e->exceptionCode = 'DELETE_PREVISION_EXCEPTION';
725            report($e);
726
727            return response(['message' => 'KO', 'error' => $e->getMessage()]);
728        }
729    }
730
731    // -------------------------------------------------------
732    // RESUMEN ANUAL (solo lectura desde front, escritura automática)
733    // -------------------------------------------------------
734
735    /**
736     * GET /finance/resumen?year=2025
737     * Actuals, n-1 y n-2 se calculan en tiempo real desde Zenital (facturacion).
738     * Budget se lee de tbl_finance_budget_anual (MySQL).
739     * sede_id = zenitalIdToSedeId(sk_sucursal).
740     */
741    public function list_resumen(Request $request)
742    {
743        try {
744            $companyIds = $this->getCompanyIds($request);
745            $year = (int) $request->query('year', date('Y'));
746            // "Hasta {mes}" filter — when the Resumen Anual tab shows months
747            // 1..N, the region_ytd_totals must sum the same range so the
748            // tab's "Total Region" matches what the user sees. Defaults to
749            // 12 (full year) for backwards compatibility with callers that
750            // don't pass the param.
751            $monthCutoff = (int) $request->query('month', 12);
752            if ($monthCutoff < 1 || $monthCutoff > 12) {
753                $monthCutoff = 12;
754            }
755
756            // Map [sk_sucursal_zenital => sede_id]. FIX: the static map
757            // returns the hardcoded sede_id from getZenitalSucursalesMap
758            // (e.g. 111 for "Extintores Clemente"), but the frontend keys
759            // sedes by tbl_finance_sedes.id (e.g. 32). Translate by name
760            // so the resumen data's sede_id matches the list_sedes output.
761            $zenitalToSede = $this->resolveZenitalSedeMap($companyIds);
762
763            // Berta audit follow-up: do NOT short-circuit when a region has
764            // no Zenital-mapped sedes. UI-created sedes (Cano Lopera, Cuenfa,
765            // Extincas, Aeroextinción, and any future ones the operator adds
766            // from the Crear Sede form) live entirely in MySQL — their budget
767            // and report rows must still surface even when the company has
768            // zero Zenital sucursales. The local merges below already handle
769            // the zenital-empty case correctly as long as we let execution
770            // continue past this point.
771            $zenitalIds = array_keys($zenitalToSede);
772
773            // Facturación desde data warehouse para año actual, n-1 y n-2.
774            //
775            // El conector pgsql de Laravel no expone `connect_timeout` ni
776            // `statement_timeout` por config, así que los fijamos en runtime:
777            //   - PGCONNECT_TIMEOUT: libpq por defecto bloquea hasta ~75s si
778            //     el DWH está caído; el hilo PHP se queda colgado y el
779            //     navegador ve la pestaña "sin info".
780            //   - statement_timeout: aborta consultas que se vayan de tiempo
781            //     (latencia transatlántica + planes lentos en el DWH). El
782            //     catch ya cae a los datos locales (budget MySQL, facturación
783            //     semanal subida desde Drive) — el caller no se queda en
784            //     blanco, simplemente sin "actuals" Zenital.
785            putenv('PGCONNECT_TIMEOUT='.env('ZENITAL_DB_CONNECT_TIMEOUT', 5));
786            $idx = [];
787            try {
788                DB::connection('zenital')->statement('SET statement_timeout = '.(int) env('ZENITAL_DB_STATEMENT_TIMEOUT_MS', 15000));
789
790                // Per-invoice dedup before summing. fact_facturacion can
791                // carry duplicate rows per num_factura (Zenital ETL bug
792                // surfaced on STG: every Alcarrena invoice was recorded
793                // twice). Collapse to one row per (num_factura,
794                // sk_sucursal, ano, num_mes) by MAX-ing the amount, then
795                // SUM. Mirror of SendFinanceReport::getResumenData.
796                $skPlaceholders = implode(',', array_fill(0, count($zenitalIds), '?'));
797                $bindings = array_merge(
798                    array_map('intval', $zenitalIds),
799                    [$year, $year - 1, $year - 2],
800                );
801
802                $facturacion = DB::connection('zenital')->select("
803                    SELECT sk_sucursal, ano, mes, SUM(invoice_amount) AS total
804                    FROM (
805                        SELECT f.num_factura, f.sk_sucursal, d.ano,
806                               d.num_mes AS mes,
807                               MAX(f.base_imponible) AS invoice_amount
808                        FROM fact_facturacion f
809                        JOIN dim_fecha d ON d.sk_fecha = f.sk_fecha_emision
810                        WHERE f.sk_sucursal IN ({$skPlaceholders})
811                          AND d.ano IN (?, ?, ?)
812                        GROUP BY f.num_factura, f.sk_sucursal, d.ano, d.num_mes
813                    ) per_invoice
814                    GROUP BY sk_sucursal, ano, mes
815                ", $bindings);
816
817                foreach ($facturacion as $row) {
818                    $sedeId = $zenitalToSede[$row->sk_sucursal] ?? $row->sk_sucursal;
819                    $idx[$sedeId][$row->ano][$row->mes] = ($idx[$sedeId][$row->ano][$row->mes] ?? 0) + (float) $row->total;
820                }
821            } catch (\Exception $e) {
822                Log::channel('third-party')->warning('Zenital connection failed in list_resumen, using local data only: '.$e->getMessage());
823            }
824
825            // Mirror of list_report_semanal lines 1248-1342 and
826            // SendFinanceReport::getResumenData. GESTIONA-sourced invoices
827            // with sk_sucursal = -1 are re-attributed via
828            // dim_cliente_facturacion.sucursal when possible; the residual
829            // stays in a per-region bucket so the frontend can render a
830            // "Sin asignar a sede" row matching the Reporte Semanal tab.
831            // Pre-fix Resumen Anual was missing ~1.65 M€ YTD of Catalunya /
832            // Castellón / Valencia / etc. Zenital invoices because the
833            // Desconocido pass simply didn't exist here.
834            $unattributedByRegion = [];
835            $skRegionToTitan = $this->getZenitalRegionMap();
836            if (! empty($skRegionToTitan)) {
837                try {
838                    DB::connection('zenital')->statement('SET statement_timeout = '.(int) env('ZENITAL_DB_STATEMENT_TIMEOUT_MS', 15000));
839
840                    $localByName = TblFinanceSedes::where('is_active', 1)
841                        ->whereNotNull('company_id')
842                        ->get()
843                        ->keyBy(fn ($s) => mb_strtolower(trim((string) $s->name)));
844                    $clientSucursalAliases = [
845                        'jomar alcarreña' => 'alcarrena',
846                    ];
847
848                    $regionKeys = array_keys($skRegionToTitan);
849                    $regionPlaceholders = implode(',', array_fill(0, count($regionKeys), '?'));
850                    $bindings = array_merge(
851                        array_map('intval', $regionKeys),
852                        [$year, $year - 1, $year - 2],
853                    );
854
855                    // BI series-letter rules — see
856                    // \App\Console\Commands\SendFinanceReport::seriesAttributionMap.
857                    $descRows = DB::connection('zenital')->select("
858                        SELECT sk_region, serie, client_sucursal, ano, mes,
859                               SUM(invoice_amount) AS total
860                        FROM (
861                            SELECT f.num_factura, f.sk_region,
862                                   UPPER(SUBSTRING(f.num_factura FROM 1 FOR 1)) AS serie,
863                                   c.sucursal AS client_sucursal, d.ano,
864                                   d.num_mes AS mes,
865                                   MAX(f.base_imponible) AS invoice_amount
866                            FROM fact_facturacion f
867                            JOIN dim_fecha d ON d.sk_fecha = f.sk_fecha_emision
868                            LEFT JOIN dim_cliente_facturacion c
869                                ON c.sk_cliente_facturacion = f.sk_cliente_facturacion
870                            WHERE f.sk_sucursal = -1
871                              AND f.fuente_datos = 'GESTIONA'
872                              AND f.sk_region IN ({$regionPlaceholders})
873                              AND d.ano IN (?, ?, ?)
874                            GROUP BY f.num_factura, f.sk_region, serie, c.sucursal, d.ano, d.num_mes
875                        ) per_invoice
876                        GROUP BY sk_region, serie, client_sucursal, ano, mes
877                    ", $bindings);
878
879                    $seriesMap = \App\Console\Commands\SendFinanceReport::seriesAttributionMap();
880                    $simplification = \App\Console\Commands\SendFinanceReport::sedeSimplificationMap();
881
882                    foreach ($descRows as $row) {
883                        $ano = (int) $row->ano;
884                        $mes = (int) $row->mes;
885                        $total = (float) $row->total;
886                        $skRegion = (int) $row->sk_region;
887                        $serie = mb_strtoupper(trim((string) ($row->serie ?? '')));
888
889                        $bySerieTarget = $seriesMap[$skRegion][$serie]
890                            ?? $seriesMap[$skRegion]['*']
891                            ?? null;
892
893                        $sede = null;
894                        if ($bySerieTarget !== null && $bySerieTarget !== '__DROP__') {
895                            $canonical = $simplification[$bySerieTarget] ?? $bySerieTarget;
896                            $sede = $localByName[$canonical] ?? null;
897                        }
898                        // No client_sucursal fallback — see getResumenData.
899
900                        if ($sede !== null) {
901                            $idx[(int) $sede->id][$ano][$mes] = ($idx[(int) $sede->id][$ano][$mes] ?? 0) + $total;
902
903                            continue;
904                        }
905
906                        $titanRegionId = $skRegionToTitan[$skRegion] ?? null;
907                        if ($titanRegionId === null) {
908                            continue;
909                        }
910                        $field = $ano === $year ? 'actuals'
911                            : ($ano === $year - 1 ? 'actuals_n1'
912                            : ($ano === $year - 2 ? 'actuals_n2' : null));
913                        if ($field === null) {
914                            continue;
915                        }
916                        $unattributedByRegion[$titanRegionId][$mes][$field]
917                            = ($unattributedByRegion[$titanRegionId][$mes][$field] ?? 0.0) + $total;
918                    }
919                } catch (\Exception $e) {
920                    Log::channel('third-party')->warning('Zenital Desconocido query failed in list_resumen: '.$e->getMessage());
921                }
922            }
923
924            // Budget de MySQL: [sede_id][mes] = amount
925            $budgets = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
926                ->where('year', $year)
927                ->get(['sede_id', 'month', 'amount']);
928            $budgetIdx = [];
929            foreach ($budgets as $b) {
930                $budgetIdx[$b->sede_id][$b->month] = (float) $b->amount;
931            }
932
933            // Construir resultado (keyed by sede_id, handles multiple sk_sucursals per sede)
934            // Also include any sede that picked up Desconocido re-attributed
935            // invoices through the pass above — those sedes don't appear in
936            // $zenitalToSede (they're not in dim_sucursal) but they DO have
937            // values in $idx and should emit a row.
938            $data = [];
939            $processedSedes = [];
940            $emittableSedeIds = array_unique(array_merge(
941                array_values($zenitalToSede),
942                array_filter(array_keys($idx), 'is_numeric')
943            ));
944            foreach ($emittableSedeIds as $sedeId) {
945                if (isset($processedSedes[$sedeId])) {
946                    continue;
947                }
948                $processedSedes[$sedeId] = true;
949
950                for ($month = 1; $month <= 12; $month++) {
951                    $actuals = $idx[$sedeId][$year][$month] ?? null;
952                    $n1 = $idx[$sedeId][$year - 1][$month] ?? null;
953                    $n2 = $idx[$sedeId][$year - 2][$month] ?? null;
954                    $budget = $budgetIdx[$sedeId][$month] ?? null;
955
956                    if ($actuals === null && $n1 === null && $n2 === null && $budget === null) {
957                        continue;
958                    }
959
960                    $data[] = [
961                        'sede_id' => $sedeId,
962                        'month' => $month,
963                        'actuals' => $actuals,
964                        'actuals_n1' => $n1,
965                        'actuals_n2' => $n2,
966                        'budget' => $budget,
967                        'deviation_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
968                        'deviation_vs_n2' => ($n2 && $actuals !== null) ? (($actuals - $n2) / $n2) : null,
969                        'deviation_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
970                        // FIRE-1027: tag the row source so the frontend knows
971                        // whether the cells are editable. 'zenital' rows come
972                        // from the DWH and stay read-only.
973                        'source' => 'zenital',
974                    ];
975                }
976            }
977
978            // Merge locally stored data for sedes not already covered by Zenital
979            $localData = TblFinanceResumenAnual::whereIn('company_id', $companyIds)
980                ->where('year', $year)
981                ->get();
982
983            $coveredSedes = [];
984            foreach ($data as $d) {
985                $coveredSedes[$d['sede_id'].'-'.$d['month']] = true;
986            }
987            foreach ($localData as $row) {
988                if (isset($coveredSedes[$row->sede_id.'-'.$row->month])) {
989                    continue;
990                }
991
992                $data[] = [
993                    'sede_id' => $row->sede_id,
994                    'month' => $row->month,
995                    'actuals' => $row->actuals,
996                    'actuals_n1' => $row->actuals_n1,
997                    'actuals_n2' => $row->actuals_n2,
998                    'budget' => $row->budget,
999                    'deviation_vs_n1' => $row->deviation_vs_n1,
1000                    'deviation_vs_n2' => $row->deviation_vs_n2,
1001                    'deviation_vs_budget' => $row->deviation_vs_budget,
1002                    // FIRE-1027: surface the stored source so the frontend
1003                    // can tell apart zenital / google_drive / manual_override
1004                    // rows and gate editability accordingly.
1005                    'source' => $row->source,
1006                ];
1007                $coveredSedes[$row->sede_id.'-'.$row->month] = true;
1008            }
1009
1010            // Jorge's request: the Reporte Semanal tab picks up Google-Drive
1011            // imported actuals (Aeroextinción, Cano Lopera, etc.) but Resumen
1012            // Anual was ignoring them. Aggregate tbl_finance_report_semanal
1013            // by (sede, year, month) for year / year-1 / year-2 and fold in
1014            // any sede+month the loops above didn't already cover.
1015            $weeklyAgg = TblFinanceReportSemanal::whereIn('company_id', $companyIds)
1016                ->whereIn('year', [$year, $year - 1, $year - 2])
1017                ->selectRaw('sede_id, year, month, SUM(actuals) as total_actuals')
1018                ->groupBy('sede_id', 'year', 'month')
1019                ->get();
1020
1021            $weeklyIdx = [];
1022            foreach ($weeklyAgg as $row) {
1023                if ($row->total_actuals === null) {
1024                    continue;
1025                }
1026                $weeklyIdx[$row->sede_id][(int) $row->year][(int) $row->month] = (float) $row->total_actuals;
1027            }
1028
1029            foreach ($weeklyIdx as $sedeId => $perYear) {
1030                for ($month = 1; $month <= 12; $month++) {
1031                    $key = $sedeId.'-'.$month;
1032                    if (isset($coveredSedes[$key])) {
1033                        continue;
1034                    }
1035                    $actuals = $perYear[$year][$month] ?? null;
1036                    $n1 = $perYear[$year - 1][$month] ?? null;
1037                    $n2 = $perYear[$year - 2][$month] ?? null;
1038                    $budget = $budgetIdx[$sedeId][$month] ?? null;
1039
1040                    if ($actuals === null && $n1 === null && $n2 === null && $budget === null) {
1041                        continue;
1042                    }
1043
1044                    $data[] = [
1045                        'sede_id' => $sedeId,
1046                        'month' => $month,
1047                        'actuals' => $actuals,
1048                        'actuals_n1' => $n1,
1049                        'actuals_n2' => $n2,
1050                        'budget' => $budget,
1051                        'deviation_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
1052                        'deviation_vs_n2' => ($n2 && $actuals !== null) ? (($actuals - $n2) / $n2) : null,
1053                        'deviation_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
1054                        // FIRE-1027: this pass synthesises rows from the
1055                        // weekly Drive imports — flag them so the UI shows
1056                        // them as editable even though no resumen row exists
1057                        // yet (one will be created on the user's first edit).
1058                        'source' => 'google_drive',
1059                    ];
1060                    $coveredSedes[$key] = true;
1061                }
1062            }
1063
1064            // Berta audit follow-up: a sede with ONLY budget data (no Zenital
1065            // facturación, no Drive-imported actuals, no manually-edited
1066            // resumen row) was invisible in Resumen Anual. UI-created sedes
1067            // typically land here when the operator first adds them — they
1068            // have budget rows in tbl_finance_budget_anual but nothing else
1069            // until Zenital or Drive starts feeding them. Walk the budget
1070            // index and add entries for any (sede, month) not already
1071            // covered, so the operator can see the rows they just typed.
1072            foreach ($budgetIdx as $sedeId => $perMonth) {
1073                foreach ($perMonth as $month => $budget) {
1074                    $key = $sedeId.'-'.$month;
1075                    if (isset($coveredSedes[$key])) {
1076                        continue;
1077                    }
1078                    // Skip null AND zero — zero-amount rows are placeholders
1079                    // (created by past upserts where the operator typed 0
1080                    // instead of using the new delete button). They'd just
1081                    // add visual noise in Resumen with no data to look at.
1082                    if (! $budget) {
1083                        continue;
1084                    }
1085                    $data[] = [
1086                        'sede_id' => $sedeId,
1087                        'month' => (int) $month,
1088                        'actuals' => null,
1089                        'actuals_n1' => null,
1090                        'actuals_n2' => null,
1091                        'budget' => $budget,
1092                        'deviation_vs_n1' => null,
1093                        'deviation_vs_n2' => null,
1094                        'deviation_vs_budget' => null,
1095                        // No actuals source yet — treat as manually editable
1096                        // so Berta can type N-1/N-2 directly if she wants.
1097                        'source' => 'manual_override',
1098                    ];
1099                    $coveredSedes[$key] = true;
1100                }
1101            }
1102
1103            // Per-region YTD totals — share the email's aggregator so the
1104            // Resumen Anual "Total Region" YTD column reads the SAME numbers
1105            // the Cierre Mensual preview shows. We instantiate
1106            // SendFinanceReport, call its getResumenData per company with
1107            // the user-chosen "Hasta {mes}" cutoff, and run the result
1108            // through aggregateAllReports — same code path as the email.
1109            //
1110            // IMPORTANT: the per-sede `data` array above is scoped to the
1111            // logged-in user's companies via $companyIds. For the region
1112            // grand totals we expand to ALL active finance companies — the
1113            // email's SendFinanceReport does the same when a recipient
1114            // covers "all regions". Without this expansion, region totals
1115            // silently lose any sede whose home company isn't in the user's
1116            // TblCompanyUsers mapping, even though Jorge's whitelist would
1117            // attribute that sede to a visible region.
1118            $regionYtdTotals = [];
1119            try {
1120                $aggregatorCompanyIds = TblFinanceSedes::where('is_active', 1)
1121                    ->pluck('company_id')
1122                    ->unique()
1123                    ->filter()
1124                    ->values()
1125                    ->toArray();
1126                $cmd = new \App\Console\Commands\SendFinanceReport();
1127                $reportsByCompany = [];
1128                foreach ($aggregatorCompanyIds as $cid) {
1129                    $company = TblCompanies::find($cid);
1130                    if (! $company) {
1131                        continue;
1132                    }
1133                    $monthlyData = $cmd->getResumenData((int) $cid, (int) $year, (int) $monthCutoff, 'monthly');
1134                    if (! empty($monthlyData['actuals_ytd']) || ! empty($monthlyData['by_sede'])) {
1135                        $reportsByCompany[$cid] = [
1136                            'company' => $company,
1137                            // weekly is not used by the aggregateAllReports
1138                            // 'monthly' section but the method iterates both,
1139                            // so pass the same payload to keep it happy.
1140                            'weekly'  => $monthlyData,
1141                            'monthly' => $monthlyData,
1142                        ];
1143                    }
1144                }
1145                if (! empty($reportsByCompany)) {
1146                    $aggregated = $cmd->aggregateAllReports($reportsByCompany, 'monthly');
1147                    $byRegion = $aggregated['monthly']['by_region'] ?? [];
1148                    foreach ($byRegion as $regionId => $vals) {
1149                        if (! is_numeric($regionId)) {
1150                            continue; // skip 'company-X' fallback buckets
1151                        }
1152                        // FIRE-1094 follow-up (n-2 total bug): the email's
1153                        // aggregateAllReports() only tracks actuals + actuals_n1
1154                        // + budget. We deliberately do NOT publish an n-2 key
1155                        // here — the frontend keeps the per-sede fallback for
1156                        // n-2, which already respects the "Hasta {mes}" cutoff
1157                        // via sumResumenForSede(). Previously this slot was
1158                        // hardcoded to 0.0, which the frontend then displayed
1159                        // as the Total n-2 column even when per-sede data
1160                        // existed.
1161                        $regionYtdTotals[(int) $regionId] = [
1162                            'actuals'    => (float) ($vals['actuals'] ?? 0),
1163                            'actuals_n1' => (float) ($vals['actuals_n1'] ?? 0),
1164                            'budget'     => (float) ($vals['budget'] ?? 0),
1165                        ];
1166                    }
1167                }
1168            } catch (\Throwable $e) {
1169                // If the email aggregator fails for any reason, fall back to
1170                // an empty totals map — the frontend will sum per-sede on
1171                // its own. Don't break Resumen Anual just because the
1172                // alignment call hiccuped.
1173                Log::channel('third-party')->warning('list_resumen — shared aggregator failed: '.$e->getMessage());
1174                $regionYtdTotals = [];
1175            }
1176
1177            return response([
1178                'message' => 'OK',
1179                'data' => $data,
1180                'unattributed_by_region' => $unattributedByRegion,
1181                'region_ytd_totals' => $regionYtdTotals,
1182            ]);
1183        } catch (\Exception $e) {
1184            /** @disregard P1014 */
1185            $e->exceptionCode = 'LIST_RESUMEN_EXCEPTION';
1186            report($e);
1187
1188            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1189        }
1190    }
1191
1192    /**
1193     * POST /finance/resumen/load
1194     * Llamado desde el job automático del día 1 de cada mes.
1195     * Body: { year, month, rows: [{ sede_id, actuals, actuals_n1, actuals_n2 }] }
1196     */
1197    public function load_resumen(Request $request)
1198    {
1199        try {
1200            $company = $this->getCompany($request);
1201            $year = $request->input('year');
1202            $month = $request->input('month');
1203            $rows = $request->input('rows', []);
1204
1205            // FIRE-1148: prefetch both the existing resumen rows and the
1206            // matching budget rows in two queries before the foreach, then
1207            // do in-memory lookups inside. Pre-fix this method fired
1208            // 3 queries per row (existing-check, budget-lookup, updateOrCreate)
1209            // × N rows from the cron payload. The two prefetches hit
1210            // idx_finresumen_company_sede_year / idx_finbudget_company_sede_year
1211            // (added by FIRE-1149) directly.
1212            $sedeIds = array_unique(array_map(fn ($r) => (int) $r['sede_id'], $rows));
1213
1214            $existingBySedeId = TblFinanceResumenAnual::where('company_id', $company->company_id)
1215                ->where('year', $year)
1216                ->where('month', $month)
1217                ->whereIn('sede_id', $sedeIds)
1218                ->get()
1219                ->keyBy('sede_id');
1220
1221            $budgetBySedeId = TblFinanceBudgetAnual::where('company_id', $company->company_id)
1222                ->where('year', $year)
1223                ->where('month', $month)
1224                ->whereIn('sede_id', $sedeIds)
1225                ->pluck('amount', 'sede_id');
1226
1227            DB::transaction(function () use ($company, $year, $month, $rows, $existingBySedeId, $budgetBySedeId) {
1228                foreach ($rows as $row) {
1229                    // FIRE-1027: don't clobber rows the user has manually
1230                    // edited. The Zenital cron only owns rows whose source
1231                    // is 'zenital' or NULL. 'google_drive' (Drive imports)
1232                    // and 'manual_override' (UI edits) are user-managed.
1233                    $existing = $existingBySedeId[$row['sede_id']] ?? null;
1234                    if ($existing && in_array($existing->source, ['google_drive', 'manual_override'], true)) {
1235                        continue;
1236                    }
1237
1238                    $budget = $budgetBySedeId[$row['sede_id']] ?? null;
1239
1240                    $actuals = $row['actuals'];
1241                    $n1 = $row['actuals_n1'] ?? null;
1242                    $n2 = $row['actuals_n2'] ?? null;
1243
1244                    TblFinanceResumenAnual::updateOrCreate(
1245                        [
1246                            'company_id' => $company->company_id,
1247                            'sede_id' => $row['sede_id'],
1248                            'year' => $year,
1249                            'month' => $month,
1250                        ],
1251                        [
1252                            'actuals' => $actuals,
1253                            'actuals_n1' => $n1,
1254                            'actuals_n2' => $n2,
1255                            'budget' => $budget,
1256                            'deviation_vs_n1' => ($n1 && $n1 != 0) ? (($actuals - $n1) / $n1) : null,
1257                            'deviation_vs_n2' => ($n2 && $n2 != 0) ? (($actuals - $n2) / $n2) : null,
1258                            'deviation_vs_budget' => ($budget && $budget != 0) ? (($actuals - $budget) / $budget) : null,
1259                            'source' => 'zenital',
1260                            'loaded_at' => now(),
1261                        ]
1262                    );
1263                }
1264            });
1265
1266            return response(['message' => 'OK']);
1267        } catch (\Exception $e) {
1268            /** @disregard P1014 */
1269            $e->exceptionCode = 'LOAD_RESUMEN_EXCEPTION';
1270            report($e);
1271
1272            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1273        }
1274    }
1275
1276    // -------------------------------------------------------
1277    // REPORT SEMANAL (solo lectura desde front, escritura automática)
1278    // -------------------------------------------------------
1279
1280    /**
1281     * GET /finance/report-semanal?year=2025&month=10
1282     * YTD (meses 1..month) calculado en tiempo real desde Zenital.
1283     * Budget y Previsión YTD se leen de MySQL.
1284     * sede_id = zenitalIdToSedeId(sk_sucursal).
1285     */
1286    public function list_report_semanal(Request $request)
1287    {
1288        try {
1289            $companyIds = $this->getCompanyIds($request);
1290            $year = (int) $request->query('year', date('Y'));
1291            $month = (int) $request->query('month', date('n'));
1292
1293            // Map [sk_sucursal_zenital => tbl_finance_sedes.id]. See the note
1294            // in list_resumen — buildZenitalSedeMap returns the hardcoded id,
1295            // we translate to the local sede id so the frontend can match.
1296            $zenitalToSede = $this->resolveZenitalSedeMap($companyIds);
1297
1298            // Berta audit follow-up: same as list_resumen — do not short-
1299            // circuit when the company has no Zenital-mapped sedes. The
1300            // local merges below cover UI-created sedes correctly when we
1301            // let execution fall through to them.
1302            $zenitalIds = array_keys($zenitalToSede);
1303
1304            // Datos mensuales desde data warehouse (año actual y año
1305            // anterior, solo el mes seleccionado). Timeouts explicados
1306            // en list_resumen — mismo motivo aquí: si el DWH no contesta
1307            // o las consultas se cuelgan, caer rápido a lo que tengamos
1308            // en tbl_finance_report_semanal local.
1309            putenv('PGCONNECT_TIMEOUT='.env('ZENITAL_DB_CONNECT_TIMEOUT', 5));
1310            $ytdIdx = [];
1311            $latestDates = collect();
1312            try {
1313                DB::connection('zenital')->statement('SET statement_timeout = '.(int) env('ZENITAL_DB_STATEMENT_TIMEOUT_MS', 15000));
1314
1315                // Per-invoice dedup before summing — collapse duplicate
1316                // fact_facturacion rows (same num_factura, same sucursal)
1317                // before aggregating. See SendFinanceReport::getResumenData
1318                // for the full rationale; tl;dr STG had every Alcarrena
1319                // invoice recorded twice and the YTD email reported 2x.
1320                $skPlaceholders = implode(',', array_fill(0, count($zenitalIds), '?'));
1321                $bindings = array_merge(
1322                    array_map('intval', $zenitalIds),
1323                    [$year, $year - 1, $month],
1324                );
1325
1326                $facturacion = DB::connection('zenital')->select("
1327                    SELECT sk_sucursal, ano, SUM(invoice_amount) AS total
1328                    FROM (
1329                        SELECT f.num_factura, f.sk_sucursal, d.ano,
1330                               MAX(f.base_imponible) AS invoice_amount
1331                        FROM fact_facturacion f
1332                        JOIN dim_fecha d ON d.sk_fecha = f.sk_fecha_emision
1333                        WHERE f.sk_sucursal IN ({$skPlaceholders})
1334                          AND d.ano IN (?, ?)
1335                          AND d.num_mes = ?
1336                        GROUP BY f.num_factura, f.sk_sucursal, d.ano
1337                    ) per_invoice
1338                    GROUP BY sk_sucursal, ano
1339                ", $bindings);
1340
1341                // Catalunya empty-actuals follow-up — same pattern list_resumen
1342                // already uses on line ~851. The runtime sucursal map can route
1343                // more than one sk_sucursal to the same local sede (Zenital has
1344                // both "AirFeu" sk=91 cod=24 and "Airfeu" sk=92 cod=19 — both
1345                // resolve to local "AirFeu" sede id 24; same for Vivo/Vivó,
1346                // Robles/Robles Legacy, SeguCor/SeguCor Legacy). Key the index
1347                // by sede_id and SUM across sk_sucursals, otherwise the per-row
1348                // emission below produces two entries per sede and the
1349                // frontend's getReportBySede[0] sometimes picks the empty one
1350                // (Valencia lost 544 K€ of AirFeu this way on stg April 2026).
1351                foreach ($facturacion as $row) {
1352                    $sedeId = $zenitalToSede[$row->sk_sucursal] ?? $row->sk_sucursal;
1353                    $ytdIdx[$sedeId][$row->ano] = ($ytdIdx[$sedeId][$row->ano] ?? 0) + (float) $row->total;
1354                }
1355
1356                // Fecha más reciente de emisión por sede (keep the MAX across
1357                // every sk_sucursal that maps to the same sede). Same dedup
1358                // rationale as the index above.
1359                $latestRows = DB::connection('zenital')
1360                    ->table('fact_facturacion as f')
1361                    ->join('dim_fecha as d', 'f.sk_fecha_emision', '=', 'd.sk_fecha')
1362                    ->whereIn('f.sk_sucursal', $zenitalIds)
1363                    ->where('d.ano', $year)
1364                    ->where('d.num_mes', $month)
1365                    ->selectRaw('f.sk_sucursal, MAX(d.fecha) as latest_date')
1366                    ->groupBy('f.sk_sucursal')
1367                    ->get();
1368                foreach ($latestRows as $r) {
1369                    $sedeId = $zenitalToSede[$r->sk_sucursal] ?? $r->sk_sucursal;
1370                    $current = $latestDates->get($sedeId);
1371                    if ($current === null || (string) $r->latest_date > (string) $current->latest_date) {
1372                        $latestDates->put($sedeId, $r);
1373                    }
1374                }
1375            } catch (\Exception $e) {
1376                Log::channel('third-party')->warning('Zenital connection failed in list_report_semanal, using local data only: '.$e->getMessage());
1377            }
1378
1379            // Catalunya empty-actuals follow-up: GESTIONA invoices in Zenital
1380            // often arrive with sk_sucursal = -1 ("Desconocido") at the
1381            // *invoice* level, but the *client* record they belong to does
1382            // carry a sucursal label in `dim_cliente_facturacion.sucursal`
1383            // (this is what Power BI groups by). For April 2026 Catalunya
1384            // alone, that re-attribution reclaims ≈ 459 K€ from the
1385            // unattributed pile and routes it to the real sedes
1386            // (Extintores Clemente, Gallex, Ingesfoc…). The remaining
1387            // ≈ 146 K€ where the client also reads "Desconocido" / NULL
1388            // stays in the regional bucket.
1389            //
1390            // Build the same lowercase-name-to-active-sede lookup the
1391            // sucursal map uses internally — same aliases for accent /
1392            // sub-entity quirks (Jomar Alcarreña → Alcarrena).
1393            $localByName = TblFinanceSedes::where('is_active', 1)
1394                ->whereNotNull('company_id')
1395                ->get()
1396                ->keyBy(fn ($s) => mb_strtolower(trim((string) $s->name)));
1397            $clientSucursalAliases = [
1398                'jomar alcarreña' => 'alcarrena',
1399            ];
1400            $unattributedByRegion = [];
1401            $skRegionToTitan = $this->getZenitalRegionMap();
1402            if (! empty($skRegionToTitan) && ! $localByName->isEmpty()) {
1403                try {
1404                    DB::connection('zenital')->statement('SET statement_timeout = '.(int) env('ZENITAL_DB_STATEMENT_TIMEOUT_MS', 15000));
1405
1406                    // Per-invoice dedup — same rationale as the sucursal
1407                    // pass above; fact_facturacion can carry duplicate
1408                    // rows per num_factura even for Desconocido invoices.
1409                    $regionKeys = array_keys($skRegionToTitan);
1410                    $regionPlaceholders = implode(',', array_fill(0, count($regionKeys), '?'));
1411                    $bindings = array_merge(
1412                        array_map('intval', $regionKeys),
1413                        [$year, $year - 1, $month],
1414                    );
1415
1416                    // BI department rules — see
1417                    // \App\Console\Commands\SendFinanceReport::seriesAttributionMap
1418                    // for the source of these mappings.
1419                    $descRows = DB::connection('zenital')->select("
1420                        SELECT sk_region, serie, client_sucursal, ano,
1421                               SUM(invoice_amount) AS total
1422                        FROM (
1423                            SELECT f.num_factura, f.sk_region,
1424                                   UPPER(SUBSTRING(f.num_factura FROM 1 FOR 1)) AS serie,
1425                                   c.sucursal AS client_sucursal, d.ano,
1426                                   MAX(f.base_imponible) AS invoice_amount
1427                            FROM fact_facturacion f
1428                            JOIN dim_fecha d ON d.sk_fecha = f.sk_fecha_emision
1429                            LEFT JOIN dim_cliente_facturacion c
1430                                ON c.sk_cliente_facturacion = f.sk_cliente_facturacion
1431                            WHERE f.sk_sucursal = -1
1432                              AND f.fuente_datos = 'GESTIONA'
1433                              AND f.sk_region IN ({$regionPlaceholders})
1434                              AND d.ano IN (?, ?)
1435                              AND d.num_mes = ?
1436                            GROUP BY f.num_factura, f.sk_region, serie, c.sucursal, d.ano
1437                        ) per_invoice
1438                        GROUP BY sk_region, serie, client_sucursal, ano
1439                    ", $bindings);
1440
1441                    $seriesMap = \App\Console\Commands\SendFinanceReport::seriesAttributionMap();
1442                    $simplification = \App\Console\Commands\SendFinanceReport::sedeSimplificationMap();
1443
1444                    foreach ($descRows as $row) {
1445                        $ano = (int) $row->ano;
1446                        $total = (float) $row->total;
1447                        $skRegion = (int) $row->sk_region;
1448                        $serie = mb_strtoupper(trim((string) ($row->serie ?? '')));
1449
1450                        $bySerieTarget = $seriesMap[$skRegion][$serie]
1451                            ?? $seriesMap[$skRegion]['*']
1452                            ?? null;
1453
1454                        $sede = null;
1455                        if ($bySerieTarget !== null && $bySerieTarget !== '__DROP__') {
1456                            $canonical = $simplification[$bySerieTarget] ?? $bySerieTarget;
1457                            $sede = $localByName[$canonical] ?? null;
1458                        }
1459                        // No client_sucursal fallback — see getResumenData.
1460
1461                        if ($sede !== null) {
1462                            // Re-attribute to the real sede so the existing
1463                            // output loop picks it up alongside any
1464                            // sk_sucursal-keyed Zenital totals.
1465                            $ytdIdx[(int) $sede->id][$ano] = ($ytdIdx[(int) $sede->id][$ano] ?? 0) + $total;
1466
1467                            continue;
1468                        }
1469
1470                        // Couldn't match — stays in the regional bucket.
1471                        $titanRegionId = $skRegionToTitan[$skRegion] ?? null;
1472                        if ($titanRegionId === null) {
1473                            continue;
1474                        }
1475                        if (! isset($unattributedByRegion[$titanRegionId])) {
1476                            $unattributedByRegion[$titanRegionId] = ['actuals' => 0.0, 'actuals_n1' => 0.0];
1477                        }
1478                        $field = $ano === $year ? 'actuals' : 'actuals_n1';
1479                        $unattributedByRegion[$titanRegionId][$field] += $total;
1480                    }
1481                } catch (\Exception $e) {
1482                    Log::channel('third-party')->warning('Zenital Desconocido query failed in list_report_semanal: '.$e->getMessage());
1483                }
1484            }
1485
1486            // Budget mensual de MySQL: solo el mes seleccionado
1487            $budgets = TblFinanceBudgetAnual::whereIn('company_id', $companyIds)
1488                ->where('year', $year)
1489                ->where('month', $month)
1490                ->get(['sede_id', 'amount']);
1491            $budgetYtd = [];
1492            foreach ($budgets as $b) {
1493                $budgetYtd[$b->sede_id] = (float) $b->amount;
1494            }
1495
1496            // Previsión mensual de MySQL: solo el mes seleccionado
1497            $previsions = TblFinancePrevisionAnual::whereIn('company_id', $companyIds)
1498                ->where('year', $year)
1499                ->where('month', $month)
1500                ->get(['sede_id', 'amount']);
1501            $previsionYtd = [];
1502            foreach ($previsions as $p) {
1503                $previsionYtd[$p->sede_id] = (float) $p->amount;
1504            }
1505
1506            // Berta audit follow-up — Item 3: build an index of manual
1507            // Resumen Anual entries up front so the Drive aggregation pass
1508            // below can override its own values when the operator has typed
1509            // an actuals/N-1 on the Resumen tab. This is the natural
1510            // hand-off: Resumen Anual is the "source of truth" the operator
1511            // edits, the weekly report should reflect those edits.
1512            $resumenOverride = TblFinanceResumenAnual::whereIn('company_id', $companyIds)
1513                ->where('year', $year)
1514                ->where('month', $month)
1515                ->get(['sede_id', 'actuals', 'actuals_n1']);
1516            $resumenIdx = [];
1517            foreach ($resumenOverride as $row) {
1518                $resumenIdx[$row->sede_id] = [
1519                    'actuals' => $row->actuals !== null ? (float) $row->actuals : null,
1520                    'actuals_n1' => $row->actuals_n1 !== null ? (float) $row->actuals_n1 : null,
1521                ];
1522            }
1523
1524            // Construir resultado — keyed by sede_id (handles the multiple
1525            // sk_sucursals per sede case via $processedSedes; mirrors
1526            // list_resumen line ~868).
1527            $data = [];
1528            $processedSedes = [];
1529            foreach ($zenitalToSede as $zenitalId => $sedeId) {
1530                if (isset($processedSedes[$sedeId])) {
1531                    continue;
1532                }
1533                $processedSedes[$sedeId] = true;
1534
1535                $zenitalActuals = $ytdIdx[$sedeId][$year] ?? null;
1536                $zenitalN1 = $ytdIdx[$sedeId][$year - 1] ?? null;
1537
1538                // Catalunya empty-actuals follow-up #2: now that the
1539                // sucursal map resolves at runtime by name, sedes that
1540                // exist in `dim_sucursal` but never get invoiced through
1541                // Zenital (Cuenfa, Cano Lopera, Aeroextinción, …) show
1542                // up in $zenitalToSede. If we emit a row for them from
1543                // the Zenital pass with all-null actuals, the Drive pass
1544                // below sees them as "covered" and skips — losing the
1545                // Drive-imported figures (sede 59 / Cuenfa lost 31 815 €
1546                // this way on stg April 2026). Hand-off back to Drive by
1547                // skipping the Zenital row when there's no Zenital data
1548                // *and* no manual Resumen override. Budget / Previsión
1549                // for those sedes still get picked up by the final
1550                // budget-only pass further down.
1551                $hasOverride = isset($resumenIdx[$sedeId]);
1552                $hasZenitalData = $zenitalActuals !== null || $zenitalN1 !== null;
1553                if (! $hasZenitalData && ! $hasOverride) {
1554                    continue;
1555                }
1556
1557                // Resumen Anual manual edits override Zenital actuals/N-1
1558                // for the same sede+month — that's the contract from
1559                // FIRE-1027 / Item 3.
1560                $actuals = $hasOverride && $resumenIdx[$sedeId]['actuals'] !== null
1561                    ? $resumenIdx[$sedeId]['actuals']
1562                    : $zenitalActuals;
1563                $n1 = $hasOverride && $resumenIdx[$sedeId]['actuals_n1'] !== null
1564                    ? $resumenIdx[$sedeId]['actuals_n1']
1565                    : $zenitalN1;
1566                $budget = $budgetYtd[$sedeId] ?? null;
1567                $prevision = $previsionYtd[$sedeId] ?? null;
1568                $weekDate = $latestDates->get($sedeId)?->latest_date ?? null;
1569
1570                if ($actuals === null && $n1 === null && $budget === null && $prevision === null) {
1571                    continue;
1572                }
1573
1574                $data[] = [
1575                    'sede_id' => $sedeId,
1576                    'year' => $year,
1577                    'month' => $month,
1578                    'week_date' => $weekDate,
1579                    'actuals' => $actuals,
1580                    'actuals_n1' => $n1,
1581                    'budget' => $budget,
1582                    'prevision' => $prevision,
1583                    'deviation_vs_n1' => ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null,
1584                    'deviation_pct_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
1585                    'deviation_vs_budget' => ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null,
1586                    'deviation_pct_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
1587                    'deviation_vs_prevision' => ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null,
1588                    'deviation_pct_vs_prevision' => ($prevision && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null,
1589                    'source' => $hasOverride ? 'manual_override' : 'zenital',
1590                ];
1591            }
1592
1593            // Merge Drive-imported data for sedes not already covered by Zenital.
1594            // The import stores one row per week; aggregate to a single row per
1595            // sede (summing actuals) so the UI renders exactly like a Zenital
1596            // sede. n-1 comes from the same table for the prior year.
1597            $coveredSedes = [];
1598            foreach ($data as $d) {
1599                $coveredSedes[$d['sede_id']] = true;
1600            }
1601
1602            $localAgg = TblFinanceReportSemanal::whereIn('company_id', $companyIds)
1603                ->where('month', $month)
1604                ->whereIn('year', [$year, $year - 1])
1605                ->selectRaw('sede_id, year, SUM(actuals) as total_actuals, MAX(week_date) as latest_week')
1606                ->groupBy('sede_id', 'year')
1607                ->get();
1608
1609            $localIdx = [];
1610            foreach ($localAgg as $row) {
1611                $localIdx[$row->sede_id][(int) $row->year] = [
1612                    'total' => $row->total_actuals !== null ? (float) $row->total_actuals : null,
1613                    'latest_week' => $row->latest_week,
1614                ];
1615            }
1616
1617            foreach ($localIdx as $sedeId => $perYear) {
1618                if (isset($coveredSedes[$sedeId])) {
1619                    continue;
1620                }
1621                $driveActuals = $perYear[$year]['total'] ?? null;
1622                $driveN1 = $perYear[$year - 1]['total'] ?? null;
1623                // Same precedence as the Zenital pass — manual Resumen
1624                // edits override Drive-imported actuals/N-1 (Item 3).
1625                $hasOverride = isset($resumenIdx[$sedeId]);
1626                $actuals = $hasOverride && $resumenIdx[$sedeId]['actuals'] !== null
1627                    ? $resumenIdx[$sedeId]['actuals']
1628                    : $driveActuals;
1629                $n1 = $hasOverride && $resumenIdx[$sedeId]['actuals_n1'] !== null
1630                    ? $resumenIdx[$sedeId]['actuals_n1']
1631                    : $driveN1;
1632                $budget = $budgetYtd[$sedeId] ?? null;
1633                $prevision = $previsionYtd[$sedeId] ?? null;
1634
1635                if ($actuals === null && $n1 === null && $budget === null && $prevision === null) {
1636                    continue;
1637                }
1638
1639                $data[] = [
1640                    'sede_id' => $sedeId,
1641                    'year' => $year,
1642                    'month' => $month,
1643                    'week_date' => $perYear[$year]['latest_week'] ?? null,
1644                    'actuals' => $actuals,
1645                    'actuals_n1' => $n1,
1646                    'budget' => $budget,
1647                    'prevision' => $prevision,
1648                    'deviation_vs_n1' => ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null,
1649                    'deviation_pct_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
1650                    'deviation_vs_budget' => ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null,
1651                    'deviation_pct_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
1652                    'deviation_vs_prevision' => ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null,
1653                    'deviation_pct_vs_prevision' => ($prevision && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null,
1654                    'source' => $hasOverride ? 'manual_override' : 'google_drive',
1655                ];
1656                $coveredSedes[$sedeId] = true;
1657            }
1658
1659            // Resumen Anual rows for sedes NOT yet covered above (a sede
1660            // whose only data is a manual Resumen edit — no Zenital map,
1661            // no Drive import). Bring those in too so Berta sees the
1662            // values she typed.
1663            foreach ($resumenIdx as $sedeId => $vals) {
1664                if (isset($coveredSedes[$sedeId])) {
1665                    continue;
1666                }
1667                $actuals = $vals['actuals'];
1668                $n1 = $vals['actuals_n1'];
1669                $budget = $budgetYtd[$sedeId] ?? null;
1670                $prevision = $previsionYtd[$sedeId] ?? null;
1671
1672                if ($actuals === null && $n1 === null && $budget === null && $prevision === null) {
1673                    continue;
1674                }
1675
1676                $data[] = [
1677                    'sede_id' => $sedeId,
1678                    'year' => $year,
1679                    'month' => $month,
1680                    'week_date' => null,
1681                    'actuals' => $actuals,
1682                    'actuals_n1' => $n1,
1683                    'budget' => $budget,
1684                    'prevision' => $prevision,
1685                    'deviation_vs_n1' => ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null,
1686                    'deviation_pct_vs_n1' => ($n1 && $actuals !== null) ? (($actuals - $n1) / $n1) : null,
1687                    'deviation_vs_budget' => ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null,
1688                    'deviation_pct_vs_budget' => ($budget && $actuals !== null) ? (($actuals - $budget) / $budget) : null,
1689                    'deviation_vs_prevision' => ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null,
1690                    'deviation_pct_vs_prevision' => ($prevision && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null,
1691                    'source' => 'manual_override',
1692                ];
1693                $coveredSedes[$sedeId] = true;
1694            }
1695
1696            // Berta audit follow-up: surface sedes that have budget and/or
1697            // previsión rows but no actuals yet (Zenital silent, no Drive
1698            // import, no manual entry). Without this pass a freshly-created
1699            // sede with only a budget typed in stays invisible on the
1700            // weekly report. We union budget + prevision keys so either
1701            // alone is enough to render a row.
1702            foreach (array_unique(array_merge(array_keys($budgetYtd), array_keys($previsionYtd))) as $sedeId) {
1703                if (isset($coveredSedes[$sedeId])) {
1704                    continue;
1705                }
1706                $budget = $budgetYtd[$sedeId] ?? null;
1707                $prevision = $previsionYtd[$sedeId] ?? null;
1708
1709                // Skip placeholder zero rows — same rationale as list_resumen.
1710                if (! $budget && ! $prevision) {
1711                    continue;
1712                }
1713
1714                $data[] = [
1715                    'sede_id' => $sedeId,
1716                    'year' => $year,
1717                    'month' => $month,
1718                    'week_date' => null,
1719                    'actuals' => null,
1720                    'actuals_n1' => null,
1721                    'budget' => $budget,
1722                    'prevision' => $prevision,
1723                    'deviation_vs_n1' => null,
1724                    'deviation_pct_vs_n1' => null,
1725                    'deviation_vs_budget' => null,
1726                    'deviation_pct_vs_budget' => null,
1727                    'deviation_vs_prevision' => null,
1728                    'deviation_pct_vs_prevision' => null,
1729                    // No actuals source yet — manual-override so Berta can
1730                    // type actuals/N-1 directly on the report tab.
1731                    'source' => 'manual_override',
1732                ];
1733                $coveredSedes[$sedeId] = true;
1734            }
1735
1736            // $unattributedByRegion was populated by the Desconocido
1737            // attribution pass earlier (right after the main Zenital
1738            // facturación query). What's left in it here is only the
1739            // money whose client record also reads "Desconocido" — the
1740            // truly unattributable residual that surfaces as the
1741            // "Sin asignar a sede" row in the UI.
1742            return response([
1743                'message' => 'OK',
1744                'data' => $data,
1745                'unattributed_by_region' => $unattributedByRegion,
1746            ]);
1747        } catch (\Exception $e) {
1748            /** @disregard P1014 */
1749            $e->exceptionCode = 'LIST_REPORT_SEMANAL_EXCEPTION';
1750            report($e);
1751
1752            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1753        }
1754    }
1755
1756    /**
1757     * POST /finance/report-semanal/load
1758     * Llamado desde el job automático de cada sábado.
1759     * Body: { week_date, year, month, rows: [{ sede_id, actuals, actuals_n1, budget, prevision }] }
1760     */
1761    public function load_report_semanal(Request $request)
1762    {
1763        try {
1764            $company = $this->getCompany($request);
1765            $weekDate = $request->input('week_date');
1766            $year = $request->input('year');
1767            $month = $request->input('month');
1768            $rows = $request->input('rows', []);
1769
1770            DB::transaction(function () use ($company, $weekDate, $year, $month, $rows) {
1771                foreach ($rows as $row) {
1772                    // FIRE-1027: skip rows the user has manually edited
1773                    // (source='manual_override') or that came from the Drive
1774                    // weekly Excel (source='google_drive') — same guard as
1775                    // load_resumen above.
1776                    $existing = TblFinanceReportSemanal::where([
1777                        'company_id' => $company->company_id,
1778                        'sede_id' => $row['sede_id'],
1779                        'week_date' => $weekDate,
1780                    ])->first();
1781                    if ($existing && in_array($existing->source, ['google_drive', 'manual_override'], true)) {
1782                        continue;
1783                    }
1784
1785                    $actuals = $row['actuals'];
1786                    $n1 = $row['actuals_n1'] ?? null;
1787                    $budget = $row['budget'] ?? null;
1788                    $prevision = $row['prevision'] ?? null;
1789
1790                    TblFinanceReportSemanal::updateOrCreate(
1791                        [
1792                            'company_id' => $company->company_id,
1793                            'sede_id' => $row['sede_id'],
1794                            'week_date' => $weekDate,
1795                        ],
1796                        [
1797                            'year' => $year,
1798                            'month' => $month,
1799                            'actuals' => $actuals,
1800                            'actuals_n1' => $n1,
1801                            'budget' => $budget,
1802                            'prevision' => $prevision,
1803                            'deviation_vs_n1' => ($n1 !== null) ? ($actuals - $n1) : null,
1804                            'deviation_pct_vs_n1' => ($n1 && $n1 != 0) ? (($actuals - $n1) / $n1) : null,
1805                            'deviation_vs_budget' => ($budget !== null) ? ($actuals - $budget) : null,
1806                            'deviation_pct_vs_budget' => ($budget && $budget != 0) ? (($actuals - $budget) / $budget) : null,
1807                            'deviation_vs_prevision' => ($prevision !== null) ? ($actuals - $prevision) : null,
1808                            'deviation_pct_vs_prevision' => ($prevision && $prevision != 0) ? (($actuals - $prevision) / $prevision) : null,
1809                            'source' => 'zenital',
1810                            'loaded_at' => now(),
1811                        ]
1812                    );
1813                }
1814            });
1815
1816            return response(['message' => 'OK']);
1817        } catch (\Exception $e) {
1818            /** @disregard P1014 */
1819            $e->exceptionCode = 'LOAD_REPORT_SEMANAL_EXCEPTION';
1820            report($e);
1821
1822            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1823        }
1824    }
1825
1826    // -------------------------------------------------------
1827    // EDICIÓN CELDA-A-CELDA (FIRE-1027)
1828    // -------------------------------------------------------
1829
1830    /**
1831     * POST /finance/resumen/upsert-cell
1832     *
1833     * FIRE-1027: per-cell write for sedes whose data flows from the manual
1834     * Excel rather than Zenital. Berta needs to be able to type the N-1 /
1835     * N-2 numbers directly for those sedes. Refuses to clobber a Zenital
1836     * row — that data is owned by the DWH and should never be edited from
1837     * the UI.
1838     *
1839     * Body: { sede_id, year, month, field, value }
1840     *   field ∈ {actuals, actuals_n1, actuals_n2, budget}
1841     *   value: number | null  (null clears the cell)
1842     */
1843    public function upsert_resumen_cell(Request $request)
1844    {
1845        try {
1846            $data = $request->validate([
1847                'sede_id' => 'required|integer',
1848                'year' => 'required|integer',
1849                'month' => 'required|integer|between:1,12',
1850                'field' => 'required|in:actuals,actuals_n1,actuals_n2,budget',
1851                'value' => 'nullable|numeric',
1852            ]);
1853
1854            // FIRE-1027: resolve company from the sede directly. We can't use
1855            // `getCompany($request)` because the user often has "Todas las
1856            // regiones" selected (Region header = "All"), and Drive-imported
1857            // sedes don't even live in the currently-selected region — every
1858            // sede already knows its own company_id.
1859            $sede = TblFinanceSedes::find($data['sede_id']);
1860            if (! $sede) {
1861                return response(['message' => 'KO', 'error' => 'sede_not_found', 'sede_id' => $data['sede_id']], 404);
1862            }
1863
1864            // FIRE-1027: hard-refuse writes to Zenital-mapped sedes. Their
1865            // data is synthesised from the DWH at list time so a row in
1866            // tbl_finance_resumen_anual is never read for them — but we
1867            // shouldn't allow a stray manual_override row to accumulate
1868            // either. The frontend should never send these, but enforce
1869            // server-side too.
1870            if ($this->isZenitalSede($data['sede_id'])) {
1871                return response(['message' => 'KO', 'error' => 'cannot_edit_zenital_row'], 403);
1872            }
1873
1874            $row = TblFinanceResumenAnual::firstOrNew([
1875                'company_id' => $sede->company_id,
1876                'sede_id' => $data['sede_id'],
1877                'year' => $data['year'],
1878                'month' => $data['month'],
1879            ]);
1880
1881            $row->{$data['field']} = $data['value'];
1882            $row->source = 'manual_override';
1883            $row->loaded_at = now();
1884
1885            // Recompute deviations against the (possibly newly set) values.
1886            $actuals = $row->actuals;
1887            $n1 = $row->actuals_n1;
1888            $n2 = $row->actuals_n2;
1889            $budget = $row->budget;
1890            $row->deviation_vs_n1 = ($n1 && $n1 != 0 && $actuals !== null) ? (($actuals - $n1) / $n1) : null;
1891            $row->deviation_vs_n2 = ($n2 && $n2 != 0 && $actuals !== null) ? (($actuals - $n2) / $n2) : null;
1892            $row->deviation_vs_budget = ($budget && $budget != 0 && $actuals !== null) ? (($actuals - $budget) / $budget) : null;
1893
1894            $row->save();
1895
1896            return response(['message' => 'OK', 'data' => $row]);
1897        } catch (\Exception $e) {
1898            /** @disregard P1014 */
1899            $e->exceptionCode = 'UPSERT_RESUMEN_CELL_EXCEPTION';
1900            report($e);
1901
1902            return response(['message' => 'KO', 'error' => $e->getMessage()]);
1903        }
1904    }
1905
1906    /**
1907     * POST /finance/report-semanal/upsert-cell
1908     *
1909     * FIRE-1027: per-cell write for the weekly report. Same shape as
1910     * upsert_resumen_cell but for tbl_finance_report_semanal, which is
1911     * keyed by (company_id, sede_id, week_date).
1912     *
1913     * The Drive importer stores one row per week; this endpoint edits
1914     * whichever week the caller specifies. If `week_date` isn't supplied,
1915     * it falls back to the most recent week_date for that sede in the
1916     * given year/month — which matches what list_report_semanal renders.
1917     *
1918     * Body: { sede_id, year, month, week_date?, field, value }
1919     *   field ∈ {actuals, actuals_n1, budget, prevision}
1920     */
1921    public function upsert_report_semanal_cell(Request $request)
1922    {
1923        try {
1924            $data = $request->validate([
1925                'sede_id' => 'required|integer',
1926                'year' => 'required|integer',
1927                'month' => 'required|integer|between:1,12',
1928                'week_date' => 'nullable|date',
1929                'field' => 'required|in:actuals,actuals_n1,budget,prevision',
1930                'value' => 'nullable|numeric',
1931            ]);
1932
1933            // FIRE-1027: resolve company from the sede (not the Region
1934            // header) — same reason as upsert_resumen_cell.
1935            $sede = TblFinanceSedes::find($data['sede_id']);
1936            if (! $sede) {
1937                return response(['message' => 'KO', 'error' => 'sede_not_found', 'sede_id' => $data['sede_id']], 404);
1938            }
1939
1940            // FIRE-1027: same hard guard as upsert_resumen_cell.
1941            if ($this->isZenitalSede($data['sede_id'])) {
1942                return response(['message' => 'KO', 'error' => 'cannot_edit_zenital_row'], 403);
1943            }
1944
1945            // Resolve week_date if the caller didn't supply one. The list
1946            // view aggregates SUM(actuals) per (sede, year, month) and tags
1947            // the response with the latest week_date — match that.
1948            $weekDate = $data['week_date'] ?? null;
1949            if (! $weekDate) {
1950                $weekDate = TblFinanceReportSemanal::where([
1951                    'company_id' => $sede->company_id,
1952                    'sede_id' => $data['sede_id'],
1953                    'year' => $data['year'],
1954                    'month' => $data['month'],
1955                ])
1956                    ->orderByDesc('week_date')
1957                    ->value('week_date');
1958            }
1959            if (! $weekDate) {
1960                // No row for this sede/month yet — anchor to the first day
1961                // of the month so the upsert has a deterministic key.
1962                $weekDate = sprintf('%04d-%02d-01', $data['year'], $data['month']);
1963            }
1964
1965            $row = TblFinanceReportSemanal::firstOrNew([
1966                'company_id' => $sede->company_id,
1967                'sede_id' => $data['sede_id'],
1968                'week_date' => $weekDate,
1969            ]);
1970
1971            if ($row->exists && $row->source === 'zenital') {
1972                return response(['message' => 'KO', 'error' => 'cannot_edit_zenital_row'], 403);
1973            }
1974
1975            $row->year = $data['year'];
1976            $row->month = $data['month'];
1977            $row->{$data['field']} = $data['value'];
1978            $row->source = 'manual_override';
1979            $row->loaded_at = now();
1980
1981            $actuals = $row->actuals;
1982            $n1 = $row->actuals_n1;
1983            $budget = $row->budget;
1984            $prevision = $row->prevision;
1985            $row->deviation_vs_n1 = ($n1 !== null && $actuals !== null) ? ($actuals - $n1) : null;
1986            $row->deviation_pct_vs_n1 = ($n1 && $n1 != 0 && $actuals !== null) ? (($actuals - $n1) / $n1) : null;
1987            $row->deviation_vs_budget = ($budget !== null && $actuals !== null) ? ($actuals - $budget) : null;
1988            $row->deviation_pct_vs_budget = ($budget && $budget != 0 && $actuals !== null) ? (($actuals - $budget) / $budget) : null;
1989            $row->deviation_vs_prevision = ($prevision !== null && $actuals !== null) ? ($actuals - $prevision) : null;
1990            $row->deviation_pct_vs_prevision = ($prevision && $prevision != 0 && $actuals !== null) ? (($actuals - $prevision) / $prevision) : null;
1991
1992            $row->save();
1993
1994            return response(['message' => 'OK', 'data' => $row]);
1995        } catch (\Exception $e) {
1996            /** @disregard P1014 */
1997            $e->exceptionCode = 'UPSERT_REPORT_SEMANAL_CELL_EXCEPTION';
1998            report($e);
1999
2000            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2001        }
2002    }
2003
2004    // -------------------------------------------------------
2005    // DESTINATARIOS REPORTE SEMANAL
2006    // -------------------------------------------------------
2007
2008    public function list_recipients(Request $request)
2009    {
2010        try {
2011            $companyIds = $this->getCompanyIds($request);
2012            // "Todas las regiones" recipients are stored with company_id = NULL
2013            // and must appear in every region's listing — otherwise adding one
2014            // looks like the save failed silently (it actually saved but the
2015            // reload couldn't find it).
2016            $data = TblFinanceReportRecipients::where(function ($q) use ($companyIds) {
2017                $q->whereIn('company_id', $companyIds)
2018                    ->orWhereNull('company_id');
2019            })
2020                ->orderBy('name')
2021                ->get();
2022
2023            return response(['message' => 'OK', 'data' => $data]);
2024        } catch (\Exception $e) {
2025            /** @disregard P1014 */
2026            $e->exceptionCode = 'LIST_RECIPIENTS_EXCEPTION';
2027            report($e);
2028
2029            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2030        }
2031    }
2032
2033    public function create_recipient(Request $request)
2034    {
2035        try {
2036            $data = $request->all();
2037
2038            // company_id comes from the modal's region selector. 'all' = NULL (todas
2039            // las regiones). The old implementation forced the header region, which
2040            // broke when the user was viewing "Todas las regiones" or picked a
2041            // different region in the modal.
2042            $companyId = $data['company_id'] ?? null;
2043            if ($companyId === 'all' || $companyId === '') {
2044                $companyId = null;
2045            }
2046
2047            $recipient = TblFinanceReportRecipients::create([
2048                'company_id' => $companyId,
2049                'name' => $data['name'],
2050                'email' => $data['email'],
2051                'is_active' => $data['is_active'] ?? 1,
2052                'days_before_delete_errors' => $data['days_before_delete_errors'] ?? 7,
2053            ]);
2054
2055            return response(['message' => 'OK', 'data' => $recipient]);
2056        } catch (\Exception $e) {
2057            /** @disregard P1014 */
2058            $e->exceptionCode = 'CREATE_RECIPIENT_EXCEPTION';
2059            report($e);
2060
2061            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2062        }
2063    }
2064
2065    public function update_recipient(Request $request, $id)
2066    {
2067        try {
2068            $data = $request->all();
2069
2070            if (array_key_exists('company_id', $data)
2071                && ($data['company_id'] === 'all' || $data['company_id'] === '')) {
2072                $data['company_id'] = null;
2073            }
2074
2075            // Whitelist updatable fields so unrelated payload keys (e.g. zone)
2076            // don't leak into the update.
2077            $update = array_intersect_key($data, array_flip([
2078                'company_id', 'name', 'email', 'is_active', 'days_before_delete_errors',
2079            ]));
2080
2081            TblFinanceReportRecipients::where('id', $id)->update($update);
2082
2083            return response(['message' => 'OK']);
2084        } catch (\Exception $e) {
2085            /** @disregard P1014 */
2086            $e->exceptionCode = 'UPDATE_RECIPIENT_EXCEPTION';
2087            report($e);
2088
2089            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2090        }
2091    }
2092
2093    public function delete_recipient($id)
2094    {
2095        try {
2096            TblFinanceReportRecipients::where('id', $id)->delete();
2097
2098            return response(['message' => 'OK']);
2099        } catch (\Exception $e) {
2100            /** @disregard P1014 */
2101            $e->exceptionCode = 'DELETE_RECIPIENT_EXCEPTION';
2102            report($e);
2103
2104            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2105        }
2106    }
2107
2108    public function send_report(Request $request)
2109    {
2110        try {
2111            $params = [];
2112            if ($request->input('month')) {
2113                $params['--month'] = (int) $request->input('month');
2114            }
2115            if ($request->input('type')) {
2116                $params['--type'] = $request->input('type');
2117            }
2118            $exitCode = Artisan::call('finance:send-report', $params);
2119
2120            return response([
2121                'message' => 'OK',
2122                'output' => Artisan::output(),
2123                'exit_code' => $exitCode,
2124            ]);
2125        } catch (\Exception $e) {
2126            /** @disregard P1014 */
2127            $e->exceptionCode = 'SEND_FINANCE_REPORT_EXCEPTION';
2128            report($e);
2129
2130            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2131        }
2132    }
2133
2134    public function test_report(Request $request)
2135    {
2136        try {
2137            // FIRE-1148: Vista previa used to call Artisan::call('finance:send-report'
2138            // --test) and scrape Artisan::output() — that ran the FULL pipeline
2139            // (every company, every recipient) just to surface the aggregated HTML,
2140            // taking tens of seconds and racing on the per-worker stdout buffer.
2141            // Now we call the renderer service directly and return the HTML.
2142            //
2143            // Response shape preserved: the frontend regex-extracts
2144            // /<div[\s\S]*<\/div>/ from `output` (see finance.component.ts:1302),
2145            // so we keep the `output` key populated with the HTML string.
2146            $month = $request->query('month') ? (int) $request->query('month') : null;
2147            $type = (string) ($request->query('type') ?: 'weekly');
2148
2149            $html = $this->renderer->renderPreview($month, $type);
2150
2151            return response([
2152                'message' => 'OK',
2153                'output' => $html,
2154                'exit_code' => 0,
2155            ]);
2156        } catch (\Exception $e) {
2157            /** @disregard P1014 */
2158            $e->exceptionCode = 'TEST_FINANCE_REPORT_EXCEPTION';
2159            report($e);
2160
2161            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2162        }
2163    }
2164
2165    // -------------------------------------------------------
2166    // GOOGLE DRIVE IMPORT
2167    // -------------------------------------------------------
2168
2169    public function import_from_drive()
2170    {
2171        try {
2172            // The import takes ~3 minutes (Google Drive downloads + PhpSpreadsheet parsing).
2173            // Running synchronously through Cloudflare hits the 100s proxy timeout (504).
2174            // Fire-and-forget: spawn the artisan command as a detached process so the
2175            // HTTP request returns immediately. Progress lands in storage/logs/third-party/*.log.
2176            $cmd = sprintf(
2177                '%s %s finance:import-drive > /dev/null 2>&1 &',
2178                escapeshellarg(PHP_BINARY),
2179                escapeshellarg(base_path('artisan'))
2180            );
2181            exec($cmd);
2182
2183            return response([
2184                'message' => 'OK',
2185                'status' => 'started',
2186                'note' => 'Import running in background. Check storage/logs/third-party/ for completion.',
2187            ]);
2188        } catch (\Exception $e) {
2189            $e->exceptionCode = 'IMPORT_FINANCE_DRIVE_EXCEPTION';
2190            report($e);
2191
2192            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2193        }
2194    }
2195
2196    // -------------------------------------------------------
2197    // SEDES CRUD
2198    // -------------------------------------------------------
2199
2200    public function create_sede(Request $request)
2201    {
2202        try {
2203            $data = $request->all();
2204
2205            $sede = TblFinanceSedes::create([
2206                'name' => $data['name'],
2207                'company_id' => $data['company_id'],
2208                'region_id' => $data['region_id'],
2209                'code' => $data['code'] ?? null,
2210                'is_active' => 1,
2211            ]);
2212
2213            return response(['message' => 'OK', 'data' => $sede]);
2214        } catch (\Exception $e) {
2215            /** @disregard P1014 */
2216            $e->exceptionCode = 'CREATE_SEDE_EXCEPTION';
2217            report($e);
2218
2219            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2220        }
2221    }
2222
2223    public function delete_sede(Request $request, $id)
2224    {
2225        try {
2226            $sede = TblFinanceSedes::findOrFail($id);
2227            $sede->update(['is_active' => 0]);
2228
2229            return response(['message' => 'OK']);
2230        } catch (\Exception $e) {
2231            /** @disregard P1014 */
2232            $e->exceptionCode = 'DELETE_SEDE_EXCEPTION';
2233            report($e);
2234
2235            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2236        }
2237    }
2238
2239    // -------------------------------------------------------
2240    // Month Config (billing close dates)
2241    // -------------------------------------------------------
2242
2243    /**
2244     * GET /finance-month-config?year=2026
2245     * Returns all month config rows for the given year.
2246     */
2247    public function list_month_config(Request $request)
2248    {
2249        try {
2250            $year = (int) $request->query('year', date('Y'));
2251
2252            $data = TblFinanceMonthConfig::where('year', $year)
2253                ->orderBy('month')
2254                ->get();
2255
2256            return response(['message' => 'OK', 'data' => $data]);
2257        } catch (\Exception $e) {
2258            /** @disregard P1014 */
2259            $e->exceptionCode = 'LIST_MONTH_CONFIG_EXCEPTION';
2260            report($e);
2261
2262            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2263        }
2264    }
2265
2266    /**
2267     * PUT /finance-month-config/{id}
2268     * Updates close_date and/or force_report_month for a row.
2269     */
2270    public function update_month_config(Request $request, $id)
2271    {
2272        try {
2273            $config = TblFinanceMonthConfig::findOrFail($id);
2274
2275            $config->update($request->only(['close_date', 'force_report_month']));
2276
2277            return response(['message' => 'OK', 'data' => $config->fresh()]);
2278        } catch (\Exception $e) {
2279            /** @disregard P1014 */
2280            $e->exceptionCode = 'UPDATE_MONTH_CONFIG_EXCEPTION';
2281            report($e);
2282
2283            return response(['message' => 'KO', 'error' => $e->getMessage()]);
2284        }
2285    }
2286}