*/ private array $fornitoreEscludiRigheFeCache = []; /** @var array */ private array $promptedVoceSpesaPresetCache = []; /** @var array */ private array $promptedContoCostoPresetCache = []; /** @var array> */ private array $xsdOptionsCache = []; /** @var array */ private array $voceSpesaLabelCache = []; private ?FatturaElettronica $linkedFeCache = null; /** @var array|null */ private ?array $linkedFeParsedCache = null; protected static ?string $title = 'Registrazione fattura fornitore'; protected static bool $shouldRegisterNavigation = false; protected static ?string $slug = 'contabilita/fatture-fornitori/registrazione/{record?}'; protected string $view = 'filament.pages.contabilita.fattura-fornitore-scheda'; public ?FatturaFornitore $record = null; public array $data = []; /** * @return array{stabile: string, gestione: string, totale: float, imponibile: float, iva: float, ra: float, netto: float, somma_righe: float, delta: float, protocollo: string, stato: string} */ public function getHeaderSummary(): array { // IMPORTANTE: non usare getState() qui, perché può triggerare la validazione Filament // e lanciare ValidationException anche su semplice GET della pagina. $state = is_array($this->data ?? null) ? $this->data : []; $stabileId = (int) ($this->record?->stabile_id ?? ($state['stabile_id'] ?? 0)); $gestioneId = (int) ($this->record?->gestione_id ?? ($state['gestione_id'] ?? 0)); $stabileLabel = '—'; if ($stabileId > 0) { $s = Stabile::query()->select(['id', 'codice_stabile', 'denominazione'])->find($stabileId); if ($s) { $stabileLabel = trim((string) ($s->codice_stabile ?? '')); $den = trim((string) ($s->denominazione ?? '')); if ($den !== '') { $stabileLabel = trim($stabileLabel . ' — ' . $den); } } } $gestioneLabel = '—'; if ($gestioneId > 0) { $g = GestioneContabile::query()->select(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix'])->find($gestioneId); if ($g) { $gestioneLabel = trim((string) ($g->denominazione ?? '')); if ($gestioneLabel === '') { $gestioneLabel = (string) $g->anno_gestione . ' - ' . (string) $g->tipo_gestione; } $prefix = trim((string) ($g->protocollo_prefix ?? '')); if ($prefix !== '') { $gestioneLabel = $prefix . ' — ' . $gestioneLabel; } } } $righe = is_array($state['righe'] ?? null) ? $state['righe'] : []; if ($righe === [] && $this->record) { $righe = $this->record->righe ? $this->record->righe->map(fn($r) => [ 'imponibile_euro' => (float) ($r->imponibile_euro ?? 0), 'iva_euro' => (float) ($r->iva_euro ?? 0), 'totale_riga' => (float) ($r->totale_euro ?? 0), ])->all() : []; } $somma = 0.0; foreach ($righe as $r) { if (! is_array($r)) { continue; } if (array_key_exists('totale_riga', $r) && is_numeric($r['totale_riga'])) { $somma += (float) $r['totale_riga']; } else { $somma += (float) ($r['imponibile_euro'] ?? 0); $somma += (float) ($r['iva_euro'] ?? 0); } } $somma = round($somma, 2); $totale = round((float) ($state['totale'] ?? ($this->record?->totale ?? 0)), 2); $imponibileHdr = round((float) ($state['imponibile'] ?? ($this->record?->imponibile ?? 0)), 2); $ivaHdr = round((float) ($state['iva'] ?? ($this->record?->iva ?? 0)), 2); $ra = round((float) ($state['ritenuta_importo'] ?? ($this->record?->ritenuta_importo ?? 0)), 2); $netto = round((float) ($state['netto_da_pagare'] ?? ($this->record?->netto_da_pagare ?? ($totale - $ra))), 2); $delta = round($totale - $somma, 2); $protocollo = $this->record?->registrazione?->protocollo_completo; $protocollo = is_string($protocollo) && $protocollo !== '' ? $protocollo : '— (assegnato alla contabilizzazione / prima nota)'; $stato = (string) ($state['stato'] ?? ($this->record?->stato ?? 'inserito')); return [ 'stabile' => $stabileLabel, 'gestione' => $gestioneLabel, 'totale' => $totale, 'imponibile' => $imponibileHdr, 'iva' => $ivaHdr, 'ra' => $ra, 'netto' => $netto, 'somma_righe' => $somma, 'delta' => $delta, 'protocollo' => $protocollo, 'stato' => $stato, ]; } private function fornitoreEscludiRigheFe(?int $fornitoreId): bool { $fornitoreId = (int) ($fornitoreId ?? 0); if ($fornitoreId <= 0) { return false; } if (array_key_exists($fornitoreId, $this->fornitoreEscludiRigheFeCache)) { return (bool) $this->fornitoreEscludiRigheFeCache[$fornitoreId]; } if (! SchemaFacade::hasColumn('fornitori', 'escludi_righe_fe')) { $this->fornitoreEscludiRigheFeCache[$fornitoreId] = false; return false; } $flag = (bool) (Fornitore::query()->whereKey($fornitoreId)->value('escludi_righe_fe') ?? false); $this->fornitoreEscludiRigheFeCache[$fornitoreId] = $flag; return $flag; } /** @param array $state */ private function applyConsumoSupplierRulesToState(array $state): array { $fornitoreId = (int) ($state['fornitore_id'] ?? 0); $escludi = $this->fornitoreEscludiRigheFe($fornitoreId); $state['fornitore_escludi_righe_fe'] = $escludi; if (! $escludi) { return $state; } $totale = isset($state['totale']) && is_numeric($state['totale']) ? round((float) $state['totale'], 2) : 0.0; $imponibile = null; $iva = null; if ($this->record) { $imponibile = is_numeric($this->record->imponibile) ? round((float) $this->record->imponibile, 2) : null; $iva = is_numeric($this->record->iva) ? round((float) $this->record->iva, 2) : null; } $righe = is_array($state['righe'] ?? null) ? $state['righe'] : []; if ($righe === [] || count($righe) !== 1) { $aliq = 0.0; if (($imponibile ?? 0.0) > 0.005 && ($iva ?? 0.0) >= 0.0) { $aliq = round(((float) $iva / (float) $imponibile) * 100.0, 2); } $state['righe'] = [[ 'riga' => null, 'descrizione' => 'Totale fattura', 'imponibile_euro' => $imponibile !== null ? $imponibile : $totale, 'iva_euro' => $iva !== null ? $iva : 0.0, 'totale_riga' => $totale, 'aliquota_iva' => $aliq, 'conto_costo_id' => null, 'voce_spesa_id' => null, 'gestione_id' => null, 'percentuale_riparto' => null, 'tabella_millesimale_id' => null, ]]; return $state; } // Se c'è già una riga, garantiamo che il totale riga coincida col totale documento. $r0 = is_array($righe[0] ?? null) ? $righe[0] : []; $sum = round(((float) ($r0['imponibile_euro'] ?? 0)) + ((float) ($r0['iva_euro'] ?? 0)), 2); if (round($sum - $totale, 2) != 0.0) { $r0['totale_riga'] = $totale; $aliq = is_numeric($r0['aliquota_iva'] ?? null) ? (float) $r0['aliquota_iva'] : 0.0; if ($aliq > 0) { $impon = round($totale / (1 + ($aliq / 100.0)), 2); $r0['imponibile_euro'] = $impon; $r0['iva_euro'] = round($totale - $impon, 2); } else { $r0['imponibile_euro'] = $totale; $r0['iva_euro'] = 0.0; } $state['righe'] = [$r0]; } return $state; } private function getActiveStabileIdFromState(): int { $state = is_array($this->data ?? null) ? $this->data : []; $stabileId = (int) ($this->record?->stabile_id ?? ($state['stabile_id'] ?? 0)); return $stabileId > 0 ? $stabileId : 0; } private function getFornitorePresetLabel(int $stabileId, int $fornitoreId): string { if ($stabileId <= 0 || $fornitoreId <= 0) { return '—'; } if (! SchemaFacade::hasTable('fornitore_stabile_impostazioni')) { return '—'; } $settings = FornitoreStabileImpostazione::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->first(); if (! $settings) { return 'Nessun preset impostato'; } $parts = []; $voceId = (int) ($settings->voce_spesa_default_id ?? 0); if ($voceId > 0 && SchemaFacade::hasTable('voci_spesa')) { $v = VoceSpesa::query()->where('stabile_id', $stabileId)->find($voceId); if ($v) { $label = trim((string) ($v->codice ?? '')); if ($label !== '') { $label .= ' — '; } $label .= (string) ($v->descrizione ?? ('Voce #' . $voceId)); $parts[] = 'Voce: ' . $label; } } $contoId = (int) ($settings->conto_costo_default_id ?? 0); if ($contoId > 0 && SchemaFacade::hasTable('contabilita_piano_conti')) { $c = PianoConti::query()->find($contoId); if ($c) { $parts[] = 'Costo: ' . trim((string) ($c->codice ?? '')) . ' - ' . trim((string) ($c->descrizione ?? '')); } } return $parts !== [] ? implode("\n", $parts) : 'Nessun preset impostato'; } /** @return array */ private function getXsdCodeOptions(string $typeName): array { $typeName = trim($typeName); if ($typeName === '') { return []; } if (array_key_exists($typeName, $this->xsdOptionsCache)) { return $this->xsdOptionsCache[$typeName]; } if (! SchemaFacade::hasTable('fe_xsd_versions') || ! SchemaFacade::hasTable('fe_xsd_code_domains') || ! SchemaFacade::hasTable('fe_xsd_codes')) { $this->xsdOptionsCache[$typeName] = []; return []; } $latestId = FeXsdVersion::query()->orderByDesc('imported_at')->orderByDesc('id')->value('id'); $latestId = is_numeric($latestId) ? (int) $latestId : null; if (! $latestId) { $this->xsdOptionsCache[$typeName] = []; return []; } $domainId = FeXsdCodeDomain::query() ->where('version_id', $latestId) ->where('type_name', $typeName) ->value('id'); $domainId = is_numeric($domainId) ? (int) $domainId : null; if (! $domainId) { $this->xsdOptionsCache[$typeName] = []; return []; } $options = FeXsdCode::query() ->where('domain_id', $domainId) ->orderBy('code') ->get(['code', 'documentation']) ->mapWithKeys(function (FeXsdCode $c) { $code = trim((string) ($c->code ?? '')); if ($code === '') { return []; } $doc = trim((string) ($c->documentation ?? '')); return [$code => ($doc !== '' ? ($code . ' — ' . $doc) : $code)]; }) ->all(); $this->xsdOptionsCache[$typeName] = $options; return $options; } private function resolveVoceSpesaLabel(int $voceId): string { $voceId = (int) $voceId; if ($voceId <= 0) { return ''; } if (array_key_exists($voceId, $this->voceSpesaLabelCache)) { return $this->voceSpesaLabelCache[$voceId]; } $v = VoceSpesa::query()->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione'])->find($voceId); if (! $v) { $this->voceSpesaLabelCache[$voceId] = 'Voce #' . $voceId; return $this->voceSpesaLabelCache[$voceId]; } $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $tipo = match ((string) ($v->tipo_gestione ?? '')) { 'ordinaria' => 'O', 'riscaldamento' => 'R', 'straordinaria' => 'S', default => strtoupper(substr((string) ($v->tipo_gestione ?? 'X'), 0, 1)), }; $label = trim((string) ($v->codice ?? '')) . ' - ' . trim((string) ($v->descrizione ?? '')); $label .= ' • ' . ($tab ? $tab : '(!) NO TABELLA'); $label .= ' • ' . $tipo; $this->voceSpesaLabelCache[$voceId] = $label; return $label; } private function resolveContoCostoIdFromVoceSpesa(int $voceId): ?int { if ($voceId <= 0) { return null; } if (! SchemaFacade::hasTable('voci_spesa') || ! SchemaFacade::hasTable('contabilita_piano_conti')) { return null; } $voce = VoceSpesa::query()->find($voceId); if (! $voce) { return null; } $codes = []; foreach (['sottoconto_pd', 'conto_pd', 'codice'] as $field) { $val = trim((string) ($voce->{$field} ?? '')); if ($val !== '') { $codes[] = $val; } } if ($codes !== []) { foreach ($codes as $code) { $candidate = $this->normalizeVoceContoCode($code); $conto = PianoConti::query()->where('codice', $candidate)->first(); if (! $conto && $candidate !== $code) { $conto = PianoConti::query()->where('codice', $code)->first(); } if ($conto) { return (int) $conto->id; } if ($candidate !== '') { $createdId = $this->createContoFromVoce($candidate, trim((string) ($voce->descrizione ?? 'Voce spesa ' . $voceId)), 'COSTO'); if ($createdId > 0) { return $createdId; } } } } return null; } private function normalizeVoceContoCode(string $code): string { $code = trim($code); if ($code === '') { return ''; } $parts = preg_split('/\s*[-–]\s*/u', $code, 2); $base = trim((string) ($parts[0] ?? $code)); $base = preg_replace('/\s+/', '', $base) ?? $base; $base = preg_replace('/[^A-Za-z0-9\.]/', '', $base) ?? $base; return $base; } private function createContoFromVoce(string $codice, string $descrizione, string $tipoConto): int { $codice = trim($codice); if ($codice === '') { return 0; } if (! SchemaFacade::hasTable('contabilita_piano_conti')) { return 0; } $existingId = DB::table('contabilita_piano_conti')->where('codice', $codice)->value('id'); if (is_numeric($existingId)) { return (int) $existingId; } $descrizione = trim($descrizione); if ($descrizione === '') { $descrizione = 'Voce spesa'; } try { $id = DB::table('contabilita_piano_conti')->insertGetId([ 'codice' => $codice, 'denominazione' => $descrizione, 'descrizione' => $descrizione, 'tipo_conto' => $tipoConto, 'attivo' => 1, 'livello' => 3, 'codice_padre' => null, 'saldo_iniziale' => 0.0, 'created_at' => now(), 'updated_at' => now(), ]); return is_numeric($id) ? (int) $id : 0; } catch (\Throwable) { return 0; } } /** * @param array $state */ private function buildDescrizioneOperazioneFromRecordState(array $state, FatturaFornitore $record): string { $parts = []; $fornitoreLabel = null; if ($record->fornitore) { $fornitoreLabel = (string) ($record->fornitore->ragione_sociale ?? $record->fornitore->denominazione ?? ''); } else { $fornitoreId = (int) ($state['fornitore_id'] ?? 0); if ($fornitoreId > 0) { $fornitore = Fornitore::query()->find($fornitoreId); $fornitoreLabel = (string) ($fornitore?->ragione_sociale ?? $fornitore?->denominazione ?? ''); } } $fornitoreLabel = trim((string) $fornitoreLabel); if ($fornitoreLabel !== '') { $parts[] = $fornitoreLabel; } $dati = is_array($record->dati_fornitura) ? $record->dati_fornitura : []; if ($dati !== []) { $tipo = (string) ($dati['tipo'] ?? ''); $tipoLabel = match ($tipo) { 'gas_naturale' => 'Utenza gas', default => $tipo !== '' ? $tipo : 'Utenza', }; array_unshift($parts, $tipoLabel); $periodo = trim((string) ($dati['periodo_fatturato'] ?? $dati['periodo_annuo'] ?? '')); $consumo = $dati['consumo_fatturato_smc'] ?? $dati['consumo_annuo_smc'] ?? null; if ($periodo !== '' && is_numeric($consumo)) { $parts[] = strtoupper($periodo) . ' - ' . number_format((float) $consumo, 0, ',', '.') . ' Smc'; } else { if ($periodo !== '') { $parts[] = strtoupper($periodo); } if (is_numeric($consumo)) { $parts[] = number_format((float) $consumo, 0, ',', '.') . ' Smc'; } } } return $parts !== [] ? implode(' - ', array_filter($parts, fn($v) => $v !== '')) : ''; } /** @return array */ public function getVoceSpesaOptionsInline(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); if ($stabileId <= 0) { return []; } $gestioneId = (int) ($this->data['gestione_id'] ?? 0); $gestioneTipo = null; if ($gestioneId > 0) { $gestioneTipo = GestioneContabile::query()->whereKey($gestioneId)->value('tipo_gestione'); } $q = VoceSpesa::query() ->where('stabile_id', $stabileId) ->where('attiva', true) ->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione']) ->orderBy('ordinamento') ->orderBy('descrizione'); if ($gestioneTipo === 'straordinaria') { $q->where(function ($qq) use ($gestioneId) { $qq->where('tipo_gestione', 'straordinaria'); if ($gestioneId > 0) { $qq->orWhere('gestione_contabile_id', $gestioneId); } }); } return $q ->limit(200) ->get(['id', 'codice', 'descrizione', 'tipo_gestione', 'tabella_millesimale_default_id']) ->mapWithKeys(function (VoceSpesa $v): array { $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $tipo = match ((string) ($v->tipo_gestione ?? '')) { 'ordinaria' => 'O', 'riscaldamento' => 'R', 'straordinaria' => 'S', default => strtoupper(substr((string) ($v->tipo_gestione ?? 'X'), 0, 1)), }; $label = trim((string) ($v->codice ?? '')) . ' - ' . trim((string) ($v->descrizione ?? '')); $label .= ' • ' . ($tab ? $tab : '(!) NO TABELLA'); $label .= ' • ' . $tipo; return [(string) $v->id => $label]; }) ->all(); } public function setVoceSpesaForRiga(string $itemKey, $voceSpesaId): void { $itemKey = trim($itemKey); if ($itemKey === '') { return; } $righe = is_array($this->data['righe'] ?? null) ? $this->data['righe'] : []; if (! array_key_exists($itemKey, $righe) || ! is_array($righe[$itemKey])) { return; } $id = is_numeric($voceSpesaId) ? (int) $voceSpesaId : 0; if ($id <= 0) { $righe[$itemKey]['voce_spesa_id'] = null; $righe[$itemKey]['voce_spesa_label'] = ''; $this->data['righe'] = $righe; return; } $righe[$itemKey]['voce_spesa_id'] = $id; $righe[$itemKey]['voce_spesa_label'] = $this->resolveVoceSpesaLabel($id); $this->data['righe'] = $righe; } public function deleteRigaSpesa(string $itemKey): void { $itemKey = trim($itemKey); if ($itemKey === '') { return; } $righe = is_array($this->data['righe'] ?? null) ? $this->data['righe'] : []; if (! array_key_exists($itemKey, $righe)) { return; } unset($righe[$itemKey]); $this->data['righe'] = $righe; } public function splitRigaSpesa(string $itemKey, $percentA = 50): void { $itemKey = trim($itemKey); if ($itemKey === '') { return; } $righe = is_array($this->data['righe'] ?? null) ? $this->data['righe'] : []; if (! array_key_exists($itemKey, $righe) || ! is_array($righe[$itemKey])) { return; } $p = is_numeric($percentA) ? (float) $percentA : 50.0; $p = max(0.0, min(100.0, $p)); $ratioA = $p / 100.0; $ratioB = 1.0 - $ratioA; $item = $righe[$itemKey]; $imponibile = (float) ($item['imponibile_euro'] ?? 0); $iva = (float) ($item['iva_euro'] ?? 0); $imponibileA = round($imponibile * $ratioA, 2); $ivaA = round($iva * $ratioA, 2); $imponibileB = round($imponibile - $imponibileA, 2); $ivaB = round($iva - $ivaA, 2); $itemA = $item; $itemA['imponibile_euro'] = $imponibileA; $itemA['iva_euro'] = $ivaA; $itemA['totale_riga'] = round($imponibileA + $ivaA, 2); $itemB = $item; $itemB['imponibile_euro'] = $imponibileB; $itemB['iva_euro'] = $ivaB; $itemB['totale_riga'] = round($imponibileB + $ivaB, 2); $newKey = (string) Str::uuid(); $newItems = []; foreach ($righe as $k => $v) { if ($k === $itemKey) { $newItems[$k] = $itemA; $newItems[$newKey] = $itemB; continue; } $newItems[$k] = $v; } $this->data['righe'] = $newItems; } public function addSpesaForFeRiga(int $rigaFe, string $descrizione, $imponibile, $iva, $aliquotaIva = 0): void { $rigaFe = (int) $rigaFe; if ($rigaFe <= 0) { return; } $righe = is_array($this->data['righe'] ?? null) ? $this->data['righe'] : []; $imp = is_numeric($imponibile) ? round((float) $imponibile, 2) : 0.0; $iv = is_numeric($iva) ? round((float) $iva, 2) : 0.0; $aliq = is_numeric($aliquotaIva) ? (float) $aliquotaIva : 0.0; $tot = round($imp + $iv, 2); $newKey = (string) Str::uuid(); $righe[$newKey] = [ 'riga' => $rigaFe, 'descrizione' => trim($descrizione) !== '' ? trim($descrizione) : ('Riga FE #' . $rigaFe), 'imponibile_euro' => $imp, 'iva_euro' => $iv, 'totale_riga' => $tot, 'aliquota_iva' => $aliq, 'conto_costo_id' => null, 'voce_spesa_id' => null, 'voce_spesa_label' => '', 'gestione_id' => (int) ($this->data['gestione_id'] ?? 0) ?: null, 'percentuale_riparto' => null, 'tabella_millesimale_id' => null, ]; $this->data['righe'] = $righe; } public function getSelectedGestioneTipo(): ?string { $gestioneId = (int) ($this->data['gestione_id'] ?? ($this->record?->gestione_id ?? 0)); if ($gestioneId <= 0) { return null; } $tipo = GestioneContabile::query()->whereKey($gestioneId)->value('tipo_gestione'); return is_string($tipo) && trim($tipo) !== '' ? trim($tipo) : null; } /** @return array */ public function getGestioniStraordinarieList(): array { $stabileId = $this->getActiveStabileIdFromState(); if ($stabileId <= 0) { return []; } return GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('tipo_gestione', 'straordinaria') ->orderByDesc('anno_gestione') ->orderByDesc('numero_straordinaria') ->orderByDesc('id') ->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'numero_straordinaria', 'stato']) ->map(function (GestioneContabile $g): array { $label = trim((string) ($g->denominazione ?? '')); if ($label === '') { $label = (string) $g->anno_gestione . ' - ' . (string) $g->tipo_gestione; } if (is_numeric($g->numero_straordinaria) && (int) $g->numero_straordinaria > 0) { $label .= ' • STR ' . (int) $g->numero_straordinaria; } $prefix = trim((string) ($g->protocollo_prefix ?? '')); if ($prefix !== '') { $label = $prefix . ' — ' . $label; } $stato = trim((string) ($g->stato ?? '')); if ($stato !== '') { $label .= ' • ' . strtoupper($stato); } return ['id' => (int) $g->id, 'label' => $label]; }) ->all(); } /** @return array */ public function getVociSpesaStraordinarieList(): array { $stabileId = $this->getActiveStabileIdFromState(); if ($stabileId <= 0) { return []; } $gestioneId = (int) ($this->data['gestione_id'] ?? 0); $q = VoceSpesa::query() ->where('stabile_id', $stabileId) ->where('attiva', true) ->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione']) ->orderBy('ordinamento') ->orderBy('codice') ->orderBy('descrizione') ->where(function ($qq) use ($gestioneId) { $qq->where('tipo_gestione', 'straordinaria'); if ($gestioneId > 0) { $qq->orWhere('gestione_contabile_id', $gestioneId); } }); return $q ->get(['id', 'codice', 'descrizione', 'tabella_millesimale_default_id', 'gestione_contabile_id']) ->map(function (VoceSpesa $v): array { $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $code = trim((string) ($v->codice ?? '')); $desc = trim((string) ($v->descrizione ?? '')); $label = ($code !== '' ? $code : ('Voce #' . $v->id)) . ' — ' . ($desc !== '' ? $desc : '—'); $label .= ' • ' . ($tab ? $tab : '(!) NO TABELLA'); return [ 'id' => (int) $v->id, 'codice' => $code, 'descrizione' => $desc, 'tabella' => $tab, 'label' => $label, ]; }) ->all(); } private function getXsdCodeDocumentation(string $typeName, string $code): ?string { $typeName = trim($typeName); $code = trim($code); if ($typeName === '' || $code === '') { return null; } if (! SchemaFacade::hasTable('fe_xsd_versions') || ! SchemaFacade::hasTable('fe_xsd_code_domains') || ! SchemaFacade::hasTable('fe_xsd_codes')) { return null; } $latestId = FeXsdVersion::query()->orderByDesc('imported_at')->orderByDesc('id')->value('id'); $latestId = is_numeric($latestId) ? (int) $latestId : null; if (! $latestId) { return null; } $domainId = FeXsdCodeDomain::query() ->where('version_id', $latestId) ->where('type_name', $typeName) ->value('id'); $domainId = is_numeric($domainId) ? (int) $domainId : null; if (! $domainId) { return null; } $doc = FeXsdCode::query() ->where('domain_id', $domainId) ->where('code', $code) ->value('documentation'); return is_string($doc) && trim($doc) !== '' ? trim($doc) : null; } public function salvaPresetVoceSpesa(int $stabileId, int $fornitoreId, int $voceSpesaId): void { $stabileId = (int) $stabileId; $fornitoreId = (int) $fornitoreId; $voceSpesaId = (int) $voceSpesaId; if ($stabileId <= 0 || $fornitoreId <= 0 || $voceSpesaId <= 0) { return; } if (! SchemaFacade::hasTable('fornitore_stabile_impostazioni')) { Notification::make()->title('Tabella mancante: fornitore_stabile_impostazioni')->warning()->send(); return; } FornitoreStabileImpostazione::query()->updateOrCreate( [ 'stabile_id' => $stabileId, 'fornitore_id' => $fornitoreId, ], [ 'voce_spesa_default_id' => $voceSpesaId, ] ); Notification::make()->title('Preset salvato')->body('Voce spesa predefinita impostata per questo stabile.')->success()->send(); } public function salvaPresetContoCosto(int $stabileId, int $fornitoreId, int $contoCostoId): void { $stabileId = (int) $stabileId; $fornitoreId = (int) $fornitoreId; $contoCostoId = (int) $contoCostoId; if ($stabileId <= 0 || $fornitoreId <= 0 || $contoCostoId <= 0) { return; } if (! SchemaFacade::hasTable('fornitore_stabile_impostazioni')) { Notification::make()->title('Tabella mancante: fornitore_stabile_impostazioni')->warning()->send(); return; } FornitoreStabileImpostazione::query()->updateOrCreate( [ 'stabile_id' => $stabileId, 'fornitore_id' => $fornitoreId, ], [ 'conto_costo_default_id' => $contoCostoId, ] ); Notification::make()->title('Preset salvato')->body('Conto costo predefinito impostato per questo stabile.')->success()->send(); } public function generaPrimaNota(): void { if (! $this->record) { Notification::make()->title('Salva prima la fattura')->warning()->send(); return; } $righe = $this->record->righe ?? []; foreach ($righe as $r) { $voceId = isset($r->voce_spesa_id) && is_numeric($r->voce_spesa_id) ? (int) $r->voce_spesa_id : 0; if ($voceId <= 0) { Notification::make()->title('Seleziona la voce spesa')->body('Ogni riga deve avere una voce di spesa prima di generare la prima nota.')->danger()->send(); return; } } if (! empty($this->record->registrazione_id)) { Notification::make()->title('Prima nota già presente')->warning()->send(); return; } $user = Auth::user(); if (! $user instanceof User) { return; } try { $service = app(FatturaFornitorePrimaNotaService::class); $service->generaRegistrazioneDaFattura($user, $this->record->fresh(['righe'])); $this->record->refresh(); Notification::make()->title('Prima nota generata')->success()->send(); } catch (\Throwable $e) { Notification::make()->title('Errore')->body($e->getMessage())->danger()->send(); } } public function mount(int | string | null $record = null): void { $user = Auth::user(); if (! $user instanceof User) { abort(403); } if (! $user->hasAnyRole(['super-admin', 'admin']) && ! $user->can('contabilita.fatture-fornitori')) { abort(403); } $activeStabileId = StabileContext::resolveActiveStabileId($user); if (! $activeStabileId) { Notification::make()->title('Seleziona uno stabile')->danger()->send(); $this->record = null; $this->getSchema('form')?->fill(); return; } if ((int) ($record ?? 0) > 0) { $this->record = FatturaFornitore::query() ->with(['righe', 'registrazione', 'movimentoPagamento', 'movimentoVersamentoRa']) ->where('stabile_id', $activeStabileId) ->findOrFail((int) $record); } $state = $this->record ? $this->toFormState($this->record) : [ 'stabile_id' => $activeStabileId, 'data_registrazione' => now()->toDateString(), 'valuta' => 'EUR', 'cambio' => 1, 'stato' => 'inserito', 'righe' => [], ]; if ($this->record) { $state = $this->applyFeDefaultsToState($state, $activeStabileId, $this->record); $state = $this->applyFornitoreDefaultsToState($state, $activeStabileId, $this->record); } $state = $this->applyConsumoSupplierRulesToState($state); if (empty($state['gestione_id'])) { $year = null; if (! empty($state['data_documento']) && is_string($state['data_documento'])) { $year = (int) substr($state['data_documento'], 0, 4); } elseif (! empty($state['data_registrazione']) && is_string($state['data_registrazione'])) { $year = (int) substr($state['data_registrazione'], 0, 4); } $defaultGestioneId = $this->resolveDefaultGestioneId($activeStabileId, $year); if ($defaultGestioneId > 0) { $state['gestione_id'] = $defaultGestioneId; } } // Le schede condividono lo stesso statePath ('data') $this->getSchema('header')?->fill($state); $this->getSchema('form')?->fill($state); } public function top(Schema $schema): Schema { return $schema ->statePath('data') ->components([ Grid::make(3) ->schema([ Placeholder::make('stabile_top') ->label('Stabile') ->content(fn(): string => (string) (($this->getHeaderSummary()['stabile'] ?? null) ?: '—')), Placeholder::make('protocollo_top') ->label('Protocollo') ->content(fn(): string => (string) (($this->getHeaderSummary()['protocollo'] ?? null) ?: '—')), Select::make('stato') ->label('Stato') ->options([ 'inserito' => 'Inserito', 'contabilizzato' => 'Contabilizzato', 'pagato' => 'Pagato', ]) ->default('inserito') ->required(), ]), ]); } protected function getHeaderActions(): array { return [ Action::make('salva') ->label('Memorizza') ->icon('heroicon-o-check') ->disabled(fn(): bool => ((float) ($this->getHeaderSummary()['delta'] ?? 0.0)) != 0.0) ->action(fn() => $this->save()), Action::make('annulla_prima_nota') ->label('Annulla prima nota') ->icon('heroicon-o-trash') ->color('danger') ->visible(fn(): bool => (int) ($this->record?->registrazione_id ?? 0) > 0) ->requiresConfirmation() ->action(function (): void { $record = $this->record; if (! $record) { return; } $regId = (int) ($record->registrazione_id ?? 0); if ($regId <= 0) { return; } DB::transaction(function () use ($record, $regId): void { if (SchemaFacade::hasTable('contabilita_movimenti')) { DB::table('contabilita_movimenti')->where('registrazione_id', $regId)->delete(); } if (SchemaFacade::hasTable('contabilita_registrazioni')) { DB::table('contabilita_registrazioni')->where('id', $regId)->delete(); } $record->registrazione_id = null; $record->stato = 'inserito'; $record->save(); }); Notification::make()->title('Prima nota annullata')->success()->send(); $this->redirect(static::getUrl(['record' => $record->id], panel: 'admin-filament')); }), Action::make('nuovo') ->label('Nuova') ->icon('heroicon-o-plus') ->url(fn() => static::getUrl(panel: 'admin-filament')), Action::make('archivio') ->label('Archivio') ->icon('heroicon-o-rectangle-stack') ->url(fn() => FattureFornitoriArchivio::getUrl(panel: 'admin-filament')), ]; } public function header(Schema $schema): Schema { return $schema ->statePath('data') ->components([ Section::make('Dati generali') ->schema([ Select::make('gestione_id') ->label('Gestione') ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $activeStabileId = StabileContext::resolveActiveStabileId($user); $stabileId = (int) ($this->record?->stabile_id ?: $activeStabileId); if (! $stabileId) { return []; } return GestioneContabile::query() ->where('stabile_id', (int) $stabileId) ->orderByDesc('anno_gestione') ->orderByDesc('numero_straordinaria') ->orderByDesc('id') ->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'stato', 'numero_straordinaria']) ->mapWithKeys(function (GestioneContabile $g) { $label = trim((string) ($g->denominazione ?? '')); if ($label === '') { $label = (string) $g->anno_gestione . ' - ' . (string) $g->tipo_gestione; } if ((string) ($g->tipo_gestione ?? '') === 'straordinaria' && is_numeric($g->numero_straordinaria) && (int) $g->numero_straordinaria > 0) { $label .= ' (N.' . (int) $g->numero_straordinaria . ')'; } $prefix = trim((string) ($g->protocollo_prefix ?? '')); if ($prefix !== '') { $label = $prefix . ' — ' . $label; } if (is_string($g->stato) && $g->stato !== '') { $label .= ' [' . $g->stato . ']'; } return [(string) $g->id => $label]; }) ->all(); }) ->searchable() ->required() ->helperText('Ordinaria / riscaldamento / straordinaria (per anno).'), Select::make('causale_contabile') ->label('Causale contabile') ->searchable() ->options([ 'FTFO' => 'FTFO - Fattura fornitore', ]) ->default('FTFO') ->required(), DatePicker::make('data_registrazione')->label('Data registrazione')->required(), DatePicker::make('data_documento') ->label('Data documento') ->required() ->afterStateUpdated(function ($state, callable $get, callable $set): void { if (! $state) { return; } $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return; } $gestioneId = $this->resolveRiscaldamentoGestioneByDate($stabileId, (string) $state); $currentGestione = (int) ($get('gestione_id') ?? 0); if ($currentGestione > 0 && $gestioneId > 0 && $currentGestione !== $gestioneId) { Notification::make() ->title('Attenzione: gestione non coerente') ->body('La data documento rientra nel periodo riscaldamento. Verifica la gestione selezionata.') ->warning() ->send(); return; } if ($currentGestione <= 0) { $year = (int) substr((string) $state, 0, 4); $defaultGestioneId = $this->resolveDefaultGestioneId($stabileId, $year ?: null); if ($defaultGestioneId > 0) { $set('gestione_id', $defaultGestioneId); } } }), TextInput::make('numero_documento')->label('Numero documento')->required(), TextInput::make('totale') ->label('Totale documento €') ->numeric() ->required() ->live() ->afterStateUpdated(function ($state, callable $get, callable $set): void { if (! is_numeric($state)) { return; } if (! (bool) ($get('fornitore_escludi_righe_fe') ?? false)) { return; } $tot = round((float) $state, 2); $righe = $get('righe'); if (! is_array($righe) || $righe === []) { $set('righe', [[ 'riga' => null, 'descrizione' => 'Totale fattura', 'imponibile_euro' => $tot, 'iva_euro' => 0.0, 'totale_riga' => $tot, 'aliquota_iva' => 0.0, 'conto_costo_id' => null, 'voce_spesa_id' => null, 'gestione_id' => null, 'percentuale_riparto' => null, 'tabella_millesimale_id' => null, ]]); return; } $r0 = is_array($righe[0] ?? null) ? $righe[0] : []; $aliq = is_numeric($r0['aliquota_iva'] ?? null) ? (float) $r0['aliquota_iva'] : 0.0; $r0['totale_riga'] = $tot; if ($aliq > 0) { $impon = round($tot / (1 + ($aliq / 100.0)), 2); $r0['imponibile_euro'] = $impon; $r0['iva_euro'] = round($tot - $impon, 2); } else { // Mantieni proporzione imponibile/iva se già presente, altrimenti tutto imponibile. $oldImpon = (float) ($r0['imponibile_euro'] ?? 0); $oldIva = (float) ($r0['iva_euro'] ?? 0); $oldSum = $oldImpon + $oldIva; if ($oldSum > 0.005) { $r0['imponibile_euro'] = round($tot * ($oldImpon / $oldSum), 2); $r0['iva_euro'] = round($tot - (float) $r0['imponibile_euro'], 2); } else { $r0['imponibile_euro'] = $tot; $r0['iva_euro'] = 0.0; } } $set('righe', [$r0]); }), ])->columns(2), Section::make('Fornitore') ->headerActions([ Action::make('modifica_fornitore') ->label('Modifica') ->icon('heroicon-o-pencil-square') ->visible(fn(callable $get): bool => (int) ($get('fornitore_id') ?? 0) > 0) ->url(fn(callable $get): string => \App\Filament\Pages\Gescon\FornitoreScheda::getUrl([ 'record' => (int) ($get('fornitore_id') ?? 0), ], panel: 'admin-filament')), ]) ->schema([ Select::make('fornitore_id') ->label('Fornitore') ->getSearchResultsUsing(function (string $search): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $activeStabileId = StabileContext::resolveActiveStabileId($user); if (! $activeStabileId) { return []; } $stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($activeStabileId); $amministratoreId = (int) ($stabile?->amministratore_id ?? 0); $q = Fornitore::query(); if ($amministratoreId > 0 && SchemaFacade::hasColumn('fornitori', 'amministratore_id')) { $q->where('amministratore_id', $amministratoreId); } $search = trim($search); if ($search !== '') { $q->where(function ($qq) use ($search) { $qq->where('ragione_sociale', 'like', '%' . $search . '%') ->orWhere('codice_fiscale', 'like', '%' . $search . '%') ->orWhere('partita_iva', 'like', '%' . $search . '%'); }); } return $q ->orderBy('ragione_sociale') ->orderBy('id') ->limit(50) ->get(['id', 'ragione_sociale', 'codice_fiscale', 'partita_iva']) ->mapWithKeys(function (Fornitore $f) { $label = trim((string) ($f->ragione_sociale ?? '')); if ($label === '') { $cf = trim((string) ($f->codice_fiscale ?? '')); $piva = trim((string) ($f->partita_iva ?? '')); $label = $cf !== '' ? $cf : ($piva !== '' ? $piva : ('Fornitore #' . $f->id)); } return [(string) $f->id => $label]; }) ->all(); }) ->getOptionLabelUsing(fn($value): ?string => $value ? (Fornitore::query()->whereKey((int) $value)->value('ragione_sociale') ?: ('Fornitore #' . (int) $value)): null) ->searchable() ->required() ->live() ->afterStateUpdated(function ($state, callable $get, callable $set): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); if ($stabileId <= 0) { return; } if (! SchemaFacade::hasTable('fornitore_stabile_impostazioni')) { return; } $fornitoreId = (int) ($state ?? 0); if ($fornitoreId <= 0) { return; } // Flag utenze/consumi: se attivo, non si usano le righe FE. $set('fornitore_escludi_righe_fe', $this->fornitoreEscludiRigheFe($fornitoreId)); $settings = FornitoreStabileImpostazione::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->first(); if (! $settings) { return; } $defaultCostAccountId = (int) ($settings->conto_costo_default_id ?? 0); $defaultVoceSpesaId = (int) ($settings->voce_spesa_default_id ?? 0); if ($defaultCostAccountId > 0) { $set('conto_costo_predefinito_id', $defaultCostAccountId); $set('usa_conto_costo_predefinito', true); } $voce = null; if ($defaultVoceSpesaId > 0) { $voce = VoceSpesa::query() ->where('stabile_id', $stabileId) ->select(['id', 'tabella_millesimale_default_id', 'gestione_contabile_id']) ->find($defaultVoceSpesaId); } $righe = $get('righe'); if (! is_array($righe) || $righe === []) { // UX richiesto: nessuna riga pre-creata. L'utente aggiunge con "Add to items". // Eccezione: utenze/consumi -> una sola riga precompilata col totale. if ((bool) ($get('fornitore_escludi_righe_fe') ?? false)) { $tot = is_numeric($get('totale')) ? round((float) $get('totale'), 2) : 0.0; $set('righe', [[ 'riga' => null, 'descrizione' => 'Totale fattura', 'imponibile_euro' => $tot, 'iva_euro' => 0.0, 'totale_riga' => $tot, 'aliquota_iva' => 0.0, 'conto_costo_id' => null, 'voce_spesa_id' => null, 'gestione_id' => null, 'percentuale_riparto' => null, 'tabella_millesimale_id' => null, ]]); } return; } $righe = array_map(function ($r) use ($defaultCostAccountId, $voce) { if (! is_array($r)) { return $r; } if ($defaultCostAccountId > 0) { $has = isset($r['conto_costo_id']) && is_numeric($r['conto_costo_id']) && (int) $r['conto_costo_id'] > 0; if (! $has) { $r['conto_costo_id'] = $defaultCostAccountId; } } if ($voce) { $hasVoce = isset($r['voce_spesa_id']) && is_numeric($r['voce_spesa_id']) && (int) $r['voce_spesa_id'] > 0; if (! $hasVoce) { $r['voce_spesa_id'] = $voce->id; } $hasTab = isset($r['tabella_millesimale_id']) && is_numeric($r['tabella_millesimale_id']) && (int) $r['tabella_millesimale_id'] > 0; if (! $hasTab && $voce->tabella_millesimale_default_id) { $r['tabella_millesimale_id'] = (int) $voce->tabella_millesimale_default_id; } $hasGest = isset($r['gestione_id']) && is_numeric($r['gestione_id']) && (int) $r['gestione_id'] > 0; if (! $hasGest && $voce->gestione_contabile_id) { $r['gestione_id'] = (int) $voce->gestione_contabile_id; } } return $r; }, $righe); $set('righe', $righe); }), Hidden::make('fornitore_escludi_righe_fe')->default(false)->dehydrated(false), Placeholder::make('fornitore_preset') ->label('Preset (solo questo stabile)') ->content(function (callable $get): string { $stabileId = $this->getActiveStabileIdFromState(); $fornitoreId = (int) ($get('fornitore_id') ?? 0); return $this->getFornitorePresetLabel($stabileId, $fornitoreId); }) ->visible(fn(callable $get): bool => (int) ($get('fornitore_id') ?? 0) > 0) ->columnSpanFull(), Textarea::make('descrizione') ->label('Descrizione operazione') ->rows(2) ->helperText('Usata nel mastrino e nella prima nota.') ->afterStateHydrated(function (callable $get, callable $set): void { $current = trim((string) ($get('descrizione') ?? '')); if ($current !== '') { return; } $set('descrizione', $this->buildDescrizioneOperazione($get)); }) ->nullable(), ]), Section::make('Ritenuta (RA)') ->schema([ Toggle::make('ra_enabled') ->label('Abilita RA') ->default(function (callable $get): bool { $imp = (float) ($get('ritenuta_importo') ?? 0); $aliq = (float) ($get('ritenuta_aliquota') ?? 0); $rt = (string) ($get('ritenuta_previdenziale_codice') ?? ''); $trib = (string) ($get('ritenuta_codice_tributo') ?? ''); return $imp > 0.005 || $aliq > 0.005 || $rt !== '' || $trib !== ''; }) ->live() ->afterStateUpdated(function ($state, callable $set): void { if (! $state) { $set('ritenuta_aliquota', null); $set('ritenuta_importo', 0); $set('ritenuta_previdenziale_codice', null); $set('ritenuta_codice_tributo', null); } }), TextInput::make('ritenuta_aliquota') ->label('Aliq. RA %') ->numeric() ->nullable() ->live() ->disabled(fn(callable $get): bool => ! (bool) ($get('ra_enabled') ?? false)) ->afterStateUpdated(function ($state, callable $get, callable $set): void { if (! is_numeric($state)) { return; } $aliq = (float) $state; if ($aliq <= 0) { $set('ritenuta_importo', 0); return; } $righe = $get('righe'); $imponibile = 0.0; if (is_array($righe)) { foreach ($righe as $r) { if (! is_array($r)) { continue; } $imponibile += (float) ($r['imponibile_euro'] ?? 0); } } $set('ritenuta_importo', round($imponibile * $aliq / 100.0, 2)); }), TextInput::make('ritenuta_importo') ->label('Importo RA €') ->numeric() ->default(0) ->required() ->disabled(fn(callable $get): bool => ! (bool) ($get('ra_enabled') ?? false)), Select::make('ritenuta_previdenziale_codice') ->label('Ritenuta previdenziale (RT..)') ->options(fn() => SchemaFacade::hasTable('fe_ritenute_previdenziali') ? FeRitenutaPrevidenziale::query() ->where('attivo', true) ->orderBy('codice') ->get(['codice', 'descrizione']) ->mapWithKeys(fn(FeRitenutaPrevidenziale $r) => [(string) $r->codice => $r->codice . ' - ' . $r->descrizione]) ->all() : []) ->searchable() ->nullable() ->disabled(fn(callable $get): bool => ! (bool) ($get('ra_enabled') ?? false)), Select::make('ritenuta_codice_tributo') ->label('Codice tributo (F24)') ->options(fn() => SchemaFacade::hasTable('fe_tributi_f24') ? FeTributoF24::query() ->where('attivo', true) ->orderBy('codice') ->get(['codice', 'descrizione']) ->mapWithKeys(fn(FeTributoF24 $t) => [(string) $t->codice => $t->codice . ' - ' . $t->descrizione]) ->all() : []) ->searchable() ->nullable() ->disabled(fn(callable $get): bool => ! (bool) ($get('ra_enabled') ?? false)), ]) ->columns(2), ]); } public function form(Schema $schema): Schema { return $schema ->statePath('data') ->components([ Placeholder::make('fe_descrizione') ->label('Descrizione FE / PDF') ->content(function (): string { if (! $this->record) { return '—'; } $state = is_array($this->data ?? null) ? $this->data : []; $descr = $this->buildDescrizioneOperazioneFromRecordState($state, $this->record); return $descr !== '' ? $descr : '—'; }) ->columnSpanFull(), Repeater::make('righe') ->label('Righe fattura') ->defaultItems(0) ->addActionLabel('Aggiungi riga') ->maxItems(fn(callable $get): ?int => (bool) ($get('fornitore_escludi_righe_fe') ?? false) ? 1 : null) ->collapsible() ->collapsed() ->itemLabel(function (array $state): string { $descrizione = trim((string) ($state['descrizione'] ?? '')); $totale = null; if (array_key_exists('totale_riga', $state) && is_numeric($state['totale_riga'])) { $totale = (float) $state['totale_riga']; } else { $totale = (float) ($state['imponibile_euro'] ?? 0) + (float) ($state['iva_euro'] ?? 0); } $totale = round((float) $totale, 2); $label = $descrizione !== '' ? $descrizione : 'Riga'; return $label . ' — € ' . number_format($totale, 2, ',', '.'); }) ->extraItemActions([ Action::make('dividi') ->label('Dividi') ->tooltip('Dividi') ->icon('heroicon-o-scissors') ->action(function (array $arguments, Repeater $component): void { $itemKey = $arguments['item'] ?? null; if (! is_string($itemKey) && ! is_int($itemKey)) { return; } $items = $component->getRawState() ?? []; if (! is_array($items) || ! array_key_exists($itemKey, $items) || ! is_array($items[$itemKey])) { return; } $item = $items[$itemKey]; $imponibile = (float) ($item['imponibile_euro'] ?? 0); $iva = (float) ($item['iva_euro'] ?? 0); $totale = round($imponibile + $iva, 2); $imponibileA = round($imponibile / 2, 2); $ivaA = round($iva / 2, 2); $imponibileB = round($imponibile - $imponibileA, 2); $ivaB = round($iva - $ivaA, 2); $itemA = $item; $itemA['imponibile_euro'] = $imponibileA; $itemA['iva_euro'] = $ivaA; $itemA['totale_riga'] = round($imponibileA + $ivaA, 2); $itemB = $item; $itemB['imponibile_euro'] = $imponibileB; $itemB['iva_euro'] = $ivaB; $itemB['totale_riga'] = round($imponibileB + $ivaB, 2); $newKey = $component->generateUuid(); $newItems = []; foreach ($items as $key => $value) { if ($key === $itemKey) { $newItems[$key] = $itemA; if ($newKey) { $newItems[$newKey] = $itemB; } else { $newItems[] = $itemB; } continue; } $newItems[$key] = $value; } $component->rawState($newItems); $component->collapsed(false, shouldMakeComponentCollapsible: false); $component->callAfterStateUpdated(); $component->shouldPartiallyRenderAfterActionsCalled() ? $component->partiallyRender() : null; }), ]) ->schema([ Textarea::make('descrizione') ->label('Descrizione FE / Riepilogo') ->rows(2) ->helperText('Riga documento da FE: visibile nel libro contabile.') ->columnSpanFull(), Select::make('voce_spesa_id') ->label('Voce spesa') ->searchable() ->required() ->placeholder('Seleziona (scorri o digita per cercare)') ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); if ($stabileId <= 0) { return []; } $gestioneId = (int) ($this->data['gestione_id'] ?? 0); $gestioneTipo = null; if ($gestioneId > 0) { $gestioneTipo = GestioneContabile::query()->whereKey($gestioneId)->value('tipo_gestione'); } $q = VoceSpesa::query() ->where('stabile_id', $stabileId) ->where('attiva', true) ->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione']) ->orderBy('ordinamento') ->orderBy('descrizione'); if (SchemaFacade::hasColumn('voci_spesa', 'gestione_contabile_id') && $gestioneId > 0) { $q->where('gestione_contabile_id', $gestioneId); } if ($gestioneTipo === 'straordinaria') { $q->where(function ($qq) use ($gestioneId) { $qq->where('tipo_gestione', 'straordinaria'); if ($gestioneId > 0) { $qq->orWhere('gestione_contabile_id', $gestioneId); } }); } return $q ->limit(50) ->get(['id', 'codice', 'descrizione', 'tipo_gestione', 'tabella_millesimale_default_id']) ->mapWithKeys(function (VoceSpesa $v) { $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $tipo = match ((string) ($v->tipo_gestione ?? '')) { 'ordinaria' => 'O', 'riscaldamento' => 'R', 'straordinaria' => 'S', default => strtoupper(substr((string) ($v->tipo_gestione ?? 'X'), 0, 1)), }; $label = trim((string) ($v->codice ?? '')) . ' - ' . trim((string) ($v->descrizione ?? '')); $label .= ' • ' . ($tab ? $tab : '—'); $label .= ' • ' . $tipo; return [(string) $v->id => $label]; }) ->all(); }) ->preload() ->getSearchResultsUsing(function (string $search): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); if ($stabileId <= 0) { return []; } $gestioneId = (int) ($this->data['gestione_id'] ?? 0); $search = trim($search); if ($search === '') { // Con preload() Filament può fare una search vuota all'apertura del menu; // se restituiamo di nuovo le stesse opzioni compaiono duplicate. return []; } $q = VoceSpesa::query() ->where('stabile_id', $stabileId) ->where('attiva', true) ->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione']); if (SchemaFacade::hasColumn('voci_spesa', 'gestione_contabile_id') && $gestioneId > 0) { $q->where('gestione_contabile_id', $gestioneId); } $gestioneTipo = null; if ($gestioneId > 0) { $gestioneTipo = GestioneContabile::query()->whereKey($gestioneId)->value('tipo_gestione'); } if ($gestioneTipo === 'straordinaria') { $q->where(function ($qq) use ($gestioneId) { $qq->where('tipo_gestione', 'straordinaria'); if ($gestioneId > 0) { $qq->orWhere('gestione_contabile_id', $gestioneId); } }); } $q->ricerca($search); return $q ->orderBy('ordinamento') ->orderBy('descrizione') ->limit(50) ->get(['id', 'codice', 'descrizione', 'tipo_gestione', 'tabella_millesimale_default_id', 'gestione_contabile_id']) ->mapWithKeys(function (VoceSpesa $v) { $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $tipo = match ((string) ($v->tipo_gestione ?? '')) { 'ordinaria' => 'O', 'riscaldamento' => 'R', 'straordinaria' => 'S', default => strtoupper(substr((string) ($v->tipo_gestione ?? 'X'), 0, 1)), }; $label = trim((string) ($v->codice ?? '')) . ' - ' . trim((string) ($v->descrizione ?? '')); $label .= ' • ' . ($tab ? $tab : '—'); $label .= ' • ' . $tipo; return [(string) $v->id => $label]; }) ->all(); }) ->getOptionLabelUsing(function ($value): ?string { if (! $value) { return null; } return $this->resolveVoceSpesaLabel((int) $value); }) ->live() ->afterStateUpdated(function ($state, callable $get, callable $set): void { if (! $state) { return; } $v = VoceSpesa::query()->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione'])->select(['id', 'codice', 'descrizione', 'tabella_millesimale_default_id', 'gestione_contabile_id', 'tipo_gestione'])->find((int) $state); if (! $v) { return; } $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $tipo = match ((string) ($v->tipo_gestione ?? '')) { 'ordinaria' => 'O', 'riscaldamento' => 'R', 'straordinaria' => 'S', default => strtoupper(substr((string) ($v->tipo_gestione ?? 'X'), 0, 1)), }; $label = trim((string) ($v->codice ?? '')) . ' - ' . trim((string) ($v->descrizione ?? '')); $label .= ' • ' . ($tab ? $tab : '—'); $label .= ' • ' . $tipo; $set('voce_spesa_label', $label); if (! $get('descrizione')) { $set('descrizione', (string) ($v->descrizione ?? '')); } if (! $get('tabella_millesimale_id') && $v->tabella_millesimale_default_id) { $set('tabella_millesimale_id', (int) $v->tabella_millesimale_default_id); } if (! $get('gestione_id') && $v->gestione_contabile_id) { $set('gestione_id', (int) $v->gestione_contabile_id); } // Preset: prima volta che scegliamo una voce per questo fornitore+stabile, chiediamo se renderla predefinita. $stabileId = $this->getActiveStabileIdFromState(); $fornitoreId = (int) ($this->data['fornitore_id'] ?? 0); $voceSpesaId = (int) $state; if ($stabileId <= 0 || $fornitoreId <= 0 || $voceSpesaId <= 0) { return; } if (! SchemaFacade::hasTable('fornitore_stabile_impostazioni')) { return; } $cacheKey = $stabileId . ':' . $fornitoreId; if (($this->promptedVoceSpesaPresetCache[$cacheKey] ?? false) === true) { return; } $settings = FornitoreStabileImpostazione::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->first(); $alreadySet = (int) ($settings?->voce_spesa_default_id ?? 0) > 0; if ($alreadySet) { return; } $this->promptedVoceSpesaPresetCache[$cacheKey] = true; Notification::make() ->title('Impostare preset?') ->body('Vuoi rendere questa voce spesa predefinita per questo fornitore su questo stabile?') ->actions([ Action::make('set_default') ->label('Sì, imposta preset') ->button() ->action(function () use ($stabileId, $fornitoreId, $voceSpesaId): void { $this->salvaPresetVoceSpesa($stabileId, $fornitoreId, $voceSpesaId); }), Action::make('skip_default') ->label('No') ->color('gray') ->close(), ]) ->info() ->send(); }), TextInput::make('voce_spesa_label') ->hidden() ->dehydrated(false) ->afterStateHydrated(function (callable $get, callable $set): void { $existing = trim((string) ($get('voce_spesa_label') ?? '')); $voceId = (int) ($get('voce_spesa_id') ?? 0); if ($existing !== '' || $voceId <= 0) { return; } $v = VoceSpesa::query()->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione'])->find($voceId); if (! $v) { return; } $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $tipo = match ((string) ($v->tipo_gestione ?? '')) { 'ordinaria' => 'O', 'riscaldamento' => 'R', 'straordinaria' => 'S', default => strtoupper(substr((string) ($v->tipo_gestione ?? 'X'), 0, 1)), }; $label = trim((string) ($v->codice ?? '')) . ' - ' . trim((string) ($v->descrizione ?? '')); $label .= ' • ' . ($tab ? $tab : '—'); $label .= ' • ' . $tipo; $set('voce_spesa_label', $label); }), TextInput::make('legacy_cod_spe') ->label('Codice spesa legacy (BOX)') ->maxLength(20) ->helperText('Es. AC1, AC2, I05, ecc. per riconciliazione con operazioni legacy.') ->columnSpan(1), TextInput::make('legacy_n_spe') ->label('N. spesa legacy (n_spe)') ->numeric() ->helperText('Numero operazione/voce legacy collegata alla riga.') ->columnSpan(1), TextInput::make('totale_riga') ->label('Importo') ->numeric() ->required() ->live() ->afterStateUpdated(function ($state, callable $get, callable $set): void { if (! is_numeric($state)) { return; } $tot = round((float) $state, 2); $aliq = is_numeric($get('aliquota_iva')) ? (float) $get('aliquota_iva') : 0.0; if ($aliq > 0) { $impon = round($tot / (1 + ($aliq / 100.0)), 2); $iva = round($tot - $impon, 2); $set('imponibile_euro', $impon); $set('iva_euro', $iva); } else { $set('imponibile_euro', $tot); $set('iva_euro', 0); } }) ->afterStateHydrated(function (callable $get, callable $set): void { $impon = (float) ($get('imponibile_euro') ?? 0); $iva = (float) ($get('iva_euro') ?? 0); $set('totale_riga', round($impon + $iva, 2)); }), TextInput::make('imponibile_euro')->hidden()->default(0)->dehydrated(true), TextInput::make('iva_euro')->hidden()->default(0)->dehydrated(true), TextInput::make('aliquota_iva')->hidden()->default(0)->dehydrated(true), Select::make('tabella_millesimale_id')->hidden()->nullable()->dehydrated(true), Select::make('gestione_id')->hidden()->nullable()->dehydrated(true), TextInput::make('percentuale_riparto')->hidden()->nullable()->dehydrated(true), Select::make('conto_costo_id')->hidden()->nullable()->dehydrated(true), ]), Tabs::make('tabs') ->tabs([ Tab::make('Generale') ->label('Generale') ->schema([ Textarea::make('note')->label('Note')->rows(2)->nullable(), ])->columns(2), Tab::make('Pagamento') ->label('Pagamento') ->schema([ DatePicker::make('data_scadenza')->label('Scadenza')->nullable(), Select::make('modalita_pagamento') ->label('Modalità pagamento') ->options(fn(): array=> $this->getXsdCodeOptions('ModalitaPagamentoType')) ->searchable() ->preload() ->nullable(), DatePicker::make('data_pagamento')->label('Data pagamento fattura')->nullable(), Select::make('movimento_pagamento_id') ->label('Movimento banca (pagamento fattura)') ->searchable() ->nullable() ->getSearchResultsUsing(function (string $search): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); if ($stabileId <= 0) { return []; } $q = MovimentoBanca::query()->where('stabile_id', $stabileId); $search = trim($search); if ($search !== '') { $q->where(function ($qq) use ($search) { $qq->where('descrizione', 'like', '%' . $search . '%') ->orWhere('iban', 'like', '%' . $search . '%'); }); } return $q ->orderByDesc('data') ->orderByDesc('id') ->limit(50) ->get(['id', 'data', 'importo', 'descrizione']) ->mapWithKeys(function (MovimentoBanca $m) { $data = $m->data ? $m->data->format('d/m/Y') : '—'; $imp = number_format((float) $m->importo, 2, ',', '.'); $descr = trim((string) $m->descrizione); if (mb_strlen($descr) > 60) { $descr = mb_substr($descr, 0, 60) . '…'; } return [(string) $m->id => $data . ' · € ' . $imp . ' · ' . $descr]; }) ->all(); }) ->getOptionLabelUsing(function ($value): ?string { if (! $value) { return null; } $m = MovimentoBanca::query()->find((int) $value); if (! $m) { return 'Movimento #' . (int) $value; } $data = $m->data ? $m->data->format('d/m/Y') : '—'; $imp = number_format((float) $m->importo, 2, ',', '.'); $descr = trim((string) $m->descrizione); if (mb_strlen($descr) > 60) { $descr = mb_substr($descr, 0, 60) . '…'; } return $data . ' · € ' . $imp . ' · ' . $descr; }), DatePicker::make('data_versamento_ra')->label("Data versamento RA")->nullable(), Select::make('movimento_versamento_ra_id') ->label('Movimento banca (versamento RA)') ->searchable() ->nullable() ->getSearchResultsUsing(function (string $search): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); if ($stabileId <= 0) { return []; } $q = MovimentoBanca::query()->where('stabile_id', $stabileId); $search = trim($search); if ($search !== '') { $q->where(function ($qq) use ($search) { $qq->where('descrizione', 'like', '%' . $search . '%') ->orWhere('iban', 'like', '%' . $search . '%'); }); } return $q ->orderByDesc('data') ->orderByDesc('id') ->limit(50) ->get(['id', 'data', 'importo', 'descrizione']) ->mapWithKeys(function (MovimentoBanca $m) { $data = $m->data ? $m->data->format('d/m/Y') : '—'; $imp = number_format((float) $m->importo, 2, ',', '.'); $descr = trim((string) $m->descrizione); if (mb_strlen($descr) > 60) { $descr = mb_substr($descr, 0, 60) . '…'; } return [(string) $m->id => $data . ' · € ' . $imp . ' · ' . $descr]; }) ->all(); }) ->getOptionLabelUsing(function ($value): ?string { if (! $value) { return null; } $m = MovimentoBanca::query()->find((int) $value); if (! $m) { return 'Movimento #' . (int) $value; } $data = $m->data ? $m->data->format('d/m/Y') : '—'; $imp = number_format((float) $m->importo, 2, ',', '.'); $descr = trim((string) $m->descrizione); if (mb_strlen($descr) > 60) { $descr = mb_substr($descr, 0, 60) . '…'; } return $data . ' · € ' . $imp . ' · ' . $descr; }), ])->columns(2), ]) ->columnSpanFull(), ]); } public function save(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $activeStabileId = StabileContext::resolveActiveStabileId($user); if (! $activeStabileId) { Notification::make()->title('Seleziona uno stabile')->danger()->send(); return; } $header = $this->getSchema('header')?->getState() ?? []; $form = $this->getSchema('form')?->getState() ?? []; $data = array_merge($form, $header); try { DB::transaction(function () use ($data, $activeStabileId) { $righe = $data['righe'] ?? []; $imponibile = 0.0; $iva = 0.0; foreach ($righe as $idx => $r) { $imp = (float) ($r['imponibile_euro'] ?? 0); $iv = (float) ($r['iva_euro'] ?? 0); $totR = is_numeric($r['totale_riga'] ?? null) ? (float) $r['totale_riga'] : round($imp + $iv, 2); if (abs($imp) < 0.005 && abs($iv) < 0.005 && $totR > 0) { $imp = $totR; $iv = 0.0; $righe[$idx]['imponibile_euro'] = $imp; $righe[$idx]['iva_euro'] = $iv; $righe[$idx]['totale_riga'] = $totR; } $imponibile += $imp; $iva += $iv; $voceId = isset($r['voce_spesa_id']) && is_numeric($r['voce_spesa_id']) ? (int) $r['voce_spesa_id'] : 0; if ($voceId <= 0) { throw new \RuntimeException('Ogni riga deve avere una voce di spesa selezionata.'); } } $totaleRighe = round((float) collect($righe)->sum(function ($r): float { if (is_array($r) && is_numeric($r['totale_riga'] ?? null)) { return (float) $r['totale_riga']; } if (is_array($r)) { return round((float) ($r['imponibile_euro'] ?? 0) + (float) ($r['iva_euro'] ?? 0), 2); } return 0.0; }), 2); $totaleTestata = round((float) ($data['totale'] ?? 0), 2); $delta = round($totaleTestata - $totaleRighe, 2); if ($delta != 0.0) { throw new \RuntimeException( 'Delta non nullo: totale testata € ' . number_format($totaleTestata, 2, '.', '') . ' / somma righe € ' . number_format($totaleRighe, 2, '.', '') . ' / delta € ' . number_format($delta, 2, '.', ''), ); } $ritenuta = round((float) ($data['ritenuta_importo'] ?? 0), 2); if ($ritenuta < 0) { throw new \RuntimeException('Importo RA non valido.'); } if ($ritenuta > $totaleTestata) { throw new \RuntimeException('Importo RA superiore al totale documento.'); } $netto = round($totaleTestata - $ritenuta, 2); $payload = [ 'stabile_id' => $activeStabileId, 'gestione_id' => ! empty($data['gestione_id']) ? (int) $data['gestione_id'] : null, 'fornitore_id' => (int) $data['fornitore_id'], 'data_registrazione' => $data['data_registrazione'] ?? null, 'data_documento' => $data['data_documento'] ?? null, 'numero_documento' => $data['numero_documento'] ?? null, 'causale_contabile' => $data['causale_contabile'] ?? null, 'descrizione' => $data['descrizione'] ?? null, 'valuta' => $data['valuta'] ?? 'EUR', 'cambio' => $data['cambio'] ?? 1, 'imponibile' => round($imponibile, 2), 'iva' => round($iva, 2), 'totale' => $totaleTestata, 'data_scadenza' => $data['data_scadenza'] ?? null, 'modalita_pagamento' => $data['modalita_pagamento'] ?? null, 'data_pagamento' => $data['data_pagamento'] ?? null, 'movimento_pagamento_id' => ! empty($data['movimento_pagamento_id']) ? (int) $data['movimento_pagamento_id'] : null, 'data_versamento_ra' => $data['data_versamento_ra'] ?? null, 'movimento_versamento_ra_id' => ! empty($data['movimento_versamento_ra_id']) ? (int) $data['movimento_versamento_ra_id'] : null, 'ritenuta_aliquota' => $data['ritenuta_aliquota'] ?? null, 'ritenuta_importo' => round($ritenuta, 2), 'ritenuta_codice_tributo' => $data['ritenuta_codice_tributo'] ?? null, 'ritenuta_previdenziale_codice' => $data['ritenuta_previdenziale_codice'] ?? null, 'netto_da_pagare' => $netto, 'stato' => $data['stato'] ?? 'inserito', 'note' => $data['note'] ?? null, ]; if ($this->record) { $this->record->fill($payload); $this->record->save(); } else { $this->record = FatturaFornitore::query()->create($payload); } $this->record->righe()->delete(); $voceIds = []; foreach ($righe as $idx => $r) { $voceId = ! empty($r['voce_spesa_id']) ? (int) $r['voce_spesa_id'] : null; $contoCostoId = $voceId ? $this->resolveContoCostoIdFromVoceSpesa($voceId) : null; if (! $contoCostoId) { throw new \RuntimeException('Voce spesa senza conto contabile collegato.'); } $this->record->righe()->create([ 'riga' => $r['riga'] ?? ($idx + 1), 'descrizione' => (string) ($r['descrizione'] ?? ''), 'imponibile_euro' => (float) ($r['imponibile_euro'] ?? 0), 'iva_euro' => (float) ($r['iva_euro'] ?? 0), 'totale_euro' => round(is_numeric($r['totale_riga'] ?? null) ? (float) $r['totale_riga'] : (((float) ($r['imponibile_euro'] ?? 0)) + ((float) ($r['iva_euro'] ?? 0))), 2), 'aliquota_iva' => $r['aliquota_iva'] ?? null, 'conto_costo_id' => $contoCostoId, 'voce_spesa_id' => $voceId, 'gestione_id' => ! empty($r['gestione_id']) ? (int) $r['gestione_id'] : null, 'percentuale_riparto' => isset($r['percentuale_riparto']) && is_numeric($r['percentuale_riparto']) ? (float) $r['percentuale_riparto'] : null, 'tabella_millesimale_id' => isset($r['tabella_millesimale_id']) && $r['tabella_millesimale_id'] ? (int) $r['tabella_millesimale_id'] : null, 'legacy_cod_spe' => isset($r['legacy_cod_spe']) ? trim((string) $r['legacy_cod_spe']) : null, 'legacy_n_spe' => isset($r['legacy_n_spe']) && is_numeric($r['legacy_n_spe']) ? (int) $r['legacy_n_spe'] : null, ]); if (! empty($r['voce_spesa_id']) && is_numeric($r['voce_spesa_id'])) { $voceIds[] = (int) $r['voce_spesa_id']; } } $gestioneId = ! empty($payload['gestione_id']) ? (int) $payload['gestione_id'] : 0; if ($gestioneId > 0 && $voceIds !== []) { $this->syncConsuntivoVociSpesa($activeStabileId, $gestioneId, array_values(array_unique($voceIds))); } }); } catch (\Throwable $e) { Notification::make()->title('Errore')->body($e->getMessage())->danger()->send(); return; } Notification::make()->title('Fattura salvata')->success()->send(); if ($this->record) { $this->redirect(static::getUrl(['record' => $this->record->id], panel: 'admin-filament')); } } private function toFormState(FatturaFornitore $fattura): array { return [ 'stabile_id' => $fattura->stabile_id, 'gestione_id' => $fattura->gestione_id, 'fornitore_id' => $fattura->fornitore_id, 'data_registrazione' => optional($fattura->data_registrazione)->toDateString(), 'data_documento' => optional($fattura->data_documento)->toDateString(), 'numero_documento' => $fattura->numero_documento, 'totale' => (float) $fattura->totale, 'causale_contabile' => $fattura->causale_contabile, 'descrizione' => $fattura->descrizione, 'valuta' => $fattura->valuta, 'cambio' => $fattura->cambio, 'data_scadenza' => optional($fattura->data_scadenza)->toDateString(), 'modalita_pagamento' => $fattura->modalita_pagamento, 'data_pagamento' => optional($fattura->data_pagamento)->toDateString(), 'movimento_pagamento_id' => $fattura->movimento_pagamento_id, 'data_versamento_ra' => optional($fattura->data_versamento_ra)->toDateString(), 'movimento_versamento_ra_id' => $fattura->movimento_versamento_ra_id, 'ritenuta_aliquota' => $fattura->ritenuta_aliquota, 'ritenuta_importo' => $fattura->ritenuta_importo, 'ritenuta_codice_tributo' => $fattura->ritenuta_codice_tributo, 'ritenuta_previdenziale_codice' => $fattura->ritenuta_previdenziale_codice, 'stato' => $fattura->stato, 'note' => $fattura->note, 'righe' => $fattura->righe->map(fn($r) => [ 'riga' => $r->riga, 'descrizione' => $r->descrizione, 'imponibile_euro' => (float) $r->imponibile_euro, 'iva_euro' => (float) $r->iva_euro, 'totale_riga' => round(((float) $r->imponibile_euro) + ((float) $r->iva_euro), 2), 'aliquota_iva' => $r->aliquota_iva, 'conto_costo_id' => $r->conto_costo_id, 'voce_spesa_id' => $r->voce_spesa_id ?? null, 'gestione_id' => $r->gestione_id ?? null, 'percentuale_riparto' => $r->percentuale_riparto ?? null, 'tabella_millesimale_id' => $r->tabella_millesimale_id, 'legacy_cod_spe' => $r->legacy_cod_spe ?? null, 'legacy_n_spe' => $r->legacy_n_spe ?? null, ])->all(), 'usa_conto_costo_predefinito' => false, 'conto_costo_predefinito_id' => null, ]; } /** * @param array $voceIds */ private function syncConsuntivoVociSpesa(int $stabileId, int $gestioneId, array $voceIds): void { if ($stabileId <= 0 || $gestioneId <= 0 || $voceIds === []) { return; } if (! SchemaFacade::hasColumn('voci_spesa', 'importo_consuntivo')) { return; } $sums = DB::table('contabilita_fatture_fornitori_righe as righe') ->join('contabilita_fatture_fornitori as fatture', 'fatture.id', '=', 'righe.fattura_id') ->where('fatture.stabile_id', $stabileId) ->where('fatture.gestione_id', $gestioneId) ->whereIn('righe.voce_spesa_id', $voceIds) ->selectRaw('righe.voce_spesa_id, SUM(righe.totale_euro) as totale') ->groupBy('righe.voce_spesa_id') ->get(); foreach ($sums as $row) { $voceId = is_numeric($row->voce_spesa_id) ? (int) $row->voce_spesa_id : 0; if ($voceId <= 0) { continue; } VoceSpesa::query()->whereKey($voceId)->update([ 'importo_consuntivo' => round((float) $row->totale, 2), ]); } } /** * @param array $state * @return array */ private function applyFeDefaultsToState(array $state, int $activeStabileId, FatturaFornitore $record): array { if (! $record->fattura_elettronica_id) { return $state; } $stabileId = (int) ($record->stabile_id ?: $activeStabileId); if ($stabileId <= 0) { $stabileId = $activeStabileId; } $fe = FatturaElettronica::query() ->where('stabile_id', $stabileId) ->find((int) $record->fattura_elettronica_id); if (! $fe) { return $state; } $parsed = null; try { $xml = (string) ($fe->xml_content ?? ''); if ($xml !== '') { $parsed = app(FatturaElettronicaXmlParser::class)->parse($xml); } } catch (\Throwable) { $parsed = null; } if (empty($state['fornitore_id']) && ! empty($fe->fornitore_id)) { $state['fornitore_id'] = (int) $fe->fornitore_id; } // Utenze/consumi: non importare/ricostruire righe da XML. $state['fornitore_escludi_righe_fe'] = $this->fornitoreEscludiRigheFe((int) ($state['fornitore_id'] ?? 0)); if (empty($state['data_documento']) && ! empty($fe->data_fattura)) { $state['data_documento'] = optional($fe->data_fattura)->toDateString(); } if (empty($state['numero_documento']) && is_string($fe->numero_fattura) && trim($fe->numero_fattura) !== '') { $state['numero_documento'] = trim($fe->numero_fattura); } if (empty($state['data_scadenza']) && ! empty($fe->data_scadenza)) { $state['data_scadenza'] = optional($fe->data_scadenza)->toDateString(); } $feModalitaPagamento = is_string($fe->pagamento_modalita) ? trim($fe->pagamento_modalita) : ''; if (empty($state['modalita_pagamento']) && $feModalitaPagamento !== '') { $state['modalita_pagamento'] = $feModalitaPagamento; } if (is_array($parsed)) { // Mappa TipoDocumento FE -> causale contabile. // Per ora: tutti i TD* (fatture) -> FTFO. $tipoDocumento = is_string($parsed['tipo_documento'] ?? null) ? strtoupper(trim((string) $parsed['tipo_documento'])) : ''; if (($state['causale_contabile'] ?? null) === null || (string) ($state['causale_contabile'] ?? '') === '') { if ($tipoDocumento !== '' && str_starts_with($tipoDocumento, 'TD')) { $state['causale_contabile'] = 'FTFO'; } } $pScadenza = $parsed['data_scadenza'] ?? null; if (empty($state['data_scadenza']) && ($pScadenza instanceof \DateTimeInterface)) { $state['data_scadenza'] = $pScadenza->toDateString(); } $pModalita = $parsed['pagamento_modalita'] ?? null; if (empty($state['modalita_pagamento']) && is_string($pModalita) && trim($pModalita) !== '') { $state['modalita_pagamento'] = trim($pModalita); } $rit = is_array($parsed['ritenuta'] ?? null) ? $parsed['ritenuta'] : null; if (is_array($rit)) { $tipo = is_string($rit['tipo'] ?? null) ? trim((string) $rit['tipo']) : ''; if (empty($state['ritenuta_previdenziale_codice']) && $tipo !== '') { $state['ritenuta_previdenziale_codice'] = $tipo; } if (((float) ($state['ritenuta_importo'] ?? 0)) <= 0 && is_numeric($rit['importo'] ?? null)) { $state['ritenuta_importo'] = round((float) $rit['importo'], 2); } if (((float) ($state['ritenuta_aliquota'] ?? 0)) <= 0 && is_numeric($rit['aliquota'] ?? null)) { $state['ritenuta_aliquota'] = round((float) $rit['aliquota'], 3); } if (empty($state['ritenuta_codice_tributo'])) { $causale = is_string($rit['causale'] ?? null) ? trim((string) $rit['causale']) : ''; $mapped = $this->mapFeRitenutaToTributo($tipo !== '' ? $tipo : null, $causale !== '' ? $causale : null); if ($mapped !== '') { $state['ritenuta_codice_tributo'] = $mapped; } } } if (! array_key_exists('ra_enabled', $state)) { $hasRa = ((float) ($state['ritenuta_importo'] ?? 0)) > 0 || ((float) ($state['ritenuta_aliquota'] ?? 0)) > 0 || ! empty($state['ritenuta_previdenziale_codice']) || ! empty($state['ritenuta_codice_tributo']); if ($hasRa) { $state['ra_enabled'] = true; } } // Ripara la quadratura: spesso il riepilogo IVA (DatiRiepilogo) non coincide con la somma DettaglioLinee. // Per utenze/consumi (escludi righe FE) NON aggiungiamo righe tecniche. if ((bool) ($state['fornitore_escludi_righe_fe'] ?? false)) { // no-op } else { $righeState = is_array($state['righe'] ?? null) ? $state['righe'] : []; $totaleTestata = null; if (isset($state['totale']) && is_numeric($state['totale'])) { $totaleTestata = (float) $state['totale']; } elseif (is_numeric($parsed['totale'] ?? null)) { $totaleTestata = (float) $parsed['totale']; } if ($totaleTestata !== null) { $sumRighe = 0.0; foreach ($righeState as $r) { if (! is_array($r)) { continue; } $sumRighe += (float) ($r['imponibile_euro'] ?? 0); $sumRighe += (float) ($r['iva_euro'] ?? 0); } $delta = round($totaleTestata - $sumRighe, 2); $parsedImponibile = is_numeric($parsed['imponibile'] ?? null) ? (float) $parsed['imponibile'] : null; $parsedIva = is_numeric($parsed['iva'] ?? null) ? (float) $parsed['iva'] : null; if ($delta != 0.0 && is_array($righeState) && count($righeState) >= 1 && $parsedImponibile !== null && $parsedIva !== null) { $sumLineImponibile = 0.0; $sumLineIva = 0.0; foreach ($righeState as $r) { if (! is_array($r)) { continue; } $sumLineImponibile += (float) ($r['imponibile_euro'] ?? 0); $sumLineIva += (float) ($r['iva_euro'] ?? 0); } $remImponibile = round($parsedImponibile - $sumLineImponibile, 2); $remIva = round($parsedIva - $sumLineIva, 2); $remTot = round($remImponibile + $remIva, 2); // Se il "buco" coincide col delta, aggiungiamo una riga di riepilogo per quadrare. if ($remTot != 0.0 && $remTot === $delta) { $aliq = 0.0; if (abs($remImponibile) > 0.005) { $aliq = round(($remIva / $remImponibile) * 100.0, 2); } $righeState[] = [ 'riga' => null, 'descrizione' => 'Riepilogo FE / arrotondamenti', 'imponibile_euro' => $remImponibile, 'iva_euro' => $remIva, 'totale_riga' => $remTot, 'aliquota_iva' => $aliq, 'conto_costo_id' => null, 'voce_spesa_id' => null, 'gestione_id' => null, 'percentuale_riparto' => null, 'tabella_millesimale_id' => null, ]; $state['righe'] = $righeState; } } } } } if (empty($state['gestione_id'])) { $year = null; if (! empty($state['data_documento']) && is_string($state['data_documento'])) { $year = (int) substr($state['data_documento'], 0, 4); } $defaultGestioneId = $this->resolveDefaultGestioneId($activeStabileId, $year); if ($defaultGestioneId > 0) { $state['gestione_id'] = $defaultGestioneId; } } if (empty($state['descrizione'])) { $state['descrizione'] = $this->buildDescrizioneOperazioneFromRecordState($state, $record); } $righeState = is_array($state['righe'] ?? null) ? $state['righe'] : []; if ($righeState !== []) { foreach ($righeState as $idx => $r) { if (! is_array($r)) { continue; } $desc = trim((string) ($r['descrizione'] ?? '')); if ($desc === '' && ! empty($state['descrizione'])) { $r['descrizione'] = (string) $state['descrizione']; $righeState[$idx] = $r; } } $state['righe'] = $righeState; } return $state; } private function mapFeRitenutaToTributo(?string $rtCode, ?string $causale): string { $rtCode = $rtCode ? strtoupper(trim($rtCode)) : ''; $causale = $causale ? strtoupper(trim($causale)) : ''; if (in_array($causale, ['1019', '1020', '1040'], true)) { return $causale; } return match ($rtCode) { 'RT02' => '1020', 'RT01' => '1019', 'RT03' => '1040', default => '', }; } private function resolveRiscaldamentoGestioneByDate(int $stabileId, string $date): int { try { $dt = Carbon::parse($date); } catch (\Throwable) { return 0; } $q = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('tipo_gestione', 'riscaldamento'); $match = (clone $q) ->whereNotNull('data_inizio') ->whereNotNull('data_fine') ->whereDate('data_inizio', '<=', $dt->toDateString()) ->whereDate('data_fine', '>=', $dt->toDateString()) ->orderByDesc('gestione_attiva') ->orderByDesc('id') ->first(); if ($match) { return (int) $match->id; } $anno = $dt->year; $targetAnno = $dt->month >= 9 ? $anno : ($anno - 1); $fallback = (clone $q) ->where('anno_gestione', $targetAnno) ->orderByDesc('gestione_attiva') ->orderByDesc('id') ->first(); return $fallback?->id ? (int) $fallback->id : 0; } private function resolveDefaultGestioneId(int $stabileId, ?int $year = null): int { $stabileId = (int) $stabileId; if ($stabileId <= 0) { return 0; } $baseQuery = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('stato', 'aperta'); if ($year) { $baseQuery->where('anno_gestione', $year); } $ordinariaId = (clone $baseQuery) ->where('tipo_gestione', 'ordinaria') ->orderByDesc('anno_gestione') ->orderByDesc('id') ->value('id'); if (is_numeric($ordinariaId) && (int) $ordinariaId > 0) { return (int) $ordinariaId; } $fallbackId = $baseQuery ->orderByDesc('anno_gestione') ->orderByDesc('id') ->value('id'); return is_numeric($fallbackId) ? (int) $fallbackId : 0; } public function getLinkedFatturaElettronica(): ?FatturaElettronica { if ($this->linkedFeCache !== null) { return $this->linkedFeCache; } if (! $this->record || ! $this->record->fattura_elettronica_id) { return null; } $stabileId = (int) ($this->record->stabile_id ?? 0); if ($stabileId <= 0) { return null; } $this->linkedFeCache = FatturaElettronica::query() ->where('stabile_id', $stabileId) ->find((int) $this->record->fattura_elettronica_id); return $this->linkedFeCache; } /** @return array */ public function getLinkedFeXmlDetails(): array { if (is_array($this->linkedFeParsedCache)) { return $this->linkedFeParsedCache; } $fe = $this->getLinkedFatturaElettronica(); if (! $fe) { $this->linkedFeParsedCache = []; return []; } $xml = is_string($fe->xml_content) ? $fe->xml_content : ''; if (trim($xml) === '') { $this->linkedFeParsedCache = []; return []; } try { $parsed = app(FatturaElettronicaXmlParser::class)->parse($xml); $this->linkedFeParsedCache = is_array($parsed) ? $parsed : []; } catch (\Throwable) { $this->linkedFeParsedCache = []; } return $this->linkedFeParsedCache; } /** * @param array $state * @return array */ private function applyFornitoreDefaultsToState(array $state, int $activeStabileId, FatturaFornitore $record): array { if (! SchemaFacade::hasTable('fornitore_stabile_impostazioni')) { return $state; } $fornitoreId = (int) ($state['fornitore_id'] ?? 0); if ($fornitoreId <= 0) { $fornitoreId = (int) ($record->fornitore_id ?? 0); } if ($fornitoreId <= 0) { return $state; } $settings = FornitoreStabileImpostazione::query() ->where('stabile_id', $activeStabileId) ->where('fornitore_id', $fornitoreId) ->first(); if (! $settings) { return $state; } $defaultCostAccountId = (int) ($settings->conto_costo_default_id ?? 0); $defaultVoceSpesaId = (int) ($settings->voce_spesa_default_id ?? 0); if ($defaultCostAccountId > 0) { if (empty($state['conto_costo_predefinito_id'])) { $state['conto_costo_predefinito_id'] = $defaultCostAccountId; } if (empty($state['usa_conto_costo_predefinito'])) { $state['usa_conto_costo_predefinito'] = true; } if (isset($state['righe']) && is_array($state['righe'])) { $state['righe'] = array_map(function ($r) use ($defaultCostAccountId) { if (! is_array($r)) { return $r; } $has = isset($r['conto_costo_id']) && is_numeric($r['conto_costo_id']) && (int) $r['conto_costo_id'] > 0; if (! $has) { $r['conto_costo_id'] = $defaultCostAccountId; } return $r; }, $state['righe']); } } if ($defaultVoceSpesaId > 0) { if (isset($state['righe']) && is_array($state['righe'])) { $state['righe'] = array_map(function ($r) use ($defaultVoceSpesaId) { if (! is_array($r)) { return $r; } $has = isset($r['voce_spesa_id']) && is_numeric($r['voce_spesa_id']) && (int) $r['voce_spesa_id'] > 0; if (! $has) { $r['voce_spesa_id'] = $defaultVoceSpesaId; } return $r; }, $state['righe']); } } return $state; } /** * @return array|null */ public function getFornitoreOverview(): ?array { if (! $this->record || ! $this->record->stabile_id || ! $this->record->fornitore_id) { return null; } $stabileId = (int) $this->record->stabile_id; $fornitoreId = (int) $this->record->fornitore_id; $totNetto = (float) FatturaFornitore::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->sum('netto_da_pagare'); $pagatoNetto = (float) FatturaFornitore::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->where('stato', 'pagato') ->sum('netto_da_pagare'); $apertoNetto = round($totNetto - $pagatoNetto, 2); $ultimaData = FatturaFornitore::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->max('data_documento'); $ultimaDataFormatted = null; if ($ultimaData) { try { $ultimaDataFormatted = Carbon::parse($ultimaData)->format('d/m/Y'); } catch (\Throwable) { $ultimaDataFormatted = null; } } $ultimeNote = FatturaFornitore::query() ->where('stabile_id', $stabileId) ->where('fornitore_id', $fornitoreId) ->whereNotNull('note') ->where('note', '!=', '') ->orderByDesc('data_documento') ->limit(5) ->get(['id', 'data_documento', 'note']) ->map(fn(FatturaFornitore $f) => [ 'id' => (int) $f->id, 'data_documento' => optional($f->data_documento)->format('d/m/Y'), 'note' => (string) $f->note, ]) ->all(); return [ 'tot_netto' => round($totNetto, 2), 'pagato_netto' => round($pagatoNetto, 2), 'aperto_netto' => $apertoNetto, 'ultima_data_documento' => $ultimaDataFormatted, 'ultime_note' => $ultimeNote, ]; } /** * Tabelle/"spese" straordinarie legacy (gescon_import.straordinarie): * vogliamo vederle TUTTE (es. 4 in 0001 + 5 in 0003 = 9), quindi nessun filtro per legacy_year. * * @return array */ public function getStraordinarieLegacyTabelleList(): array { $stabileId = $this->getActiveStabileIdFromState(); if ($stabileId <= 0) { return []; } if (! SchemaFacade::connection('gescon_import')->hasTable('straordinarie')) { return []; } $selectColumns = ['id']; if (SchemaFacade::hasColumn('stabili', 'codice_operatore')) { $selectColumns[] = 'codice_operatore'; } if (SchemaFacade::hasColumn('stabili', 'cod_stabile')) { $selectColumns[] = 'cod_stabile'; } if (SchemaFacade::hasColumn('stabili', 'codice_stabile')) { $selectColumns[] = 'codice_stabile'; } $stabile = Stabile::query()->whereKey($stabileId)->first($selectColumns); if (! $stabile) { return []; } $codStabileLegacy = null; if (isset($stabile->codice_operatore) && is_string($stabile->codice_operatore)) { $codStabileLegacy = $stabile->codice_operatore; } elseif (isset($stabile->cod_stabile) && is_string($stabile->cod_stabile)) { $codStabileLegacy = $stabile->cod_stabile; } elseif (isset($stabile->codice_stabile) && is_string($stabile->codice_stabile)) { $codStabileLegacy = $stabile->codice_stabile; } if (! is_string($codStabileLegacy) || trim($codStabileLegacy) === '') { return []; } $q = DB::connection('gescon_import')->table('straordinarie') ->where('cod_stabile', trim($codStabileLegacy)) ->whereNotNull('id_stra') ->where('id_stra', '>', 0); // NOTA: nessun filtro per legacy_year (serve la lista completa) return $q ->distinct() ->orderBy('id_stra') ->get(['id_stra', 'descriz_prev_cons', 'descriz_ricev', 'codice']) ->map(function ($r): array { $n = is_numeric($r->id_stra ?? null) ? (int) $r->id_stra : 0; $tit = trim((string) ($r->descriz_prev_cons ?? '')); if ($tit === '') { $tit = trim((string) ($r->descriz_ricev ?? '')); } if ($tit === '') { $tit = trim((string) ($r->codice ?? '')); } if ($tit === '') { $tit = 'Straordinaria #' . $n; } return ['n_stra' => $n, 'titolo' => $tit]; }) ->filter(fn(array $row) => ($row['n_stra'] ?? 0) > 0) ->values() ->all(); } /** * Tabelle millesimali (ordinaria/riscaldamento) + voci spesa associate. * * @return array */ public function getTabelleMillesimaliByTipo(string $tipoTabella): array { $tipoTabella = trim(strtolower($tipoTabella)); if (! in_array($tipoTabella, ['ordinaria', 'riscaldamento'], true)) { return []; } $stabileId = $this->getActiveStabileIdFromState(); if ($stabileId <= 0) { return []; } return TabellaMillesimale::query() ->where('stabile_id', $stabileId) ->where('tipo_tabella', $tipoTabella) ->orderBy('ordinamento') ->orderBy('codice_tabella') ->orderBy('nome_tabella') ->get(['id', 'denominazione', 'nome_tabella', 'codice_tabella']) ->map(function (TabellaMillesimale $t): array { $label = trim((string) ($t->denominazione ?? '')); if ($label === '') { $label = trim((string) ($t->nome_tabella ?? '')); } if ($label === '') { $label = trim((string) ($t->codice_tabella ?? '')); } if ($label === '') { $label = 'Tabella #' . $t->id; } return ['id' => (int) $t->id, 'label' => $label]; }) ->all(); } /** @return array */ public function getVociSpesaByTabellaMillesimale(int $tabellaId): array { $tabellaId = (int) $tabellaId; if ($tabellaId <= 0) { return []; } $stabileId = $this->getActiveStabileIdFromState(); if ($stabileId <= 0) { return []; } $gestioneId = (int) ($this->data['gestione_id'] ?? ($this->record?->gestione_id ?? 0)); return VoceSpesa::query() ->where('stabile_id', $stabileId) ->where('attiva', true) ->where('tabella_millesimale_default_id', $tabellaId) ->when(SchemaFacade::hasColumn('voci_spesa', 'gestione_contabile_id') && $gestioneId > 0, function ($q) use ($gestioneId) { $q->where('gestione_contabile_id', $gestioneId); }) ->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione']) ->orderBy('ordinamento') ->orderBy('codice') ->orderBy('descrizione') ->get(['id', 'codice', 'descrizione', 'tabella_millesimale_default_id']) ->map(function (VoceSpesa $v): array { $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $code = trim((string) ($v->codice ?? '')); $desc = trim((string) ($v->descrizione ?? '')); $label = ($code !== '' ? $code : ('Voce #' . $v->id)) . ' — ' . ($desc !== '' ? $desc : '—'); return ['id' => (int) $v->id, 'label' => $label, 'tabella' => $tab]; }) ->all(); } /** * Voci spesa associate ad una specifica straordinaria (numero N_STRA). * In pratica: voci che appartengono a gestioni_contabili.straordinaria con numero_straordinaria = N. * * @return array */ public function getVociSpesaByNumeroStraordinaria(int $numeroStraordinaria): array { $numeroStraordinaria = (int) $numeroStraordinaria; if ($numeroStraordinaria <= 0) { return []; } $stabileId = $this->getActiveStabileIdFromState(); if ($stabileId <= 0) { return []; } $selectedGestioneId = (int) ($this->data['gestione_id'] ?? 0); $selectedYear = null; if ($selectedGestioneId > 0) { $y = GestioneContabile::query()->whereKey($selectedGestioneId)->value('anno_gestione'); if (is_numeric($y)) { $selectedYear = (int) $y; } } $gestioniQuery = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('tipo_gestione', 'straordinaria') ->where('numero_straordinaria', $numeroStraordinaria); // IMPORTANTE: se l'utente sta lavorando in una gestione specifica, evita collisioni tra anni diversi // (stessa numero_straordinaria su 2024/2025) mostrando solo l'anno selezionato. if (is_int($selectedYear) && $selectedYear > 0) { $gestioniQuery->where('anno_gestione', $selectedYear); } $gestioneIds = $gestioniQuery ->pluck('id') ->map(fn($v) => (int) $v) ->filter(fn($v) => $v > 0) ->values() ->all(); if (empty($gestioneIds)) { return []; } return VoceSpesa::query() ->where('stabile_id', $stabileId) ->where('attiva', true) ->whereIn('gestione_contabile_id', $gestioneIds) ->with(['tabellaMillesimaleDefault:id,codice_tabella,nome_tabella,denominazione']) ->orderBy('ordinamento') ->orderBy('codice') ->orderBy('descrizione') ->get(['id', 'codice', 'descrizione', 'tabella_millesimale_default_id', 'gestione_contabile_id']) ->map(function (VoceSpesa $v): array { $tab = $v->tabellaMillesimaleDefault?->denominazione ?: ($v->tabellaMillesimaleDefault?->nome_tabella ?: ($v->tabellaMillesimaleDefault?->codice_tabella ?: null)); $code = trim((string) ($v->codice ?? '')); $desc = trim((string) ($v->descrizione ?? '')); $label = ($code !== '' ? $code : ('Voce #' . $v->id)) . ' — ' . ($desc !== '' ? $desc : '—'); $label .= ' • ' . ($tab ? $tab : '(!) NO TABELLA'); return ['id' => (int) $v->id, 'label' => $label, 'tabella' => $tab]; }) ->all(); } }