Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 450
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
IncentivesController
0.00% covered (danger)
0.00%
0 / 450
0.00% covered (danger)
0.00%
0 / 14
5700
0.00% covered (danger)
0.00%
0 / 1
 __construct
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
 list_criteria
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 1
306
 list_datos
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
42
 list_parameters
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 update_parameter
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
272
 list_parameters_log
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 callerIsAdmin
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 quarterRange
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 collectNewClientBonusesByCommercial
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 collectCaptacionBonusesByCommercial
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
6
 collectInstalacionCommissionsByCommercial
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 list_margins
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 upsert_margin
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\TblCompanies;
6use App\Models\TblCompanyUsers;
7use App\Models\TblIncentiveInstalacionMargins;
8use App\Models\TblIncentivePlanParameters;
9use App\Models\TblIncentivePlanParametersLog;
10use App\Services\IncentivePlanParameters;
11use Illuminate\Http\Request;
12use Illuminate\Support\Facades\App;
13use Illuminate\Support\Facades\DB;
14
15class IncentivesController extends Controller
16{
17    /**
18     * Budget type name mappings from spec vocabulary to DB vocabulary.
19     * Pending Berta's confirmation (see investigation Finding 2):
20     *   - Does PREVENTIVO include "Mantenimiento pasivo" (id 14)?
21     *   - Is RENOVACION a new budget type or subset of Mantenimiento + Recurrente?
22     */
23    private const TIPO_PREVENTIVO = ['Mantenimiento', 'Mantenimiento pasivo'];
24
25    private const TIPO_CORRECTIVO = ['Correctivos'];
26
27    private const TIPO_INSTALACION = ['Instalaciones'];
28
29    // FIRE-958 seeded "Mantenimiento Renovación" into tbl_budget_types —
30    // finally closes FIRE-953 Finding 2. Any accepted quotation tagged
31    // with this name now shows up in facturación / comisión renovación.
32    private const TIPO_RENOVACION = ['Mantenimiento Renovación'];
33
34    /**
35     * Customer types considered "Carterizado" for renovación.
36     * Pending confirmation — current mapping is "Recurrente" family.
37     */
38    private const CLIENTE_CARTERIZADO = ['Recurrente', 'Recurrente - Facility'];
39
40    /**
41     * Commercials enrolled in the incentive plan (FIRE-955 spec).
42     * The spec preamble says "8 comerciales" but the table enumerates 11 —
43     * we include all 11 until Berta clarifies; trimming to 8 is a one-line
44     * edit if positions 9–11 (Carrasco / Llopart / Calaf) turn out to be a
45     * future cohort.
46     *
47     * Will migrate to a config table (tbl_incentive_commercial_rosters)
48     * once the FIRE-954 sibling "Parámetros configurables del plan" lands.
49     */
50    private const COMERCIALES_ACTIVOS = [
51        'Xavier Centella',
52        'Jose Solanas',
53        'Hernan Vignogna',
54        'Joan Centella',
55        'Eugenio Romo',
56        'Jose Valle',
57        'Adolfo Fernandez Beut',
58        'Javier Grau',
59        'Jose Carrasco',
60        'Joan Llopart',
61        'Jaume Calaf',
62    ];
63
64    private IncentivePlanParameters $params;
65
66    public function __construct()
67    {
68        $this->params = new IncentivePlanParameters;
69        App::setLocale(request()->header('Locale-Id'));
70    }
71
72    private function getCompanyIds(Request $request): array
73    {
74        $region = urldecode($request->header('Region'));
75        $userId = intval($request->header('User-ID'));
76
77        if ($region === 'All' || empty($region)) {
78            return TblCompanyUsers::where('user_id', $userId)
79                ->pluck('company_id')
80                ->toArray();
81        }
82
83        $company = TblCompanies::where('region', $region)->first();
84
85        return $company ? [$company->company_id] : [];
86    }
87
88    /**
89     * GET /api/incentives-criteria?year=YYYY&quarter=Q1|Q2|Q3|Q4|annual
90     *
91     * Returns one row per (commercial, quarter) with the 15 columns of
92     * Tabla 1 — Tabla de criterios (FIRE-953).
93     *
94     * The K2 / K3 columns (long-term preventivo bonuses) are stubbed at 0
95     * until contract_duration_months exists on tbl_quotations (see
96     * investigation Finding 4, blocked on the Datos sibling ticket).
97     */
98    public function list_criteria(Request $request)
99    {
100        try {
101            $companyIds = $this->getCompanyIds($request);
102            if (empty($companyIds)) {
103                return response(['message' => 'OK', 'data' => []]);
104            }
105
106            $year = (int) $request->query('year', date('Y'));
107            $quarter = strtoupper((string) $request->query('quarter', 'ANNUAL'));
108
109            [$startDate, $endDate] = $this->quarterRange($year, $quarter);
110
111            // Fetch accepted quotations in the window, grouped by commercial + quarter.
112            //
113            // COALESCE prefers `acceptance_date` (the BUSINESS date — when the
114            // customer actually accepted) over `accepted_at` (a system
115            // timestamp set when the row's status flips to "Aceptado" in
116            // Titan). Older code had the order reversed, which caused
117            // mass-updated rows to slide into the wrong quarter — e.g.
118            // Adolfo's 3 instalaciones from January 2026 (Q1) were all
119            // marked accepted in Titan on 2026-04-24 (Q2) and incorrectly
120            // attributed 9.044,92 € to his Q2 instalaciones.
121            //
122            // `acceptance_date` is preferred unconditionally; `accepted_at`
123            // is only the fallback for historical rows where the business
124            // date was never captured (investigation Finding 5).
125            //
126            // Per FIRE-953 spec: "Trimestre derived from Fecha de aceptación".
127            // Read these here because the main SQL uses them as bindings
128            // for the L.P. CASE expressions below.
129            $capInstalacion = $this->params->get('cap_instalacion_individual');
130            $bonusLargo2Sql = $this->params->get('bonus_largo_plazo_2_anos');
131            $bonusLargo3Sql = $this->params->get('bonus_largo_plazo_3_anos');
132            $duracionMin2Sql = (int) $this->params->get('duracion_min_largo_plazo_2_anos');
133            $duracionMin3Sql = (int) $this->params->get('duracion_min_largo_plazo_3_anos');
134
135            $rows = DB::table('tbl_quotations as q')
136                ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
137                ->leftJoin('tbl_customer_types as ct', 'ct.customer_type_id', '=', 'q.customer_type_id')
138                ->where('q.budget_status_id', 3)
139                ->whereIn('q.company_id', $companyIds)
140                ->whereNotNull('q.commercial')
141                ->where('q.commercial', '!=', '')
142                ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
143                ->whereRaw('COALESCE(q.acceptance_date, q.accepted_at) BETWEEN ? AND ?', [$startDate, $endDate])
144                ->selectRaw(<<<'SQL'
145                    q.commercial                                                                           AS comercial,
146                    YEAR(COALESCE(q.acceptance_date, q.accepted_at))                                       AS anio,
147                    QUARTER(COALESCE(q.acceptance_date, q.accepted_at))                                    AS trimestre,
148                    SUM(CASE WHEN bt.name IN (?, ?)                                       THEN 1 ELSE 0 END) AS n_prev,
149                    SUM(CASE WHEN bt.name IN (?, ?)                                       THEN q.amount + 0 ELSE 0 END) AS fact_prev,
150                    SUM(CASE WHEN bt.name IN (?)                                          THEN q.amount + 0 ELSE 0 END) AS fact_corr,
151                    SUM(CASE WHEN bt.name IN (?) THEN LEAST(q.amount + 0, ?) ELSE 0 END)                                  AS fact_inst,
152                    SUM(CASE WHEN ct.name IN (?, ?) THEN q.amount + 0 ELSE 0 END)                                         AS fact_renov,
153                    -- FIRE-956 Phase 2: count L.P. preventivos by duration band.
154                    -- tbl_quotations.duration is stored in YEARS (max observed 20)
155                    -- while the duracion_min_largo_plazo_* parameters are in
156                    -- MONTHS (24, 36). Multiply by 12 to compare in the same unit.
157                    SUM(CASE
158                        WHEN bt.name IN (?, ?) AND CAST(NULLIF(TRIM(q.duration), '') AS UNSIGNED) * 12 >= ? THEN 1
159                        ELSE 0
160                    END) AS n_prev_lp_3,
161                    SUM(CASE
162                        WHEN bt.name IN (?, ?)
163                          AND CAST(NULLIF(TRIM(q.duration), '') AS UNSIGNED) * 12 >= ?
164                          AND CAST(NULLIF(TRIM(q.duration), '') AS UNSIGNED) * 12 <  ?
165                        THEN 1
166                        ELSE 0
167                    END) AS n_prev_lp_2,
168                    -- L.P. bonus € — additive over the base 5,5 % preventivo
169                    -- commission. Same row pays the 3-year tier when duration
170                    -- crosses both thresholds (24 ≤ d_months < 36 → 2 %, d_months ≥ 36 → 3 %).
171                    SUM(CASE
172                        WHEN bt.name IN (?, ?) AND CAST(NULLIF(TRIM(q.duration), '') AS UNSIGNED) * 12 >= ? THEN (q.amount + 0) * ?
173                        WHEN bt.name IN (?, ?) AND CAST(NULLIF(TRIM(q.duration), '') AS UNSIGNED) * 12 >= ? THEN (q.amount + 0) * ?
174                        ELSE 0
175                    END) AS bonus_lp
176                SQL, [
177                    ...self::TIPO_PREVENTIVO, // count preventivo
178                    ...self::TIPO_PREVENTIVO, // sum preventivo
179                    ...self::TIPO_CORRECTIVO,
180                    ...self::TIPO_INSTALACION, $capInstalacion,
181                    ...self::CLIENTE_CARTERIZADO,
182                    // n_prev_lp_3
183                    ...self::TIPO_PREVENTIVO, $duracionMin3Sql,
184                    // n_prev_lp_2
185                    ...self::TIPO_PREVENTIVO, $duracionMin2Sql, $duracionMin3Sql,
186                    // bonus_lp — 3-year branch first (higher threshold wins)
187                    ...self::TIPO_PREVENTIVO, $duracionMin3Sql, $bonusLargo3Sql,
188                    ...self::TIPO_PREVENTIVO, $duracionMin2Sql, $bonusLargo2Sql,
189                ])
190                ->groupBy('q.commercial')
191                ->groupByRaw('YEAR(COALESCE(q.acceptance_date, q.accepted_at))')
192                ->groupByRaw('QUARTER(COALESCE(q.acceptance_date, q.accepted_at))')
193                ->orderBy('q.commercial')
194                ->get();
195
196            // Read every plan parameter once per request — the service
197            // memoizes internally so this is a single SELECT.
198            $objetivoPrev = $this->params->get('objetivo_preventivo_trimestre');
199            $objetivoCorr = $this->params->get('objetivo_correctivo_trimestre');
200            $umbralActivacion = $this->params->get('umbral_activacion');
201            $minPreventivos = (int) $this->params->get('min_preventivos_desbloqueo');
202            $comisionPrev = $this->params->get('comision_preventivo');
203            $comisionCorr = $this->params->get('comision_correctivo');
204            $comisionRenov = $this->params->get('comision_renovacion');
205
206            // FIRE-956 Phase 2/3 parameters (newly consumed).
207            $bonusLargo2 = $this->params->get('bonus_largo_plazo_2_anos');
208            $bonusLargo3 = $this->params->get('bonus_largo_plazo_3_anos');
209            $duracionMin2 = (int) $this->params->get('duracion_min_largo_plazo_2_anos');
210            $duracionMin3 = (int) $this->params->get('duracion_min_largo_plazo_3_anos');
211            $bonusCaptBajo = $this->params->get('bonus_captacion_tramo_bajo');
212            $bonusCaptAlto = $this->params->get('bonus_captacion_tramo_alto');
213            // Tramos for Phase 3 — pre-fetched so the row-map can look up by tier.
214            $tramoA = $this->params->get('comision_instalacion_tramo_a');
215            $tramoB = $this->params->get('comision_instalacion_tramo_b');
216            $tramoC = $this->params->get('comision_instalacion_tramo_c');
217            $tramoD = $this->params->get('comision_instalacion_tramo_d');
218
219            // FIRE-1032 Bug 2 Tier A — nuevo-cliente bonus (80 € / 150 €).
220            // Diarte's review on FIRE-1032 noted Adolfo could be owed this on
221            // top of the 5,5 % preventivo commission when the preventivo is
222            // for a brand-new client. SOP v13 defines "nuevo" as "sin
223            // contrato de preventivo durante los últimos 13 meses".
224            //
225            // Tier A (this PR): match by lowercased email — `client_id` is
226            // ~100 % NULL on accepted rows so it's not usable yet. Annualisation
227            // is also skipped here — `tbl_quotations.duration` is unreliable;
228            // we compare the raw amount against the 3 k / 5 k tramo bands. For
229            // mantenimiento contracts (overwhelmingly annual) this matches the
230            // SOP. Tier B (FIRE-958 sibling) will flip to `client_id` +
231            // annualised amount once those land.
232            //
233            // FIRE-956 Phase 2 follow-up: nuevo-cliente bonus now ONLY pays for
234            // short-term contracts (duration < duracion_min_largo_plazo_2_anos,
235            // i.e. < 24 months). Long-term new clients go through the captación
236            // bonus instead (collectCaptacionBonusesByCommercial below). The
237            // two are mutually exclusive — never additive on the same row.
238            $bonusByKey = $this->collectNewClientBonusesByCommercial(
239                $companyIds,
240                $startDate,
241                $endDate,
242                $duracionMin2,
243            );
244
245            // FIRE-956 Phase 2 — captación bonus (350 € / 600 €) for new
246            // long-term preventivo contracts (duration ≥ 24 months). Same
247            // 13-month no-prior-preventivo rule as nuevo-cliente but with
248            // higher tramos and different amount bands (3-10k vs >10k).
249            $captacionByKey = $this->collectCaptacionBonusesByCommercial(
250                $companyIds,
251                $startDate,
252                $endDate,
253                $duracionMin2,
254                $bonusCaptBajo,
255                $bonusCaptAlto,
256            );
257
258            // FIRE-956 Phase 3 — installation commission. Joins
259            // tbl_incentive_instalacion_margins per quotation; only rows
260            // with an M.C. entry contribute. Pending installations show
261            // nothing yet (partial pay-out — Finanzas can fill margins
262            // incrementally and the comercial is paid as they land).
263            $instalacionByKey = $this->collectInstalacionCommissionsByCommercial(
264                $companyIds,
265                $startDate,
266                $endDate,
267                $capInstalacion,
268                $tramoA,
269                $tramoB,
270                $tramoC,
271                $tramoD,
272            );
273
274            $data = $rows->map(function ($r) use (
275                $objetivoPrev, $objetivoCorr, $umbralActivacion, $minPreventivos,
276                $comisionPrev, $comisionCorr, $comisionRenov,
277                $bonusByKey, $captacionByKey, $instalacionByKey
278            ) {
279                $nPrev = (int) $r->n_prev;
280                $factPrev = (float) $r->fact_prev;
281                $factCorr = (float) $r->fact_corr;
282                $factInst = (float) $r->fact_inst;
283                $factRenov = (float) $r->fact_renov;
284
285                $pctPrev = $objetivoPrev > 0 ? $factPrev / $objetivoPrev : 0;
286                $pctCorr = $objetivoCorr > 0 ? $factCorr / $objetivoCorr : 0;
287
288                $condPrev = $pctPrev >= $umbralActivacion;
289                $condCorr = $pctCorr >= $umbralActivacion && $nPrev >= $minPreventivos;
290                $condInst = $nPrev >= $minPreventivos;
291
292                // Tabla 2 — FIRE-956 commission columns.
293                // D2, F stay null until the Datos sibling ticket lands
294                // (contract_duration_months + helper columns AN / AR).
295                // H lights up automatically once RENOVACION vocabulary is set.
296                // I, J are Phase 3 (manual M.C. input + tier lookup).
297                // D (this PR) — FIRE-1032 Bug 1: pay preventivo commission
298                // when the activation threshold (60 % of 36 k €) is met. Pre-fix
299                // this returned null and Adolfo's quarterly 2 002 € was held
300                // back as "Pendiente" in the UI.
301                $comisionPrevStd = $condPrev ? round($factPrev * $comisionPrev, 2) : 0.0;
302                $comisionCorrectivo = $condCorr ? round($factCorr * $comisionCorr, 2) : 0.0;
303                $comisionRenovacion = round($factRenov * $comisionRenov, 2);
304
305                $bonusKey = $r->comercial.'|'.((int) $r->anio).'|'.((int) $r->trimestre);
306                $bonusNuevoCliente = isset($bonusByKey[$bonusKey])
307                    ? round((float) $bonusByKey[$bonusKey], 2)
308                    : 0.0;
309
310                // FIRE-956 Phase 2A — bonus L.P. comes pre-summed from SQL.
311                // Gated on $condPrev (same activation as the base preventivo
312                // commission) so it doesn't pay out when the trimester
313                // hasn't crossed the threshold.
314                $bonusLargoPlazo = $condPrev ? round((float) $r->bonus_lp, 2) : 0.0;
315
316                // FIRE-956 Phase 2B — captación bonus from the lookup helper.
317                $bonusCaptacion = isset($captacionByKey[$bonusKey])
318                    ? round((float) $captacionByKey[$bonusKey], 2)
319                    : 0.0;
320
321                // FIRE-956 Phase 3 — installation commission. Null when no
322                // margins have been entered yet for any installation in
323                // this trimester (Pendiente in UI). 0.0 is a valid value
324                // and means margins were entered but all fell in the lowest
325                // tramo with 0 commission — distinguish from Pendiente.
326                $instData = $instalacionByKey[$bonusKey] ?? null;
327                $comisionInstalacion = $instData !== null && $condInst
328                    ? round((float) $instData['comision'], 2)
329                    : null;
330                // Margin % shown in the UI is the weighted average of
331                // entered margins (sum(amount * margin) / sum(amount)).
332                // Null until at least one row in this trimester has a
333                // margin entered.
334                $marginInstalacionPct = $instData !== null
335                    ? round((float) $instData['weighted_margin_pct'], 4)
336                    : null;
337
338                $totalTrimestre = $comisionPrevStd
339                    + $bonusLargoPlazo
340                    + $comisionCorrectivo
341                    + $bonusCaptacion
342                    + $bonusNuevoCliente
343                    + $comisionRenovacion
344                    + ($comisionInstalacion ?? 0.0);
345
346                return [
347                    'comercial' => $r->comercial,
348                    'anio' => (int) $r->anio,
349                    'trimestre' => 'Q'.((int) $r->trimestre),
350                    'n_contratos_preventivos' => $nPrev,
351                    'facturacion_preventivo' => round($factPrev, 2),
352                    'facturacion_correctivo' => round($factCorr, 2),
353                    'facturacion_renovacion' => round($factRenov, 2),
354                    'facturacion_instalacion' => round($factInst, 2),
355                    'objetivo_preventivo' => $objetivoPrev,
356                    'objetivo_correctivo' => $objetivoCorr,
357                    'pct_cumplimiento_preventivo' => round($pctPrev, 4),
358                    'pct_cumplimiento_correctivo' => round($pctCorr, 4),
359                    // FIRE-956 Phase 2 — counts now populated from SQL
360                    // CASE expressions over CAST(duration AS UNSIGNED).
361                    'n_prev_largo_plazo_2_anos' => (int) $r->n_prev_lp_2,
362                    'n_prev_largo_plazo_3_anos' => (int) $r->n_prev_lp_3,
363                    'condicion_preventivo_activa' => $condPrev ? 'SÍ' : 'NO',
364                    'condicion_correctivo_activa' => $condCorr ? 'SÍ' : 'NO',
365                    'condicion_instalacion_activa' => $condInst ? 'SÍ' : 'NO',
366                    // Tabla 2 columns (FIRE-956)
367                    'comision_preventivo_std' => $comisionPrevStd,
368                    'bonus_preventivo_largo_plazo' => $bonusLargoPlazo,
369                    'comision_correctivo' => $comisionCorrectivo,
370                    'bonus_captacion' => $bonusCaptacion,
371                    'bonus_nuevo_cliente' => $bonusNuevoCliente,
372                    'comision_renovacion' => $comisionRenovacion,
373                    'margin_instalacion_pct' => $marginInstalacionPct,
374                    'comision_instalacion' => $comisionInstalacion,
375                    'total_trimestre' => round($totalTrimestre, 2),
376                ];
377            })->values();
378
379            return response(['message' => 'OK', 'data' => $data]);
380        } catch (\Exception $e) {
381            /** @disregard P1014 */
382            $e->exceptionCode = 'LIST_INCENTIVES_CRITERIA_EXCEPTION';
383            report($e);
384
385            return response(['message' => 'KO', 'error' => $e->getMessage()]);
386        }
387    }
388
389    /**
390     * GET /api/incentives-datos?year=YYYY&quarter=Q1..Q4|annual
391     *
392     * FIRE-958: per-quotation view of the Calculadora de Eficiencia
393     * Comercial dataset — what the spec calls "tabla Datos". Returns
394     * one row per accepted quotation in the window with the seven
395     * derived columns AK/AL/AM/AN/AO/AP/AQ inline.
396     *
397     * Phase 1 scope (today):
398     *   ✓ AK Aplica              — CASE WHEN per row
399     *   ✓ AL Mes                 — PHP-side Spanish locale format
400     *   ✓ AM Tipo de trabajo     — CASE WHEN mapping to the 5 buckets
401     *   ✓ AP Cap inst. 100K      — LEAST(amount, cap) per row
402     * Phase 2/3 stubs (return null):
403     *   ✗ AN Helper captación    — blocked on contract duration + client identity + lookback window
404     *   ✗ AO Helper instalación  — blocked on tbl_incentive_instalacion_margins (FIRE-956 Phase 3)
405     *   ✗ AQ Helper nuevo        — blocked on contract duration (annualization)
406     */
407    public function list_datos(Request $request)
408    {
409        try {
410            $companyIds = $this->getCompanyIds($request);
411            if (empty($companyIds)) {
412                return response(['message' => 'OK', 'data' => []]);
413            }
414
415            $year = (int) $request->query('year', date('Y'));
416            $quarter = strtoupper((string) $request->query('quarter', 'ANNUAL'));
417            [$startDate, $endDate] = $this->quarterRange($year, $quarter);
418            $cap = $this->params->get('cap_instalacion_individual');
419
420            $applica = array_merge(
421                self::TIPO_PREVENTIVO,
422                self::TIPO_CORRECTIVO,
423                self::TIPO_INSTALACION,
424                self::TIPO_RENOVACION,
425            );
426            $placeholders = implode(',', array_fill(0, count($applica), '?'));
427
428            $prevPlaceholders = implode(',', array_fill(0, count(self::TIPO_PREVENTIVO), '?'));
429            $corrPlaceholders = implode(',', array_fill(0, count(self::TIPO_CORRECTIVO), '?'));
430            $instPlaceholders = implode(',', array_fill(0, count(self::TIPO_INSTALACION), '?'));
431            $renovPlaceholders = implode(',', array_fill(0, count(self::TIPO_RENOVACION), '?'));
432
433            $rows = DB::table('tbl_quotations as q')
434                ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
435                ->leftJoin('tbl_customer_types as ct', 'ct.customer_type_id', '=', 'q.customer_type_id')
436                ->where('q.budget_status_id', 3)
437                ->whereIn('q.company_id', $companyIds)
438                ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
439                ->whereRaw('COALESCE(q.acceptance_date, q.accepted_at) BETWEEN ? AND ?', [$startDate, $endDate])
440                ->selectRaw(<<<SQL
441                    q.id,
442                    q.quote_id,
443                    q.commercial,
444                    CAST(q.amount AS DECIMAL(14,2))                      AS importe,
445                    bt.name                                              AS tipo_presupuesto,
446                    ct.name                                              AS tipo_cliente,
447                    COALESCE(q.acceptance_date, q.accepted_at)           AS fecha_aceptacion,
448                    CASE WHEN bt.name IN ({$placeholders}) THEN 1 ELSE 0 END AS aplica,
449                    CASE
450                        WHEN bt.name IN ({$prevPlaceholders})  THEN 'PREVENTIVO'
451                        WHEN bt.name IN ({$corrPlaceholders})  THEN 'CORRECTIVO'
452                        WHEN bt.name IN ({$instPlaceholders})  THEN 'INSTALACION'
453                        WHEN bt.name IN ({$renovPlaceholders}) THEN 'RENOVACION'
454                        ELSE 'OTRO'
455                    END                                                   AS tipo_trabajo,
456                    LEAST(q.amount + 0, ?)                                AS cap_inst_100k
457                SQL, [
458                    ...$applica,
459                    ...self::TIPO_PREVENTIVO,
460                    ...self::TIPO_CORRECTIVO,
461                    ...self::TIPO_INSTALACION,
462                    ...self::TIPO_RENOVACION,
463                    $cap,
464                ])
465                ->orderByDesc('fecha_aceptacion')
466                ->get();
467
468            // AL Mes — Spanish "feb-2025" format. IntlDateFormatter keeps
469            // us away from the deprecated strftime and independent of
470            // MySQL's lc_time_names server setting.
471            $fmt = new \IntlDateFormatter('es_ES', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, null, null, 'MMM-yyyy');
472
473            $data = $rows->map(function ($r) use ($fmt) {
474                $mes = '-';
475                if ((int) $r->aplica === 1 && $r->fecha_aceptacion) {
476                    $ts = strtotime((string) $r->fecha_aceptacion);
477                    if ($ts !== false) {
478                        // IntlDateFormatter returns "feb." with a trailing
479                        // dot in Spanish locale — strip it and lowercase.
480                        $mes = strtolower(rtrim($fmt->format($ts), '.'));
481                    }
482                }
483
484                return [
485                    'id' => $r->id,
486                    'quote_id' => $r->quote_id,
487                    'comercial' => $r->commercial,
488                    'importe' => (float) $r->importe,
489                    'tipo_presupuesto' => $r->tipo_presupuesto,
490                    'tipo_cliente' => $r->tipo_cliente,
491                    'fecha_aceptacion' => $r->fecha_aceptacion,
492                    'aplica' => (int) $r->aplica,
493                    'mes' => $mes,
494                    'tipo_trabajo' => $r->tipo_trabajo,
495                    'cap_inst_100k' => (float) $r->cap_inst_100k,
496                    // Phase 2/3 stubs — see method docblock for blockers.
497                    'helper_captacion' => null,
498                    'helper_instalacion' => null,
499                    'helper_nuevo' => null,
500                ];
501            })->values();
502
503            return response(['message' => 'OK', 'data' => $data]);
504        } catch (\Exception $e) {
505            /** @disregard P1014 */
506            $e->exceptionCode = 'LIST_INCENTIVES_DATOS_EXCEPTION';
507            report($e);
508
509            return response(['message' => 'KO', 'error' => $e->getMessage()]);
510        }
511    }
512
513    /**
514     * GET /api/incentives-parameters — full plan-parameter catalogue for
515     * the admin panel. Reads every row; no scoping.
516     */
517    public function list_parameters(Request $request)
518    {
519        try {
520            $data = TblIncentivePlanParameters::orderBy('id')->get();
521
522            // Move "Comisión Instalación tramo D" so it sits right after
523            // tramo C in the UI instead of at the end. Tramo D was added
524            // later (FIRE-1032 follow-up) so its natural id puts it at the
525            // bottom; visually we want the four tramos grouped A→B→C→D.
526            $list = $data->all();
527            $cIdx = $dIdx = null;
528            foreach ($list as $i => $row) {
529                if ($row->param_key === 'comision_instalacion_tramo_c') { $cIdx = $i; }
530                if ($row->param_key === 'comision_instalacion_tramo_d') { $dIdx = $i; }
531            }
532            if ($cIdx !== null && $dIdx !== null && $dIdx > $cIdx) {
533                $dRow = $list[$dIdx];
534                array_splice($list, $dIdx, 1);
535                array_splice($list, $cIdx + 1, 0, [$dRow]);
536                $data = collect($list);
537            }
538
539            return response(['message' => 'OK', 'data' => $data]);
540        } catch (\Exception $e) {
541            /** @disregard P1014 */
542            $e->exceptionCode = 'LIST_INCENTIVES_PARAMETERS_EXCEPTION';
543            report($e);
544
545            return response(['message' => 'KO', 'error' => $e->getMessage()]);
546        }
547    }
548
549    /**
550     * PUT /api/incentives-parameters/{param_key}
551     * Body: { "value": <decimal> }
552     *
553     * Updates one parameter and appends an audit row if the value
554     * actually changed. FIRE-955 role gating is still unresolved —
555     * Phase-1 fallback: require Admin until "Dirección Comercial" and
556     * "Finanzas" roles exist in tbl_roles.
557     */
558    public function update_parameter(Request $request, string $param_key)
559    {
560        try {
561            if (! $this->callerIsAdmin($request)) {
562                return response(['message' => 'KO', 'error' => 'role not allowed'], 403);
563            }
564
565            $data = $request->validate([
566                'value' => 'required|numeric',
567            ]);
568
569            $row = TblIncentivePlanParameters::where('param_key', $param_key)->first();
570            if (! $row) {
571                return response(['message' => 'KO', 'error' => "Unknown parameter: {$param_key}"], 404);
572            }
573
574            $newValue = (float) $data['value'];
575            $oldValue = (float) $row->value;
576
577            // Unit-scoped validation.
578            if ($row->unit === 'fraction' && ($newValue < 0 || $newValue > 1)) {
579                return response(['message' => 'KO', 'error' => 'fraction must be in [0, 1]'], 422);
580            }
581            if ($row->unit === 'eur' && $newValue < 0) {
582                return response(['message' => 'KO', 'error' => 'eur must be >= 0'], 422);
583            }
584            if ($row->unit === 'months' && $newValue < 0) {
585                return response(['message' => 'KO', 'error' => 'months must be >= 0'], 422);
586            }
587            if ($row->unit === 'contracts' && ($newValue < 0 || floor($newValue) !== $newValue)) {
588                return response(['message' => 'KO', 'error' => 'contracts must be a non-negative integer'], 422);
589            }
590
591            $changedBy = $request->header('User-ID') ?: 'unknown';
592
593            if ($oldValue !== $newValue) {
594                $row->value = $newValue;
595                $row->updated_by = $changedBy;
596                $row->save();
597
598                TblIncentivePlanParametersLog::create([
599                    'param_key' => $param_key,
600                    'old_value' => $oldValue,
601                    'new_value' => $newValue,
602                    'changed_by' => $changedBy,
603                    'changed_at' => now(),
604                ]);
605            }
606
607            return response(['message' => 'OK', 'data' => $row->fresh()]);
608        } catch (\Exception $e) {
609            /** @disregard P1014 */
610            $e->exceptionCode = 'UPDATE_INCENTIVES_PARAMETER_EXCEPTION';
611            report($e);
612
613            return response(['message' => 'KO', 'error' => $e->getMessage()]);
614        }
615    }
616
617    /**
618     * GET /api/incentives-parameters/log?param_key=...
619     */
620    public function list_parameters_log(Request $request)
621    {
622        try {
623            $q = TblIncentivePlanParametersLog::query()->orderByDesc('changed_at');
624            if ($request->filled('param_key')) {
625                $q->where('param_key', $request->query('param_key'));
626            }
627
628            return response(['message' => 'OK', 'data' => $q->limit(200)->get()]);
629        } catch (\Exception $e) {
630            /** @disregard P1014 */
631            $e->exceptionCode = 'LIST_INCENTIVES_PARAMETERS_LOG_EXCEPTION';
632            report($e);
633
634            return response(['message' => 'KO', 'error' => $e->getMessage()]);
635        }
636    }
637
638    /**
639     * FIRE-955 role gate fallback. When that ticket lands an
640     * EnsureRole middleware this check moves out of the controller.
641     */
642    private function callerIsAdmin(Request $request): bool
643    {
644        $userId = intval($request->header('User-ID'));
645        if (! $userId) {
646            return false;
647        }
648        $roleId = DB::table('tbl_users')
649            ->where('id', $userId)
650            ->value('role_id');
651        if (! $roleId) {
652            return false;
653        }
654        $roleName = DB::table('tbl_roles')
655            ->where('role_id', $roleId)
656            ->value('name');
657
658        return $roleName === 'Admin';
659    }
660
661    /**
662     * Translate year + quarter label (Q1..Q4 or "ANNUAL") to an inclusive
663     * [start, end] datetime range.
664     */
665    private function quarterRange(int $year, string $quarter): array
666    {
667        $q = [
668            'Q1' => ['01-01', '03-31'],
669            'Q2' => ['04-01', '06-30'],
670            'Q3' => ['07-01', '09-30'],
671            'Q4' => ['10-01', '12-31'],
672        ];
673        if (isset($q[$quarter])) {
674            return ["{$year}-{$q[$quarter][0]} 00:00:00", "{$year}-{$q[$quarter][1]} 23:59:59"];
675        }
676
677        return ["{$year}-01-01 00:00:00", "{$year}-12-31 23:59:59"];
678    }
679
680    /**
681     * Aggregate the nuevo-cliente bonus per (commercial, year, quarter)
682     * inside the requested window. SOP v13 (FIRE-1032 Bug 2): a preventivo
683     * earns the bonus when the client has had no preventivo accepted in the
684     * 13 months immediately preceding *this* preventivo's acceptance.
685     *
686     * Tier-A client identity: lowercased `email`. `client_id` is ~100 %
687     * NULL on accepted rows today (FIRE-958 Finding 3), so email is the
688     * only stable key available; the trade-off is documented in the
689     * findings file (shared-email noise floor is < 1 %).
690     *
691     * Tramos are read from tbl_incentive_plan_parameters at query time so
692     * the admin Parámetros UI can adjust them without a deploy:
693     *   - amount in [3.001, 5.000]  → bonus_nuevo_cliente_tramo_bajo (80 €)
694     *   - amount > 5.000            → bonus_nuevo_cliente_tramo_alto (150 €)
695     *   - amount ≤ 3.000            → 0 €
696     *
697     * Returns an array keyed by "{commercial}|{year}|{quarter}" so the
698     * row-map closure in list_criteria can look up the bonus in O(1).
699     */
700    private function collectNewClientBonusesByCommercial(array $companyIds, string $startDate, string $endDate, int $duracionMin2): array
701    {
702        $bonusBajo = $this->params->get('bonus_nuevo_cliente_tramo_bajo');
703        $bonusAlto = $this->params->get('bonus_nuevo_cliente_tramo_alto');
704
705        $rows = DB::table('tbl_quotations as q')
706            ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
707            ->where('q.budget_status_id', 3)
708            ->whereIn('q.company_id', $companyIds)
709            ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
710            ->whereIn('bt.name', self::TIPO_PREVENTIVO)
711            ->whereRaw('COALESCE(q.acceptance_date, q.accepted_at) BETWEEN ? AND ?', [$startDate, $endDate])
712            ->whereNotNull('q.email')
713            ->where('q.email', '!=', '')
714            // FIRE-956 Phase 2: short-term contracts only — duration < min_2.
715            // tbl_quotations.duration is in YEARS; the parameter is in MONTHS,
716            // so multiply by 12 to compare in the same unit.
717            // Empty/NULL duration falls through here (treated as "unknown,
718            // probably short" via CAST → 0). Long-term contracts route to
719            // collectCaptacionBonusesByCommercial instead.
720            ->whereRaw('CAST(NULLIF(TRIM(q.duration), \'\') AS UNSIGNED) * 12 < ?', [$duracionMin2])
721            ->whereNotExists(function ($sub) {
722                $sub->select(DB::raw(1))
723                    ->from('tbl_quotations as q2')
724                    ->join('tbl_budget_types as bt2', 'bt2.budget_type_id', '=', 'q2.budget_type_id')
725                    ->whereRaw('LOWER(q2.email) = LOWER(q.email)')
726                    ->where('q2.budget_status_id', 3)
727                    ->whereIn('bt2.name', self::TIPO_PREVENTIVO)
728                    ->whereRaw('COALESCE(q2.acceptance_date, q2.accepted_at) < COALESCE(q.acceptance_date, q.accepted_at)')
729                    ->whereRaw('COALESCE(q2.acceptance_date, q2.accepted_at) >= DATE_SUB(COALESCE(q.acceptance_date, q.accepted_at), INTERVAL 13 MONTH)');
730            })
731            ->selectRaw(<<<'SQL'
732                q.commercial,
733                YEAR(COALESCE(q.acceptance_date, q.accepted_at))    AS anio,
734                QUARTER(COALESCE(q.acceptance_date, q.accepted_at)) AS trimestre,
735                SUM(CASE
736                    WHEN (q.amount + 0) >  5000 THEN ?
737                    WHEN (q.amount + 0) >  3000 THEN ?
738                    ELSE 0
739                END) AS bonus
740            SQL, [$bonusAlto, $bonusBajo])
741            ->groupBy('q.commercial')
742            ->groupByRaw('YEAR(COALESCE(q.acceptance_date, q.accepted_at))')
743            ->groupByRaw('QUARTER(COALESCE(q.acceptance_date, q.accepted_at))')
744            ->get();
745
746        $byKey = [];
747        foreach ($rows as $r) {
748            $byKey[$r->commercial.'|'.((int) $r->anio).'|'.((int) $r->trimestre)] = (float) $r->bonus;
749        }
750
751        return $byKey;
752    }
753
754    /**
755     * FIRE-956 Phase 2B: captación bonus for new long-term preventivo
756     * contracts. Same 13-month no-prior-preventivo rule as nuevo-cliente
757     * (lowercased email key), but the contract must be ≥ duracion_min_2
758     * months long and the tramos / amount bands are different:
759     *   - amount in (3.000, 10.000]  → bonus_captacion_tramo_bajo  (350 €)
760     *   - amount > 10.000            → bonus_captacion_tramo_alto  (600 €)
761     *   - amount ≤ 3.000             → 0 €
762     *
763     * Mutually exclusive with nuevo-cliente (Q2 answer "b" — alternative
764     * by contract length, never additive on the same row).
765     */
766    private function collectCaptacionBonusesByCommercial(
767        array $companyIds,
768        string $startDate,
769        string $endDate,
770        int $duracionMin2,
771        float $bonusBajo,
772        float $bonusAlto,
773    ): array {
774        $rows = DB::table('tbl_quotations as q')
775            ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
776            ->where('q.budget_status_id', 3)
777            ->whereIn('q.company_id', $companyIds)
778            ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
779            ->whereIn('bt.name', self::TIPO_PREVENTIVO)
780            ->whereRaw('COALESCE(q.acceptance_date, q.accepted_at) BETWEEN ? AND ?', [$startDate, $endDate])
781            ->whereNotNull('q.email')
782            ->where('q.email', '!=', '')
783            // tbl_quotations.duration is in YEARS; multiply by 12 to compare
784            // against the months-based duracion_min_largo_plazo_2_anos parameter.
785            ->whereRaw('CAST(NULLIF(TRIM(q.duration), \'\') AS UNSIGNED) * 12 >= ?', [$duracionMin2])
786            ->whereNotExists(function ($sub) {
787                $sub->select(DB::raw(1))
788                    ->from('tbl_quotations as q2')
789                    ->join('tbl_budget_types as bt2', 'bt2.budget_type_id', '=', 'q2.budget_type_id')
790                    ->whereRaw('LOWER(q2.email) = LOWER(q.email)')
791                    ->where('q2.budget_status_id', 3)
792                    ->whereIn('bt2.name', self::TIPO_PREVENTIVO)
793                    ->whereRaw('COALESCE(q2.acceptance_date, q2.accepted_at) < COALESCE(q.acceptance_date, q.accepted_at)')
794                    ->whereRaw('COALESCE(q2.acceptance_date, q2.accepted_at) >= DATE_SUB(COALESCE(q.acceptance_date, q.accepted_at), INTERVAL 13 MONTH)');
795            })
796            ->selectRaw(<<<'SQL'
797                q.commercial,
798                YEAR(COALESCE(q.acceptance_date, q.accepted_at))    AS anio,
799                QUARTER(COALESCE(q.acceptance_date, q.accepted_at)) AS trimestre,
800                SUM(CASE
801                    WHEN (q.amount + 0) > 10000 THEN ?
802                    WHEN (q.amount + 0) >  3000 THEN ?
803                    ELSE 0
804                END) AS bonus
805            SQL, [$bonusAlto, $bonusBajo])
806            ->groupBy('q.commercial')
807            ->groupByRaw('YEAR(COALESCE(q.acceptance_date, q.accepted_at))')
808            ->groupByRaw('QUARTER(COALESCE(q.acceptance_date, q.accepted_at))')
809            ->get();
810
811        $byKey = [];
812        foreach ($rows as $r) {
813            $byKey[$r->commercial.'|'.((int) $r->anio).'|'.((int) $r->trimestre)] = (float) $r->bonus;
814        }
815
816        return $byKey;
817    }
818
819    /**
820     * FIRE-956 Phase 3: per-(commercial, year, trimester) installation
821     * commission. INNER JOINs `tbl_incentive_instalacion_margins` so only
822     * accepted installations with an M.C. row contribute. Tramo selection:
823     *   margin_pct >  0.45  → tramo A (1 %)
824     *   margin_pct >= 0.42  → tramo B (0.75 %)
825     *   margin_pct >= 0.35  → tramo C (0.50 %)
826     *   margin_pct <  0.35  → tramo D (0.25 %)
827     *
828     * Returns map: "{commercial}|{year}|{trimester}" => [
829     *   'comision'             => float (sum across installations),
830     *   'weighted_margin_pct'  => float (sum(amount*margin) / sum(amount)),
831     * ]
832     *
833     * Partial pay-out: if Finanzas has entered margins for SOME of the
834     * trimester's installations and not others, the comisión reflects
835     * only the ones that have margins. The pending ones surface in
836     * facturacion_instalacion (Tabla 1) but contribute nothing here.
837     */
838    private function collectInstalacionCommissionsByCommercial(
839        array $companyIds,
840        string $startDate,
841        string $endDate,
842        float $cap,
843        float $tramoA,
844        float $tramoB,
845        float $tramoC,
846        float $tramoD,
847    ): array {
848        $rows = DB::table('tbl_quotations as q')
849            ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
850            ->join('tbl_incentive_instalacion_margins as m', 'm.quotation_id', '=', 'q.id')
851            ->where('q.budget_status_id', 3)
852            ->whereIn('q.company_id', $companyIds)
853            ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
854            ->whereIn('bt.name', self::TIPO_INSTALACION)
855            ->whereRaw('COALESCE(q.acceptance_date, q.accepted_at) BETWEEN ? AND ?', [$startDate, $endDate])
856            ->selectRaw(<<<'SQL'
857                q.commercial,
858                YEAR(COALESCE(q.acceptance_date, q.accepted_at))    AS anio,
859                QUARTER(COALESCE(q.acceptance_date, q.accepted_at)) AS trimestre,
860                SUM(LEAST(q.amount + 0, ?) * CASE
861                    WHEN m.margin_pct >  0.45 THEN ?
862                    WHEN m.margin_pct >= 0.42 THEN ?
863                    WHEN m.margin_pct >= 0.35 THEN ?
864                    ELSE ?
865                END) AS comision,
866                -- Weighted-average margin: sum(amount_capped * margin) / sum(amount_capped)
867                SUM(LEAST(q.amount + 0, ?) * m.margin_pct) AS weighted_num,
868                SUM(LEAST(q.amount + 0, ?))                AS weighted_den
869            SQL, [$cap, $tramoA, $tramoB, $tramoC, $tramoD, $cap, $cap])
870            ->groupBy('q.commercial')
871            ->groupByRaw('YEAR(COALESCE(q.acceptance_date, q.accepted_at))')
872            ->groupByRaw('QUARTER(COALESCE(q.acceptance_date, q.accepted_at))')
873            ->get();
874
875        $byKey = [];
876        foreach ($rows as $r) {
877            $den = (float) $r->weighted_den;
878            $byKey[$r->commercial.'|'.((int) $r->anio).'|'.((int) $r->trimestre)] = [
879                'comision' => (float) $r->comision,
880                'weighted_margin_pct' => $den > 0 ? ((float) $r->weighted_num) / $den : 0.0,
881            ];
882        }
883
884        return $byKey;
885    }
886
887    /**
888     * GET /api/incentives-margins?year=YYYY&quarter=Q1..Q4|annual
889     *
890     * FIRE-956 Phase 3: list of accepted installations in the window
891     * together with their current M.C. % (or null if not yet entered).
892     * The frontend renders this in Tabla Incentivos as an inline-edit
893     * column — Finanzas types the percentage and the next list_criteria
894     * call picks up the new value.
895     */
896    public function list_margins(Request $request)
897    {
898        try {
899            $companyIds = $this->getCompanyIds($request);
900            if (empty($companyIds)) {
901                return response(['message' => 'OK', 'data' => []]);
902            }
903
904            $year = (int) $request->query('year', date('Y'));
905            $quarter = strtoupper((string) $request->query('quarter', 'ANNUAL'));
906            [$startDate, $endDate] = $this->quarterRange($year, $quarter);
907
908            $rows = DB::table('tbl_quotations as q')
909                ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
910                ->leftJoin('tbl_incentive_instalacion_margins as m', 'm.quotation_id', '=', 'q.id')
911                ->where('q.budget_status_id', 3)
912                ->whereIn('q.company_id', $companyIds)
913                ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
914                ->whereIn('bt.name', self::TIPO_INSTALACION)
915                ->whereRaw('COALESCE(q.acceptance_date, q.accepted_at) BETWEEN ? AND ?', [$startDate, $endDate])
916                ->selectRaw(<<<'SQL'
917                    q.id                                               AS quotation_id,
918                    q.quote_id,
919                    q.commercial,
920                    q.amount + 0                                       AS importe,
921                    COALESCE(q.acceptance_date, q.accepted_at)         AS fecha_aceptacion,
922                    YEAR(COALESCE(q.acceptance_date, q.accepted_at))    AS anio,
923                    QUARTER(COALESCE(q.acceptance_date, q.accepted_at)) AS trimestre,
924                    m.margin_pct,
925                    m.entered_by,
926                    m.entered_at
927                SQL)
928                ->orderByDesc('fecha_aceptacion')
929                ->get();
930
931            return response(['message' => 'OK', 'data' => $rows]);
932        } catch (\Exception $e) {
933            /** @disregard P1014 */
934            $e->exceptionCode = 'LIST_INCENTIVES_MARGINS_EXCEPTION';
935            report($e);
936
937            return response(['message' => 'KO', 'error' => $e->getMessage()]);
938        }
939    }
940
941    /**
942     * PUT /api/incentives-margins/{quotation_id}
943     * Body: { "margin_pct": <decimal in [0, 1]> }
944     *
945     * FIRE-956 Phase 3: upsert one installation's M.C. %. RBAC mirrors
946     * update_parameter (admin-only fallback until tbl_roles has the
947     * dedicated Finanzas role).
948     */
949    public function upsert_margin(Request $request, int $quotation_id)
950    {
951        try {
952            if (! $this->callerIsAdmin($request)) {
953                return response(['message' => 'KO', 'error' => 'role not allowed'], 403);
954            }
955
956            $data = $request->validate([
957                'margin_pct' => 'required|numeric|min:0|max:1',
958            ]);
959
960            $exists = DB::table('tbl_quotations')->where('id', $quotation_id)->exists();
961            if (! $exists) {
962                return response(['message' => 'KO', 'error' => "Unknown quotation: {$quotation_id}"], 404);
963            }
964
965            $changedBy = $request->header('User-ID') ?: 'unknown';
966            $row = TblIncentiveInstalacionMargins::updateOrCreate(
967                ['quotation_id' => $quotation_id],
968                [
969                    'margin_pct' => (float) $data['margin_pct'],
970                    'entered_by' => (string) $changedBy,
971                    'entered_at' => now(),
972                ],
973            );
974
975            return response(['message' => 'OK', 'data' => $row->fresh()]);
976        } catch (\Exception $e) {
977            /** @disregard P1014 */
978            $e->exceptionCode = 'UPSERT_INCENTIVES_MARGIN_EXCEPTION';
979            report($e);
980
981            return response(['message' => 'KO', 'error' => $e->getMessage()]);
982        }
983    }
984}