feat(fornitore-seriali-mysql): importazione seriali RMA da contabilita MySQL, selettore fornitore e risoluzione contesto Nethome

This commit is contained in:
michele 2026-09-11 16:16:50 +02:00
parent 208287779b
commit 85037aece1
12 changed files with 818 additions and 8 deletions

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use App\Services\Catalog\ContabilitaMysqlSerialImportService;
use Illuminate\Console\Command;
class ImportNcomSerialsCommand extends Command
{
protected $signature = 'fornitore:import-mysql-serials
{--supplier=NCOMSRL : Codice fornitore in contabilità MySQL (es. NCOMSRL)}
{--fornitore-id= : ID del fornitore locale in NetGescon}
{--dry-run : Esegue la scansione senza persistere modifiche}';
protected $description = 'Importa prodotti, codici articolo, prezzi e seriali (RMA) dalla contabilità MySQL esterna.';
public function handle(ContabilitaMysqlSerialImportService $importer): int
{
$supplier = (string) $this->option('supplier');
$fornitoreId = $this->option('fornitore-id') ? (int) $this->option('fornitore-id') : null;
$dryRun = (bool) $this->option('dry-run');
$this->info("Inizio importazione seriali e prodotti per '{$supplier}' da contabilità MySQL...");
try {
$stats = $importer->importForSupplier($supplier, $fornitoreId, $dryRun);
$this->table(['Metrica', 'Valore'], [
['Codice Fornitore', $stats['supplier_code']],
['ID Fornitore Locale', $stats['fornitore_id']],
['Fatture Trovate', $stats['invoices_count']],
['Righe Documento Elaborate', $stats['lines_processed']],
['Prodotti Creati', $stats['products_created']],
['Codici Articolo / Identifier Creati', $stats['identifiers_created']],
['Seriali Nuovi Inseriti', $stats['serials_created']],
['Seriali Aggiornati', $stats['serials_updated']],
['Modalità Dry-Run', $stats['dry_run'] ? 'Sì' : 'No'],
]);
$this->info("Importazione completata con successo!");
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error("Errore durante l'importazione: " . $e->getMessage());
return self::FAILURE;
}
}
}

View File

@ -37,8 +37,22 @@ protected function resolveOperatoreContext(?int $forcedFornitoreId = null, bool
return [$fornitore, null];
}
if ($allowAdminWithoutSupplier) {
return [null, null];
if ($fornitoreId <= 0) {
$linkedSupplier = $this->resolveCurrentUserSupplier($user);
if ($linkedSupplier instanceof Fornitore && $this->canAccessFornitoreAsInternalUser($user, $linkedSupplier)) {
return [$linkedSupplier, null];
}
$defaultSupplier = Fornitore::query()->where('partita_iva', '10055221005')->first()
?? Fornitore::query()->orderBy('id')->first();
if ($defaultSupplier instanceof Fornitore && $this->canAccessFornitoreAsInternalUser($user, $defaultSupplier)) {
return [$defaultSupplier, null];
}
if ($allowAdminWithoutSupplier) {
return [null, null];
}
}
}
@ -145,13 +159,58 @@ protected function resolveCurrentUserSupplier($user): ?Fornitore
return null;
}
return Fornitore::query()
->whereRaw('LOWER(email) = ?', [$email])
// 1. Direct match on Fornitore email or pec
$supplier = Fornitore::query()
->where(function ($q) use ($email) {
$q->whereRaw('LOWER(email) = ?', [$email])
->orWhereRaw('LOWER(pec) = ?', [$email]);
})
->withCount(['ticketInterventi', 'dipendenti'])
->orderByDesc('ticket_interventi_count')
->orderByDesc('dipendenti_count')
->orderByDesc('id')
->first();
if ($supplier instanceof Fornitore) {
return $supplier;
}
// 2. Match on FornitoreDipendente
$dipendente = FornitoreDipendente::query()
->where('attivo', true)
->where(function ($q) use ($user, $email) {
$q->where('user_id', (int) $user->id)
->orWhereRaw('LOWER(email) = ?', [$email]);
})
->first();
if ($dipendente && $dipendente->fornitore_id) {
$fornitore = Fornitore::query()->find($dipendente->fornitore_id);
if ($fornitore instanceof Fornitore) {
return $fornitore;
}
}
// 3. Match operational_config (supplier_emails or supplier_user_ids)
$candidates = Fornitore::query()->whereNotNull('operational_config')->get();
foreach ($candidates as $cand) {
$config = (array) $cand->operational_config;
$allowedEmails = array_map('strtolower', (array) data_get($config, 'supplier_emails', []));
if (in_array($email, $allowedEmails, true)) {
return $cand;
}
$allowedUserIds = (array) data_get($config, 'supplier_user_ids', []);
if (in_array((int) $user->id, $allowedUserIds, true)) {
return $cand;
}
}
// 4. Hook for Nethome (PIVA 10055221005) if user has nethome domain
if (str_ends_with($email, '@nethome.it')) {
return Fornitore::query()->where('partita_iva', '10055221005')->first();
}
return null;
}
protected function resolveCollaboratoreForUser(?int $fornitoreId = null, ?int $currentSupplierId = null): ?FornitoreDipendente

