netgescon-day0/app/Services/Catalog/ContabilitaMysqlSerialImportService.php

429 lines
18 KiB
PHP

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