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.
This commit is contained in:
@@ -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
|
`flipView` (180° GSAP-animated turn between front/back view), `addMarker` (in-scene hotspot with a video
|
||||||
popup).
|
popup).
|
||||||
6. **Pricing is server-side only**: `backoffice/app/Support/Configuratore/CalcolatorePrezzo.php`.
|
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
|
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
|
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,
|
(ml from L/H multipliers + extra mm). Groups with `ui = multipla` (profili extra) accept several codes,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Impostazione;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regola base di scontistica: sconti massimi che ogni cliente può applicare al preventivo,
|
||||||
|
* salvo massimi personalizzati sulla sua scheda.
|
||||||
|
*
|
||||||
|
* @property Form $form
|
||||||
|
*/
|
||||||
|
class Scontistica extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-receipt-percent';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Scontistica';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Scontistica globale';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
protected static string $view = 'filament.pages.scontistica';
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Resources\ClienteResource\Pages;
|
use App\Filament\Resources\ClienteResource\Pages;
|
||||||
use App\Models\Cliente;
|
use App\Models\Cliente;
|
||||||
|
use App\Models\Impostazione;
|
||||||
use App\Support\ClienteCredenziali;
|
use App\Support\ClienteCredenziali;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
@@ -63,6 +64,36 @@ class ClienteResource extends Resource
|
|||||||
? 'Generata automaticamente. Ricordati di copiarla dopo il salvataggio: non sarà più leggibile.'
|
? '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.'),
|
: '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')
|
Forms\Components\Section::make('Anagrafica')
|
||||||
->description('Campi facoltativi.')
|
->description('Campi facoltativi.')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -124,6 +155,19 @@ class ClienteResource extends Resource
|
|||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('username')->label('Nome utente')->copyable()->placeholder('—'),
|
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('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')
|
Infolists\Components\Section::make('Anagrafica')
|
||||||
->columns(3)
|
->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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -145,7 +145,28 @@ class PreventivoResource extends Resource
|
|||||||
->label('Stato')
|
->label('Stato')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (string $state) => Preventivo::STATI[$state] ?? $state),
|
->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('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'),
|
Infolists\Components\TextEntry::make('created_at')->label('Creato il')->dateTime('d/m/Y H:i'),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources\PreventivoResource\Pages;
|
|||||||
use App\Filament\Resources\PreventivoResource;
|
use App\Filament\Resources\PreventivoResource;
|
||||||
use App\Support\PreventivoPdf;
|
use App\Support\PreventivoPdf;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -38,6 +39,21 @@ class ViewPreventivo extends ViewRecord
|
|||||||
'preventivo-'.$this->record->numero.'.pdf'
|
'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(),
|
Actions\EditAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ class AuthController extends Controller
|
|||||||
'provincia' => $cliente->provincia,
|
'provincia' => $cliente->provincia,
|
||||||
'note' => $cliente->note,
|
'note' => $cliente->note,
|
||||||
],
|
],
|
||||||
|
// Sconti massimi (%) applicabili al preventivo prima dell'invio (0 = non consentito).
|
||||||
|
'sconti' => $cliente->scontiMassimi(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class CarrelloController extends Controller
|
class CarrelloController extends Controller
|
||||||
{
|
{
|
||||||
@@ -142,6 +143,15 @@ class CarrelloController extends Controller
|
|||||||
'note' => ['nullable', 'string', 'max:2000'],
|
'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
|
// Se la richiesta arriva da un cliente loggato nel configuratore (token Sanctum
|
||||||
// valido), il preventivo va agganciato al suo record esistente — aggiornando
|
// valido), il preventivo va agganciato al suo record esistente — aggiornando
|
||||||
// l'anagrafica con i dati confermati nel form — e NON a un nuovo cliente.
|
// 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.
|
// per i guest (o token assenti/scaduti/non validi) restituisce null.
|
||||||
$clienteLoggato = Auth::guard('sanctum')->user();
|
$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;
|
$cliente = $clienteLoggato;
|
||||||
|
|
||||||
if ($cliente) {
|
if ($cliente) {
|
||||||
@@ -162,7 +182,10 @@ class CarrelloController extends Controller
|
|||||||
$preventivo->stato = 'inviato';
|
$preventivo->stato = 'inviato';
|
||||||
$preventivo->numero = Preventivo::prossimoNumero();
|
$preventivo->numero = Preventivo::prossimoNumero();
|
||||||
$preventivo->confirmed_at = now();
|
$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');
|
$preventivo->refresh()->load('cliente', 'items');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class Cliente extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'username', 'password',
|
'username', 'password',
|
||||||
|
'sconti_abilitati', 'sconto_max_1', 'sconto_max_2',
|
||||||
'nome', 'cognome', 'ragione_sociale', 'email', 'telefono',
|
'nome', 'cognome', 'ragione_sociale', 'email', 'telefono',
|
||||||
'indirizzo', 'cap', 'citta', 'provincia', 'note',
|
'indirizzo', 'cap', 'citta', 'provincia', 'note',
|
||||||
];
|
];
|
||||||
@@ -24,8 +25,29 @@ class Cliente extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'password' => 'hashed',
|
'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
|
public function preventivi(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Preventivo::class);
|
return $this->hasMany(Preventivo::class);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class Impostazione extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'impostazioni';
|
||||||
|
|
||||||
|
protected $fillable = ['chiave', 'valore'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'valore' => '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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,14 +12,21 @@ class Preventivo extends Model
|
|||||||
protected $table = 'preventivi';
|
protected $table = 'preventivi';
|
||||||
|
|
||||||
protected $fillable = [
|
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 = [
|
protected $casts = [
|
||||||
|
'trasporto_importo' => 'decimal:2',
|
||||||
|
'subtotale' => 'decimal:2',
|
||||||
|
'sconto_1' => 'decimal:2',
|
||||||
|
'sconto_2' => 'decimal:2',
|
||||||
'totale' => 'decimal:2',
|
'totale' => 'decimal:2',
|
||||||
'confirmed_at' => 'datetime',
|
'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 = [
|
public const STATI = [
|
||||||
'bozza' => 'Bozza',
|
'bozza' => 'Bozza',
|
||||||
'inviato' => 'Inviato',
|
'inviato' => 'Inviato',
|
||||||
@@ -55,13 +62,85 @@ class Preventivo extends Model
|
|||||||
return $this->hasMany(PreventivoItem::class);
|
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
|
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();
|
$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). */
|
/** Numero progressivo assegnato alla conferma (max+1). */
|
||||||
public static function prossimoNumero(): int
|
public static function prossimoNumero(): int
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Filament\Http\Middleware\Authenticate;
|
|||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
use Filament\Pages;
|
use Filament\Pages;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
@@ -39,6 +40,12 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->pages([
|
->pages([
|
||||||
Pages\Dashboard::class,
|
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')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
->widgets([])
|
->widgets([])
|
||||||
->middleware([
|
->middleware([
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ use Illuminate\Validation\ValidationException;
|
|||||||
/**
|
/**
|
||||||
* Prezzo di una configurazione, calcolato solo lato server dalle regole del backoffice:
|
* 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;
|
* - 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)
|
* - 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.
|
* 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
|
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<string,mixed>} $scelte
|
* @param array{larghezza?:mixed,altezza?:mixed,finitura_interna?:mixed,finitura_esterna?:mixed,maniglia?:mixed,vetro?:mixed,opzioni?:array<string,mixed>} $scelte
|
||||||
* @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array}
|
* @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array}
|
||||||
@@ -37,7 +46,8 @@ class CalcolatorePrezzo
|
|||||||
[$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza);
|
[$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza);
|
||||||
|
|
||||||
$opzioni = Opzione::perModello($modello);
|
$opzioni = Opzione::perModello($modello);
|
||||||
$supplementi = [];
|
// Opzioni scelte per fase: [voce, Opzione, valore scelto]; gli importi dopo, a cascata.
|
||||||
|
$fasi = ['finitura' => [], 'profili' => [], 'altro' => []];
|
||||||
$configurazione = [
|
$configurazione = [
|
||||||
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
|
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
|
||||||
'width' => $larghezza,
|
'width' => $larghezza,
|
||||||
@@ -60,7 +70,7 @@ class CalcolatorePrezzo
|
|||||||
default => 'uniforme',
|
default => 'uniforme',
|
||||||
};
|
};
|
||||||
$voce = $opzioni->get('tipo_finitura', collect())->firstWhere('valore', $tipo);
|
$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['tipo_finitura'] = $voce?->etichetta ?? ucfirst($tipo);
|
||||||
$configurazione['finitura_interna'] = $interna->descrizione;
|
$configurazione['finitura_interna'] = $interna->descrizione;
|
||||||
@@ -87,10 +97,12 @@ class CalcolatorePrezzo
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$fase = $gruppo === self::PROFILI_EXTRA ? 'profili' : 'altro';
|
||||||
|
|
||||||
if ($definizione->ui === 'multipla') {
|
if ($definizione->ui === 'multipla') {
|
||||||
$scelteGruppo = $this->scegliMultiple($righe, $scelte['opzioni'][$gruppo] ?? [], "opzioni.{$gruppo}");
|
$scelteGruppo = $this->scegliMultiple($righe, $scelte['opzioni'][$gruppo] ?? [], "opzioni.{$gruppo}");
|
||||||
foreach ($scelteGruppo as $scelta) {
|
foreach ($scelteGruppo as $scelta) {
|
||||||
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
|
$fasi[$fase][] = [$definizione->etichetta, $scelta, null];
|
||||||
}
|
}
|
||||||
if ($scelteGruppo->isNotEmpty()) {
|
if ($scelteGruppo->isNotEmpty()) {
|
||||||
$configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', ');
|
$configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', ');
|
||||||
@@ -101,7 +113,7 @@ class CalcolatorePrezzo
|
|||||||
}
|
}
|
||||||
|
|
||||||
$scelta = $this->scegli($righe, $scelte['opzioni'][$gruppo] ?? null, "opzioni.{$gruppo}", null, 'valore');
|
$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;
|
$configurazione[$gruppo] = $scelta->etichetta;
|
||||||
$normalizzate['opzioni'][$gruppo] = $scelta->valore;
|
$normalizzate['opzioni'][$gruppo] = $scelta->valore;
|
||||||
}
|
}
|
||||||
@@ -117,11 +129,22 @@ class CalcolatorePrezzo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$supplementi = array_values(array_filter($supplementi));
|
// Ogni fase applica le sue percentuali al totale della precedente.
|
||||||
$percentuale = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] === 'percentuale'), 'valore'));
|
$supplementi = [];
|
||||||
// Importi fissi e al metro lineare (già calcolati sulle misure).
|
$subtotali = [];
|
||||||
$fisso = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] !== 'percentuale'), 'importo'));
|
$totale = $base;
|
||||||
$prezzo = $base * (1 + $percentuale / 100) + $fisso + ($vetro['importo'] ?? 0);
|
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 [
|
return [
|
||||||
'prezzo' => round($prezzo, 2),
|
'prezzo' => round($prezzo, 2),
|
||||||
@@ -131,8 +154,8 @@ class CalcolatorePrezzo
|
|||||||
'prezzo_base' => $base,
|
'prezzo_base' => $base,
|
||||||
'misura_listino' => $misuraListino,
|
'misura_listino' => $misuraListino,
|
||||||
'supplementi' => $supplementi,
|
'supplementi' => $supplementi,
|
||||||
'percentuale_totale' => $percentuale,
|
// Totale dopo ogni fase (finitura = serramento, profili = con profili extra).
|
||||||
'fisso_totale' => $fisso,
|
'subtotali' => $subtotali,
|
||||||
'vetro' => $vetro,
|
'vetro' => $vetro,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -215,6 +238,7 @@ class CalcolatorePrezzo
|
|||||||
return $righe->filter(fn (Opzione $o) => $scelte->contains(fn (Opzione $s) => $s->is($o)))->values();
|
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
|
private function supplemento(string $voce, ?Opzione $opzione, float $base, int $larghezza, int $altezza, ?string $valore = null): ?array
|
||||||
{
|
{
|
||||||
if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) {
|
if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) {
|
||||||
|
|||||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/jpeg" href="/kreios-logo.jpg" />
|
<link rel="icon" type="image/jpeg" href="/kreios-logo.jpg" />
|
||||||
<title>Il tuo preventivo · Krèios</title>
|
<title>Il tuo preventivo · Krèios</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CnLA_o5j.js"></script>
|
<script type="module" crossorigin src="/assets/index-CmMOSSq4.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-vT7pz0E4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C_ir1rDB.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -18,6 +18,51 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
const anagraficaCaricata = ref(false)
|
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)
|
const isEmpty = computed(() => !carrello.items || carrello.items.length === 0)
|
||||||
|
|
||||||
// Se il cliente è loggato nel configuratore, precompila il form con i suoi dati già
|
// Se il cliente è loggato nel configuratore, precompila il form con i suoi dati già
|
||||||
@@ -31,6 +76,7 @@ async function precompilaAnagrafica() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.me(clienteToken)
|
const data = await api.me(clienteToken)
|
||||||
|
Object.assign(scontiMassimi, data.sconti || {})
|
||||||
const campi = ['nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note']
|
const campi = ['nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note']
|
||||||
for (const campo of campi) {
|
for (const campo of campi) {
|
||||||
if (!form[campo] && data.cliente[campo]) {
|
if (!form[campo] && data.cliente[campo]) {
|
||||||
@@ -45,6 +91,7 @@ async function precompilaAnagrafica() {
|
|||||||
function vaiAlCheckout() {
|
function vaiAlCheckout() {
|
||||||
step.value = 'checkout'
|
step.value = 'checkout'
|
||||||
precompilaAnagrafica()
|
precompilaAnagrafica()
|
||||||
|
if (!opzioniTrasporto.value.length) caricaOpzioniTrasporto()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCarrello() {
|
async function loadCarrello() {
|
||||||
@@ -83,7 +130,14 @@ async function conferma() {
|
|||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
fieldErrors.value = {}
|
fieldErrors.value = {}
|
||||||
try {
|
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
|
numeroConfermato.value = data.numero
|
||||||
clearToken()
|
clearToken()
|
||||||
step.value = 'done'
|
step.value = 'done'
|
||||||
@@ -224,6 +278,56 @@ onMounted(loadCarrello)
|
|||||||
<textarea v-model="form.note" rows="3"></textarea>
|
<textarea v-model="form.note" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trasportoOltreMax" class="field">
|
||||||
|
<label>Trasporto</label>
|
||||||
|
<div class="nota-trasporto">
|
||||||
|
<b>{{ etichettaOltreMax }}</b>: il preventivo contiene {{ pezzi }} pezzi e supera il limite di
|
||||||
|
{{ maxPezziTrasporto }} pezzi previsto per il contributo cadauno. {{ notaTrasporto }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="field">
|
||||||
|
<label>Trasporto *</label>
|
||||||
|
<select v-model="trasporto">
|
||||||
|
<option value="" disabled>Seleziona…</option>
|
||||||
|
<option v-for="zona in opzioniTrasporto" :key="zona.valore" :value="zona.valore">
|
||||||
|
{{ zona.etichetta }}{{ zona.importo != null ? ` — ${formatEuro(zona.importo)} cad.` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="fieldErrors.trasporto" class="err">{{ fieldErrors.trasporto[0] }}</div>
|
||||||
|
<div v-if="zonaTrasporto && zonaTrasporto.importo == null" class="nota-trasporto">{{ notaTrasporto }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sconti">
|
||||||
|
<p v-if="puoScontare" class="sconti-titolo">Sconti</p>
|
||||||
|
<div v-if="puoScontare" class="grid-2">
|
||||||
|
<div v-if="scontiMassimi.max_1 > 0" class="field">
|
||||||
|
<label>1° sconto concesso (max {{ scontiMassimi.max_1 }}%)</label>
|
||||||
|
<input v-model.number="sconti.sconto_1" type="number" min="0" :max="scontiMassimi.max_1" step="0.5" />
|
||||||
|
<div v-if="fieldErrors.sconto_1" class="err">{{ fieldErrors.sconto_1[0] }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="scontiMassimi.max_2 > 0" class="field">
|
||||||
|
<label>2° sconto extra (max {{ scontiMassimi.max_2 }}%)</label>
|
||||||
|
<input v-model.number="sconti.sconto_2" type="number" min="0" :max="scontiMassimi.max_2" step="0.5" />
|
||||||
|
<div v-if="fieldErrors.sconto_2" class="err">{{ fieldErrors.sconto_2[0] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sconti-riga"><span>Totale parziale</span><span>{{ formatEuro(riepilogoSconti.parziale) }}</span></div>
|
||||||
|
<div v-if="riepilogoSconti.s1" class="sconti-riga"><span>1° sconto</span><span>− {{ formatEuro(riepilogoSconti.s1) }}</span></div>
|
||||||
|
<div v-if="riepilogoSconti.s2" class="sconti-riga"><span>2° sconto extra</span><span>− {{ formatEuro(riepilogoSconti.s2) }}</span></div>
|
||||||
|
<div class="sconti-riga forte"><span>Totale imponibile offerta</span><span>{{ formatEuro(riepilogoSconti.imponibile) }}</span></div>
|
||||||
|
<div v-if="trasportoOltreMax" class="sconti-riga">
|
||||||
|
<span>Trasporto</span><span>da quantificare</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="zonaTrasporto" class="sconti-riga">
|
||||||
|
<span>Trasporto<template v-if="riepilogoSconti.trasportoImporto != null"> ({{ pezzi }} pz)</template></span>
|
||||||
|
<span>{{ riepilogoSconti.trasportoImporto != null ? formatEuro(riepilogoSconti.trasportoImporto) : 'da quantificare' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="totale-row">
|
||||||
|
<span>Totale <span class="iva-nota">IVA esclusa</span></span>
|
||||||
|
<span>{{ formatEuro(riepilogoSconti.totale) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn-primary" :disabled="submitting" @click="conferma">
|
<button class="btn-primary" :disabled="submitting" @click="conferma">
|
||||||
{{ submitting ? 'Invio in corso…' : 'Conferma e invia' }}
|
{{ submitting ? 'Invio in corso…' : 'Conferma e invia' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ async function request(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
config: () => request('/config'),
|
||||||
getCarrello: (token) => request(`/carrello/${token}`),
|
getCarrello: (token) => request(`/carrello/${token}`),
|
||||||
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
||||||
// Se l'utente era loggato nel configuratore, passiamo il suo token al backend così
|
// Se l'utente era loggato nel configuratore, passiamo il suo token al backend così
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; }
|
|||||||
|
|
||||||
.field { margin-bottom: 12px; }
|
.field { margin-bottom: 12px; }
|
||||||
.field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
.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;
|
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; }
|
.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; }
|
.success-icon { font-size: 48px; }
|
||||||
.done-logo { width: auto; border-radius: 3px; margin-bottom: 16px; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -7,6 +7,30 @@ return [
|
|||||||
// URL base dell'app cliente (per link nelle email admin).
|
// URL base dell'app cliente (per link nelle email admin).
|
||||||
'frontend_cliente_url' => env('APP_FRONTEND_CLIENTE_URL', 'http://localhost:5199'),
|
'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.
|
// Durata (in giorni) della sessione di login del configuratore.
|
||||||
'configurator_session_days' => (int) env('CONFIGURATOR_SESSION_DAYS', 30),
|
'configurator_session_days' => (int) env('CONFIGURATOR_SESSION_DAYS', 30),
|
||||||
];
|
];
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scontistica sul preventivo: il cliente applica, prima dell'invio, un 1° sconto sull'imponibile e
|
||||||
|
* un 2° sconto extra sul totale già scontato, entro i massimi decisi dall'amministratore.
|
||||||
|
* Massimi null = regola base (config preventivi.sconti); sconti_abilitati = false li disattiva.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('clienti', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contributo trasporto scelto al checkout (chiave di config preventivi.trasporto.zone): importo
|
||||||
|
* per pezzo x pezzi, aggiunto dopo gli sconti. Importo null = da quantificare (zona "Altro").
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('preventivi', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impostazioni globali modificabili dal backoffice (chiave => 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,6 +13,13 @@
|
|||||||
**Email:** {{ $preventivo->cliente?->email }}
|
**Email:** {{ $preventivo->cliente?->email }}
|
||||||
**Telefono:** {{ $preventivo->cliente?->telefono ?: '—' }}
|
**Telefono:** {{ $preventivo->cliente?->telefono ?: '—' }}
|
||||||
**Prodotti:** {{ $preventivo->items->count() }}
|
**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, ',', '.') }}
|
**Totale:** € {{ number_format($preventivo->totale, 2, ',', '.') }}
|
||||||
|
|
||||||
<x-mail::button :url="$adminUrl">
|
<x-mail::button :url="$adminUrl">
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ indicativo (prezzi IVA esclusa).
|
|||||||
|
|
||||||
Un nostro consulente ti ricontatterà al più presto per finalizzare l'offerta.
|
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,<br>
|
Grazie,<br>
|
||||||
Il team di Krèios
|
Il team di Krèios
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament-panels::form wire:submit="save">
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
<x-filament-panels::form.actions :actions="[
|
||||||
|
\Filament\Actions\Action::make('save')->label('Salva')->submit('save'),
|
||||||
|
]" />
|
||||||
|
</x-filament-panels::form>
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
.conf td.k { color: #666; width: 45%; }
|
.conf td.k { color: #666; width: 45%; }
|
||||||
.prezzo-riga { margin-top: 6px; text-align: right; font-size: 12px; }
|
.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; }
|
.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; }
|
.nota { color: #888; font-size: 10px; text-align: right; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -99,7 +105,35 @@
|
|||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
<div class="totale">Totale: € {{ number_format($preventivo->totale, 2, ',', '.') }}</div>
|
@php $importi = $preventivo->importiSconto(); @endphp
|
||||||
|
@if($preventivo->haSconti() || $preventivo->trasporto)
|
||||||
|
<table class="riepilogo">
|
||||||
|
<tr><td>Totale parziale</td><td>€ {{ number_format($preventivo->subtotale, 2, ',', '.') }}</td></tr>
|
||||||
|
@if((float) $preventivo->sconto_1 > 0)
|
||||||
|
<tr><td>1° sconto concesso ({{ rtrim(rtrim(number_format($preventivo->sconto_1, 2, ',', ''), '0'), ',') }}%)</td><td>− € {{ number_format($importi['sconto_1'], 2, ',', '.') }}</td></tr>
|
||||||
|
@endif
|
||||||
|
@if((float) $preventivo->sconto_2 > 0)
|
||||||
|
<tr><td>2° sconto extra ({{ rtrim(rtrim(number_format($preventivo->sconto_2, 2, ',', ''), '0'), ',') }}%)</td><td>− € {{ number_format($importi['sconto_2'], 2, ',', '.') }}</td></tr>
|
||||||
|
@endif
|
||||||
|
@if($preventivo->trasporto)
|
||||||
|
<tr class="forte"><td>Totale imponibile offerta</td><td>€ {{ number_format($importi['imponibile'], 2, ',', '.') }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
{{-- A capo dopo "Campania": "Contributo trasporto Campania / entro 150 km". --}}
|
||||||
|
<td>
|
||||||
|
{!! preg_replace('/(Campania)\s+/u', '$1<br>', e($preventivo->trasporto_etichetta)) !!}
|
||||||
|
@unless($preventivo->trasportoDaQuantificare())
|
||||||
|
<br><span class="dettaglio">{{ $preventivo->items->sum('quantita') }} pz × € {{ number_format(config("preventivi.trasporto.zone.{$preventivo->trasporto}.importo"), 2, ',', '.') }}</span>
|
||||||
|
@endunless
|
||||||
|
</td>
|
||||||
|
<td>{{ $preventivo->trasporto === \App\Models\Preventivo::TRASPORTO_OLTRE_MAX ? '—' : ($preventivo->trasportoDaQuantificare() ? 'da quantificare' : '€ '.number_format($preventivo->trasporto_importo, 2, ',', '.')) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</table>
|
||||||
|
@endif
|
||||||
|
<div class="totale">{{ $preventivo->trasporto ? 'Totale' : 'Totale imponibile' }}: € {{ number_format($preventivo->totale, 2, ',', '.') }}</div>
|
||||||
|
@if($preventivo->trasportoDaQuantificare())
|
||||||
|
<div class="avviso-trasporto">{{ $preventivo->notaTrasporto() }}</div>
|
||||||
|
@endif
|
||||||
<div class="nota">Prezzi IVA esclusa. Preventivo indicativo, salvo verifica tecnica e rilievo misure.</div>
|
<div class="nota">Prezzi IVA esclusa. Preventivo indicativo, salvo verifica tecnica e rilievo misure.</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ use Illuminate\Support\Facades\Route;
|
|||||||
// frontend non deve indovinare la porta su cui gira Vite (può cambiare se 5174 è occupata).
|
// frontend non deve indovinare la porta su cui gira Vite (può cambiare se 5174 è occupata).
|
||||||
Route::get('config', fn () => response()->json([
|
Route::get('config', fn () => response()->json([
|
||||||
'frontend_cliente_url' => config('preventivi.frontend_cliente_url'),
|
'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.
|
// Catalogo prodotti (sistemi e modelli con icone) per il configuratore.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Mail\PreventivoCliente;
|
use App\Mail\PreventivoCliente;
|
||||||
use App\Models\Cliente;
|
use App\Models\Cliente;
|
||||||
|
use App\Models\Impostazione;
|
||||||
use App\Models\Preventivo;
|
use App\Models\Preventivo;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
@@ -53,6 +54,7 @@ class ConfermaPreventivoTest extends TestCase
|
|||||||
'citta' => 'Milano',
|
'citta' => 'Milano',
|
||||||
'provincia' => 'MI',
|
'provincia' => 'MI',
|
||||||
'note' => null,
|
'note' => null,
|
||||||
|
'trasporto' => 'campania_entro_150km',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +122,176 @@ class ConfermaPreventivoTest extends TestCase
|
|||||||
$this->assertSame('mario.rossi@example.com', $preventivo->cliente->email);
|
$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
|
public function test_conferma_con_token_non_valido_tratta_l_utente_come_guest(): void
|
||||||
{
|
{
|
||||||
Mockery::mock('alias:App\Support\PreventivoPdf')
|
Mockery::mock('alias:App\Support\PreventivoPdf')
|
||||||
|
|||||||
@@ -315,6 +315,13 @@ class ConfiguratorePrezzoTest extends TestCase
|
|||||||
->assertJsonPath('prezzo', 775)
|
->assertJsonPath('prezzo', 775)
|
||||||
->assertJsonPath('configurazione.profili_extra', 'COPRIFILI SCATOLATI, 50811_GUIDA 34, HO9320_T/M, NP8300_ALL.30')
|
->assertJsonPath('configurazione.profili_extra', 'COPRIFILI SCATOLATI, 50811_GUIDA 34, HO9320_T/M, NP8300_ALL.30')
|
||||||
->assertJsonPath('dettaglio.supplementi.1.metri_lineari', 3);
|
->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
|
public function test_profili_extra_alternativi_e_per_modello(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user