Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 233 |
|
0.00% |
0 / 9 |
CRAP | |
0.00% |
0 / 1 |
| IncentivesController | |
0.00% |
0 / 233 |
|
0.00% |
0 / 9 |
2256 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getCompanyIds | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
| list_criteria | |
0.00% |
0 / 82 |
|
0.00% |
0 / 1 |
110 | |||
| list_datos | |
0.00% |
0 / 71 |
|
0.00% |
0 / 1 |
42 | |||
| list_parameters | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| update_parameter | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
272 | |||
| list_parameters_log | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
| callerIsAdmin | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
| quarterRange | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers; |
| 4 | |
| 5 | use App\Models\TblCompanies; |
| 6 | use App\Models\TblCompanyUsers; |
| 7 | use App\Models\TblIncentivePlanParameters; |
| 8 | use App\Models\TblIncentivePlanParametersLog; |
| 9 | use App\Services\IncentivePlanParameters; |
| 10 | use Illuminate\Http\Request; |
| 11 | use Illuminate\Support\Facades\App; |
| 12 | use Illuminate\Support\Facades\DB; |
| 13 | |
| 14 | class 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 | } |