Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 233
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
IncentivesController
0.00% covered (danger)
0.00%
0 / 233
0.00% covered (danger)
0.00%
0 / 9
2256
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 / 82
0.00% covered (danger)
0.00%
0 / 1
110
 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 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 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
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\TblCompanies;
6use App\Models\TblCompanyUsers;
7use App\Models\TblIncentivePlanParameters;
8use App\Models\TblIncentivePlanParametersLog;
9use App\Services\IncentivePlanParameters;
10use Illuminate\Http\Request;
11use Illuminate\Support\Facades\App;
12use Illuminate\Support\Facades\DB;
13
14class IncentivesController extends Controller
15{
16    /**
17     * Budget type name mappings from spec vocabulary to DB vocabulary.
18     * Pending Berta's confirmation (see investigation Finding 2):
19     *   - Does PREVENTIVO include "Mantenimiento pasivo" (id 14)?
20     *   - Is RENOVACION a new budget type or subset of Mantenimiento + Recurrente?
21     */
22    private const TIPO_PREVENTIVO = ['Mantenimiento', 'Mantenimiento pasivo'];
23    private const TIPO_CORRECTIVO = ['Correctivos'];
24    private const TIPO_INSTALACION = ['Instalaciones'];
25    // FIRE-958 seeded "Mantenimiento Renovación" into tbl_budget_types —
26    // finally closes FIRE-953 Finding 2. Any accepted quotation tagged
27    // with this name now shows up in facturación / comisión renovación.
28    private const TIPO_RENOVACION = ['Mantenimiento Renovación'];
29
30    /**
31     * Customer types considered "Carterizado" for renovación.
32     * Pending confirmation — current mapping is "Recurrente" family.
33     */
34    private const CLIENTE_CARTERIZADO = ['Recurrente', 'Recurrente - Facility'];
35
36    /**
37     * Commercials enrolled in the incentive plan (FIRE-955 spec).
38     * The spec preamble says "8 comerciales" but the table enumerates 11 —
39     * we include all 11 until Berta clarifies; trimming to 8 is a one-line
40     * edit if positions 9–11 (Carrasco / Llopart / Calaf) turn out to be a
41     * future cohort.
42     *
43     * Will migrate to a config table (tbl_incentive_commercial_rosters)
44     * once the FIRE-954 sibling "Parámetros configurables del plan" lands.
45     */
46    private const COMERCIALES_ACTIVOS = [
47        'Xavier Centella',
48        'Jose Solanas',
49        'Hernan Vignogna',
50        'Joan Centella',
51        'Eugenio Romo',
52        'Jose Valle',
53        'Adolfo Fernandez Beut',
54        'Javier Grau',
55        'Jose Carrasco',
56        'Joan Llopart',
57        'Jaume Calaf',
58    ];
59
60    private IncentivePlanParameters $params;
61
62    public function __construct()
63    {
64        $this->params = new IncentivePlanParameters();
65        App::setLocale(request()->header('Locale-Id'));
66    }
67
68    private function getCompanyIds(Request $request): array
69    {
70        $region = urldecode($request->header('Region'));
71        $userId = intval($request->header('User-ID'));
72
73        if ($region === 'All' || empty($region)) {
74            return TblCompanyUsers::where('user_id', $userId)
75                ->pluck('company_id')
76                ->toArray();
77        }
78
79        $company = TblCompanies::where('region', $region)->first();
80
81        return $company ? [$company->company_id] : [];
82    }
83
84    /**
85     * GET /api/incentives-criteria?year=YYYY&quarter=Q1|Q2|Q3|Q4|annual
86     *
87     * Returns one row per (commercial, quarter) with the 15 columns of
88     * Tabla 1 — Tabla de criterios (FIRE-953).
89     *
90     * The K2 / K3 columns (long-term preventivo bonuses) are stubbed at 0
91     * until contract_duration_months exists on tbl_quotations (see
92     * investigation Finding 4, blocked on the Datos sibling ticket).
93     */
94    public function list_criteria(Request $request)
95    {
96        try {
97            $companyIds = $this->getCompanyIds($request);
98            if (empty($companyIds)) {
99                return response(['message' => 'OK', 'data' => []]);
100            }
101
102            $year = (int) $request->query('year', date('Y'));
103            $quarter = strtoupper((string) $request->query('quarter', 'ANNUAL'));
104
105            [$startDate, $endDate] = $this->quarterRange($year, $quarter);
106
107            // Fetch accepted quotations in the window, grouped by commercial + quarter.
108            // COALESCE on accepted_at/acceptance_date is required — historical data
109            // only has acceptance_date (investigation Finding 5).
110            $rows = DB::table('tbl_quotations as q')
111                ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
112                ->leftJoin('tbl_customer_types as ct', 'ct.customer_type_id', '=', 'q.customer_type_id')
113                ->where('q.budget_status_id', 3)
114                ->whereIn('q.company_id', $companyIds)
115                ->whereNotNull('q.commercial')
116                ->where('q.commercial', '!=', '')
117                ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
118                ->whereRaw('COALESCE(q.accepted_at, q.acceptance_date) BETWEEN ? AND ?', [$startDate, $endDate])
119                ->selectRaw(<<<'SQL'
120                    q.commercial                                                                           AS comercial,
121                    YEAR(COALESCE(q.accepted_at, q.acceptance_date))                                       AS anio,
122                    QUARTER(COALESCE(q.accepted_at, q.acceptance_date))                                    AS trimestre,
123                    SUM(CASE WHEN bt.name IN (?, ?)                                       THEN 1 ELSE 0 END) AS n_prev,
124                    SUM(CASE WHEN bt.name IN (?, ?)                                       THEN q.amount + 0 ELSE 0 END) AS fact_prev,
125                    SUM(CASE WHEN bt.name IN (?)                                          THEN q.amount + 0 ELSE 0 END) AS fact_corr,
126                    SUM(CASE WHEN bt.name IN (?) THEN LEAST(q.amount + 0, ?) ELSE 0 END)                                  AS fact_inst,
127                    SUM(CASE WHEN ct.name IN (?, ?) THEN q.amount + 0 ELSE 0 END)                                         AS fact_renov
128                SQL, [
129                    ...self::TIPO_PREVENTIVO, // count preventivo
130                    ...self::TIPO_PREVENTIVO, // sum preventivo
131                    ...self::TIPO_CORRECTIVO,
132                    ...self::TIPO_INSTALACION, $this->params->get('cap_instalacion_individual'),
133                    ...self::CLIENTE_CARTERIZADO,
134                ])
135                ->groupBy('q.commercial')
136                ->groupByRaw('YEAR(COALESCE(q.accepted_at, q.acceptance_date))')
137                ->groupByRaw('QUARTER(COALESCE(q.accepted_at, q.acceptance_date))')
138                ->orderBy('q.commercial')
139                ->get();
140
141            // Read every plan parameter once per request — the service
142            // memoizes internally so this is a single SELECT.
143            $objetivoPrev = $this->params->get('objetivo_preventivo_trimestre');
144            $objetivoCorr = $this->params->get('objetivo_correctivo_trimestre');
145            $umbralActivacion = $this->params->get('umbral_activacion');
146            $minPreventivos = (int) $this->params->get('min_preventivos_desbloqueo');
147            $comisionCorr = $this->params->get('comision_correctivo');
148            $comisionRenov = $this->params->get('comision_renovacion');
149
150            $data = $rows->map(function ($r) use ($objetivoPrev, $objetivoCorr, $umbralActivacion, $minPreventivos, $comisionCorr, $comisionRenov) {
151                $nPrev = (int) $r->n_prev;
152                $factPrev = (float) $r->fact_prev;
153                $factCorr = (float) $r->fact_corr;
154                $factInst = (float) $r->fact_inst;
155                $factRenov = (float) $r->fact_renov;
156
157                $pctPrev = $objetivoPrev > 0 ? $factPrev / $objetivoPrev : 0;
158                $pctCorr = $objetivoCorr > 0 ? $factCorr / $objetivoCorr : 0;
159
160                $condPrev = $pctPrev >= $umbralActivacion;
161                $condCorr = $pctCorr >= $umbralActivacion && $nPrev >= $minPreventivos;
162                $condInst = $nPrev >= $minPreventivos;
163
164                // Tabla 2 — FIRE-956 commission columns.
165                // Phase 1: compute what we can from Tabla 1 values alone.
166                // D, D2, F, G stay null until the Datos sibling ticket lands
167                // (contract_duration_months + helper columns AN/AQ/AR).
168                // H lights up automatically once RENOVACION vocabulary is set.
169                // I, J are Phase 3 (manual M.C. input + tier lookup).
170                $comisionCorrectivo = $condCorr ? round($factCorr * $comisionCorr, 2) : 0.0;
171                $comisionRenovacion = round($factRenov * $comisionRenov, 2);
172
173                $totalTrimestre = $comisionCorrectivo + $comisionRenovacion;
174
175                return [
176                    'comercial' => $r->comercial,
177                    'anio' => (int) $r->anio,
178                    'trimestre' => 'Q' . ((int) $r->trimestre),
179                    'n_contratos_preventivos' => $nPrev,
180                    'facturacion_preventivo' => round($factPrev, 2),
181                    'facturacion_correctivo' => round($factCorr, 2),
182                    'facturacion_renovacion' => round($factRenov, 2),
183                    'facturacion_instalacion' => round($factInst, 2),
184                    'objetivo_preventivo' => $objetivoPrev,
185                    'objetivo_correctivo' => $objetivoCorr,
186                    'pct_cumplimiento_preventivo' => round($pctPrev, 4),
187                    'pct_cumplimiento_correctivo' => round($pctCorr, 4),
188                    // K2 / K3 stubbed until tbl_quotations has contract_duration_months
189                    'n_prev_largo_plazo_2_anos' => 0,
190                    'n_prev_largo_plazo_3_anos' => 0,
191                    'condicion_preventivo_activa' => $condPrev ? 'SÍ' : 'NO',
192                    'condicion_correctivo_activa' => $condCorr ? 'SÍ' : 'NO',
193                    'condicion_instalacion_activa' => $condInst ? 'SÍ' : 'NO',
194                    // Tabla 2 columns (FIRE-956)
195                    'comision_preventivo_std' => null,        // Phase 2 — needs contract_duration_months
196                    'bonus_preventivo_largo_plazo' => null,    // Phase 2 — needs Datos col AR
197                    'comision_correctivo' => $comisionCorrectivo,
198                    'bonus_captacion' => null,                 // Phase 2 — needs Datos col AN
199                    'bonus_nuevo_cliente' => null,             // Phase 2 — needs Datos col AQ
200                    'comision_renovacion' => $comisionRenovacion,
201                    'margin_instalacion_pct' => null,          // Phase 3 — manual input by Finanzas
202                    'comision_instalacion' => null,            // Phase 3 — depends on margin
203                    'total_trimestre' => round($totalTrimestre, 2),
204                ];
205            })->values();
206
207            return response(['message' => 'OK', 'data' => $data]);
208        } catch (\Exception $e) {
209            /** @disregard P1014 */
210            $e->exceptionCode = 'LIST_INCENTIVES_CRITERIA_EXCEPTION';
211            report($e);
212
213            return response(['message' => 'KO', 'error' => $e->getMessage()]);
214        }
215    }
216
217    /**
218     * GET /api/incentives-datos?year=YYYY&quarter=Q1..Q4|annual
219     *
220     * FIRE-958: per-quotation view of the Calculadora de Eficiencia
221     * Comercial dataset — what the spec calls "tabla Datos". Returns
222     * one row per accepted quotation in the window with the seven
223     * derived columns AK/AL/AM/AN/AO/AP/AQ inline.
224     *
225     * Phase 1 scope (today):
226     *   ✓ AK Aplica              — CASE WHEN per row
227     *   ✓ AL Mes                 — PHP-side Spanish locale format
228     *   ✓ AM Tipo de trabajo     — CASE WHEN mapping to the 5 buckets
229     *   ✓ AP Cap inst. 100K      — LEAST(amount, cap) per row
230     * Phase 2/3 stubs (return null):
231     *   ✗ AN Helper captación    — blocked on contract duration + client identity + lookback window
232     *   ✗ AO Helper instalación  — blocked on tbl_incentive_instalacion_margins (FIRE-956 Phase 3)
233     *   ✗ AQ Helper nuevo        — blocked on contract duration (annualization)
234     */
235    public function list_datos(Request $request)
236    {
237        try {
238            $companyIds = $this->getCompanyIds($request);
239            if (empty($companyIds)) {
240                return response(['message' => 'OK', 'data' => []]);
241            }
242
243            $year = (int) $request->query('year', date('Y'));
244            $quarter = strtoupper((string) $request->query('quarter', 'ANNUAL'));
245            [$startDate, $endDate] = $this->quarterRange($year, $quarter);
246            $cap = $this->params->get('cap_instalacion_individual');
247
248            $applica = array_merge(
249                self::TIPO_PREVENTIVO,
250                self::TIPO_CORRECTIVO,
251                self::TIPO_INSTALACION,
252                self::TIPO_RENOVACION,
253            );
254            $placeholders = implode(',', array_fill(0, count($applica), '?'));
255
256            $prevPlaceholders = implode(',', array_fill(0, count(self::TIPO_PREVENTIVO), '?'));
257            $corrPlaceholders = implode(',', array_fill(0, count(self::TIPO_CORRECTIVO), '?'));
258            $instPlaceholders = implode(',', array_fill(0, count(self::TIPO_INSTALACION), '?'));
259            $renovPlaceholders = implode(',', array_fill(0, count(self::TIPO_RENOVACION), '?'));
260
261            $rows = DB::table('tbl_quotations as q')
262                ->join('tbl_budget_types as bt', 'bt.budget_type_id', '=', 'q.budget_type_id')
263                ->leftJoin('tbl_customer_types as ct', 'ct.customer_type_id', '=', 'q.customer_type_id')
264                ->where('q.budget_status_id', 3)
265                ->whereIn('q.company_id', $companyIds)
266                ->whereIn('q.commercial', self::COMERCIALES_ACTIVOS)
267                ->whereRaw('COALESCE(q.accepted_at, q.acceptance_date) BETWEEN ? AND ?', [$startDate, $endDate])
268                ->selectRaw(<<<SQL
269                    q.id,
270                    q.quote_id,
271                    q.commercial,
272                    CAST(q.amount AS DECIMAL(14,2))                      AS importe,
273                    bt.name                                              AS tipo_presupuesto,
274                    ct.name                                              AS tipo_cliente,
275                    COALESCE(q.accepted_at, q.acceptance_date)           AS fecha_aceptacion,
276                    CASE WHEN bt.name IN ({$placeholders}) THEN 1 ELSE 0 END AS aplica,
277                    CASE
278                        WHEN bt.name IN ({$prevPlaceholders})  THEN 'PREVENTIVO'
279                        WHEN bt.name IN ({$corrPlaceholders})  THEN 'CORRECTIVO'
280                        WHEN bt.name IN ({$instPlaceholders})  THEN 'INSTALACION'
281                        WHEN bt.name IN ({$renovPlaceholders}) THEN 'RENOVACION'
282                        ELSE 'OTRO'
283                    END                                                   AS tipo_trabajo,
284                    LEAST(q.amount + 0, ?)                                AS cap_inst_100k
285                SQL, [
286                    ...$applica,
287                    ...self::TIPO_PREVENTIVO,
288                    ...self::TIPO_CORRECTIVO,
289                    ...self::TIPO_INSTALACION,
290                    ...self::TIPO_RENOVACION,
291                    $cap,
292                ])
293                ->orderByDesc('fecha_aceptacion')
294                ->get();
295
296            // AL Mes — Spanish "feb-2025" format. IntlDateFormatter keeps
297            // us away from the deprecated strftime and independent of
298            // MySQL's lc_time_names server setting.
299            $fmt = new \IntlDateFormatter('es_ES', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, null, null, 'MMM-yyyy');
300
301            $data = $rows->map(function ($r) use ($fmt) {
302                $mes = '-';
303                if ((int) $r->aplica === 1 && $r->fecha_aceptacion) {
304                    $ts = strtotime((string) $r->fecha_aceptacion);
305                    if ($ts !== false) {
306                        // IntlDateFormatter returns "feb." with a trailing
307                        // dot in Spanish locale — strip it and lowercase.
308                        $mes = strtolower(rtrim($fmt->format($ts), '.'));
309                    }
310                }
311                return [
312                    'id' => $r->id,
313                    'quote_id' => $r->quote_id,
314                    'comercial' => $r->commercial,
315                    'importe' => (float) $r->importe,
316                    'tipo_presupuesto' => $r->tipo_presupuesto,
317                    'tipo_cliente' => $r->tipo_cliente,
318                    'fecha_aceptacion' => $r->fecha_aceptacion,
319                    'aplica' => (int) $r->aplica,
320                    'mes' => $mes,
321                    'tipo_trabajo' => $r->tipo_trabajo,
322                    'cap_inst_100k' => (float) $r->cap_inst_100k,
323                    // Phase 2/3 stubs — see method docblock for blockers.
324                    'helper_captacion' => null,
325                    'helper_instalacion' => null,
326                    'helper_nuevo' => null,
327                ];
328            })->values();
329
330            return response(['message' => 'OK', 'data' => $data]);
331        } catch (\Exception $e) {
332            /** @disregard P1014 */
333            $e->exceptionCode = 'LIST_INCENTIVES_DATOS_EXCEPTION';
334            report($e);
335
336            return response(['message' => 'KO', 'error' => $e->getMessage()]);
337        }
338    }
339
340    /**
341     * GET /api/incentives-parameters — full plan-parameter catalogue for
342     * the admin panel. Reads every row; no scoping.
343     */
344    public function list_parameters(Request $request)
345    {
346        try {
347            $data = TblIncentivePlanParameters::orderBy('id')->get();
348
349            return response(['message' => 'OK', 'data' => $data]);
350        } catch (\Exception $e) {
351            /** @disregard P1014 */
352            $e->exceptionCode = 'LIST_INCENTIVES_PARAMETERS_EXCEPTION';
353            report($e);
354
355            return response(['message' => 'KO', 'error' => $e->getMessage()]);
356        }
357    }
358
359    /**
360     * PUT /api/incentives-parameters/{param_key}
361     * Body: { "value": <decimal> }
362     *
363     * Updates one parameter and appends an audit row if the value
364     * actually changed. FIRE-955 role gating is still unresolved —
365     * Phase-1 fallback: require Admin until "Dirección Comercial" and
366     * "Finanzas" roles exist in tbl_roles.
367     */
368    public function update_parameter(Request $request, string $param_key)
369    {
370        try {
371            if (! $this->callerIsAdmin($request)) {
372                return response(['message' => 'KO', 'error' => 'role not allowed'], 403);
373            }
374
375            $data = $request->validate([
376                'value' => 'required|numeric',
377            ]);
378
379            $row = TblIncentivePlanParameters::where('param_key', $param_key)->first();
380            if (! $row) {
381                return response(['message' => 'KO', 'error' => "Unknown parameter: {$param_key}"], 404);
382            }
383
384            $newValue = (float) $data['value'];
385            $oldValue = (float) $row->value;
386
387            // Unit-scoped validation.
388            if ($row->unit === 'fraction' && ($newValue < 0 || $newValue > 1)) {
389                return response(['message' => 'KO', 'error' => 'fraction must be in [0, 1]'], 422);
390            }
391            if ($row->unit === 'eur' && $newValue < 0) {
392                return response(['message' => 'KO', 'error' => 'eur must be >= 0'], 422);
393            }
394            if ($row->unit === 'months' && $newValue < 0) {
395                return response(['message' => 'KO', 'error' => 'months must be >= 0'], 422);
396            }
397            if ($row->unit === 'contracts' && ($newValue < 0 || floor($newValue) !== $newValue)) {
398                return response(['message' => 'KO', 'error' => 'contracts must be a non-negative integer'], 422);
399            }
400
401            $changedBy = $request->header('User-ID') ?: 'unknown';
402
403            if ($oldValue !== $newValue) {
404                $row->value = $newValue;
405                $row->updated_by = $changedBy;
406                $row->save();
407
408                TblIncentivePlanParametersLog::create([
409                    'param_key' => $param_key,
410                    'old_value' => $oldValue,
411                    'new_value' => $newValue,
412                    'changed_by' => $changedBy,
413                    'changed_at' => now(),
414                ]);
415            }
416
417            return response(['message' => 'OK', 'data' => $row->fresh()]);
418        } catch (\Exception $e) {
419            /** @disregard P1014 */
420            $e->exceptionCode = 'UPDATE_INCENTIVES_PARAMETER_EXCEPTION';
421            report($e);
422
423            return response(['message' => 'KO', 'error' => $e->getMessage()]);
424        }
425    }
426
427    /**
428     * GET /api/incentives-parameters/log?param_key=...
429     */
430    public function list_parameters_log(Request $request)
431    {
432        try {
433            $q = TblIncentivePlanParametersLog::query()->orderByDesc('changed_at');
434            if ($request->filled('param_key')) {
435                $q->where('param_key', $request->query('param_key'));
436            }
437
438            return response(['message' => 'OK', 'data' => $q->limit(200)->get()]);
439        } catch (\Exception $e) {
440            /** @disregard P1014 */
441            $e->exceptionCode = 'LIST_INCENTIVES_PARAMETERS_LOG_EXCEPTION';
442            report($e);
443
444            return response(['message' => 'KO', 'error' => $e->getMessage()]);
445        }
446    }
447
448    /**
449     * FIRE-955 role gate fallback. When that ticket lands an
450     * EnsureRole middleware this check moves out of the controller.
451     */
452    private function callerIsAdmin(Request $request): bool
453    {
454        $userId = intval($request->header('User-ID'));
455        if (! $userId) {
456            return false;
457        }
458        $roleId = DB::table('tbl_users')
459            ->where('id', $userId)
460            ->value('role_id');
461        if (! $roleId) {
462            return false;
463        }
464        $roleName = DB::table('tbl_roles')
465            ->where('role_id', $roleId)
466            ->value('name');
467
468        return $roleName === 'Admin';
469    }
470
471    /**
472     * Translate year + quarter label (Q1..Q4 or "ANNUAL") to an inclusive
473     * [start, end] datetime range.
474     */
475    private function quarterRange(int $year, string $quarter): array
476    {
477        $q = [
478            'Q1' => ['01-01', '03-31'],
479            'Q2' => ['04-01', '06-30'],
480            'Q3' => ['07-01', '09-30'],
481            'Q4' => ['10-01', '12-31'],
482        ];
483        if (isset($q[$quarter])) {
484            return ["{$year}-{$q[$quarter][0]} 00:00:00", "{$year}-{$q[$quarter][1]} 23:59:59"];
485        }
486
487        return ["{$year}-01-01 00:00:00", "{$year}-12-31 23:59:59"];
488    }
489}