= a quella richiesta (mm), entro min/max del listino; * - supplementi: tipo finitura (bianco/uniforme/bicolore) e opzioni scelte (gruppi di Opzione); * - variazione vetro: somma delle aree fatturabili delle lastre (max(area, minimo) per lastra) * per la maggiorazione €/m² del riempimento, con le lastre date dalla regola vetro del modello. * * I calcoli interni non sono arrotondati: solo il prezzo finale va a 2 decimali. */ class CalcolatorePrezzo { /** * @param array{larghezza?:mixed,altezza?:mixed,finitura_interna?:mixed,finitura_esterna?:mixed,maniglia?:mixed,vetro?:mixed,opzioni?:array} $scelte * @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array} * * @throws ValidationException se la misura è fuori listino o una scelta non esiste */ public function calcola(Modello $modello, array $scelte): array { $larghezza = (int) ($scelte['larghezza'] ?? 0); $altezza = (int) ($scelte['altezza'] ?? 0); [$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza); $opzioni = Opzione::perModello($modello); $supplementi = []; $configurazione = [ 'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome), 'width' => $larghezza, 'height' => $altezza, ]; $normalizzate = ['larghezza' => $larghezza, 'altezza' => $altezza, 'opzioni' => []]; if ($modello->haModulo('modello')) { // Finiture attive nel modello, ciascuna scelta solo tra quelle del suo lato. $profili = Finitura::delProfiloPerModello($modello); $interna = $this->scegli($profili->filter->disponibileSu('interno'), $scelte['finitura_interna'] ?? null, 'finitura_interna', fn ($f) => $f->predefinita); // Senza finitura esterna separata il serramento è tutto della finitura interna. $esterna = $modello->haModulo('finitura_esterna') ? $this->scegli($profili->filter->disponibileSu('esterno'), $scelte['finitura_esterna'] ?? null, 'finitura_esterna', fn ($f) => $f->predefinita) : $interna; $tipo = match (true) { $interna->isNot($esterna) => 'bicolore', $interna->predefinita => 'bianco', default => 'uniforme', }; $voce = $opzioni->get('tipo_finitura', collect())->firstWhere('valore', $tipo); $supplementi[] = $this->supplemento('Tipo finitura', $voce, $base, $larghezza, $altezza, $tipo); $configurazione['tipo_finitura'] = $voce?->etichetta ?? ucfirst($tipo); $configurazione['finitura_interna'] = $interna->descrizione; if ($modello->haModulo('finitura_esterna')) { $configurazione['finitura_esterna'] = $esterna->descrizione; } $normalizzate['finitura_interna'] = $interna->codice; $normalizzate['finitura_esterna'] = $esterna->codice; } if ($modello->haModulo('maniglia')) { $maniglie = $modello->sistema->finiture()->where('tipo', 'maniglia')->attive()->get(); if ($maniglie->isNotEmpty()) { $maniglia = $this->scegli($maniglie, $scelte['maniglia'] ?? null, 'maniglia'); $configurazione['maniglia'] = $maniglia->descrizione; $normalizzate['maniglia'] = $maniglia->codice; } } $gruppi = GruppoOpzioni::perCodice(); foreach ($opzioni as $gruppo => $righe) { $definizione = $gruppi->get($gruppo); if (! $definizione || ! $definizione->haControllo() || ! $modello->haModulo($definizione->modulo)) { continue; } if ($definizione->ui === 'multipla') { $scelteGruppo = $this->scegliMultiple($righe, $scelte['opzioni'][$gruppo] ?? [], "opzioni.{$gruppo}"); foreach ($scelteGruppo as $scelta) { $supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza); } if ($scelteGruppo->isNotEmpty()) { $configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', '); } $normalizzate['opzioni'][$gruppo] = $scelteGruppo->pluck('valore')->all(); continue; } $scelta = $this->scegli($righe, $scelte['opzioni'][$gruppo] ?? null, "opzioni.{$gruppo}", null, 'valore'); $supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza); $configurazione[$gruppo] = $scelta->etichetta; $normalizzate['opzioni'][$gruppo] = $scelta->valore; } $vetro = null; if ($modello->haModulo('vetro')) { $vetri = $modello->sistema->vetri()->attivi()->get(); if ($vetri->isNotEmpty()) { $scelto = $this->scegli($vetri, $scelte['vetro'] ?? null, 'vetro', fn ($v) => $v->predefinito); $vetro = $this->variazioneVetro($modello, $scelto, $larghezza, $altezza); $configurazione['vetro'] = $scelto->descrizione; $normalizzate['vetro'] = $scelto->codice; } } $supplementi = array_values(array_filter($supplementi)); $percentuale = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] === 'percentuale'), 'valore')); // Importi fissi e al metro lineare (già calcolati sulle misure). $fisso = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] !== 'percentuale'), 'importo')); $prezzo = $base * (1 + $percentuale / 100) + $fisso + ($vetro['importo'] ?? 0); return [ 'prezzo' => round($prezzo, 2), 'scelte' => $normalizzate, 'configurazione' => $configurazione, 'dettaglio' => [ 'prezzo_base' => $base, 'misura_listino' => $misuraListino, 'supplementi' => $supplementi, 'percentuale_totale' => $percentuale, 'fisso_totale' => $fisso, 'vetro' => $vetro, ], ]; } /** @return array{0:float,1:array{larghezza:int,altezza:int}} */ private function prezzoBase(Modello $modello, int $larghezza, int $altezza): array { $griglia = $modello->listino()->get(['larghezza', 'altezza', 'prezzo']); if ($griglia->isEmpty()) { throw ValidationException::withMessages(['larghezza' => 'Nessun listino caricato per questo modello.']); } $l = $this->misuraListino($griglia->pluck('larghezza'), $larghezza); $h = $this->misuraListino($griglia->pluck('altezza'), $altezza); $cella = ($l !== null && $h !== null) ? $griglia->first(fn ($p) => $p->larghezza === $l && $p->altezza === $h) : null; if (! $cella) { throw ValidationException::withMessages(['larghezza' => 'Misura non disponibile a listino.']); } return [(float) $cella->prezzo, ['larghezza' => $l, 'altezza' => $h]]; } /** Prima misura del listino >= a quella richiesta; null se fuori dal min/max del listino. */ private function misuraListino(Collection $misure, int $valore): ?int { $misure = $misure->unique()->sort()->values(); if ($valore <= 0 || $valore < $misure->first()) { return null; } return $misure->first(fn ($m) => $m >= $valore); } /** * Elemento scelto per codice (o valore); senza scelta il predefinito, altrimenti il primo. * * @template T of \Illuminate\Database\Eloquent\Model * * @param Collection $elementi * @return T */ private function scegli(Collection $elementi, mixed $codice, string $campo, ?callable $predefinito = null, string $chiave = 'codice') { if ($codice === null || $codice === '') { $scelto = ($predefinito ? $elementi->first($predefinito) : null) ?? $elementi->first(); } else { $scelto = $elementi->first(fn ($e) => (string) $e->{$chiave} === (string) $codice); } if (! $scelto) { throw ValidationException::withMessages([$campo => 'Scelta non disponibile per questo modello.']); } return $scelto; } /** * Opzioni scelte in un gruppo a scelta multipla (anche nessuna); quelle con la stessa * `alternativa` si escludono a vicenda. * * @return Collection */ private function scegliMultiple(Collection $righe, mixed $valori, string $campo): Collection { $valori = collect(is_array($valori) ? $valori : [$valori])->filter(fn ($v) => is_scalar($v) && $v !== '')->map(fn ($v) => (string) $v)->unique(); $scelte = $valori->map(fn (string $v) => $this->scegli($righe, $v, $campo, null, 'valore')); $doppie = $scelte->whereNotNull('alternativa')->groupBy('alternativa')->filter(fn ($g) => $g->count() > 1); if ($doppie->isNotEmpty()) { throw ValidationException::withMessages([$campo => 'Si può scegliere una sola opzione tra: '.$doppie->first()->pluck('etichetta')->implode(', ').'.']); } // Nell'ordine del gruppo, non in quello di invio. return $righe->filter(fn (Opzione $o) => $scelte->contains(fn (Opzione $s) => $s->is($o)))->values(); } private function supplemento(string $voce, ?Opzione $opzione, float $base, int $larghezza, int $altezza, ?string $valore = null): ?array { if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) { return null; } return [ 'voce' => "{$voce}: {$opzione->etichetta}", 'valore_scelto' => $valore ?? $opzione->valore, 'tipo' => $opzione->supplemento_tipo, 'valore' => $opzione->supplemento, 'metri_lineari' => $opzione->supplemento_tipo === 'metro_lineare' ? round($opzione->metriLineari($larghezza, $altezza), 3) : null, 'importo' => $opzione->importo($base, $larghezza, $altezza), ]; } private function variazioneVetro(Modello $modello, Vetro $vetro, int $larghezza, int $altezza): array { $regola = $modello->regolaVetro; $lastre = []; $mqFatturabili = 0.0; foreach ($regola?->calcolaLastre($larghezza, $altezza) ?? [] as $lastra) { $mq = max(0, $lastra['larghezza']) * max(0, $lastra['altezza']) / 1_000_000; // Il minimo fatturabile vale per singola lastra. $fatturabili = max($mq, $vetro->minimo_mq); $mqFatturabili += $fatturabili * $lastra['quantita']; $lastre[] = $lastra + ['mq' => round($mq, 3), 'mq_fatturabili' => round($fatturabili, 3)]; } return [ 'codice' => $vetro->codice, 'descrizione' => $vetro->descrizione, 'regola' => $regola?->codice, 'maggiorazione_mq' => $vetro->maggiorazione_mq, 'minimo_mq' => $vetro->minimo_mq, 'lastre' => $lastre, 'mq_fatturabili' => round($mqFatturabili, 3), 'importo' => $mqFatturabili * $vetro->maggiorazione_mq, ]; } }