From 8bef57b64bdbf93ca3802786f6b420a836b20a72 Mon Sep 17 00:00:00 2001 From: Carmine Savino Date: Tue, 20 Oct 2026 17:45:50 +0200 Subject: [PATCH] feat: implement discount and transport features in the quote system - Added maximum discount settings configurable by the administrator. - Implemented discount calculations in the quote confirmation process. - Introduced transport options with associated costs, including handling for exceeding maximum piece limits. - Updated frontend to display discounts and transport information in the quote summary. - Created new database migrations for storing discount and transport settings. - Developed a new Filament page for managing global discount settings. - Enhanced email templates to include discount and transport details. - Added tests to ensure proper functionality of discounts and transport calculations. --- CLAUDE.md | 4 +- backoffice/app/Filament/Pages/Scontistica.php | 79 ++++++++ .../Filament/Resources/ClienteResource.php | 50 +++++ .../Filament/Resources/PreventivoResource.php | 23 ++- .../Pages/ViewPreventivo.php | 16 ++ .../Http/Controllers/Api/AuthController.php | 2 + .../Controllers/Api/CarrelloController.php | 27 ++- backoffice/app/Models/Cliente.php | 22 +++ backoffice/app/Models/Impostazione.php | 52 ++++++ backoffice/app/Models/Preventivo.php | 85 ++++++++- .../Providers/Filament/AdminPanelProvider.php | 7 + .../Configuratore/CalcolatorePrezzo.php | 50 +++-- backoffice/cliente/dist/index.html | 4 +- backoffice/cliente/src/App.vue | 106 ++++++++++- backoffice/cliente/src/api.js | 1 + backoffice/cliente/src/style.css | 8 +- backoffice/config/preventivi.php | 24 +++ ...000_add_sconti_to_clienti_e_preventivi.php | 43 +++++ ..._20_110000_add_trasporto_to_preventivi.php | 27 +++ ...10_20_120000_create_impostazioni_table.php | 27 +++ .../views/emails/preventivo-admin.blade.php | 7 + .../views/emails/preventivo-cliente.blade.php | 6 +- .../filament/pages/scontistica.blade.php | 9 + .../resources/views/pdf/preventivo.blade.php | 36 +++- backoffice/routes/api.php | 7 + .../tests/Feature/ConfermaPreventivoTest.php | 172 ++++++++++++++++++ .../tests/Feature/ConfiguratorePrezzoTest.php | 7 + 27 files changed, 875 insertions(+), 26 deletions(-) create mode 100644 backoffice/app/Filament/Pages/Scontistica.php create mode 100644 backoffice/app/Models/Impostazione.php create mode 100644 backoffice/database/migrations/2026_10_20_100000_add_sconti_to_clienti_e_preventivi.php create mode 100644 backoffice/database/migrations/2026_10_20_110000_add_trasporto_to_preventivi.php create mode 100644 backoffice/database/migrations/2026_10_20_120000_create_impostazioni_table.php create mode 100644 backoffice/resources/views/filament/pages/scontistica.blade.php diff --git a/CLAUDE.md b/CLAUDE.md index 6bce9f9..6004a17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,9 @@ 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. **Pricing is server-side only**: `backoffice/app/Support/Configuratore/CalcolatorePrezzo.php`. - `prezzo = base × (1 + supplementi % / 100) + supplementi fissi + variazione vetro` (VAT excluded). Base is + Computed in cascading phases, each phase's percentages applied to the previous phase's total (VAT + excluded): `serramento = base × (1 + finitura %)`, then profili extra on the serramento (measures + + finishes only), then the other options (telaio, apertura, accessori), then `+ variazione vetro`. Base is the first grid size ≥ the requested one (mm). Supplements come from `opzioni` rows (finish type bianco/uniforme/bicolore, hinges, profili extra, ...): percentage, fixed, or per linear metre (ml from L/H multipliers + extra mm). Groups with `ui = multipla` (profili extra) accept several codes, diff --git a/backoffice/app/Filament/Pages/Scontistica.php b/backoffice/app/Filament/Pages/Scontistica.php new file mode 100644 index 0000000..5ba5dc0 --- /dev/null +++ b/backoffice/app/Filament/Pages/Scontistica.php @@ -0,0 +1,79 @@ + */ + public ?array $data = []; + + public function mount(): void + { + $this->form->fill(Impostazione::scontiBase()); + } + + public function form(Form $form): Form + { + return $form + ->statePath('data') + ->schema([ + Forms\Components\Section::make('Regola base') + ->description('Sconti massimi che i clienti possono applicare al preventivo prima dell\'invio: il 1° sull\'imponibile, lo sconto extra sul totale già scontato. Vale per tutti i clienti, salvo quelli con massimi personalizzati o sconti disabilitati nella loro scheda.') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('max_1') + ->label('1° sconto massimo') + ->numeric() + ->required() + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->suffix('%'), + Forms\Components\TextInput::make('max_2') + ->label('Sconto extra massimo') + ->numeric() + ->required() + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->suffix('%'), + ]), + ]); + } + + public function save(): void + { + $dati = $this->form->getState(); + + Impostazione::imposta('sconti.max_1', (float) $dati['max_1']); + Impostazione::imposta('sconti.max_2', (float) $dati['max_2']); + + Notification::make()->title('Scontistica salvata')->success()->send(); + } +} diff --git a/backoffice/app/Filament/Resources/ClienteResource.php b/backoffice/app/Filament/Resources/ClienteResource.php index 954f14a..55af519 100644 --- a/backoffice/app/Filament/Resources/ClienteResource.php +++ b/backoffice/app/Filament/Resources/ClienteResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\ClienteResource\Pages; use App\Models\Cliente; +use App\Models\Impostazione; use App\Support\ClienteCredenziali; use Filament\Forms; use Filament\Forms\Form; @@ -63,6 +64,36 @@ class ClienteResource extends Resource ? 'Generata automaticamente. Ricordati di copiarla dopo il salvataggio: non sarà più leggibile.' : 'Lascia vuoto per mantenere la password attuale, oppure genera/inserisci una nuova password.'), ]), + Forms\Components\Section::make('Scontistica') + ->description(fn () => sprintf( + 'Sconti che il cliente può applicare al preventivo prima dell\'invio. Lascia vuoto per la regola base (1° sconto fino al %s%%, sconto extra fino al %s%% sul totale già scontato).', + static::percentuale(Impostazione::scontiBase()['max_1']), + static::percentuale(Impostazione::scontiBase()['max_2']), + )) + ->columns(2) + ->schema([ + Forms\Components\Toggle::make('sconti_abilitati') + ->label('Può applicare sconti') + ->default(true) + ->live() + ->columnSpanFull(), + Forms\Components\TextInput::make('sconto_max_1') + ->label('1° sconto massimo') + ->numeric() + ->minValue(0) + ->maxValue(100) + ->suffix('%') + ->placeholder(fn () => static::percentuale(Impostazione::scontiBase()['max_1']).' (regola base)') + ->visible(fn (Forms\Get $get) => (bool) $get('sconti_abilitati')), + Forms\Components\TextInput::make('sconto_max_2') + ->label('Sconto extra massimo') + ->numeric() + ->minValue(0) + ->maxValue(100) + ->suffix('%') + ->placeholder(fn () => static::percentuale(Impostazione::scontiBase()['max_2']).' (regola base)') + ->visible(fn (Forms\Get $get) => (bool) $get('sconti_abilitati')), + ]), Forms\Components\Section::make('Anagrafica') ->description('Campi facoltativi.') ->columns(2) @@ -124,6 +155,19 @@ class ClienteResource extends Resource ->schema([ Infolists\Components\TextEntry::make('username')->label('Nome utente')->copyable()->placeholder('—'), Infolists\Components\TextEntry::make('created_at')->label('Cliente dal')->dateTime('d/m/Y H:i'), + Infolists\Components\TextEntry::make('scontistica') + ->label('Scontistica') + ->state(function (Cliente $record): string { + if (! $record->sconti_abilitati) { + return 'Disabilitata'; + } + $massimi = $record->scontiMassimi(); + $base = $record->sconto_max_1 === null && $record->sconto_max_2 === null ? ' (regola base)' : ''; + + return sprintf('Fino al %s%% + extra fino al %s%%%s', + static::percentuale($massimi['max_1']), static::percentuale($massimi['max_2']), $base); + }) + ->columnSpanFull(), ]), Infolists\Components\Section::make('Anagrafica') ->columns(3) @@ -140,6 +184,12 @@ class ClienteResource extends Resource ]); } + /** 50.00 -> "50", 12.50 -> "12,5" */ + private static function percentuale(mixed $valore): string + { + return rtrim(rtrim(number_format((float) $valore, 2, ',', ''), '0'), ','); + } + public static function getPages(): array { return [ diff --git a/backoffice/app/Filament/Resources/PreventivoResource.php b/backoffice/app/Filament/Resources/PreventivoResource.php index 51c4745..cbf8e82 100644 --- a/backoffice/app/Filament/Resources/PreventivoResource.php +++ b/backoffice/app/Filament/Resources/PreventivoResource.php @@ -145,7 +145,28 @@ class PreventivoResource extends Resource ->label('Stato') ->badge() ->formatStateUsing(fn (string $state) => Preventivo::STATI[$state] ?? $state), - Infolists\Components\TextEntry::make('totale')->label('Totale')->money('EUR'), + Infolists\Components\TextEntry::make('subtotale') + ->label('Totale parziale') + ->money('EUR') + ->visible(fn (Preventivo $record) => $record->haSconti()), + Infolists\Components\TextEntry::make('sconti') + ->label('Sconti applicati') + ->visible(fn (Preventivo $record) => $record->haSconti()) + ->state(function (Preventivo $record): string { + $importi = $record->importiSconto(); + $euro = fn (float $v) => '€ '.number_format($v, 2, ',', '.'); + + return sprintf('1° sconto %s%% (−%s) · extra %s%% (−%s)', + (float) $record->sconto_1, $euro($importi['sconto_1']), + (float) $record->sconto_2, $euro($importi['sconto_2'])); + }), + Infolists\Components\TextEntry::make('trasporto_etichetta') + ->label('Trasporto') + ->visible(fn (Preventivo $record) => $record->trasporto !== null) + ->formatStateUsing(fn (string $state, Preventivo $record) => $record->trasportoDaQuantificare() + ? "{$state} — da quantificare" + : $state.' — € '.number_format($record->trasporto_importo, 2, ',', '.')), + Infolists\Components\TextEntry::make('totale')->label('Totale (IVA esclusa)')->money('EUR'), Infolists\Components\TextEntry::make('confirmed_at')->label('Confermato il')->dateTime('d/m/Y H:i')->placeholder('—'), Infolists\Components\TextEntry::make('created_at')->label('Creato il')->dateTime('d/m/Y H:i'), ]), diff --git a/backoffice/app/Filament/Resources/PreventivoResource/Pages/ViewPreventivo.php b/backoffice/app/Filament/Resources/PreventivoResource/Pages/ViewPreventivo.php index 530a05a..57e988b 100644 --- a/backoffice/app/Filament/Resources/PreventivoResource/Pages/ViewPreventivo.php +++ b/backoffice/app/Filament/Resources/PreventivoResource/Pages/ViewPreventivo.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\PreventivoResource\Pages; use App\Filament\Resources\PreventivoResource; use App\Support\PreventivoPdf; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; use Illuminate\Support\Facades\Storage; @@ -38,6 +39,21 @@ class ViewPreventivo extends ViewRecord 'preventivo-'.$this->record->numero.'.pdf' ); }), + // Il PDF è salvato alla conferma e poi riservito così com'è: dopo modifiche al + // template o ai dati va rigenerato. + Actions\Action::make('rigeneraPdf') + ->label('Rigenera PDF') + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->visible(fn () => $this->record->stato !== 'bozza') + ->requiresConfirmation() + ->modalDescription('Il PDF verrà ricreato con il modello e i dati attuali, sostituendo quello salvato.') + ->action(function () { + $this->record->pdf_path = PreventivoPdf::genera($this->record); + $this->record->save(); + + Notification::make()->title('PDF rigenerato')->success()->send(); + }), Actions\EditAction::make(), ]; } diff --git a/backoffice/app/Http/Controllers/Api/AuthController.php b/backoffice/app/Http/Controllers/Api/AuthController.php index f5c98b3..b9be67a 100644 --- a/backoffice/app/Http/Controllers/Api/AuthController.php +++ b/backoffice/app/Http/Controllers/Api/AuthController.php @@ -67,6 +67,8 @@ class AuthController extends Controller 'provincia' => $cliente->provincia, 'note' => $cliente->note, ], + // Sconti massimi (%) applicabili al preventivo prima dell'invio (0 = non consentito). + 'sconti' => $cliente->scontiMassimi(), ]); } } diff --git a/backoffice/app/Http/Controllers/Api/CarrelloController.php b/backoffice/app/Http/Controllers/Api/CarrelloController.php index 4cab460..99ca6ff 100644 --- a/backoffice/app/Http/Controllers/Api/CarrelloController.php +++ b/backoffice/app/Http/Controllers/Api/CarrelloController.php @@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; class CarrelloController extends Controller { @@ -142,6 +143,15 @@ class CarrelloController extends Controller 'note' => ['nullable', 'string', 'max:2000'], ]); + // Oltre il massimo di pezzi il trasporto non si sceglie: è da quantificare. + $trasporto = Preventivo::oltreMaxPezzi((int) $preventivo->items->sum('quantita')) + ? Preventivo::TRASPORTO_OLTRE_MAX + : $request->validate([ + 'trasporto' => ['required', 'string', Rule::in(array_keys(config('preventivi.trasporto.zone')))], + ], [ + 'trasporto.required' => 'Seleziona il trasporto.', + ])['trasporto']; + // Se la richiesta arriva da un cliente loggato nel configuratore (token Sanctum // valido), il preventivo va agganciato al suo record esistente — aggiornando // l'anagrafica con i dati confermati nel form — e NON a un nuovo cliente. @@ -149,7 +159,17 @@ class CarrelloController extends Controller // per i guest (o token assenti/scaduti/non validi) restituisce null. $clienteLoggato = Auth::guard('sanctum')->user(); - DB::transaction(function () use ($preventivo, $dati, $clienteLoggato) { + // Sconti solo per i clienti loggati, entro i massimi decisi dall'amministratore. + $massimi = $clienteLoggato?->scontiMassimi() ?? ['max_1' => 0, 'max_2' => 0]; + $sconti = $request->validate([ + 'sconto_1' => ['nullable', 'numeric', 'min:0', 'max:'.$massimi['max_1']], + 'sconto_2' => ['nullable', 'numeric', 'min:0', 'max:'.$massimi['max_2']], + ], [ + 'sconto_1.max' => 'Il 1° sconto non può superare il :max%.', + 'sconto_2.max' => 'Lo sconto extra non può superare il :max%.', + ]); + + DB::transaction(function () use ($preventivo, $dati, $clienteLoggato, $sconti, $trasporto) { $cliente = $clienteLoggato; if ($cliente) { @@ -162,7 +182,10 @@ class CarrelloController extends Controller $preventivo->stato = 'inviato'; $preventivo->numero = Preventivo::prossimoNumero(); $preventivo->confirmed_at = now(); - $preventivo->save(); + $preventivo->sconto_1 = round((float) ($sconti['sconto_1'] ?? 0), 2); + $preventivo->sconto_2 = round((float) ($sconti['sconto_2'] ?? 0), 2); + $preventivo->trasporto = $trasporto; + $preventivo->ricalcolaTotale(); }); $preventivo->refresh()->load('cliente', 'items'); diff --git a/backoffice/app/Models/Cliente.php b/backoffice/app/Models/Cliente.php index bd73e0d..009d398 100644 --- a/backoffice/app/Models/Cliente.php +++ b/backoffice/app/Models/Cliente.php @@ -14,6 +14,7 @@ class Cliente extends Model protected $fillable = [ 'username', 'password', + 'sconti_abilitati', 'sconto_max_1', 'sconto_max_2', 'nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note', ]; @@ -24,8 +25,29 @@ class Cliente extends Model protected $casts = [ 'password' => 'hashed', + 'sconti_abilitati' => 'boolean', + 'sconto_max_1' => 'decimal:2', + 'sconto_max_2' => 'decimal:2', ]; + /** + * Sconti massimi (%) che il cliente può applicare al preventivo: i suoi, se personalizzati, + * altrimenti la regola base. Zero per entrambi se l'amministratore li ha disabilitati. + * + * @return array{max_1:float,max_2:float} + */ + public function scontiMassimi(): array + { + if (! $this->sconti_abilitati) { + return ['max_1' => 0.0, 'max_2' => 0.0]; + } + + return [ + 'max_1' => (float) ($this->sconto_max_1 ?? Impostazione::scontiBase()['max_1']), + 'max_2' => (float) ($this->sconto_max_2 ?? Impostazione::scontiBase()['max_2']), + ]; + } + public function preventivi(): HasMany { return $this->hasMany(Preventivo::class); diff --git a/backoffice/app/Models/Impostazione.php b/backoffice/app/Models/Impostazione.php new file mode 100644 index 0000000..cd38e4f --- /dev/null +++ b/backoffice/app/Models/Impostazione.php @@ -0,0 +1,52 @@ + 'json', + ]; + + private const CACHE_KEY = 'impostazioni'; + + /** Valore dell'impostazione, o $default se non è mai stata salvata. */ + public static function valore(string $chiave, mixed $default = null): mixed + { + $tutte = Cache::rememberForever(self::CACHE_KEY, fn () => static::query()->pluck('valore', 'chiave')->all()); + + return $tutte[$chiave] ?? $default; + } + + public static function imposta(string $chiave, mixed $valore): void + { + static::query()->updateOrCreate(['chiave' => $chiave], ['valore' => $valore]); + } + + /** + * Regola base di scontistica (%), valida per i clienti senza massimi personalizzati: + * quella salvata dall'amministratore, altrimenti config/preventivi.php. + * + * @return array{max_1:float,max_2:float} + */ + public static function scontiBase(): array + { + return [ + 'max_1' => (float) static::valore('sconti.max_1', config('preventivi.sconti.max_1')), + 'max_2' => (float) static::valore('sconti.max_2', config('preventivi.sconti.max_2')), + ]; + } + + protected static function booted(): void + { + static::saved(fn () => Cache::forget(self::CACHE_KEY)); + static::deleted(fn () => Cache::forget(self::CACHE_KEY)); + } +} diff --git a/backoffice/app/Models/Preventivo.php b/backoffice/app/Models/Preventivo.php index d2deb2d..8302730 100644 --- a/backoffice/app/Models/Preventivo.php +++ b/backoffice/app/Models/Preventivo.php @@ -12,14 +12,21 @@ class Preventivo extends Model protected $table = 'preventivi'; protected $fillable = [ - 'cliente_id', 'token', 'numero', 'stato', 'totale', 'pdf_path', 'confirmed_at', + 'cliente_id', 'token', 'numero', 'stato', 'subtotale', 'sconto_1', 'sconto_2', 'trasporto', 'trasporto_importo', 'totale', 'pdf_path', 'confirmed_at', ]; protected $casts = [ + 'trasporto_importo' => 'decimal:2', + 'subtotale' => 'decimal:2', + 'sconto_1' => 'decimal:2', + 'sconto_2' => 'decimal:2', 'totale' => 'decimal:2', 'confirmed_at' => 'datetime', ]; + /** Trasporto oltre il numero massimo di pezzi della tariffa cadauno: da quantificare. */ + public const TRASPORTO_OLTRE_MAX = 'oltre_max_pezzi'; + public const STATI = [ 'bozza' => 'Bozza', 'inviato' => 'Inviato', @@ -55,13 +62,85 @@ class Preventivo extends Model return $this->hasMany(PreventivoItem::class); } - /** Ricalcola e salva il totale dagli item. */ + /** + * Ricalcola e salva subtotale (somma degli item), contributo trasporto e totale: + * imponibile scontato + trasporto (che non entra negli sconti). + */ public function ricalcolaTotale(): void { - $this->totale = $this->items()->sum('prezzo_totale'); + $this->subtotale = $this->items()->sum('prezzo_totale'); + + $pezzi = (int) $this->items()->sum('quantita'); + if ($this->trasporto !== null && static::oltreMaxPezzi($pezzi)) { + $this->trasporto = self::TRASPORTO_OLTRE_MAX; + } + + $perPezzo = config("preventivi.trasporto.zone.{$this->trasporto}.importo"); + $this->trasporto_importo = $perPezzo === null ? null : round($perPezzo * $pezzi, 2); + + $this->totale = $this->importiSconto()['imponibile'] + (float) $this->trasporto_importo; $this->save(); } + public function getTrasportoEtichettaAttribute(): ?string + { + if ($this->trasporto === self::TRASPORTO_OLTRE_MAX) { + return config('preventivi.trasporto.etichetta_oltre_max'); + } + + return $this->trasporto ? config("preventivi.trasporto.zone.{$this->trasporto}.etichetta", $this->trasporto) : null; + } + + /** Oltre il massimo di pezzi il trasporto non si sceglie: è da quantificare. */ + public static function oltreMaxPezzi(int $pezzi): bool + { + return $pezzi > (int) config('preventivi.trasporto.max_pezzi'); + } + + /** Avviso per il cliente quando il trasporto è da quantificare. */ + public function notaTrasporto(): ?string + { + if (! $this->trasportoDaQuantificare()) { + return null; + } + + $nota = config('preventivi.trasporto.nota_da_quantificare'); + + return $this->trasporto === self::TRASPORTO_OLTRE_MAX + ? sprintf('%s: il preventivo supera i %d pezzi. %s', config('preventivi.trasporto.etichetta_oltre_max'), config('preventivi.trasporto.max_pezzi'), $nota) + : $nota; + } + + /** Zona scelta senza un importo fisso ("Altro"): il costo va chiesto all'azienda. */ + public function trasportoDaQuantificare(): bool + { + return $this->trasporto !== null && $this->trasporto_importo === null; + } + + /** + * Sconti a cascata sul totale parziale (IVA esclusa): il 1° sul subtotale, il 2° extra + * sul totale già scontato. Es. 1000 -50% = 500, -10% = 450. + * + * @return array{sconto_1:float,sconto_2:float,imponibile:float} + */ + public function importiSconto(): array + { + $subtotale = (float) $this->subtotale; + $sconto1 = round($subtotale * (float) $this->sconto_1 / 100, 2); + $sconto2 = round(($subtotale - $sconto1) * (float) $this->sconto_2 / 100, 2); + + return [ + 'sconto_1' => $sconto1, + 'sconto_2' => $sconto2, + 'imponibile' => round($subtotale - $sconto1 - $sconto2, 2), + ]; + } + + public function haSconti(): bool + { + return (float) $this->sconto_1 > 0 || (float) $this->sconto_2 > 0; + } + /** Numero progressivo assegnato alla conferma (max+1). */ public static function prossimoNumero(): int { diff --git a/backoffice/app/Providers/Filament/AdminPanelProvider.php b/backoffice/app/Providers/Filament/AdminPanelProvider.php index 06fd542..314f3f9 100644 --- a/backoffice/app/Providers/Filament/AdminPanelProvider.php +++ b/backoffice/app/Providers/Filament/AdminPanelProvider.php @@ -6,6 +6,7 @@ use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\NavigationItem; use Filament\Pages; use Filament\Panel; use Filament\PanelProvider; @@ -39,6 +40,12 @@ class AdminPanelProvider extends PanelProvider ->pages([ Pages\Dashboard::class, ]) + ->navigationItems([ + NavigationItem::make('Apri configuratore') + ->url(fn (): string => config('preventivi.configuratore_url'), shouldOpenInNewTab: true) + ->icon('heroicon-o-arrow-top-right-on-square') + ->sort(100), + ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([]) ->middleware([ diff --git a/backoffice/app/Support/Configuratore/CalcolatorePrezzo.php b/backoffice/app/Support/Configuratore/CalcolatorePrezzo.php index c471ac9..b97bee9 100644 --- a/backoffice/app/Support/Configuratore/CalcolatorePrezzo.php +++ b/backoffice/app/Support/Configuratore/CalcolatorePrezzo.php @@ -13,10 +13,16 @@ 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 + * a fasi, ciascuna calcolata sul totale della precedente: + * + * serramento = base x (1 + finitura % / 100) + finitura fissi + * con profili = serramento x (1 + profili extra % / 100) + profili extra fissi/al ml + * prezzo = con profili x (1 + altre opzioni % / 100) + altre opzioni fisse + 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); + * - finitura: tipo finitura (bianco/uniforme/bicolore), cioè solo misure + finiture; + * - profili extra: sul serramento finito, prima di vetro e accessori; + * - altre opzioni: telaio, apertura, accessori (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. * @@ -24,6 +30,9 @@ use Illuminate\Validation\ValidationException; */ class CalcolatorePrezzo { + /** Gruppo calcolato sul serramento finito (misure + finiture), prima di vetro e accessori. */ + private const PROFILI_EXTRA = 'profili_extra'; + /** * @param array{larghezza?:mixed,altezza?:mixed,finitura_interna?:mixed,finitura_esterna?:mixed,maniglia?:mixed,vetro?:mixed,opzioni?:array} $scelte * @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array} @@ -37,7 +46,8 @@ class CalcolatorePrezzo [$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza); $opzioni = Opzione::perModello($modello); - $supplementi = []; + // Opzioni scelte per fase: [voce, Opzione, valore scelto]; gli importi dopo, a cascata. + $fasi = ['finitura' => [], 'profili' => [], 'altro' => []]; $configurazione = [ 'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome), 'width' => $larghezza, @@ -60,7 +70,7 @@ class CalcolatorePrezzo default => 'uniforme', }; $voce = $opzioni->get('tipo_finitura', collect())->firstWhere('valore', $tipo); - $supplementi[] = $this->supplemento('Tipo finitura', $voce, $base, $larghezza, $altezza, $tipo); + $fasi['finitura'][] = ['Tipo finitura', $voce, $tipo]; $configurazione['tipo_finitura'] = $voce?->etichetta ?? ucfirst($tipo); $configurazione['finitura_interna'] = $interna->descrizione; @@ -87,10 +97,12 @@ class CalcolatorePrezzo continue; } + $fase = $gruppo === self::PROFILI_EXTRA ? 'profili' : 'altro'; + 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); + $fasi[$fase][] = [$definizione->etichetta, $scelta, null]; } if ($scelteGruppo->isNotEmpty()) { $configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', '); @@ -101,7 +113,7 @@ class CalcolatorePrezzo } $scelta = $this->scegli($righe, $scelte['opzioni'][$gruppo] ?? null, "opzioni.{$gruppo}", null, 'valore'); - $supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza); + $fasi[$fase][] = [$definizione->etichetta, $scelta, null]; $configurazione[$gruppo] = $scelta->etichetta; $normalizzate['opzioni'][$gruppo] = $scelta->valore; } @@ -117,11 +129,22 @@ class CalcolatorePrezzo } } - $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); + // Ogni fase applica le sue percentuali al totale della precedente. + $supplementi = []; + $subtotali = []; + $totale = $base; + foreach ($fasi as $fase => $voci) { + $daAggiungere = 0.0; + foreach ($voci as [$etichetta, $opzione, $valore]) { + if ($supplemento = $this->supplemento($etichetta, $opzione, $totale, $larghezza, $altezza, $valore)) { + $supplementi[] = $supplemento + ['fase' => $fase]; + $daAggiungere += $supplemento['importo']; + } + } + $totale += $daAggiungere; + $subtotali[$fase] = $totale; + } + $prezzo = $totale + ($vetro['importo'] ?? 0); return [ 'prezzo' => round($prezzo, 2), @@ -131,8 +154,8 @@ class CalcolatorePrezzo 'prezzo_base' => $base, 'misura_listino' => $misuraListino, 'supplementi' => $supplementi, - 'percentuale_totale' => $percentuale, - 'fisso_totale' => $fisso, + // Totale dopo ogni fase (finitura = serramento, profili = con profili extra). + 'subtotali' => $subtotali, 'vetro' => $vetro, ], ]; @@ -215,6 +238,7 @@ class CalcolatorePrezzo return $righe->filter(fn (Opzione $o) => $scelte->contains(fn (Opzione $s) => $s->is($o)))->values(); } + /** @param float $base totale della fase precedente, su cui si applicano le percentuali */ private function supplemento(string $voce, ?Opzione $opzione, float $base, int $larghezza, int $altezza, ?string $valore = null): ?array { if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) { diff --git a/backoffice/cliente/dist/index.html b/backoffice/cliente/dist/index.html index bb7cc59..9a352e7 100644 --- a/backoffice/cliente/dist/index.html +++ b/backoffice/cliente/dist/index.html @@ -5,8 +5,8 @@ Il tuo preventivo · Krèios - - + +
diff --git a/backoffice/cliente/src/App.vue b/backoffice/cliente/src/App.vue index 1e30cad..b29338d 100644 --- a/backoffice/cliente/src/App.vue +++ b/backoffice/cliente/src/App.vue @@ -18,6 +18,51 @@ const form = reactive({ }) const anagraficaCaricata = ref(false) +// Sconti applicabili dal cliente loggato (massimi decisi dall'amministratore). Il backend +// ricalcola e valida: qui servono solo per il riepilogo. +const scontiMassimi = reactive({ max_1: 0, max_2: 0 }) +const sconti = reactive({ sconto_1: 0, sconto_2: 0 }) +const puoScontare = computed(() => scontiMassimi.max_1 > 0 || scontiMassimi.max_2 > 0) + +// Contributo trasporto: opzioni da /api/config, importo per pezzo (null = da quantificare). +const opzioniTrasporto = ref([]) +const notaTrasporto = ref('') +const trasporto = ref('') +const maxPezziTrasporto = ref(null) +const etichettaOltreMax = ref('') +// Oltre il massimo di pezzi niente scelta: il trasporto è da quantificare. +const trasportoOltreMax = computed(() => maxPezziTrasporto.value != null && pezzi.value > maxPezziTrasporto.value) +const zonaTrasporto = computed(() => opzioniTrasporto.value.find((z) => z.valore === trasporto.value)) +const pezzi = computed(() => carrello.items.reduce((n, item) => n + (item.quantita || 0), 0)) + +function percentuale(valore, max) { + const n = Number(valore) + return Number.isFinite(n) ? Math.min(Math.max(n, 0), max) : 0 +} + +// Stessa regola del backend: 1° sconto sul totale parziale, 2° extra sul totale già scontato. +const riepilogoSconti = computed(() => { + const parziale = carrello.totale || 0 + const s1 = Math.round(parziale * percentuale(sconti.sconto_1, scontiMassimi.max_1)) / 100 + const s2 = Math.round((parziale - s1) * percentuale(sconti.sconto_2, scontiMassimi.max_2)) / 100 + const imponibile = Math.round((parziale - s1 - s2) * 100) / 100 + const perPezzo = trasportoOltreMax.value ? null : zonaTrasporto.value?.importo + const trasportoImporto = perPezzo == null ? null : Math.round(perPezzo * pezzi.value * 100) / 100 + return { parziale, s1, s2, imponibile, trasportoImporto, totale: imponibile + (trasportoImporto || 0) } +}) + +async function caricaOpzioniTrasporto() { + try { + const data = await api.config() + opzioniTrasporto.value = data.trasporto || [] + notaTrasporto.value = data.trasporto_nota || '' + maxPezziTrasporto.value = data.trasporto_max_pezzi ?? null + etichettaOltreMax.value = data.trasporto_etichetta_oltre_max || '' + } catch (e) { + // Senza opzioni il backend risponderà con l'errore di validazione sul campo. + } +} + const isEmpty = computed(() => !carrello.items || carrello.items.length === 0) // Se il cliente è loggato nel configuratore, precompila il form con i suoi dati già @@ -31,6 +76,7 @@ async function precompilaAnagrafica() { try { const data = await api.me(clienteToken) + Object.assign(scontiMassimi, data.sconti || {}) const campi = ['nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note'] for (const campo of campi) { if (!form[campo] && data.cliente[campo]) { @@ -45,6 +91,7 @@ async function precompilaAnagrafica() { function vaiAlCheckout() { step.value = 'checkout' precompilaAnagrafica() + if (!opzioniTrasporto.value.length) caricaOpzioniTrasporto() } async function loadCarrello() { @@ -83,7 +130,14 @@ async function conferma() { errorMsg.value = '' fieldErrors.value = {} try { - const data = await api.conferma(token.value, { ...form }) + const data = await api.conferma(token.value, { + ...form, + ...(trasportoOltreMax.value ? {} : { trasporto: trasporto.value }), + ...(puoScontare.value ? { + sconto_1: percentuale(sconti.sconto_1, scontiMassimi.max_1), + sconto_2: percentuale(sconti.sconto_2, scontiMassimi.max_2), + } : {}), + }) numeroConfermato.value = data.numero clearToken() step.value = 'done' @@ -224,6 +278,56 @@ onMounted(loadCarrello) +
+ +
+ {{ etichettaOltreMax }}: il preventivo contiene {{ pezzi }} pezzi e supera il limite di + {{ maxPezziTrasporto }} pezzi previsto per il contributo cadauno. {{ notaTrasporto }} +
+
+
+ + +
{{ fieldErrors.trasporto[0] }}
+
{{ notaTrasporto }}
+
+ +
+