View File

@ -61,6 +61,9 @@ public static function canAccess(): bool
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore']);
}
/** @var array<int, string> */
public array $fornitoriOptions = [];
public function mount(): void
{
$this->scope = (string) request()->query('scope', 'tutte');
@ -73,9 +76,37 @@ public function mount(): void
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$user = Auth::user();
if ($this->isInternalOperator($user)) {
$this->fornitoriOptions = Fornitore::query()
->where(function ($q) {
$q->whereHas('ticketInterventi')
->orWhere('partita_iva', '10055221005')
->orWhere('partita_iva', '14001151001')
->orWhereIn('id', [236, 392]);
})
->orderBy('ragione_sociale')
->get()
->mapWithKeys(fn(Fornitore $f): array => [
(int) $f->id => trim((string) ($f->ragione_sociale ?: ('Fornitore #' . $f->id))) . ($f->partita_iva ? ' (' . $f->partita_iva . ')' : ''),
])
->all();
}
$this->refreshData();
}
public function updatedFornitoreId(): void
{
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
if ($fornitore instanceof Fornitore) {
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->detailModal = null;
$this->refreshData();
}
}
public function updatedScope(): void
{
$this->refreshData();

View File

@ -87,6 +87,9 @@ public static function canAccess(): bool
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore']);
}
/** @var array<int, string> */
public array $fornitoriOptions = [];
public function mount(): void
{
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
@ -116,6 +119,22 @@ public function mount(): void
trim((string) ($stabile->denominazione ?? '')),
])))
: null;
if ($this->isInternalOperator($user)) {
$this->fornitoriOptions = Fornitore::query()
->where(function ($q) {
$q->whereHas('productOffers')
->orWhere('partita_iva', '10055221005')
->orWhere('partita_iva', '14001151001')
->orWhereIn('id', [236, 392]);
})
->orderBy('ragione_sociale')
->get()
->mapWithKeys(fn(Fornitore $f): array => [
(int) $f->id => trim((string) ($f->ragione_sociale ?: ('Fornitore #' . $f->id))) . ($f->partita_iva ? ' (' . $f->partita_iva . ')' : ''),
])
->all();
}
}
$this->refreshRows();
@ -125,6 +144,17 @@ public function mount(): void
}
}
public function updatedFornitoreId(): void
{
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
if ($fornitore instanceof Fornitore) {
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->selectedProductId = null;
$this->detailCard = null;
$this->refreshRows();
}
}
public function updatedSearch(): void
{
$this->refreshRows();

View File

@ -70,11 +70,31 @@ public function mount(): void
$this->selectedSerialId = $requestedSerialId > 0 ? $requestedSerialId : null;
$this->refreshRows();
$user = Auth::user();
if ($this->isInternalOperator($user)) {
$this->fornitoriOptions = Fornitore::query()
->where(function ($q) {
$q->whereHas('productSerials')
->orWhere('partita_iva', '10055221005')
->orWhere('partita_iva', '14001151001')
->orWhereIn('id', [236, 392]);
})
->orderBy('ragione_sociale')
->get()
->mapWithKeys(fn(Fornitore $f): array => [
(int) $f->id => trim((string) ($f->ragione_sociale ?: ('Fornitore #' . $f->id))) . ($f->partita_iva ? ' (' . $f->partita_iva . ')' : ''),
])
->all();
}
if ($this->selectedSerialId) {
$this->openSerialDetail($this->selectedSerialId);
}
}
/** @var array<int, string> */
public array $fornitoriOptions = [];
public function updatedSearch(): void
{
$this->refreshRows();
@ -85,6 +105,17 @@ public function updatedSearch(): void
}
}
public function updatedFornitoreId(): void
{
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
if ($fornitore instanceof Fornitore) {
$this->fornitoreLabel = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
$this->selectedSerialId = null;
$this->detailCard = null;
$this->refreshRows();
}
}
public function getProdottiUrl(): string
{
return ProdottiCatalogo::getUrl(['fornitore' => (int) ($this->fornitoreId ?? 0), 'tab' => 'seriali', 'q' => trim($this->search)], panel: 'admin-filament');

View File

@ -62,6 +62,9 @@ public static function canAccess(): bool
&& app(ProgramAclService::class)->canAccessProgram($user, 'fornitore.ticket-operativi');
}
/** @var array<int, string> */
public array $fornitoriOptions = [];
public function mount(): void
{
$this->status = (string) request()->query('stato', 'aperti');
@ -76,9 +79,36 @@ public function mount(): void
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$user = Auth::user();
if ($this->isInternalOperator($user)) {
$this->fornitoriOptions = Fornitore::query()
->where(function ($q) {
$q->whereHas('ticketInterventi')
->orWhere('partita_iva', '10055221005')
->orWhere('partita_iva', '14001151001')
->orWhereIn('id', [236, 392]);
})
->orderBy('ragione_sociale')
->get()
->mapWithKeys(fn(Fornitore $f): array => [
(int) $f->id => trim((string) ($f->ragione_sociale ?: ('Fornitore #' . $f->id))) . ($f->partita_iva ? ' (' . $f->partita_iva . ')' : ''),
])
->all();
}
$this->refreshData();
}
public function updatedFornitoreId(): void
{
$fornitore = Fornitore::query()->find((int) $this->fornitoreId);
if ($fornitore instanceof Fornitore) {
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->detailModal = null;
$this->refreshData();
}
}
public function updatedStatus(): void
{
$this->refreshData();

View File

@ -0,0 +1,428 @@
<?php
namespace App\Services\Catalog;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductOffer;
use App\Models\ProductSerial;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ContabilitaMysqlSerialImportService
{
public function __construct(
private readonly ProductOfferService $productOfferService,
) {
}
/**
* Import invoices, products, and serials from external MySQL accounting database (e.g. arc_nehr).
*
* @return array{
* supplier_code: string,
* fornitore_id: int,
* invoices_count: int,
* lines_processed: int,
* products_created: int,
* identifiers_created: int,
* serials_created: int,
* serials_updated: int,
* dry_run: bool
* }
*/
public function importForSupplier(string $supplierCode = 'NCOMSRL', ?int $targetFornitoreId = null, bool $dryRun = false): array
{
$stats = [
'supplier_code' => $supplierCode,
'fornitore_id' => 0,
'invoices_count' => 0,
'lines_processed' => 0,
'products_created' => 0,
'identifiers_created' => 0,
'serials_created' => 0,
'serials_updated' => 0,
'dry_run' => $dryRun,
];
// 1. Resolve or create local Fornitore in NetGescon
$fornitore = $targetFornitoreId ? Fornitore::query()->find($targetFornitoreId) : null;
if (! $fornitore instanceof Fornitore) {
$fornitore = Fornitore::query()
->where('codice_univoco', $supplierCode)
->orWhere('ragione_sociale', 'like', '%' . $supplierCode . '%')
->orWhere('partita_iva', '14001151001')
->first();
}
// Fetch supplier info from remote accounting database if not found locally
if (! $fornitore instanceof Fornitore) {
$remoteSupplier = DB::connection('contabilita_mysql')
->table('fet')
->where('frn_codice', $supplierCode)
->first([
'frn_codice', 'frn_descrizione', 'frn_partita_iva', 'frn_codice_fiscale',
'frn_via', 'frn_numero_civico', 'frn_cap', 'frn_citta', 'frn_provincia',
]);
if ($remoteSupplier) {
$piva = trim((string) ($remoteSupplier->frn_partita_iva ?: $remoteSupplier->frn_codice_fiscale));
$fornitore = Fornitore::query()->where('partita_iva', $piva)->first();
if (! $fornitore && ! $dryRun) {
$adminId = Fornitore::query()->where('partita_iva', '10055221005')->value('amministratore_id') ?: 13;
$fornitore = Fornitore::query()->create([
'ragione_sociale' => trim((string) $remoteSupplier->frn_descrizione) ?: $supplierCode,
'nome' => '',
'cognome' => trim((string) $remoteSupplier->frn_descrizione) ?: $supplierCode,
'partita_iva' => $piva,
'codice_fiscale' => trim((string) ($remoteSupplier->frn_codice_fiscale ?: $piva)),
'indirizzo' => trim((string) $remoteSupplier->frn_via),
'civico' => trim((string) $remoteSupplier->frn_numero_civico),
'cap' => trim((string) $remoteSupplier->frn_cap),
'citta' => trim((string) $remoteSupplier->frn_citta),
'provincia' => trim((string) $remoteSupplier->frn_provincia),
'nazione' => 'IT',
'amministratore_id' => $adminId,
'codice_univoco' => substr($supplierCode, 0, 8),
'note' => 'Fornitore importato da contabilità MySQL Target Cross arc_nehr',
]);
}
}
}
if (! $fornitore instanceof Fornitore && ! $dryRun) {
throw new \RuntimeException("Fornitore locale non trovato né creabile per il codice '{$supplierCode}'.");
}
$fornitoreId = $fornitore ? (int) $fornitore->id : 0;
$stats['fornitore_id'] = $fornitoreId;
// Ensure Nethome merges this supplier into its catalog scope
if ($fornitoreId > 0 && ! $dryRun) {
$nethome = Fornitore::query()->where('partita_iva', '10055221005')->first();
if ($nethome instanceof Fornitore && (int) $nethome->id !== $fornitoreId) {
$cfg = (array) ($nethome->operational_config ?? []);
$merged = array_unique(array_merge((array) data_get($cfg, 'merged_supplier_ids', []), [$fornitoreId]));
$cfg['merged_supplier_ids'] = array_values($merged);
$nethome->operational_config = $cfg;
$nethome->save();
}
}
// 2. Fetch rows from contabilita_mysql
$rows = DB::connection('contabilita_mysql')
->table('fea')
->join('fet', 'fea.progressivo', '=', 'fet.progressivo')
->where('fet.frn_codice', $supplierCode)
->select([
'fea.id as fea_id',
'fea.progressivo',
'fea.riga',
'fea.quantita',
'fea.importo',
'fea.note',
'fea.art_codice',
'fet.numero_documento',
'fet.data_documento',
'fet.id_sdi',
'fet.frn_descrizione',
])
->orderBy('fet.data_documento', 'asc')
->orderBy('fea.progressivo', 'asc')
->orderBy('fea.riga', 'asc')
->get();
$seenInvoices = [];
foreach ($rows as $row) {
$stats['lines_processed']++;
$invKey = (string) $row->numero_documento . '-' . (string) $row->data_documento;
if (! isset($seenInvoices[$invKey])) {
$seenInvoices[$invKey] = true;
$stats['invoices_count']++;
}
$note = (string) ($row->note ?? '');
$serials = $this->extractSerials($note);
$productInfo = $this->extractProductInfo($note, (string) ($row->art_codice ?? ''));
if (empty($productInfo['title']) && empty($serials)) {
continue;
}
$qty = max(1.0, (float) ($row->quantita ?? 1.0));
$lineAmount = (float) ($row->importo ?? 0.0);
$unitPrice = round($lineAmount / $qty, 2);
$purchaseDate = null;
if (! empty($row->data_documento)) {
try {
$purchaseDate = Carbon::parse($row->data_documento);
} catch (\Throwable) {
$purchaseDate = null;
}
}
if ($dryRun) {
$stats['serials_created'] += count($serials);
continue;
}
// 3. Resolve or create Product
$product = $this->resolveOrCreateProduct($fornitore, $productInfo, $unitPrice);
if ($product->wasRecentlyCreated) {
$stats['products_created']++;
}
// 4. Upsert Supplier SKU Identifier (checking normalized_code unique scope)
if (! empty($productInfo['sku'])) {
$normCode = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($productInfo['sku']));
if ($normCode !== '') {
$ident = ProductIdentifier::query()
->where('fornitore_id', $fornitoreId)
->where('code_type', 'supplier_sku')
->where('normalized_code', $normCode)
->first();
if (! $ident) {
ProductIdentifier::query()->create([
'product_id' => (int) $product->id,
'fornitore_id' => $fornitoreId,
'code_value' => $productInfo['sku'],
'code_type' => 'supplier_sku',
'code_role' => 'supplier',
'normalized_code' => $normCode,
'source' => 'contabilita_mysql',
'source_reference' => 'fet:' . $row->progressivo . ';sdi:' . $row->id_sdi,
'is_primary' => true,
]);
$stats['identifiers_created']++;
}
}
}
// 5. Upsert Product Offer using ProductOfferService
$this->productOfferService->syncInternalSupplierOffer($product, $fornitore, [
'external_sku' => $productInfo['sku'] ?: null,
'title' => (string) ($product->name ?? $productInfo['title']),
'currency' => 'EUR',
'price_amount' => $unitPrice > 0 ? $unitPrice : null,
'availability' => 'purchased_from_supplier',
'meta' => [
'source' => 'contabilita_mysql',
'invoice_number' => (string) $row->numero_documento,
'invoice_date' => $purchaseDate?->format('Y-m-d H:i:s'),
'invoice_line' => $row->riga,
'purchase_quantity' => $qty,
'purchase_total_line' => $lineAmount,
'id_sdi' => (string) $row->id_sdi,
],
]);
// 6. Upsert Serial Numbers
foreach ($serials as $serialNumber) {
$serialNumber = trim($serialNumber);
if ($serialNumber === '') {
continue;
}
$existingSerial = ProductSerial::query()
->where('fornitore_id', $fornitoreId)
->where('serial_number', $serialNumber)
->first();
$sourceRef = 'fet:' . $row->progressivo . ';fea:' . $row->fea_id . ';sdi:' . $row->id_sdi;
if ($existingSerial instanceof ProductSerial) {
$dirty = false;
if ($existingSerial->purchase_price === null && $unitPrice > 0) {
$existingSerial->purchase_price = $unitPrice;
$dirty = true;
}
if ($existingSerial->purchase_date === null && $purchaseDate) {
$existingSerial->purchase_date = $purchaseDate;
$dirty = true;
}
if (empty($existingSerial->purchase_invoice_ref) && ! empty($row->numero_documento)) {
$existingSerial->purchase_invoice_ref = trim((string) $row->numero_documento);
$dirty = true;
}
if ($dirty) {
$existingSerial->save();
$stats['serials_updated']++;
}
} else {
ProductSerial::query()->create([
'fornitore_id' => $fornitoreId,
'product_id' => (int) $product->id,
'customer_name' => null,
'product_model' => $product->name,
'product_code' => $productInfo['sku'] ?: $product->internal_code,
'serial_number' => $serialNumber,
'serial_number_2' => null,
'purchase_date' => $purchaseDate,
'purchase_price' => $unitPrice > 0 ? $unitPrice : null,
'purchase_currency' => 'EUR',
'purchase_tax_rate' => 22.00,
'purchase_invoice_ref' => trim((string) $row->numero_documento),
'purchase_invoice_line' => (int) ($row->riga ?? 1),
'internal_notes' => 'Importato da contabilità MySQL Target Cross arc_nehr (SDI: ' . $row->id_sdi . ')',
'source' => 'contabilita_mysql',
'source_reference' => $sourceRef,
]);
$stats['serials_created']++;
}
}
}
return $stats;
}
/**
* Extract serial numbers from note text.
*
* @return array<int, string>
*/
public function extractSerials(string $text): array
{
$serials = [];
if (preg_match_all('/\b(?:S\/?N|SERIALE|SERIAL)\b\s*[:#-]?\s*([^\n\r]+)/iu', $text, $matches)) {
foreach ($matches[1] as $block) {
$block = preg_replace('/\b(?:CODICI|CODICE|INTERNO|NOTE|GARANZIA).*$/i', '', $block);
$parts = preg_split('/[,;\/]+|\s{2,}/', (string) $block);
foreach ($parts as $p) {
$clean = trim($p, " \t\n\r\0\x0B:,.-_");
if (strlen($clean) >= 4 && preg_match('/^[A-Za-z0-9_-]+$/i', $clean)) {
$serials[] = strtoupper($clean);
}
}
}
}
return array_values(array_unique($serials));
}
/**
* Extract product title, brand, model, and SKU from note text.
*
* @return array{title: string, brand: ?string, model: ?string, sku: string}
*/
public function extractProductInfo(string $text, ?string $artCode): array
{
$title = '';
if (str_contains($text, '//')) {
$parts = explode('//', $text, 2);
$prefix = trim($parts[0]);
$title = $prefix;
$genericCategories = [
'ALL IN ONE', 'NOTEBOOK', 'NOTEBOOK PORTATILI',
'ULTRABOOK TABLET PC 2 IN 1 PORTATILI', 'PC DESKTOP', 'COMPUTER',
];
if (strlen($prefix) < 15 || in_array(strtoupper($prefix), $genericCategories, true)) {
$after = trim($parts[1]);
if (preg_match('/^([^\n\r]+?)(?:\s+S\/N|\s+S\/n|\s+CODICI|$)/iu', $after, $m)) {
$title = trim($m[1]);
}
}
} else {
$lines = explode("\n", $text);
$title = trim($lines[0]);
}
$title = preg_replace('/\s+S\/[Nn].*$/i', '', $title);
$title = trim($title, " -/,.");
$brand = null;
$knownBrands = ['HP', 'DELL', 'LENOVO', 'APPLE', 'MACBOOK', 'IPHONE', 'ASUS', 'ACER', 'SAMSUNG', 'FUJITSU'];
foreach ($knownBrands as $b) {
if (stripos($title, $b) !== false) {
$brand = ($b === 'MACBOOK' || $b === 'IPHONE') ? 'APPLE' : $b;
break;
}
}
$model = null;
if (preg_match('/\b(PROONE\s+\d+\s+G\d+|LATITUDE\s+\d+|ELITEBOOK\s+\d+\s+G\d+|THINKPAD\s+[A-Z0-9]+|ELITEDESK\s+[A-Z0-9\s]+|MACBOOK\s+PRO|IPHONE\s+[0-9A-Z]+)\b/i', $title, $modelMatch)) {
$model = strtoupper(trim($modelMatch[1]));
}
$sku = trim((string) $artCode);
if ($sku === '' && preg_match('/INTERNO:\s*([^\n\r]+)/i', $text, $m)) {
$sku = trim($m[1]);
}
$sku = trim($sku, " :,.-_");
return [
'title' => $title ?: 'Prodotto senza titolo',
'brand' => $brand,
'model' => $model,
'sku' => $sku,
];
}
/**
* Resolve existing Product or create a new one.
*/
private function resolveOrCreateProduct(Fornitore $fornitore, array $productInfo, float $unitPrice): Product
{
$sku = $productInfo['sku'];
$title = $productInfo['title'];
$brand = $productInfo['brand'];
$model = $productInfo['model'];
// 1. Search by Supplier SKU in ProductIdentifier
if ($sku !== '') {
$norm = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($sku));
if ($norm !== '') {
$existingByIdentifier = Product::query()
->whereHas('identifiers', function ($q) use ($norm, $fornitore) {
$q->where('normalized_code', $norm)
->where('fornitore_id', (int) $fornitore->id);
})
->first();
if ($existingByIdentifier instanceof Product) {
return $existingByIdentifier;
}
}
}
// 2. Search by Canonical Key or exact Name
$canonicalKey = Str::slug(($brand ? $brand . ' ' : '') . ($model ?: $title));
$existing = Product::query()
->where('canonical_key', $canonicalKey)
->orWhere(function ($q) use ($title, $fornitore) {
$q->where('name', $title)
->where('default_fornitore_id', (int) $fornitore->id);
})
->first();
if ($existing instanceof Product) {
return $existing;
}
// 3. Create new Product
return Product::query()->create([
'default_fornitore_id' => (int) $fornitore->id,
'type' => 'product',
'canonical_key' => $canonicalKey,
'name' => $title,
'brand' => $brand,
'model' => $model,
'unit_measure' => 'pz',
'description' => $title,
'track_serials' => true,
'is_active' => true,
'meta' => [
'source' => 'contabilita_mysql',
'prezzo_acquisto' => $unitPrice > 0 ? $unitPrice : null,
],
]);
}
}

