feat: Implement price list management and import functionality

- Added ListinoRelationManager for managing price lists associated with models.
- Introduced ImportaListiniAction for importing price lists from Excel files.
- Created ListinoPrezzo model to represent price entries in the database.
- Developed ListinoExcelParser for parsing Excel files containing price lists.
- Implemented ListinoImporter to handle the import logic and associations.
- Added migration for creating listino_prezzi table and converting measures to mm.
- Updated frontend logic to handle new price list features and validations.
- Enhanced tests for price list import and conversion functionalities.
This commit is contained in:
2026-10-19 16:51:24 +02:00
parent a8aa6d9927
commit 1e352eb2ef
46 changed files with 1575 additions and 327 deletions
@@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\ModelloResource\Pages;
use App\Filament\Resources\ModelloResource\RelationManagers\ListinoRelationManager;
use App\Models\Modello;
use Filament\Forms;
use Filament\Forms\Form;
@@ -126,6 +127,13 @@ class ModelloResource extends Resource
->badge()
->formatStateUsing(fn (?string $state) => Modello::CATEGORIE[$state] ?? $state)
->placeholder('—'),
Tables\Columns\TextColumn::make('listino_count')
->label('Listino')
->counts('listino')
->formatStateUsing(fn (int $state) => $state ? "{$state} prezzi" : 'Nessuno')
->color(fn (int $state) => $state ? null : 'gray')
->description(fn (Modello $record) => $record->listino_aggiornato_at?->format('d/m/Y'))
->sortable(),
Tables\Columns\ToggleColumn::make('attivo')
->label('Attivo')
->disabled(fn (Modello $record) => ! $record->attivo && (! $record->cartella || ! $record->file_modello)),
@@ -146,6 +154,13 @@ class ModelloResource extends Resource
]);
}
public static function getRelations(): array
{
return [
ListinoRelationManager::class,
];
}
public static function getPages(): array
{
return [
@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Resources\ModelloResource\RelationManagers;
use App\Models\ListinoPrezzo;
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\Database\Eloquent\Builder;
use Illuminate\Validation\Rules\Unique;
class ListinoRelationManager extends RelationManager
{
protected static string $relationship = 'listino';
protected static ?string $title = 'Listino';
protected static ?string $modelLabel = 'prezzo';
protected static ?string $pluralModelLabel = 'prezzi';
public function form(Form $form): Form
{
return $form->columns(3)->schema([
Forms\Components\TextInput::make('larghezza')
->label('Larghezza (mm)')
->integer()
->minValue(1)
->required()
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Forms\Get $get) => $rule
->where('modello_id', $this->getOwnerRecord()->getKey())
->where('altezza', $get('altezza')),
)
->validationMessages(['unique' => 'Esiste già un prezzo per questa misura.']),
Forms\Components\TextInput::make('altezza')
->label('Altezza (mm)')
->integer()
->minValue(1)
->required(),
Forms\Components\TextInput::make('prezzo')
->label('Prezzo')
->numeric()
->minValue(0)
->prefix('€')
->required(),
]);
}
public function table(Table $table): Table
{
$misure = fn (string $colonna) => fn () => ListinoPrezzo::query()
->where('modello_id', $this->getOwnerRecord()->getKey())
->orderBy($colonna)
->distinct()
->pluck($colonna)
->mapWithKeys(fn ($v) => [$v => $this->mm($v)])
->all();
return $table
->description(fn () => ($data = $this->getOwnerRecord()->listino_aggiornato_at)
? 'Ultimo aggiornamento: '.$data->format('d/m/Y H:i').'. Il configuratore usa la prima misura del listino uguale o superiore a quella richiesta.'
: 'Nessun listino caricato: importalo dalla lista Sistemi o aggiungi i prezzi a mano.')
->modifyQueryUsing(fn (Builder $query) => $query->orderBy('altezza')->orderBy('larghezza'))
->defaultPaginationPageOption(25)
->columns([
Tables\Columns\TextColumn::make('altezza')
->label('Altezza')
->formatStateUsing(fn ($state) => $this->mm($state))
->sortable(),
Tables\Columns\TextColumn::make('larghezza')
->label('Larghezza')
->formatStateUsing(fn ($state) => $this->mm($state))
->sortable(),
Tables\Columns\TextInputColumn::make('prezzo')
->label('Prezzo (€)')
->type('number')
->step('0.01')
->rules(['required', 'numeric', 'min:0'])
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('altezza')->label('Altezza')->options($misure('altezza')),
Tables\Filters\SelectFilter::make('larghezza')->label('Larghezza')->options($misure('larghezza')),
])
->headerActions([
Tables\Actions\CreateAction::make(),
Tables\Actions\Action::make('svuota')
->label('Svuota listino')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Verranno eliminati tutti i prezzi del modello. Il configuratore non potrà calcolare il prezzo finché non sarà caricato un nuovo listino.')
->visible(fn () => $this->getOwnerRecord()->listino()->exists())
->action(function () {
$modello = $this->getOwnerRecord();
$modello->listino()->delete();
$modello->update(['listino_aggiornato_at' => null]);
Notification::make()->title('Listino svuotato')->success()->send();
}),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public function isReadOnly(): bool
{
return false;
}
private function mm(mixed $valore): string
{
return number_format((int) $valore, 0, ',', '.').' mm';
}
}
@@ -32,7 +32,7 @@ class ItemsRelationManager extends RelationManager
Tables\Columns\TextColumn::make('dimensioni')
->label('Dimensioni')
->state(fn (Model $record) => $record->larghezza && $record->altezza
? "{$record->larghezza} × {$record->altezza} cm" : '—'),
? "{$record->larghezza} × {$record->altezza} mm" : '—'),
Tables\Columns\TextColumn::make('quantita')->label('Q.tà'),
Tables\Columns\TextColumn::make('prezzo_unitario')->label('Prezzo un.')->money('EUR'),
Tables\Columns\TextColumn::make('prezzo_totale')->label('Totale')->money('EUR'),
@@ -2,6 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\SistemaResource\Actions\ImportaListiniAction;
use App\Filament\Resources\SistemaResource\Pages;
use App\Models\Sistema;
use Filament\Forms;
@@ -77,6 +78,7 @@ class SistemaResource extends Resource
->url(fn (Sistema $record) => ModelloResource::getUrl('index', [
'tableFilters' => ['sistema' => ['value' => $record->getKey()]],
])),
ImportaListiniAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->modalDescription('Verrà eliminato definitivamente il sistema insieme a tutti i suoi modelli.'),
@@ -0,0 +1,141 @@
<?php
namespace App\Filament\Resources\SistemaResource\Actions;
use App\Models\Sistema;
use App\Support\Listini\ListinoExcelParser;
use App\Support\Listini\ListinoImporter;
use Filament\Forms;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Filament\Tables\Actions\Action;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Throwable;
/**
* Importa i listini di un sistema da Excel in due passi: caricamento del file e
* revisione delle associazioni tabella del file → modello prima di salvare.
*/
class ImportaListiniAction
{
public static function make(): Action
{
return Action::make('importaListini')
->label('Importa listini')
->icon('heroicon-o-arrow-up-tray')
->color('gray')
->modalHeading(fn (Sistema $record) => "Importa listini {$record->nome}")
->modalWidth('3xl')
->modalSubmitActionLabel('Importa listini')
->steps(fn (Sistema $record) => [
Forms\Components\Wizard\Step::make('File')
->description('Carica il file Excel')
->schema([
Forms\Components\FileUpload::make('file')
->label('File listini')
->helperText('File .xls o .xlsx: ogni tabella ha nella prima colonna il codice dell\'infisso, nella prima riga le larghezze e nella prima colonna delle righe successive le altezze (in mm).')
->required()
->storeFiles(false)
->maxSize(10240),
])
->afterValidation(function (Get $get, Set $set) use ($record) {
$blocchi = self::leggi($get('file'));
$associazioni = ListinoImporter::suggerisciAssociazioni($record, array_column($blocchi, 'codice'));
$set('associazioni', collect($blocchi)->mapWithKeys(fn (array $b) => [(string) Str::uuid() => [
'codice' => $b['codice'],
'prezzi' => count($b['prezzi']),
'modello_id' => $associazioni[$b['codice']],
]])->all());
}),
Forms\Components\Wizard\Step::make('Associazioni')
->description('Verifica i modelli associati')
->schema([
Forms\Components\Placeholder::make('istruzioni')
->hiddenLabel()
->content('Per ogni tabella trovata nel file scegli il modello a cui assegnare il listino. Le tabelle senza modello non vengono importate; il listino dei modelli associati viene sostituito.'),
// Intestazione unica: le righe sotto sono senza etichette, in forma di tabella.
Forms\Components\Grid::make(5)
->extraAttributes(['class' => 'px-4 text-sm font-medium text-gray-950 dark:text-white'])
->schema([
Forms\Components\Placeholder::make('h_codice')->hiddenLabel()->content('Codice nel file')->columnSpan(2),
Forms\Components\Placeholder::make('h_prezzi')->hiddenLabel()->content('Prezzi')->columnSpan(1),
Forms\Components\Placeholder::make('h_modello')->hiddenLabel()->content('Modello')->columnSpan(2),
]),
Forms\Components\Repeater::make('associazioni')
->hiddenLabel()
->addable(false)
->deletable(false)
->reorderable(false)
->columns(5)
->extraAttributes(['class' => '-mt-4'])
->schema([
Forms\Components\Hidden::make('codice'),
Forms\Components\Placeholder::make('codice_visibile')
->hiddenLabel()
->content(fn (Get $get) => $get('codice'))
->extraAttributes(['class' => 'font-mono break-all'])
->columnSpan(2),
Forms\Components\Placeholder::make('prezzi')
->hiddenLabel()
->content(fn (Get $get) => $get('prezzi'))
->columnSpan(1),
Forms\Components\Select::make('modello_id')
->hiddenLabel()
->placeholder('— Non importare —')
->options(fn () => $record->modelli()->orderBy('ordine')->pluck('nome', 'id'))
->searchable()
->distinct()
->columnSpan(2),
]),
]),
])
->action(function (array $data, Sistema $record) {
$blocchi = self::leggi($data['file']);
$associazioni = collect($data['associazioni'] ?? [])
->mapWithKeys(fn (array $a) => [$a['codice'] => $a['modello_id'] ?? null])
->all();
$aggiornati = ListinoImporter::importa($record, $blocchi, $associazioni);
Notification::make()
->title('Listini importati')
->body("Aggiornati i listini di {$aggiornati} modelli.")
->success()
->send();
});
}
/**
* @return list<array{codice: string, prezzi: list<array>}>
*/
private static function leggi(mixed $file): array
{
if (is_array($file)) {
$file = reset($file);
}
try {
$blocchi = $file instanceof TemporaryUploadedFile ? ListinoExcelParser::parse($file->getRealPath()) : [];
} catch (Throwable $e) {
report($e);
$blocchi = [];
}
if (! $blocchi) {
Notification::make()
->title('Nessun listino trovato')
->body('Il file non è un Excel valido o non contiene tabelle nel formato atteso.')
->danger()
->send();
throw new Halt;
}
return $blocchi;
}
}