Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZenitalCatalog
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 5
240
0.00% covered (danger)
0.00%
0 / 1
 sucursalesMap
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 regionMap
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 forget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadSucursalesMap
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 loadRegionMap
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace App\Services;
4
5use App\Models\TblFinanceRegions;
6use App\Models\TblFinanceSedes;
7use Illuminate\Support\Facades\DB;
8use Illuminate\Support\Facades\Log;
9
10/**
11 * Single source of truth for Zenital DWH catalog lookups
12 * (dim_sucursal, dim_region) needed by Finance read/write paths.
13 *
14 * FIRE-1148: replaces two pairs of duplicate private methods that lived in
15 * FinanceController and SendFinanceReport — same query, same alias map, same
16 * SCD-2 dedup. Caches via ResultCache in the 'finance' domain so each cold
17 * request used to pay 200–500 ms over the WAN to dim_sucursal + dim_region;
18 * after this it's a Redis hit (< 1 ms) for the TTL window.
19 *
20 * Callers that mutate tbl_finance_sedes / tbl_finance_regions must call
21 * `forget()` (or `ResultCache::forgetDomain('finance')`) so the new entries
22 * are visible immediately — see FinanceController::ensureSedesExist.
23 */
24final class ZenitalCatalog
25{
26    /** Cache TTL in seconds — 1h matches FIRE-1148's stated TTL. */
27    private const TTL = 3600;
28
29    private const DOMAIN = 'finance';
30
31    private const KEY_SUCURSALES = 'finance.zenital.sucursales_map';
32
33    private const KEY_REGIONS = 'finance.zenital.region_map';
34
35    /**
36     * Runtime sk_sucursal → local sede metadata.
37     *
38     * @return array<int, array{nombre: string, company_id: int, sede_id: int}>
39     */
40    public function sucursalesMap(): array
41    {
42        return ResultCache::rememberKey(self::DOMAIN, self::KEY_SUCURSALES, self::TTL, fn (): array => $this->loadSucursalesMap());
43    }
44
45    /**
46     * Runtime sk_region (Zenital) → region_id (Titan) lookup.
47     *
48     * @return array<int, int>
49     */
50    public function regionMap(): array
51    {
52        return ResultCache::rememberKey(self::DOMAIN, self::KEY_REGIONS, self::TTL, fn (): array => $this->loadRegionMap());
53    }
54
55    /**
56     * Invalidate both maps. Call after inserting/updating tbl_finance_sedes
57     * or tbl_finance_regions rows so the new entries surface immediately.
58     */
59    public function forget(): void
60    {
61        ResultCache::forgetDomain(self::DOMAIN);
62    }
63
64    private function loadSucursalesMap(): array
65    {
66        $localByName = TblFinanceSedes::where('is_active', 1)
67            ->whereNotNull('company_id')
68            ->get()
69            ->keyBy(fn ($s) => mb_strtolower(trim((string) $s->name)));
70
71        if ($localByName->isEmpty()) {
72            return [];
73        }
74
75        // Zenital → Titan name aliases. Añadir aquí cuando el DWH use una
76        // etiqueta distinta de la de tbl_finance_sedes.
77        $aliases = [
78            'jomar alcarreña' => 'alcarrena',
79        ];
80
81        try {
82            putenv('PGCONNECT_TIMEOUT='.env('ZENITAL_DB_CONNECT_TIMEOUT', 5));
83            DB::connection('zenital')->statement(
84                'SET statement_timeout = '.(int) env('ZENITAL_DB_STATEMENT_TIMEOUT_MS', 15000)
85            );
86            // cod_sucursal is the business key (sk_sucursal is the SCD-2
87            // surrogate). When the DWH carries multiple versions of the
88            // same sucursal, every version shares the same cod_sucursal;
89            // we need to know which version we already kept. Ordering DESC
90            // ensures the latest sk_sucursal wins per cod.
91            $rows = DB::connection('zenital')
92                ->table('dim_sucursal')
93                ->select('sk_sucursal', 'cod_sucursal', 'sucursal')
94                ->where('sk_sucursal', '>', 0)
95                ->orderByDesc('sk_sucursal')
96                ->get();
97        } catch (\Throwable $e) {
98            Log::channel('third-party')->warning('ZenitalCatalog::sucursalesMap — lookup failed: '.$e->getMessage());
99
100            return [];
101        }
102
103        // SCD-2 dedup: when dim_sucursal has multiple rows with the same
104        // cod_sucursal (different sk_sucursal versions of the same business,
105        // e.g. STG had cod=14 with sk=149 "Alcarrena" + sk=150 "Jomar
106        // Alcarreña" both pointing at the same invoices), keep only one sk
107        // per cod. Pre-fix, both versions got name-mapped to the same local
108        // sede and the Zenital actuals query summed BOTH versions of every
109        // invoice — La Mancha doubled from 2,7 M€ to 5,4 M€ on the YTD email.
110        $map = [];
111        $seenCods = [];
112        foreach ($rows as $r) {
113            $cod = (int) ($r->cod_sucursal ?? 0);
114            if ($cod > 0) {
115                if (isset($seenCods[$cod])) {
116                    continue;
117                }
118                $seenCods[$cod] = true;
119            }
120
121            $zenitalKey = mb_strtolower(trim((string) $r->sucursal));
122            $titanKey = $aliases[$zenitalKey] ?? $zenitalKey;
123            $sede = $localByName[$titanKey] ?? null;
124            if (! $sede) {
125                continue;
126            }
127            $map[(int) $r->sk_sucursal] = [
128                'nombre' => $sede->name,
129                'company_id' => (int) $sede->company_id,
130                'sede_id' => (int) $sede->id,
131            ];
132        }
133
134        return $map;
135    }
136
137    private function loadRegionMap(): array
138    {
139        $titanRegions = TblFinanceRegions::where('is_active', 1)
140            ->get()
141            ->keyBy(fn ($r) => mb_strtolower(trim((string) $r->name)));
142
143        if ($titanRegions->isEmpty()) {
144            return [];
145        }
146
147        // Zenital region name (lowercased) → Titan region name (lowercased).
148        // Sub-entities and accent-only diffs go here.
149        $aliases = [
150            'cataluña' => 'catalunya',
151            'oasys' => 'aragón',
152            'crespo' => 'castilla y león',
153            'togasa' => 'castilla y león',
154            'extinfuego' => 'valencia',
155            'almería' => 'andalucía',
156        ];
157
158        try {
159            putenv('PGCONNECT_TIMEOUT='.env('ZENITAL_DB_CONNECT_TIMEOUT', 5));
160            DB::connection('zenital')->statement(
161                'SET statement_timeout = '.(int) env('ZENITAL_DB_STATEMENT_TIMEOUT_MS', 15000)
162            );
163            $rows = DB::connection('zenital')
164                ->table('dim_region')
165                ->select('sk_region', 'region')
166                ->where('sk_region', '>', 0)
167                ->get();
168        } catch (\Throwable $e) {
169            Log::channel('third-party')->warning('ZenitalCatalog::regionMap — lookup failed: '.$e->getMessage());
170
171            return [];
172        }
173
174        $map = [];
175        foreach ($rows as $r) {
176            $key = mb_strtolower(trim((string) $r->region));
177            $titanKey = $aliases[$key] ?? $key;
178            if (! isset($titanRegions[$titanKey])) {
179                continue;
180            }
181            $map[(int) $r->sk_region] = (int) $titanRegions[$titanKey]->id;
182        }
183
184        return $map;
185    }
186}