View File

@ -12,7 +12,17 @@
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-center gap-2">
@if(count($this->fornitoriOptions) > 1)
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-gray-500">Fornitore:</span>
<select wire:model.live="fornitoreId" class="rounded-lg border-gray-300 py-1 px-2 text-xs font-medium text-gray-800 shadow-sm focus:border-sky-500 focus:ring-sky-500">
@foreach($this->fornitoriOptions as $optId => $optLabel)
<option value="{{ $optId }}">{{ $optLabel }}</option>
@endforeach
</select>
</div>
@endif
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Ticket operativi</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Collaboratori</a>
<a href="{{ $this->getRubricaUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Rubrica clienti</a>

View File

@ -12,7 +12,17 @@
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-center gap-2">
@if(count($this->fornitoriOptions) > 1)
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-gray-500">Fornitore:</span>
<select wire:model.live="fornitoreId" class="rounded-lg border-gray-300 py-1 px-2 text-xs font-medium text-gray-800 shadow-sm focus:border-sky-500 focus:ring-sky-500">
@foreach($this->fornitoriOptions as $optId => $optLabel)
<option value="{{ $optId }}">{{ $optLabel }}</option>
@endforeach
</select>
</div>
@endif
@if($this->getFornitoreSchedaUrl())
<a href="{{ $this->getFornitoreSchedaUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Scheda fornitore</a>
@endif

