diff --git a/CLAUDE.md b/CLAUDE.md index 7bf47f4..3f0c6ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,8 @@ Entry point is `src/js/starter.js`, loaded from `src/index.html`. Flow on load ( `flipView` (180° GSAP-animated turn between front/back view), `addMarker` (in-scene hotspot with a video popup). 6. **`PriceCalculator`** (`src/js/price_calculator.js`) is a stateless-ish module (single module-level - `listini` cache set via `init`) that looks up a base price from a width/height grid (`listino.json`, - rounded up to the nearest 10cm) and layers on percentage/flat supplements for finish, glass, hinges, handle + `listini` cache set via `init`) that looks up a base price from a width/height grid in **mm** (served by + the backoffice API, using the first grid size ≥ the requested one) and layers on percentage/flat supplements for finish, glass, hinges, handle height, and actuator type, then adds 22% VAT. ### Data conventions diff --git a/backoffice/app/Filament/Resources/ModelloResource.php b/backoffice/app/Filament/Resources/ModelloResource.php index b1e404c..ae42a2a 100644 --- a/backoffice/app/Filament/Resources/ModelloResource.php +++ b/backoffice/app/Filament/Resources/ModelloResource.php @@ -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 [ diff --git a/backoffice/app/Filament/Resources/ModelloResource/RelationManagers/ListinoRelationManager.php b/backoffice/app/Filament/Resources/ModelloResource/RelationManagers/ListinoRelationManager.php new file mode 100644 index 0000000..b17b3b6 --- /dev/null +++ b/backoffice/app/Filament/Resources/ModelloResource/RelationManagers/ListinoRelationManager.php @@ -0,0 +1,127 @@ +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'; + } +} diff --git a/backoffice/app/Filament/Resources/PreventivoResource/RelationManagers/ItemsRelationManager.php b/backoffice/app/Filament/Resources/PreventivoResource/RelationManagers/ItemsRelationManager.php index 5d7b979..7ee940a 100644 --- a/backoffice/app/Filament/Resources/PreventivoResource/RelationManagers/ItemsRelationManager.php +++ b/backoffice/app/Filament/Resources/PreventivoResource/RelationManagers/ItemsRelationManager.php @@ -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'), diff --git a/backoffice/app/Filament/Resources/SistemaResource.php b/backoffice/app/Filament/Resources/SistemaResource.php index 15a2bf8..4bed834 100644 --- a/backoffice/app/Filament/Resources/SistemaResource.php +++ b/backoffice/app/Filament/Resources/SistemaResource.php @@ -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.'), diff --git a/backoffice/app/Filament/Resources/SistemaResource/Actions/ImportaListiniAction.php b/backoffice/app/Filament/Resources/SistemaResource/Actions/ImportaListiniAction.php new file mode 100644 index 0000000..f2a2d55 --- /dev/null +++ b/backoffice/app/Filament/Resources/SistemaResource/Actions/ImportaListiniAction.php @@ -0,0 +1,141 @@ +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}> + */ + 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; + } +} diff --git a/backoffice/app/Http/Controllers/Api/CatalogoController.php b/backoffice/app/Http/Controllers/Api/CatalogoController.php index d3944af..a139517 100644 --- a/backoffice/app/Http/Controllers/Api/CatalogoController.php +++ b/backoffice/app/Http/Controllers/Api/CatalogoController.php @@ -35,6 +35,26 @@ class CatalogoController extends Controller return response()->json($this->sistema($sistema)); } + /** + * GET /api/sistemi/{codice}/modelli/{modello}/listino + * Griglia prezzi del modello (misure in mm), ordinata per altezza e larghezza. + */ + 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() + ->orderBy('altezza') + ->orderBy('larghezza') + ->get(['altezza', 'larghezza', 'prezzo']) + ->map(fn ($p) => ['altezza' => $p->altezza, 'larghezza' => $p->larghezza, 'prezzo' => $p->prezzo]); + + return response()->json($prezzi); + } + private function sistema(Sistema $sistema): array { return [ diff --git a/backoffice/app/Models/ListinoPrezzo.php b/backoffice/app/Models/ListinoPrezzo.php new file mode 100644 index 0000000..a8a3271 --- /dev/null +++ b/backoffice/app/Models/ListinoPrezzo.php @@ -0,0 +1,35 @@ + 'integer', + 'altezza' => 'integer', + 'prezzo' => 'float', + ]; + + public function modello(): BelongsTo + { + return $this->belongsTo(Modello::class); + } + + protected static function booted(): void + { + // Le modifiche manuali dal pannello aggiornano la data del listino del modello. + $tocca = fn (ListinoPrezzo $prezzo) => $prezzo->modello?->update(['listino_aggiornato_at' => now()]); + + static::saved($tocca); + static::deleted($tocca); + } +} diff --git a/backoffice/app/Models/Modello.php b/backoffice/app/Models/Modello.php index 940e1f4..824cd4f 100644 --- a/backoffice/app/Models/Modello.php +++ b/backoffice/app/Models/Modello.php @@ -5,6 +5,7 @@ namespace App\Models; 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\Storage; class Modello extends Model @@ -36,12 +37,14 @@ class Modello extends Model protected $fillable = [ 'sistema_id', 'codice', 'nome', 'descrizione', 'icona', 'ordine', 'attivo', 'cartella', 'file_modello', 'categoria', 'cartella_texture', 'moduli', + 'listino_aggiornato_at', ]; protected $casts = [ 'ordine' => 'integer', 'attivo' => 'boolean', 'moduli' => 'array', + 'listino_aggiornato_at' => 'datetime', ]; public function sistema(): BelongsTo @@ -49,6 +52,11 @@ class Modello extends Model return $this->belongsTo(Sistema::class); } + public function listino(): HasMany + { + return $this->hasMany(ListinoPrezzo::class); + } + public function scopeAttivi(Builder $query): Builder { return $query->where('attivo', true)->orderBy('ordine')->orderBy('id'); diff --git a/backoffice/app/Support/ConfigurazioneFormatter.php b/backoffice/app/Support/ConfigurazioneFormatter.php index 2baa70b..9a70c89 100644 --- a/backoffice/app/Support/ConfigurazioneFormatter.php +++ b/backoffice/app/Support/ConfigurazioneFormatter.php @@ -13,8 +13,8 @@ class ConfigurazioneFormatter { /** Etichette italiane per le chiavi note. */ private const LABELS = [ - 'width' => 'Larghezza (cm)', - 'height' => 'Altezza (cm)', + 'width' => 'Larghezza (mm)', + 'height' => 'Altezza (mm)', 'tipo_finitura' => 'Tipo finitura', 'finitura_interna' => 'Finitura interna', 'finitura_esterna' => 'Finitura esterna', diff --git a/backoffice/app/Support/Listini/ListinoExcelParser.php b/backoffice/app/Support/Listini/ListinoExcelParser.php new file mode 100644 index 0000000..6dc2269 --- /dev/null +++ b/backoffice/app/Support/Listini/ListinoExcelParser.php @@ -0,0 +1,102 @@ +}> + */ + public static function parse(string $path): array + { + $reader = IOFactory::createReader(IOFactory::identify($path)); + $reader->setReadDataOnly(true); + + $righe = $reader->load($path)->getSheet(0)->toArray(null, true, false, false); + + return self::parseRighe($righe); + } + + /** + * @param list> $righe + */ + public static function parseRighe(array $righe): array + { + $blocchi = []; + $n = count($righe); + + for ($r = 0; $r < $n; $r++) { + $titolo = $righe[$r][0] ?? null; + if (! is_string($titolo) || trim($titolo) === '') { + continue; + } + + $intestazione = $r; + $larghezze = self::larghezze($righe[$r]); + if (! $larghezze && isset($righe[$r + 1])) { + $intestazione = $r + 1; + $larghezze = self::larghezze($righe[$r + 1]); + } + if (! $larghezze) { + continue; + } + + $prezzi = []; + for ($rr = $intestazione + 1; $rr < $n; $rr++) { + $altezza = $righe[$rr][0] ?? null; + if (! is_numeric($altezza)) { + break; + } + + foreach ($larghezze as $colonna => $larghezza) { + $prezzo = $righe[$rr][$colonna] ?? null; + if (! is_numeric($prezzo)) { + continue; + } + + $prezzi[] = [ + 'larghezza' => (int) round($larghezza), + 'altezza' => (int) round((float) $altezza), + 'prezzo' => round((float) $prezzo, 2), + ]; + } + } + + if ($prezzi) { + $blocchi[] = ['codice' => trim($titolo), 'prezzi' => $prezzi]; + } + + $r = $rr - 1; + } + + return $blocchi; + } + + /** + * Larghezze consecutive a partire dalla colonna B, indicizzate per colonna. + * + * @return array + */ + private static function larghezze(array $riga): array + { + $larghezze = []; + for ($c = 1; $c < count($riga) && is_numeric($riga[$c]); $c++) { + $larghezze[$c] = (float) $riga[$c]; + } + + return $larghezze; + } +} diff --git a/backoffice/app/Support/Listini/ListinoImporter.php b/backoffice/app/Support/Listini/ListinoImporter.php new file mode 100644 index 0000000..212c9b2 --- /dev/null +++ b/backoffice/app/Support/Listini/ListinoImporter.php @@ -0,0 +1,86 @@ + 'finestra_vasistas', + ]; + + /** + * Associazione proposta codice-del-file → modello del sistema (null se nessun + * modello corrisponde): per codice interno, poi per nome o descrizione. + * + * @param list $codici + * @return array + */ + public static function suggerisciAssociazioni(Sistema $sistema, array $codici): array + { + $modelli = $sistema->modelli()->get(); + $associazioni = []; + + foreach ($codici as $codice) { + $associazioni[$codice] = self::trovaModello($modelli, $codice)?->id; + } + + return $associazioni; + } + + private static function trovaModello(Collection $modelli, string $codice): ?Modello + { + $slug = Str::slug($codice, '_'); + $slug = self::ALIAS[$slug] ?? $slug; + + return $modelli->first(fn (Modello $m) => $m->codice === $slug) + ?? $modelli->first(fn (Modello $m) => Str::slug($m->nome, '_') === $slug) + ?? $modelli->first(fn (Modello $m) => $m->descrizione && Str::slug($m->descrizione, '_') === $slug); + } + + /** + * Sostituisce il listino di ogni modello associato con i prezzi del rispettivo blocco. + * + * @param list}> $blocchi + * @param array $associazioni codice del file → id modello + * @return int numero di modelli aggiornati + */ + public static function importa(Sistema $sistema, array $blocchi, array $associazioni): int + { + $prezziPerCodice = collect($blocchi)->keyBy('codice')->map(fn (array $b) => $b['prezzi']); + $modelli = $sistema->modelli()->get()->keyBy('id'); + + return DB::transaction(function () use ($associazioni, $prezziPerCodice, $modelli) { + $aggiornati = 0; + $ora = now(); + + foreach ($associazioni as $codice => $modelloId) { + $modello = $modelloId ? $modelli->get($modelloId) : null; + if (! $modello || ! $prezziPerCodice->has($codice)) { + continue; + } + + $modello->listino()->delete(); + // Una stessa misura ripetuta nel blocco: vale l'ultima. + $righe = collect($prezziPerCodice[$codice]) + ->keyBy(fn (array $p) => $p['larghezza'].'x'.$p['altezza']) + ->map(fn (array $p) => $p + ['modello_id' => $modello->id, 'created_at' => $ora, 'updated_at' => $ora]) + ->values(); + foreach ($righe->chunk(500) as $chunk) { + $modello->listino()->insert($chunk->all()); + } + + $modello->update(['listino_aggiornato_at' => $ora]); + $aggiornati++; + } + + return $aggiornati; + }); + } +} diff --git a/backoffice/cliente/src/App.vue b/backoffice/cliente/src/App.vue index ca938b8..1e30cad 100644 --- a/backoffice/cliente/src/App.vue +++ b/backoffice/cliente/src/App.vue @@ -127,7 +127,7 @@ onMounted(loadCarrello)

