feat: add previous configuration handling for user selections in quotes

- Implemented session storage management for the last configuration choices made by the user.
- Added functionality to prompt users when changing models to retain their previous selections.
- Introduced methods to apply previous choices to the newly loaded model, including finishes, handles, glass types, and options.
- Enhanced user experience by ensuring selections are automatically applied where applicable.
This commit is contained in:
2026-10-19 19:49:44 +02:00
parent 863e6ca920
commit 06617a985a
197 changed files with 4892 additions and 400 deletions
@@ -0,0 +1,260 @@
<?php
namespace App\Support\Configuratore;
use App\Models\Finitura;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use App\Models\Opzione;
use App\Models\Vetro;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
/**
* Prezzo di una configurazione, calcolato solo lato server dalle regole del backoffice:
*
* prezzo = base x (1 + supplementi % / 100) + supplementi fissi + variazione vetro
*
* - base: prima misura del listino >= 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<string,mixed>} $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<int,T> $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<int,Opzione>
*/
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,
];
}
}