View File

@ -12,7 +12,17 @@
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-center gap-2">
@if(count($this->fornitoriOptions) > 1)
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-gray-500">Fornitore:</span>
<select wire:model.live="fornitoreId" class="rounded-lg border-gray-300 py-1 px-2 text-xs font-medium text-gray-800 shadow-sm focus:border-sky-500 focus:ring-sky-500">
@foreach($this->fornitoriOptions as $optId => $optLabel)
<option value="{{ $optId }}">{{ $optLabel }}</option>
@endforeach
</select>
</div>
@endif
@if($this->getFornitoreSchedaUrl())
<a href="{{ $this->getFornitoreSchedaUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Scheda fornitore</a>
@endif

View File

@ -12,7 +12,17 @@
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-center gap-2">
@if(count($this->fornitoriOptions) > 1)
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-gray-500">Fornitore:</span>
<select wire:model.live="fornitoreId" class="rounded-lg border-gray-300 py-1 px-2 text-xs font-medium text-gray-800 shadow-sm focus:border-sky-500 focus:ring-sky-500">
@foreach($this->fornitoriOptions as $optId => $optLabel)
<option value="{{ $optId }}">{{ $optLabel }}</option>
@endforeach
</select>
</div>
@endif
<a href="{{ \App\Filament\Pages\Fornitore\LavorazioniOperative::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Lavorazioni</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Collaboratori</a>
<a href="{{ $this->getImpostazioniUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Impostazioni</a>

