feat: Implement price list management and import functionality

- Added ListinoRelationManager for managing price lists associated with models.
- Introduced ImportaListiniAction for importing price lists from Excel files.
- Created ListinoPrezzo model to represent price entries in the database.
- Developed ListinoExcelParser for parsing Excel files containing price lists.
- Implemented ListinoImporter to handle the import logic and associations.
- Added migration for creating listino_prezzi table and converting measures to mm.
- Updated frontend logic to handle new price list features and validations.
- Enhanced tests for price list import and conversion functionalities.
This commit is contained in:
2026-10-19 16:51:24 +02:00
parent a8aa6d9927
commit 1e352eb2ef
46 changed files with 1575 additions and 327 deletions
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature;
use App\Models\Modello;
use App\Models\Preventivo;
use App\Support\ConfigurazioneFormatter;
use Database\Seeders\CatalogoSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class ConversioneMisureMmTest extends TestCase
{
use RefreshDatabase;
private const MIGRAZIONE = 'database/migrations/2026_10_19_150000_converti_misure_in_mm.php';
public function test_la_migrazione_porta_listini_e_preventivi_esistenti_in_mm(): void
{
Storage::fake('public');
$this->seed(CatalogoSeeder::class);
// Riporta lo schema e i dati allo stato precedente (in cm).
Artisan::call('migrate:rollback', ['--path' => self::MIGRAZIONE]);
$modello = Modello::where('codice', 'finestra_1_anta')->firstOrFail();
DB::table('listino_prezzi')->insert(['modello_id' => $modello->id, 'larghezza' => 145, 'altezza' => 40, 'prezzo' => 367.2]);
$preventivo = Preventivo::create(['token' => Str::random(40), 'stato' => 'bozza']);
$item = $preventivo->items()->create([
'nome_modello' => 'Finestra 1 anta',
'larghezza' => 80,
'altezza' => 120,
'payload' => ['width' => '80.00', 'height' => '120.5', 'maniglia' => 'Bianca'],
]);
Artisan::call('migrate', ['--path' => self::MIGRAZIONE]);
$this->assertSame([1450, 400], [(int) $modello->listino()->value('larghezza'), (int) $modello->listino()->value('altezza')]);
$item->refresh();
$this->assertSame([800, 1200], [$item->larghezza, $item->altezza]);
$this->assertSame(['width' => '800', 'height' => '1205', 'maniglia' => 'Bianca'], $item->payload);
$righe = collect(ConfigurazioneFormatter::toRows($item->payload))->pluck('value', 'label');
$this->assertSame('800', $righe['Larghezza (mm)']);
}
}
@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature;
use App\Filament\Resources\ModelloResource\Pages\EditModello;
use App\Filament\Resources\ModelloResource\RelationManagers\ListinoRelationManager;
use App\Filament\Resources\SistemaResource\Pages\ListSistemi;
use App\Models\ListinoPrezzo;
use App\Models\Modello;
use App\Models\Sistema;
use App\Models\User;
use App\Support\Listini\ListinoExcelParser;
use App\Support\Listini\ListinoImporter;
use Database\Seeders\CatalogoSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class ImportazioneListiniTest extends TestCase
{
use RefreshDatabase;
private Sistema $pvc;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->seed(CatalogoSeeder::class);
$this->pvc = Sistema::where('codice', 'pvc')->firstOrFail();
$this->actingAs(User::factory()->create());
}
/** Due tabelle nel formato del listino, separate da una riga vuota. */
private function righe(): array
{
return [
[null, null, null],
['finestra_1_anta', 400, 500],
[400, 367.2, 381.244],
[500, 381.24, null],
[null, null, null],
['PORTA 1 ANTA, CON SERRATURA', 500, 600],
[1800, 796.5, 820],
];
}
private function fileExcel(): UploadedFile
{
$spreadsheet = new Spreadsheet;
$spreadsheet->getActiveSheet()->fromArray($this->righe(), null, 'A1', true);
$path = tempnam(sys_get_temp_dir(), 'listino');
(new Xlsx($spreadsheet))->save($path);
return UploadedFile::fake()->createWithContent('listino.xlsx', file_get_contents($path));
}
public function test_il_parser_legge_le_tabelle_in_mm(): void
{
$blocchi = ListinoExcelParser::parseRighe($this->righe());
$this->assertSame(['finestra_1_anta', 'PORTA 1 ANTA, CON SERRATURA'], array_column($blocchi, 'codice'));
$this->assertSame([
['larghezza' => 400, 'altezza' => 400, 'prezzo' => 367.2],
['larghezza' => 500, 'altezza' => 400, 'prezzo' => 381.24],
['larghezza' => 400, 'altezza' => 500, 'prezzo' => 381.24],
], $blocchi[0]['prezzi']);
}
public function test_il_parser_gestisce_l_intestazione_sulla_riga_successiva(): void
{
$blocchi = ListinoExcelParser::parseRighe([
['CASSONETTO PVC', null, null],
['mm', 500, 600],
[300, 94.14, 104.59],
]);
$this->assertSame('CASSONETTO PVC', $blocchi[0]['codice']);
$this->assertCount(2, $blocchi[0]['prezzi']);
}
public function test_associazione_proposta_per_codice_alias_e_descrizione(): void
{
$associazioni = ListinoImporter::suggerisciAssociazioni($this->pvc, [
'finestra_1_anta', 'finestra_wasistas', 'BILICO ORIZZONTALE', 'ALZANTE 2ANTE_MANO SX',
]);
$id = fn (string $codice) => Modello::where('codice', $codice)->value('id');
$this->assertSame([
'finestra_1_anta' => $id('finestra_1_anta'),
'finestra_wasistas' => $id('finestra_vasistas'),
'BILICO ORIZZONTALE' => $id('bilico'),
'ALZANTE 2ANTE_MANO SX' => null,
], $associazioni);
}
public function test_l_azione_importa_i_listini_secondo_le_associazioni_scelte(): void
{
$finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail();
$porta = Modello::where('codice', 'porta_1_anta')->firstOrFail();
$bilico = Modello::where('codice', 'bilico')->firstOrFail();
// Listino preesistente di un modello non coinvolto: deve restare com'è.
ListinoPrezzo::create(['modello_id' => $bilico->id, 'larghezza' => 1000, 'altezza' => 1000, 'prezzo' => 1]);
ListinoPrezzo::create(['modello_id' => $finestra->id, 'larghezza' => 9990, 'altezza' => 9990, 'prezzo' => 1]);
Livewire::test(ListSistemi::class)
->callTableAction('importaListini', $this->pvc, data: [
'file' => $this->fileExcel(),
'associazioni' => [
'a' => ['codice' => 'finestra_1_anta', 'modello_id' => $finestra->id],
// Associazione corretta a mano dall'admin.
'b' => ['codice' => 'PORTA 1 ANTA, CON SERRATURA', 'modello_id' => $porta->id],
],
])
->assertHasNoTableActionErrors()
->assertNotified('Listini importati');
$this->assertSame(3, $finestra->listino()->count());
$this->assertFalse($finestra->listino()->where('larghezza', 9990)->exists());
$this->assertEquals(820, $porta->listino()->where('larghezza', 600)->value('prezzo'));
$this->assertSame(1, $bilico->listino()->count());
$this->assertNotNull($finestra->fresh()->listino_aggiornato_at);
}
public function test_due_tabelle_non_possono_andare_sullo_stesso_modello(): void
{
$finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail();
Livewire::test(ListSistemi::class)
->callTableAction('importaListini', $this->pvc, data: [
'file' => $this->fileExcel(),
'associazioni' => [
'a' => ['codice' => 'finestra_1_anta', 'modello_id' => $finestra->id],
'b' => ['codice' => 'PORTA 1 ANTA, CON SERRATURA', 'modello_id' => $finestra->id],
],
])
->assertHasTableActionErrors();
$this->assertSame(0, $finestra->listino()->count());
}
public function test_il_listino_e_modificabile_dal_modello(): void
{
$finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail();
$prezzo = ListinoPrezzo::create(['modello_id' => $finestra->id, 'larghezza' => 400, 'altezza' => 400, 'prezzo' => 100]);
Livewire::test(ListinoRelationManager::class, ['ownerRecord' => $finestra, 'pageClass' => EditModello::class])
->assertCanSeeTableRecords([$prezzo])
->callTableAction('create', data: ['larghezza' => 500, 'altezza' => 400, 'prezzo' => 120])
->assertHasNoTableActionErrors()
->callTableAction('create', data: ['larghezza' => 400, 'altezza' => 400, 'prezzo' => 1])
->assertHasTableActionErrors(['larghezza' => 'unique']);
$this->assertSame(2, $finestra->listino()->count());
}
public function test_api_listino_restituisce_la_griglia_ordinata(): void
{
$finestra = Modello::where('codice', 'finestra_1_anta')->firstOrFail();
ListinoImporter::importa($this->pvc, ListinoExcelParser::parseRighe($this->righe()), ['finestra_1_anta' => $finestra->id]);
$this->getJson('/api/sistemi/pvc/modelli/finestra_1_anta/listino')
->assertOk()
->assertExactJson([
['altezza' => 400, 'larghezza' => 400, 'prezzo' => 367.2],
['altezza' => 400, 'larghezza' => 500, 'prezzo' => 381.24],
['altezza' => 500, 'larghezza' => 400, 'prezzo' => 381.24],
]);
// Modello disattivo (senza asset 3D): non esposto.
$this->getJson('/api/sistemi/pvc/modelli/scorrevole_sf_3_ante/listino')->assertNotFound();
}
}