Sconti

+
+
+ + +
{{ fieldErrors.sconto_1[0] }}
+
+
+ + +
{{ fieldErrors.sconto_2[0] }}
+
+
+
Totale parziale{{ formatEuro(riepilogoSconti.parziale) }}
+
1° sconto− {{ formatEuro(riepilogoSconti.s1) }}
+
2° sconto extra− {{ formatEuro(riepilogoSconti.s2) }}
+
Totale imponibile offerta{{ formatEuro(riepilogoSconti.imponibile) }}
+
+ Trasportoda quantificare +
+
+ Trasporto + {{ riepilogoSconti.trasportoImporto != null ? formatEuro(riepilogoSconti.trasportoImporto) : 'da quantificare' }} +
+
+ Totale IVA esclusa + {{ formatEuro(riepilogoSconti.totale) }} +
+
+ diff --git a/backoffice/cliente/src/api.js b/backoffice/cliente/src/api.js index 01894d1..edf95ae 100644 --- a/backoffice/cliente/src/api.js +++ b/backoffice/cliente/src/api.js @@ -20,6 +20,7 @@ async function request(path, options = {}) { } export const api = { + config: () => request('/config'), getCarrello: (token) => request(`/carrello/${token}`), rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }), // Se l'utente era loggato nel configuratore, passiamo il suo token al backend così diff --git a/backoffice/cliente/src/style.css b/backoffice/cliente/src/style.css index 5d52585..be91c0d 100644 --- a/backoffice/cliente/src/style.css +++ b/backoffice/cliente/src/style.css @@ -78,7 +78,7 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; } .field { margin-bottom: 12px; } .field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; } -.field input, .field textarea { +.field input, .field textarea, .field select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font: inherit; } .field .err { color: var(--danger); font-size: 12px; margin-top: 3px; } @@ -91,3 +91,9 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; } .success-icon { font-size: 48px; } .done-logo { width: auto; border-radius: 3px; margin-bottom: 16px; } .alert { background: #fdecea; color: var(--danger); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; } + +.sconti { border-top: 1px solid var(--border); margin: 8px 0 16px; padding-top: 12px; } +.sconti-titolo { font-weight: 600; margin: 0 0 8px; } +.sconti-riga { display: flex; justify-content: space-between; font-size: 14px; color: var(--muted); padding: 2px 0; } +.sconti-riga.forte { color: var(--text); font-weight: 600; } +.nota-trasporto { margin-top: 6px; font-size: 13px; color: #7a5d00; background: #fff8d6; border-radius: 6px; padding: 6px 8px; } diff --git a/backoffice/config/preventivi.php b/backoffice/config/preventivi.php index ae67f35..1074d2f 100644 --- a/backoffice/config/preventivi.php +++ b/backoffice/config/preventivi.php @@ -7,6 +7,30 @@ return [ // URL base dell'app cliente (per link nelle email admin). 'frontend_cliente_url' => env('APP_FRONTEND_CLIENTE_URL', 'http://localhost:5199'), + // URL del configuratore 3D (linkato dal menu del backoffice). + 'configuratore_url' => env('APP_CONFIGURATORE_URL', 'http://localhost:5173'), + + // Regola base di scontistica (in %), valida per tutti i clienti salvo personalizzazione: + // imponibile = totale parziale x (1 - 1° sconto) x (1 - 2° sconto extra). + 'sconti' => [ + 'max_1' => (float) env('SCONTO_MAX_1', 50), + 'max_2' => (float) env('SCONTO_MAX_2', 10), + ], + + // Contributo trasporto scelto al checkout, aggiunto dopo gli sconti: importo cadauno, cioè + // moltiplicato per il numero di pezzi (10 finestre = 10 volte). Importo null = da quantificare. + 'trasporto' => [ + 'zone' => [ + 'campania_entro_150km' => ['etichetta' => 'Contributo trasporto Campania entro 150 km', 'importo' => 100], + 'campania_oltre_150km' => ['etichetta' => 'Contributo trasporto Campania oltre 150 km', 'importo' => 150], + 'altro' => ['etichetta' => 'Altro', 'importo' => null], + ], + 'nota_da_quantificare' => 'Per una stima dei costi di trasporto contatta Krèios Infissi.', + // Oltre questo numero di pezzi la tariffa cadauno non vale: niente scelta, trasporto da quantificare. + 'max_pezzi' => 20, + 'etichetta_oltre_max' => 'Contributo per il trasporto da quantificare', + ], + // Durata (in giorni) della sessione di login del configuratore. 'configurator_session_days' => (int) env('CONFIGURATOR_SESSION_DAYS', 30), ]; diff --git a/backoffice/database/migrations/2026_10_20_100000_add_sconti_to_clienti_e_preventivi.php b/backoffice/database/migrations/2026_10_20_100000_add_sconti_to_clienti_e_preventivi.php new file mode 100644 index 0000000..da5c594 --- /dev/null +++ b/backoffice/database/migrations/2026_10_20_100000_add_sconti_to_clienti_e_preventivi.php @@ -0,0 +1,43 @@ +boolean('sconti_abilitati')->default(true)->after('password'); + $table->decimal('sconto_max_1', 5, 2)->nullable()->after('sconti_abilitati'); + $table->decimal('sconto_max_2', 5, 2)->nullable()->after('sconto_max_1'); + }); + + Schema::table('preventivi', function (Blueprint $table) { + $table->decimal('subtotale', 10, 2)->default(0)->after('stato'); + $table->decimal('sconto_1', 5, 2)->default(0)->after('subtotale'); + $table->decimal('sconto_2', 5, 2)->default(0)->after('sconto_1'); + }); + + // Preventivi esistenti: nessuno sconto, il subtotale coincide con il totale. + DB::table('preventivi')->update(['subtotale' => DB::raw('totale')]); + } + + public function down(): void + { + Schema::table('clienti', function (Blueprint $table) { + $table->dropColumn(['sconti_abilitati', 'sconto_max_1', 'sconto_max_2']); + }); + + Schema::table('preventivi', function (Blueprint $table) { + $table->dropColumn(['subtotale', 'sconto_1', 'sconto_2']); + }); + } +}; diff --git a/backoffice/database/migrations/2026_10_20_110000_add_trasporto_to_preventivi.php b/backoffice/database/migrations/2026_10_20_110000_add_trasporto_to_preventivi.php new file mode 100644 index 0000000..e37a2f9 --- /dev/null +++ b/backoffice/database/migrations/2026_10_20_110000_add_trasporto_to_preventivi.php @@ -0,0 +1,27 @@ +string('trasporto', 50)->nullable()->after('sconto_2'); + $table->decimal('trasporto_importo', 10, 2)->nullable()->after('trasporto'); + }); + } + + public function down(): void + { + Schema::table('preventivi', function (Blueprint $table) { + $table->dropColumn(['trasporto', 'trasporto_importo']); + }); + } +}; diff --git a/backoffice/database/migrations/2026_10_20_120000_create_impostazioni_table.php b/backoffice/database/migrations/2026_10_20_120000_create_impostazioni_table.php new file mode 100644 index 0000000..3936e06 --- /dev/null +++ b/backoffice/database/migrations/2026_10_20_120000_create_impostazioni_table.php @@ -0,0 +1,27 @@ + valore), es. la regola base di + * scontistica. Una chiave assente ricade sul valore in config/preventivi.php. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('impostazioni', function (Blueprint $table) { + $table->id(); + $table->string('chiave')->unique(); + $table->json('valore')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('impostazioni'); + } +}; diff --git a/backoffice/resources/views/emails/preventivo-admin.blade.php b/backoffice/resources/views/emails/preventivo-admin.blade.php index 77174ba..80b051b 100644 --- a/backoffice/resources/views/emails/preventivo-admin.blade.php +++ b/backoffice/resources/views/emails/preventivo-admin.blade.php @@ -13,6 +13,13 @@ **Email:** {{ $preventivo->cliente?->email }} **Telefono:** {{ $preventivo->cliente?->telefono ?: '—' }} **Prodotti:** {{ $preventivo->items->count() }} +@if($preventivo->haSconti()) +**Totale parziale:** € {{ number_format($preventivo->subtotale, 2, ',', '.') }} +**Sconti applicati:** {{ (float) $preventivo->sconto_1 }}% + {{ (float) $preventivo->sconto_2 }}% extra +@endif +@if($preventivo->trasporto) +**Trasporto:** {{ $preventivo->trasporto_etichetta }} — {{ $preventivo->trasportoDaQuantificare() ? 'da quantificare' : '€ '.number_format($preventivo->trasporto_importo, 2, ',', '.') }} +@endif **Totale:** € {{ number_format($preventivo->totale, 2, ',', '.') }} diff --git a/backoffice/resources/views/emails/preventivo-cliente.blade.php b/backoffice/resources/views/emails/preventivo-cliente.blade.php index 7ebe80f..8c3b107 100644 --- a/backoffice/resources/views/emails/preventivo-cliente.blade.php +++ b/backoffice/resources/views/emails/preventivo-cliente.blade.php @@ -16,7 +16,11 @@ indicativo (prezzi IVA esclusa). Un nostro consulente ti ricontatterà al più presto per finalizzare l'offerta. -**Totale indicativo:** € {{ number_format($preventivo->totale, 2, ',', '.') }} +**Totale indicativo (IVA esclusa{{ $preventivo->haSconti() ? ', sconti inclusi' : '' }}):** € {{ number_format($preventivo->totale, 2, ',', '.') }} +@if($preventivo->trasportoDaQuantificare()) + +{{ $preventivo->notaTrasporto() }} +@endif Grazie,
Il team di Krèios diff --git a/backoffice/resources/views/filament/pages/scontistica.blade.php b/backoffice/resources/views/filament/pages/scontistica.blade.php new file mode 100644 index 0000000..5f15804 --- /dev/null +++ b/backoffice/resources/views/filament/pages/scontistica.blade.php @@ -0,0 +1,9 @@ + + + {{ $this->form }} + + + + diff --git a/backoffice/resources/views/pdf/preventivo.blade.php b/backoffice/resources/views/pdf/preventivo.blade.php index ab7563f..cbf99ff 100644 --- a/backoffice/resources/views/pdf/preventivo.blade.php +++ b/backoffice/resources/views/pdf/preventivo.blade.php @@ -31,6 +31,12 @@ .conf td.k { color: #666; width: 45%; } .prezzo-riga { margin-top: 6px; text-align: right; font-size: 12px; } .totale { margin-top: 14px; text-align: right; font-size: 16px; font-weight: bold; color: #12373C; } + .riepilogo { width: 62%; margin: 14px 0 0 auto; } + .riepilogo td { padding: 3px 0; text-align: right; color: #444; vertical-align: top; } + .riepilogo td + td { width: 110px; padding-left: 12px; white-space: nowrap; } + .riepilogo .dettaglio { color: #888; font-size: 10px; } + .riepilogo tr.forte td { font-weight: bold; border-top: 1px solid #ddd; } + .avviso-trasporto { margin-top: 10px; padding: 8px 10px; border: 1px solid #e6c200; background: #fff8d6; font-size: 11px; } .nota { color: #888; font-size: 10px; text-align: right; } @@ -99,7 +105,35 @@ @endforeach -
Totale: € {{ number_format($preventivo->totale, 2, ',', '.') }}
+ @php $importi = $preventivo->importiSconto(); @endphp + @if($preventivo->haSconti() || $preventivo->trasporto) + + + @if((float) $preventivo->sconto_1 > 0) + + @endif + @if((float) $preventivo->sconto_2 > 0) + + @endif + @if($preventivo->trasporto) + + + {{-- A capo dopo "Campania": "Contributo trasporto Campania / entro 150 km". --}} + + + + @endif + + @endif +
{{ $preventivo->trasporto ? 'Totale' : 'Totale imponibile' }}: € {{ number_format($preventivo->totale, 2, ',', '.') }}
+ @if($preventivo->trasportoDaQuantificare()) +
{{ $preventivo->notaTrasporto() }}
+ @endif
Prezzi IVA esclusa. Preventivo indicativo, salvo verifica tecnica e rilievo misure.
diff --git a/backoffice/routes/api.php b/backoffice/routes/api.php index 297e2d0..1ef8177 100644 --- a/backoffice/routes/api.php +++ b/backoffice/routes/api.php @@ -10,6 +10,13 @@ use Illuminate\Support\Facades\Route; // frontend non deve indovinare la porta su cui gira Vite (può cambiare se 5174 è occupata). Route::get('config', fn () => response()->json([ 'frontend_cliente_url' => config('preventivi.frontend_cliente_url'), + // Opzioni del contributo trasporto al checkout (importo per pezzo, null = da quantificare). + 'trasporto' => collect(config('preventivi.trasporto.zone')) + ->map(fn (array $zona, string $valore) => ['valore' => $valore] + $zona) + ->values(), + 'trasporto_nota' => config('preventivi.trasporto.nota_da_quantificare'), + 'trasporto_max_pezzi' => config('preventivi.trasporto.max_pezzi'), + 'trasporto_etichetta_oltre_max' => config('preventivi.trasporto.etichetta_oltre_max'), ])); // Catalogo prodotti (sistemi e modelli con icone) per il configuratore. diff --git a/backoffice/tests/Feature/ConfermaPreventivoTest.php b/backoffice/tests/Feature/ConfermaPreventivoTest.php index 8f17eb0..d5eb22e 100644 --- a/backoffice/tests/Feature/ConfermaPreventivoTest.php +++ b/backoffice/tests/Feature/ConfermaPreventivoTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Mail\PreventivoCliente; use App\Models\Cliente; +use App\Models\Impostazione; use App\Models\Preventivo; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; @@ -53,6 +54,7 @@ class ConfermaPreventivoTest extends TestCase 'citta' => 'Milano', 'provincia' => 'MI', 'note' => null, + 'trasporto' => 'campania_entro_150km', ]; } @@ -120,6 +122,176 @@ class ConfermaPreventivoTest extends TestCase $this->assertSame('mario.rossi@example.com', $preventivo->cliente->email); } + /** Carrello da 1000 € e cliente loggato; ritorna [preventivo, header Authorization]. */ + private function carrelloDaMilleConCliente(array $attributiCliente = []): array + { + Mockery::mock('alias:App\Support\PreventivoPdf') + ->shouldReceive('genera') + ->andReturn('preventivi/preventivo-1.pdf'); + Mail::fake(); + + $cliente = Cliente::create($attributiCliente + ['username' => 'user1234', 'password' => 'Segreta123!']); + $token = $cliente->createToken('configuratore', ['*'])->plainTextToken; + + $preventivo = $this->creaCarrelloBozza(); + $preventivo->items()->first()->update(['prezzo_unitario' => 1000, 'prezzo_totale' => 1000]); + $preventivo->ricalcolaTotale(); + + return [$preventivo, ['Authorization' => "Bearer {$token}"]]; + } + + public function test_regola_base_sconti_a_cascata(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(); + + $this->getJson('/api/auth/me', $auth) + ->assertJsonPath('sconti.max_1', 50) + ->assertJsonPath('sconti.max_2', 10); + + // Schema: 1000 -50% = 500, -10% extra sul già scontato = 450. + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 50, 'sconto_2' => 10], $auth) + ->assertOk(); + + $preventivo->refresh(); + $this->assertEquals(1000, $preventivo->subtotale); + $this->assertEquals(450 + 100, $preventivo->totale); + $this->assertSame(['sconto_1' => 500.0, 'sconto_2' => 50.0, 'imponibile' => 450.0], $preventivo->importiSconto()); + } + + public function test_sconto_oltre_il_massimo_e_rifiutato(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 60], $auth) + ->assertStatus(422) + ->assertJsonValidationErrors('sconto_1'); + + $this->assertSame('bozza', $preventivo->fresh()->stato); + } + + public function test_massimi_personalizzati_per_cliente(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(['sconto_max_1' => 30, 'sconto_max_2' => 0]); + + $this->getJson('/api/auth/me', $auth) + ->assertJsonPath('sconti.max_1', 30) + ->assertJsonPath('sconti.max_2', 0); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 30, 'sconto_2' => 5], $auth) + ->assertStatus(422) + ->assertJsonValidationErrors('sconto_2'); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 30], $auth) + ->assertOk(); + $this->assertEquals(700 + 100, $preventivo->fresh()->totale); + } + + public function test_regola_base_modificata_dal_backoffice(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(); + Impostazione::imposta('sconti.max_1', 20); + Impostazione::imposta('sconti.max_2', 5); + + $this->getJson('/api/auth/me', $auth) + ->assertJsonPath('sconti.max_1', 20) + ->assertJsonPath('sconti.max_2', 5); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 25], $auth) + ->assertStatus(422) + ->assertJsonValidationErrors('sconto_1'); + } + + public function test_sconti_disabilitati_dall_amministratore(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(['sconti_abilitati' => false]); + + $this->getJson('/api/auth/me', $auth)->assertJsonPath('sconti.max_1', 0); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 10], $auth) + ->assertStatus(422) + ->assertJsonValidationErrors('sconto_1'); + } + + public function test_guest_non_puo_applicare_sconti(): void + { + Mockery::mock('alias:App\Support\PreventivoPdf') + ->shouldReceive('genera') + ->andReturn('preventivi/preventivo-1.pdf'); + Mail::fake(); + + $preventivo = $this->creaCarrelloBozza(); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma() + ['sconto_1' => 10]) + ->assertStatus(422) + ->assertJsonValidationErrors('sconto_1'); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma())->assertOk(); + $this->assertEquals(500 + 100, $preventivo->fresh()->totale); // + trasporto entro 150 km, 1 pz + } + + public function test_trasporto_cadauno_dopo_gli_sconti(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(); + // 10 finestre: il contributo si paga 10 volte e non entra negli sconti. + $preventivo->items()->first()->update(['quantita' => 10]); + + $dati = ['trasporto' => 'campania_oltre_150km', 'sconto_1' => 50, 'sconto_2' => 10] + $this->datiConferma(); + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $dati, $auth)->assertOk(); + + $preventivo->refresh(); + $this->assertEquals(1500, $preventivo->trasporto_importo); + $this->assertEquals(450 + 1500, $preventivo->totale); + $this->assertFalse($preventivo->trasportoDaQuantificare()); + } + + public function test_oltre_20_pezzi_trasporto_da_quantificare_senza_scelta(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(); + $preventivo->items()->first()->update(['quantita' => 21]); + + // Nessuna scelta richiesta (e quella inviata è ignorata): il trasporto è da quantificare. + $dati = $this->datiConferma(); + unset($dati['trasporto']); + $this->postJson("/api/carrello/{$preventivo->token}/conferma", $dati, $auth)->assertOk(); + + $preventivo->refresh()->load('items', 'cliente'); + $this->assertSame(Preventivo::TRASPORTO_OLTRE_MAX, $preventivo->trasporto); + $this->assertNull($preventivo->trasporto_importo); + $this->assertEquals(1000, $preventivo->totale); + + $html = view('pdf.preventivo', ['preventivo' => $preventivo])->render(); + $this->assertStringContainsString('Contributo per il trasporto da quantificare', $html); + $this->assertStringContainsString('supera i 20 pezzi', $html); + } + + public function test_trasporto_altro_da_quantificare(): void + { + [$preventivo, $auth] = $this->carrelloDaMilleConCliente(); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", ['trasporto' => 'altro'] + $this->datiConferma(), $auth)->assertOk(); + + $preventivo->refresh(); + $this->assertNull($preventivo->trasporto_importo); + $this->assertTrue($preventivo->trasportoDaQuantificare()); + $this->assertEquals(1000, $preventivo->totale); + + $html = view('pdf.preventivo', ['preventivo' => $preventivo->load('items', 'cliente')])->render(); + $this->assertStringContainsString(config('preventivi.trasporto.nota_da_quantificare'), $html); + } + + public function test_trasporto_obbligatorio_e_valido(): void + { + $preventivo = $this->creaCarrelloBozza(); + + $this->postJson("/api/carrello/{$preventivo->token}/conferma", ['trasporto' => null] + $this->datiConferma()) + ->assertStatus(422)->assertJsonValidationErrors('trasporto'); + $this->postJson("/api/carrello/{$preventivo->token}/conferma", ['trasporto' => 'luna'] + $this->datiConferma()) + ->assertStatus(422)->assertJsonValidationErrors('trasporto'); + + $this->getJson('/api/config')->assertJsonPath('trasporto.0.valore', 'campania_entro_150km') + ->assertJsonPath('trasporto.2.importo', null); + } + public function test_conferma_con_token_non_valido_tratta_l_utente_come_guest(): void { Mockery::mock('alias:App\Support\PreventivoPdf') diff --git a/backoffice/tests/Feature/ConfiguratorePrezzoTest.php b/backoffice/tests/Feature/ConfiguratorePrezzoTest.php index b880672..93f0665 100644 --- a/backoffice/tests/Feature/ConfiguratorePrezzoTest.php +++ b/backoffice/tests/Feature/ConfiguratorePrezzoTest.php @@ -315,6 +315,13 @@ class ConfiguratorePrezzoTest extends TestCase ->assertJsonPath('prezzo', 775) ->assertJsonPath('configurazione.profili_extra', 'COPRIFILI SCATOLATI, 50811_GUIDA 34, HO9320_T/M, NP8300_ALL.30') ->assertJsonPath('dettaglio.supplementi.1.metri_lineari', 3); + + // I profili extra si calcolano sul serramento con le finiture: uniforme 500 x 1,27 = 635, + // coprifili +3% di 635 = 19,05; la cerniera (+100) viene dopo e non entra nel 3%. + $this->prezzo('finestra_2_ante', $misura + ['finitura_interna' => '2', 'finitura_esterna' => '2', 'opzioni' => ['profili_extra' => ['coprifili_scatolati'], 'cerniere_a_scomparsa' => 'si']]) + ->assertJsonPath('prezzo', 754.05) + ->assertJsonPath('dettaglio.subtotali.finitura', 635) + ->assertJsonPath('dettaglio.subtotali.profili', 654.05); } public function test_profili_extra_alternativi_e_per_modello(): void