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,
];
}
}
@@ -0,0 +1,148 @@
<?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;
/**
* Dati del configuratore per un modello: struttura del menu, finiture, vetri, opzioni e
* misure del listino, con le immagini servite dal backoffice. Sostituisce i vecchi
* form.json / modello.json / maniglia.json / vetro.json e la cartella immagini.
*
* Non espone supplementi e maggiorazioni: il prezzo lo calcola CalcolatorePrezzo.
*/
class ConfigurazioneModello
{
public function perModello(Modello $modello): array
{
$sistema = $modello->sistema;
// Finiture del profilo attive nel modello, divise per lato (interno / esterno).
$finiture = $modello->haModulo('modello') ? Finitura::delProfiloPerModello($modello) : collect();
$interne = $finiture->filter(fn (Finitura $f) => $f->disponibileSu('interno'))->map($this->finitura(...))->values();
$esterne = $modello->haModulo('finitura_esterna')
? $finiture->filter(fn (Finitura $f) => $f->disponibileSu('esterno'))->map($this->finitura(...))->values()
: collect();
$maniglie = $modello->haModulo('maniglia')
? $sistema->finiture()->where('tipo', 'maniglia')->attive()->get()->map($this->finitura(...))->values()
: collect();
$vetri = $modello->haModulo('vetro')
? $sistema->vetri()->attivi()->get()->map($this->vetro(...))->values()
: collect();
return [
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
'form' => $this->form($modello, $interne->isNotEmpty(), $esterne->isNotEmpty(), $maniglie->isNotEmpty(), $vetri->isNotEmpty()),
'finiture' => $finiture->map($this->finitura(...))->values(),
'finiture_interne' => $interne,
'finiture_esterne' => $esterne,
'maniglie' => $maniglie,
'vetri' => $vetri,
'misure' => $modello->listino()
->orderBy('altezza')
->orderBy('larghezza')
->get(['altezza', 'larghezza', 'prezzo'])
->map(fn ($p) => ['altezza' => $p->altezza, 'larghezza' => $p->larghezza, 'prezzo' => $p->prezzo])
->values(),
];
}
/** Stessa struttura del vecchio form.json (radios, select, finiture) letta dal MenuBuilder. */
private function form(Modello $modello, bool $interne, bool $esterne, bool $maniglie, bool $vetri): array
{
$radios = [];
$select = [];
$multiple = [];
$gruppi = [];
$definizioni = GruppoOpzioni::perCodice();
$opzioni = Opzione::perModello($modello);
// Nell'ordine dei gruppi: il configuratore li presenta così.
foreach ($definizioni as $gruppo => $definizione) {
$righe = $opzioni->get($gruppo);
if (! $righe || ! $definizione->haControllo() || ! $modello->haModulo($definizione->modulo)) {
continue;
}
$gruppi[$gruppo] = ['etichetta' => $definizione->etichetta, 'ui' => $definizione->ui, 'sezione' => $definizione->sezione];
$voci = $righe->map(fn (Opzione $o) => ($o->parametri ?? []) + [
'valore' => $o->valore,
'label' => $o->etichetta,
'icon' => Media::url($o->icona),
'alternativa' => $o->alternativa,
])->values();
match ($definizione->ui) {
'radio' => $radios[$gruppo] = $voci,
'multipla' => $multiple[$gruppo] = $voci,
default => $select[$gruppo] = $voci,
};
}
$menu = [];
if ($interne) {
$menu[] = ['nome' => 'finitura_interna', 'categorie' => ['interno'], 'data' => 'data_finiture_interne'];
}
if ($esterne) {
$menu[] = ['nome' => 'finitura_esterna', 'categorie' => ['esterno'], 'data' => 'data_finiture_esterne'];
}
if ($maniglie) {
$menu[] = ['nome' => 'maniglia', 'categorie' => ['maniglia', 'cerniere'], 'data' => 'data_maniglie'];
}
if ($vetri) {
$menu[] = ['nome' => 'vetro', 'categorie' => ['vetro'], 'data' => 'data_vetri'];
}
return [
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
'radios' => (object) $radios,
'select' => (object) $select,
// Pulsanti selezionabili insieme (es. profili extra), anche nessuno.
'multiple' => (object) $multiple,
// Titolo, controllo e sezione del menu dei gruppi sopra, nell'ordine del backoffice.
'gruppi' => (object) $gruppi,
'finiture' => $menu,
];
}
/** Campi con i nomi letti da APP.Player.changeTexture/changeColor. */
private function finitura(Finitura $f): array
{
return [
'id' => $f->codice,
'descrizione' => $f->descrizione,
'anteprima' => Media::url($f->anteprima),
'texture_map' => Media::url($f->texture),
'texture_bump' => Media::url($f->texture_bump),
'bump_valore' => $f->bump_scala,
'color_valore' => $f->colore,
'color_valore_corpo_base' => $f->colore_corpo_base,
'roughness_valore' => $f->roughness,
'metalness_valore' => $f->metalness,
'predefinito' => $f->predefinita,
];
}
private function vetro(Vetro $v): array
{
return [
'id' => $v->codice,
'descrizione' => $v->descrizione,
'anteprima' => Media::url($v->anteprima),
'pop_up' => Media::url($v->popup),
'texture_map' => Media::url($v->texture),
'texture_bump' => Media::url($v->texture_bump),
'bump_valore' => $v->bump_scala,
'color_valore' => $v->colore,
'roughness_valore' => $v->roughness,
'metalness_valore' => $v->metalness,
'opacity_valore' => $v->opacita,
'predefinito' => $v->predefinito,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Support\Configuratore;
/**
* URL pubblici delle immagini del configuratore. Passano dalla rotta API `media` (e non
* da /storage) perché le texture caricate da Three.js richiedono gli header CORS.
*/
class Media
{
/** Cartelle del disco public servite dalla rotta media. */
public const CARTELLE = ['configuratore/', 'modelli/'];
public static function url(?string $percorso): ?string
{
return $percorso ? route('media', ['percorso' => $percorso]) : null;
}
}
@@ -27,17 +27,21 @@ class ConfigurazioneFormatter
'tipo_ispezione' => 'Tipo ispezione',
'avvolgibile' => 'Avvolgibile',
'cassonetto' => 'Cassonetto',
'apertura' => 'Verso di apertura',
'telaio' => 'Telaio',
'cerniere_a_scomparsa' => 'Cerniere a scomparsa',
'profili_extra' => 'Profili extra',
];
/** Ordine di visualizzazione preferito. */
private const ORDER = [
'width', 'height', 'tipo_finitura', 'finitura_interna', 'finitura_esterna',
'maniglia', 'altezza_maniglia', 'vetro', 'cerniere_scomparsa', 'azionamento',
'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
'maniglia', 'altezza_maniglia', 'vetro', 'apertura', 'profili_extra', 'telaio', 'cerniere_scomparsa',
'cerniere_a_scomparsa', 'azionamento', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
];
/** Chiavi tecniche da non mostrare nella lista (usate altrove, es. titolo). */
private const SKIP = ['nome'];
private const SKIP = ['nome', 'scelte', 'dettaglio_prezzo'];
/**
* @param array<string,mixed>|null $configurazione