{{ item.nome_modello || 'Prodotto' }}

{{ item.sistema }} - · {{ item.larghezza }}×{{ item.altezza }} cm + · {{ item.larghezza }}×{{ item.altezza }} mm

diff --git a/backoffice/composer.json b/backoffice/composer.json index bdc8318..0ccd893 100644 --- a/backoffice/composer.json +++ b/backoffice/composer.json @@ -11,7 +11,8 @@ "filament/filament": "^3.2", "laravel/framework": "^13.8", "laravel/sanctum": "^4.0", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "phpoffice/phpspreadsheet": "^5.10" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/backoffice/composer.lock b/backoffice/composer.lock index 3ed1c06..8ed65f6 100644 --- a/backoffice/composer.lock +++ b/backoffice/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5fb04fda27e55e3061efd06c8eed80c5", + "content-hash": "e1a742903ad0f0633f16a259629241b9", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -426,6 +426,82 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", @@ -3463,6 +3539,191 @@ ], "time": "2026-06-27T03:14:20+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "masterminds/html5", "version": "2.10.1", @@ -4133,6 +4394,117 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.10.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53", + "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.2", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "ext-openssl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": ">=6.8.0 <7.0.0" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0" + }, + "time": "2026-09-17T03:54:50+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", diff --git a/backoffice/database/migrations/2026_10_19_120000_create_listino_prezzi_table.php b/backoffice/database/migrations/2026_10_19_120000_create_listino_prezzi_table.php new file mode 100644 index 0000000..099eb72 --- /dev/null +++ b/backoffice/database/migrations/2026_10_19_120000_create_listino_prezzi_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('modello_id')->constrained('modelli')->cascadeOnDelete(); + $table->decimal('larghezza', 8, 2); + $table->decimal('altezza', 8, 2); + $table->decimal('prezzo', 10, 2); + $table->timestamps(); + + $table->unique(['modello_id', 'larghezza', 'altezza']); + }); + + Schema::table('modelli', function (Blueprint $table) { + $table->timestamp('listino_aggiornato_at')->nullable()->after('moduli'); + }); + } + + public function down(): void + { + Schema::table('modelli', function (Blueprint $table) { + $table->dropColumn('listino_aggiornato_at'); + }); + + Schema::dropIfExists('listino_prezzi'); + } +}; diff --git a/backoffice/database/migrations/2026_10_19_150000_converti_misure_in_mm.php b/backoffice/database/migrations/2026_10_19_150000_converti_misure_in_mm.php new file mode 100644 index 0000000..d19c1ff --- /dev/null +++ b/backoffice/database/migrations/2026_10_19_150000_converti_misure_in_mm.php @@ -0,0 +1,65 @@ +converti(10); + + Schema::table('listino_prezzi', function (Blueprint $table) { + $table->unsignedInteger('larghezza')->change(); + $table->unsignedInteger('altezza')->change(); + }); + } + + public function down(): void + { + Schema::table('listino_prezzi', function (Blueprint $table) { + $table->decimal('larghezza', 8, 2)->change(); + $table->decimal('altezza', 8, 2)->change(); + }); + + $this->converti(0.1); + } + + private function converti(float $fattore): void + { + // In due passaggi (prima negativi, poi positivi): MySQL verifica l'indice univoco + // riga per riga, e un valore già convertito (30 → 300) collide con uno non ancora + // convertito. I negativi non collidono con nessun valore originale. + DB::table('listino_prezzi')->update([ + 'larghezza' => DB::raw("-ROUND(larghezza * {$fattore}, 2)"), + 'altezza' => DB::raw("-ROUND(altezza * {$fattore}, 2)"), + ]); + DB::table('listino_prezzi')->update([ + 'larghezza' => DB::raw('-larghezza'), + 'altezza' => DB::raw('-altezza'), + ]); + + DB::table('preventivo_items')->orderBy('id')->chunkById(200, function ($items) use ($fattore) { + foreach ($items as $item) { + $payload = json_decode($item->payload ?? 'null', true); + foreach (['width', 'height'] as $chiave) { + if (is_array($payload) && is_numeric($payload[$chiave] ?? null)) { + $payload[$chiave] = (string) round((float) $payload[$chiave] * $fattore, 2); + } + } + + DB::table('preventivo_items')->where('id', $item->id)->update([ + 'larghezza' => $item->larghezza === null ? null : (int) round($item->larghezza * $fattore), + 'altezza' => $item->altezza === null ? null : (int) round($item->altezza * $fattore), + 'payload' => $payload === null ? $item->payload : json_encode($payload), + ]); + } + }); + } +}; diff --git a/backoffice/routes/api.php b/backoffice/routes/api.php index e0f08f1..7b02b41 100644 --- a/backoffice/routes/api.php +++ b/backoffice/routes/api.php @@ -14,6 +14,7 @@ Route::get('config', fn () => response()->json([ // Catalogo prodotti (sistemi e modelli con icone) per il configuratore. Route::get('sistemi', [CatalogoController::class, 'sistemi']); Route::get('sistemi/{codice}', [CatalogoController::class, 'sistemaPerCodice']); +Route::get('sistemi/{codice}/modelli/{modello}/listino', [CatalogoController::class, 'listino']); Route::prefix('auth')->group(function () { Route::post('login', [AuthController::class, 'login']); diff --git a/backoffice/tests/Feature/ConversioneMisureMmTest.php b/backoffice/tests/Feature/ConversioneMisureMmTest.php new file mode 100644 index 0000000..a289bcc --- /dev/null +++ b/backoffice/tests/Feature/ConversioneMisureMmTest.php @@ -0,0 +1,51 @@ +seed(CatalogoSeeder::class); + + // Riporta lo schema e i dati allo stato precedente (in cm). + Artisan::call('migrate:rollback', ['--path' => self::MIGRAZIONE]); + + $modello = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); + DB::table('listino_prezzi')->insert(['modello_id' => $modello->id, 'larghezza' => 145, 'altezza' => 40, 'prezzo' => 367.2]); + $preventivo = Preventivo::create(['token' => Str::random(40), 'stato' => 'bozza']); + $item = $preventivo->items()->create([ + 'nome_modello' => 'Finestra 1 anta', + 'larghezza' => 80, + 'altezza' => 120, + 'payload' => ['width' => '80.00', 'height' => '120.5', 'maniglia' => 'Bianca'], + ]); + + Artisan::call('migrate', ['--path' => self::MIGRAZIONE]); + + $this->assertSame([1450, 400], [(int) $modello->listino()->value('larghezza'), (int) $modello->listino()->value('altezza')]); + + $item->refresh(); + $this->assertSame([800, 1200], [$item->larghezza, $item->altezza]); + $this->assertSame(['width' => '800', 'height' => '1205', 'maniglia' => 'Bianca'], $item->payload); + + $righe = collect(ConfigurazioneFormatter::toRows($item->payload))->pluck('value', 'label'); + $this->assertSame('800', $righe['Larghezza (mm)']); + } +} diff --git a/backoffice/tests/Feature/ImportazioneListiniTest.php b/backoffice/tests/Feature/ImportazioneListiniTest.php new file mode 100644 index 0000000..189cb4c --- /dev/null +++ b/backoffice/tests/Feature/ImportazioneListiniTest.php @@ -0,0 +1,178 @@ +seed(CatalogoSeeder::class); + $this->pvc = Sistema::where('codice', 'pvc')->firstOrFail(); + $this->actingAs(User::factory()->create()); + } + + /** Due tabelle nel formato del listino, separate da una riga vuota. */ + private function righe(): array + { + return [ + [null, null, null], + ['finestra_1_anta', 400, 500], + [400, 367.2, 381.244], + [500, 381.24, null], + [null, null, null], + ['PORTA 1 ANTA, CON SERRATURA', 500, 600], + [1800, 796.5, 820], + ]; + } + + private function fileExcel(): UploadedFile + { + $spreadsheet = new Spreadsheet; + $spreadsheet->getActiveSheet()->fromArray($this->righe(), null, 'A1', true); + $path = tempnam(sys_get_temp_dir(), 'listino'); + (new Xlsx($spreadsheet))->save($path); + + return UploadedFile::fake()->createWithContent('listino.xlsx', file_get_contents($path)); + } + + public function test_il_parser_legge_le_tabelle_in_mm(): void + { + $blocchi = ListinoExcelParser::parseRighe($this->righe()); + + $this->assertSame(['finestra_1_anta', 'PORTA 1 ANTA, CON SERRATURA'], array_column($blocchi, 'codice')); + $this->assertSame([ + ['larghezza' => 400, 'altezza' => 400, 'prezzo' => 367.2], + ['larghezza' => 500, 'altezza' => 400, 'prezzo' => 381.24], + ['larghezza' => 400, 'altezza' => 500, 'prezzo' => 381.24], + ], $blocchi[0]['prezzi']); + } + + public function test_il_parser_gestisce_l_intestazione_sulla_riga_successiva(): void + { + $blocchi = ListinoExcelParser::parseRighe([ + ['CASSONETTO PVC', null, null], + ['mm', 500, 600], + [300, 94.14, 104.59], + ]); + + $this->assertSame('CASSONETTO PVC', $blocchi[0]['codice']); + $this->assertCount(2, $blocchi[0]['prezzi']); + } + + public function test_associazione_proposta_per_codice_alias_e_descrizione(): void + { + $associazioni = ListinoImporter::suggerisciAssociazioni($this->pvc, [ + 'finestra_1_anta', 'finestra_wasistas', 'BILICO ORIZZONTALE', 'ALZANTE 2ANTE_MANO SX', + ]); + + $id = fn (string $codice) => Modello::where('codice', $codice)->value('id'); + $this->assertSame([ + 'finestra_1_anta' => $id('finestra_1_anta'), + 'finestra_wasistas' => $id('finestra_vasistas'), + 'BILICO ORIZZONTALE' => $id('bilico'), + 'ALZANTE 2ANTE_MANO SX' => null, + ], $associazioni); + } + + public function test_l_azione_importa_i_listini_secondo_le_associazioni_scelte(): void + { + $finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); + $porta = Modello::where('codice', 'porta_1_anta')->firstOrFail(); + $bilico = Modello::where('codice', 'bilico')->firstOrFail(); + // Listino preesistente di un modello non coinvolto: deve restare com'è. + ListinoPrezzo::create(['modello_id' => $bilico->id, 'larghezza' => 1000, 'altezza' => 1000, 'prezzo' => 1]); + ListinoPrezzo::create(['modello_id' => $finestra->id, 'larghezza' => 9990, 'altezza' => 9990, 'prezzo' => 1]); + + Livewire::test(ListSistemi::class) + ->callTableAction('importaListini', $this->pvc, data: [ + 'file' => $this->fileExcel(), + 'associazioni' => [ + 'a' => ['codice' => 'finestra_1_anta', 'modello_id' => $finestra->id], + // Associazione corretta a mano dall'admin. + 'b' => ['codice' => 'PORTA 1 ANTA, CON SERRATURA', 'modello_id' => $porta->id], + ], + ]) + ->assertHasNoTableActionErrors() + ->assertNotified('Listini importati'); + + $this->assertSame(3, $finestra->listino()->count()); + $this->assertFalse($finestra->listino()->where('larghezza', 9990)->exists()); + $this->assertEquals(820, $porta->listino()->where('larghezza', 600)->value('prezzo')); + $this->assertSame(1, $bilico->listino()->count()); + $this->assertNotNull($finestra->fresh()->listino_aggiornato_at); + } + + public function test_due_tabelle_non_possono_andare_sullo_stesso_modello(): void + { + $finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); + + Livewire::test(ListSistemi::class) + ->callTableAction('importaListini', $this->pvc, data: [ + 'file' => $this->fileExcel(), + 'associazioni' => [ + 'a' => ['codice' => 'finestra_1_anta', 'modello_id' => $finestra->id], + 'b' => ['codice' => 'PORTA 1 ANTA, CON SERRATURA', 'modello_id' => $finestra->id], + ], + ]) + ->assertHasTableActionErrors(); + + $this->assertSame(0, $finestra->listino()->count()); + } + + public function test_il_listino_e_modificabile_dal_modello(): void + { + $finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); + $prezzo = ListinoPrezzo::create(['modello_id' => $finestra->id, 'larghezza' => 400, 'altezza' => 400, 'prezzo' => 100]); + + Livewire::test(ListinoRelationManager::class, ['ownerRecord' => $finestra, 'pageClass' => EditModello::class]) + ->assertCanSeeTableRecords([$prezzo]) + ->callTableAction('create', data: ['larghezza' => 500, 'altezza' => 400, 'prezzo' => 120]) + ->assertHasNoTableActionErrors() + ->callTableAction('create', data: ['larghezza' => 400, 'altezza' => 400, 'prezzo' => 1]) + ->assertHasTableActionErrors(['larghezza' => 'unique']); + + $this->assertSame(2, $finestra->listino()->count()); + } + + public function test_api_listino_restituisce_la_griglia_ordinata(): void + { + $finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail(); + ListinoImporter::importa($this->pvc, ListinoExcelParser::parseRighe($this->righe()), ['finestra_1_anta' => $finestra->id]); + + $this->getJson('/api/sistemi/pvc/modelli/finestra_1_anta/listino') + ->assertOk() + ->assertExactJson([ + ['altezza' => 400, 'larghezza' => 400, 'prezzo' => 367.2], + ['altezza' => 400, 'larghezza' => 500, 'prezzo' => 381.24], + ['altezza' => 500, 'larghezza' => 400, 'prezzo' => 381.24], + ]); + + // Modello disattivo (senza asset 3D): non esposto. + $this->getJson('/api/sistemi/pvc/modelli/scorrevole_sf_3_ante/listino')->assertNotFound(); + } +} diff --git a/src/assets/_menu-cassonetto.html b/src/assets/_menu-cassonetto.html index f3cec4f..a0a3323 100644 --- a/src/assets/_menu-cassonetto.html +++ b/src/assets/_menu-cassonetto.html @@ -49,9 +49,9 @@