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
+3 -1
View File
@@ -4,4 +4,6 @@
/backoffice/cliente/dist/ /backoffice/cliente/dist/
/node_modules/ /node_modules/
kreios.zip kreios.zip
.env.production .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`, *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 `communication/IframeBridge.js`) that does not exist yet in `src/js` — the current implementation is the
flatter `starter.js`/`params_parser.js` described here.* 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 2. Catalog data comes from the Laravel backoffice (`backoffice/`, API base in `src/js/api_base.js`):
`src/assets/data/modelli/<sistema>/<cartella>/{form,modello,maniglia,avvolgibile,vetro,listino}.json` `GET /api/sistemi` (systems + active models that have a price list; models without one are hidden and flagged in the admin list) and
(which JSON files are fetched depends on the `moduli` array in the matched `modelli.json` entry). `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 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 `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 list, handles the mobile-portrait/landscape layout reflow, and initializes `PriceCalculator`. It returns
the price calculator instance used by the rest of the app. 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 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 on the size fields, selects and radios. On every change (debounced) it sends the selected codes to
`window.configurazione` (the object that gets posted to the parent on "add to cart"), and updates the DOM. `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 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/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 (`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`), 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 `flipView` (180° GSAP-animated turn between front/back view), `addMarker` (in-scene hotspot with a video
popup). popup).
6. **`PriceCalculator`** (`src/js/price_calculator.js`) is a stateless-ish module (single module-level 6. **Pricing is server-side only**: `backoffice/app/Support/Configuratore/CalcolatorePrezzo.php`.
`listini` cache set via `init`) that looks up a base price from a width/height grid in **mm** (served by `prezzo = base × (1 + supplementi % / 100) + supplementi fissi + variazione vetro` (VAT excluded). Base is
the backoffice API, using the first grid size ≥ the requested one) and layers on percentage/flat supplements for finish, glass, hinges, handle the first grid size ≥ the requested one (mm). Supplements come from `opzioni` rows (finish type
height, and actuator type, then adds 22% VAT. 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 ### Data conventions
- Every 3D-visible part is tagged with `userData.categoria` in the GLB/scene so JS can show/hide/retexture it - 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`, ...). without knowing mesh names (`interno`, `esterno`, `maniglia`, `cerniere`, `vetro`, ...).
- Each model folder under `src/assets/data/modelli/<sistema>/<cartella>/` follows the same file set: - Which menu sections a model shows (and which items enter the price) is driven by its `moduli` in the
`form.json` (UI radios/selects/finiture wiring — declares which named `<select>`/radio groups exist and backoffice (`Modello::MODULI`, e.g. `finitura_esterna`, `apertura`, `telaio`, `vetro`). The per-model JSON
which categories/texture folders they map to), `modello.json` (finish options), `maniglia.json` (handle files under `src/assets/data/modelli/` are legacy and no longer read (except `avvolgibile.json`).
options), `avvolgibile.json` (shutter, cassonetto-only), `vetro.json` (glass options), `listino.json` - Texture and icon images live in the backoffice (`storage/app/public/configuratore/...`, seeded once by
(price grid). `ConfiguratoreSeeder` from `backoffice/database/seeders/data/configuratore/`) and are loaded lazily per
- Texture images live under `src/assets/data/immagini/<sistema>/<folder>/<texture_folder>/<id>/...` and are selection (`APP.Player.changeTexture`) from absolute URLs; a monotonically-checked `isStale()` guard discards
loaded lazily per selection (`APP.Player.changeTexture`); a monotonically-checked `isStale()` guard discards
a texture load if the user changed the selection again before it finished. a texture load if the user changed the selection again before it finished.
- `window.*` globals (`window.configurazione`, `window.cambiaTexture`, `window.caricaModello`, - `window.*` globals (`window.configurazione`, `window.cambiaTexture`, `window.caricaModello`,
`window.rotate`, `window.sistema`, etc.) are the integration surface between the jQuery-driven menu markup `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; namespace App\Filament\Resources;
use App\Filament\Resources\ModelloResource\Pages; 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\ListinoRelationManager;
use App\Filament\Resources\ModelloResource\RelationManagers\OpzioniRelationManager;
use App\Models\Modello; use App\Models\Modello;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Form; use Filament\Forms\Form;
@@ -11,6 +13,7 @@ use Filament\Forms\Get;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rules\Unique; use Illuminate\Validation\Rules\Unique;
class ModelloResource extends Resource class ModelloResource extends Resource
@@ -61,6 +64,11 @@ class ModelloResource extends Resource
Forms\Components\TextInput::make('descrizione') Forms\Components\TextInput::make('descrizione')
->label('Descrizione') ->label('Descrizione')
->maxLength(255), ->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') Forms\Components\FileUpload::make('icona')
->label('Icona') ->label('Icona')
->image() ->image()
@@ -104,9 +112,20 @@ class ModelloResource extends Resource
->helperText('Se vuota si usa la cartella dati.'), ->helperText('Se vuota si usa la cartella dati.'),
Forms\Components\CheckboxList::make('moduli') Forms\Components\CheckboxList::make('moduli')
->label('Moduli') ->label('Moduli')
->options(Modello::MODULI) ->helperText('Sezioni del menu del configuratore e voci che entrano nel prezzo.')
->options(fn () => Modello::moduliDisponibili())
->columns(3) ->columns(3)
->columnSpanFull(), ->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 public static function table(Table $table): Table
{ {
return $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') ->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') ->defaultGroup('sistema.nome')
->columns([ ->columns([
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(48), Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(48),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->copyable(), // Nome a capo su due righe e codice sotto, nella stessa cella: evita lo
Tables\Columns\TextColumn::make('nome')->label('Nome')->searchable(), // 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('descrizione')->label('Descrizione')->searchable()->wrap()->placeholder('—'),
Tables\Columns\TextColumn::make('categoria') Tables\Columns\TextColumn::make('categoria')
->label('Categoria') ->label('Categoria')
@@ -131,7 +161,11 @@ class ModelloResource extends Resource
->label('Listino') ->label('Listino')
->counts('listino') ->counts('listino')
->formatStateUsing(fn (int $state) => $state ? "{$state} prezzi" : 'Nessuno') ->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')) ->description(fn (Modello $record) => $record->listino_aggiornato_at?->format('d/m/Y'))
->sortable(), ->sortable(),
Tables\Columns\ToggleColumn::make('attivo') 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('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('categoria')->label('Categoria')->options(Modello::CATEGORIE), Tables\Filters\SelectFilter::make('categoria')->label('Categoria')->options(Modello::CATEGORIE),
Tables\Filters\TernaryFilter::make('attivo')->label('Attivo'), 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([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make(), Tables\Actions\DeleteAction::make()->iconButton(),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([ Tables\Actions\BulkActionGroup::make([
@@ -158,6 +201,8 @@ class ModelloResource extends Resource
{ {
return [ return [
ListinoRelationManager::class, 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\Preventivo;
use App\Models\PreventivoItem; use App\Models\PreventivoItem;
use App\Support\ConfigurazioneFormatter; use App\Support\ConfigurazioneFormatter;
use App\Support\Configuratore\CalcolatorePrezzo;
use App\Support\PreventivoPdf; use App\Support\PreventivoPdf;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -21,39 +22,57 @@ use Illuminate\Support\Str;
class CarrelloController extends Controller class CarrelloController extends Controller
{ {
/** /**
* POST /api/carrello/aggiungi * Voci descrittive del configuratore che non incidono sul prezzo: sono le uniche
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza). * 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([ $data = $request->validate([
'token' => ['nullable', 'string', 'max:64'], 'token' => ['nullable', 'string', 'max:64'],
'sistema' => ['nullable', 'string', 'max:50'], 'sistema' => ['required', 'string', 'max:50'],
'type' => ['nullable', 'string', 'max:100'], 'type' => ['required', 'string', 'max:100'],
'nome' => ['nullable', 'string', 'max:255'],
'larghezza' => ['nullable', 'integer', 'min:0'],
'altezza' => ['nullable', 'integer', 'min:0'],
'qty' => ['nullable', 'integer', 'min:1', 'max:999'], 'qty' => ['nullable', 'integer', 'min:1', 'max:999'],
'prezzo' => ['required', 'numeric', 'min:0'], 'scelte' => ['required', 'array'],
...CatalogoController::regoleScelte('scelte.'),
'payload' => ['nullable', 'array'], 'payload' => ['nullable', 'array'],
'img' => ['nullable', 'string'], '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); $preventivo = $this->risolviCarrello($data['token'] ?? null);
$qty = $data['qty'] ?? 1; $qty = $data['qty'] ?? 1;
$prezzoUnitario = round((float) $data['prezzo'], 2); $prezzoUnitario = $calcolo['prezzo'];
$configurazione = $calcolo['configurazione'];
$item = new PreventivoItem([ $item = new PreventivoItem([
'sistema' => $data['sistema'] ?? null, 'sistema' => $modello->sistema->codice,
'type' => $data['type'] ?? null, 'type' => $modello->codice,
'nome_modello' => $data['nome'] ?? ($data['payload']['nome'] ?? null), 'nome_modello' => $configurazione['nome'],
'larghezza' => $data['larghezza'] ?? ($data['payload']['width'] ?? null), 'larghezza' => $configurazione['width'],
'altezza' => $data['altezza'] ?? ($data['payload']['height'] ?? null), 'altezza' => $configurazione['height'],
'quantita' => $qty, 'quantita' => $qty,
'prezzo_unitario' => $prezzoUnitario, 'prezzo_unitario' => $prezzoUnitario,
'prezzo_totale' => round($prezzoUnitario * $qty, 2), '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), 'immagine_path' => $this->salvaImmagine($data['img'] ?? null),
]); ]);
@@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Modello; use App\Models\Modello;
use App\Models\Sistema; use App\Models\Sistema;
use App\Support\Configuratore\CalcolatorePrezzo;
use App\Support\Configuratore\ConfigurazioneModello;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CatalogoController extends Controller class CatalogoController extends Controller
{ {
/** /**
* GET /api/sistemi * 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 public function sistemi(): JsonResponse
{ {
$sistemi = Sistema::attivi() $sistemi = Sistema::attivi()
->with(['modelli' => fn ($q) => $q->attivi()]) ->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
->get(); ->get();
return response()->json($sistemi->map(fn (Sistema $s) => $this->sistema($s))->values()); return response()->json($sistemi->map(fn (Sistema $s) => $this->sistema($s))->values());
@@ -29,7 +32,7 @@ class CatalogoController extends Controller
{ {
$sistema = Sistema::attivi() $sistema = Sistema::attivi()
->where('codice', $codice) ->where('codice', $codice)
->with(['modelli' => fn ($q) => $q->attivi()]) ->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
->firstOrFail(); ->firstOrFail();
return response()->json($this->sistema($sistema)); return response()->json($this->sistema($sistema));
@@ -41,12 +44,7 @@ class CatalogoController extends Controller
*/ */
public function listino(string $codice, string $modello): JsonResponse public function listino(string $codice, string $modello): JsonResponse
{ {
$modello = Modello::attivi() $prezzi = $this->trovaModello($codice, $modello)->listino()
->where('codice', $modello)
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
->firstOrFail();
$prezzi = $modello->listino()
->orderBy('altezza') ->orderBy('altezza')
->orderBy('larghezza') ->orderBy('larghezza')
->get(['altezza', 'larghezza', 'prezzo']) ->get(['altezza', 'larghezza', 'prezzo'])
@@ -55,6 +53,57 @@ class CatalogoController extends Controller
return response()->json($prezzi); 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 private function sistema(Sistema $sistema): array
{ {
return [ 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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class Modello extends Model class Modello extends Model
@@ -21,23 +22,23 @@ class Modello extends Model
'accessori' => 'Accessori', '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 = [ public const MODULI = [
'listino' => 'Listino', 'listino' => 'Listino',
'modello' => 'Finiture', 'modello' => 'Finiture',
'finitura_esterna' => 'Finitura esterna (bicolore)',
'maniglia' => 'Maniglia', 'maniglia' => 'Maniglia',
'altezza_maniglia' => 'Altezza maniglia', 'altezza_maniglia' => 'Altezza maniglia',
'vetro' => 'Vetro', 'vetro' => 'Vetro',
'cerniere' => 'Cerniere',
'telaio' => 'Telaio',
'avvolgibile' => 'Avvolgibile', 'avvolgibile' => 'Avvolgibile',
'select_finiture' => 'Select finiture', 'select_finiture' => 'Select finiture',
]; ];
protected $fillable = [ 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', 'cartella', 'file_modello', 'categoria', 'cartella_texture', 'moduli',
'listino_aggiornato_at', 'regola_vetro_id', 'listino_aggiornato_at',
]; ];
protected $casts = [ protected $casts = [
@@ -57,18 +58,86 @@ class Modello extends Model
return $this->hasMany(ListinoPrezzo::class); 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 public function scopeAttivi(Builder $query): Builder
{ {
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id'); 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 public function getIconaUrlAttribute(): ?string
{ {
return $this->icona ? Storage::disk('public')->url($this->icona) : null; 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 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. // Rimuove il file dell'icona sostituita o del modello eliminato.
static::updated(function (Modello $modello): void { static::updated(function (Modello $modello): void {
$vecchia = $modello->getOriginal('icona'); $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); 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 public function scopeAttivi(Builder $query): Builder
{ {
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id'); 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', 'tipo_ispezione' => 'Tipo ispezione',
'avvolgibile' => 'Avvolgibile', 'avvolgibile' => 'Avvolgibile',
'cassonetto' => 'Cassonetto', 'cassonetto' => 'Cassonetto',
'apertura' => 'Verso di apertura',
'telaio' => 'Telaio',
'cerniere_a_scomparsa' => 'Cerniere a scomparsa',
'profili_extra' => 'Profili extra',
]; ];
/** Ordine di visualizzazione preferito. */ /** Ordine di visualizzazione preferito. */
private const ORDER = [ private const ORDER = [
'width', 'height', 'tipo_finitura', 'finitura_interna', 'finitura_esterna', 'width', 'height', 'tipo_finitura', 'finitura_interna', 'finitura_esterna',
'maniglia', 'altezza_maniglia', 'vetro', 'cerniere_scomparsa', 'azionamento', 'maniglia', 'altezza_maniglia', 'vetro', 'apertura', 'profili_extra', 'telaio', 'cerniere_scomparsa',
'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto', 'cerniere_a_scomparsa', 'azionamento', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
]; ];
/** Chiavi tecniche da non mostrare nella lista (usate altrove, es. titolo). */ /** 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 * @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]); $pvc = Sistema::firstOrCreate(['codice' => 'pvc'], ['nome' => 'PVC', 'ordine' => 1]);
$this->seedModelli($pvc, require __DIR__.'/data/modelli_pvc.php'); $this->seedModelli($pvc, require __DIR__.'/data/modelli_pvc.php');
Modello::normalizzaOrdine($pvc->id);
} }
private function seedModelli(Sistema $sistema, array $modelli): void 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