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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user