feat(fornitore-seriali-mysql): importazione seriali RMA da contabilita MySQL, selettore fornitore e risoluzione contesto Nethome
This commit is contained in:
parent
208287779b
commit
85037aece1
46
app/Console/Commands/ImportNcomSerialsCommand.php
Normal file
46
app/Console/Commands/ImportNcomSerialsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
428
app/Services/Catalog/ContabilitaMysqlSerialImportService.php
Normal file
428
app/Services/Catalog/ContabilitaMysqlSerialImportService.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
tests/Feature/FornitoreContabilitaSerialiImportTest.php
Normal file
115
tests/Feature/FornitoreContabilitaSerialiImportTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user