$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, ], ]); } }