Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 450 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
| IncentivesController | |
0.00% |
0 / 450 |
|
0.00% |
0 / 14 |
5700 | |
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 / 152 |
|
0.00% |
0 / 1 |
306 | |||
| list_datos | |
0.00% |
0 / 71 |
|
0.00% |
0 / 1 |
42 | |||
| list_parameters | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
72 | |||
| 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 | |||
| collectNewClientBonusesByCommercial | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
6 | |||
| collectCaptacionBonusesByCommercial | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
6 | |||
| collectInstalacionCommissionsByCommercial | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
| list_margins | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
| upsert_margin | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers; |
| 4 | |
| 5 | use App\Models\TblCompanies; |
| 6 | use App\Models\TblCompanyUsers; |
| 7 | use App\Models\TblIncentiveInstalacionMargins; |
| 8 | use App\Models\TblIncentivePlanParameters; |
| 9 | use App\Models\TblIncentivePlanParametersLog; |
| 10 | use App\Services\IncentivePlanParameters; |
| 11 | use Illuminate\Http\Request; |
| 12 | use Illuminate\Support\Facades\App; |
| 13 | use Illuminate\Support\Facades\DB; |
| 14 | |
| 15 | class 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 | } |