From 85037aece1347debf759f1203bcfd3dd7050d48f Mon Sep 17 00:00:00 2001 From: michele Date: Fri, 11 Sep 2026 16:16:50 +0200 Subject: [PATCH] feat(fornitore-seriali-mysql): importazione seriali RMA da contabilita MySQL, selettore fornitore e risoluzione contesto Nethome --- .../Commands/ImportNcomSerialsCommand.php | 46 ++ .../Concerns/ResolvesOperatoreContext.php | 67 ++- .../Pages/Fornitore/LavorazioniOperative.php | 31 ++ .../Pages/Fornitore/ProdottiCatalogo.php | 30 ++ .../Pages/Fornitore/SerialiCatalogo.php | 31 ++ .../Pages/Fornitore/TicketOperativi.php | 30 ++ .../ContabilitaMysqlSerialImportService.php | 428 ++++++++++++++++++ .../fornitore/lavorazioni-operative.blade.php | 12 +- .../fornitore/prodotti-catalogo.blade.php | 12 +- .../fornitore/seriali-catalogo.blade.php | 12 +- .../fornitore/ticket-operativi.blade.php | 12 +- .../FornitoreContabilitaSerialiImportTest.php | 115 +++++ 12 files changed, 818 insertions(+), 8 deletions(-) create mode 100644 app/Console/Commands/ImportNcomSerialsCommand.php create mode 100644 app/Services/Catalog/ContabilitaMysqlSerialImportService.php create mode 100644 tests/Feature/FornitoreContabilitaSerialiImportTest.php diff --git a/app/Console/Commands/ImportNcomSerialsCommand.php b/app/Console/Commands/ImportNcomSerialsCommand.php new file mode 100644 index 0000000..85cee0f --- /dev/null +++ b/app/Console/Commands/ImportNcomSerialsCommand.php @@ -0,0 +1,46 @@ +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; + } + } +} diff --git a/app/Filament/Pages/Fornitore/Concerns/ResolvesOperatoreContext.php b/app/Filament/Pages/Fornitore/Concerns/ResolvesOperatoreContext.php index 164faa9..da4bfca 100755 --- a/app/Filament/Pages/Fornitore/Concerns/ResolvesOperatoreContext.php +++ b/app/Filament/Pages/Fornitore/Concerns/ResolvesOperatoreContext.php @@ -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 diff --git a/app/Filament/Pages/Fornitore/LavorazioniOperative.php b/app/Filament/Pages/Fornitore/LavorazioniOperative.php index c00d00f..09722c1 100755 --- a/app/Filament/Pages/Fornitore/LavorazioniOperative.php +++ b/app/Filament/Pages/Fornitore/LavorazioniOperative.php @@ -61,6 +61,9 @@ public static function canAccess(): bool && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore']); } + /** @var array */ + 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(); diff --git a/app/Filament/Pages/Fornitore/ProdottiCatalogo.php b/app/Filament/Pages/Fornitore/ProdottiCatalogo.php index 7a83d2b..bdde32d 100755 --- a/app/Filament/Pages/Fornitore/ProdottiCatalogo.php +++ b/app/Filament/Pages/Fornitore/ProdottiCatalogo.php @@ -87,6 +87,9 @@ public static function canAccess(): bool && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore']); } + /** @var array */ + 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(); diff --git a/app/Filament/Pages/Fornitore/SerialiCatalogo.php b/app/Filament/Pages/Fornitore/SerialiCatalogo.php index 03708b3..75e97e2 100755 --- a/app/Filament/Pages/Fornitore/SerialiCatalogo.php +++ b/app/Filament/Pages/Fornitore/SerialiCatalogo.php @@ -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 */ + 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'); diff --git a/app/Filament/Pages/Fornitore/TicketOperativi.php b/app/Filament/Pages/Fornitore/TicketOperativi.php index 8bae311..5ded332 100755 --- a/app/Filament/Pages/Fornitore/TicketOperativi.php +++ b/app/Filament/Pages/Fornitore/TicketOperativi.php @@ -62,6 +62,9 @@ public static function canAccess(): bool && app(ProgramAclService::class)->canAccessProgram($user, 'fornitore.ticket-operativi'); } + /** @var array */ + 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(); diff --git a/app/Services/Catalog/ContabilitaMysqlSerialImportService.php b/app/Services/Catalog/ContabilitaMysqlSerialImportService.php new file mode 100644 index 0000000..ecaf64a --- /dev/null +++ b/app/Services/Catalog/ContabilitaMysqlSerialImportService.php @@ -0,0 +1,428 @@ + $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 + */ + 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, + ], + ]); + } +} diff --git a/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php b/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php index 9da0d27..c4974d3 100755 --- a/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php +++ b/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php @@ -12,7 +12,17 @@ @endif -
+
+ @if(count($this->fornitoriOptions) > 1) +
+ Fornitore: + +
+ @endif Ticket operativi Collaboratori Rubrica clienti diff --git a/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php b/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php index f335f1e..83811e8 100755 --- a/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php +++ b/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php @@ -12,7 +12,17 @@ @endif
-
+
+ @if(count($this->fornitoriOptions) > 1) +
+ Fornitore: + +
+ @endif @if($this->getFornitoreSchedaUrl()) Scheda fornitore @endif diff --git a/resources/views/filament/pages/fornitore/seriali-catalogo.blade.php b/resources/views/filament/pages/fornitore/seriali-catalogo.blade.php index f4c956e..41f6b1b 100755 --- a/resources/views/filament/pages/fornitore/seriali-catalogo.blade.php +++ b/resources/views/filament/pages/fornitore/seriali-catalogo.blade.php @@ -12,7 +12,17 @@ @endif
-
+
+ @if(count($this->fornitoriOptions) > 1) +
+ Fornitore: + +
+ @endif @if($this->getFornitoreSchedaUrl()) Scheda fornitore @endif diff --git a/resources/views/filament/pages/fornitore/ticket-operativi.blade.php b/resources/views/filament/pages/fornitore/ticket-operativi.blade.php index cff121c..4fcf7ed 100755 --- a/resources/views/filament/pages/fornitore/ticket-operativi.blade.php +++ b/resources/views/filament/pages/fornitore/ticket-operativi.blade.php @@ -12,7 +12,17 @@ @endif
-
+
+ @if(count($this->fornitoriOptions) > 1) +
+ Fornitore: + +
+ @endif Lavorazioni Collaboratori Impostazioni diff --git a/tests/Feature/FornitoreContabilitaSerialiImportTest.php b/tests/Feature/FornitoreContabilitaSerialiImportTest.php new file mode 100644 index 0000000..22620e3 --- /dev/null +++ b/tests/Feature/FornitoreContabilitaSerialiImportTest.php @@ -0,0 +1,115 @@ +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); +});