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,31 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Facades\Storage;
/**
* Immagini del configuratore salvate sul disco public: rimuove i file sostituiti o del
* record eliminato. Ogni record ha percorsi propri, quindi nessun file è condiviso.
*
* Il modello dichiara le colonne con `protected static array $immagini = [...]`.
*/
trait HaImmaginiCatalogo
{
protected static function bootHaImmaginiCatalogo(): void
{
static::updated(function ($record): void {
foreach (static::$immagini as $colonna) {
$vecchia = $record->getOriginal($colonna);
if ($record->wasChanged($colonna) && $vecchia) {
Storage::disk('public')->delete($vecchia);
}
}
});
static::deleted(function ($record): void {
$file = array_filter(array_map(fn ($c) => $record->{$c}, static::$immagini));
Storage::disk('public')->delete($file);
});
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Models;
use App\Models\Concerns\HaImmaginiCatalogo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class Finitura extends Model
{
use HaImmaginiCatalogo;
protected $table = 'finiture';
public const TIPI = [
'profilo' => 'Finitura profilo',
'maniglia' => 'Maniglia e cerniere',
];
/** Lati del serramento su cui la finitura del profilo è disponibile. */
public const LATI = [
'entrambi' => 'Interno ed esterno',
'interno' => 'Solo interno',
'esterno' => 'Solo esterno',
];
/** Scelta per modello: oltre ai lati, "nessuno" la disattiva nel modello. */
public const LATI_MODELLO = self::LATI + ['nessuno' => 'Disattiva nel modello'];
protected static array $immagini = ['anteprima', 'texture', 'texture_bump'];
protected $fillable = [
'sistema_id', 'tipo', 'lato', 'codice', 'descrizione', 'codice_mazzetta', 'colore', 'colore_corpo_base',
'roughness', 'metalness', 'anteprima', 'texture', 'texture_bump', 'bump_scala',
'predefinita', 'ordine', 'attivo',
];
protected $casts = [
'roughness' => 'float',
'metalness' => 'float',
'bump_scala' => 'float',
'predefinita' => 'boolean',
'ordine' => 'integer',
'attivo' => 'boolean',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function scopeAttive(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
}
/**
* Finiture del profilo disponibili nel modello, ciascuna con `lato_modello`
* (entrambi | interno | esterno). La scelta del modello, se c'è, vince su attivazione
* e lato del sistema: può anche attivare una finitura disattiva nel sistema.
*
* @return Collection<int,Finitura>
*/
public static function delProfiloPerModello(Modello $modello): Collection
{
$scelte = DB::table('finiture_modelli')->where('modello_id', $modello->getKey())->pluck('lato', 'finitura_id');
return static::query()
->where('sistema_id', $modello->sistema_id)
->where('tipo', 'profilo')
->orderBy('ordine')
->orderBy('id')
->get()
->each(fn (Finitura $f) => $f->lato_modello = $scelte[$f->id] ?? ($f->attivo ? $f->lato : 'nessuno'))
->reject(fn (Finitura $f) => $f->lato_modello === 'nessuno')
->values();
}
public function disponibileSu(string $lato): bool
{
return in_array($this->lato_modello ?? $this->lato, ['entrambi', $lato], true);
}
protected static function booted(): void
{
// Un solo elemento predefinito per sistema e tipo.
static::saved(function (self $record): void {
if ($record->predefinita && ($record->wasRecentlyCreated || $record->wasChanged('predefinita'))) {
static::query()
->where('sistema_id', $record->sistema_id)->where('tipo', $record->tipo)
->whereKeyNot($record->getKey())
->update(['predefinita' => false]);
}
});
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Gruppo di opzioni del configuratore (es. telaio, profili extra, motore): come si
* presenta nel menu e quale modulo del modello lo rende visibile.
*/
class GruppoOpzioni extends Model
{
protected $table = 'gruppi_opzioni';
public const UI = [
'radio' => 'Pulsanti con icona, una scelta',
'multipla' => 'Pulsanti con icona, più scelte (anche nessuna)',
'select' => 'Menu a tendina',
'nessuna' => 'Nessuno: solo prezzo (es. tipo finitura)',
];
public const SEZIONI = [
'sistema' => 'Sistema (telaio, apertura...)',
'accessori' => 'Accessori (maniglia, cerniere...)',
];
protected $fillable = ['codice', 'etichetta', 'ui', 'sezione', 'modulo', 'ordine'];
protected $casts = ['ordine' => 'integer'];
public function opzioni(): HasMany
{
return $this->hasMany(Opzione::class, 'gruppo', 'codice');
}
/** @return Collection<string,GruppoOpzioni> per codice, nell'ordine del menu */
public static function perCodice(): Collection
{
return static::query()->orderBy('ordine')->orderBy('id')->get()->keyBy('codice');
}
/** @return array<string,string> codice => etichetta */
public static function etichette(): array
{
return static::query()->orderBy('ordine')->orderBy('id')->pluck('etichetta', 'codice')->all();
}
public function haControllo(): bool
{
return $this->ui !== 'nessuna';
}
}
+74 -5
View File
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class Modello extends Model
@@ -21,23 +22,23 @@ class Modello extends Model
'accessori' => 'Accessori',
];
// Moduli dati caricati dal configuratore per il modello.
// Moduli base del configuratore (sezioni del menu e voci di prezzo); quelli dei gruppi
// di opzioni si aggiungono da GruppoOpzioni (vedi moduliDisponibili).
public const MODULI = [
'listino' => 'Listino',
'modello' => 'Finiture',
'finitura_esterna' => 'Finitura esterna (bicolore)',
'maniglia' => 'Maniglia',
'altezza_maniglia' => 'Altezza maniglia',
'vetro' => 'Vetro',
'cerniere' => 'Cerniere',
'telaio' => 'Telaio',
'avvolgibile' => 'Avvolgibile',
'select_finiture' => 'Select finiture',
];
protected $fillable = [
'sistema_id', 'codice', 'nome', 'descrizione', 'icona', 'ordine', 'attivo',
'sistema_id', 'codice', 'nome', 'descrizione', 'titolo', 'icona', 'ordine', 'attivo',
'cartella', 'file_modello', 'categoria', 'cartella_texture', 'moduli',
'listino_aggiornato_at',
'regola_vetro_id', 'listino_aggiornato_at',
];
protected $casts = [
@@ -57,18 +58,86 @@ class Modello extends Model
return $this->hasMany(ListinoPrezzo::class);
}
public function regolaVetro(): BelongsTo
{
return $this->belongsTo(RegolaVetro::class);
}
/** Opzioni personalizzate del modello (sostituiscono i gruppi del sistema). */
public function opzioni(): HasMany
{
return $this->hasMany(Opzione::class);
}
/** Tutte le finiture del profilo del sistema (per scegliere quali usare nel modello). */
public function finitureDelSistema(): HasMany
{
return $this->hasMany(Finitura::class, 'sistema_id', 'sistema_id')->where('tipo', 'profilo');
}
/** Moduli selezionabili: quelli base più uno per ogni gruppo di opzioni con un controllo. */
public static function moduliDisponibili(): array
{
$gruppi = GruppoOpzioni::query()
->where('ui', '!=', 'nessuna')
->orderBy('ordine')
->pluck('etichetta', 'modulo')
->all();
return self::MODULI + $gruppi;
}
public function haModulo(string $modulo): bool
{
return in_array($modulo, $this->moduli ?? [], true);
}
public function scopeAttivi(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
}
/** Modelli mostrati nel configuratore: attivi e con un listino (senza prezzi non si possono vendere). */
public function scopeNelConfiguratore(Builder $query): Builder
{
return $query->attivi()->whereHas('listino');
}
public function getIconaUrlAttribute(): ?string
{
return $this->icona ? Storage::disk('public')->url($this->icona) : null;
}
/**
* Rinumera l'ordine dei modelli del sistema mettendo i disattivi in fondo,
* senza cambiare l'ordine relativo all'interno dei due gruppi.
*/
public static function normalizzaOrdine(int $sistemaId): void
{
$ids = static::query()
->where('sistema_id', $sistemaId)
->orderByDesc('attivo')
->orderBy('ordine')
->orderBy('id')
->pluck('id');
DB::transaction(function () use ($ids) {
foreach ($ids as $i => $id) {
// Query diretta: niente eventi del modello (e niente ricorsione).
static::query()->whereKey($id)->update(['ordine' => $i + 1]);
}
});
}
protected static function booted(): void
{
// Attivando o disattivando un modello (o creandone uno), i disattivi tornano in fondo.
static::saved(function (Modello $modello): void {
if ($modello->wasRecentlyCreated || $modello->wasChanged(['attivo', 'sistema_id'])) {
static::normalizzaOrdine($modello->sistema_id);
}
});
// Rimuove il file dell'icona sostituita o del modello eliminato.
static::updated(function (Modello $modello): void {
$vecchia = $modello->getOriginal('icona');
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Models;
use App\Models\Concerns\HaImmaginiCatalogo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* Opzione del configuratore con eventuale supplemento di prezzo. Le righe senza modello
* valgono per tutto il sistema; se un modello ha righe proprie per un gruppo, per quel
* modello sostituiscono l'intero gruppo del sistema.
*/
class Opzione extends Model
{
use HaImmaginiCatalogo;
protected $table = 'opzioni';
/** Valori del gruppo tipo_finitura, derivati dalle finiture scelte (vedi CalcolatorePrezzo). */
public const TIPI_FINITURA = [
'bianco' => 'Bianco (finitura predefinita su interno ed esterno)',
'uniforme' => 'Uniforme (stessa finitura su interno ed esterno)',
'bicolore' => 'Bicolore (finiture diverse)',
];
public const SUPPLEMENTI = [
'percentuale' => 'Percentuale sul prezzo base',
'fisso' => 'Importo fisso (€)',
'metro_lineare' => 'Al metro lineare (€/ml)',
];
protected static array $immagini = ['icona'];
protected $fillable = [
'sistema_id', 'modello_id', 'gruppo', 'valore', 'etichetta', 'icona', 'alternativa',
'supplemento_tipo', 'supplemento', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm',
'parametri', 'ordine', 'attivo',
];
protected $casts = [
'supplemento' => 'float',
'ml_per_larghezza' => 'float',
'ml_per_altezza' => 'float',
'ml_aggiunta_mm' => 'float',
'parametri' => 'array',
'ordine' => 'integer',
'attivo' => 'boolean',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function modello(): BelongsTo
{
return $this->belongsTo(Modello::class);
}
public function definizione(): BelongsTo
{
return $this->belongsTo(GruppoOpzioni::class, 'gruppo', 'codice');
}
public function scopeDelSistema(Builder $query): Builder
{
return $query->whereNull('modello_id');
}
/**
* Opzioni attive valide per il modello, per gruppo: quelle del modello dove ne ha,
* altrimenti quelle del sistema.
*
* @return Collection<string,Collection<int,Opzione>>
*/
public static function perModello(Modello $modello): Collection
{
$righe = static::query()
->where('sistema_id', $modello->sistema_id)
->where(fn (Builder $q) => $q->whereNull('modello_id')->orWhere('modello_id', $modello->getKey()))
->orderBy('ordine')
->orderBy('id')
->get();
// Basta una riga del modello (anche disattiva) per sostituire il gruppo.
$personalizzati = $righe->whereNotNull('modello_id')->pluck('gruppo')->unique();
return $righe
->filter(fn (Opzione $o) => $o->attivo && ($o->modello_id !== null) === $personalizzati->contains($o->gruppo))
->groupBy('gruppo');
}
public function descriviSupplemento(): string
{
if (! $this->supplemento_tipo || ! $this->supplemento) {
return '—';
}
$valore = number_format($this->supplemento, 2, ',', '.');
return match ($this->supplemento_tipo) {
'percentuale' => "{$valore}%",
'metro_lineare' => "€ {$valore}/ml × ".$this->formulaMetri(),
default => "€ {$valore}",
};
}
/** Metri lineari per un serramento L x H (mm), senza arrotondamenti. */
public function metriLineari(float $larghezza, float $altezza): float
{
return max(0, $larghezza * $this->ml_per_larghezza + $altezza * $this->ml_per_altezza + $this->ml_aggiunta_mm) / 1000;
}
/** Importo del supplemento sul prezzo base, per un serramento L x H (mm). */
public function importo(float $base, float $larghezza, float $altezza): float
{
return match ($this->supplemento_tipo) {
'percentuale' => $base * $this->supplemento / 100,
'metro_lineare' => $this->supplemento * $this->metriLineari($larghezza, $altezza),
'fisso' => $this->supplemento,
default => 0.0,
};
}
/** Es. "(H x 2 + 200 mm)". */
public function formulaMetri(): string
{
$termini = array_filter([
$this->ml_per_larghezza ? 'L'.($this->ml_per_larghezza != 1 ? ' × '.(float) $this->ml_per_larghezza : '') : null,
$this->ml_per_altezza ? 'H'.($this->ml_per_altezza != 1 ? ' × '.(float) $this->ml_per_altezza : '') : null,
$this->ml_aggiunta_mm ? (float) $this->ml_aggiunta_mm.' mm' : null,
]);
return '('.(implode(' + ', $termini) ?: '0').')';
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Geometria delle lastre di una tipologia: per ogni riga
* LV = (L - detrazione_larghezza) / divisore, HV = H - detrazione_altezza, ripetuta `quantita` volte.
* Es. F2: 2 x (L - 312) / 2; traslante anta + fisso: (L - 162) / 2 e (L - 362) / 2, cioè L/2 - 81 e L/2 - 181.
*/
class RegolaVetro extends Model
{
protected $table = 'regole_vetro';
protected $fillable = [
'sistema_id', 'codice', 'descrizione', 'lastre', 'note',
];
protected $casts = [
'lastre' => 'array',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function modelli(): HasMany
{
return $this->hasMany(Modello::class);
}
/**
* Misure in mm delle lastre per un serramento L x H, senza arrotondamenti.
*
* @return array<int,array{descrizione:?string,quantita:int,larghezza:float,altezza:float}>
*/
public function calcolaLastre(float $larghezza, float $altezza): array
{
return array_map(fn (array $lastra) => [
'descrizione' => $lastra['descrizione'] ?? null,
'quantita' => max(1, (int) ($lastra['quantita'] ?? 1)),
'larghezza' => ($larghezza - (float) ($lastra['detrazione_larghezza'] ?? 0)) / max(1, (float) ($lastra['divisore'] ?? 1)),
'altezza' => $altezza - (float) ($lastra['detrazione_altezza'] ?? 0),
], $this->lastre ?? []);
}
}
+21
View File
@@ -24,6 +24,27 @@ class Sistema extends Model
return $this->hasMany(Modello::class);
}
public function finiture(): HasMany
{
return $this->hasMany(Finitura::class);
}
public function vetri(): HasMany
{
return $this->hasMany(Vetro::class);
}
public function regoleVetro(): HasMany
{
return $this->hasMany(RegolaVetro::class);
}
/** Opzioni del sistema (senza quelle personalizzate per modello). */
public function opzioni(): HasMany
{
return $this->hasMany(Opzione::class)->whereNull('modello_id');
}
public function scopeAttivi(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Models\Concerns\HaImmaginiCatalogo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Vetro extends Model
{
use HaImmaginiCatalogo;
protected $table = 'vetri';
protected static array $immagini = ['anteprima', 'popup', 'texture', 'texture_bump'];
protected $fillable = [
'sistema_id', 'codice', 'descrizione', 'maggiorazione_mq', 'minimo_mq',
'colore', 'roughness', 'metalness', 'opacita', 'anteprima', 'popup', 'texture',
'texture_bump', 'bump_scala', 'predefinito', 'ordine', 'attivo',
];
protected $casts = [
'maggiorazione_mq' => 'float',
'minimo_mq' => 'float',
'roughness' => 'float',
'metalness' => 'float',
'opacita' => 'float',
'bump_scala' => 'float',
'predefinito' => 'boolean',
'ordine' => 'integer',
'attivo' => 'boolean',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function scopeAttivi(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
}
protected static function booted(): void
{
// Un solo elemento predefinito per sistema.
static::saved(function (self $record): void {
if ($record->predefinito && ($record->wasRecentlyCreated || $record->wasChanged('predefinito'))) {
static::query()
->where('sistema_id', $record->sistema_id)
->whereKeyNot($record->getKey())
->update(['predefinito' => false]);
}
});
}
}