netgescon-day0/app/Filament/Pages/Contabilita/FatturaFornitoreScheda.php

3201 lines
143 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Filament\Pages\Contabilita;
use App\Models\FatturaElettronica;
use App\Models\FeRitenutaPrevidenziale;
use App\Models\FeTributoF24;
use App\Models\FeXsdCode;
use App\Models\FeXsdCodeDomain;
use App\Models\FeXsdVersion;
use App\Models\Fornitore;
use App\Models\FornitoreStabileImpostazione;
use App\Models\GestioneContabile;
use App\Models\Stabile;
use App\Models\TabellaMillesimale;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\PianoConti;
use App\Modules\Contabilita\Services\FatturaFornitorePrimaNotaService;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Support\StabileContext;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema as SchemaFacade;
use Illuminate\Support\Str;
class FatturaFornitoreScheda extends Page implements HasForms, HasActions
{
use InteractsWithForms;
use InteractsWithActions;
/** @var array<int, bool> */
private array $fornitoreEscludiRigheFeCache = [];
/** @var array<string, bool> */
private array $promptedVoceSpesaPresetCache = [];
/** @var array<string, bool> */
private array $promptedContoCostoPresetCache = [];
/** @var array<string, array<string, string>> */
private array $xsdOptionsCache = [];
/** @var array<int, string> */
private array $voceSpesaLabelCache = [];
private ?FatturaElettronica $linkedFeCache = null;
/** @var array<string, mixed>|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<string, mixed> $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<string, string> */
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<string, mixed> $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<string, string> */
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<int, array{id:int, label:string}> */
public function getGestioniStraordinarieList(): array
{
$stabileId = $this->getActiveStabileIdFromState();
if ($stabileId <= 0) {
return [];
}
$stabile = Stabile::query()->find($stabileId);
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('tipo_gestione', 'straordinaria')
->where('stato', 'aperta')
->orderByDesc('anno_gestione')
->orderByDesc('numero_straordinaria')
->orderByDesc('id')
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'numero_straordinaria', 'stato'])
->filter(fn(GestioneContabile $g): bool => $this->isGestioneVisibleForStabile($g, $stabile))
->map(fn(GestioneContabile $g): array=> ['id' => (int) $g->id, 'label' => $this->formatGestioneLabel($g, $stabile)])
->all();
}
/** @return array<int, array{id:int, codice:string, descrizione:string, tabella:?string, label:string}> */
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(fn(): array=> $this->getVisibleGestioneOptions())
->searchable()
->required()
->helperText('Solo gestioni aperte e coerenti con lo stabile attivo.'),
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<int> $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<string, mixed> $state
* @return array<string, mixed>
*/
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
{
$stabile = Stabile::query()->find($stabileId);
if ($stabile instanceof Stabile && ! $stabile->hasOperationalHeating()) {
return 0;
}
try {
$dt = Carbon::parse($date);
} catch (\Throwable) {
return 0;
}
$q = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('tipo_gestione', 'riscaldamento')
->where('stato', 'aperta');
$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;
}
$stabile = Stabile::query()->find($stabileId);
$baseQuery = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta');
if ($year) {
$baseQuery->where('anno_gestione', $year);
}
if ($stabile instanceof Stabile) {
if (! $stabile->hasOperationalHeating()) {
$baseQuery->where('tipo_gestione', '!=', 'riscaldamento');
}
if ($stabile->getTakeoverDate()) {
$baseQuery->where('anno_gestione', '>=', (int) $stabile->getTakeoverDate()->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;
}
/** @return array<string, string> */
private function getVisibleGestioneOptions(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
$stabileId = (int) ($this->record?->stabile_id ?: $activeStabileId);
if ($stabileId <= 0) {
return [];
}
$stabile = Stabile::query()->find($stabileId);
return GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta')
->orderByDesc('anno_gestione')
->orderByDesc('numero_straordinaria')
->orderByDesc('id')
->get(['id', 'anno_gestione', 'tipo_gestione', 'denominazione', 'protocollo_prefix', 'stato', 'numero_straordinaria'])
->filter(fn(GestioneContabile $g): bool => $this->isGestioneVisibleForStabile($g, $stabile))
->mapWithKeys(fn(GestioneContabile $g) => [(string) $g->id => $this->formatGestioneLabel($g, $stabile)])
->all();
}
private function isGestioneVisibleForStabile(GestioneContabile $gestione, ?Stabile $stabile): bool
{
if (trim((string) ($gestione->stato ?? '')) !== 'aperta') {
return false;
}
if ($stabile instanceof Stabile) {
if ((string) ($gestione->tipo_gestione ?? '') === 'riscaldamento' && ! $stabile->hasOperationalHeating()) {
return false;
}
$year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null;
if (! $stabile->isOperationalYearVisible($year)) {
return false;
}
}
return true;
}
private function formatGestioneLabel(GestioneContabile $gestione, ?Stabile $stabile): string
{
$label = trim((string) ($gestione->denominazione ?? ''));
if ($label === '') {
$label = (string) $gestione->anno_gestione . ' - ' . (string) $gestione->tipo_gestione;
}
if ((string) ($gestione->tipo_gestione ?? '') === 'straordinaria' && is_numeric($gestione->numero_straordinaria) && (int) $gestione->numero_straordinaria > 0) {
$label .= ' (N.' . (int) $gestione->numero_straordinaria . ')';
}
$prefix = trim((string) ($gestione->protocollo_prefix ?? ''));
if ($prefix !== '') {
$label = $prefix . ' — ' . $label;
}
if ($stabile instanceof Stabile && $stabile->getTakeoverDate()) {
$year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null;
if (is_int($year) && $year < (int) $stabile->getTakeoverDate()->year) {
$label .= ' • pre-presa in carico';
}
}
return $label;
}
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<string, mixed> */
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<string, mixed> $state
* @return array<string, mixed>
*/
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<string, mixed>|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,
];
}
/**
* @return array<int, array{id:int, codice:string, descrizione:string, is_default:bool}>
*/
public function getSuggerimentiVociSpesa(): array
{
if (! $this->record || ! $this->record->stabile_id || ! $this->record->fornitore_id) {
return [];
}
$stabileId = (int) $this->record->stabile_id;
$fornitoreId = (int) $this->record->fornitore_id;
$impostazione = \App\Models\FornitoreStabileImpostazione::query()
->where('stabile_id', $stabileId)
->where('fornitore_id', $fornitoreId)
->first();
$defaultVoceId = $impostazione?->voce_spesa_default_id;
if (! $impostazione) {
$fornitore = \App\Models\Fornitore::query()->find($fornitoreId);
if ($fornitore) {
app(\App\Services\Arera\AreraFornitoreClassifier::class)->applyDefaultStableMapping($fornitore, $stabileId);
$impostazione = \App\Models\FornitoreStabileImpostazione::query()
->where('stabile_id', $stabileId)
->where('fornitore_id', $fornitoreId)
->first();
$defaultVoceId = $impostazione?->voce_spesa_default_id;
}
}
$settori = [];
if ($impostazione && is_array($impostazione->meta ?? null)) {
$settori = array_map('mb_strtolower', (array) ($impostazione->meta['arera_settori'] ?? []));
}
$query = \App\Models\VoceSpesa::query()
->where('stabile_id', $stabileId)
->where('attiva', true);
$isGas = collect($settori)->contains(fn(string $s): bool => str_contains($s, 'gas')) ||
($impostazione && ($impostazione->meta['service_supplier_kind'] ?? '') === 'gas');
$isAcqua = collect($settori)->contains(fn(string $s): bool => str_contains($s, 'idrico') || str_contains($s, 'acqua'));
$isElettrico = collect($settori)->contains(fn(string $s): bool => str_contains($s, 'elettr'));
$query->where(function($q) use ($isGas, $isAcqua, $isElettrico, $defaultVoceId): void {
if ($defaultVoceId) {
$q->orWhere('id', $defaultVoceId);
}
if ($isGas) {
$q->orWhere('tipo_gestione', 'riscaldamento')
->orWhere('tipo', 'riscaldamento')
->orWhere('categoria', 'gas')
->orWhere('categoria', 'riscaldamento')
->orWhere('codice', 'like', 'R%')
->orWhere('codice', 'like', 'RL%');
}
if ($isAcqua) {
$q->orWhere('categoria', 'acqua')
->orWhere('codice', 'like', 'A%');
}
if ($isElettrico) {
$q->orWhere('categoria', 'energia_elettrica')
->orWhere('codice', 'like', 'E%')
->orWhere('codice', 'like', 'L%');
}
});
$voci = $query->orderBy('codice')->get();
$rows = [];
foreach ($voci as $voce) {
$rows[] = [
'id' => (int) $voce->id,
'codice' => (string) $voce->codice,
'descrizione' => (string) $voce->descrizione,
'is_default' => $voce->id === $defaultVoceId,
];
}
return $rows;
}
public function applicaVoceSpesaSuggerita(int $voceId): void
{
$righe = is_array($this->data['righe'] ?? null) ? $this->data['righe'] : [];
if ($righe === []) {
$newKey = 'item_' . uniqid();
$righe[$newKey] = [
'descrizione' => '',
'imponibile_euro' => null,
'iva_euro' => null,
'totale_euro' => null,
'aliquota_iva' => null,
'voce_spesa_id' => $voceId,
'voce_spesa_label' => $this->resolveVoceSpesaLabel($voceId),
];
} else {
$found = false;
foreach ($righe as $key => $item) {
if (empty($item['voce_spesa_id'])) {
$righe[$key]['voce_spesa_id'] = $voceId;
$righe[$key]['voce_spesa_label'] = $this->resolveVoceSpesaLabel($voceId);
$found = true;
break;
}
}
if (! $found) {
$newKey = 'item_' . uniqid();
$righe[$newKey] = [
'descrizione' => '',
'imponibile_euro' => null,
'iva_euro' => null,
'totale_euro' => null,
'aliquota_iva' => null,
'voce_spesa_id' => $voceId,
'voce_spesa_label' => $this->resolveVoceSpesaLabel($voceId),
];
}
}
$this->data['righe'] = $righe;
Notification::make()
->title('Voce applicata')
->body('La voce di spesa è stata aggiunta/applicata alle righe della fattura.')
->success()
->send();
}
/**
* 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<int, array{n_stra:int, titolo:string}>
*/
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<int, array{id:int, label:string}>
*/
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)
->ordinato()
->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<int, array{id:int, label:string, tabella:?string}> */
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<int, array{id:int, label:string, tabella:?string}>
*/
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();
}
}