View File

@ -0,0 +1,115 @@
<?php
use App\Filament\Pages\Fornitore\ProdottiCatalogo;
use App\Filament\Pages\Fornitore\SerialiCatalogo;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductSerial;
use App\Models\User;
use App\Services\Catalog\ContabilitaMysqlSerialImportService;
use Illuminate\Support\Facades\Auth;
test('contabilita mysql serial import parser correctly extracts multi-serials and product details', function () {
$service = app(ContabilitaMysqlSerialImportService::class);
$noteSingle = "ALL IN ONE // ALL IN ONE RICONDIZIONATO HP PROONE 600 G5 TOUCHSCREEN 21.5 CORE I5-9500 RAM 8GB SSD 256GB WINDOWS 11 PRO WIFI WEBCAM NO BASE - GRADO B+ S/N: 8CG9454YMN ,\n\nCODICI ARTICOLO:\nINTERNO: AOB+I59600G5TSNBSWSW-R16";
$serialsSingle = $service->extractSerials($noteSingle);
expect($serialsSingle)->toEqual(['8CG9454YMN']);
$info = $service->extractProductInfo($noteSingle, 'AOB+I59600G5TSNBSWSW-R16');
expect($info['brand'])->toBe('HP')
->and($info['sku'])->toBe('AOB+I59600G5TSNBSWSW-R16')
->and($info['title'])->toContain('HP PROONE 600 G5');
$noteMulti = "ULTRABOOK TABLET PC 2 IN 1 PORTATILI // NOTEBOOK RICONDIZIONATO DELL LATITUDE 7200 2 IN 1 TOUCHSCREEN 12\" CORE I5-8365U RAM 16GB SSD 512GB WINDOWS 11 PRO GRADO B+ S/N: 5JRH633 , 6VCH633 ,";
$serialsMulti = $service->extractSerials($noteMulti);
expect($serialsMulti)->toEqual(['5JRH633', '6VCH633']);
});
test('seriali catalogo page mounts with default nethome supplier and filters imported serials', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
Auth::login($user);
$ncom = Fornitore::query()->where('partita_iva', '14001151001')->first();
if (! $ncom) {
$ncom = Fornitore::query()->create([
'partita_iva' => '14001151001',
'ragione_sociale' => 'NCOM SRL',
'codice_univoco' => 'NCOMSRL',
'amministratore_id' => 13,
]);
}
$nethome = Fornitore::query()->where('partita_iva', '10055221005')->first();
if (! $nethome) {
$nethome = Fornitore::query()->create([
'partita_iva' => '10055221005',
'ragione_sociale' => 'NETHOME sas di BARONE M. & C.',
'codice_univoco' => 'NETHOME',
'amministratore_id' => 13,
'operational_config'=> [
'merged_supplier_ids' => [(int) $ncom->id],
],
]);
} else {
$cfg = (array) ($nethome->operational_config ?? []);
$cfg['merged_supplier_ids'] = array_values(array_unique(array_merge((array) data_get($cfg, 'merged_supplier_ids', []), [(int) $ncom->id])));
$nethome->operational_config = $cfg;
$nethome->save();
}
$serial = ProductSerial::query()->where('serial_number', '8CG9454YMN')->first();
if (! $serial) {
$product = Product::query()->firstOrCreate([
'canonical_key' => 'hp-proone-600-g5-test',
], [
'name' => 'HP ProOne 600 G5 Touchscreen',
'default_fornitore_id' => $ncom->id,
'brand' => 'HP',
'model' => 'PROONE 600 G5',
]);
$serial = ProductSerial::query()->create([
'fornitore_id' => (int) $ncom->id,
'product_id' => (int) $product->id,
'serial_number' => '8CG9454YMN',
'purchase_invoice_ref' => '3964',
'purchase_price' => 265.00,
]);
}
$page = new SerialiCatalogo();
$page->mount();
expect($page->missingAdminContext)->toBeFalse()
->and($page->fornitoreId)->toBe((int) $nethome->id);
$page->search = '8CG9454YMN';
$page->updatedSearch();
expect(count($page->rows))->toBeGreaterThanOrEqual(1)
->and($page->rows[0]['serial_number'])->toBe('8CG9454YMN');
});
test('prodotti catalogo page mounts with default supplier without throwing missing admin context', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
Auth::login($user);
$nethome = Fornitore::query()->where('partita_iva', '10055221005')->first();
if (! $nethome) {
Fornitore::query()->create([
'partita_iva' => '10055221005',
'ragione_sociale' => 'NETHOME sas di BARONE M. & C.',
'codice_univoco' => 'NETHOME',
'amministratore_id' => 13,
]);
}
$page = new ProdottiCatalogo();
$page->mount();
expect($page->missingAdminContext)->toBeFalse()
->and($page->fornitoreId)->toBeGreaterThan(0);
});