netgescon-day0/app/Services/FattureElettroniche/FatturaElettronicaImporter.php

2086 lines
94 KiB
PHP

<?php
namespace App\Services\FattureElettroniche;
use App\Models\FatturaElettronica;
use App\Models\Fornitore;
use App\Models\FornitoreStabileImpostazione;
use App\Models\GestioneContabile;
use App\Models\RegistroRitenuteAcconto;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Modules\Contabilita\Models\RegolaPrimaNota;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Support\ArchivioPaths;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Smalot\PdfParser\Parser;
class FatturaElettronicaImporter
{
public function __construct(
private readonly FatturaElettronicaXmlParser $parser,
) {}
/**
* @return array{status: 'imported'|'duplicate'|'error', id?: int, message?: string}
*/
public function importXml(string $xml, int $fallbackStabileId, ?string $originalFilename = null, array $extra = []): array
{
$xml = $this->sanitizeXml($xml);
$xml = trim($xml);
if ($xml === '') {
return ['status' => 'error', 'message' => 'File vuoto'];
}
$hash = hash('sha256', $xml);
$existingByHash = FatturaElettronica::query()->where('xml_hash', $hash)->first();
if ($existingByHash) {
// Auto-repair: se stiamo importando di nuovo lo stesso XML ma ci portiamo dietro
// percorsi mancanti (p7m_path/xml_path) o vogliamo completare PDF/protocollo, aggiorna l'esistente.
if (empty($extra['force_update']) && ! empty($extra['auto_repair_duplicate']) && $this->shouldAutoRepairDuplicate($existingByHash, $extra)) {
try {
$updated = $this->updateExisting($existingByHash, $xml, $originalFilename, $extra);
$this->postProcess($updated, $xml, $extra);
return ['status' => 'imported', 'id' => (int) $updated->id, 'message' => 'Aggiornata (auto-repair duplicato)'];
} catch (\Throwable $e) {
return ['status' => 'error', 'id' => (int) $existingByHash->id, 'message' => $e->getMessage()];
}
}
if (! empty($extra['force_update'])) {
try {
$updated = $this->updateExisting($existingByHash, $xml, $originalFilename, $extra);
$this->postProcess($updated, $xml, $extra);
return ['status' => 'imported', 'id' => (int) $updated->id, 'message' => 'Aggiornata (force)'];
} catch (\Throwable $e) {
return ['status' => 'error', 'id' => (int) $existingByHash->id, 'message' => $e->getMessage()];
}
}
$msg = 'Già importata (hash)';
$msg .= ' — id=' . (int) $existingByHash->id;
$msg .= ' — hash=' . substr((string) $hash, 0, 10);
if (is_string($originalFilename) && $originalFilename !== '') {
$msg .= ' — file=' . $originalFilename;
}
return [
'status' => 'duplicate',
'id' => (int) $existingByHash->id,
'message' => $msg,
'debug' => [
'method' => 'hash',
'hash' => $hash,
'existing_id' => (int) $existingByHash->id,
'existing_stabile_id' => (int) ($existingByHash->stabile_id ?? 0),
],
];
}
$data = $this->parser->parse($xml);
$numero = isset($data['numero_fattura']) ? trim((string) $data['numero_fattura']) : null;
if ($numero !== null) {
$n = strtoupper(trim($numero));
if ($n === '' || $n === 'ND' || $n === 'N/D' || $n === 'N.D.' || $n === 'N. D.' || $n === 'N.D') {
$numero = null;
}
}
$dataFattura = $data['data_fattura'] ?? null;
$fornitorePiva = $data['fornitore_piva'] ?? null;
$fornitoreCf = $data['fornitore_cf'] ?? null;
// La tabella richiede fornitore_piva: se manca, ripieghiamo sul CF.
if (! $fornitorePiva && $fornitoreCf) {
$fornitorePiva = $fornitoreCf;
}
if (! $fornitorePiva) {
$fornitorePiva = 'ND';
}
$fornitorePivaForDedup = $fornitorePiva;
if (is_string($fornitorePivaForDedup)) {
$tmp = strtoupper(trim($fornitorePivaForDedup));
if ($tmp === '' || $tmp === 'ND' || $tmp === 'N/D' || $tmp === 'N.D.' || $tmp === 'N. D.') {
$fornitorePivaForDedup = null;
}
}
$preferredStabileId = (int) ($extra['force_stabile_id'] ?? 0);
$stabileRouting = $this->resolveStabileRoutingFromXml(
$data,
$preferredStabileId > 0 ? $preferredStabileId : $fallbackStabileId,
! empty($extra['allow_fallback_stabile'])
);
$stabileId = (int) $stabileRouting['stabile_id'];
// Dedup “logico” anche se l'XML differisce (formattazione ecc.)
// Nota: evitiamo dedup “logico” se i campi sono placeholder (es. numero = ND) oppure se P.IVA/CF non è disponibile.
if ($numero && $dataFattura && $fornitorePivaForDedup) {
$existingLogical = FatturaElettronica::query()
->where('fornitore_piva', $fornitorePivaForDedup)
->where('numero_fattura', $numero)
->whereDate('data_fattura', $dataFattura)
->first();
if ($existingLogical) {
// Auto-repair: se il record esistente è incompleto (mancano path/file) e il file corrente può completarlo.
if (empty($extra['force_update']) && ! empty($extra['auto_repair_duplicate']) && $this->shouldAutoRepairDuplicate($existingLogical, $extra)) {
try {
$updated = $this->updateExisting($existingLogical, $xml, $originalFilename, $extra);
$this->postProcess($updated, $xml, $extra);
return ['status' => 'imported', 'id' => (int) $updated->id, 'message' => 'Aggiornata (auto-repair duplicato)'];
} catch (\Throwable $e) {
return ['status' => 'error', 'id' => (int) $existingLogical->id, 'message' => $e->getMessage()];
}
}
if (! empty($extra['force_update'])) {
try {
$updated = $this->updateExisting($existingLogical, $xml, $originalFilename, $extra);
$this->postProcess($updated, $xml, $extra);
return ['status' => 'imported', 'id' => (int) $updated->id, 'message' => 'Aggiornata (force)'];
} catch (\Throwable $e) {
return ['status' => 'error', 'id' => (int) $existingLogical->id, 'message' => $e->getMessage()];
}
}
$msg = 'Già presente (fornitore/numero/data)';
$msg .= ' — id=' . (int) $existingLogical->id;
$msg .= ' — piva=' . (string) $fornitorePivaForDedup;
$msg .= ' — num=' . (string) $numero;
try {
$msg .= ' — data=' . (string) (is_object($dataFattura) && method_exists($dataFattura, 'format') ? $dataFattura->format('Y-m-d') : $dataFattura);
} catch (\Throwable) {
// ignore
}
return [
'status' => 'duplicate',
'id' => (int) $existingLogical->id,
'message' => $msg,
'debug' => [
'method' => 'logical',
'fornitore_piva' => (string) $fornitorePivaForDedup,
'numero_fattura' => (string) $numero,
'data_fattura' => is_object($dataFattura) && method_exists($dataFattura, 'format') ? $dataFattura->format('Y-m-d') : (string) $dataFattura,
'existing_id' => (int) $existingLogical->id,
'existing_stabile_id' => (int) ($existingLogical->stabile_id ?? 0),
],
];
}
}
$stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId);
$amministratoreId = (int) ($stabile?->amministratore_id ?: 0);
$fornitoreId = $this->matchFornitoreIdForAmministratore($amministratoreId, $fornitorePiva, $fornitoreCf);
if (! $fornitoreId && ! empty($extra['create_fornitore_if_missing']) && $amministratoreId > 0) {
try {
$fornitoreId = $this->ensureFornitoreFromFeData($amministratoreId, $data, $extra);
} catch (\Throwable) {
// ignore: l'import FE deve poter proseguire anche senza creare il fornitore.
$fornitoreId = null;
}
}
// Best-effort: se il fornitore esiste, aggiorna i suoi dati base dalla FE (P.IVA/CF/denominazione, contatti, pagamenti).
try {
if ($fornitoreId) {
$this->syncFornitoreFromFeData((int) $fornitoreId, $data, $extra);
}
} catch (\Throwable) {
// ignore
}
$importRighe = $this->shouldImportRighe($fornitoreId, $extra);
$contabilitaFatturaId = null;
try {
/** @var FatturaElettronica $created */
$created = DB::transaction(function () use ($stabileId, $fornitoreId, $data, $xml, $hash, $originalFilename, $fornitorePiva, $extra, $importRighe, &$contabilitaFatturaId) {
$created = FatturaElettronica::query()->create([
'stabile_id' => $stabileId,
'fornitore_id' => $fornitoreId,
'sdi_file' => isset($data['sdi_file']) ? trim((string) $data['sdi_file']) : null,
'numero_fattura' => $data['numero_fattura'] ?? 'ND',
'data_fattura' => $data['data_fattura'] ?? now()->startOfDay(),
'data_scadenza' => $data['data_scadenza'] ?? null,
'fornitore_piva' => $fornitorePiva,
'fornitore_cf' => $data['fornitore_cf'] ?? null,
'fornitore_denominazione' => $data['fornitore_denominazione'] ?? 'ND',
'imponibile' => (float) ($data['imponibile'] ?? 0),
'iva' => (float) ($data['iva'] ?? 0),
'totale' => (float) ($data['totale'] ?? 0),
'pagamento_modalita' => $data['pagamento_modalita'] ?? null,
'pagamento_iban' => $data['pagamento_iban'] ?? null,
'pagamento_bic' => $data['pagamento_bic'] ?? null,
'consumo_valore' => $data['consumo_valore'] ?? null,
'consumo_unita' => $data['consumo_unita'] ?? null,
'consumo_riferimento' => $data['consumo_riferimento'] ?? null,
'consumo_raw' => $data['consumo_raw'] ?? null,
'codice_destinatario' => $data['codice_destinatario'] ?? null,
'pec_destinatario' => $data['pec_destinatario'] ?? null,
'destinatario_cf' => $data['destinatario_cf'] ?? null,
'destinatario_piva' => $data['destinatario_piva'] ?? null,
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? null,
'stato' => 'ricevuta',
'xml_content' => $xml,
'nome_file_xml' => $originalFilename,
'xml_hash' => $hash,
'nome_file_p7m' => $extra['nome_file_p7m'] ?? null,
'p7m_path' => $extra['p7m_path'] ?? null,
'xml_path' => $extra['xml_path'] ?? null,
]);
// Crea anche la testata contabile (debito fornitore) collegata alla FE.
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$ritenuta = is_array($data['ritenuta'] ?? null) ? $data['ritenuta'] : [];
$ritenutaImporto = (float) ($ritenuta['importo'] ?? 0);
$ritenutaAliquota = $ritenuta['aliquota'] ?? null;
$netto = round(((float) ($data['totale'] ?? 0)) - $ritenutaImporto, 2);
$dataDoc = $data['data_fattura'] ?? null;
$dataRegistrazione = null;
if ($dataDoc instanceof \Carbon\CarbonInterface) {
$dataRegistrazione = $dataDoc->copy()->startOfDay();
} elseif (is_string($dataDoc) && trim($dataDoc) !== '') {
try {
$dataRegistrazione = \Carbon\Carbon::parse($dataDoc)->startOfDay();
} catch (\Throwable) {
$dataRegistrazione = null;
}
}
$fatturaContabile = FatturaFornitore::query()->create([
'stabile_id' => $stabileId,
'fattura_elettronica_id' => $created->id,
'fornitore_id' => $fornitoreId,
'data_registrazione' => $dataRegistrazione ?: now()->startOfDay(),
'data_documento' => $dataDoc ?: null,
'numero_documento' => $data['numero_fattura'] ?? null,
'descrizione' => $data['fornitore_denominazione'] ?? null,
'valuta' => 'EUR',
'cambio' => 1,
'imponibile' => round((float) ($data['imponibile'] ?? 0), 2),
'iva' => round((float) ($data['iva'] ?? 0), 2),
'totale' => round((float) ($data['totale'] ?? 0), 2),
'data_scadenza' => $data['data_scadenza'] ?? null,
'modalita_pagamento' => $data['pagamento_modalita'] ?? null,
'ritenuta_aliquota' => $ritenutaAliquota,
'ritenuta_importo' => round($ritenutaImporto, 2),
'ritenuta_codice_tributo' => null,
'netto_da_pagare' => $netto,
'stato' => 'inserito',
'note' => null,
]);
$contabilitaFatturaId = (int) $fatturaContabile->id;
$defaultCostAccountId = null;
if ($fornitoreId && Schema::hasTable('fornitore_stabile_impostazioni')) {
$defaultCostAccountId = FornitoreStabileImpostazione::query()
->where('stabile_id', (int) $stabileId)
->where('fornitore_id', (int) $fornitoreId)
->value('conto_costo_default_id');
$defaultCostAccountId = is_numeric($defaultCostAccountId) ? (int) $defaultCostAccountId : null;
if ($defaultCostAccountId !== null && $defaultCostAccountId <= 0) {
$defaultCostAccountId = null;
}
}
if ($importRighe) {
$righe = is_array($data['righe'] ?? null) ? $data['righe'] : [];
if (count($righe) < 1) {
$righe = [[
'numero_linea' => 1,
'descrizione' => $data['numero_fattura'] ?? 'Fattura elettronica',
'quantita' => null,
'prezzo_unitario' => null,
'prezzo_totale' => (float) ($data['imponibile'] ?? 0),
'aliquota_iva' => null,
'ritenuta' => null,
]];
}
foreach ($righe as $idx => $r) {
$imponibileRiga = (float) ($r['prezzo_totale'] ?? 0);
if ($imponibileRiga <= 0 && isset($r['prezzo_unitario'], $r['quantita'])) {
$imponibileRiga = (float) $r['prezzo_unitario'] * (float) $r['quantita'];
}
$aliquota = $r['aliquota_iva'] ?? null;
$ivaRiga = 0.0;
if ($aliquota !== null) {
$ivaRiga = round($imponibileRiga * ((float) $aliquota / 100), 2);
}
$fatturaContabile->righe()->create([
'riga' => (int) (($r['numero_linea'] ?? null) ?: ($idx + 1)),
'descrizione' => Str::limit(trim((string) ($r['descrizione'] ?? '')), 255, ''),
'imponibile_euro' => round($imponibileRiga, 2),
'iva_euro' => round($ivaRiga, 2),
'totale_euro' => round($imponibileRiga + $ivaRiga, 2),
'aliquota_iva' => $aliquota,
'conto_costo_id' => $defaultCostAccountId,
'tabella_millesimale_id' => null,
]);
}
// Quadratura: alcuni XML hanno componenti (cassa/bollo/arrotondamenti) fuori dalle DettaglioLinee.
// Se restano differenze tra totale testata e somma righe, aggiungiamo una riga di riepilogo.
$this->ensureQuadraturaRighe($fatturaContabile, $defaultCostAccountId);
$this->applyFornitoreVocePreset($fatturaContabile, (int) $stabileId, (int) $fornitoreId);
}
}
return $created;
});
} catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
$this->postProcess($created, $xml, $extra);
if ($fornitoreId && Schema::hasTable('products')) {
try {
$fornitore = Fornitore::query()->find((int) $fornitoreId);
if ($fornitore instanceof Fornitore) {
app(FornitoreProductCatalogService::class)->extractFromFattureElettroniche($fornitore, (int) $stabileId, 1);
}
} catch (\Throwable) {
// Il catalogo prodotti non deve interrompere l'import della FE.
}
}
return ['status' => 'imported', 'id' => (int) $created->id, 'contabilita_fattura_id' => $contabilitaFatturaId];
}
private function sanitizeXml(string $xml): string
{
$xml = preg_replace('/^\xEF\xBB\xBF/', '', $xml) ?? $xml;
if (function_exists('mb_check_encoding') && ! mb_check_encoding($xml, 'UTF-8')) {
if (function_exists('mb_convert_encoding')) {
$xml = mb_convert_encoding($xml, 'UTF-8', 'UTF-8,ISO-8859-1,Windows-1252');
}
}
$fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $xml);
if (is_string($fixed) && $fixed !== '') {
$xml = $fixed;
}
$xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml) ?? $xml;
return $xml;
}
private function ensureQuadraturaRighe(FatturaFornitore $fatturaContabile, ?int $defaultCostAccountId = null): void
{
try {
$sum = (float) $fatturaContabile->righe()->sum('totale_euro');
$totale = (float) ($fatturaContabile->totale ?? 0);
$delta = round($totale - $sum, 2);
if (abs($delta) <= 0.01) {
return;
}
$already = $fatturaContabile->righe()
->where('descrizione', 'Riepilogo FE / arrotondamenti')
->exists();
if ($already) {
return;
}
$maxRiga = $fatturaContabile->righe()->max('riga');
$nextRiga = (int) (is_numeric($maxRiga) ? $maxRiga : 0) + 1;
$fatturaContabile->righe()->create([
'riga' => $nextRiga,
'descrizione' => 'Riepilogo FE / arrotondamenti',
'imponibile_euro' => round($delta, 2),
'iva_euro' => 0.0,
'totale_euro' => round($delta, 2),
'aliquota_iva' => null,
'conto_costo_id' => $defaultCostAccountId,
'tabella_millesimale_id' => null,
]);
} catch (\Throwable) {
// best-effort
}
}
private function applyFornitoreVocePreset(FatturaFornitore $fatturaContabile, int $stabileId, int $fornitoreId): void
{
if (! Schema::hasTable('fornitore_stabile_impostazioni')) {
return;
}
$voceSpesaId = null;
$gestioneId = null;
$tabellaId = null;
$impostazione = FornitoreStabileImpostazione::query()
->where('stabile_id', $stabileId)
->where('fornitore_id', $fornitoreId)
->first(['voce_spesa_default_id', 'meta']);
if (! $impostazione) {
return;
}
if (! empty($impostazione->voce_spesa_default_id)) {
$voceSpesaId = (int) $impostazione->voce_spesa_default_id;
if ($voceSpesaId <= 0) {
$voceSpesaId = null;
}
}
$meta = is_array($impostazione->meta) ? $impostazione->meta : [];
if (isset($meta['gestione_id']) && is_numeric($meta['gestione_id'])) {
$gestioneId = (int) $meta['gestione_id'];
if ($gestioneId <= 0) {
$gestioneId = null;
}
}
if (isset($meta['tabella_millesimale_id']) && is_numeric($meta['tabella_millesimale_id'])) {
$tabellaId = (int) $meta['tabella_millesimale_id'];
if ($tabellaId <= 0) {
$tabellaId = null;
}
}
if ($voceSpesaId && (! $gestioneId || ! $tabellaId)) {
$voce = DB::table('voci_spesa')
->where('id', $voceSpesaId)
->first(['gestione_contabile_id', 'tabella_millesimale_default_id']);
if ($voce) {
if (! $gestioneId && is_numeric($voce->gestione_contabile_id ?? null)) {
$gestioneId = (int) $voce->gestione_contabile_id;
if ($gestioneId <= 0) {
$gestioneId = null;
}
}
if (! $tabellaId && is_numeric($voce->tabella_millesimale_default_id ?? null)) {
$tabellaId = (int) $voce->tabella_millesimale_default_id;
if ($tabellaId <= 0) {
$tabellaId = null;
}
}
}
}
if ($gestioneId && empty($fatturaContabile->gestione_id)) {
$fatturaContabile->gestione_id = $gestioneId;
$fatturaContabile->save();
}
$update = [];
if ($voceSpesaId) {
$update['voce_spesa_id'] = $voceSpesaId;
}
if ($gestioneId) {
$update['gestione_id'] = $gestioneId;
}
if ($tabellaId) {
$update['tabella_millesimale_id'] = $tabellaId;
}
if ($update !== []) {
$fatturaContabile->righe()
->whereNull('voce_spesa_id')
->where('descrizione', '!=', 'Riepilogo FE / arrotondamenti')
->update($update);
}
}
private function updateContabilitaFornituraDaPdf(FatturaElettronica $fattura): void
{
if (! Schema::hasTable('contabilita_fatture_fornitori')) {
return;
}
if (! Schema::hasColumn('contabilita_fatture_fornitori', 'dati_fornitura')) {
return;
}
$fatturaContabile = FatturaFornitore::query()
->where('fattura_elettronica_id', (int) $fattura->id)
->first();
if (! $fatturaContabile) {
return;
}
$existing = is_array($fatturaContabile->dati_fornitura) ? $fatturaContabile->dati_fornitura : [];
if (count($existing) > 0) {
return;
}
$pdfPath = is_string($fattura->allegato_pdf_path ?? null) ? trim((string) $fattura->allegato_pdf_path) : '';
if ($pdfPath === '') {
return;
}
$disk = Storage::disk('local');
$resolvedPath = $pdfPath;
if (! $disk->exists($resolvedPath) && $disk->exists('private/' . $resolvedPath)) {
$resolvedPath = 'private/' . $resolvedPath;
}
if (! $disk->exists($resolvedPath)) {
return;
}
$fullPath = $disk->path($resolvedPath);
$data = $this->extractGasFornituraDataFromPdf($fullPath);
if (! is_array($data) || $data === []) {
return;
}
$fatturaContabile->dati_fornitura = $data;
$fatturaContabile->save();
}
/** @return array<string, mixed>|null */
private function extractGasFornituraDataFromPdf(string $fullPath): ?array
{
try {
$parser = new Parser();
$text = $parser->parseFile($fullPath)->getText();
} catch (\Throwable) {
return null;
}
if (! is_string($text) || trim($text) === '') {
return null;
}
$codCliente = $this->matchFirst($text, '/Cod\.?\s*Cliente\s*([A-Z0-9]+)/i');
$contratto = $this->matchFirst($text, '/Contratto\s*([A-Z0-9_]+)/i');
$pdr = $this->matchFirst($text, '/PDR\s*([0-9]{8,})/i');
$periodoFatturato = null;
$consumoFatturatoRaw = null;
if (preg_match('/([0-9\.]+)\s*Consumo\s*Fatturato\s*Smc\s*([A-ZÀ-Ù]+\s*\d{4})/u', $text, $m)) {
$consumoFatturatoRaw = trim((string) $m[1]);
$periodoFatturato = trim((string) $m[2]);
} else {
$periodoFatturato = $this->matchFirst($text, '/Periodo\s*([A-ZÀ-Ù]+\s+\d{4})/u');
$consumoFatturatoRaw = $this->matchFirst($text, '/Consumo\s*Fatturato\s*([0-9\.]+)\s*Smc/i');
}
$consumoFatturatoSmc = $this->parseSmcValue($consumoFatturatoRaw);
$periodoAnnuo = $this->matchFirst($text, '/([A-Za-zà-ù]+\s+\d{4}\s*-\s*[A-Za-zà-ù]+\s+\d{4})\s*Periodo/i');
if (! $periodoAnnuo) {
$periodoAnnuo = $this->matchFirst($text, '/Periodo\s*([A-Za-zà-ù]+\s+\d{4}\s*-\s*[A-Za-zà-ù]+\s+\d{4})/i');
}
$consumoAnnuoRaw = null;
if (preg_match_all('/([0-9\.]+)\s*Smc[\s\S]{0,120}?Consumo\s*annuo/i', $text, $m) && isset($m[1])) {
$all = array_filter(array_map('trim', $m[1]));
if ($all !== []) {
usort($all, fn($a, $b) => ($this->parseSmcValue($b) ?? 0) <=> ($this->parseSmcValue($a) ?? 0));
$consumoAnnuoRaw = $all[0] ?? null;
}
}
if (! $consumoAnnuoRaw) {
$consumoAnnuoRaw = $this->matchFirst($text, '/([0-9\.]+)\s*Smc\s*Consumo\s*annuo/i');
}
if (preg_match('/Consumo\s*annuo([\s\S]{0,400})/i', $text, $m)) {
if (preg_match_all('/([0-9\.]+)\s*Smc/i', (string) $m[1], $vals) && isset($vals[1])) {
$all = array_filter(array_map('trim', $vals[1]));
if ($all !== []) {
usort($all, fn($a, $b) => ($this->parseSmcValue($b) ?? 0) <=> ($this->parseSmcValue($a) ?? 0));
$consumoAnnuoRaw = $all[0] ?? null;
}
}
}
$consumoAnnuoSmc = $this->parseSmcValue($consumoAnnuoRaw);
[$offerta, $scadenzaOfferta] = $this->extractOffertaInfo($text);
$payload = [
'tipo' => 'gas_naturale',
'codice_cliente' => $codCliente,
'contratto' => $contratto,
'pdr' => $pdr,
'periodo_fatturato' => $periodoFatturato,
'consumo_fatturato_raw' => $consumoFatturatoRaw,
'consumo_fatturato_smc' => $consumoFatturatoSmc,
'periodo_annuo' => $periodoAnnuo,
'consumo_annuo_raw' => $consumoAnnuoRaw,
'consumo_annuo_smc' => $consumoAnnuoSmc,
'offerta' => $offerta,
'offerta_scadenza' => $scadenzaOfferta,
];
$payload = array_filter($payload, fn($v) => $v !== null && $v !== '');
return $payload !== [] ? $payload : null;
}
/** @param array<int, string> $lines */
private function findValueAfterLabel(array $lines, string $labelPattern, int $maxLookahead = 6, ?string $valuePattern = null): ?string
{
$count = count($lines);
for ($i = 0; $i < $count; $i++) {
if (! preg_match($labelPattern, $lines[$i])) {
continue;
}
$limit = min($count - 1, $i + $maxLookahead);
for ($j = $i + 1; $j <= $limit; $j++) {
$val = trim($lines[$j]);
if ($val === '') {
continue;
}
if ($valuePattern && ! preg_match($valuePattern, $val)) {
continue;
}
return $val;
}
}
return null;
}
/** @param array<int, string> $lines */
private function findFirstMatchInLines(array $lines, string $pattern): ?string
{
foreach ($lines as $line) {
if (preg_match($pattern, $line, $m) && isset($m[1])) {
$value = trim((string) $m[1]);
if ($value !== '') {
return $value;
}
}
}
return null;
}
/** @param array<int, string> $lines */
private function findSmcAfterLabel(array $lines, string $labelPattern, int $maxLookahead, string $mode): ?string
{
$count = count($lines);
for ($i = 0; $i < $count; $i++) {
if (! preg_match($labelPattern, $lines[$i])) {
continue;
}
$limit = min($count - 1, $i + $maxLookahead);
$values = [];
for ($j = $i + 1; $j <= $limit; $j++) {
if (! preg_match('/([0-9\.]+)\s*Smc/i', $lines[$j], $m)) {
continue;
}
$values[] = $m[1];
}
if ($values === []) {
continue;
}
if ($mode === 'max') {
usort($values, fn($a, $b) => ($this->parseSmcValue($b) ?? 0) <=> ($this->parseSmcValue($a) ?? 0));
}
return $values[0] ?? null;
}
return null;
}
private function matchFirst(string $text, string $pattern): ?string
{
if (preg_match($pattern, $text, $m) && isset($m[1])) {
$value = trim((string) $m[1]);
return $value !== '' ? $value : null;
}
return null;
}
private function parseSmcValue(?string $raw): ?float
{
if ($raw === null) {
return null;
}
$clean = str_replace(['.', ' '], '', $raw);
$clean = str_replace(',', '.', $clean);
if ($clean === '' || ! is_numeric($clean)) {
return null;
}
return (float) $clean;
}
/** @return array{0:?string,1:?string} */
private function extractOffertaInfo(string $text): array
{
$offerta = null;
$scadenza = null;
$lines = preg_split('/\R+/', $text) ?: [];
$prev = null;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
if ($offerta === null && preg_match('/^Offerta/i', $line) && ! preg_match('/^Nome Offerta/i', $line)) {
$candidate = trim(preg_replace('/^Offerta\s*/i', '', $line));
if ($candidate !== '' && stripos($candidate, 'Offerta') !== 0) {
$offerta = $candidate;
}
}
if ($scadenza === null && preg_match('/Scadenza\s*condizioni\s*economiche\s*([A-Za-zÀ-Ùà-ù]+)/u', $line, $m)) {
$scadenza = trim((string) ($m[1] ?? ''));
}
if ($scadenza === null && preg_match('/Scadenza\s*condizioni/i', $line)) {
if (is_string($prev) && trim($prev) !== '') {
$scadenza = trim($prev);
}
}
if ($scadenza === null && preg_match('/Scadenza\s*([A-Za-zÀ-Ùà-ù]+)/u', $line, $m)) {
$candidate = trim((string) ($m[1] ?? ''));
if ($candidate !== '' && ! preg_match('/^condizioni$/i', $candidate)) {
$scadenza = $candidate;
}
}
if ($scadenza === null && preg_match('/^Scadenza/i', $line)) {
if (is_string($prev) && trim($prev) !== '') {
$scadenza = trim($prev);
}
}
$prev = $line;
}
return [$offerta, $scadenza];
}
private function shouldAutoRepairDuplicate(FatturaElettronica $existing, array $extra): bool
{
// Se non ci stiamo portando dietro nulla (né path né filename), non facciamo nulla.
$hasIncomingXmlPath = isset($extra['xml_path']) && is_string($extra['xml_path']) && trim($extra['xml_path']) !== '';
$hasIncomingP7mPath = isset($extra['p7m_path']) && is_string($extra['p7m_path']) && trim($extra['p7m_path']) !== '';
$hasIncomingP7mName = isset($extra['nome_file_p7m']) && is_string($extra['nome_file_p7m']) && trim($extra['nome_file_p7m']) !== '';
if (! $hasIncomingXmlPath && ! $hasIncomingP7mPath && ! $hasIncomingP7mName) {
return false;
}
// Se mancano file/percorso sul record esistente, possiamo completarlo.
if ($hasIncomingP7mPath && (! is_string($existing->p7m_path) || trim((string) $existing->p7m_path) === '')) {
return true;
}
if ($hasIncomingXmlPath && (! is_string($existing->xml_path) || trim((string) $existing->xml_path) === '')) {
return true;
}
if (! is_string($existing->xml_content) || trim((string) $existing->xml_content) === '') {
return true;
}
// Se il file su storage non esiste più (pulizie/move), reimpostiamo i path.
try {
if ($hasIncomingP7mPath && is_string($existing->p7m_path) && $existing->p7m_path !== '' && ! Storage::disk('local')->exists($existing->p7m_path)) {
return true;
}
if ($hasIncomingXmlPath && is_string($existing->xml_path) && $existing->xml_path !== '' && ! Storage::disk('local')->exists($existing->xml_path)) {
return true;
}
} catch (\Throwable) {
// ignore
}
// Se manca PDF, il postProcess può estrarlo dagli Allegati o creare/riparare Documento.
if (Schema::hasColumn('fatture_elettroniche', 'allegato_pdf_path')) {
if (! is_string($existing->allegato_pdf_path) || trim((string) $existing->allegato_pdf_path) === '') {
return true;
}
}
return false;
}
private function postProcess(FatturaElettronica $fattura, string $xml, array $extra = []): void
{
// Best-effort: se esiste una fattura contabile collegata ma senza fornitore_id, backfill automatico.
try {
if (
$fattura->fornitore_id
&& Schema::hasTable('contabilita_fatture_fornitori')
&& Schema::hasColumn('contabilita_fatture_fornitori', 'fattura_elettronica_id')
) {
$fatturaContabile = FatturaFornitore::query()
->where('fattura_elettronica_id', (int) $fattura->id)
->first();
if ($fatturaContabile && empty($fatturaContabile->fornitore_id)) {
$fatturaContabile->fornitore_id = (int) $fattura->fornitore_id;
$fatturaContabile->save();
}
}
} catch (\Throwable) {
// ignore
}
// Best-effort: aggiorna dati pagamento del fornitore se mancanti.
try {
if ($fattura->fornitore_id && Schema::hasColumn('fornitori', 'iban')) {
$fornitore = Fornitore::query()->find($fattura->fornitore_id);
if ($fornitore) {
$dirty = false;
if (Schema::hasColumn('fornitori', 'iban') && is_string($fattura->pagamento_iban) && trim($fattura->pagamento_iban) !== '') {
$ibanFe = trim($fattura->pagamento_iban);
$ibanDb = is_string($fornitore->iban) ? trim((string) $fornitore->iban) : '';
if ($ibanDb !== $ibanFe) {
$fornitore->iban = $ibanFe;
$dirty = true;
}
}
if (Schema::hasColumn('fornitori', 'bic') && is_string($fattura->pagamento_bic) && trim($fattura->pagamento_bic) !== '') {
$bicFe = trim($fattura->pagamento_bic);
$bicDb = is_string($fornitore->bic) ? trim((string) $fornitore->bic) : '';
if ($bicDb !== $bicFe) {
$fornitore->bic = $bicFe;
$dirty = true;
}
}
if (Schema::hasColumn('fornitori', 'modalita_pagamento_predefinita') && empty($fornitore->modalita_pagamento_predefinita) && is_string($fattura->pagamento_modalita) && $fattura->pagamento_modalita !== '') {
$fornitore->modalita_pagamento_predefinita = $fattura->pagamento_modalita;
$dirty = true;
}
if ($dirty) {
$fornitore->save();
}
}
}
} catch (\Throwable) {
// ignore
}
// Best-effort: estrai e salva il PDF embedded in <Allegati> (se presente).
try {
if (Schema::hasColumn('fatture_elettroniche', 'allegato_pdf_path')) {
$pdf = $this->extractFirstPdfAttachment($xml);
if ($pdf && is_string($pdf['bytes']) && $pdf['bytes'] !== '') {
$hashPdf = hash('sha256', $pdf['bytes']);
$ym = optional($fattura->data_fattura)->format('Y/m') ?: now()->format('Y/m');
$base = $this->archiveBaseForStabile((int) $fattura->stabile_id, $ym);
$originalName = (string) ($pdf['filename'] ?? 'allegato.pdf');
$originalName = str_replace(["\r", "\n"], '', $originalName);
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
if ($safeName === '') {
$safeName = 'allegato';
}
$safeName .= '.pdf';
$path = $base . '/allegati/' . $hashPdf . '-' . $safeName;
if (! Storage::disk('local')->exists($path)) {
Storage::disk('local')->put($path, $pdf['bytes']);
}
$fattura->forceFill([
'allegato_pdf_nome' => $originalName,
'allegato_pdf_mime' => (string) ($pdf['mime'] ?? 'application/pdf'),
'allegato_pdf_path' => $path,
'allegato_pdf_hash' => $hashPdf,
])->save();
}
}
} catch (\Throwable) {
// ignore
}
// Best-effort: estrai dati fornitura da PDF e salva sulla fattura contabile.
try {
$this->updateContabilitaFornituraDaPdf($fattura);
} catch (\Throwable) {
// ignore
}
// Best-effort: crea documento protocollato e rinomina/sposta i file.
try {
if (Schema::hasTable('documenti')) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($fattura);
}
} catch (\Throwable) {
// ignore
}
// Best-effort: crea riga in registro ritenute d'acconto se presente (RA).
try {
if (! Schema::hasTable('registro_ritenute_acconto')) {
return;
}
$parsed = $this->parser->parse($xml);
$ritenuta = is_array($parsed['ritenuta'] ?? null) ? $parsed['ritenuta'] : [];
$ritenutaImporto = (float) ($ritenuta['importo'] ?? 0);
$ritenutaAliquota = (float) ($ritenuta['aliquota'] ?? 0);
if ($ritenutaImporto <= 0 || $ritenutaAliquota <= 0) {
return;
}
$dataCompetenza = $fattura->data_fattura ? $fattura->data_fattura->copy() : now();
$dataCompetenza = $dataCompetenza->startOfDay();
$scadenzaVersamento = $dataCompetenza->copy()->addMonthNoOverflow()->day(16);
$gestione = GestioneContabile::query()
->where('stabile_id', (int) $fattura->stabile_id)
->where('gestione_attiva', true)
->orderByDesc('id')
->first();
if (! $gestione) {
$gestione = GestioneContabile::query()
->where('stabile_id', (int) $fattura->stabile_id)
->where('anno_gestione', (int) $dataCompetenza->format('Y'))
->orderByDesc('id')
->first();
}
if (! $gestione) {
return;
}
$tenantId = (string) ($gestione->tenant_id ?? '');
if ($tenantId === '') {
$stabile = Stabile::query()->with('amministratore:id,codice_amministratore')->select(['id', 'amministratore_id'])->find((int) $fattura->stabile_id);
$tenantId = (string) ($stabile?->amministratore?->codice_amministratore ?: '');
}
$fatturaContabileId = null;
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$fatturaContabileId = FatturaFornitore::query()->where('fattura_elettronica_id', (int) $fattura->id)->value('id');
$fatturaContabileId = $fatturaContabileId ? (int) $fatturaContabileId : null;
}
$exists = RegistroRitenuteAcconto::query()
->where('gestione_id', (int) $gestione->id)
->where('fornitore_id', (int) ($fattura->fornitore_id ?: 0))
->whereDate('data_competenza', $dataCompetenza->toDateString())
->where('importo_ritenuta', round($ritenutaImporto, 4))
->when($fatturaContabileId, fn($q) => $q->where('fattura_id', $fatturaContabileId))
->exists();
if ($exists) {
return;
}
DB::transaction(function () use ($gestione, $tenantId, $fattura, $fatturaContabileId, $dataCompetenza, $scadenzaVersamento, $ritenutaImporto, $ritenutaAliquota, $ritenuta) {
$next = (int) (RegistroRitenuteAcconto::query()
->where('gestione_id', (int) $gestione->id)
->lockForUpdate()
->max('numero_progressivo') ?? 0);
$next++;
RegistroRitenuteAcconto::query()->create([
'tenant_id' => $tenantId,
'gestione_id' => (int) $gestione->id,
'numero_progressivo' => $next,
'data_competenza' => $dataCompetenza->toDateString(),
'fornitore_id' => (int) ($fattura->fornitore_id ?: 0),
'operazione_id' => null,
'fattura_id' => $fatturaContabileId,
'imponibile' => round((float) ($fattura->imponibile ?? 0), 4),
'aliquota_ritenuta' => round((float) $ritenutaAliquota, 2),
'importo_ritenuta' => round((float) $ritenutaImporto, 4),
'codice_tributo' => '1040',
'tipo_ritenuta' => isset($ritenuta['tipo']) && is_string($ritenuta['tipo']) && $ritenuta['tipo'] !== '' ? $ritenuta['tipo'] : 'professionale',
'rif_rda' => null,
'stato_versamento' => 'da_versare',
'data_scadenza_versamento' => $scadenzaVersamento->toDateString(),
'anno_dichiarazione' => (int) $dataCompetenza->format('Y'),
]);
});
} catch (\Throwable) {
// ignore
}
// Best-effort: genera prima nota automatica (registrazione + movimenti) se configurata.
try {
if (! Schema::hasTable('contabilita_fatture_fornitori') || ! Schema::hasTable('contabilita_registrazioni') || ! Schema::hasTable('contabilita_movimenti')) {
return;
}
// Se la gestione è già chiusa, non generiamo nuove registrazioni contabili.
if (Schema::hasTable('contabilita_chiusure_gestione') && Schema::hasColumn('contabilita_registrazioni', 'gestione_id')) {
$gestioneId = (int) ($fattura->gestione_id ?? 0);
if ($gestioneId > 0) {
$chiusa = \App\Modules\Contabilita\Models\ChiusuraGestione::query()
->where('gestione_id', $gestioneId)
->where('stato', 'chiusa')
->exists();
if ($chiusa) {
return;
}
}
}
$userId = (int) ($extra['user_id'] ?? 0);
if ($userId <= 0) {
// Fallback: manteniamo comportamento pre-esistente per evitare errori DB su colonne NOT NULL.
$userId = 1;
}
$fatturaContabile = FatturaFornitore::query()
->where('fattura_elettronica_id', (int) $fattura->id)
->first();
if (! $fatturaContabile) {
return;
}
if (! empty($fatturaContabile->registrazione_id)) {
return;
}
if (! Schema::hasTable('contabilita_regole_prima_nota')) {
return;
}
$regola = RegolaPrimaNota::query()
->where('stabile_id', (int) $fatturaContabile->stabile_id)
->where('origine', 'fattura')
->where('chiave', 'default')
->where('attiva', true)
->first();
if (! $regola || ! $regola->conto_dare_id || ! $regola->conto_avere_id) {
return;
}
$descrizione = 'Fattura fornitore ' . (string) ($fattura->fornitore_denominazione ?? '') . ' ' . (string) ($fattura->numero_fattura ?? '');
$descrizione = trim($descrizione);
if ($descrizione === '') {
$descrizione = 'Fattura fornitore';
}
$importo = (float) ($fatturaContabile->totale ?? 0);
if ($importo <= 0) {
return;
}
DB::transaction(function () use ($fatturaContabile, $regola, $descrizione, $importo, $userId) {
$regPayload = [
'stabile_id' => (int) $fatturaContabile->stabile_id,
'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id')
? \App\Modules\Contabilita\Models\Registrazione::resolveGestioneIdOrDefault(
(int) ($fatturaContabile->gestione_id ?: 0),
(int) $fatturaContabile->stabile_id,
(string) ($fatturaContabile->data_registrazione ?: '')
)
: null),
'data_registrazione' => ($fatturaContabile->data_registrazione ?: now()->toDateString()),
'descrizione' => $descrizione,
'created_by' => $userId,
'updated_by' => $userId,
];
if (Schema::hasColumn('contabilita_registrazioni', 'user_id')) {
$regPayload['user_id'] = $userId;
}
/** @var \App\Modules\Contabilita\Models\Registrazione $reg */
$reg = \App\Modules\Contabilita\Models\Registrazione::query()->create($regPayload);
\App\Modules\Contabilita\Models\Movimento::query()->create([
'registrazione_id' => (int) $reg->id,
'conto_id' => (int) $regola->conto_dare_id,
'tipo' => 'dare',
'importo' => round($importo, 2),
]);
\App\Modules\Contabilita\Models\Movimento::query()->create([
'registrazione_id' => (int) $reg->id,
'conto_id' => (int) $regola->conto_avere_id,
'tipo' => 'avere',
'importo' => round($importo, 2),
]);
// Blindatura: mai consentire prime note sbilanciate, nemmeno di 0,01.
$reg->assertBilanciata();
$fatturaContabile->registrazione_id = (int) $reg->id;
$fatturaContabile->stato = 'contabilizzato';
$fatturaContabile->save();
});
} catch (\Throwable) {
// ignore
}
}
private function archiveBaseForStabile(int $stabileId, string $ym): string
{
$stabile = Stabile::query()
->with(['amministratore:id,codice_amministratore,codice_univoco'])
->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco'])
->find($stabileId);
if ($stabile) {
$base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore);
if (is_string($base) && $base !== '') {
return $base . '/fatture-ricevute/' . $ym;
}
}
// fallback ultra-conservativo
$adminId = (int) ($stabile?->amministratore_id ?: 0);
return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture-ricevute/' . $ym;
}
/**
* @return array{filename: string, mime: string, bytes: string}|null
*/
private function extractFirstPdfAttachment(string $xml): ?array
{
$dom = new DOMDocument();
$dom->preserveWhiteSpace = false;
$dom->formatOutput = false;
$loaded = @$dom->loadXML($xml);
if (! $loaded) {
return null;
}
$xpath = new DOMXPath($dom);
$nodes = $xpath->query("//*[local-name()='Allegati']");
if (! $nodes || $nodes->length < 1) {
return null;
}
foreach ($nodes as $allegatoNode) {
$nome = $this->xpathFirstString($xpath, ".//*[local-name()='NomeAttachment']", $allegatoNode);
$formato = $this->xpathFirstString($xpath, ".//*[local-name()='FormatoAttachment']", $allegatoNode);
$attachment = $this->xpathFirstString($xpath, ".//*[local-name()='Attachment']", $allegatoNode);
if (! $attachment) {
continue;
}
$nomeLower = strtolower((string) $nome);
$formatoLower = strtolower((string) $formato);
$looksPdf = false;
if ($nomeLower !== '' && str_ends_with($nomeLower, '.pdf')) {
$looksPdf = true;
}
if (! $looksPdf && $formatoLower !== '' && str_contains($formatoLower, 'pdf')) {
$looksPdf = true;
}
if (! $looksPdf) {
continue;
}
$base64 = preg_replace('/\s+/', '', $attachment);
$bytes = base64_decode((string) $base64, true);
if (! is_string($bytes) || $bytes === '') {
// Fallback: alcuni provider usano base64url o omettono il padding.
$base64b = strtr((string) $base64, '-_', '+/');
$pad = strlen($base64b) % 4;
if ($pad > 0) {
$base64b .= str_repeat('=', 4 - $pad);
}
$bytes = base64_decode((string) $base64b, true);
}
if (! is_string($bytes) || $bytes === '') {
// Ultimo tentativo: decode non strict (ignora caratteri non-base64).
$bytes = base64_decode((string) $base64, false);
}
if (! is_string($bytes) || $bytes === '') {
continue;
}
$filename = $nome ?: 'allegato.pdf';
if (! str_ends_with(strtolower($filename), '.pdf')) {
$filename .= '.pdf';
}
return [
'filename' => $filename,
'mime' => 'application/pdf',
'bytes' => $bytes,
];
}
return null;
}
private function xpathFirstString(DOMXPath $xpath, string $expr, \DOMNode $contextNode): ?string
{
$nodes = $xpath->query($expr, $contextNode);
if (! $nodes || $nodes->length < 1) {
return null;
}
$value = trim((string) $nodes->item(0)?->textContent);
return $value !== '' ? $value : null;
}
private function resolveStabileIdFromXml(array $parsed, int $fallbackStabileId, bool $allowFallback): int
{
return (int) $this->resolveStabileRoutingFromXml($parsed, $fallbackStabileId, $allowFallback)['stabile_id'];
}
/**
* @return array{stabile_id:int, source:string}
*/
private function resolveStabileRoutingFromXml(array $parsed, int $preferredStabileId, bool $allowPreferredFallback): array
{
$directMatch = $this->matchUniqueStabileIdFromParsedIdentifiers($parsed);
if ($directMatch !== null) {
return $directMatch;
}
if ($preferredStabileId > 0 && $this->stabileMatchesParsedRecipientContext($preferredStabileId, $parsed)) {
return [
'stabile_id' => $preferredStabileId,
'source' => 'preferred_context',
];
}
if ($allowPreferredFallback && $preferredStabileId > 0) {
return [
'stabile_id' => $preferredStabileId,
'source' => 'preferred_fallback',
];
}
throw new \RuntimeException('Impossibile determinare lo stabile dalla FE (identificativi destinatario non trovati o non compatibili)');
}
/**
* @return array{stabile_id:int, source:string}|null
*/
private function matchUniqueStabileIdFromParsedIdentifiers(array $parsed): ?array
{
$destCf = $this->normalizeId(isset($parsed['destinatario_cf']) ? (string) $parsed['destinatario_cf'] : '');
$destPiva = $this->normalizeId(isset($parsed['destinatario_piva']) ? (string) $parsed['destinatario_piva'] : '');
$codice = $this->normalizeId(isset($parsed['codice_destinatario']) ? (string) $parsed['codice_destinatario'] : '');
$pec = strtolower(trim((string) ($parsed['pec_destinatario'] ?? '')));
if ($destCf !== '') {
$match = Stabile::query()
->whereNotNull('codice_fiscale')
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$destCf])
->where('attivo', true)
->get();
if ($match->count() === 1) {
return [
'stabile_id' => (int) $match->first()->id,
'source' => 'destinatario_cf',
];
}
}
if ($codice !== '') {
$match = Stabile::query()
->whereNotNull('codice_destinatario_sdi')
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
->where('attivo', true)
->get();
if ($match->count() === 1) {
return [
'stabile_id' => (int) $match->first()->id,
'source' => 'codice_destinatario',
];
}
}
if ($pec !== '') {
$match = Stabile::query()
->where(function ($q) use ($pec) {
$q->whereRaw('LOWER(pec_condominio) = ?', [$pec]);
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$q->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
}
})
->where('attivo', true)
->get();
if ($match->count() === 1) {
return [
'stabile_id' => (int) $match->first()->id,
'source' => 'pec_destinatario',
];
}
}
if ($destPiva !== '') {
$match = Stabile::query()
->select('stabili.id')
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
->where('stabili.attivo', true)
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$destPiva])
->get();
if ($match->count() === 1) {
return [
'stabile_id' => (int) $match->first()->id,
'source' => 'destinatario_piva_amministratore',
];
}
}
return null;
}
private function stabileMatchesParsedRecipientContext(int $stabileId, array $parsed): bool
{
$stabileColumns = ['id', 'amministratore_id', 'codice_fiscale', 'cod_fisc_amministratore', 'codice_destinatario_sdi', 'pec_condominio'];
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$stabileColumns[] = 'pec_amministratore';
}
$stabile = Stabile::query()
->with('amministratore:id,partita_iva,codice_fiscale_studio')
->select($stabileColumns)
->find($stabileId);
if (! $stabile) {
return false;
}
$destCf = $this->normalizeId(isset($parsed['destinatario_cf']) ? (string) $parsed['destinatario_cf'] : '');
$destPiva = $this->normalizeId(isset($parsed['destinatario_piva']) ? (string) $parsed['destinatario_piva'] : '');
$codice = $this->normalizeId(isset($parsed['codice_destinatario']) ? (string) $parsed['codice_destinatario'] : '');
$pec = strtolower(trim((string) ($parsed['pec_destinatario'] ?? '')));
$candidateIds = array_filter([
$this->normalizeId((string) ($stabile->codice_fiscale ?? '')),
$this->normalizeId((string) ($stabile->cod_fisc_amministratore ?? '')),
$this->normalizeId((string) ($stabile->amministratore?->codice_fiscale_studio ?? '')),
$this->normalizeId((string) ($stabile->amministratore?->partita_iva ?? '')),
]);
if ($destCf !== '' && in_array($destCf, $candidateIds, true)) {
return true;
}
if ($destPiva !== '' && in_array($destPiva, $candidateIds, true)) {
return true;
}
if ($codice !== '' && $this->normalizeId((string) ($stabile->codice_destinatario_sdi ?? '')) === $codice) {
return true;
}
if ($pec !== '') {
$candidatePec = array_filter([
strtolower(trim((string) ($stabile->pec_condominio ?? ''))),
strtolower(trim((string) ($stabile->pec_amministratore ?? ''))),
]);
if (in_array($pec, $candidatePec, true)) {
return true;
}
}
return false;
}
private function shouldImportRighe(?int $fornitoreId, array $extra): bool
{
$mode = (string) ($extra['importa_righe'] ?? 'auto'); // auto|si|no
$mode = strtolower(trim($mode));
if ($mode === 'si' || $mode === 'true' || $mode === '1') {
return true;
}
if ($mode === 'no' || $mode === 'false' || $mode === '0') {
return false;
}
if (! $fornitoreId) {
return true;
}
if (! Schema::hasColumn('fornitori', 'escludi_righe_fe')) {
return true;
}
$fornitore = Fornitore::query()->select(['id', 'escludi_righe_fe'])->find($fornitoreId);
if (! $fornitore) {
return true;
}
return ! (bool) ($fornitore->escludi_righe_fe ?? false);
}
private function updateExisting(FatturaElettronica $existing, string $xml, ?string $originalFilename, array $extra): FatturaElettronica
{
$xml = trim($xml);
if ($xml === '') {
throw new \RuntimeException('File vuoto');
}
$data = $this->parser->parse($xml);
$stabileId = $this->resolveStabileIdFromXml($data, (int) ($existing->stabile_id ?? 0), true);
$fornitorePiva = $data['fornitore_piva'] ?? null;
$fornitoreCf = $data['fornitore_cf'] ?? null;
if (! $fornitorePiva && $fornitoreCf) {
$fornitorePiva = $fornitoreCf;
}
if (! $fornitorePiva) {
$fornitorePiva = $existing->fornitore_piva ?: 'ND';
}
$fornitoreId = $this->matchFornitoreId($fornitorePiva, $fornitoreCf);
$importRighe = $this->shouldImportRighe($fornitoreId, $extra);
return DB::transaction(function () use ($existing, $data, $xml, $originalFilename, $extra, $stabileId, $fornitoreId, $fornitorePiva, $importRighe) {
$existing->forceFill([
'stabile_id' => $stabileId,
'fornitore_id' => $fornitoreId,
'sdi_file' => isset($data['sdi_file']) ? trim((string) $data['sdi_file']) : ($existing->sdi_file ?? null),
'numero_fattura' => $data['numero_fattura'] ?? $existing->numero_fattura,
'data_fattura' => $data['data_fattura'] ?? $existing->data_fattura,
'data_scadenza' => $data['data_scadenza'] ?? $existing->data_scadenza,
'fornitore_piva' => $fornitorePiva,
'fornitore_cf' => $data['fornitore_cf'] ?? $existing->fornitore_cf,
'fornitore_denominazione' => $data['fornitore_denominazione'] ?? $existing->fornitore_denominazione,
'imponibile' => (float) ($data['imponibile'] ?? $existing->imponibile ?? 0),
'iva' => (float) ($data['iva'] ?? $existing->iva ?? 0),
'totale' => (float) ($data['totale'] ?? $existing->totale ?? 0),
'pagamento_modalita' => $data['pagamento_modalita'] ?? $existing->pagamento_modalita,
'pagamento_iban' => $data['pagamento_iban'] ?? $existing->pagamento_iban,
'pagamento_bic' => $data['pagamento_bic'] ?? $existing->pagamento_bic,
'codice_destinatario' => $data['codice_destinatario'] ?? $existing->codice_destinatario,
'pec_destinatario' => $data['pec_destinatario'] ?? $existing->pec_destinatario,
'destinatario_cf' => $data['destinatario_cf'] ?? $existing->destinatario_cf,
'destinatario_piva' => $data['destinatario_piva'] ?? $existing->destinatario_piva,
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? $existing->destinatario_denominazione,
'xml_content' => $xml,
'nome_file_xml' => $originalFilename ?: $existing->nome_file_xml,
'xml_hash' => hash('sha256', $xml),
'nome_file_p7m' => $extra['nome_file_p7m'] ?? $existing->nome_file_p7m,
'p7m_path' => $extra['p7m_path'] ?? $existing->p7m_path,
'xml_path' => $extra['xml_path'] ?? $existing->xml_path,
]);
$existing->save();
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$fatturaContabile = FatturaFornitore::query()->where('fattura_elettronica_id', $existing->id)->first();
if (! $fatturaContabile) {
$ritenuta = is_array($data['ritenuta'] ?? null) ? $data['ritenuta'] : [];
$ritenutaImporto = (float) ($ritenuta['importo'] ?? 0);
$ritenutaAliquota = $ritenuta['aliquota'] ?? null;
$netto = round(((float) ($data['totale'] ?? 0)) - $ritenutaImporto, 2);
$dataDoc = $data['data_fattura'] ?? null;
$dataRegistrazione = null;
if ($dataDoc instanceof \Carbon\CarbonInterface) {
$dataRegistrazione = $dataDoc->copy()->startOfDay();
} elseif (is_string($dataDoc) && trim($dataDoc) !== '') {
try {
$dataRegistrazione = \Carbon\Carbon::parse($dataDoc)->startOfDay();
} catch (\Throwable) {
$dataRegistrazione = null;
}
}
$fatturaContabile = FatturaFornitore::query()->create([
'stabile_id' => $stabileId,
'fattura_elettronica_id' => $existing->id,
'fornitore_id' => $fornitoreId,
'data_registrazione' => $dataRegistrazione ?: now()->startOfDay(),
'data_documento' => $dataDoc ?: null,
'numero_documento' => $data['numero_fattura'] ?? null,
'descrizione' => $data['fornitore_denominazione'] ?? null,
'valuta' => 'EUR',
'cambio' => 1,
'imponibile' => round((float) ($data['imponibile'] ?? 0), 2),
'iva' => round((float) ($data['iva'] ?? 0), 2),
'totale' => round((float) ($data['totale'] ?? 0), 2),
'data_scadenza' => $data['data_scadenza'] ?? null,
'modalita_pagamento' => $data['pagamento_modalita'] ?? null,
'ritenuta_aliquota' => $ritenutaAliquota,
'ritenuta_importo' => round($ritenutaImporto, 2),
'ritenuta_codice_tributo' => null,
'netto_da_pagare' => $netto,
'stato' => 'inserito',
'note' => null,
]);
if ($importRighe) {
$defaultCostAccountId = null;
if ($fornitoreId && Schema::hasTable('fornitore_stabile_impostazioni')) {
$defaultCostAccountId = FornitoreStabileImpostazione::query()
->where('stabile_id', (int) $stabileId)
->where('fornitore_id', (int) $fornitoreId)
->value('conto_costo_default_id');
$defaultCostAccountId = is_numeric($defaultCostAccountId) ? (int) $defaultCostAccountId : null;
if ($defaultCostAccountId !== null && $defaultCostAccountId <= 0) {
$defaultCostAccountId = null;
}
}
$righe = is_array($data['righe'] ?? null) ? $data['righe'] : [];
if (count($righe) < 1) {
$righe = [[
'numero_linea' => 1,
'descrizione' => $data['numero_fattura'] ?? 'Fattura elettronica',
'quantita' => null,
'prezzo_unitario' => null,
'prezzo_totale' => (float) ($data['imponibile'] ?? 0),
'aliquota_iva' => null,
'ritenuta' => null,
]];
}
foreach ($righe as $idx => $r) {
$imponibileRiga = (float) ($r['prezzo_totale'] ?? 0);
if ($imponibileRiga <= 0 && isset($r['prezzo_unitario'], $r['quantita'])) {
$imponibileRiga = (float) $r['prezzo_unitario'] * (float) $r['quantita'];
}
$aliquota = $r['aliquota_iva'] ?? null;
$ivaRiga = 0.0;
if ($aliquota !== null) {
$ivaRiga = round($imponibileRiga * ((float) $aliquota / 100), 2);
}
$fatturaContabile->righe()->create([
'riga' => (int) (($r['numero_linea'] ?? null) ?: ($idx + 1)),
'descrizione' => (string) ($r['descrizione'] ?? ''),
'imponibile_euro' => round($imponibileRiga, 2),
'iva_euro' => round($ivaRiga, 2),
'totale_euro' => round($imponibileRiga + $ivaRiga, 2),
'aliquota_iva' => $aliquota,
'conto_costo_id' => $defaultCostAccountId,
'tabella_millesimale_id' => null,
]);
}
$this->ensureQuadraturaRighe($fatturaContabile, $defaultCostAccountId);
}
} else {
// Repair opzionale: utile per vecchi XML già importati che risultano “non quadrati”
// (es. Cassa Previdenziale in DatiGeneraliDocumento).
if (! empty($extra['repair_contabilita'])) {
$defaultCostAccountId = null;
if ($fornitoreId && Schema::hasTable('fornitore_stabile_impostazioni')) {
$defaultCostAccountId = FornitoreStabileImpostazione::query()
->where('stabile_id', (int) $stabileId)
->where('fornitore_id', (int) $fornitoreId)
->value('conto_costo_default_id');
$defaultCostAccountId = is_numeric($defaultCostAccountId) ? (int) $defaultCostAccountId : null;
if ($defaultCostAccountId !== null && $defaultCostAccountId <= 0) {
$defaultCostAccountId = null;
}
}
// Aggiunge righe mancanti se presenti nel parser (es. cassa/bollo), senza toccare righe esistenti.
if ($importRighe) {
$righeParsed = is_array($data['righe'] ?? null) ? $data['righe'] : [];
foreach ($righeParsed as $r) {
$desc = Str::limit(trim((string) ($r['descrizione'] ?? '')), 255, '');
$imp = (float) ($r['prezzo_totale'] ?? 0);
$aliquota = $r['aliquota_iva'] ?? null;
$iva = 0.0;
if ($aliquota !== null) {
$iva = round($imp * ((float) $aliquota / 100), 2);
}
$tot = round($imp + $iva, 2);
if (trim($desc) === '' || abs($tot) < 0.00001) {
continue;
}
$exists = $fatturaContabile->righe()
->where('descrizione', $desc)
->whereRaw('ABS(totale_euro - ?) <= 0.01', [$tot])
->exists();
if ($exists) {
continue;
}
$maxRiga = $fatturaContabile->righe()->max('riga');
$nextRiga = (int) (is_numeric($maxRiga) ? $maxRiga : 0) + 1;
$fatturaContabile->righe()->create([
'riga' => $nextRiga,
'descrizione' => $desc,
'imponibile_euro' => round($imp, 2),
'iva_euro' => round($iva, 2),
'totale_euro' => $tot,
'aliquota_iva' => $aliquota,
'conto_costo_id' => $defaultCostAccountId,
'tabella_millesimale_id' => null,
]);
}
}
$this->ensureQuadraturaRighe($fatturaContabile, $defaultCostAccountId);
}
}
}
return $existing;
});
}
private function matchFornitoreId(?string $piva, ?string $cf): ?int
{
return $this->matchFornitoreIdForAmministratore(0, $piva, $cf);
}
private function matchFornitoreIdForAmministratore(int $amministratoreId, ?string $piva, ?string $cf): ?int
{
$query = Fornitore::query();
if ($amministratoreId > 0) {
$query->where('amministratore_id', $amministratoreId);
}
$pivaNorm = $this->normalizeId($piva);
$cfNorm = $this->normalizeId($cf);
// Priorità assoluta: P.IVA (dato certo AdE/FE)
if ($pivaNorm !== '' && $pivaNorm !== 'ND') {
$match = (clone $query)
->whereRaw("REPLACE(UPPER(partita_iva), ' ', '') = ?", [$pivaNorm])
->first();
if ($match) {
return (int) $match->id;
}
// Fallback: spesso in DB è salvata senza prefisso IT.
if (str_starts_with($pivaNorm, 'IT') && strlen($pivaNorm) > 2) {
$pivaNoIt = substr($pivaNorm, 2);
$match = (clone $query)
->whereRaw("REPLACE(UPPER(partita_iva), ' ', '') = ?", [$pivaNoIt])
->first();
if ($match) {
return (int) $match->id;
}
}
}
if ($cfNorm !== '' && $cfNorm !== 'ND') {
$match = (clone $query)
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cfNorm])
->first();
if ($match) {
return (int) $match->id;
}
}
return null;
}
private function normalizeId(?string $value): string
{
$value = strtoupper(trim((string) $value));
$value = str_replace(' ', '', $value);
return $value;
}
/**
* Aggiorna i dati del fornitore (e della Rubrica collegata) con informazioni affidabili da FE.
* Non sovrascrive valori già presenti, salvo riallineamenti innocui.
*/
private function syncFornitoreFromFeData(int $fornitoreId, array $data, array $extra = []): void
{
$fornitore = Fornitore::query()->with('rubrica')->find($fornitoreId);
if (! $fornitore) {
return;
}
$userId = (int) ($extra['user_id'] ?? 0);
$now = now();
$piva = isset($data['fornitore_piva']) && is_string($data['fornitore_piva']) ? trim($data['fornitore_piva']) : null;
$cf = isset($data['fornitore_cf']) && is_string($data['fornitore_cf']) ? trim($data['fornitore_cf']) : null;
$den = isset($data['fornitore_denominazione']) && is_string($data['fornitore_denominazione']) ? trim($data['fornitore_denominazione']) : '';
if ($piva === '' || $piva === 'ND') {
$piva = null;
}
if ($cf === '' || $cf === 'ND') {
$cf = null;
}
$fornitoreNome = $this->normalizeNullableString($data['fornitore_nome'] ?? null);
$fornitoreCognome = $this->normalizeNullableString($data['fornitore_cognome'] ?? null);
$fornitoreIndirizzo = $this->normalizeNullableString($data['fornitore_indirizzo'] ?? null);
$fornitoreCivico = $this->normalizeNullableString($data['fornitore_civico'] ?? null);
$fornitoreCap = $this->normalizeNullableString($data['fornitore_cap'] ?? null);
$fornitoreCitta = $this->normalizeNullableString($data['fornitore_citta'] ?? null);
$fornitoreProvincia = $this->normalizeNullableString($data['fornitore_provincia'] ?? null);
$fornitoreNazione = $this->normalizeNullableString($data['fornitore_nazione'] ?? null);
$fornitoreTelefono = $this->normalizeNullableString($data['fornitore_telefono'] ?? null);
$fornitoreFax = $this->normalizeNullableString($data['fornitore_fax'] ?? null);
$fornitoreEmailRaw = $this->normalizeNullableString($data['fornitore_email'] ?? null);
$fornitorePec = $this->looksLikePec($fornitoreEmailRaw) ? $fornitoreEmailRaw : null;
$fornitoreEmail = $fornitorePec ? null : $fornitoreEmailRaw;
$dirty = false;
if (empty($fornitore->partita_iva) && $piva) {
$fornitore->partita_iva = $piva;
$dirty = true;
}
if (empty($fornitore->codice_fiscale) && $cf) {
$fornitore->codice_fiscale = $cf;
$dirty = true;
}
if ((empty($fornitore->ragione_sociale) || $fornitore->ragione_sociale === 'ND') && $den !== '') {
$fornitore->ragione_sociale = $den;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'nome') && empty($fornitore->nome) && $fornitoreNome) {
$fornitore->nome = $fornitoreNome;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'cognome') && empty($fornitore->cognome) && $fornitoreCognome) {
$fornitore->cognome = $fornitoreCognome;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'indirizzo') && empty($fornitore->indirizzo) && $fornitoreIndirizzo) {
$fornitore->indirizzo = $fornitoreIndirizzo;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'civico') && empty($fornitore->civico) && $fornitoreCivico) {
$fornitore->civico = $fornitoreCivico;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'cap') && empty($fornitore->cap) && $fornitoreCap) {
$fornitore->cap = $fornitoreCap;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'citta') && empty($fornitore->citta) && $fornitoreCitta) {
$fornitore->citta = $fornitoreCitta;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'provincia') && empty($fornitore->provincia) && $fornitoreProvincia) {
$fornitore->provincia = strtoupper($fornitoreProvincia);
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'nazione') && empty($fornitore->nazione) && $fornitoreNazione) {
$fornitore->nazione = strtoupper($fornitoreNazione);
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'telefono') && empty($fornitore->telefono) && $fornitoreTelefono) {
$fornitore->telefono = $fornitoreTelefono;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'email') && empty($fornitore->email) && $fornitoreEmail) {
$fornitore->email = $fornitoreEmail;
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'pec') && empty($fornitore->pec) && $fornitorePec) {
$fornitore->pec = $fornitorePec;
$dirty = true;
}
// Pagamenti: già gestiti anche nel postProcess, qui lo facciamo comunque (best-effort).
if (Schema::hasColumn('fornitori', 'iban') && empty($fornitore->iban) && isset($data['pagamento_iban']) && is_string($data['pagamento_iban']) && trim($data['pagamento_iban']) !== '') {
$fornitore->iban = trim($data['pagamento_iban']);
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'bic') && empty($fornitore->bic) && isset($data['pagamento_bic']) && is_string($data['pagamento_bic']) && trim($data['pagamento_bic']) !== '') {
$fornitore->bic = trim($data['pagamento_bic']);
$dirty = true;
}
if (Schema::hasColumn('fornitori', 'modalita_pagamento_predefinita') && empty($fornitore->modalita_pagamento_predefinita) && isset($data['pagamento_modalita']) && is_string($data['pagamento_modalita']) && trim($data['pagamento_modalita']) !== '') {
$fornitore->modalita_pagamento_predefinita = trim($data['pagamento_modalita']);
$dirty = true;
}
if ($dirty) {
$fornitore->save();
}
// Allinea anche Rubrica se presente.
$rubrica = $fornitore->rubrica;
if ($rubrica) {
$rDirty = false;
if (empty($rubrica->partita_iva) && $piva) {
$rubrica->partita_iva = $piva;
$rDirty = true;
}
if (empty($rubrica->codice_fiscale) && $cf) {
$rubrica->codice_fiscale = $cf;
$rDirty = true;
}
if ((empty($rubrica->ragione_sociale) || $rubrica->ragione_sociale === 'ND') && $den !== '') {
$rubrica->ragione_sociale = $den;
$rubrica->tipo_contatto = $rubrica->tipo_contatto ?: 'persona_giuridica';
$rubrica->categoria = $rubrica->categoria ?: 'fornitore';
$rubrica->stato = $rubrica->stato ?: 'attivo';
$rDirty = true;
}
if (empty($rubrica->indirizzo) && $fornitoreIndirizzo) {
$rubrica->indirizzo = $fornitoreIndirizzo;
$rDirty = true;
}
if (empty($rubrica->civico) && $fornitoreCivico) {
$rubrica->civico = $fornitoreCivico;
$rDirty = true;
}
if (empty($rubrica->cap) && $fornitoreCap) {
$rubrica->cap = $fornitoreCap;
$rDirty = true;
}
if (empty($rubrica->citta) && $fornitoreCitta) {
$rubrica->citta = $fornitoreCitta;
$rDirty = true;
}
if (empty($rubrica->provincia) && $fornitoreProvincia) {
$rubrica->provincia = strtoupper($fornitoreProvincia);
$rDirty = true;
}
if (empty($rubrica->nazione) && $fornitoreNazione) {
$rubrica->nazione = strtoupper($fornitoreNazione);
$rDirty = true;
}
if (empty($rubrica->telefono_ufficio) && $fornitoreTelefono) {
$rubrica->telefono_ufficio = $fornitoreTelefono;
$rDirty = true;
}
if (empty($rubrica->fax) && $fornitoreFax) {
$rubrica->fax = $fornitoreFax;
$rDirty = true;
}
if (empty($rubrica->email) && $fornitoreEmail) {
$rubrica->email = $fornitoreEmail;
$rDirty = true;
}
if (empty($rubrica->pec) && $fornitorePec) {
$rubrica->pec = $fornitorePec;
$rDirty = true;
}
if ($rDirty) {
$rubrica->data_ultima_modifica = $now;
if ($userId > 0) {
$rubrica->modificato_da = $userId;
}
$rubrica->save();
}
}
}
private function ensureFornitoreFromFeData(int $amministratoreId, array $data, array $extra = []): ?int
{
$piva = isset($data['fornitore_piva']) && is_string($data['fornitore_piva']) ? trim($data['fornitore_piva']) : null;
$cf = isset($data['fornitore_cf']) && is_string($data['fornitore_cf']) ? trim($data['fornitore_cf']) : null;
$den = isset($data['fornitore_denominazione']) && is_string($data['fornitore_denominazione']) ? trim($data['fornitore_denominazione']) : '';
if ($piva === '' || $piva === 'ND') {
$piva = null;
}
if ($cf === '') {
$cf = null;
}
if ($den === '') {
$den = $piva ?: ($cf ?: 'Fornitore');
}
$existing = $this->matchFornitoreIdForAmministratore($amministratoreId, $piva, $cf);
if ($existing) {
return $existing;
}
$userId = (int) ($extra['user_id'] ?? 0);
$now = now();
$fornitoreNome = $this->normalizeNullableString($data['fornitore_nome'] ?? null);
$fornitoreCognome = $this->normalizeNullableString($data['fornitore_cognome'] ?? null);
$fornitoreIndirizzo = $this->normalizeNullableString($data['fornitore_indirizzo'] ?? null);
$fornitoreCivico = $this->normalizeNullableString($data['fornitore_civico'] ?? null);
$fornitoreCap = $this->normalizeNullableString($data['fornitore_cap'] ?? null);
$fornitoreCitta = $this->normalizeNullableString($data['fornitore_citta'] ?? null);
$fornitoreProvincia = $this->normalizeNullableString($data['fornitore_provincia'] ?? null);
$fornitoreNazione = $this->normalizeNullableString($data['fornitore_nazione'] ?? null);
$fornitoreTelefono = $this->normalizeNullableString($data['fornitore_telefono'] ?? null);
$fornitoreFax = $this->normalizeNullableString($data['fornitore_fax'] ?? null);
$fornitoreEmailRaw = $this->normalizeNullableString($data['fornitore_email'] ?? null);
$fornitorePec = $this->looksLikePec($fornitoreEmailRaw) ? $fornitoreEmailRaw : null;
$fornitoreEmail = $fornitorePec ? null : $fornitoreEmailRaw;
$rubrica = null;
if ($piva) {
$rubrica = RubricaUniversale::query()->where('partita_iva', $piva)->first();
}
if (! $rubrica && $cf) {
$rubrica = RubricaUniversale::query()->where('codice_fiscale', $cf)->first();
}
if (! $rubrica) {
$rubrica = RubricaUniversale::query()
->where('tipo_contatto', 'persona_giuridica')
->where('categoria', 'fornitore')
->whereRaw('LOWER(ragione_sociale) = ?', [mb_strtolower($den)])
->first();
}
if (! $rubrica) {
$rubrica = RubricaUniversale::query()->create([
'ragione_sociale' => $den,
'tipo_contatto' => 'persona_giuridica',
'categoria' => 'fornitore',
'stato' => 'attivo',
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'indirizzo' => $fornitoreIndirizzo,
'civico' => $fornitoreCivico,
'cap' => $fornitoreCap,
'citta' => $fornitoreCitta,
'provincia' => $fornitoreProvincia ? strtoupper($fornitoreProvincia) : null,
'nazione' => $fornitoreNazione ? strtoupper($fornitoreNazione) : null,
'telefono_ufficio' => $fornitoreTelefono,
'fax' => $fornitoreFax,
'email' => $fornitoreEmail,
'pec' => $fornitorePec,
'data_inserimento' => $now,
'data_ultima_modifica' => $now,
'creato_da' => $userId > 0 ? $userId : null,
'modificato_da' => $userId > 0 ? $userId : null,
]);
} else {
$dirty = false;
if (empty($rubrica->ragione_sociale) && $den !== '') {
$rubrica->ragione_sociale = $den;
$dirty = true;
}
if (empty($rubrica->partita_iva) && $piva) {
$rubrica->partita_iva = $piva;
$dirty = true;
}
if (empty($rubrica->codice_fiscale) && $cf) {
$rubrica->codice_fiscale = $cf;
$dirty = true;
}
if (empty($rubrica->indirizzo) && $fornitoreIndirizzo) {
$rubrica->indirizzo = $fornitoreIndirizzo;
$dirty = true;
}
if (empty($rubrica->civico) && $fornitoreCivico) {
$rubrica->civico = $fornitoreCivico;
$dirty = true;
}
if (empty($rubrica->cap) && $fornitoreCap) {
$rubrica->cap = $fornitoreCap;
$dirty = true;
}
if (empty($rubrica->citta) && $fornitoreCitta) {
$rubrica->citta = $fornitoreCitta;
$dirty = true;
}
if (empty($rubrica->provincia) && $fornitoreProvincia) {
$rubrica->provincia = strtoupper($fornitoreProvincia);
$dirty = true;
}
if (empty($rubrica->nazione) && $fornitoreNazione) {
$rubrica->nazione = strtoupper($fornitoreNazione);
$dirty = true;
}
if (empty($rubrica->telefono_ufficio) && $fornitoreTelefono) {
$rubrica->telefono_ufficio = $fornitoreTelefono;
$dirty = true;
}
if (empty($rubrica->fax) && $fornitoreFax) {
$rubrica->fax = $fornitoreFax;
$dirty = true;
}
if (empty($rubrica->email) && $fornitoreEmail) {
$rubrica->email = $fornitoreEmail;
$dirty = true;
}
if (empty($rubrica->pec) && $fornitorePec) {
$rubrica->pec = $fornitorePec;
$dirty = true;
}
if ($dirty) {
$rubrica->categoria = $rubrica->categoria ?: 'fornitore';
$rubrica->tipo_contatto = $rubrica->tipo_contatto ?: 'persona_giuridica';
$rubrica->stato = $rubrica->stato ?: 'attivo';
$rubrica->data_ultima_modifica = $now;
if ($userId > 0) {
$rubrica->modificato_da = $userId;
}
$rubrica->save();
}
}
$fornitore = Fornitore::query()->create([
'amministratore_id' => $amministratoreId,
'rubrica_id' => (int) $rubrica->id,
// Un solo codice: quello della Rubrica.
'codice_univoco' => (string) ($rubrica->codice_univoco ?? ''),
'ragione_sociale' => $den,
'nome' => $fornitoreNome,
'cognome' => $fornitoreCognome,
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'indirizzo' => $fornitoreIndirizzo,
'civico' => $fornitoreCivico,
'cap' => $fornitoreCap,
'citta' => $fornitoreCitta,
'provincia' => $fornitoreProvincia ? strtoupper($fornitoreProvincia) : null,
'nazione' => $fornitoreNazione ? strtoupper($fornitoreNazione) : null,
'telefono' => $fornitoreTelefono,
'email' => $fornitoreEmail,
'pec' => $fornitorePec,
'iban' => isset($data['pagamento_iban']) && is_string($data['pagamento_iban']) ? $data['pagamento_iban'] : null,
'bic' => isset($data['pagamento_bic']) && is_string($data['pagamento_bic']) ? $data['pagamento_bic'] : null,
'modalita_pagamento_predefinita' => isset($data['pagamento_modalita']) && is_string($data['pagamento_modalita']) ? $data['pagamento_modalita'] : null,
]);
return (int) $fornitore->id;
}
private function normalizeNullableString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim($value);
if ($normalized === '' || strtoupper($normalized) === 'ND') {
return null;
}
return $normalized;
}
private function looksLikePec(?string $email): bool
{
if (! is_string($email) || trim($email) === '') {
return false;
}
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
return preg_match('/\b(pec|legalmail|postecert|cert|mailpec)\b/i', $email) === 1;
}
}