429 lines
18 KiB
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,
|
|
],
|
|
]);
|
|
}
|
|
}
|