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
+2
View File
@@ -5,3 +5,5 @@
/node_modules/
kreios.zip
.env.production
Thumbs.db
/reference/icone_profili_extra
+29 -16
View File
@@ -41,16 +41,21 @@ Entry point is `src/js/starter.js`, loaded from `src/index.html`. Flow on load (
*Note: that doc describes a more modular refactor (`Application.js`, `communication/UrlParamsParser.js`,
`communication/IframeBridge.js`) that does not exist yet in `src/js` — the current implementation is the
flatter `starter.js`/`params_parser.js` described here.*
2. Product/catalog data is fetched as JSON from `src/assets/data/sistemi/<sistema>/modelli.json` and
`src/assets/data/modelli/<sistema>/<cartella>/{form,modello,maniglia,avvolgibile,vetro,listino}.json`
(which JSON files are fetched depends on the `moduli` array in the matched `modelli.json` entry).
2. Catalog data comes from the Laravel backoffice (`backoffice/`, API base in `src/js/api_base.js`):
`GET /api/sistemi` (systems + active models that have a price list; models without one are hidden and flagged in the admin list) and
`GET /api/sistemi/<sistema>/modelli/<modello>/configurazione`, which returns the menu wiring (`form`: radios,
selects, finish menus — the old `form.json` shape), finishes, handles, glass types and the price-grid sizes,
with image URLs served by `GET /api/media/...` (CORS-enabled, needed for WebGL textures). Only
`avvolgibile.json` and `sistemi/<sistema>/altezza_maniglia_<categoria>.json` are still read from `src/assets/data`.
3. **`MenuBuilder.build()`** (`src/js/menu_builder.js`) injects an HTML template (`assets/_menu.html`, or
`assets/_menu-cassonetto.html` when `type=cassonetto`) into `#left-container`, builds the model-picker
list, handles the mobile-portrait/landscape layout reflow, and initializes `PriceCalculator`. It returns
the price calculator instance used by the rest of the app.
4. **`Configurator.init()` / `.enable()`** (`src/js/configurator.js`) wires a single delegated `input` handler
on the size fields, selects and radios. On every change it recomputes price via `PriceCalculator`, builds
`window.configurazione` (the object that gets posted to the parent on "add to cart"), and updates the DOM.
on the size fields, selects and radios. On every change (debounced) it sends the selected codes to
`POST /api/sistemi/<sistema>/modelli/<modello>/prezzo`, stores the result in `window.configurazione` and
updates the DOM. "Add to cart" sends the same codes (`scelte`): the backend recomputes the price and ignores
any price coming from the client.
5. **`APP.Player`** (`src/js/app.js`) wraps a Three.js renderer/scene/camera (scene graph loaded from
`src/assets/app.json`, a three.js-editor-style export) plus the actual product GLB
(`src/assets/models/<sistema>/<modello.modello>`). Two players are created: `player` (visible canvas) and
@@ -59,22 +64,30 @@ Entry point is `src/js/starter.js`, loaded from `src/index.html`. Flow on load (
per `userData.categoria` mesh tag — categories like `interno`, `esterno`, `maniglia`, `vetro`, `cerniere`),
`flipView` (180° GSAP-animated turn between front/back view), `addMarker` (in-scene hotspot with a video
popup).
6. **`PriceCalculator`** (`src/js/price_calculator.js`) is a stateless-ish module (single module-level
`listini` cache set via `init`) that looks up a base price from a width/height grid in **mm** (served by
the backoffice API, using the first grid size ≥ the requested one) and layers on percentage/flat supplements for finish, glass, hinges, handle
height, and actuator type, then adds 22% VAT.
6. **Pricing is server-side only**: `backoffice/app/Support/Configuratore/CalcolatorePrezzo.php`.
`prezzo = base × (1 + supplementi % / 100) + supplementi fissi + variazione vetro` (VAT excluded). Base is
the first grid size ≥ the requested one (mm). Supplements come from `opzioni` rows (finish type
bianco/uniforme/bicolore, hinges, profili extra, ...): percentage, fixed, or per linear metre
(ml from L/H multipliers + extra mm). Groups with `ui = multipla` (profili extra) accept several codes,
with same-`alternativa` options mutually exclusive. Sistema-level rows can be overridden per model by group.
Option groups themselves live in `gruppi_opzioni` (title, control radio/select/multipla, menu section, and
the model `moduli` key that enables them): a group without fixed markup in `_menu.html` is rendered by
`MenuBuilder` into `.opzioni-sistema` / `.opzioni-accessori`. Select options can call a `window[func]`
(e.g. `nascondiCategoria` with `categoria: maniglia` for the motor) via their `parametri`. The glass
variation is Σ max(pane area, minimo_mq) × maggiorazione_mq, with panes from the model's `regole_vetro`
(spec: "Specifica tecnica vetri"). All of it is edited in Filament under "Configuratore".
`src/js/price_calculator.js` only wraps the API call and the grid sizes.
### Data conventions
- Every 3D-visible part is tagged with `userData.categoria` in the GLB/scene so JS can show/hide/retexture it
without knowing mesh names (`interno`, `esterno`, `maniglia`, `cerniere`, `vetro`, ...).
- Each model folder under `src/assets/data/modelli/<sistema>/<cartella>/` follows the same file set:
`form.json` (UI radios/selects/finiture wiring — declares which named `<select>`/radio groups exist and
which categories/texture folders they map to), `modello.json` (finish options), `maniglia.json` (handle
options), `avvolgibile.json` (shutter, cassonetto-only), `vetro.json` (glass options), `listino.json`
(price grid).
- Texture images live under `src/assets/data/immagini/<sistema>/<folder>/<texture_folder>/<id>/...` and are
loaded lazily per selection (`APP.Player.changeTexture`); a monotonically-checked `isStale()` guard discards
- Which menu sections a model shows (and which items enter the price) is driven by its `moduli` in the
backoffice (`Modello::MODULI`, e.g. `finitura_esterna`, `apertura`, `telaio`, `vetro`). The per-model JSON
files under `src/assets/data/modelli/` are legacy and no longer read (except `avvolgibile.json`).
- Texture and icon images live in the backoffice (`storage/app/public/configuratore/...`, seeded once by
`ConfiguratoreSeeder` from `backoffice/database/seeders/data/configuratore/`) and are loaded lazily per
selection (`APP.Player.changeTexture`) from absolute URLs; a monotonically-checked `isStale()` guard discards
a texture load if the user changed the selection again before it finished.
- `window.*` globals (`window.configurazione`, `window.cambiaTexture`, `window.caricaModello`,
`window.rotate`, `window.sistema`, etc.) are the integration surface between the jQuery-driven menu markup
@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\FinituraResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\Finitura;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rules\Unique;
class FinituraResource extends Resource
{
protected static ?string $model = Finitura::class;
protected static ?string $navigationIcon = 'heroicon-o-swatch';
protected static ?string $slug = 'finiture';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Finiture e maniglie';
protected static ?string $modelLabel = 'finitura';
protected static ?string $pluralModelLabel = 'finiture';
protected static ?int $navigationSort = 20;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
CampiConfiguratore::sistema(),
Forms\Components\Select::make('tipo')
->label('Tipo')
->options(Finitura::TIPI)
->default('profilo')
->required()
->live(),
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Identificativo inviato dal configuratore (es. 2, M1).')
->required()
->maxLength(50)
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule
->where('sistema_id', $get('sistema_id'))
->where('tipo', $get('tipo')),
),
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->required()->maxLength(255),
Forms\Components\TextInput::make('codice_mazzetta')->label('Codice mazzetta')->maxLength(50),
CampiConfiguratore::immagine('anteprima', 'Anteprima nel menu', 'finiture/anteprime'),
Forms\Components\Select::make('lato')
->label('Lato')
->helperText('Su quale lato del serramento si può scegliere. Si può cambiare per singolo modello dalla scheda del modello.')
->options(Finitura::LATI)
->default('entrambi')
->required()
->visible(fn (Get $get) => $get('tipo') === 'profilo'),
Forms\Components\Toggle::make('predefinita')
->label('Predefinita')
->helperText('Selezionata all\'apertura. Per le finiture del profilo: la stessa finitura predefinita su interno ed esterno è il "bianco" senza supplemento.')
->inline(false),
Forms\Components\Toggle::make('attivo')
->label('Attiva nel sistema')
->helperText('Disattiva: non compare nei modelli, salvo quelli che la attivano nella propria scheda.')
->default(true)
->inline(false),
]),
CampiConfiguratore::aspetto3d('finiture'),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->defaultGroup('tipo')
->groups([
Tables\Grouping\Group::make('tipo')
->label('Tipo')
->getTitleFromRecordUsing(fn (Finitura $record) => Finitura::TIPI[$record->tipo] ?? $record->tipo),
])
->columns([
Tables\Columns\ImageColumn::make('anteprima')->label('Anteprima')->disk('public')->height(40),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable(),
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap(),
Tables\Columns\SelectColumn::make('lato')
->label('Lato')
->options(Finitura::LATI)
->selectablePlaceholder(false)
->disabled(fn (Finitura $record) => $record->tipo !== 'profilo'),
Tables\Columns\ColorColumn::make('colore')->label('Colore'),
Tables\Columns\IconColumn::make('texture')
->label('Texture')
->icon(fn (?string $state) => $state ? 'heroicon-o-photo' : null),
Tables\Columns\IconColumn::make('predefinita')->label('Predefinita')->boolean(),
Tables\Columns\ToggleColumn::make('attivo')->label('Attiva'),
])
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('tipo')->label('Tipo')->options(Finitura::TIPI),
Tables\Filters\SelectFilter::make('lato')->label('Lato')->options(Finitura::LATI),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('lato')
->label('Imposta lato')
->icon('heroicon-o-arrows-right-left')
->form([
Forms\Components\Select::make('lato')->label('Lato')->options(Finitura::LATI)->required(),
])
->action(fn (Collection $records, array $data) => $records
->where('tipo', 'profilo')
->each->update(['lato' => $data['lato']]))
->deselectRecordsAfterCompletion(),
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageFiniture::route('/'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\FinituraResource\Pages;
use App\Filament\Resources\FinituraResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageFiniture extends ManageRecords
{
protected static string $resource = FinituraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\GruppoOpzioniResource\Pages;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
/** Gruppi di opzioni del configuratore: un nuovo gruppo si crea qui, le sue voci in "Opzioni e supplementi". */
class GruppoOpzioniResource extends Resource
{
protected static ?string $model = GruppoOpzioni::class;
protected static ?string $navigationIcon = 'heroicon-o-queue-list';
protected static ?string $slug = 'gruppi-opzioni';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Gruppi di opzioni';
protected static ?string $modelLabel = 'gruppo di opzioni';
protected static ?string $pluralModelLabel = 'gruppi di opzioni';
protected static ?int $navigationSort = 24;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Chiave del gruppo nelle opzioni e nel preventivo (es. soglia_ribassata). Non modificarlo se il gruppo ha già opzioni.')
->required()
->alphaDash()
->maxLength(50)
->unique(ignoreRecord: true)
->live(onBlur: true)
->afterStateUpdated(fn (Forms\Set $set, Forms\Get $get, ?string $state) => blank($get('modulo')) ? $set('modulo', $state) : null),
Forms\Components\TextInput::make('etichetta')
->label('Titolo nel configuratore')
->required()
->maxLength(255),
Forms\Components\Select::make('ui')
->label('Controllo')
->options(GruppoOpzioni::UI)
->default('select')
->required(),
Forms\Components\Select::make('sezione')
->label('Sezione del menu')
->options(GruppoOpzioni::SEZIONI)
->default('accessori')
->required(),
Forms\Components\TextInput::make('modulo')
->label('Modulo')
->helperText('Il gruppo compare nei modelli che hanno questo modulo tra i "Moduli" (scheda del modello).')
->required()
->alphaDash()
->maxLength(50)
->notIn(array_keys(Modello::MODULI))
->validationMessages(['not_in' => 'È un modulo base: scegline uno dedicato al gruppo.']),
Forms\Components\TextInput::make('ordine')->label('Ordine')->numeric()->minValue(0)->default(0),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->columns([
Tables\Columns\TextColumn::make('etichetta')->label('Titolo')->description(fn (GruppoOpzioni $record) => $record->codice)->searchable(['etichetta', 'codice']),
Tables\Columns\TextColumn::make('ui')->label('Controllo')->formatStateUsing(fn (string $state) => GruppoOpzioni::UI[$state] ?? $state)->wrap(),
Tables\Columns\TextColumn::make('sezione')->label('Sezione')->badge()->formatStateUsing(fn (string $state) => ucfirst($state)),
Tables\Columns\TextColumn::make('modulo')->label('Modulo')->color('gray'),
Tables\Columns\TextColumn::make('opzioni_count')->label('Opzioni')->counts('opzioni'),
Tables\Columns\TextColumn::make('modelli')
->label('Modelli')
->state(fn (GruppoOpzioni $record) => Modello::whereJsonContains('moduli', $record->modulo)->count()),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()
->iconButton()
->disabled(fn (GruppoOpzioni $record) => $record->codice === 'tipo_finitura' || $record->opzioni()->exists())
->tooltip(fn (GruppoOpzioni $record) => $record->opzioni()->exists() ? 'Elimina prima le opzioni del gruppo' : null),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageGruppiOpzioni::route('/'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\GruppoOpzioniResource\Pages;
use App\Filament\Resources\GruppoOpzioniResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageGruppiOpzioni extends ManageRecords
{
protected static string $resource = GruppoOpzioniResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -3,7 +3,9 @@
namespace App\Filament\Resources;
use App\Filament\Resources\ModelloResource\Pages;
use App\Filament\Resources\ModelloResource\RelationManagers\FinitureRelationManager;
use App\Filament\Resources\ModelloResource\RelationManagers\ListinoRelationManager;
use App\Filament\Resources\ModelloResource\RelationManagers\OpzioniRelationManager;
use App\Models\Modello;
use Filament\Forms;
use Filament\Forms\Form;
@@ -11,6 +13,7 @@ use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rules\Unique;
class ModelloResource extends Resource
@@ -61,6 +64,11 @@ class ModelloResource extends Resource
Forms\Components\TextInput::make('descrizione')
->label('Descrizione')
->maxLength(255),
Forms\Components\TextInput::make('titolo')
->label('Titolo nel configuratore')
->helperText('Intestazione del menu e nome nel preventivo; se vuoto si usa la descrizione.')
->maxLength(255)
->columnSpanFull(),
Forms\Components\FileUpload::make('icona')
->label('Icona')
->image()
@@ -104,9 +112,20 @@ class ModelloResource extends Resource
->helperText('Se vuota si usa la cartella dati.'),
Forms\Components\CheckboxList::make('moduli')
->label('Moduli')
->options(Modello::MODULI)
->helperText('Sezioni del menu del configuratore e voci che entrano nel prezzo.')
->options(fn () => Modello::moduliDisponibili())
->columns(3)
->columnSpanFull(),
Forms\Components\Select::make('regola_vetro_id')
->label('Regola vetro')
->helperText('Geometria delle lastre per la variazione di prezzo del vetro. Vuota: nessuna variazione.')
->relationship(
'regolaVetro',
'descrizione',
modifyQueryUsing: fn (Builder $query, Get $get) => $query->where('sistema_id', $get('sistema_id'))->orderBy('codice'),
)
->getOptionLabelFromRecordUsing(fn ($record) => "{$record->codice} — {$record->descrizione}")
->preload(),
]),
]);
}
@@ -114,13 +133,24 @@ class ModelloResource extends Resource
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
// Ordine del catalogo (quello del configuratore), con i disattivi in fondo.
->defaultSort(fn (Builder $query) => $query->orderByDesc('attivo')->orderBy('ordine'))
->reorderable('ordine')
->reorderRecordsTriggerAction(fn (Tables\Actions\Action $action, bool $isReordering) => $action
->button()
->icon($isReordering ? 'heroicon-m-check' : 'heroicon-m-arrows-up-down')
->label($isReordering ? 'Fine riordino' : 'Riordina'))
->defaultGroup('sistema.nome')
->columns([
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(48),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->copyable(),
Tables\Columns\TextColumn::make('nome')->label('Nome')->searchable(),
// Nome a capo su due righe e codice sotto, nella stessa cella: evita lo
// scroll orizzontale (i codici, senza spazi, non possono andare a capo da soli).
Tables\Columns\TextColumn::make('nome')
->label('Nome / codice')
->description(fn (Modello $record) => $record->codice)
->searchable(['nome', 'codice'])
->wrap()
->extraAttributes(['style' => 'min-width: 11rem; max-width: 14rem; overflow-wrap: anywhere']),
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap()->placeholder('—'),
Tables\Columns\TextColumn::make('categoria')
->label('Categoria')
@@ -131,7 +161,11 @@ class ModelloResource extends Resource
->label('Listino')
->counts('listino')
->formatStateUsing(fn (int $state) => $state ? "{$state} prezzi" : 'Nessuno')
->color(fn (int $state) => $state ? null : 'gray')
// Senza listino il modello non compare nel configuratore.
->badge(fn (int $state) => ! $state)
->color(fn (int $state) => $state ? null : 'danger')
->icon(fn (int $state) => $state ? null : 'heroicon-m-exclamation-triangle')
->tooltip(fn (int $state) => $state ? null : 'Senza listino: non compare nel configuratore')
->description(fn (Modello $record) => $record->listino_aggiornato_at?->format('d/m/Y'))
->sortable(),
Tables\Columns\ToggleColumn::make('attivo')
@@ -142,10 +176,19 @@ class ModelloResource extends Resource
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('categoria')->label('Categoria')->options(Modello::CATEGORIE),
Tables\Filters\TernaryFilter::make('attivo')->label('Attivo'),
Tables\Filters\TernaryFilter::make('listino')
->label('Listino')
->trueLabel('Con listino')
->falseLabel('Senza listino (non nel configuratore)')
->queries(
true: fn (Builder $query) => $query->whereHas('listino'),
false: fn (Builder $query) => $query->whereDoesntHave('listino'),
),
])
// Solo icone (con tooltip): le etichette allargherebbero la tabella.
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
@@ -158,6 +201,8 @@ class ModelloResource extends Resource
{
return [
ListinoRelationManager::class,
FinitureRelationManager::class,
OpzioniRelationManager::class,
];
}
@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources\ModelloResource\RelationManagers;
use App\Models\Finitura;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
/**
* Finiture del profilo del sistema e, per ciascuna, la scelta del modello: come il
* sistema (nessuna riga in finiture_modelli) oppure lato proprio o disattivata.
*/
class FinitureRelationManager extends RelationManager
{
protected static string $relationship = 'finitureDelSistema';
protected static ?string $title = 'Finiture';
protected static ?string $modelLabel = 'finitura';
protected static ?string $pluralModelLabel = 'finiture';
/** Scelte del modello per finitura_id, lette una volta per richiesta. */
protected ?\Illuminate\Support\Collection $scelte = null;
public function table(Table $table): Table
{
return $table
->description('Per ogni finitura del profilo scegli se nel modello vale l\'impostazione del sistema (lato e attivazione in "Finiture e maniglie") o una propria: un lato diverso, oppure disattivata. La scelta del modello può anche attivare una finitura disattiva nel sistema.')
->defaultSort('ordine')
->defaultPaginationPageOption(50)
->columns([
Tables\Columns\ImageColumn::make('anteprima')->label('Anteprima')->disk('public')->height(36),
Tables\Columns\TextColumn::make('descrizione')
->label('Finitura')
->description(fn (Finitura $record) => $record->codice)
->searchable(['descrizione', 'codice']),
Tables\Columns\TextColumn::make('sistema')
->label('Nel sistema')
->state(fn (Finitura $record) => $record->attivo ? Finitura::LATI[$record->lato] : 'Disattiva')
->color('gray'),
Tables\Columns\SelectColumn::make('scelta_modello')
->label('In questo modello')
->options(Finitura::LATI_MODELLO)
->placeholder('Come il sistema')
->getStateUsing(fn (Finitura $record) => $this->scelte()->get($record->id))
->updateStateUsing(function (Finitura $record, ?string $state) {
$this->salvaScelta([$record->id], $state);
return $state;
}),
Tables\Columns\TextColumn::make('effettiva')
->label('Risultato')
->state(function (Finitura $record) {
$lato = $this->scelte()->get($record->id) ?? ($record->attivo ? $record->lato : 'nessuno');
return $lato === 'nessuno' ? 'Non disponibile' : Finitura::LATI[$lato];
})
->badge()
->color(fn (string $state) => $state === 'Non disponibile' ? 'danger' : 'success'),
])
->filters([
Tables\Filters\TernaryFilter::make('personalizzata')
->label('Scelta del modello')
->placeholder('Tutte')
->trueLabel('Personalizzate')
->falseLabel('Come il sistema')
->queries(
true: fn ($query) => $query->whereIn('id', $this->scelte()->keys()),
false: fn ($query) => $query->whereNotIn('id', $this->scelte()->keys()),
),
])
->headerActions([
Tables\Actions\Action::make('ripristina')
->label('Tutte come il sistema')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->modalDescription('Elimina le scelte del modello: per tutte le finiture torna a valere l\'impostazione del sistema.')
->visible(fn () => $this->scelte()->isNotEmpty())
->action(function () {
DB::table('finiture_modelli')->where('modello_id', $this->getOwnerRecord()->getKey())->delete();
$this->scelte = null;
Notification::make()->title('Finiture ripristinate come nel sistema')->success()->send();
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('imposta')
->label('Imposta nel modello')
->icon('heroicon-o-adjustments-horizontal')
->form([
Forms\Components\Select::make('lato')
->label('In questo modello')
->options(['sistema' => 'Come il sistema'] + Finitura::LATI_MODELLO)
->required(),
])
->action(fn (Collection $records, array $data) => $this->salvaScelta(
$records->modelKeys(),
$data['lato'] === 'sistema' ? null : $data['lato'],
))
->deselectRecordsAfterCompletion(),
]);
}
public function isReadOnly(): bool
{
return false;
}
private function scelte(): \Illuminate\Support\Collection
{
return $this->scelte ??= DB::table('finiture_modelli')
->where('modello_id', $this->getOwnerRecord()->getKey())
->pluck('lato', 'finitura_id');
}
/** null = come il sistema (rimuove la scelta del modello). */
private function salvaScelta(array $finiture, ?string $lato): void
{
$modello = $this->getOwnerRecord()->getKey();
$righe = DB::table('finiture_modelli')->where('modello_id', $modello)->whereIn('finitura_id', $finiture);
if ($lato === null || $lato === '') {
$righe->delete();
} else {
abort_unless(array_key_exists($lato, Finitura::LATI_MODELLO), 422);
DB::table('finiture_modelli')->upsert(
array_map(fn ($id) => ['modello_id' => $modello, 'finitura_id' => $id, 'lato' => $lato, 'created_at' => now(), 'updated_at' => now()], $finiture),
['modello_id', 'finitura_id'],
['lato', 'updated_at'],
);
}
$this->scelte = null;
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Filament\Resources\ModelloResource\RelationManagers;
use App\Filament\Resources\OpzioneResource;
use App\Models\GruppoOpzioni;
use App\Models\Opzione;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Opzioni personalizzate del modello: se il modello ha almeno una riga in un gruppo,
* per quel modello il gruppo del sistema è sostituito per intero.
*/
class OpzioniRelationManager extends RelationManager
{
protected static string $relationship = 'opzioni';
protected static ?string $title = 'Opzioni personalizzate';
protected static ?string $modelLabel = 'opzione';
protected static ?string $pluralModelLabel = 'opzioni';
public function form(Form $form): Form
{
return $form->schema(OpzioneResource::campi(conSistema: false, modello: fn () => $this->getOwnerRecord()));
}
public function table(Table $table): Table
{
return OpzioneResource::tabella($table)
->description('I gruppi presenti qui sostituiscono, per questo modello, quelli definiti in "Opzioni e supplementi" del sistema (es. icone di apertura proprie o un supplemento diverso). Eliminando tutte le righe di un gruppo torna a valere quello del sistema.')
->headerActions([
Tables\Actions\Action::make('personalizza')
->label('Personalizza gruppo')
->icon('heroicon-o-document-duplicate')
->color('gray')
->modalDescription('Copia nel modello le opzioni del gruppo definite sul sistema, da modificare qui.')
->form([
Forms\Components\Select::make('gruppo')
->label('Gruppo')
->options(fn () => $this->gruppiPersonalizzabili())
->required(),
])
->action(function (array $data) {
$copiate = $this->copiaGruppo($data['gruppo']);
Notification::make()->title("Copiate {$copiate} opzioni dal sistema")->success()->send();
}),
Tables\Actions\CreateAction::make()
->mutateFormDataUsing(fn (array $data) => $this->conIconaDelSistema($data) + ['sistema_id' => $this->getOwnerRecord()->sistema_id]),
]);
}
public function isReadOnly(): bool
{
return false;
}
/** Gruppi del sistema non ancora personalizzati per il modello. */
private function gruppiPersonalizzabili(): array
{
$modello = $this->getOwnerRecord();
$personalizzati = $modello->opzioni()->distinct()->pluck('gruppo')->all();
return Opzione::query()
->where('sistema_id', $modello->sistema_id)
->whereNull('modello_id')
->whereNotIn('gruppo', $personalizzati)
->distinct()
->pluck('gruppo')
->mapWithKeys(fn (string $g) => [$g => GruppoOpzioni::where('codice', $g)->value('etichetta') ?? $g])
->all();
}
private function copiaGruppo(string $gruppo): int
{
$modello = $this->getOwnerRecord();
$righe = Opzione::query()
->where('sistema_id', $modello->sistema_id)
->whereNull('modello_id')
->where('gruppo', $gruppo)
->get();
DB::transaction(function () use ($righe, $modello) {
foreach ($righe as $riga) {
$copia = $riga->replicate(['icona']);
$copia->modello_id = $modello->getKey();
$copia->icona = $this->copiaIcona($riga);
$copia->save();
}
});
return $righe->count();
}
/** Nuova opzione con un valore del sistema e senza icona: prende una copia di quella del sistema. */
private function conIconaDelSistema(array $data): array
{
if (blank($data['icona'] ?? null)) {
$origine = OpzioneResource::catalogo($this->getOwnerRecord(), $data['gruppo'] ?? null)->firstWhere('valore', $data['valore'] ?? null);
$data['icona'] = $origine ? $this->copiaIcona($origine) : null;
}
return $data;
}
/** Icona duplicata: ogni record ha i suoi file (vedi HaImmaginiCatalogo). */
private function copiaIcona(Opzione $origine): ?string
{
$disco = Storage::disk('public');
if (! $origine->icona || ! $disco->exists($origine->icona)) {
return null;
}
$percorso = 'configuratore/opzioni/'.Str::random(20).'.'.pathinfo($origine->icona, PATHINFO_EXTENSION);
$disco->copy($origine->icona, $percorso);
return $percorso;
}
}
@@ -0,0 +1,267 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OpzioneResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use App\Models\Opzione;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rules\Unique;
/** Opzioni e supplementi del sistema (le personalizzazioni per modello sono nella scheda del modello). */
class OpzioneResource extends Resource
{
/** Voce "Altro" del valore nelle opzioni personalizzate: valore scritto a mano. */
public const ALTRO = '__altro';
protected static ?string $model = Opzione::class;
protected static ?string $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static ?string $slug = 'opzioni';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Opzioni e supplementi';
protected static ?string $modelLabel = 'opzione';
protected static ?string $pluralModelLabel = 'opzioni';
protected static ?int $navigationSort = 23;
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->whereNull('modello_id');
}
public static function form(Form $form): Form
{
return $form->schema(self::campi(conSistema: true));
}
/**
* Campi dell'opzione; senza sistema (relation manager del modello) l'unicità è sul modello
* e il valore si sceglie tra quelli del gruppo sul sistema, o "Altro" per scriverlo a mano.
*
* @param \Closure(): Modello|null $modello
*/
public static function campi(bool $conSistema, ?\Closure $modello = null): array
{
$unicita = fn (Unique $rule, Get $get) => self::unicita($rule, $get, $modello);
$finitura = fn (Get $get) => $get('gruppo') === 'tipo_finitura';
return [
Forms\Components\Section::make()
->columns(2)
->schema(array_values(array_filter([
$conSistema ? CampiConfiguratore::sistema() : null,
Forms\Components\Select::make('gruppo')
->label('Gruppo')
->options(fn () => GruppoOpzioni::etichette())
->required()
->live()
->afterStateUpdated(function (Set $set) {
$set('valore', null);
$set('valore_altro', null);
}),
$modello ? null : Forms\Components\Select::make('valore')
->label('Valore')
->options(Opzione::TIPI_FINITURA)
->helperText('Il tipo finitura è calcolato dalle finiture interna ed esterna scelte.')
->visible($finitura)
->required()
->unique(ignoreRecord: true, modifyRuleUsing: $unicita),
$modello ? null : Forms\Components\TextInput::make('valore')
->label('Valore')
->helperText('Identificativo inviato dal configuratore (es. dx, telaio_l).')
->hidden($finitura)
->required()
->alphaDash()
->maxLength(100)
->unique(ignoreRecord: true, modifyRuleUsing: $unicita),
$modello ? Forms\Components\Select::make('valore')
->label('Valore')
->helperText(fn (Get $get) => $finitura($get)
? 'Il tipo finitura è calcolato dalle finiture interna ed esterna scelte.'
: 'Valori del gruppo definiti sul sistema: sceglierne uno ne precompila i dati. "Altro" per un valore nuovo.')
// Un solo campo per tutti i gruppi: con due campi "valore" Filament aggiorna solo il primo.
->options(fn (Get $get) => $finitura($get)
? Opzione::TIPI_FINITURA
: self::catalogo($modello(), $get('gruppo'))
->mapWithKeys(fn (Opzione $o) => [$o->valore => "{$o->etichetta} ({$o->valore})"])
->put(self::ALTRO, 'Altro…')
->all())
->required()
->live()
->unique(ignoreRecord: true, modifyRuleUsing: $unicita)
// Valore non presente sul sistema: in modifica compare come "Altro".
->afterStateHydrated(function (Forms\Components\Select $component, ?string $state, Get $get, Set $set) use ($modello) {
if (filled($state) && $get('gruppo') !== 'tipo_finitura'
&& ! self::catalogo($modello(), $get('gruppo'))->contains('valore', $state)) {
$component->state(self::ALTRO);
$set('valore_altro', $state);
}
})
->afterStateUpdated(fn (?string $state, Get $get, Set $set) => self::precompila($modello(), $get('gruppo'), $state, $set))
->dehydrateStateUsing(fn (?string $state, Get $get) => $state === self::ALTRO ? $get('valore_altro') : $state) : null,
$modello ? Forms\Components\TextInput::make('valore_altro')
->label('Nuovo valore')
->helperText('Identificativo inviato dal configuratore (es. dx, telaio_l).')
->visible(fn (Get $get) => $get('valore') === self::ALTRO)
->required()
->alphaDash()
->maxLength(100)
->notIn([self::ALTRO])
->unique(table: Opzione::class, column: 'valore', ignoreRecord: true, modifyRuleUsing: $unicita)
->dehydrated(false) : null,
Forms\Components\TextInput::make('etichetta')->label('Etichetta')->required()->maxLength(255),
Forms\Components\TextInput::make('alternativa')
->label('Alternativa a')
->helperText('Le opzioni del gruppo con lo stesso valore si escludono a vicenda (es. "coprifili": piatti o scatolati).')
->maxLength(50)
->visible(fn (Get $get) => GruppoOpzioni::where('codice', $get('gruppo'))->value('ui') === 'multipla'),
Forms\Components\Toggle::make('attivo')->label('Attiva')->default(true)->inline(false),
]))),
Forms\Components\Section::make('Supplemento')
->columns(2)
->schema([
Forms\Components\Select::make('supplemento_tipo')
->label('Tipo')
->options(Opzione::SUPPLEMENTI)
->placeholder('Nessun supplemento')
->live(),
Forms\Components\TextInput::make('supplemento')
->label('Valore')
->numeric()
->step(0.01)
->default(0)
->required()
->prefix(fn (Get $get) => in_array($get('supplemento_tipo'), ['fisso', 'metro_lineare'], true) ? '€' : null)
->suffix(fn (Get $get) => match ($get('supplemento_tipo')) {
'percentuale' => '%',
'metro_lineare' => '/ml',
default => null,
})
->visible(fn (Get $get) => filled($get('supplemento_tipo'))),
Forms\Components\Fieldset::make('Metri lineari')
->columns(3)
->visible(fn (Get $get) => $get('supplemento_tipo') === 'metro_lineare')
->schema([
Forms\Components\Placeholder::make('formula_ml')
->hiddenLabel()
->content('ml = (L × moltiplicatore L + H × moltiplicatore H + aggiunta) ÷ 1000, con L e H del serramento in mm. Es. "H + 100 mm, 2 pezzi": moltiplicatore H 2, aggiunta 200.')
->columnSpanFull(),
Forms\Components\TextInput::make('ml_per_larghezza')->label('Moltiplicatore L')->numeric()->step(0.001)->default(0)->required(),
Forms\Components\TextInput::make('ml_per_altezza')->label('Moltiplicatore H')->numeric()->step(0.001)->default(0)->required(),
Forms\Components\TextInput::make('ml_aggiunta_mm')->label('Aggiunta')->numeric()->step(1)->suffix('mm')->default(0)->required(),
]),
]),
Forms\Components\Section::make('Configuratore')
->columns(2)
->collapsible()
->hidden(fn (Get $get) => $get('gruppo') === 'tipo_finitura')
->schema([
CampiConfiguratore::immagine('icona', 'Icona', 'opzioni'),
Forms\Components\KeyValue::make('parametri')
->label('Parametri')
->helperText('Letti dal configuratore: scale_x (1 / -1) per l\'apertura, func e categoria per le select.')
->keyLabel('Nome')
->valueLabel('Valore'),
]),
];
}
public static function table(Table $table): Table
{
return self::tabella($table)
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('gruppo')->label('Gruppo')->options(fn () => GruppoOpzioni::etichette()),
]);
}
/** Tabella condivisa con le opzioni personalizzate del modello. */
public static function tabella(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->defaultGroup('gruppo')
->groups([
Tables\Grouping\Group::make('gruppo')
->label('Gruppo')
->getTitleFromRecordUsing(fn (Opzione $record) => $record->definizione?->etichetta ?? $record->gruppo),
])
->columns([
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(40),
Tables\Columns\TextColumn::make('etichetta')->label('Etichetta')->searchable(),
Tables\Columns\TextColumn::make('valore')->label('Valore')->searchable()->color('gray'),
Tables\Columns\TextColumn::make('alternativa')->label('Alternativa')->badge()->color('gray')->placeholder('—')->toggleable(),
Tables\Columns\TextColumn::make('supplemento')
->label('Supplemento')
->state(fn (Opzione $record) => $record->descriviSupplemento()),
Tables\Columns\ToggleColumn::make('attivo')->label('Attiva'),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageOpzioni::route('/'),
];
}
private static function unicita(Unique $rule, Get $get, ?\Closure $modello): Unique
{
$rule->where('gruppo', $get('gruppo'));
return $modello
? $rule->where('modello_id', $modello()->getKey())
: $rule->where('sistema_id', $get('sistema_id'))->whereNull('modello_id');
}
/** Opzioni del gruppo definite sul sistema del modello. */
public static function catalogo(Modello $modello, ?string $gruppo): Collection
{
return Opzione::query()
->where('sistema_id', $modello->sistema_id)
->whereNull('modello_id')
->where('gruppo', $gruppo)
->orderBy('ordine')
->get();
}
/** Copia nel form i dati dell'opzione del sistema scelta come valore. */
private static function precompila(Modello $modello, ?string $gruppo, ?string $valore, Set $set): void
{
$set('valore_altro', null);
$origine = self::catalogo($modello, $gruppo)->firstWhere('valore', $valore);
if (! $origine) {
return;
}
foreach (['etichetta', 'alternativa', 'supplemento_tipo', 'supplemento', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm', 'parametri'] as $campo) {
$set($campo, $origine->{$campo});
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\OpzioneResource\Pages;
use App\Filament\Resources\OpzioneResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageOpzioni extends ManageRecords
{
protected static string $resource = OpzioneResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,155 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RegolaVetroResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\RegolaVetro;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Unique;
class RegolaVetroResource extends Resource
{
protected static ?string $model = RegolaVetro::class;
protected static ?string $navigationIcon = 'heroicon-o-calculator';
protected static ?string $slug = 'regole-vetro';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Regole vetro';
protected static ?string $modelLabel = 'regola vetro';
protected static ?string $pluralModelLabel = 'regole vetro';
protected static ?int $navigationSort = 22;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
CampiConfiguratore::sistema(),
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Es. F2, TR_A_F.')
->required()
->maxLength(50)
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('sistema_id', $get('sistema_id')),
),
Forms\Components\TextInput::make('descrizione')->label('Tipologia')->required()->maxLength(255)->columnSpanFull(),
]),
Forms\Components\Section::make('Lastre')
->description('Per ogni riga: larghezza vetro LV = (L − detrazione) ÷ divisore, altezza vetro HV = H − detrazione, ripetuta per la quantità. Es. L/2 − 181 si scrive detrazione 362, divisore 2.')
->schema([
Forms\Components\Repeater::make('lastre')
->hiddenLabel()
->columns(5)
->minItems(1)
->defaultItems(1)
->reorderable(false)
->live(onBlur: true)
->schema([
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->placeholder('es. Vetro anta')->maxLength(100),
Forms\Components\TextInput::make('quantita')->label('Quantità')->integer()->minValue(1)->default(1)->required(),
Forms\Components\TextInput::make('divisore')->label('Divisore L')->integer()->minValue(1)->default(1)->required(),
Forms\Components\TextInput::make('detrazione_larghezza')->label('Detrazione L (mm)')->numeric()->default(0)->required(),
Forms\Components\TextInput::make('detrazione_altezza')->label('Detrazione H (mm)')->numeric()->default(0)->required(),
]),
]),
Forms\Components\Section::make('Prova')
->description('Verifica la regola su una misura: non viene salvata.')
->columns(3)
->collapsible()
->schema([
Forms\Components\TextInput::make('prova_larghezza')->label('Larghezza L (mm)')->integer()->default(1200)->dehydrated(false)->live(onBlur: true),
Forms\Components\TextInput::make('prova_altezza')->label('Altezza H (mm)')->integer()->default(1400)->dehydrated(false)->live(onBlur: true),
Forms\Components\Placeholder::make('prova_risultato')
->label('Lastre risultanti')
->content(fn (Get $get) => self::anteprima($get('lastre') ?? [], (float) $get('prova_larghezza'), (float) $get('prova_altezza'))),
]),
Forms\Components\Textarea::make('note')->label('Note')->rows(2)->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('codice')
->columns([
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->sortable(),
Tables\Columns\TextColumn::make('descrizione')->label('Tipologia')->searchable()->wrap(),
Tables\Columns\TextColumn::make('lastre')
->label('Formula')
->state(fn (RegolaVetro $record) => self::formula($record->lastre ?? []))
->html(),
Tables\Columns\TextColumn::make('modelli_count')->label('Modelli')->counts('modelli'),
])
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()
->iconButton()
->modalDescription('I modelli che usano questa regola non avranno più variazione di prezzo per il vetro.'),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageRegoleVetro::route('/'),
];
}
/** Formula leggibile, una riga per lastra (es. "2 × (L − 312) ÷ 2 × (H − 189)"). */
private static function formula(array $lastre): HtmlString
{
$righe = array_map(function (array $l) {
$larghezza = 'L'.self::meno($l['detrazione_larghezza'] ?? 0);
if ((int) ($l['divisore'] ?? 1) > 1) {
$larghezza = "({$larghezza}) ÷ {$l['divisore']}";
}
$descrizione = filled($l['descrizione'] ?? null) ? e($l['descrizione']).': ' : '';
return $descrizione.((int) ($l['quantita'] ?? 1)).' × '.$larghezza.' × (H'.self::meno($l['detrazione_altezza'] ?? 0).')';
}, $lastre);
return new HtmlString(implode('<br>', $righe));
}
private static function meno(mixed $detrazione): string
{
return (float) $detrazione ? ' − '.(float) $detrazione : '';
}
private static function anteprima(array $lastre, float $larghezza, float $altezza): HtmlString
{
if ($larghezza <= 0 || $altezza <= 0 || ! $lastre) {
return new HtmlString('—');
}
$calcolate = (new RegolaVetro(['lastre' => array_values($lastre)]))->calcolaLastre($larghezza, $altezza);
return new HtmlString(implode('<br>', array_map(fn (array $l) => sprintf(
'%s%d × %s × %s mm = %s m² cad.',
filled($l['descrizione']) ? e($l['descrizione']).': ' : '',
$l['quantita'],
number_format($l['larghezza'], 1, ',', '.'),
number_format($l['altezza'], 1, ',', '.'),
number_format(max(0, $l['larghezza']) * max(0, $l['altezza']) / 1_000_000, 3, ',', '.'),
), $calcolate)));
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RegolaVetroResource\Pages;
use App\Filament\Resources\RegolaVetroResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageRegoleVetro extends ManageRecords
{
protected static string $resource = RegolaVetroResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\VetroResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\Vetro;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Validation\Rules\Unique;
class VetroResource extends Resource
{
protected static ?string $model = Vetro::class;
protected static ?string $navigationIcon = 'heroicon-o-square-2-stack';
protected static ?string $slug = 'vetri';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Vetri e riempimenti';
protected static ?string $modelLabel = 'vetro';
protected static ?string $pluralModelLabel = 'vetri';
protected static ?int $navigationSort = 21;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
CampiConfiguratore::sistema(),
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Identificativo inviato dal configuratore (es. V3).')
->required()
->maxLength(50)
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('sistema_id', $get('sistema_id')),
),
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->required()->maxLength(255)->columnSpanFull(),
Forms\Components\Toggle::make('predefinito')->label('Predefinito')->helperText('Selezionato all\'apertura del configuratore.')->inline(false),
Forms\Components\Toggle::make('attivo')->label('Attivo')->default(true)->inline(false),
]),
Forms\Components\Section::make('Prezzo')
->description('Variazione = somma per lastra di max(area, minimo fatturabile) x maggiorazione. Le lastre sono calcolate dalla regola vetro del modello.')
->columns(2)
->schema([
Forms\Components\TextInput::make('maggiorazione_mq')
->label('Maggiorazione')
->helperText('Negativa per una decurtazione (es. infisso senza vetro).')
->numeric()
->step(0.01)
->prefix('€')
->suffix('/m²')
->default(0)
->required(),
Forms\Components\TextInput::make('minimo_mq')
->label('Minimo fatturabile per lastra')
->numeric()
->minValue(0)
->step(0.001)
->suffix('m²')
->default(0)
->required(),
]),
Forms\Components\Section::make('Immagini')
->columns(2)
->schema([
CampiConfiguratore::immagine('anteprima', 'Icona nel menu', 'vetri'),
CampiConfiguratore::immagine('popup', 'Immagine informativa (popup)', 'vetri'),
]),
CampiConfiguratore::aspetto3d('vetri', opacita: true),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->columns([
Tables\Columns\ImageColumn::make('anteprima')->label('Icona')->disk('public')->height(40),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable(),
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap(),
Tables\Columns\TextColumn::make('maggiorazione_mq')
->label('Maggiorazione')
->formatStateUsing(fn ($state) => '€ '.number_format((float) $state, 2, ',', '.').'/m²')
->color(fn ($state) => $state < 0 ? 'danger' : null)
->sortable(),
Tables\Columns\TextColumn::make('minimo_mq')
->label('Minimo')
->formatStateUsing(fn ($state) => number_format((float) $state, 2, ',', '.').' m²'),
Tables\Columns\IconColumn::make('predefinito')->label('Predefinito')->boolean(),
Tables\Columns\ToggleColumn::make('attivo')->label('Attivo'),
])
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageVetri::route('/'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\VetroResource\Pages;
use App\Filament\Resources\VetroResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageVetri extends ManageRecords
{
protected static string $resource = VetroResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Support;
use App\Models\Sistema;
use Filament\Forms;
/** Campi condivisi dalle risorse del catalogo del configuratore. */
class CampiConfiguratore
{
public static function sistema(): Forms\Components\Select
{
return Forms\Components\Select::make('sistema_id')
->label('Sistema')
->relationship('sistema', 'nome')
->default(fn () => Sistema::orderBy('ordine')->value('id'))
->required()
->preload()
->live();
}
public static function immagine(string $campo, string $etichetta, string $cartella): Forms\Components\FileUpload
{
return Forms\Components\FileUpload::make($campo)
->label($etichetta)
->image()
->disk('public')
->directory("configuratore/{$cartella}")
->maxSize(10240);
}
/** Proprietà del materiale Three.js applicate alle mesh della categoria. */
public static function aspetto3d(string $cartella, bool $opacita = false): Forms\Components\Section
{
return Forms\Components\Section::make('Aspetto 3D')
->description('Proprietà del materiale applicate al modello 3D. I campi vuoti lasciano il valore del modello.')
->columns(3)
->collapsible()
->schema(array_values(array_filter([
Forms\Components\ColorPicker::make('colore')->label('Colore'),
$cartella === 'finiture'
? Forms\Components\ColorPicker::make('colore_corpo_base')->label('Colore corpo base')
: null,
Forms\Components\TextInput::make('roughness')->label('Roughness')->numeric()->minValue(0)->maxValue(1)->step(0.01),
Forms\Components\TextInput::make('metalness')->label('Metalness')->numeric()->minValue(0)->maxValue(1)->step(0.01),
$opacita
? Forms\Components\TextInput::make('opacita')->label('Opacità')->numeric()->minValue(0)->maxValue(1)->step(0.01)
: null,
Forms\Components\TextInput::make('bump_scala')->label('Intensità rilievo (bump)')->numeric()->step(0.1),
self::immagine('texture', 'Texture', "{$cartella}/texture"),
self::immagine('texture_bump', 'Texture rilievo (bump)', "{$cartella}/texture"),
])));
}
}
@@ -9,6 +9,7 @@ use App\Models\Cliente;
use App\Models\Preventivo;
use App\Models\PreventivoItem;
use App\Support\ConfigurazioneFormatter;
use App\Support\Configuratore\CalcolatorePrezzo;
use App\Support\PreventivoPdf;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -21,39 +22,57 @@ use Illuminate\Support\Str;
class CarrelloController extends Controller
{
/**
* POST /api/carrello/aggiungi
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza).
* Voci descrittive del configuratore che non incidono sul prezzo: sono le uniche
* accettate dal payload del client, tutto il resto lo ricostruisce il server.
*/
public function aggiungi(Request $request): JsonResponse
private const VOCI_DESCRITTIVE = ['altezza_maniglia', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto'];
/**
* POST /api/carrello/aggiungi
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza). Il prezzo è
* ricalcolato dalle scelte con le regole del backoffice: quello del client è ignorato.
*/
public function aggiungi(Request $request, CalcolatorePrezzo $calcolatore): JsonResponse
{
$data = $request->validate([
'token' => ['nullable', 'string', 'max:64'],
'sistema' => ['nullable', 'string', 'max:50'],
'type' => ['nullable', 'string', 'max:100'],
'nome' => ['nullable', 'string', 'max:255'],
'larghezza' => ['nullable', 'integer', 'min:0'],
'altezza' => ['nullable', 'integer', 'min:0'],
'sistema' => ['required', 'string', 'max:50'],
'type' => ['required', 'string', 'max:100'],
'qty' => ['nullable', 'integer', 'min:1', 'max:999'],
'prezzo' => ['required', 'numeric', 'min:0'],
'scelte' => ['required', 'array'],
...CatalogoController::regoleScelte('scelte.'),
'payload' => ['nullable', 'array'],
'img' => ['nullable', 'string'],
]);
$modello = CatalogoController::trovaModello($data['sistema'], $data['type']);
$calcolo = $calcolatore->calcola($modello, $data['scelte']);
$descrittive = collect($data['payload'] ?? [])
->only(self::VOCI_DESCRITTIVE)
->filter(fn ($v) => is_scalar($v) && $v !== '')
->map(fn ($v) => mb_substr((string) $v, 0, 255))
->all();
$preventivo = $this->risolviCarrello($data['token'] ?? null);
$qty = $data['qty'] ?? 1;
$prezzoUnitario = round((float) $data['prezzo'], 2);
$prezzoUnitario = $calcolo['prezzo'];
$configurazione = $calcolo['configurazione'];
$item = new PreventivoItem([
'sistema' => $data['sistema'] ?? null,
'type' => $data['type'] ?? null,
'nome_modello' => $data['nome'] ?? ($data['payload']['nome'] ?? null),
'larghezza' => $data['larghezza'] ?? ($data['payload']['width'] ?? null),
'altezza' => $data['altezza'] ?? ($data['payload']['height'] ?? null),
'sistema' => $modello->sistema->codice,
'type' => $modello->codice,
'nome_modello' => $configurazione['nome'],
'larghezza' => $configurazione['width'],
'altezza' => $configurazione['height'],
'quantita' => $qty,
'prezzo_unitario' => $prezzoUnitario,
'prezzo_totale' => round($prezzoUnitario * $qty, 2),
'payload' => $data['payload'] ?? [],
'payload' => $descrittive + $configurazione + [
'scelte' => $calcolo['scelte'],
'dettaglio_prezzo' => $calcolo['dettaglio'],
],
'immagine_path' => $this->salvaImmagine($data['img'] ?? null),
]);
@@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Modello;
use App\Models\Sistema;
use App\Support\Configuratore\CalcolatorePrezzo;
use App\Support\Configuratore\ConfigurazioneModello;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CatalogoController extends Controller
{
/**
* GET /api/sistemi
* Sistemi attivi con i rispettivi modelli attivi, nell'ordine del menu.
* Sistemi attivi con i rispettivi modelli attivi e con listino, nell'ordine del menu.
*/
public function sistemi(): JsonResponse
{
$sistemi = Sistema::attivi()
->with(['modelli' => fn ($q) => $q->attivi()])
->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
->get();
return response()->json($sistemi->map(fn (Sistema $s) => $this->sistema($s))->values());
@@ -29,7 +32,7 @@ class CatalogoController extends Controller
{
$sistema = Sistema::attivi()
->where('codice', $codice)
->with(['modelli' => fn ($q) => $q->attivi()])
->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
->firstOrFail();
return response()->json($this->sistema($sistema));
@@ -41,12 +44,7 @@ class CatalogoController extends Controller
*/
public function listino(string $codice, string $modello): JsonResponse
{
$modello = Modello::attivi()
->where('codice', $modello)
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
->firstOrFail();
$prezzi = $modello->listino()
$prezzi = $this->trovaModello($codice, $modello)->listino()
->orderBy('altezza')
->orderBy('larghezza')
->get(['altezza', 'larghezza', 'prezzo'])
@@ -55,6 +53,57 @@ class CatalogoController extends Controller
return response()->json($prezzi);
}
/**
* GET /api/sistemi/{codice}/modelli/{modello}/configurazione
* Menu, finiture, vetri, opzioni e misure del modello per il configuratore.
*/
public function configurazione(string $codice, string $modello, ConfigurazioneModello $configurazione): JsonResponse
{
return response()->json($configurazione->perModello($this->trovaModello($codice, $modello)));
}
/**
* POST /api/sistemi/{codice}/modelli/{modello}/prezzo
* Prezzo (IVA esclusa) della configurazione scelta; 422 se la misura è fuori listino.
*/
public function prezzo(Request $request, string $codice, string $modello, CalcolatorePrezzo $calcolatore): JsonResponse
{
$scelte = $request->validate(self::regoleScelte());
return response()->json($calcolatore->calcola($this->trovaModello($codice, $modello), $scelte));
}
/** Validazione delle scelte del configuratore, condivisa con il carrello. */
public static function regoleScelte(string $prefisso = ''): array
{
return [
"{$prefisso}larghezza" => ['required', 'integer', 'min:1', 'max:100000'],
"{$prefisso}altezza" => ['required', 'integer', 'min:1', 'max:100000'],
"{$prefisso}finitura_interna" => ['nullable', 'string', 'max:50'],
"{$prefisso}finitura_esterna" => ['nullable', 'string', 'max:50'],
"{$prefisso}maniglia" => ['nullable', 'string', 'max:50'],
"{$prefisso}vetro" => ['nullable', 'string', 'max:50'],
"{$prefisso}opzioni" => ['nullable', 'array'],
// Un codice per i gruppi a scelta singola, una lista di codici per quelli multipli.
"{$prefisso}opzioni.*" => ['nullable', function (string $campo, mixed $valore, \Closure $errore) {
$codici = is_array($valore) ? $valore : [$valore];
if (count($codici) > 50 || collect($codici)->contains(fn ($c) => ! is_string($c) || mb_strlen($c) > 100)) {
$errore('Scelta non valida.');
}
}],
];
}
/** Modello attivo e con listino di un sistema attivo, cercato per codici (404 altrimenti). */
public static function trovaModello(string $codice, string $modello): Modello
{
return Modello::nelConfiguratore()
->where('codice', $modello)
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
->with('sistema', 'regolaVetro')
->firstOrFail();
}
private function sistema(Sistema $sistema): array
{
return [
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Support\Configuratore\Media;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class MediaController extends Controller
{
/**
* GET /api/media/{percorso}
* Immagini del configuratore dal disco public, con gli header CORS dell'API (necessari
* alle texture WebGL caricate da un'altra origine).
*/
public function __invoke(string $percorso): BinaryFileResponse
{
$disco = Storage::disk('public');
abort_if(
str_contains($percorso, '..')
|| ! Str::startsWith($percorso, Media::CARTELLE)
|| ! $disco->exists($percorso),
404
);
return response()->file($disco->path($percorso), [
'Cache-Control' => 'public, max-age=86400',
]);
}
}
@@ -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]);
}
});
}
}
@@ -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
@@ -0,0 +1,120 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Catalogo del configuratore per sistema (finiture, vetri, regole vetro, opzioni con
* supplementi): sostituisce i JSON e le immagini sotto src/assets/data.
*/
return new class extends Migration
{
public function up(): void
{
// Finiture del profilo e colori maniglia/cerniere: stesse proprietà 3D.
Schema::create('finiture', function (Blueprint $table) {
$table->id();
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
$table->string('tipo', 20); // profilo | maniglia
// Valore inviato dal configuratore (es. "2", "M1").
$table->string('codice', 50);
$table->string('descrizione');
$table->string('codice_mazzetta', 50)->nullable();
$table->string('colore', 20)->nullable();
$table->string('colore_corpo_base', 20)->nullable();
$table->decimal('roughness', 4, 2)->nullable();
$table->decimal('metalness', 4, 2)->nullable();
$table->string('anteprima')->nullable();
$table->string('texture')->nullable();
$table->string('texture_bump')->nullable();
$table->decimal('bump_scala', 6, 2)->nullable();
$table->boolean('predefinita')->default(false);
$table->unsignedInteger('ordine')->default(0);
$table->boolean('attivo')->default(true);
$table->timestamps();
$table->unique(['sistema_id', 'tipo', 'codice']);
});
// Riempimenti (vetri e pannelli): prezzo a m² sulle lastre calcolate dalla regola del modello.
Schema::create('vetri', function (Blueprint $table) {
$table->id();
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
$table->string('codice', 50);
$table->string('descrizione');
$table->decimal('maggiorazione_mq', 10, 2)->default(0);
$table->decimal('minimo_mq', 8, 3)->default(0);
$table->string('colore', 20)->nullable();
$table->decimal('roughness', 4, 2)->nullable();
$table->decimal('metalness', 4, 2)->nullable();
$table->decimal('opacita', 4, 2)->nullable();
$table->string('anteprima')->nullable();
$table->string('popup')->nullable();
$table->string('texture')->nullable();
$table->string('texture_bump')->nullable();
$table->decimal('bump_scala', 6, 2)->nullable();
$table->boolean('predefinito')->default(false);
$table->unsignedInteger('ordine')->default(0);
$table->boolean('attivo')->default(true);
$table->timestamps();
$table->unique(['sistema_id', 'codice']);
});
// Geometria delle lastre per tipologia (es. F2: 2 lastre (L - 312) / 2 x (H - 189)).
Schema::create('regole_vetro', function (Blueprint $table) {
$table->id();
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
$table->string('codice', 50);
$table->string('descrizione');
// [{descrizione, quantita, divisore, detrazione_larghezza, detrazione_altezza}]
$table->json('lastre');
$table->text('note')->nullable();
$table->timestamps();
$table->unique(['sistema_id', 'codice']);
});
// Opzioni del configuratore (radio/select) e supplementi di prezzo. Con modello_id
// valorizzato sostituiscono, per quel modello, l'intero gruppo definito sul sistema.
Schema::create('opzioni', function (Blueprint $table) {
$table->id();
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
$table->foreignId('modello_id')->nullable()->constrained('modelli')->cascadeOnDelete();
$table->string('gruppo', 50);
$table->string('valore', 100);
$table->string('etichetta');
$table->string('icona')->nullable();
$table->string('supplemento_tipo', 20)->nullable(); // percentuale | fisso
$table->decimal('supplemento', 10, 2)->default(0);
$table->json('parametri')->nullable();
$table->unsignedInteger('ordine')->default(0);
$table->boolean('attivo')->default(true);
$table->timestamps();
$table->index(['sistema_id', 'gruppo']);
$table->index(['modello_id', 'gruppo']);
});
Schema::table('modelli', function (Blueprint $table) {
// Titolo esteso mostrato nel configuratore e nel preventivo.
$table->string('titolo')->nullable()->after('descrizione');
$table->foreignId('regola_vetro_id')->nullable()->after('moduli')
->constrained('regole_vetro')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('modelli', function (Blueprint $table) {
$table->dropConstrainedForeignId('regola_vetro_id');
$table->dropColumn('titolo');
});
Schema::dropIfExists('opzioni');
Schema::dropIfExists('regole_vetro');
Schema::dropIfExists('vetri');
Schema::dropIfExists('finiture');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Lato delle finiture del profilo (interno, esterno o entrambi) sul sistema, con
* personalizzazione per modello: una riga in finiture_modelli sostituisce, per quel
* modello, attivazione e lato del sistema.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('finiture', function (Blueprint $table) {
$table->string('lato', 20)->default('entrambi')->after('tipo'); // entrambi | interno | esterno
});
Schema::create('finiture_modelli', function (Blueprint $table) {
$table->id();
$table->foreignId('modello_id')->constrained('modelli')->cascadeOnDelete();
$table->foreignId('finitura_id')->constrained('finiture')->cascadeOnDelete();
$table->string('lato', 20); // entrambi | interno | esterno | nessuno
$table->timestamps();
$table->unique(['modello_id', 'finitura_id']);
});
}
public function down(): void
{
Schema::dropIfExists('finiture_modelli');
Schema::table('finiture', function (Blueprint $table) {
$table->dropColumn('lato');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Supplementi al metro lineare (es. profili extra): metri = (L x ml_per_larghezza +
* H x ml_per_altezza + ml_aggiunta_mm) / 1000. Le opzioni con la stessa `alternativa`
* si escludono a vicenda nei gruppi a scelta multipla.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('opzioni', function (Blueprint $table) {
$table->string('alternativa', 50)->nullable()->after('icona');
$table->decimal('ml_per_larghezza', 8, 3)->default(0)->after('supplemento');
$table->decimal('ml_per_altezza', 8, 3)->default(0)->after('ml_per_larghezza');
$table->decimal('ml_aggiunta_mm', 10, 2)->default(0)->after('ml_per_altezza');
});
}
public function down(): void
{
Schema::table('opzioni', function (Blueprint $table) {
$table->dropColumn(['alternativa', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm']);
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Gruppi di opzioni del configuratore gestiti dal backoffice (prima erano fissi nel codice):
* controllo del menu, sezione in cui compare e modulo del modello che lo rende visibile.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('gruppi_opzioni', function (Blueprint $table) {
$table->id();
// Chiave usata da opzioni.gruppo e dal configuratore.
$table->string('codice', 50)->unique();
$table->string('etichetta');
$table->string('ui', 20); // nessuna | radio | select | multipla
$table->string('sezione', 20)->default('accessori'); // sistema | accessori
// Modulo del modello che rende visibile il gruppo (Modello::moduli).
$table->string('modulo', 50);
$table->unsignedInteger('ordine')->default(0);
$table->timestamps();
});
$adesso = now();
DB::table('gruppi_opzioni')->insert(array_map(fn (array $g) => $g + ['created_at' => $adesso, 'updated_at' => $adesso], [
['codice' => 'tipo_finitura', 'etichetta' => 'Tipo finitura', 'ui' => 'nessuna', 'sezione' => 'sistema', 'modulo' => 'modello', 'ordine' => 1],
['codice' => 'telaio', 'etichetta' => 'Seleziona il telaio del serramento', 'ui' => 'radio', 'sezione' => 'sistema', 'modulo' => 'telaio', 'ordine' => 2],
['codice' => 'apertura', 'etichetta' => 'Seleziona il verso di apertura del serramento', 'ui' => 'radio', 'sezione' => 'sistema', 'modulo' => 'apertura', 'ordine' => 3],
['codice' => 'profili_extra', 'etichetta' => 'Profili extra', 'ui' => 'multipla', 'sezione' => 'sistema', 'modulo' => 'profili_extra', 'ordine' => 4],
['codice' => 'cerniere_a_scomparsa', 'etichetta' => 'Cerniere a scomparsa', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'cerniere', 'ordine' => 5],
['codice' => 'soglia_ribassata', 'etichetta' => 'Soglia ribassata', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'soglia_ribassata', 'ordine' => 6],
['codice' => 'motore', 'etichetta' => 'Motore', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'motore', 'ordine' => 7],
['codice' => 'azionamento', 'etichetta' => 'Azionamento', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'azionamento', 'ordine' => 8],
]));
}
public function down(): void
{
Schema::dropIfExists('gruppi_opzioni');
}
};
@@ -18,6 +18,8 @@ class CatalogoSeeder extends Seeder
$pvc = Sistema::firstOrCreate(['codice' => 'pvc'], ['nome' => 'PVC', 'ordine' => 1]);
$this->seedModelli($pvc, require __DIR__.'/data/modelli_pvc.php');
Modello::normalizzaOrdine($pvc->id);
}
private function seedModelli(Sistema $sistema, array $modelli): void
@@ -0,0 +1,168 @@
<?php
namespace Database\Seeders;
use App\Models\Finitura;
use App\Models\Modello;
use App\Models\Opzione;
use App\Models\RegolaVetro;
use App\Models\Sistema;
use App\Models\Vetro;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Catalogo iniziale del configuratore PVC (finiture, maniglie, vetri, regole vetro,
* opzioni e supplementi) con le immagini della nuova grafica. Gira una volta sola: se il
* sistema ha già finiture o vetri non tocca nulla, per non sovrascrivere il backoffice.
*/
class ConfiguratoreSeeder extends Seeder
{
private const SORGENTE = __DIR__.'/data/configuratore/pvc';
private const DESTINAZIONE = 'configuratore/pvc';
public function run(): void
{
$pvc = Sistema::where('codice', 'pvc')->first();
if (! $pvc || $pvc->finiture()->exists() || $pvc->vetri()->exists()) {
return;
}
$catalogo = require __DIR__.'/data/configuratore_pvc_catalogo.php';
$regole = require __DIR__.'/data/configuratore_pvc.php';
DB::transaction(function () use ($pvc, $catalogo, $regole) {
$this->finiture($pvc, 'profilo', $catalogo['finiture']);
$this->finiture($pvc, 'maniglia', $catalogo['maniglie']);
$this->vetri($pvc, $regole['vetri']);
$this->opzioni($pvc, $regole['opzioni']);
$this->modelli($pvc, $regole, $catalogo['titoli']);
});
}
private function finiture(Sistema $sistema, string $tipo, array $finiture): void
{
foreach ($finiture as $i => $f) {
Finitura::create([
'sistema_id' => $sistema->id,
'tipo' => $tipo,
'ordine' => $i + 1,
'anteprima' => $this->copia($f['anteprima']),
'texture' => $this->copia($f['texture'] ?? null),
] + $f);
}
}
private function vetri(Sistema $sistema, array $vetri): void
{
foreach ($vetri as $i => [$codice, $descrizione, $maggiorazione, $minimo, $icona, $aspetto]) {
Vetro::create([
'sistema_id' => $sistema->id,
'codice' => $codice,
'descrizione' => $descrizione,
'maggiorazione_mq' => $maggiorazione,
'minimo_mq' => $minimo,
'ordine' => $i + 1,
'anteprima' => $this->copia("vetri/icone/{$icona}", "vetri/{$codice}/{$icona}"),
// Ogni vetro ha la sua copia della texture: i file non sono mai condivisi tra record.
'texture_bump' => isset($aspetto['texture_bump'])
? $this->copia($aspetto['texture_bump'], "vetri/{$codice}/".basename($aspetto['texture_bump']))
: null,
] + array_diff_key($aspetto, ['texture_bump' => true]));
}
}
private function opzioni(Sistema $sistema, array $gruppi): void
{
foreach ($gruppi as $gruppo => $voci) {
foreach ($voci as $i => $voce) {
[$valore, $etichetta, $tipo, $supplemento, $icona, $parametri] = $voce;
Opzione::create([
'sistema_id' => $sistema->id,
'gruppo' => $gruppo,
'valore' => $valore,
'etichetta' => $etichetta,
'supplemento_tipo' => $tipo,
'supplemento' => $supplemento,
'icona' => $icona ? $this->copia("opzioni/{$icona}", "opzioni/sistema/{$icona}") : null,
'parametri' => $parametri,
'ordine' => $i + 1,
'attivo' => $voce[6] ?? true,
]);
}
}
}
private function modelli(Sistema $sistema, array $regole, array $titoli): void
{
$regoleVetro = collect($regole['regole_vetro'])->map(fn (array $r, string $codice) => RegolaVetro::create([
'sistema_id' => $sistema->id,
'codice' => $codice,
'descrizione' => $r[0],
'lastre' => $r[1],
]));
foreach ($sistema->modelli()->get() as $modello) {
[$regola, $moduli] = $regole['modelli'][$modello->codice] ?? [null, []];
$modello->update([
'titolo' => $modello->titolo ?: ($titoli[$modello->cartella ?? $modello->codice] ?? null),
'regola_vetro_id' => $modello->regola_vetro_id ?? $regoleVetro->get($regola)?->id,
'moduli' => array_values(array_unique([...($modello->moduli ?? []), ...$moduli])),
]);
$this->apertura($modello);
$this->aggiornaIcona($modello);
}
}
/** Icone DX/SX proprie del modello: override del gruppo apertura del sistema. */
private function apertura(Modello $modello): void
{
$cartella = "opzioni/apertura/{$modello->codice}";
if (! is_dir(self::SORGENTE."/{$cartella}")) {
return;
}
foreach ([['dx', 'DX', 'destra.jpg', 1], ['sx', 'SX', 'sinistra.jpg', -1]] as $i => [$valore, $etichetta, $file, $scala]) {
Opzione::create([
'sistema_id' => $modello->sistema_id,
'modello_id' => $modello->id,
'gruppo' => 'apertura',
'valore' => $valore,
'etichetta' => $etichetta,
'icona' => $this->copia("{$cartella}/{$file}"),
'parametri' => ['scale_x' => $scala],
'ordine' => $i + 1,
]);
}
}
/** Sostituisce l'icona del modello con quella della nuova grafica, se non è stata cambiata dal pannello. */
private function aggiornaIcona(Modello $modello): void
{
$sorgente = __DIR__."/data/icone/pvc/{$modello->codice}.jpg";
$seminata = "modelli/icone/pvc/{$modello->codice}.jpg";
if (is_file($sorgente) && in_array($modello->icona, [null, $seminata], true)) {
Storage::disk('public')->put($seminata, file_get_contents($sorgente));
$modello->updateQuietly(['icona' => $seminata]);
}
}
/** Copia un file della grafica sul disco public; restituisce il percorso salvato. */
private function copia(?string $relativo, ?string $destinazione = null): ?string
{
if (! $relativo) {
return null;
}
$percorso = self::DESTINAZIONE.'/'.($destinazione ?? $relativo);
Storage::disk('public')->put($percorso, file_get_contents(self::SORGENTE."/{$relativo}"));
return $percorso;
}
}
@@ -22,6 +22,6 @@ class DatabaseSeeder extends Seeder
]
);
$this->call(CatalogoSeeder::class);
$this->call([CatalogoSeeder::class, ConfiguratoreSeeder::class, ProfiliExtraSeeder::class, NuoveOpzioniSeeder::class]);
}
}
@@ -0,0 +1,92 @@
<?php
namespace Database\Seeders;
use App\Models\Modello;
use App\Models\Opzione;
use App\Models\Sistema;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Cerniere a scomparsa, soglia ribassata e motore per modello, dal foglio "Nomi" di
* reference/OrdineSerramenti_Descrizioni_NuoveOpzioni.xlsx. Gira una volta sola: se il
* sistema ha già le opzioni della soglia ribassata non tocca nulla.
*/
class NuoveOpzioniSeeder extends Seeder
{
/** Modelli con cerniere a scomparsa e relativo sovrapprezzo (gli altri: NO). */
private const CERNIERE = [
'finestra_1_anta' => 50, 'finestra_2_ante' => 100, 'finestra_3_ante' => 150, 'finestra_vasistas' => 50,
'portafinestra_1_anta' => 50, 'portafinestra_2_ante' => 100, 'portafinestra_3_ante' => 150,
];
/** Modelli con soglia ribassata e relativo sovrapprezzo. */
private const SOGLIA = [
'portafinestra_1_anta' => 65, 'portafinestra_2_ante' => 105, 'portafinestra_3_ante' => 145,
'scorrevole_sf_2_ante' => 105, 'scorrevole_sf_3_ante' => 145, 'scorrevole_sf_4_ante' => 185,
];
/** Motore: "opzione select motore, togliere maniglia". */
private const MOTORE = ['finestra_vasistas' => 150];
public function run(): void
{
$pvc = Sistema::where('codice', 'pvc')->first();
if (! $pvc || Opzione::where('sistema_id', $pvc->id)->where('gruppo', 'soglia_ribassata')->exists()) {
return;
}
DB::transaction(function () use ($pvc) {
// Prezzo del sistema = il più comune; gli altri modelli hanno il gruppo personalizzato.
$this->siNo($pvc, null, 'cerniere_a_scomparsa', 50, ['categoria' => 'cerniere', 'func' => 'mostraCerniere'], ['categoria' => 'cerniere', 'func' => 'nascondiCerniere']);
$this->siNo($pvc, null, 'soglia_ribassata', 65);
$this->siNo($pvc, null, 'motore', 150, ['categoria' => 'maniglia', 'func' => 'mostraCategoria'], ['categoria' => 'maniglia', 'func' => 'nascondiCategoria']);
foreach ($pvc->modelli()->get() as $modello) {
$moduli = array_values(array_diff($modello->moduli ?? [], ['cerniere']));
foreach (['cerniere' => self::CERNIERE, 'soglia_ribassata' => self::SOGLIA, 'motore' => self::MOTORE] as $modulo => $prezzi) {
if (isset($prezzi[$modello->codice])) {
$moduli[] = $modulo;
}
}
$modello->update(['moduli' => array_values(array_unique($moduli))]);
if ((self::CERNIERE[$modello->codice] ?? 50) !== 50) {
$this->siNo($pvc, $modello, 'cerniere_a_scomparsa', self::CERNIERE[$modello->codice], ['categoria' => 'cerniere', 'func' => 'mostraCerniere'], ['categoria' => 'cerniere', 'func' => 'nascondiCerniere'], sostituisci: true);
}
if ((self::SOGLIA[$modello->codice] ?? 65) !== 65) {
$this->siNo($pvc, $modello, 'soglia_ribassata', self::SOGLIA[$modello->codice]);
}
}
});
}
/** Opzioni NO (senza supplemento) e SI (importo fisso); per il modello sostituiscono il gruppo del sistema. */
private function siNo(Sistema $sistema, ?Modello $modello, string $gruppo, float $prezzo, ?array $parametriNo = null, ?array $parametriSi = null, bool $sostituisci = false): void
{
$esistenti = Opzione::where('sistema_id', $sistema->id)->where('gruppo', $gruppo)->where('modello_id', $modello?->id);
if ($sostituisci) {
$esistenti->delete();
} elseif ($esistenti->exists()) {
// Cerniere del sistema già create dal catalogo iniziale: si aggiorna solo il prezzo.
$esistenti->where('valore', 'si')->update(['supplemento' => $prezzo]);
return;
}
foreach ([['no', 'NO', 0, $parametriNo], ['si', 'SI', $prezzo, $parametriSi]] as $i => [$valore, $etichetta, $supplemento, $parametri]) {
Opzione::create([
'sistema_id' => $sistema->id,
'modello_id' => $modello?->id,
'gruppo' => $gruppo,
'valore' => $valore,
'etichetta' => $etichetta,
'supplemento_tipo' => 'fisso',
'supplemento' => $supplemento,
'parametri' => $parametri,
'ordine' => $i + 1,
]);
}
}
}
@@ -0,0 +1,99 @@
<?php
namespace Database\Seeders;
use App\Models\Modello;
use App\Models\Opzione;
use App\Models\Sistema;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Profili extra PVC: codici e descrizioni dal foglio "Profili Extra" di
* reference/OrdineSerramenti_Descrizioni_NuoveOpzioni.xlsx, prezzi da
* reference/Regole_Profili Extra.xls, icone da reference/icone_profili_extra.
*
* Gira una volta sola: se il sistema ha già profili extra con un prezzo non tocca nulla.
* Sostituisce le righe segnaposto (senza prezzo) create dalla prima versione del catalogo.
*/
class ProfiliExtraSeeder extends Seeder
{
private const ICONE = __DIR__.'/data/configuratore/pvc/opzioni/profili_extra';
/** Modelli senza profili extra (foglio "Nomi"; regole: "tutti tranne scuretto, cassonetto, cielino"). */
private const ESCLUSI = ['scuretto_base', 'scuretto_pannello_alessandrite', 'scuretto_pannello_malachite', 'cassonetto_80', 'cassonetto_139', 'cassonetto_150'];
/** Il cielino ha solo il frontale 4062000 (regole: "solo cielino"). */
private const CIELINO = 'celino';
public function run(): void
{
$pvc = Sistema::where('codice', 'pvc')->first();
$esistenti = Opzione::where('gruppo', 'profili_extra')->where('sistema_id', $pvc?->id);
if (! $pvc || (clone $esistenti)->whereNotNull('supplemento_tipo')->exists()) {
return;
}
DB::transaction(function () use ($pvc, $esistenti) {
// Segnaposto senza prezzo: eliminati uno per uno per rimuovere anche le icone.
$esistenti->get()->each->delete();
foreach (self::profili() as $i => $profilo) {
if ($profilo['valore'] === '406200') {
continue;
}
$this->crea($pvc, null, $profilo, $i + 1);
}
foreach ($pvc->modelli()->get() as $modello) {
if (in_array($modello->codice, self::ESCLUSI, true)) {
continue;
}
$modello->update(['moduli' => array_values(array_unique([...($modello->moduli ?? []), 'profili_extra']))]);
if ($modello->codice === self::CIELINO) {
$this->crea($pvc, $modello, collect(self::profili())->firstWhere('valore', '406200'), 1);
}
}
});
}
/**
* ml = (L x ml_per_larghezza + H x ml_per_altezza + ml_aggiunta_mm) / 1000.
* Guide: "H+100mm x 2" -> 2H + 200. Frontale: "solo larghezza (1 pz)" -> L.
* Allargamenti NP: "ML" senza misura di riferimento -> perimetro 2L + 2H (da confermare).
*/
public static function profili(): array
{
$ml = fn (float $l, float $h, float $mm) => ['ml_per_larghezza' => $l, 'ml_per_altezza' => $h, 'ml_aggiunta_mm' => $mm];
return [
['valore' => 'coprifili_piatti', 'etichetta' => 'COPRIFILI PIATTI', 'supplemento_tipo' => 'percentuale', 'supplemento' => 1, 'alternativa' => 'coprifili'],
['valore' => 'coprifili_scatolati', 'etichetta' => 'COPRIFILI SCATOLATI', 'supplemento_tipo' => 'percentuale', 'supplemento' => 3, 'alternativa' => 'coprifili'],
['valore' => '50811', 'etichetta' => '50811_GUIDA 34', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 15, 'alternativa' => 'guida'] + $ml(0, 2, 200),
['valore' => '50811_50801', 'etichetta' => '50811+50801_GUIDA 87', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 15, 'alternativa' => 'guida'] + $ml(0, 2, 200),
['valore' => '9320', 'etichetta' => 'HO9320_T/M', 'supplemento_tipo' => 'fisso', 'supplemento' => 85],
['valore' => 'NP8300', 'etichetta' => 'NP8300_ALL.30', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 25, 'alternativa' => 'allargamento'] + $ml(2, 2, 0),
['valore' => 'NP8500', 'etichetta' => 'NP8500_ALL.50', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 33, 'alternativa' => 'allargamento'] + $ml(2, 2, 0),
['valore' => 'NP8700', 'etichetta' => 'NP8700_ALL.150', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 45, 'alternativa' => 'allargamento'] + $ml(2, 2, 0),
['valore' => '406200', 'etichetta' => '4062000_FRONTALE', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 145] + $ml(1, 0, 0),
];
}
private function crea(Sistema $sistema, ?Modello $modello, array $profilo, int $ordine): void
{
$file = "{$profilo['valore']}.jpg";
$icona = 'configuratore/pvc/opzioni/'.($modello ? $modello->codice : 'sistema').'/profili_extra/'.$file;
Storage::disk('public')->put($icona, file_get_contents(self::ICONE."/{$file}"));
Opzione::create($profilo + [
'sistema_id' => $sistema->id,
'modello_id' => $modello?->id,
'gruppo' => 'profili_extra',
'icona' => $icona,
'ordine' => $ordine,
]);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Some files were not shown because too many files have changed in this diff Show More