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.
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FinituraResource\Pages;
|
||||
use App\Filament\Support\CampiConfiguratore;
|
||||
use App\Models\Finitura;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
class FinituraResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Finitura::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-swatch';
|
||||
|
||||
protected static ?string $slug = 'finiture';
|
||||
|
||||
protected static ?string $navigationGroup = 'Configuratore';
|
||||
|
||||
protected static ?string $navigationLabel = 'Finiture e maniglie';
|
||||
|
||||
protected static ?string $modelLabel = 'finitura';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'finiture';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
CampiConfiguratore::sistema(),
|
||||
Forms\Components\Select::make('tipo')
|
||||
->label('Tipo')
|
||||
->options(Finitura::TIPI)
|
||||
->default('profilo')
|
||||
->required()
|
||||
->live(),
|
||||
Forms\Components\TextInput::make('codice')
|
||||
->label('Codice')
|
||||
->helperText('Identificativo inviato dal configuratore (es. 2, M1).')
|
||||
->required()
|
||||
->maxLength(50)
|
||||
->unique(
|
||||
ignoreRecord: true,
|
||||
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule
|
||||
->where('sistema_id', $get('sistema_id'))
|
||||
->where('tipo', $get('tipo')),
|
||||
),
|
||||
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->required()->maxLength(255),
|
||||
Forms\Components\TextInput::make('codice_mazzetta')->label('Codice mazzetta')->maxLength(50),
|
||||
CampiConfiguratore::immagine('anteprima', 'Anteprima nel menu', 'finiture/anteprime'),
|
||||
Forms\Components\Select::make('lato')
|
||||
->label('Lato')
|
||||
->helperText('Su quale lato del serramento si può scegliere. Si può cambiare per singolo modello dalla scheda del modello.')
|
||||
->options(Finitura::LATI)
|
||||
->default('entrambi')
|
||||
->required()
|
||||
->visible(fn (Get $get) => $get('tipo') === 'profilo'),
|
||||
Forms\Components\Toggle::make('predefinita')
|
||||
->label('Predefinita')
|
||||
->helperText('Selezionata all\'apertura. Per le finiture del profilo: la stessa finitura predefinita su interno ed esterno è il "bianco" senza supplemento.')
|
||||
->inline(false),
|
||||
Forms\Components\Toggle::make('attivo')
|
||||
->label('Attiva nel sistema')
|
||||
->helperText('Disattiva: non compare nei modelli, salvo quelli che la attivano nella propria scheda.')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
]),
|
||||
CampiConfiguratore::aspetto3d('finiture'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('ordine')
|
||||
->reorderable('ordine')
|
||||
->defaultGroup('tipo')
|
||||
->groups([
|
||||
Tables\Grouping\Group::make('tipo')
|
||||
->label('Tipo')
|
||||
->getTitleFromRecordUsing(fn (Finitura $record) => Finitura::TIPI[$record->tipo] ?? $record->tipo),
|
||||
])
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('anteprima')->label('Anteprima')->disk('public')->height(40),
|
||||
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable(),
|
||||
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap(),
|
||||
Tables\Columns\SelectColumn::make('lato')
|
||||
->label('Lato')
|
||||
->options(Finitura::LATI)
|
||||
->selectablePlaceholder(false)
|
||||
->disabled(fn (Finitura $record) => $record->tipo !== 'profilo'),
|
||||
Tables\Columns\ColorColumn::make('colore')->label('Colore'),
|
||||
Tables\Columns\IconColumn::make('texture')
|
||||
->label('Texture')
|
||||
->icon(fn (?string $state) => $state ? 'heroicon-o-photo' : null),
|
||||
Tables\Columns\IconColumn::make('predefinita')->label('Predefinita')->boolean(),
|
||||
Tables\Columns\ToggleColumn::make('attivo')->label('Attiva'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
|
||||
Tables\Filters\SelectFilter::make('tipo')->label('Tipo')->options(Finitura::TIPI),
|
||||
Tables\Filters\SelectFilter::make('lato')->label('Lato')->options(Finitura::LATI),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()->iconButton(),
|
||||
Tables\Actions\DeleteAction::make()->iconButton(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\BulkAction::make('lato')
|
||||
->label('Imposta lato')
|
||||
->icon('heroicon-o-arrows-right-left')
|
||||
->form([
|
||||
Forms\Components\Select::make('lato')->label('Lato')->options(Finitura::LATI)->required(),
|
||||
])
|
||||
->action(fn (Collection $records, array $data) => $records
|
||||
->where('tipo', 'profilo')
|
||||
->each->update(['lato' => $data['lato']]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManageFiniture::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FinituraResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FinituraResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageFiniture extends ManageRecords
|
||||
{
|
||||
protected static string $resource = FinituraResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\GruppoOpzioniResource\Pages;
|
||||
use App\Models\GruppoOpzioni;
|
||||
use App\Models\Modello;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
/** Gruppi di opzioni del configuratore: un nuovo gruppo si crea qui, le sue voci in "Opzioni e supplementi". */
|
||||
class GruppoOpzioniResource extends Resource
|
||||
{
|
||||
protected static ?string $model = GruppoOpzioni::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-queue-list';
|
||||
|
||||
protected static ?string $slug = 'gruppi-opzioni';
|
||||
|
||||
protected static ?string $navigationGroup = 'Configuratore';
|
||||
|
||||
protected static ?string $navigationLabel = 'Gruppi di opzioni';
|
||||
|
||||
protected static ?string $modelLabel = 'gruppo di opzioni';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'gruppi di opzioni';
|
||||
|
||||
protected static ?int $navigationSort = 24;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('codice')
|
||||
->label('Codice')
|
||||
->helperText('Chiave del gruppo nelle opzioni e nel preventivo (es. soglia_ribassata). Non modificarlo se il gruppo ha già opzioni.')
|
||||
->required()
|
||||
->alphaDash()
|
||||
->maxLength(50)
|
||||
->unique(ignoreRecord: true)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Forms\Set $set, Forms\Get $get, ?string $state) => blank($get('modulo')) ? $set('modulo', $state) : null),
|
||||
Forms\Components\TextInput::make('etichetta')
|
||||
->label('Titolo nel configuratore')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('ui')
|
||||
->label('Controllo')
|
||||
->options(GruppoOpzioni::UI)
|
||||
->default('select')
|
||||
->required(),
|
||||
Forms\Components\Select::make('sezione')
|
||||
->label('Sezione del menu')
|
||||
->options(GruppoOpzioni::SEZIONI)
|
||||
->default('accessori')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('modulo')
|
||||
->label('Modulo')
|
||||
->helperText('Il gruppo compare nei modelli che hanno questo modulo tra i "Moduli" (scheda del modello).')
|
||||
->required()
|
||||
->alphaDash()
|
||||
->maxLength(50)
|
||||
->notIn(array_keys(Modello::MODULI))
|
||||
->validationMessages(['not_in' => 'È un modulo base: scegline uno dedicato al gruppo.']),
|
||||
Forms\Components\TextInput::make('ordine')->label('Ordine')->numeric()->minValue(0)->default(0),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('ordine')
|
||||
->reorderable('ordine')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('etichetta')->label('Titolo')->description(fn (GruppoOpzioni $record) => $record->codice)->searchable(['etichetta', 'codice']),
|
||||
Tables\Columns\TextColumn::make('ui')->label('Controllo')->formatStateUsing(fn (string $state) => GruppoOpzioni::UI[$state] ?? $state)->wrap(),
|
||||
Tables\Columns\TextColumn::make('sezione')->label('Sezione')->badge()->formatStateUsing(fn (string $state) => ucfirst($state)),
|
||||
Tables\Columns\TextColumn::make('modulo')->label('Modulo')->color('gray'),
|
||||
Tables\Columns\TextColumn::make('opzioni_count')->label('Opzioni')->counts('opzioni'),
|
||||
Tables\Columns\TextColumn::make('modelli')
|
||||
->label('Modelli')
|
||||
->state(fn (GruppoOpzioni $record) => Modello::whereJsonContains('moduli', $record->modulo)->count()),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()->iconButton(),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->iconButton()
|
||||
->disabled(fn (GruppoOpzioni $record) => $record->codice === 'tipo_finitura' || $record->opzioni()->exists())
|
||||
->tooltip(fn (GruppoOpzioni $record) => $record->opzioni()->exists() ? 'Elimina prima le opzioni del gruppo' : null),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManageGruppiOpzioni::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GruppoOpzioniResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GruppoOpzioniResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageGruppiOpzioni extends ManageRecords
|
||||
{
|
||||
protected static string $resource = GruppoOpzioniResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ModelloResource\Pages;
|
||||
use App\Filament\Resources\ModelloResource\RelationManagers\FinitureRelationManager;
|
||||
use App\Filament\Resources\ModelloResource\RelationManagers\ListinoRelationManager;
|
||||
use App\Filament\Resources\ModelloResource\RelationManagers\OpzioniRelationManager;
|
||||
use App\Models\Modello;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
@@ -11,6 +13,7 @@ use Filament\Forms\Get;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
class ModelloResource extends Resource
|
||||
@@ -61,6 +64,11 @@ class ModelloResource extends Resource
|
||||
Forms\Components\TextInput::make('descrizione')
|
||||
->label('Descrizione')
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('titolo')
|
||||
->label('Titolo nel configuratore')
|
||||
->helperText('Intestazione del menu e nome nel preventivo; se vuoto si usa la descrizione.')
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\FileUpload::make('icona')
|
||||
->label('Icona')
|
||||
->image()
|
||||
@@ -104,9 +112,20 @@ class ModelloResource extends Resource
|
||||
->helperText('Se vuota si usa la cartella dati.'),
|
||||
Forms\Components\CheckboxList::make('moduli')
|
||||
->label('Moduli')
|
||||
->options(Modello::MODULI)
|
||||
->helperText('Sezioni del menu del configuratore e voci che entrano nel prezzo.')
|
||||
->options(fn () => Modello::moduliDisponibili())
|
||||
->columns(3)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\Select::make('regola_vetro_id')
|
||||
->label('Regola vetro')
|
||||
->helperText('Geometria delle lastre per la variazione di prezzo del vetro. Vuota: nessuna variazione.')
|
||||
->relationship(
|
||||
'regolaVetro',
|
||||
'descrizione',
|
||||
modifyQueryUsing: fn (Builder $query, Get $get) => $query->where('sistema_id', $get('sistema_id'))->orderBy('codice'),
|
||||
)
|
||||
->getOptionLabelFromRecordUsing(fn ($record) => "{$record->codice} — {$record->descrizione}")
|
||||
->preload(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -114,13 +133,24 @@ class ModelloResource extends Resource
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('ordine')
|
||||
// Ordine del catalogo (quello del configuratore), con i disattivi in fondo.
|
||||
->defaultSort(fn (Builder $query) => $query->orderByDesc('attivo')->orderBy('ordine'))
|
||||
->reorderable('ordine')
|
||||
->reorderRecordsTriggerAction(fn (Tables\Actions\Action $action, bool $isReordering) => $action
|
||||
->button()
|
||||
->icon($isReordering ? 'heroicon-m-check' : 'heroicon-m-arrows-up-down')
|
||||
->label($isReordering ? 'Fine riordino' : 'Riordina'))
|
||||
->defaultGroup('sistema.nome')
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(48),
|
||||
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->copyable(),
|
||||
Tables\Columns\TextColumn::make('nome')->label('Nome')->searchable(),
|
||||
// Nome a capo su due righe e codice sotto, nella stessa cella: evita lo
|
||||
// scroll orizzontale (i codici, senza spazi, non possono andare a capo da soli).
|
||||
Tables\Columns\TextColumn::make('nome')
|
||||
->label('Nome / codice')
|
||||
->description(fn (Modello $record) => $record->codice)
|
||||
->searchable(['nome', 'codice'])
|
||||
->wrap()
|
||||
->extraAttributes(['style' => 'min-width: 11rem; max-width: 14rem; overflow-wrap: anywhere']),
|
||||
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('categoria')
|
||||
->label('Categoria')
|
||||
@@ -131,7 +161,11 @@ class ModelloResource extends Resource
|
||||
->label('Listino')
|
||||
->counts('listino')
|
||||
->formatStateUsing(fn (int $state) => $state ? "{$state} prezzi" : 'Nessuno')
|
||||
->color(fn (int $state) => $state ? null : 'gray')
|
||||
// Senza listino il modello non compare nel configuratore.
|
||||
->badge(fn (int $state) => ! $state)
|
||||
->color(fn (int $state) => $state ? null : 'danger')
|
||||
->icon(fn (int $state) => $state ? null : 'heroicon-m-exclamation-triangle')
|
||||
->tooltip(fn (int $state) => $state ? null : 'Senza listino: non compare nel configuratore')
|
||||
->description(fn (Modello $record) => $record->listino_aggiornato_at?->format('d/m/Y'))
|
||||
->sortable(),
|
||||
Tables\Columns\ToggleColumn::make('attivo')
|
||||
@@ -142,10 +176,19 @@ class ModelloResource extends Resource
|
||||
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
|
||||
Tables\Filters\SelectFilter::make('categoria')->label('Categoria')->options(Modello::CATEGORIE),
|
||||
Tables\Filters\TernaryFilter::make('attivo')->label('Attivo'),
|
||||
Tables\Filters\TernaryFilter::make('listino')
|
||||
->label('Listino')
|
||||
->trueLabel('Con listino')
|
||||
->falseLabel('Senza listino (non nel configuratore)')
|
||||
->queries(
|
||||
true: fn (Builder $query) => $query->whereHas('listino'),
|
||||
false: fn (Builder $query) => $query->whereDoesntHave('listino'),
|
||||
),
|
||||
])
|
||||
// Solo icone (con tooltip): le etichette allargherebbero la tabella.
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
Tables\Actions\EditAction::make()->iconButton(),
|
||||
Tables\Actions\DeleteAction::make()->iconButton(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
@@ -158,6 +201,8 @@ class ModelloResource extends Resource
|
||||
{
|
||||
return [
|
||||
ListinoRelationManager::class,
|
||||
FinitureRelationManager::class,
|
||||
OpzioniRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModelloResource\RelationManagers;
|
||||
|
||||
use App\Models\Finitura;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Finiture del profilo del sistema e, per ciascuna, la scelta del modello: come il
|
||||
* sistema (nessuna riga in finiture_modelli) oppure lato proprio o disattivata.
|
||||
*/
|
||||
class FinitureRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'finitureDelSistema';
|
||||
|
||||
protected static ?string $title = 'Finiture';
|
||||
|
||||
protected static ?string $modelLabel = 'finitura';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'finiture';
|
||||
|
||||
/** Scelte del modello per finitura_id, lette una volta per richiesta. */
|
||||
protected ?\Illuminate\Support\Collection $scelte = null;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->description('Per ogni finitura del profilo scegli se nel modello vale l\'impostazione del sistema (lato e attivazione in "Finiture e maniglie") o una propria: un lato diverso, oppure disattivata. La scelta del modello può anche attivare una finitura disattiva nel sistema.')
|
||||
->defaultSort('ordine')
|
||||
->defaultPaginationPageOption(50)
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('anteprima')->label('Anteprima')->disk('public')->height(36),
|
||||
Tables\Columns\TextColumn::make('descrizione')
|
||||
->label('Finitura')
|
||||
->description(fn (Finitura $record) => $record->codice)
|
||||
->searchable(['descrizione', 'codice']),
|
||||
Tables\Columns\TextColumn::make('sistema')
|
||||
->label('Nel sistema')
|
||||
->state(fn (Finitura $record) => $record->attivo ? Finitura::LATI[$record->lato] : 'Disattiva')
|
||||
->color('gray'),
|
||||
Tables\Columns\SelectColumn::make('scelta_modello')
|
||||
->label('In questo modello')
|
||||
->options(Finitura::LATI_MODELLO)
|
||||
->placeholder('Come il sistema')
|
||||
->getStateUsing(fn (Finitura $record) => $this->scelte()->get($record->id))
|
||||
->updateStateUsing(function (Finitura $record, ?string $state) {
|
||||
$this->salvaScelta([$record->id], $state);
|
||||
|
||||
return $state;
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('effettiva')
|
||||
->label('Risultato')
|
||||
->state(function (Finitura $record) {
|
||||
$lato = $this->scelte()->get($record->id) ?? ($record->attivo ? $record->lato : 'nessuno');
|
||||
|
||||
return $lato === 'nessuno' ? 'Non disponibile' : Finitura::LATI[$lato];
|
||||
})
|
||||
->badge()
|
||||
->color(fn (string $state) => $state === 'Non disponibile' ? 'danger' : 'success'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('personalizzata')
|
||||
->label('Scelta del modello')
|
||||
->placeholder('Tutte')
|
||||
->trueLabel('Personalizzate')
|
||||
->falseLabel('Come il sistema')
|
||||
->queries(
|
||||
true: fn ($query) => $query->whereIn('id', $this->scelte()->keys()),
|
||||
false: fn ($query) => $query->whereNotIn('id', $this->scelte()->keys()),
|
||||
),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\Action::make('ripristina')
|
||||
->label('Tutte come il sistema')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Elimina le scelte del modello: per tutte le finiture torna a valere l\'impostazione del sistema.')
|
||||
->visible(fn () => $this->scelte()->isNotEmpty())
|
||||
->action(function () {
|
||||
DB::table('finiture_modelli')->where('modello_id', $this->getOwnerRecord()->getKey())->delete();
|
||||
$this->scelte = null;
|
||||
|
||||
Notification::make()->title('Finiture ripristinate come nel sistema')->success()->send();
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkAction::make('imposta')
|
||||
->label('Imposta nel modello')
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->form([
|
||||
Forms\Components\Select::make('lato')
|
||||
->label('In questo modello')
|
||||
->options(['sistema' => 'Come il sistema'] + Finitura::LATI_MODELLO)
|
||||
->required(),
|
||||
])
|
||||
->action(fn (Collection $records, array $data) => $this->salvaScelta(
|
||||
$records->modelKeys(),
|
||||
$data['lato'] === 'sistema' ? null : $data['lato'],
|
||||
))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isReadOnly(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
private function scelte(): \Illuminate\Support\Collection
|
||||
{
|
||||
return $this->scelte ??= DB::table('finiture_modelli')
|
||||
->where('modello_id', $this->getOwnerRecord()->getKey())
|
||||
->pluck('lato', 'finitura_id');
|
||||
}
|
||||
|
||||
/** null = come il sistema (rimuove la scelta del modello). */
|
||||
private function salvaScelta(array $finiture, ?string $lato): void
|
||||
{
|
||||
$modello = $this->getOwnerRecord()->getKey();
|
||||
$righe = DB::table('finiture_modelli')->where('modello_id', $modello)->whereIn('finitura_id', $finiture);
|
||||
|
||||
if ($lato === null || $lato === '') {
|
||||
$righe->delete();
|
||||
} else {
|
||||
abort_unless(array_key_exists($lato, Finitura::LATI_MODELLO), 422);
|
||||
DB::table('finiture_modelli')->upsert(
|
||||
array_map(fn ($id) => ['modello_id' => $modello, 'finitura_id' => $id, 'lato' => $lato, 'created_at' => now(), 'updated_at' => now()], $finiture),
|
||||
['modello_id', 'finitura_id'],
|
||||
['lato', 'updated_at'],
|
||||
);
|
||||
}
|
||||
|
||||
$this->scelte = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModelloResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\OpzioneResource;
|
||||
use App\Models\GruppoOpzioni;
|
||||
use App\Models\Opzione;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Opzioni personalizzate del modello: se il modello ha almeno una riga in un gruppo,
|
||||
* per quel modello il gruppo del sistema è sostituito per intero.
|
||||
*/
|
||||
class OpzioniRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'opzioni';
|
||||
|
||||
protected static ?string $title = 'Opzioni personalizzate';
|
||||
|
||||
protected static ?string $modelLabel = 'opzione';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'opzioni';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form->schema(OpzioneResource::campi(conSistema: false, modello: fn () => $this->getOwnerRecord()));
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return OpzioneResource::tabella($table)
|
||||
->description('I gruppi presenti qui sostituiscono, per questo modello, quelli definiti in "Opzioni e supplementi" del sistema (es. icone di apertura proprie o un supplemento diverso). Eliminando tutte le righe di un gruppo torna a valere quello del sistema.')
|
||||
->headerActions([
|
||||
Tables\Actions\Action::make('personalizza')
|
||||
->label('Personalizza gruppo')
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->color('gray')
|
||||
->modalDescription('Copia nel modello le opzioni del gruppo definite sul sistema, da modificare qui.')
|
||||
->form([
|
||||
Forms\Components\Select::make('gruppo')
|
||||
->label('Gruppo')
|
||||
->options(fn () => $this->gruppiPersonalizzabili())
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $data) {
|
||||
$copiate = $this->copiaGruppo($data['gruppo']);
|
||||
|
||||
Notification::make()->title("Copiate {$copiate} opzioni dal sistema")->success()->send();
|
||||
}),
|
||||
Tables\Actions\CreateAction::make()
|
||||
->mutateFormDataUsing(fn (array $data) => $this->conIconaDelSistema($data) + ['sistema_id' => $this->getOwnerRecord()->sistema_id]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isReadOnly(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Gruppi del sistema non ancora personalizzati per il modello. */
|
||||
private function gruppiPersonalizzabili(): array
|
||||
{
|
||||
$modello = $this->getOwnerRecord();
|
||||
$personalizzati = $modello->opzioni()->distinct()->pluck('gruppo')->all();
|
||||
|
||||
return Opzione::query()
|
||||
->where('sistema_id', $modello->sistema_id)
|
||||
->whereNull('modello_id')
|
||||
->whereNotIn('gruppo', $personalizzati)
|
||||
->distinct()
|
||||
->pluck('gruppo')
|
||||
->mapWithKeys(fn (string $g) => [$g => GruppoOpzioni::where('codice', $g)->value('etichetta') ?? $g])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function copiaGruppo(string $gruppo): int
|
||||
{
|
||||
$modello = $this->getOwnerRecord();
|
||||
|
||||
$righe = Opzione::query()
|
||||
->where('sistema_id', $modello->sistema_id)
|
||||
->whereNull('modello_id')
|
||||
->where('gruppo', $gruppo)
|
||||
->get();
|
||||
|
||||
DB::transaction(function () use ($righe, $modello) {
|
||||
foreach ($righe as $riga) {
|
||||
$copia = $riga->replicate(['icona']);
|
||||
$copia->modello_id = $modello->getKey();
|
||||
$copia->icona = $this->copiaIcona($riga);
|
||||
$copia->save();
|
||||
}
|
||||
});
|
||||
|
||||
return $righe->count();
|
||||
}
|
||||
|
||||
/** Nuova opzione con un valore del sistema e senza icona: prende una copia di quella del sistema. */
|
||||
private function conIconaDelSistema(array $data): array
|
||||
{
|
||||
if (blank($data['icona'] ?? null)) {
|
||||
$origine = OpzioneResource::catalogo($this->getOwnerRecord(), $data['gruppo'] ?? null)->firstWhere('valore', $data['valore'] ?? null);
|
||||
$data['icona'] = $origine ? $this->copiaIcona($origine) : null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Icona duplicata: ogni record ha i suoi file (vedi HaImmaginiCatalogo). */
|
||||
private function copiaIcona(Opzione $origine): ?string
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
if (! $origine->icona || ! $disco->exists($origine->icona)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$percorso = 'configuratore/opzioni/'.Str::random(20).'.'.pathinfo($origine->icona, PATHINFO_EXTENSION);
|
||||
$disco->copy($origine->icona, $percorso);
|
||||
|
||||
return $percorso;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OpzioneResource\Pages;
|
||||
use App\Filament\Support\CampiConfiguratore;
|
||||
use App\Models\GruppoOpzioni;
|
||||
use App\Models\Modello;
|
||||
use App\Models\Opzione;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Forms\Set;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
/** Opzioni e supplementi del sistema (le personalizzazioni per modello sono nella scheda del modello). */
|
||||
class OpzioneResource extends Resource
|
||||
{
|
||||
/** Voce "Altro" del valore nelle opzioni personalizzate: valore scritto a mano. */
|
||||
public const ALTRO = '__altro';
|
||||
|
||||
protected static ?string $model = Opzione::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-adjustments-horizontal';
|
||||
|
||||
protected static ?string $slug = 'opzioni';
|
||||
|
||||
protected static ?string $navigationGroup = 'Configuratore';
|
||||
|
||||
protected static ?string $navigationLabel = 'Opzioni e supplementi';
|
||||
|
||||
protected static ?string $modelLabel = 'opzione';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'opzioni';
|
||||
|
||||
protected static ?int $navigationSort = 23;
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->whereNull('modello_id');
|
||||
}
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema(self::campi(conSistema: true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Campi dell'opzione; senza sistema (relation manager del modello) l'unicità è sul modello
|
||||
* e il valore si sceglie tra quelli del gruppo sul sistema, o "Altro" per scriverlo a mano.
|
||||
*
|
||||
* @param \Closure(): Modello|null $modello
|
||||
*/
|
||||
public static function campi(bool $conSistema, ?\Closure $modello = null): array
|
||||
{
|
||||
$unicita = fn (Unique $rule, Get $get) => self::unicita($rule, $get, $modello);
|
||||
$finitura = fn (Get $get) => $get('gruppo') === 'tipo_finitura';
|
||||
|
||||
return [
|
||||
Forms\Components\Section::make()
|
||||
->columns(2)
|
||||
->schema(array_values(array_filter([
|
||||
$conSistema ? CampiConfiguratore::sistema() : null,
|
||||
Forms\Components\Select::make('gruppo')
|
||||
->label('Gruppo')
|
||||
->options(fn () => GruppoOpzioni::etichette())
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(function (Set $set) {
|
||||
$set('valore', null);
|
||||
$set('valore_altro', null);
|
||||
}),
|
||||
$modello ? null : Forms\Components\Select::make('valore')
|
||||
->label('Valore')
|
||||
->options(Opzione::TIPI_FINITURA)
|
||||
->helperText('Il tipo finitura è calcolato dalle finiture interna ed esterna scelte.')
|
||||
->visible($finitura)
|
||||
->required()
|
||||
->unique(ignoreRecord: true, modifyRuleUsing: $unicita),
|
||||
$modello ? null : Forms\Components\TextInput::make('valore')
|
||||
->label('Valore')
|
||||
->helperText('Identificativo inviato dal configuratore (es. dx, telaio_l).')
|
||||
->hidden($finitura)
|
||||
->required()
|
||||
->alphaDash()
|
||||
->maxLength(100)
|
||||
->unique(ignoreRecord: true, modifyRuleUsing: $unicita),
|
||||
$modello ? Forms\Components\Select::make('valore')
|
||||
->label('Valore')
|
||||
->helperText(fn (Get $get) => $finitura($get)
|
||||
? 'Il tipo finitura è calcolato dalle finiture interna ed esterna scelte.'
|
||||
: 'Valori del gruppo definiti sul sistema: sceglierne uno ne precompila i dati. "Altro" per un valore nuovo.')
|
||||
// Un solo campo per tutti i gruppi: con due campi "valore" Filament aggiorna solo il primo.
|
||||
->options(fn (Get $get) => $finitura($get)
|
||||
? Opzione::TIPI_FINITURA
|
||||
: self::catalogo($modello(), $get('gruppo'))
|
||||
->mapWithKeys(fn (Opzione $o) => [$o->valore => "{$o->etichetta} ({$o->valore})"])
|
||||
->put(self::ALTRO, 'Altro…')
|
||||
->all())
|
||||
->required()
|
||||
->live()
|
||||
->unique(ignoreRecord: true, modifyRuleUsing: $unicita)
|
||||
// Valore non presente sul sistema: in modifica compare come "Altro".
|
||||
->afterStateHydrated(function (Forms\Components\Select $component, ?string $state, Get $get, Set $set) use ($modello) {
|
||||
if (filled($state) && $get('gruppo') !== 'tipo_finitura'
|
||||
&& ! self::catalogo($modello(), $get('gruppo'))->contains('valore', $state)) {
|
||||
$component->state(self::ALTRO);
|
||||
$set('valore_altro', $state);
|
||||
}
|
||||
})
|
||||
->afterStateUpdated(fn (?string $state, Get $get, Set $set) => self::precompila($modello(), $get('gruppo'), $state, $set))
|
||||
->dehydrateStateUsing(fn (?string $state, Get $get) => $state === self::ALTRO ? $get('valore_altro') : $state) : null,
|
||||
$modello ? Forms\Components\TextInput::make('valore_altro')
|
||||
->label('Nuovo valore')
|
||||
->helperText('Identificativo inviato dal configuratore (es. dx, telaio_l).')
|
||||
->visible(fn (Get $get) => $get('valore') === self::ALTRO)
|
||||
->required()
|
||||
->alphaDash()
|
||||
->maxLength(100)
|
||||
->notIn([self::ALTRO])
|
||||
->unique(table: Opzione::class, column: 'valore', ignoreRecord: true, modifyRuleUsing: $unicita)
|
||||
->dehydrated(false) : null,
|
||||
Forms\Components\TextInput::make('etichetta')->label('Etichetta')->required()->maxLength(255),
|
||||
Forms\Components\TextInput::make('alternativa')
|
||||
->label('Alternativa a')
|
||||
->helperText('Le opzioni del gruppo con lo stesso valore si escludono a vicenda (es. "coprifili": piatti o scatolati).')
|
||||
->maxLength(50)
|
||||
->visible(fn (Get $get) => GruppoOpzioni::where('codice', $get('gruppo'))->value('ui') === 'multipla'),
|
||||
Forms\Components\Toggle::make('attivo')->label('Attiva')->default(true)->inline(false),
|
||||
]))),
|
||||
Forms\Components\Section::make('Supplemento')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('supplemento_tipo')
|
||||
->label('Tipo')
|
||||
->options(Opzione::SUPPLEMENTI)
|
||||
->placeholder('Nessun supplemento')
|
||||
->live(),
|
||||
Forms\Components\TextInput::make('supplemento')
|
||||
->label('Valore')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->default(0)
|
||||
->required()
|
||||
->prefix(fn (Get $get) => in_array($get('supplemento_tipo'), ['fisso', 'metro_lineare'], true) ? '€' : null)
|
||||
->suffix(fn (Get $get) => match ($get('supplemento_tipo')) {
|
||||
'percentuale' => '%',
|
||||
'metro_lineare' => '/ml',
|
||||
default => null,
|
||||
})
|
||||
->visible(fn (Get $get) => filled($get('supplemento_tipo'))),
|
||||
Forms\Components\Fieldset::make('Metri lineari')
|
||||
->columns(3)
|
||||
->visible(fn (Get $get) => $get('supplemento_tipo') === 'metro_lineare')
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('formula_ml')
|
||||
->hiddenLabel()
|
||||
->content('ml = (L × moltiplicatore L + H × moltiplicatore H + aggiunta) ÷ 1000, con L e H del serramento in mm. Es. "H + 100 mm, 2 pezzi": moltiplicatore H 2, aggiunta 200.')
|
||||
->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('ml_per_larghezza')->label('Moltiplicatore L')->numeric()->step(0.001)->default(0)->required(),
|
||||
Forms\Components\TextInput::make('ml_per_altezza')->label('Moltiplicatore H')->numeric()->step(0.001)->default(0)->required(),
|
||||
Forms\Components\TextInput::make('ml_aggiunta_mm')->label('Aggiunta')->numeric()->step(1)->suffix('mm')->default(0)->required(),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Section::make('Configuratore')
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->hidden(fn (Get $get) => $get('gruppo') === 'tipo_finitura')
|
||||
->schema([
|
||||
CampiConfiguratore::immagine('icona', 'Icona', 'opzioni'),
|
||||
Forms\Components\KeyValue::make('parametri')
|
||||
->label('Parametri')
|
||||
->helperText('Letti dal configuratore: scale_x (1 / -1) per l\'apertura, func e categoria per le select.')
|
||||
->keyLabel('Nome')
|
||||
->valueLabel('Valore'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return self::tabella($table)
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
|
||||
Tables\Filters\SelectFilter::make('gruppo')->label('Gruppo')->options(fn () => GruppoOpzioni::etichette()),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Tabella condivisa con le opzioni personalizzate del modello. */
|
||||
public static function tabella(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('ordine')
|
||||
->reorderable('ordine')
|
||||
->defaultGroup('gruppo')
|
||||
->groups([
|
||||
Tables\Grouping\Group::make('gruppo')
|
||||
->label('Gruppo')
|
||||
->getTitleFromRecordUsing(fn (Opzione $record) => $record->definizione?->etichetta ?? $record->gruppo),
|
||||
])
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(40),
|
||||
Tables\Columns\TextColumn::make('etichetta')->label('Etichetta')->searchable(),
|
||||
Tables\Columns\TextColumn::make('valore')->label('Valore')->searchable()->color('gray'),
|
||||
Tables\Columns\TextColumn::make('alternativa')->label('Alternativa')->badge()->color('gray')->placeholder('—')->toggleable(),
|
||||
Tables\Columns\TextColumn::make('supplemento')
|
||||
->label('Supplemento')
|
||||
->state(fn (Opzione $record) => $record->descriviSupplemento()),
|
||||
Tables\Columns\ToggleColumn::make('attivo')->label('Attiva'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()->iconButton(),
|
||||
Tables\Actions\DeleteAction::make()->iconButton(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManageOpzioni::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function unicita(Unique $rule, Get $get, ?\Closure $modello): Unique
|
||||
{
|
||||
$rule->where('gruppo', $get('gruppo'));
|
||||
|
||||
return $modello
|
||||
? $rule->where('modello_id', $modello()->getKey())
|
||||
: $rule->where('sistema_id', $get('sistema_id'))->whereNull('modello_id');
|
||||
}
|
||||
|
||||
/** Opzioni del gruppo definite sul sistema del modello. */
|
||||
public static function catalogo(Modello $modello, ?string $gruppo): Collection
|
||||
{
|
||||
return Opzione::query()
|
||||
->where('sistema_id', $modello->sistema_id)
|
||||
->whereNull('modello_id')
|
||||
->where('gruppo', $gruppo)
|
||||
->orderBy('ordine')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** Copia nel form i dati dell'opzione del sistema scelta come valore. */
|
||||
private static function precompila(Modello $modello, ?string $gruppo, ?string $valore, Set $set): void
|
||||
{
|
||||
$set('valore_altro', null);
|
||||
$origine = self::catalogo($modello, $gruppo)->firstWhere('valore', $valore);
|
||||
if (! $origine) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['etichetta', 'alternativa', 'supplemento_tipo', 'supplemento', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm', 'parametri'] as $campo) {
|
||||
$set($campo, $origine->{$campo});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OpzioneResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OpzioneResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageOpzioni extends ManageRecords
|
||||
{
|
||||
protected static string $resource = OpzioneResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\RegolaVetroResource\Pages;
|
||||
use App\Filament\Support\CampiConfiguratore;
|
||||
use App\Models\RegolaVetro;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
class RegolaVetroResource extends Resource
|
||||
{
|
||||
protected static ?string $model = RegolaVetro::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-calculator';
|
||||
|
||||
protected static ?string $slug = 'regole-vetro';
|
||||
|
||||
protected static ?string $navigationGroup = 'Configuratore';
|
||||
|
||||
protected static ?string $navigationLabel = 'Regole vetro';
|
||||
|
||||
protected static ?string $modelLabel = 'regola vetro';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'regole vetro';
|
||||
|
||||
protected static ?int $navigationSort = 22;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
CampiConfiguratore::sistema(),
|
||||
Forms\Components\TextInput::make('codice')
|
||||
->label('Codice')
|
||||
->helperText('Es. F2, TR_A_F.')
|
||||
->required()
|
||||
->maxLength(50)
|
||||
->unique(
|
||||
ignoreRecord: true,
|
||||
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('sistema_id', $get('sistema_id')),
|
||||
),
|
||||
Forms\Components\TextInput::make('descrizione')->label('Tipologia')->required()->maxLength(255)->columnSpanFull(),
|
||||
]),
|
||||
Forms\Components\Section::make('Lastre')
|
||||
->description('Per ogni riga: larghezza vetro LV = (L − detrazione) ÷ divisore, altezza vetro HV = H − detrazione, ripetuta per la quantità. Es. L/2 − 181 si scrive detrazione 362, divisore 2.')
|
||||
->schema([
|
||||
Forms\Components\Repeater::make('lastre')
|
||||
->hiddenLabel()
|
||||
->columns(5)
|
||||
->minItems(1)
|
||||
->defaultItems(1)
|
||||
->reorderable(false)
|
||||
->live(onBlur: true)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->placeholder('es. Vetro anta')->maxLength(100),
|
||||
Forms\Components\TextInput::make('quantita')->label('Quantità')->integer()->minValue(1)->default(1)->required(),
|
||||
Forms\Components\TextInput::make('divisore')->label('Divisore L')->integer()->minValue(1)->default(1)->required(),
|
||||
Forms\Components\TextInput::make('detrazione_larghezza')->label('Detrazione L (mm)')->numeric()->default(0)->required(),
|
||||
Forms\Components\TextInput::make('detrazione_altezza')->label('Detrazione H (mm)')->numeric()->default(0)->required(),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Section::make('Prova')
|
||||
->description('Verifica la regola su una misura: non viene salvata.')
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('prova_larghezza')->label('Larghezza L (mm)')->integer()->default(1200)->dehydrated(false)->live(onBlur: true),
|
||||
Forms\Components\TextInput::make('prova_altezza')->label('Altezza H (mm)')->integer()->default(1400)->dehydrated(false)->live(onBlur: true),
|
||||
Forms\Components\Placeholder::make('prova_risultato')
|
||||
->label('Lastre risultanti')
|
||||
->content(fn (Get $get) => self::anteprima($get('lastre') ?? [], (float) $get('prova_larghezza'), (float) $get('prova_altezza'))),
|
||||
]),
|
||||
Forms\Components\Textarea::make('note')->label('Note')->rows(2)->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('codice')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('descrizione')->label('Tipologia')->searchable()->wrap(),
|
||||
Tables\Columns\TextColumn::make('lastre')
|
||||
->label('Formula')
|
||||
->state(fn (RegolaVetro $record) => self::formula($record->lastre ?? []))
|
||||
->html(),
|
||||
Tables\Columns\TextColumn::make('modelli_count')->label('Modelli')->counts('modelli'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()->iconButton(),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->iconButton()
|
||||
->modalDescription('I modelli che usano questa regola non avranno più variazione di prezzo per il vetro.'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManageRegoleVetro::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
/** Formula leggibile, una riga per lastra (es. "2 × (L − 312) ÷ 2 × (H − 189)"). */
|
||||
private static function formula(array $lastre): HtmlString
|
||||
{
|
||||
$righe = array_map(function (array $l) {
|
||||
$larghezza = 'L'.self::meno($l['detrazione_larghezza'] ?? 0);
|
||||
if ((int) ($l['divisore'] ?? 1) > 1) {
|
||||
$larghezza = "({$larghezza}) ÷ {$l['divisore']}";
|
||||
}
|
||||
$descrizione = filled($l['descrizione'] ?? null) ? e($l['descrizione']).': ' : '';
|
||||
|
||||
return $descrizione.((int) ($l['quantita'] ?? 1)).' × '.$larghezza.' × (H'.self::meno($l['detrazione_altezza'] ?? 0).')';
|
||||
}, $lastre);
|
||||
|
||||
return new HtmlString(implode('<br>', $righe));
|
||||
}
|
||||
|
||||
private static function meno(mixed $detrazione): string
|
||||
{
|
||||
return (float) $detrazione ? ' − '.(float) $detrazione : '';
|
||||
}
|
||||
|
||||
private static function anteprima(array $lastre, float $larghezza, float $altezza): HtmlString
|
||||
{
|
||||
if ($larghezza <= 0 || $altezza <= 0 || ! $lastre) {
|
||||
return new HtmlString('—');
|
||||
}
|
||||
|
||||
$calcolate = (new RegolaVetro(['lastre' => array_values($lastre)]))->calcolaLastre($larghezza, $altezza);
|
||||
|
||||
return new HtmlString(implode('<br>', array_map(fn (array $l) => sprintf(
|
||||
'%s%d × %s × %s mm = %s m² cad.',
|
||||
filled($l['descrizione']) ? e($l['descrizione']).': ' : '',
|
||||
$l['quantita'],
|
||||
number_format($l['larghezza'], 1, ',', '.'),
|
||||
number_format($l['altezza'], 1, ',', '.'),
|
||||
number_format(max(0, $l['larghezza']) * max(0, $l['altezza']) / 1_000_000, 3, ',', '.'),
|
||||
), $calcolate)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RegolaVetroResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RegolaVetroResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageRegoleVetro extends ManageRecords
|
||||
{
|
||||
protected static string $resource = RegolaVetroResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\VetroResource\Pages;
|
||||
use App\Filament\Support\CampiConfiguratore;
|
||||
use App\Models\Vetro;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
class VetroResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Vetro::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-square-2-stack';
|
||||
|
||||
protected static ?string $slug = 'vetri';
|
||||
|
||||
protected static ?string $navigationGroup = 'Configuratore';
|
||||
|
||||
protected static ?string $navigationLabel = 'Vetri e riempimenti';
|
||||
|
||||
protected static ?string $modelLabel = 'vetro';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'vetri';
|
||||
|
||||
protected static ?int $navigationSort = 21;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
CampiConfiguratore::sistema(),
|
||||
Forms\Components\TextInput::make('codice')
|
||||
->label('Codice')
|
||||
->helperText('Identificativo inviato dal configuratore (es. V3).')
|
||||
->required()
|
||||
->maxLength(50)
|
||||
->unique(
|
||||
ignoreRecord: true,
|
||||
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('sistema_id', $get('sistema_id')),
|
||||
),
|
||||
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->required()->maxLength(255)->columnSpanFull(),
|
||||
Forms\Components\Toggle::make('predefinito')->label('Predefinito')->helperText('Selezionato all\'apertura del configuratore.')->inline(false),
|
||||
Forms\Components\Toggle::make('attivo')->label('Attivo')->default(true)->inline(false),
|
||||
]),
|
||||
Forms\Components\Section::make('Prezzo')
|
||||
->description('Variazione = somma per lastra di max(area, minimo fatturabile) x maggiorazione. Le lastre sono calcolate dalla regola vetro del modello.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('maggiorazione_mq')
|
||||
->label('Maggiorazione')
|
||||
->helperText('Negativa per una decurtazione (es. infisso senza vetro).')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->prefix('€')
|
||||
->suffix('/m²')
|
||||
->default(0)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('minimo_mq')
|
||||
->label('Minimo fatturabile per lastra')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->step(0.001)
|
||||
->suffix('m²')
|
||||
->default(0)
|
||||
->required(),
|
||||
]),
|
||||
Forms\Components\Section::make('Immagini')
|
||||
->columns(2)
|
||||
->schema([
|
||||
CampiConfiguratore::immagine('anteprima', 'Icona nel menu', 'vetri'),
|
||||
CampiConfiguratore::immagine('popup', 'Immagine informativa (popup)', 'vetri'),
|
||||
]),
|
||||
CampiConfiguratore::aspetto3d('vetri', opacita: true),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('ordine')
|
||||
->reorderable('ordine')
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('anteprima')->label('Icona')->disk('public')->height(40),
|
||||
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable(),
|
||||
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap(),
|
||||
Tables\Columns\TextColumn::make('maggiorazione_mq')
|
||||
->label('Maggiorazione')
|
||||
->formatStateUsing(fn ($state) => '€ '.number_format((float) $state, 2, ',', '.').'/m²')
|
||||
->color(fn ($state) => $state < 0 ? 'danger' : null)
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('minimo_mq')
|
||||
->label('Minimo')
|
||||
->formatStateUsing(fn ($state) => number_format((float) $state, 2, ',', '.').' m²'),
|
||||
Tables\Columns\IconColumn::make('predefinito')->label('Predefinito')->boolean(),
|
||||
Tables\Columns\ToggleColumn::make('attivo')->label('Attivo'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make()->iconButton(),
|
||||
Tables\Actions\DeleteAction::make()->iconButton(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManageVetri::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\VetroResource\Pages;
|
||||
|
||||
use App\Filament\Resources\VetroResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageVetri extends ManageRecords
|
||||
{
|
||||
protected static string $resource = VetroResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\Sistema;
|
||||
use Filament\Forms;
|
||||
|
||||
/** Campi condivisi dalle risorse del catalogo del configuratore. */
|
||||
class CampiConfiguratore
|
||||
{
|
||||
public static function sistema(): Forms\Components\Select
|
||||
{
|
||||
return Forms\Components\Select::make('sistema_id')
|
||||
->label('Sistema')
|
||||
->relationship('sistema', 'nome')
|
||||
->default(fn () => Sistema::orderBy('ordine')->value('id'))
|
||||
->required()
|
||||
->preload()
|
||||
->live();
|
||||
}
|
||||
|
||||
public static function immagine(string $campo, string $etichetta, string $cartella): Forms\Components\FileUpload
|
||||
{
|
||||
return Forms\Components\FileUpload::make($campo)
|
||||
->label($etichetta)
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory("configuratore/{$cartella}")
|
||||
->maxSize(10240);
|
||||
}
|
||||
|
||||
/** Proprietà del materiale Three.js applicate alle mesh della categoria. */
|
||||
public static function aspetto3d(string $cartella, bool $opacita = false): Forms\Components\Section
|
||||
{
|
||||
return Forms\Components\Section::make('Aspetto 3D')
|
||||
->description('Proprietà del materiale applicate al modello 3D. I campi vuoti lasciano il valore del modello.')
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->schema(array_values(array_filter([
|
||||
Forms\Components\ColorPicker::make('colore')->label('Colore'),
|
||||
$cartella === 'finiture'
|
||||
? Forms\Components\ColorPicker::make('colore_corpo_base')->label('Colore corpo base')
|
||||
: null,
|
||||
Forms\Components\TextInput::make('roughness')->label('Roughness')->numeric()->minValue(0)->maxValue(1)->step(0.01),
|
||||
Forms\Components\TextInput::make('metalness')->label('Metalness')->numeric()->minValue(0)->maxValue(1)->step(0.01),
|
||||
$opacita
|
||||
? Forms\Components\TextInput::make('opacita')->label('Opacità')->numeric()->minValue(0)->maxValue(1)->step(0.01)
|
||||
: null,
|
||||
Forms\Components\TextInput::make('bump_scala')->label('Intensità rilievo (bump)')->numeric()->step(0.1),
|
||||
self::immagine('texture', 'Texture', "{$cartella}/texture"),
|
||||
self::immagine('texture_bump', 'Texture rilievo (bump)', "{$cartella}/texture"),
|
||||
])));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Cliente;
|
||||
use App\Models\Preventivo;
|
||||
use App\Models\PreventivoItem;
|
||||
use App\Support\ConfigurazioneFormatter;
|
||||
use App\Support\Configuratore\CalcolatorePrezzo;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -21,39 +22,57 @@ use Illuminate\Support\Str;
|
||||
class CarrelloController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/carrello/aggiungi
|
||||
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza).
|
||||
* Voci descrittive del configuratore che non incidono sul prezzo: sono le uniche
|
||||
* accettate dal payload del client, tutto il resto lo ricostruisce il server.
|
||||
*/
|
||||
public function aggiungi(Request $request): JsonResponse
|
||||
private const VOCI_DESCRITTIVE = ['altezza_maniglia', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto'];
|
||||
|
||||
/**
|
||||
* POST /api/carrello/aggiungi
|
||||
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza). Il prezzo è
|
||||
* ricalcolato dalle scelte con le regole del backoffice: quello del client è ignorato.
|
||||
*/
|
||||
public function aggiungi(Request $request, CalcolatorePrezzo $calcolatore): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'token' => ['nullable', 'string', 'max:64'],
|
||||
'sistema' => ['nullable', 'string', 'max:50'],
|
||||
'type' => ['nullable', 'string', 'max:100'],
|
||||
'nome' => ['nullable', 'string', 'max:255'],
|
||||
'larghezza' => ['nullable', 'integer', 'min:0'],
|
||||
'altezza' => ['nullable', 'integer', 'min:0'],
|
||||
'sistema' => ['required', 'string', 'max:50'],
|
||||
'type' => ['required', 'string', 'max:100'],
|
||||
'qty' => ['nullable', 'integer', 'min:1', 'max:999'],
|
||||
'prezzo' => ['required', 'numeric', 'min:0'],
|
||||
'scelte' => ['required', 'array'],
|
||||
...CatalogoController::regoleScelte('scelte.'),
|
||||
'payload' => ['nullable', 'array'],
|
||||
'img' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$modello = CatalogoController::trovaModello($data['sistema'], $data['type']);
|
||||
$calcolo = $calcolatore->calcola($modello, $data['scelte']);
|
||||
|
||||
$descrittive = collect($data['payload'] ?? [])
|
||||
->only(self::VOCI_DESCRITTIVE)
|
||||
->filter(fn ($v) => is_scalar($v) && $v !== '')
|
||||
->map(fn ($v) => mb_substr((string) $v, 0, 255))
|
||||
->all();
|
||||
|
||||
$preventivo = $this->risolviCarrello($data['token'] ?? null);
|
||||
|
||||
$qty = $data['qty'] ?? 1;
|
||||
$prezzoUnitario = round((float) $data['prezzo'], 2);
|
||||
$prezzoUnitario = $calcolo['prezzo'];
|
||||
$configurazione = $calcolo['configurazione'];
|
||||
|
||||
$item = new PreventivoItem([
|
||||
'sistema' => $data['sistema'] ?? null,
|
||||
'type' => $data['type'] ?? null,
|
||||
'nome_modello' => $data['nome'] ?? ($data['payload']['nome'] ?? null),
|
||||
'larghezza' => $data['larghezza'] ?? ($data['payload']['width'] ?? null),
|
||||
'altezza' => $data['altezza'] ?? ($data['payload']['height'] ?? null),
|
||||
'sistema' => $modello->sistema->codice,
|
||||
'type' => $modello->codice,
|
||||
'nome_modello' => $configurazione['nome'],
|
||||
'larghezza' => $configurazione['width'],
|
||||
'altezza' => $configurazione['height'],
|
||||
'quantita' => $qty,
|
||||
'prezzo_unitario' => $prezzoUnitario,
|
||||
'prezzo_totale' => round($prezzoUnitario * $qty, 2),
|
||||
'payload' => $data['payload'] ?? [],
|
||||
'payload' => $descrittive + $configurazione + [
|
||||
'scelte' => $calcolo['scelte'],
|
||||
'dettaglio_prezzo' => $calcolo['dettaglio'],
|
||||
],
|
||||
'immagine_path' => $this->salvaImmagine($data['img'] ?? null),
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Modello;
|
||||
use App\Models\Sistema;
|
||||
use App\Support\Configuratore\CalcolatorePrezzo;
|
||||
use App\Support\Configuratore\ConfigurazioneModello;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CatalogoController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/sistemi
|
||||
* Sistemi attivi con i rispettivi modelli attivi, nell'ordine del menu.
|
||||
* Sistemi attivi con i rispettivi modelli attivi e con listino, nell'ordine del menu.
|
||||
*/
|
||||
public function sistemi(): JsonResponse
|
||||
{
|
||||
$sistemi = Sistema::attivi()
|
||||
->with(['modelli' => fn ($q) => $q->attivi()])
|
||||
->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
|
||||
->get();
|
||||
|
||||
return response()->json($sistemi->map(fn (Sistema $s) => $this->sistema($s))->values());
|
||||
@@ -29,7 +32,7 @@ class CatalogoController extends Controller
|
||||
{
|
||||
$sistema = Sistema::attivi()
|
||||
->where('codice', $codice)
|
||||
->with(['modelli' => fn ($q) => $q->attivi()])
|
||||
->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
|
||||
->firstOrFail();
|
||||
|
||||
return response()->json($this->sistema($sistema));
|
||||
@@ -41,12 +44,7 @@ class CatalogoController extends Controller
|
||||
*/
|
||||
public function listino(string $codice, string $modello): JsonResponse
|
||||
{
|
||||
$modello = Modello::attivi()
|
||||
->where('codice', $modello)
|
||||
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
|
||||
->firstOrFail();
|
||||
|
||||
$prezzi = $modello->listino()
|
||||
$prezzi = $this->trovaModello($codice, $modello)->listino()
|
||||
->orderBy('altezza')
|
||||
->orderBy('larghezza')
|
||||
->get(['altezza', 'larghezza', 'prezzo'])
|
||||
@@ -55,6 +53,57 @@ class CatalogoController extends Controller
|
||||
return response()->json($prezzi);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sistemi/{codice}/modelli/{modello}/configurazione
|
||||
* Menu, finiture, vetri, opzioni e misure del modello per il configuratore.
|
||||
*/
|
||||
public function configurazione(string $codice, string $modello, ConfigurazioneModello $configurazione): JsonResponse
|
||||
{
|
||||
return response()->json($configurazione->perModello($this->trovaModello($codice, $modello)));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sistemi/{codice}/modelli/{modello}/prezzo
|
||||
* Prezzo (IVA esclusa) della configurazione scelta; 422 se la misura è fuori listino.
|
||||
*/
|
||||
public function prezzo(Request $request, string $codice, string $modello, CalcolatorePrezzo $calcolatore): JsonResponse
|
||||
{
|
||||
$scelte = $request->validate(self::regoleScelte());
|
||||
|
||||
return response()->json($calcolatore->calcola($this->trovaModello($codice, $modello), $scelte));
|
||||
}
|
||||
|
||||
/** Validazione delle scelte del configuratore, condivisa con il carrello. */
|
||||
public static function regoleScelte(string $prefisso = ''): array
|
||||
{
|
||||
return [
|
||||
"{$prefisso}larghezza" => ['required', 'integer', 'min:1', 'max:100000'],
|
||||
"{$prefisso}altezza" => ['required', 'integer', 'min:1', 'max:100000'],
|
||||
"{$prefisso}finitura_interna" => ['nullable', 'string', 'max:50'],
|
||||
"{$prefisso}finitura_esterna" => ['nullable', 'string', 'max:50'],
|
||||
"{$prefisso}maniglia" => ['nullable', 'string', 'max:50'],
|
||||
"{$prefisso}vetro" => ['nullable', 'string', 'max:50'],
|
||||
"{$prefisso}opzioni" => ['nullable', 'array'],
|
||||
// Un codice per i gruppi a scelta singola, una lista di codici per quelli multipli.
|
||||
"{$prefisso}opzioni.*" => ['nullable', function (string $campo, mixed $valore, \Closure $errore) {
|
||||
$codici = is_array($valore) ? $valore : [$valore];
|
||||
if (count($codici) > 50 || collect($codici)->contains(fn ($c) => ! is_string($c) || mb_strlen($c) > 100)) {
|
||||
$errore('Scelta non valida.');
|
||||
}
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
/** Modello attivo e con listino di un sistema attivo, cercato per codici (404 altrimenti). */
|
||||
public static function trovaModello(string $codice, string $modello): Modello
|
||||
{
|
||||
return Modello::nelConfiguratore()
|
||||
->where('codice', $modello)
|
||||
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
|
||||
->with('sistema', 'regolaVetro')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function sistema(Sistema $sistema): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Configuratore\Media;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class MediaController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/media/{percorso}
|
||||
* Immagini del configuratore dal disco public, con gli header CORS dell'API (necessari
|
||||
* alle texture WebGL caricate da un'altra origine).
|
||||
*/
|
||||
public function __invoke(string $percorso): BinaryFileResponse
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
|
||||
abort_if(
|
||||
str_contains($percorso, '..')
|
||||
|| ! Str::startsWith($percorso, Media::CARTELLE)
|
||||
|| ! $disco->exists($percorso),
|
||||
404
|
||||
);
|
||||
|
||||
return response()->file($disco->path($percorso), [
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Immagini del configuratore salvate sul disco public: rimuove i file sostituiti o del
|
||||
* record eliminato. Ogni record ha percorsi propri, quindi nessun file è condiviso.
|
||||
*
|
||||
* Il modello dichiara le colonne con `protected static array $immagini = [...]`.
|
||||
*/
|
||||
trait HaImmaginiCatalogo
|
||||
{
|
||||
protected static function bootHaImmaginiCatalogo(): void
|
||||
{
|
||||
static::updated(function ($record): void {
|
||||
foreach (static::$immagini as $colonna) {
|
||||
$vecchia = $record->getOriginal($colonna);
|
||||
if ($record->wasChanged($colonna) && $vecchia) {
|
||||
Storage::disk('public')->delete($vecchia);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function ($record): void {
|
||||
$file = array_filter(array_map(fn ($c) => $record->{$c}, static::$immagini));
|
||||
Storage::disk('public')->delete($file);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Modello extends Model
|
||||
@@ -21,23 +22,23 @@ class Modello extends Model
|
||||
'accessori' => 'Accessori',
|
||||
];
|
||||
|
||||
// Moduli dati caricati dal configuratore per il modello.
|
||||
// Moduli base del configuratore (sezioni del menu e voci di prezzo); quelli dei gruppi
|
||||
// di opzioni si aggiungono da GruppoOpzioni (vedi moduliDisponibili).
|
||||
public const MODULI = [
|
||||
'listino' => 'Listino',
|
||||
'modello' => 'Finiture',
|
||||
'finitura_esterna' => 'Finitura esterna (bicolore)',
|
||||
'maniglia' => 'Maniglia',
|
||||
'altezza_maniglia' => 'Altezza maniglia',
|
||||
'vetro' => 'Vetro',
|
||||
'cerniere' => 'Cerniere',
|
||||
'telaio' => 'Telaio',
|
||||
'avvolgibile' => 'Avvolgibile',
|
||||
'select_finiture' => 'Select finiture',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'sistema_id', 'codice', 'nome', 'descrizione', 'icona', 'ordine', 'attivo',
|
||||
'sistema_id', 'codice', 'nome', 'descrizione', 'titolo', 'icona', 'ordine', 'attivo',
|
||||
'cartella', 'file_modello', 'categoria', 'cartella_texture', 'moduli',
|
||||
'listino_aggiornato_at',
|
||||
'regola_vetro_id', 'listino_aggiornato_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -57,18 +58,86 @@ class Modello extends Model
|
||||
return $this->hasMany(ListinoPrezzo::class);
|
||||
}
|
||||
|
||||
public function regolaVetro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RegolaVetro::class);
|
||||
}
|
||||
|
||||
/** Opzioni personalizzate del modello (sostituiscono i gruppi del sistema). */
|
||||
public function opzioni(): HasMany
|
||||
{
|
||||
return $this->hasMany(Opzione::class);
|
||||
}
|
||||
|
||||
/** Tutte le finiture del profilo del sistema (per scegliere quali usare nel modello). */
|
||||
public function finitureDelSistema(): HasMany
|
||||
{
|
||||
return $this->hasMany(Finitura::class, 'sistema_id', 'sistema_id')->where('tipo', 'profilo');
|
||||
}
|
||||
|
||||
/** Moduli selezionabili: quelli base più uno per ogni gruppo di opzioni con un controllo. */
|
||||
public static function moduliDisponibili(): array
|
||||
{
|
||||
$gruppi = GruppoOpzioni::query()
|
||||
->where('ui', '!=', 'nessuna')
|
||||
->orderBy('ordine')
|
||||
->pluck('etichetta', 'modulo')
|
||||
->all();
|
||||
|
||||
return self::MODULI + $gruppi;
|
||||
}
|
||||
|
||||
public function haModulo(string $modulo): bool
|
||||
{
|
||||
return in_array($modulo, $this->moduli ?? [], true);
|
||||
}
|
||||
|
||||
public function scopeAttivi(Builder $query): Builder
|
||||
{
|
||||
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
|
||||
}
|
||||
|
||||
/** Modelli mostrati nel configuratore: attivi e con un listino (senza prezzi non si possono vendere). */
|
||||
public function scopeNelConfiguratore(Builder $query): Builder
|
||||
{
|
||||
return $query->attivi()->whereHas('listino');
|
||||
}
|
||||
|
||||
public function getIconaUrlAttribute(): ?string
|
||||
{
|
||||
return $this->icona ? Storage::disk('public')->url($this->icona) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rinumera l'ordine dei modelli del sistema mettendo i disattivi in fondo,
|
||||
* senza cambiare l'ordine relativo all'interno dei due gruppi.
|
||||
*/
|
||||
public static function normalizzaOrdine(int $sistemaId): void
|
||||
{
|
||||
$ids = static::query()
|
||||
->where('sistema_id', $sistemaId)
|
||||
->orderByDesc('attivo')
|
||||
->orderBy('ordine')
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
DB::transaction(function () use ($ids) {
|
||||
foreach ($ids as $i => $id) {
|
||||
// Query diretta: niente eventi del modello (e niente ricorsione).
|
||||
static::query()->whereKey($id)->update(['ordine' => $i + 1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Attivando o disattivando un modello (o creandone uno), i disattivi tornano in fondo.
|
||||
static::saved(function (Modello $modello): void {
|
||||
if ($modello->wasRecentlyCreated || $modello->wasChanged(['attivo', 'sistema_id'])) {
|
||||
static::normalizzaOrdine($modello->sistema_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Rimuove il file dell'icona sostituita o del modello eliminato.
|
||||
static::updated(function (Modello $modello): void {
|
||||
$vecchia = $modello->getOriginal('icona');
|
||||
|
||||
@@ -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').')';
|
||||
}
|
||||
}
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,27 @@ class Sistema extends Model
|
||||
return $this->hasMany(Modello::class);
|
||||
}
|
||||
|
||||
public function finiture(): HasMany
|
||||
{
|
||||
return $this->hasMany(Finitura::class);
|
||||
}
|
||||
|
||||
public function vetri(): HasMany
|
||||
{
|
||||
return $this->hasMany(Vetro::class);
|
||||
}
|
||||
|
||||
public function regoleVetro(): HasMany
|
||||
{
|
||||
return $this->hasMany(RegolaVetro::class);
|
||||
}
|
||||
|
||||
/** Opzioni del sistema (senza quelle personalizzate per modello). */
|
||||
public function opzioni(): HasMany
|
||||
{
|
||||
return $this->hasMany(Opzione::class)->whereNull('modello_id');
|
||||
}
|
||||
|
||||
public function scopeAttivi(Builder $query): Builder
|
||||
{
|
||||
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HaImmaginiCatalogo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Vetro extends Model
|
||||
{
|
||||
use HaImmaginiCatalogo;
|
||||
|
||||
protected $table = 'vetri';
|
||||
|
||||
protected static array $immagini = ['anteprima', 'popup', 'texture', 'texture_bump'];
|
||||
|
||||
protected $fillable = [
|
||||
'sistema_id', 'codice', 'descrizione', 'maggiorazione_mq', 'minimo_mq',
|
||||
'colore', 'roughness', 'metalness', 'opacita', 'anteprima', 'popup', 'texture',
|
||||
'texture_bump', 'bump_scala', 'predefinito', 'ordine', 'attivo',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'maggiorazione_mq' => 'float',
|
||||
'minimo_mq' => 'float',
|
||||
'roughness' => 'float',
|
||||
'metalness' => 'float',
|
||||
'opacita' => 'float',
|
||||
'bump_scala' => 'float',
|
||||
'predefinito' => 'boolean',
|
||||
'ordine' => 'integer',
|
||||
'attivo' => 'boolean',
|
||||
];
|
||||
|
||||
public function sistema(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Sistema::class);
|
||||
}
|
||||
|
||||
public function scopeAttivi(Builder $query): Builder
|
||||
{
|
||||
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Un solo elemento predefinito per sistema.
|
||||
static::saved(function (self $record): void {
|
||||
if ($record->predefinito && ($record->wasRecentlyCreated || $record->wasChanged('predefinito'))) {
|
||||
static::query()
|
||||
->where('sistema_id', $record->sistema_id)
|
||||
->whereKeyNot($record->getKey())
|
||||
->update(['predefinito' => false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Configuratore;
|
||||
|
||||
use App\Models\Finitura;
|
||||
use App\Models\GruppoOpzioni;
|
||||
use App\Models\Modello;
|
||||
use App\Models\Opzione;
|
||||
use App\Models\Vetro;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Prezzo di una configurazione, calcolato solo lato server dalle regole del backoffice:
|
||||
*
|
||||
* prezzo = base x (1 + supplementi % / 100) + supplementi fissi + variazione vetro
|
||||
*
|
||||
* - base: prima misura del listino >= a quella richiesta (mm), entro min/max del listino;
|
||||
* - supplementi: tipo finitura (bianco/uniforme/bicolore) e opzioni scelte (gruppi di Opzione);
|
||||
* - variazione vetro: somma delle aree fatturabili delle lastre (max(area, minimo) per lastra)
|
||||
* per la maggiorazione €/m² del riempimento, con le lastre date dalla regola vetro del modello.
|
||||
*
|
||||
* I calcoli interni non sono arrotondati: solo il prezzo finale va a 2 decimali.
|
||||
*/
|
||||
class CalcolatorePrezzo
|
||||
{
|
||||
/**
|
||||
* @param array{larghezza?:mixed,altezza?:mixed,finitura_interna?:mixed,finitura_esterna?:mixed,maniglia?:mixed,vetro?:mixed,opzioni?:array<string,mixed>} $scelte
|
||||
* @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array}
|
||||
*
|
||||
* @throws ValidationException se la misura è fuori listino o una scelta non esiste
|
||||
*/
|
||||
public function calcola(Modello $modello, array $scelte): array
|
||||
{
|
||||
$larghezza = (int) ($scelte['larghezza'] ?? 0);
|
||||
$altezza = (int) ($scelte['altezza'] ?? 0);
|
||||
[$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza);
|
||||
|
||||
$opzioni = Opzione::perModello($modello);
|
||||
$supplementi = [];
|
||||
$configurazione = [
|
||||
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
|
||||
'width' => $larghezza,
|
||||
'height' => $altezza,
|
||||
];
|
||||
$normalizzate = ['larghezza' => $larghezza, 'altezza' => $altezza, 'opzioni' => []];
|
||||
|
||||
if ($modello->haModulo('modello')) {
|
||||
// Finiture attive nel modello, ciascuna scelta solo tra quelle del suo lato.
|
||||
$profili = Finitura::delProfiloPerModello($modello);
|
||||
$interna = $this->scegli($profili->filter->disponibileSu('interno'), $scelte['finitura_interna'] ?? null, 'finitura_interna', fn ($f) => $f->predefinita);
|
||||
// Senza finitura esterna separata il serramento è tutto della finitura interna.
|
||||
$esterna = $modello->haModulo('finitura_esterna')
|
||||
? $this->scegli($profili->filter->disponibileSu('esterno'), $scelte['finitura_esterna'] ?? null, 'finitura_esterna', fn ($f) => $f->predefinita)
|
||||
: $interna;
|
||||
|
||||
$tipo = match (true) {
|
||||
$interna->isNot($esterna) => 'bicolore',
|
||||
$interna->predefinita => 'bianco',
|
||||
default => 'uniforme',
|
||||
};
|
||||
$voce = $opzioni->get('tipo_finitura', collect())->firstWhere('valore', $tipo);
|
||||
$supplementi[] = $this->supplemento('Tipo finitura', $voce, $base, $larghezza, $altezza, $tipo);
|
||||
|
||||
$configurazione['tipo_finitura'] = $voce?->etichetta ?? ucfirst($tipo);
|
||||
$configurazione['finitura_interna'] = $interna->descrizione;
|
||||
if ($modello->haModulo('finitura_esterna')) {
|
||||
$configurazione['finitura_esterna'] = $esterna->descrizione;
|
||||
}
|
||||
$normalizzate['finitura_interna'] = $interna->codice;
|
||||
$normalizzate['finitura_esterna'] = $esterna->codice;
|
||||
}
|
||||
|
||||
if ($modello->haModulo('maniglia')) {
|
||||
$maniglie = $modello->sistema->finiture()->where('tipo', 'maniglia')->attive()->get();
|
||||
if ($maniglie->isNotEmpty()) {
|
||||
$maniglia = $this->scegli($maniglie, $scelte['maniglia'] ?? null, 'maniglia');
|
||||
$configurazione['maniglia'] = $maniglia->descrizione;
|
||||
$normalizzate['maniglia'] = $maniglia->codice;
|
||||
}
|
||||
}
|
||||
|
||||
$gruppi = GruppoOpzioni::perCodice();
|
||||
foreach ($opzioni as $gruppo => $righe) {
|
||||
$definizione = $gruppi->get($gruppo);
|
||||
if (! $definizione || ! $definizione->haControllo() || ! $modello->haModulo($definizione->modulo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($definizione->ui === 'multipla') {
|
||||
$scelteGruppo = $this->scegliMultiple($righe, $scelte['opzioni'][$gruppo] ?? [], "opzioni.{$gruppo}");
|
||||
foreach ($scelteGruppo as $scelta) {
|
||||
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
|
||||
}
|
||||
if ($scelteGruppo->isNotEmpty()) {
|
||||
$configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', ');
|
||||
}
|
||||
$normalizzate['opzioni'][$gruppo] = $scelteGruppo->pluck('valore')->all();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$scelta = $this->scegli($righe, $scelte['opzioni'][$gruppo] ?? null, "opzioni.{$gruppo}", null, 'valore');
|
||||
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
|
||||
$configurazione[$gruppo] = $scelta->etichetta;
|
||||
$normalizzate['opzioni'][$gruppo] = $scelta->valore;
|
||||
}
|
||||
|
||||
$vetro = null;
|
||||
if ($modello->haModulo('vetro')) {
|
||||
$vetri = $modello->sistema->vetri()->attivi()->get();
|
||||
if ($vetri->isNotEmpty()) {
|
||||
$scelto = $this->scegli($vetri, $scelte['vetro'] ?? null, 'vetro', fn ($v) => $v->predefinito);
|
||||
$vetro = $this->variazioneVetro($modello, $scelto, $larghezza, $altezza);
|
||||
$configurazione['vetro'] = $scelto->descrizione;
|
||||
$normalizzate['vetro'] = $scelto->codice;
|
||||
}
|
||||
}
|
||||
|
||||
$supplementi = array_values(array_filter($supplementi));
|
||||
$percentuale = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] === 'percentuale'), 'valore'));
|
||||
// Importi fissi e al metro lineare (già calcolati sulle misure).
|
||||
$fisso = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] !== 'percentuale'), 'importo'));
|
||||
$prezzo = $base * (1 + $percentuale / 100) + $fisso + ($vetro['importo'] ?? 0);
|
||||
|
||||
return [
|
||||
'prezzo' => round($prezzo, 2),
|
||||
'scelte' => $normalizzate,
|
||||
'configurazione' => $configurazione,
|
||||
'dettaglio' => [
|
||||
'prezzo_base' => $base,
|
||||
'misura_listino' => $misuraListino,
|
||||
'supplementi' => $supplementi,
|
||||
'percentuale_totale' => $percentuale,
|
||||
'fisso_totale' => $fisso,
|
||||
'vetro' => $vetro,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:float,1:array{larghezza:int,altezza:int}} */
|
||||
private function prezzoBase(Modello $modello, int $larghezza, int $altezza): array
|
||||
{
|
||||
$griglia = $modello->listino()->get(['larghezza', 'altezza', 'prezzo']);
|
||||
if ($griglia->isEmpty()) {
|
||||
throw ValidationException::withMessages(['larghezza' => 'Nessun listino caricato per questo modello.']);
|
||||
}
|
||||
|
||||
$l = $this->misuraListino($griglia->pluck('larghezza'), $larghezza);
|
||||
$h = $this->misuraListino($griglia->pluck('altezza'), $altezza);
|
||||
$cella = ($l !== null && $h !== null)
|
||||
? $griglia->first(fn ($p) => $p->larghezza === $l && $p->altezza === $h)
|
||||
: null;
|
||||
|
||||
if (! $cella) {
|
||||
throw ValidationException::withMessages(['larghezza' => 'Misura non disponibile a listino.']);
|
||||
}
|
||||
|
||||
return [(float) $cella->prezzo, ['larghezza' => $l, 'altezza' => $h]];
|
||||
}
|
||||
|
||||
/** Prima misura del listino >= a quella richiesta; null se fuori dal min/max del listino. */
|
||||
private function misuraListino(Collection $misure, int $valore): ?int
|
||||
{
|
||||
$misure = $misure->unique()->sort()->values();
|
||||
|
||||
if ($valore <= 0 || $valore < $misure->first()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $misure->first(fn ($m) => $m >= $valore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elemento scelto per codice (o valore); senza scelta il predefinito, altrimenti il primo.
|
||||
*
|
||||
* @template T of \Illuminate\Database\Eloquent\Model
|
||||
*
|
||||
* @param Collection<int,T> $elementi
|
||||
* @return T
|
||||
*/
|
||||
private function scegli(Collection $elementi, mixed $codice, string $campo, ?callable $predefinito = null, string $chiave = 'codice')
|
||||
{
|
||||
if ($codice === null || $codice === '') {
|
||||
$scelto = ($predefinito ? $elementi->first($predefinito) : null) ?? $elementi->first();
|
||||
} else {
|
||||
$scelto = $elementi->first(fn ($e) => (string) $e->{$chiave} === (string) $codice);
|
||||
}
|
||||
|
||||
if (! $scelto) {
|
||||
throw ValidationException::withMessages([$campo => 'Scelta non disponibile per questo modello.']);
|
||||
}
|
||||
|
||||
return $scelto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opzioni scelte in un gruppo a scelta multipla (anche nessuna); quelle con la stessa
|
||||
* `alternativa` si escludono a vicenda.
|
||||
*
|
||||
* @return Collection<int,Opzione>
|
||||
*/
|
||||
private function scegliMultiple(Collection $righe, mixed $valori, string $campo): Collection
|
||||
{
|
||||
$valori = collect(is_array($valori) ? $valori : [$valori])->filter(fn ($v) => is_scalar($v) && $v !== '')->map(fn ($v) => (string) $v)->unique();
|
||||
|
||||
$scelte = $valori->map(fn (string $v) => $this->scegli($righe, $v, $campo, null, 'valore'));
|
||||
|
||||
$doppie = $scelte->whereNotNull('alternativa')->groupBy('alternativa')->filter(fn ($g) => $g->count() > 1);
|
||||
if ($doppie->isNotEmpty()) {
|
||||
throw ValidationException::withMessages([$campo => 'Si può scegliere una sola opzione tra: '.$doppie->first()->pluck('etichetta')->implode(', ').'.']);
|
||||
}
|
||||
|
||||
// Nell'ordine del gruppo, non in quello di invio.
|
||||
return $righe->filter(fn (Opzione $o) => $scelte->contains(fn (Opzione $s) => $s->is($o)))->values();
|
||||
}
|
||||
|
||||
private function supplemento(string $voce, ?Opzione $opzione, float $base, int $larghezza, int $altezza, ?string $valore = null): ?array
|
||||
{
|
||||
if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'voce' => "{$voce}: {$opzione->etichetta}",
|
||||
'valore_scelto' => $valore ?? $opzione->valore,
|
||||
'tipo' => $opzione->supplemento_tipo,
|
||||
'valore' => $opzione->supplemento,
|
||||
'metri_lineari' => $opzione->supplemento_tipo === 'metro_lineare' ? round($opzione->metriLineari($larghezza, $altezza), 3) : null,
|
||||
'importo' => $opzione->importo($base, $larghezza, $altezza),
|
||||
];
|
||||
}
|
||||
|
||||
private function variazioneVetro(Modello $modello, Vetro $vetro, int $larghezza, int $altezza): array
|
||||
{
|
||||
$regola = $modello->regolaVetro;
|
||||
$lastre = [];
|
||||
$mqFatturabili = 0.0;
|
||||
|
||||
foreach ($regola?->calcolaLastre($larghezza, $altezza) ?? [] as $lastra) {
|
||||
$mq = max(0, $lastra['larghezza']) * max(0, $lastra['altezza']) / 1_000_000;
|
||||
// Il minimo fatturabile vale per singola lastra.
|
||||
$fatturabili = max($mq, $vetro->minimo_mq);
|
||||
$mqFatturabili += $fatturabili * $lastra['quantita'];
|
||||
|
||||
$lastre[] = $lastra + ['mq' => round($mq, 3), 'mq_fatturabili' => round($fatturabili, 3)];
|
||||
}
|
||||
|
||||
return [
|
||||
'codice' => $vetro->codice,
|
||||
'descrizione' => $vetro->descrizione,
|
||||
'regola' => $regola?->codice,
|
||||
'maggiorazione_mq' => $vetro->maggiorazione_mq,
|
||||
'minimo_mq' => $vetro->minimo_mq,
|
||||
'lastre' => $lastre,
|
||||
'mq_fatturabili' => round($mqFatturabili, 3),
|
||||
'importo' => $mqFatturabili * $vetro->maggiorazione_mq,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Configuratore;
|
||||
|
||||
use App\Models\Finitura;
|
||||
use App\Models\GruppoOpzioni;
|
||||
use App\Models\Modello;
|
||||
use App\Models\Opzione;
|
||||
use App\Models\Vetro;
|
||||
|
||||
/**
|
||||
* Dati del configuratore per un modello: struttura del menu, finiture, vetri, opzioni e
|
||||
* misure del listino, con le immagini servite dal backoffice. Sostituisce i vecchi
|
||||
* form.json / modello.json / maniglia.json / vetro.json e la cartella immagini.
|
||||
*
|
||||
* Non espone supplementi e maggiorazioni: il prezzo lo calcola CalcolatorePrezzo.
|
||||
*/
|
||||
class ConfigurazioneModello
|
||||
{
|
||||
public function perModello(Modello $modello): array
|
||||
{
|
||||
$sistema = $modello->sistema;
|
||||
|
||||
// Finiture del profilo attive nel modello, divise per lato (interno / esterno).
|
||||
$finiture = $modello->haModulo('modello') ? Finitura::delProfiloPerModello($modello) : collect();
|
||||
$interne = $finiture->filter(fn (Finitura $f) => $f->disponibileSu('interno'))->map($this->finitura(...))->values();
|
||||
$esterne = $modello->haModulo('finitura_esterna')
|
||||
? $finiture->filter(fn (Finitura $f) => $f->disponibileSu('esterno'))->map($this->finitura(...))->values()
|
||||
: collect();
|
||||
$maniglie = $modello->haModulo('maniglia')
|
||||
? $sistema->finiture()->where('tipo', 'maniglia')->attive()->get()->map($this->finitura(...))->values()
|
||||
: collect();
|
||||
$vetri = $modello->haModulo('vetro')
|
||||
? $sistema->vetri()->attivi()->get()->map($this->vetro(...))->values()
|
||||
: collect();
|
||||
|
||||
return [
|
||||
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
|
||||
'form' => $this->form($modello, $interne->isNotEmpty(), $esterne->isNotEmpty(), $maniglie->isNotEmpty(), $vetri->isNotEmpty()),
|
||||
'finiture' => $finiture->map($this->finitura(...))->values(),
|
||||
'finiture_interne' => $interne,
|
||||
'finiture_esterne' => $esterne,
|
||||
'maniglie' => $maniglie,
|
||||
'vetri' => $vetri,
|
||||
'misure' => $modello->listino()
|
||||
->orderBy('altezza')
|
||||
->orderBy('larghezza')
|
||||
->get(['altezza', 'larghezza', 'prezzo'])
|
||||
->map(fn ($p) => ['altezza' => $p->altezza, 'larghezza' => $p->larghezza, 'prezzo' => $p->prezzo])
|
||||
->values(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Stessa struttura del vecchio form.json (radios, select, finiture) letta dal MenuBuilder. */
|
||||
private function form(Modello $modello, bool $interne, bool $esterne, bool $maniglie, bool $vetri): array
|
||||
{
|
||||
$radios = [];
|
||||
$select = [];
|
||||
$multiple = [];
|
||||
$gruppi = [];
|
||||
$definizioni = GruppoOpzioni::perCodice();
|
||||
$opzioni = Opzione::perModello($modello);
|
||||
|
||||
// Nell'ordine dei gruppi: il configuratore li presenta così.
|
||||
foreach ($definizioni as $gruppo => $definizione) {
|
||||
$righe = $opzioni->get($gruppo);
|
||||
if (! $righe || ! $definizione->haControllo() || ! $modello->haModulo($definizione->modulo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gruppi[$gruppo] = ['etichetta' => $definizione->etichetta, 'ui' => $definizione->ui, 'sezione' => $definizione->sezione];
|
||||
|
||||
$voci = $righe->map(fn (Opzione $o) => ($o->parametri ?? []) + [
|
||||
'valore' => $o->valore,
|
||||
'label' => $o->etichetta,
|
||||
'icon' => Media::url($o->icona),
|
||||
'alternativa' => $o->alternativa,
|
||||
])->values();
|
||||
|
||||
match ($definizione->ui) {
|
||||
'radio' => $radios[$gruppo] = $voci,
|
||||
'multipla' => $multiple[$gruppo] = $voci,
|
||||
default => $select[$gruppo] = $voci,
|
||||
};
|
||||
}
|
||||
|
||||
$menu = [];
|
||||
if ($interne) {
|
||||
$menu[] = ['nome' => 'finitura_interna', 'categorie' => ['interno'], 'data' => 'data_finiture_interne'];
|
||||
}
|
||||
if ($esterne) {
|
||||
$menu[] = ['nome' => 'finitura_esterna', 'categorie' => ['esterno'], 'data' => 'data_finiture_esterne'];
|
||||
}
|
||||
if ($maniglie) {
|
||||
$menu[] = ['nome' => 'maniglia', 'categorie' => ['maniglia', 'cerniere'], 'data' => 'data_maniglie'];
|
||||
}
|
||||
if ($vetri) {
|
||||
$menu[] = ['nome' => 'vetro', 'categorie' => ['vetro'], 'data' => 'data_vetri'];
|
||||
}
|
||||
|
||||
return [
|
||||
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
|
||||
'radios' => (object) $radios,
|
||||
'select' => (object) $select,
|
||||
// Pulsanti selezionabili insieme (es. profili extra), anche nessuno.
|
||||
'multiple' => (object) $multiple,
|
||||
// Titolo, controllo e sezione del menu dei gruppi sopra, nell'ordine del backoffice.
|
||||
'gruppi' => (object) $gruppi,
|
||||
'finiture' => $menu,
|
||||
];
|
||||
}
|
||||
|
||||
/** Campi con i nomi letti da APP.Player.changeTexture/changeColor. */
|
||||
private function finitura(Finitura $f): array
|
||||
{
|
||||
return [
|
||||
'id' => $f->codice,
|
||||
'descrizione' => $f->descrizione,
|
||||
'anteprima' => Media::url($f->anteprima),
|
||||
'texture_map' => Media::url($f->texture),
|
||||
'texture_bump' => Media::url($f->texture_bump),
|
||||
'bump_valore' => $f->bump_scala,
|
||||
'color_valore' => $f->colore,
|
||||
'color_valore_corpo_base' => $f->colore_corpo_base,
|
||||
'roughness_valore' => $f->roughness,
|
||||
'metalness_valore' => $f->metalness,
|
||||
'predefinito' => $f->predefinita,
|
||||
];
|
||||
}
|
||||
|
||||
private function vetro(Vetro $v): array
|
||||
{
|
||||
return [
|
||||
'id' => $v->codice,
|
||||
'descrizione' => $v->descrizione,
|
||||
'anteprima' => Media::url($v->anteprima),
|
||||
'pop_up' => Media::url($v->popup),
|
||||
'texture_map' => Media::url($v->texture),
|
||||
'texture_bump' => Media::url($v->texture_bump),
|
||||
'bump_valore' => $v->bump_scala,
|
||||
'color_valore' => $v->colore,
|
||||
'roughness_valore' => $v->roughness,
|
||||
'metalness_valore' => $v->metalness,
|
||||
'opacity_valore' => $v->opacita,
|
||||
'predefinito' => $v->predefinito,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Configuratore;
|
||||
|
||||
/**
|
||||
* URL pubblici delle immagini del configuratore. Passano dalla rotta API `media` (e non
|
||||
* da /storage) perché le texture caricate da Three.js richiedono gli header CORS.
|
||||
*/
|
||||
class Media
|
||||
{
|
||||
/** Cartelle del disco public servite dalla rotta media. */
|
||||
public const CARTELLE = ['configuratore/', 'modelli/'];
|
||||
|
||||
public static function url(?string $percorso): ?string
|
||||
{
|
||||
return $percorso ? route('media', ['percorso' => $percorso]) : null;
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,21 @@ class ConfigurazioneFormatter
|
||||
'tipo_ispezione' => 'Tipo ispezione',
|
||||
'avvolgibile' => 'Avvolgibile',
|
||||
'cassonetto' => 'Cassonetto',
|
||||
'apertura' => 'Verso di apertura',
|
||||
'telaio' => 'Telaio',
|
||||
'cerniere_a_scomparsa' => 'Cerniere a scomparsa',
|
||||
'profili_extra' => 'Profili extra',
|
||||
];
|
||||
|
||||
/** Ordine di visualizzazione preferito. */
|
||||
private const ORDER = [
|
||||
'width', 'height', 'tipo_finitura', 'finitura_interna', 'finitura_esterna',
|
||||
'maniglia', 'altezza_maniglia', 'vetro', 'cerniere_scomparsa', 'azionamento',
|
||||
'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
|
||||
'maniglia', 'altezza_maniglia', 'vetro', 'apertura', 'profili_extra', 'telaio', 'cerniere_scomparsa',
|
||||
'cerniere_a_scomparsa', 'azionamento', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
|
||||
];
|
||||
|
||||
/** Chiavi tecniche da non mostrare nella lista (usate altrove, es. titolo). */
|
||||
private const SKIP = ['nome'];
|
||||
private const SKIP = ['nome', 'scelte', 'dettaglio_prezzo'];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $configurazione
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Catalogo del configuratore per sistema (finiture, vetri, regole vetro, opzioni con
|
||||
* supplementi): sostituisce i JSON e le immagini sotto src/assets/data.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Finiture del profilo e colori maniglia/cerniere: stesse proprietà 3D.
|
||||
Schema::create('finiture', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
|
||||
$table->string('tipo', 20); // profilo | maniglia
|
||||
// Valore inviato dal configuratore (es. "2", "M1").
|
||||
$table->string('codice', 50);
|
||||
$table->string('descrizione');
|
||||
$table->string('codice_mazzetta', 50)->nullable();
|
||||
$table->string('colore', 20)->nullable();
|
||||
$table->string('colore_corpo_base', 20)->nullable();
|
||||
$table->decimal('roughness', 4, 2)->nullable();
|
||||
$table->decimal('metalness', 4, 2)->nullable();
|
||||
$table->string('anteprima')->nullable();
|
||||
$table->string('texture')->nullable();
|
||||
$table->string('texture_bump')->nullable();
|
||||
$table->decimal('bump_scala', 6, 2)->nullable();
|
||||
$table->boolean('predefinita')->default(false);
|
||||
$table->unsignedInteger('ordine')->default(0);
|
||||
$table->boolean('attivo')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['sistema_id', 'tipo', 'codice']);
|
||||
});
|
||||
|
||||
// Riempimenti (vetri e pannelli): prezzo a m² sulle lastre calcolate dalla regola del modello.
|
||||
Schema::create('vetri', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
|
||||
$table->string('codice', 50);
|
||||
$table->string('descrizione');
|
||||
$table->decimal('maggiorazione_mq', 10, 2)->default(0);
|
||||
$table->decimal('minimo_mq', 8, 3)->default(0);
|
||||
$table->string('colore', 20)->nullable();
|
||||
$table->decimal('roughness', 4, 2)->nullable();
|
||||
$table->decimal('metalness', 4, 2)->nullable();
|
||||
$table->decimal('opacita', 4, 2)->nullable();
|
||||
$table->string('anteprima')->nullable();
|
||||
$table->string('popup')->nullable();
|
||||
$table->string('texture')->nullable();
|
||||
$table->string('texture_bump')->nullable();
|
||||
$table->decimal('bump_scala', 6, 2)->nullable();
|
||||
$table->boolean('predefinito')->default(false);
|
||||
$table->unsignedInteger('ordine')->default(0);
|
||||
$table->boolean('attivo')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['sistema_id', 'codice']);
|
||||
});
|
||||
|
||||
// Geometria delle lastre per tipologia (es. F2: 2 lastre (L - 312) / 2 x (H - 189)).
|
||||
Schema::create('regole_vetro', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
|
||||
$table->string('codice', 50);
|
||||
$table->string('descrizione');
|
||||
// [{descrizione, quantita, divisore, detrazione_larghezza, detrazione_altezza}]
|
||||
$table->json('lastre');
|
||||
$table->text('note')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['sistema_id', 'codice']);
|
||||
});
|
||||
|
||||
// Opzioni del configuratore (radio/select) e supplementi di prezzo. Con modello_id
|
||||
// valorizzato sostituiscono, per quel modello, l'intero gruppo definito sul sistema.
|
||||
Schema::create('opzioni', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('sistema_id')->constrained('sistemi')->cascadeOnDelete();
|
||||
$table->foreignId('modello_id')->nullable()->constrained('modelli')->cascadeOnDelete();
|
||||
$table->string('gruppo', 50);
|
||||
$table->string('valore', 100);
|
||||
$table->string('etichetta');
|
||||
$table->string('icona')->nullable();
|
||||
$table->string('supplemento_tipo', 20)->nullable(); // percentuale | fisso
|
||||
$table->decimal('supplemento', 10, 2)->default(0);
|
||||
$table->json('parametri')->nullable();
|
||||
$table->unsignedInteger('ordine')->default(0);
|
||||
$table->boolean('attivo')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['sistema_id', 'gruppo']);
|
||||
$table->index(['modello_id', 'gruppo']);
|
||||
});
|
||||
|
||||
Schema::table('modelli', function (Blueprint $table) {
|
||||
// Titolo esteso mostrato nel configuratore e nel preventivo.
|
||||
$table->string('titolo')->nullable()->after('descrizione');
|
||||
$table->foreignId('regola_vetro_id')->nullable()->after('moduli')
|
||||
->constrained('regole_vetro')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('modelli', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('regola_vetro_id');
|
||||
$table->dropColumn('titolo');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('opzioni');
|
||||
Schema::dropIfExists('regole_vetro');
|
||||
Schema::dropIfExists('vetri');
|
||||
Schema::dropIfExists('finiture');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Lato delle finiture del profilo (interno, esterno o entrambi) sul sistema, con
|
||||
* personalizzazione per modello: una riga in finiture_modelli sostituisce, per quel
|
||||
* modello, attivazione e lato del sistema.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('finiture', function (Blueprint $table) {
|
||||
$table->string('lato', 20)->default('entrambi')->after('tipo'); // entrambi | interno | esterno
|
||||
});
|
||||
|
||||
Schema::create('finiture_modelli', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('modello_id')->constrained('modelli')->cascadeOnDelete();
|
||||
$table->foreignId('finitura_id')->constrained('finiture')->cascadeOnDelete();
|
||||
$table->string('lato', 20); // entrambi | interno | esterno | nessuno
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['modello_id', 'finitura_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('finiture_modelli');
|
||||
|
||||
Schema::table('finiture', function (Blueprint $table) {
|
||||
$table->dropColumn('lato');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Supplementi al metro lineare (es. profili extra): metri = (L x ml_per_larghezza +
|
||||
* H x ml_per_altezza + ml_aggiunta_mm) / 1000. Le opzioni con la stessa `alternativa`
|
||||
* si escludono a vicenda nei gruppi a scelta multipla.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('opzioni', function (Blueprint $table) {
|
||||
$table->string('alternativa', 50)->nullable()->after('icona');
|
||||
$table->decimal('ml_per_larghezza', 8, 3)->default(0)->after('supplemento');
|
||||
$table->decimal('ml_per_altezza', 8, 3)->default(0)->after('ml_per_larghezza');
|
||||
$table->decimal('ml_aggiunta_mm', 10, 2)->default(0)->after('ml_per_altezza');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('opzioni', function (Blueprint $table) {
|
||||
$table->dropColumn(['alternativa', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Gruppi di opzioni del configuratore gestiti dal backoffice (prima erano fissi nel codice):
|
||||
* controllo del menu, sezione in cui compare e modulo del modello che lo rende visibile.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('gruppi_opzioni', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Chiave usata da opzioni.gruppo e dal configuratore.
|
||||
$table->string('codice', 50)->unique();
|
||||
$table->string('etichetta');
|
||||
$table->string('ui', 20); // nessuna | radio | select | multipla
|
||||
$table->string('sezione', 20)->default('accessori'); // sistema | accessori
|
||||
// Modulo del modello che rende visibile il gruppo (Modello::moduli).
|
||||
$table->string('modulo', 50);
|
||||
$table->unsignedInteger('ordine')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
$adesso = now();
|
||||
DB::table('gruppi_opzioni')->insert(array_map(fn (array $g) => $g + ['created_at' => $adesso, 'updated_at' => $adesso], [
|
||||
['codice' => 'tipo_finitura', 'etichetta' => 'Tipo finitura', 'ui' => 'nessuna', 'sezione' => 'sistema', 'modulo' => 'modello', 'ordine' => 1],
|
||||
['codice' => 'telaio', 'etichetta' => 'Seleziona il telaio del serramento', 'ui' => 'radio', 'sezione' => 'sistema', 'modulo' => 'telaio', 'ordine' => 2],
|
||||
['codice' => 'apertura', 'etichetta' => 'Seleziona il verso di apertura del serramento', 'ui' => 'radio', 'sezione' => 'sistema', 'modulo' => 'apertura', 'ordine' => 3],
|
||||
['codice' => 'profili_extra', 'etichetta' => 'Profili extra', 'ui' => 'multipla', 'sezione' => 'sistema', 'modulo' => 'profili_extra', 'ordine' => 4],
|
||||
['codice' => 'cerniere_a_scomparsa', 'etichetta' => 'Cerniere a scomparsa', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'cerniere', 'ordine' => 5],
|
||||
['codice' => 'soglia_ribassata', 'etichetta' => 'Soglia ribassata', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'soglia_ribassata', 'ordine' => 6],
|
||||
['codice' => 'motore', 'etichetta' => 'Motore', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'motore', 'ordine' => 7],
|
||||
['codice' => 'azionamento', 'etichetta' => 'Azionamento', 'ui' => 'select', 'sezione' => 'accessori', 'modulo' => 'azionamento', 'ordine' => 8],
|
||||
]));
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('gruppi_opzioni');
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,8 @@ class CatalogoSeeder extends Seeder
|
||||
$pvc = Sistema::firstOrCreate(['codice' => 'pvc'], ['nome' => 'PVC', 'ordine' => 1]);
|
||||
|
||||
$this->seedModelli($pvc, require __DIR__.'/data/modelli_pvc.php');
|
||||
|
||||
Modello::normalizzaOrdine($pvc->id);
|
||||
}
|
||||
|
||||
private function seedModelli(Sistema $sistema, array $modelli): void
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Finitura;
|
||||
use App\Models\Modello;
|
||||
use App\Models\Opzione;
|
||||
use App\Models\RegolaVetro;
|
||||
use App\Models\Sistema;
|
||||
use App\Models\Vetro;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Catalogo iniziale del configuratore PVC (finiture, maniglie, vetri, regole vetro,
|
||||
* opzioni e supplementi) con le immagini della nuova grafica. Gira una volta sola: se il
|
||||
* sistema ha già finiture o vetri non tocca nulla, per non sovrascrivere il backoffice.
|
||||
*/
|
||||
class ConfiguratoreSeeder extends Seeder
|
||||
{
|
||||
private const SORGENTE = __DIR__.'/data/configuratore/pvc';
|
||||
|
||||
private const DESTINAZIONE = 'configuratore/pvc';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$pvc = Sistema::where('codice', 'pvc')->first();
|
||||
if (! $pvc || $pvc->finiture()->exists() || $pvc->vetri()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$catalogo = require __DIR__.'/data/configuratore_pvc_catalogo.php';
|
||||
$regole = require __DIR__.'/data/configuratore_pvc.php';
|
||||
|
||||
DB::transaction(function () use ($pvc, $catalogo, $regole) {
|
||||
$this->finiture($pvc, 'profilo', $catalogo['finiture']);
|
||||
$this->finiture($pvc, 'maniglia', $catalogo['maniglie']);
|
||||
$this->vetri($pvc, $regole['vetri']);
|
||||
$this->opzioni($pvc, $regole['opzioni']);
|
||||
$this->modelli($pvc, $regole, $catalogo['titoli']);
|
||||
});
|
||||
}
|
||||
|
||||
private function finiture(Sistema $sistema, string $tipo, array $finiture): void
|
||||
{
|
||||
foreach ($finiture as $i => $f) {
|
||||
Finitura::create([
|
||||
'sistema_id' => $sistema->id,
|
||||
'tipo' => $tipo,
|
||||
'ordine' => $i + 1,
|
||||
'anteprima' => $this->copia($f['anteprima']),
|
||||
'texture' => $this->copia($f['texture'] ?? null),
|
||||
] + $f);
|
||||
}
|
||||
}
|
||||
|
||||
private function vetri(Sistema $sistema, array $vetri): void
|
||||
{
|
||||
foreach ($vetri as $i => [$codice, $descrizione, $maggiorazione, $minimo, $icona, $aspetto]) {
|
||||
Vetro::create([
|
||||
'sistema_id' => $sistema->id,
|
||||
'codice' => $codice,
|
||||
'descrizione' => $descrizione,
|
||||
'maggiorazione_mq' => $maggiorazione,
|
||||
'minimo_mq' => $minimo,
|
||||
'ordine' => $i + 1,
|
||||
'anteprima' => $this->copia("vetri/icone/{$icona}", "vetri/{$codice}/{$icona}"),
|
||||
// Ogni vetro ha la sua copia della texture: i file non sono mai condivisi tra record.
|
||||
'texture_bump' => isset($aspetto['texture_bump'])
|
||||
? $this->copia($aspetto['texture_bump'], "vetri/{$codice}/".basename($aspetto['texture_bump']))
|
||||
: null,
|
||||
] + array_diff_key($aspetto, ['texture_bump' => true]));
|
||||
}
|
||||
}
|
||||
|
||||
private function opzioni(Sistema $sistema, array $gruppi): void
|
||||
{
|
||||
foreach ($gruppi as $gruppo => $voci) {
|
||||
foreach ($voci as $i => $voce) {
|
||||
[$valore, $etichetta, $tipo, $supplemento, $icona, $parametri] = $voce;
|
||||
|
||||
Opzione::create([
|
||||
'sistema_id' => $sistema->id,
|
||||
'gruppo' => $gruppo,
|
||||
'valore' => $valore,
|
||||
'etichetta' => $etichetta,
|
||||
'supplemento_tipo' => $tipo,
|
||||
'supplemento' => $supplemento,
|
||||
'icona' => $icona ? $this->copia("opzioni/{$icona}", "opzioni/sistema/{$icona}") : null,
|
||||
'parametri' => $parametri,
|
||||
'ordine' => $i + 1,
|
||||
'attivo' => $voce[6] ?? true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function modelli(Sistema $sistema, array $regole, array $titoli): void
|
||||
{
|
||||
$regoleVetro = collect($regole['regole_vetro'])->map(fn (array $r, string $codice) => RegolaVetro::create([
|
||||
'sistema_id' => $sistema->id,
|
||||
'codice' => $codice,
|
||||
'descrizione' => $r[0],
|
||||
'lastre' => $r[1],
|
||||
]));
|
||||
|
||||
foreach ($sistema->modelli()->get() as $modello) {
|
||||
[$regola, $moduli] = $regole['modelli'][$modello->codice] ?? [null, []];
|
||||
|
||||
$modello->update([
|
||||
'titolo' => $modello->titolo ?: ($titoli[$modello->cartella ?? $modello->codice] ?? null),
|
||||
'regola_vetro_id' => $modello->regola_vetro_id ?? $regoleVetro->get($regola)?->id,
|
||||
'moduli' => array_values(array_unique([...($modello->moduli ?? []), ...$moduli])),
|
||||
]);
|
||||
|
||||
$this->apertura($modello);
|
||||
$this->aggiornaIcona($modello);
|
||||
}
|
||||
}
|
||||
|
||||
/** Icone DX/SX proprie del modello: override del gruppo apertura del sistema. */
|
||||
private function apertura(Modello $modello): void
|
||||
{
|
||||
$cartella = "opzioni/apertura/{$modello->codice}";
|
||||
if (! is_dir(self::SORGENTE."/{$cartella}")) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([['dx', 'DX', 'destra.jpg', 1], ['sx', 'SX', 'sinistra.jpg', -1]] as $i => [$valore, $etichetta, $file, $scala]) {
|
||||
Opzione::create([
|
||||
'sistema_id' => $modello->sistema_id,
|
||||
'modello_id' => $modello->id,
|
||||
'gruppo' => 'apertura',
|
||||
'valore' => $valore,
|
||||
'etichetta' => $etichetta,
|
||||
'icona' => $this->copia("{$cartella}/{$file}"),
|
||||
'parametri' => ['scale_x' => $scala],
|
||||
'ordine' => $i + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sostituisce l'icona del modello con quella della nuova grafica, se non è stata cambiata dal pannello. */
|
||||
private function aggiornaIcona(Modello $modello): void
|
||||
{
|
||||
$sorgente = __DIR__."/data/icone/pvc/{$modello->codice}.jpg";
|
||||
$seminata = "modelli/icone/pvc/{$modello->codice}.jpg";
|
||||
|
||||
if (is_file($sorgente) && in_array($modello->icona, [null, $seminata], true)) {
|
||||
Storage::disk('public')->put($seminata, file_get_contents($sorgente));
|
||||
$modello->updateQuietly(['icona' => $seminata]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Copia un file della grafica sul disco public; restituisce il percorso salvato. */
|
||||
private function copia(?string $relativo, ?string $destinazione = null): ?string
|
||||
{
|
||||
if (! $relativo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$percorso = self::DESTINAZIONE.'/'.($destinazione ?? $relativo);
|
||||
Storage::disk('public')->put($percorso, file_get_contents(self::SORGENTE."/{$relativo}"));
|
||||
|
||||
return $percorso;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,6 @@ class DatabaseSeeder extends Seeder
|
||||
]
|
||||
);
|
||||
|
||||
$this->call(CatalogoSeeder::class);
|
||||
$this->call([CatalogoSeeder::class, ConfiguratoreSeeder::class, ProfiliExtraSeeder::class, NuoveOpzioniSeeder::class]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Modello;
|
||||
use App\Models\Opzione;
|
||||
use App\Models\Sistema;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Cerniere a scomparsa, soglia ribassata e motore per modello, dal foglio "Nomi" di
|
||||
* reference/OrdineSerramenti_Descrizioni_NuoveOpzioni.xlsx. Gira una volta sola: se il
|
||||
* sistema ha già le opzioni della soglia ribassata non tocca nulla.
|
||||
*/
|
||||
class NuoveOpzioniSeeder extends Seeder
|
||||
{
|
||||
/** Modelli con cerniere a scomparsa e relativo sovrapprezzo (gli altri: NO). */
|
||||
private const CERNIERE = [
|
||||
'finestra_1_anta' => 50, 'finestra_2_ante' => 100, 'finestra_3_ante' => 150, 'finestra_vasistas' => 50,
|
||||
'portafinestra_1_anta' => 50, 'portafinestra_2_ante' => 100, 'portafinestra_3_ante' => 150,
|
||||
];
|
||||
|
||||
/** Modelli con soglia ribassata e relativo sovrapprezzo. */
|
||||
private const SOGLIA = [
|
||||
'portafinestra_1_anta' => 65, 'portafinestra_2_ante' => 105, 'portafinestra_3_ante' => 145,
|
||||
'scorrevole_sf_2_ante' => 105, 'scorrevole_sf_3_ante' => 145, 'scorrevole_sf_4_ante' => 185,
|
||||
];
|
||||
|
||||
/** Motore: "opzione select motore, togliere maniglia". */
|
||||
private const MOTORE = ['finestra_vasistas' => 150];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$pvc = Sistema::where('codice', 'pvc')->first();
|
||||
if (! $pvc || Opzione::where('sistema_id', $pvc->id)->where('gruppo', 'soglia_ribassata')->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($pvc) {
|
||||
// Prezzo del sistema = il più comune; gli altri modelli hanno il gruppo personalizzato.
|
||||
$this->siNo($pvc, null, 'cerniere_a_scomparsa', 50, ['categoria' => 'cerniere', 'func' => 'mostraCerniere'], ['categoria' => 'cerniere', 'func' => 'nascondiCerniere']);
|
||||
$this->siNo($pvc, null, 'soglia_ribassata', 65);
|
||||
$this->siNo($pvc, null, 'motore', 150, ['categoria' => 'maniglia', 'func' => 'mostraCategoria'], ['categoria' => 'maniglia', 'func' => 'nascondiCategoria']);
|
||||
|
||||
foreach ($pvc->modelli()->get() as $modello) {
|
||||
$moduli = array_values(array_diff($modello->moduli ?? [], ['cerniere']));
|
||||
foreach (['cerniere' => self::CERNIERE, 'soglia_ribassata' => self::SOGLIA, 'motore' => self::MOTORE] as $modulo => $prezzi) {
|
||||
if (isset($prezzi[$modello->codice])) {
|
||||
$moduli[] = $modulo;
|
||||
}
|
||||
}
|
||||
$modello->update(['moduli' => array_values(array_unique($moduli))]);
|
||||
|
||||
if ((self::CERNIERE[$modello->codice] ?? 50) !== 50) {
|
||||
$this->siNo($pvc, $modello, 'cerniere_a_scomparsa', self::CERNIERE[$modello->codice], ['categoria' => 'cerniere', 'func' => 'mostraCerniere'], ['categoria' => 'cerniere', 'func' => 'nascondiCerniere'], sostituisci: true);
|
||||
}
|
||||
if ((self::SOGLIA[$modello->codice] ?? 65) !== 65) {
|
||||
$this->siNo($pvc, $modello, 'soglia_ribassata', self::SOGLIA[$modello->codice]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Opzioni NO (senza supplemento) e SI (importo fisso); per il modello sostituiscono il gruppo del sistema. */
|
||||
private function siNo(Sistema $sistema, ?Modello $modello, string $gruppo, float $prezzo, ?array $parametriNo = null, ?array $parametriSi = null, bool $sostituisci = false): void
|
||||
{
|
||||
$esistenti = Opzione::where('sistema_id', $sistema->id)->where('gruppo', $gruppo)->where('modello_id', $modello?->id);
|
||||
if ($sostituisci) {
|
||||
$esistenti->delete();
|
||||
} elseif ($esistenti->exists()) {
|
||||
// Cerniere del sistema già create dal catalogo iniziale: si aggiorna solo il prezzo.
|
||||
$esistenti->where('valore', 'si')->update(['supplemento' => $prezzo]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([['no', 'NO', 0, $parametriNo], ['si', 'SI', $prezzo, $parametriSi]] as $i => [$valore, $etichetta, $supplemento, $parametri]) {
|
||||
Opzione::create([
|
||||
'sistema_id' => $sistema->id,
|
||||
'modello_id' => $modello?->id,
|
||||
'gruppo' => $gruppo,
|
||||
'valore' => $valore,
|
||||
'etichetta' => $etichetta,
|
||||
'supplemento_tipo' => 'fisso',
|
||||
'supplemento' => $supplemento,
|
||||
'parametri' => $parametri,
|
||||
'ordine' => $i + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Modello;
|
||||
use App\Models\Opzione;
|
||||
use App\Models\Sistema;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Profili extra PVC: codici e descrizioni dal foglio "Profili Extra" di
|
||||
* reference/OrdineSerramenti_Descrizioni_NuoveOpzioni.xlsx, prezzi da
|
||||
* reference/Regole_Profili Extra.xls, icone da reference/icone_profili_extra.
|
||||
*
|
||||
* Gira una volta sola: se il sistema ha già profili extra con un prezzo non tocca nulla.
|
||||
* Sostituisce le righe segnaposto (senza prezzo) create dalla prima versione del catalogo.
|
||||
*/
|
||||
class ProfiliExtraSeeder extends Seeder
|
||||
{
|
||||
private const ICONE = __DIR__.'/data/configuratore/pvc/opzioni/profili_extra';
|
||||
|
||||
/** Modelli senza profili extra (foglio "Nomi"; regole: "tutti tranne scuretto, cassonetto, cielino"). */
|
||||
private const ESCLUSI = ['scuretto_base', 'scuretto_pannello_alessandrite', 'scuretto_pannello_malachite', 'cassonetto_80', 'cassonetto_139', 'cassonetto_150'];
|
||||
|
||||
/** Il cielino ha solo il frontale 4062000 (regole: "solo cielino"). */
|
||||
private const CIELINO = 'celino';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$pvc = Sistema::where('codice', 'pvc')->first();
|
||||
$esistenti = Opzione::where('gruppo', 'profili_extra')->where('sistema_id', $pvc?->id);
|
||||
if (! $pvc || (clone $esistenti)->whereNotNull('supplemento_tipo')->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($pvc, $esistenti) {
|
||||
// Segnaposto senza prezzo: eliminati uno per uno per rimuovere anche le icone.
|
||||
$esistenti->get()->each->delete();
|
||||
|
||||
foreach (self::profili() as $i => $profilo) {
|
||||
if ($profilo['valore'] === '406200') {
|
||||
continue;
|
||||
}
|
||||
$this->crea($pvc, null, $profilo, $i + 1);
|
||||
}
|
||||
|
||||
foreach ($pvc->modelli()->get() as $modello) {
|
||||
if (in_array($modello->codice, self::ESCLUSI, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modello->update(['moduli' => array_values(array_unique([...($modello->moduli ?? []), 'profili_extra']))]);
|
||||
|
||||
if ($modello->codice === self::CIELINO) {
|
||||
$this->crea($pvc, $modello, collect(self::profili())->firstWhere('valore', '406200'), 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ml = (L x ml_per_larghezza + H x ml_per_altezza + ml_aggiunta_mm) / 1000.
|
||||
* Guide: "H+100mm x 2" -> 2H + 200. Frontale: "solo larghezza (1 pz)" -> L.
|
||||
* Allargamenti NP: "ML" senza misura di riferimento -> perimetro 2L + 2H (da confermare).
|
||||
*/
|
||||
public static function profili(): array
|
||||
{
|
||||
$ml = fn (float $l, float $h, float $mm) => ['ml_per_larghezza' => $l, 'ml_per_altezza' => $h, 'ml_aggiunta_mm' => $mm];
|
||||
|
||||
return [
|
||||
['valore' => 'coprifili_piatti', 'etichetta' => 'COPRIFILI PIATTI', 'supplemento_tipo' => 'percentuale', 'supplemento' => 1, 'alternativa' => 'coprifili'],
|
||||
['valore' => 'coprifili_scatolati', 'etichetta' => 'COPRIFILI SCATOLATI', 'supplemento_tipo' => 'percentuale', 'supplemento' => 3, 'alternativa' => 'coprifili'],
|
||||
['valore' => '50811', 'etichetta' => '50811_GUIDA 34', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 15, 'alternativa' => 'guida'] + $ml(0, 2, 200),
|
||||
['valore' => '50811_50801', 'etichetta' => '50811+50801_GUIDA 87', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 15, 'alternativa' => 'guida'] + $ml(0, 2, 200),
|
||||
['valore' => '9320', 'etichetta' => 'HO9320_T/M', 'supplemento_tipo' => 'fisso', 'supplemento' => 85],
|
||||
['valore' => 'NP8300', 'etichetta' => 'NP8300_ALL.30', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 25, 'alternativa' => 'allargamento'] + $ml(2, 2, 0),
|
||||
['valore' => 'NP8500', 'etichetta' => 'NP8500_ALL.50', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 33, 'alternativa' => 'allargamento'] + $ml(2, 2, 0),
|
||||
['valore' => 'NP8700', 'etichetta' => 'NP8700_ALL.150', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 45, 'alternativa' => 'allargamento'] + $ml(2, 2, 0),
|
||||
['valore' => '406200', 'etichetta' => '4062000_FRONTALE', 'supplemento_tipo' => 'metro_lineare', 'supplemento' => 145] + $ml(1, 0, 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function crea(Sistema $sistema, ?Modello $modello, array $profilo, int $ordine): void
|
||||
{
|
||||
$file = "{$profilo['valore']}.jpg";
|
||||
$icona = 'configuratore/pvc/opzioni/'.($modello ? $modello->codice : 'sistema').'/profili_extra/'.$file;
|
||||
Storage::disk('public')->put($icona, file_get_contents(self::ICONE."/{$file}"));
|
||||
|
||||
Opzione::create($profilo + [
|
||||
'sistema_id' => $sistema->id,
|
||||
'modello_id' => $modello?->id,
|
||||
'gruppo' => 'profili_extra',
|
||||
'icona' => $icona,
|
||||
'ordine' => $ordine,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 909 KiB |
|
After Width: | Height: | Size: 855 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 623 KiB |
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 737 KiB |