feat: miglioramenti stabili, millesimi, ripartizioni e inserimento docs/DEVELOPER_GUIDE.md

This commit is contained in:
michele 2026-07-04 10:47:30 +02:00
parent 9bc1a481c9
commit 6f7b26a38d
67 changed files with 13466 additions and 1119 deletions

View File

@ -156,6 +156,7 @@ public function handle(): int
$metaArr = is_array($metaArr) ? $metaArr : [];
$metaCodCond = $this->normalizeCodCondRaw(data_get($metaArr, 'cod_cond'));
$metaCodCondNum = $this->normalizeCodCond(data_get($metaArr, 'cod_cond'));
if ($ruolo === 'inquilino' && $unitaId && ($inquilinoMarker[$unitaId] ?? false)) {
if (! $metaCodCond || ! $this->isInquilinoCodCond($metaCodCond)) {
@ -207,41 +208,18 @@ public function handle(): int
->where('interno', $interno);
}
$legacyAny = null;
$legacyAny = null;
if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) {
$legacyAny = (clone $legacyBase)
->where('legacy_year', $legacyDir)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'proprietario',
'inquilino',
'cognome',
'nome',
'nom_cond',
'cond_cod_fisc',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
->first();
}
if (!$legacyAny) {
$legacyAny = (clone $legacyBase)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'proprietario',
'inquilino',
'cognome',
'nome',
'nom_cond',
'cond_cod_fisc',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
->first();
}
if (!$legacyAny) {
@ -258,14 +236,7 @@ public function handle(): int
$ownerRow = $ownerRowQ
->where('proprietario', 1)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'nom_cond',
'nome',
'cognome',
'cond_cod_fisc',
]);
->first();
}
if (!$ownerRow && Schema::connection($conn)->hasColumn('condomin', 'proprietario')) {
// Fallback: se per l'anno richiesto non esiste la riga proprietario=1,
@ -273,14 +244,7 @@ public function handle(): int
$ownerRow = (clone $legacyBase)
->where('proprietario', 1)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'nom_cond',
'nome',
'cognome',
'cond_cod_fisc',
]);
->first();
}
if (!$ownerRow) {
$ownerRow = $legacyAny;
@ -295,35 +259,36 @@ public function handle(): int
$tenantRow = (clone $tenantRowQ)
->where('inquilino', 1)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
->first();
}
if (!$tenantRow) {
// Fallback: tenant presente anche se flag incoerente (campi identità valorizzati)
$tenantRow = (clone $tenantRowQ)
->where(function ($w) {
$w->whereNotNull('inquilino_denominazione')
->where('inquilino_denominazione', '!=', '')
->orWhere(function ($w2) {
$w2->whereNotNull('inquil_nome')->where('inquil_nome', '!=', '');
})
->orWhere(function ($w3) {
$w3->whereNotNull('inquil_cod_fisc')->where('inquil_cod_fisc', '!=', '');
});
->where(function ($w) use ($conn) {
$schema = Schema::connection($conn);
$hasInqDen = $schema->hasColumn('condomin', 'inquilino_denominazione');
$hasInqNome = $schema->hasColumn('condomin', 'inquil_nome');
$hasInqCf = $schema->hasColumn('condomin', 'inquil_cod_fisc');
$conditions = false;
if ($hasInqDen) {
$w->orWhere(fn($q) => $q->whereNotNull('inquilino_denominazione')->where('inquilino_denominazione', '!=', ''));
$conditions = true;
}
if ($hasInqNome) {
$w->orWhere(fn($q) => $q->whereNotNull('inquil_nome')->where('inquil_nome', '!=', ''));
$conditions = true;
}
if ($hasInqCf) {
$w->orWhere(fn($q) => $q->whereNotNull('inquil_cod_fisc')->where('inquil_cod_fisc', '!=', ''));
$conditions = true;
}
if (! $conditions) {
$w->whereRaw('1=0');
}
})
->orderByDesc('id')
->first([
'id',
'legacy_year',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
->first();
}
$rubricaName = $this->normalizeIdentityStr($rr->ragione_sociale ?: trim((string) ($rr->nome ?? '') . ' ' . (string) ($rr->cognome ?? '')));

View File

@ -52,6 +52,11 @@ public function handle(): int
[$stabile?->codice_stabile ?? null, $stabile?->cod_stabile ?? null]
))));
if (! Schema::connection('gescon_import')->hasTable('operazioni')) {
$this->warn('Tabella di staging "operazioni" non trovata. Nessun movimento da riallineare.');
return 0;
}
$query = DB::connection('gescon_import')->table('operazioni');
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile') && $legacyStableCodes !== []) {
@ -69,8 +74,12 @@ public function handle(): int
}
if ($year) {
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'legacy_year')) {
$query->where('legacy_year', (string) $year);
} else {
$query->whereYear('dt_spe', $year);
}
}
if ($tipo !== 'all') {
$gestioneCode = match ($tipo) {
@ -84,10 +93,8 @@ public function handle(): int
}
}
$rows = $query
->orderBy('dt_spe')
->orderBy('id_operaz')
->get([
$availableColumns = Schema::connection('gescon_import')->getColumnListing('operazioni');
$desiredColumns = [
'id_operaz',
'dt_spe',
'benef',
@ -106,7 +113,20 @@ public function handle(): int
'compet',
'cod_stabile',
'legacy_year',
]);
];
$selectColumns = [];
foreach ($desiredColumns as $col) {
if (in_array($col, $availableColumns, true)) {
$selectColumns[] = $col;
} else {
$selectColumns[] = DB::raw("NULL as `{$col}`");
}
}
$rows = $query
->orderBy('dt_spe')
->orderBy('id_operaz')
->get($selectColumns);
$created = 0;
$updated = 0;

View File

@ -29,6 +29,7 @@ class ImportGesconFullPipeline extends Command
{--force-mapping : Applica mapping anche sovrascrivendo valori esistenti}
{--fill-missing-millesimi : Crea righe mancanti a zero per ogni unita/tabella (uso eccezionale)}
{--amministratore-id= : Forza l\'assegnazione dello stabile a questo amministratore}
{--update-only : Solo aggiornamento incrementale (evita la pulizia dei dati esistenti)}
';
protected $description = 'Pipeline orchestrata di import completo da archivio Gescon (MDB singolo anno) verso schema dominio.';
@ -51,6 +52,7 @@ class ImportGesconFullPipeline extends Command
'straord',
'addebiti',
'detrazioni',
'affitti',
];
private array $legacyYearMap = [];
@ -245,6 +247,17 @@ private function resolveLatestStagingLegacyYear(string $table, ?string $stabile
return $this->stagingLatestLegacyYearCache[$cacheKey] = $this->requestedLegacyYear();
}
$reqYear = $this->requestedLegacyYear();
if ($reqYear !== null) {
$exists = DB::connection('gescon_import')->table($table)
->where('legacy_year', $reqYear)
->when($stabile !== null && $stabile !== '' && Schema::connection('gescon_import')->hasColumn($table, 'cod_stabile'), fn($q) => $q->where('cod_stabile', $stabile))
->exists();
if ($exists) {
return $this->stagingLatestLegacyYearCache[$cacheKey] = $reqYear;
}
}
$query = DB::connection('gescon_import')->table($table)
->whereNotNull('legacy_year')
->where('legacy_year', '!=', '');
@ -702,6 +715,15 @@ private function stepStabili(?int $limit): int
}
if ($created > 0 || $updated > 0) {
$this->line(" → stabili creati: {$created}, aggiornati: {$updated}");
if (!$this->isDryRun) {
$stabiliQuery = \App\Models\Stabile::query();
if ($this->option('stabile')) {
$stabiliQuery->where('codice_stabile', $this->option('stabile'));
}
foreach ($stabiliQuery->get() as $stabile) {
$stabile->syncRubricaContatto();
}
}
}
return $created + $updated;
}
@ -831,14 +853,12 @@ private function stepUnita(?int $limit): int
if ($normalizedInterno !== null) {
$physicalMatch = DB::table('unita_immobiliari')
->where('stabile_id', $rowStabileId)
->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at'))
->where('scala', $scalaVal)
->where('interno', $normalizedInterno)
->first();
if (! $physicalMatch && $sub !== null && $sub !== '' && Schema::hasColumn('unita_immobiliari', 'subalterno')) {
$physicalMatch = DB::table('unita_immobiliari')
->where('stabile_id', $rowStabileId)
->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at'))
->where('scala', $scalaVal)
->where('subalterno', $sub)
->first();
@ -850,7 +870,6 @@ private function stepUnita(?int $limit): int
if (! $exists && $legacyCondId !== null && $legacyCondId !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) {
$exists = DB::table('unita_immobiliari')
->where('stabile_id', $rowStabileId)
->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at'))
->where('legacy_cond_id', $legacyCondId)
->first();
}
@ -1144,7 +1163,7 @@ private function stepUnita(?int $limit): int
}
// Cleanup: rimuovi unità non presenti in condomin (evita "alieni")
if (! $this->isDryRun && $this->option('stabile')) {
if (! $this->isDryRun && $this->option('stabile') && ! $this->option('update-only')) {
$condRows = $this->currentSnapshotCondominQuery()
->select('scala', 'interno', 'cod_cond')
->get();
@ -2287,15 +2306,19 @@ private function stepMillesimi(?int $limit): int
if ($headerOnlyTable && $mm <= 0) {
goto importi_tabella;
}
// Upsert by (tabella, unita): ignora ruolo C/I e usa il massimo mm
$exists = DB::table('dettaglio_millesimi')
// Upsert by (tabella, unita, ruolo_legacy)
$existsQuery = DB::table('dettaglio_millesimi')
->where('tabella_millesimale_id', $tab->id)
->where('unita_immobiliare_id', $unitaId)
->first();
->where('unita_immobiliare_id', $unitaId);
if ($hasRuoloLegacy) {
$existsQuery->where('ruolo_legacy', $roleLegacy);
}
$exists = $existsQuery->first();
if ($exists) {
$currentMm = is_numeric($exists->millesimi ?? null) ? (float) $exists->millesimi : 0.0;
$upd = [
'millesimi' => max($currentMm, $mm),
'millesimi' => $mm,
'updated_at' => now(),
];
// Se la tabella ha una colonna per ordine (es. posizione, ordinale, nord), prova ad aggiornarla
@ -2320,6 +2343,9 @@ private function stepMillesimi(?int $limit): int
'created_at' => now(),
'updated_at' => now(),
];
if ($hasRuoloLegacy) {
$payload['ruolo_legacy'] = $roleLegacy;
}
if ($nord !== null) {
foreach (['nord', 'ordine', 'posizione', 'ordinale'] as $c) {
if (in_array($c, $detailsCols, true)) {
@ -2507,12 +2533,26 @@ private function stepMillesimi(?int $limit): int
}
private function inferStraordinariaSequence(mixed ...$candidates): ?int
{
if (isset($candidates[0]) && is_numeric(trim((string) $candidates[0]))) {
$val = (int) trim((string) $candidates[0]);
if ($val > 0) {
return $val;
}
}
foreach ($candidates as $candidate) {
$value = strtoupper(trim((string) ($candidate ?? '')));
if ($value === '') {
continue;
}
if (is_numeric($value)) {
$val = (int) $value;
if ($val > 0) {
return $val;
}
}
if (preg_match('/([0-9]{4})$/', $value, $match)) {
$sequence = (int) substr($match[1], -2);
if ($sequence > 0) {
@ -3231,12 +3271,12 @@ private function stepOperazioni(?int $limit): int
continue;
}
$exists = DB::table('operazioni_contabili')->where('legacy_id', $legacy)->first();
if ($exists) {
if ($exists && ! $this->option('update-only')) {
continue;
}
$gestioneId = $this->resolveGestioneId($o->legacy_year ?? ($o->anno ?? null), $o->compet ?? null, $o->gestione ?? null, $o->n_stra ?? null);
$protoNum = $this->nextProtocolNumber();
$protoNum = (isset($o->n_spe) && is_numeric($o->n_spe) && (int) $o->n_spe > 0) ? (int) $o->n_spe : $this->nextProtocolNumber();
$prefix = null;
if ($gestioneId) {
$prefix = DB::table('gestioni_contabili')->where('id', $gestioneId)->value('protocollo_prefix');
@ -3297,6 +3337,19 @@ private function stepOperazioni(?int $limit): int
}
}
$isEntrata = strtolower(trim((string) ($o->natura2 ?? ''))) === 'entrata';
$amount = (float) ($o->importo_euro ?? ($o->imp_calcolato_euro ?? 0));
$dareVal = (float) ($o->importo_spese ?? 0);
$avereVal = (float) ($o->importo_entrate ?? 0);
if ($dareVal == 0 && $avereVal == 0 && $amount != 0) {
if ($isEntrata) {
$avereVal = $amount;
} else {
$dareVal = $amount;
}
}
$data = [
'tenant_id' => null,
'gestione_id' => $gestioneId,
@ -3310,8 +3363,8 @@ private function stepOperazioni(?int $limit): int
'protocollo_completo' => $protoCompleto,
'voce_spesa_snapshot' => $voceSpesaSnapshot,
'fornitore_snapshot' => $fornitoreSnapshot,
'dare' => (float) ($o->importo_spese ?? 0),
'avere' => (float) ($o->importo_entrate ?? 0),
'dare' => $dareVal,
'avere' => $avereVal,
'conto_contabile' => $o->cod_spe ?? null,
'stato_operazione' => 'confermata',
'created_at' => now(),
@ -3343,6 +3396,38 @@ private function stepOperazioni(?int $limit): int
}
}
}
if ($exists) {
$dirty = false;
$updateData = [];
$updatableFields = [
'descrizione',
'data_operazione',
'compet',
'natura2',
'n_stra',
'dare',
'avere',
'conto_contabile',
'voce_spesa_snapshot',
'fornitore_snapshot',
];
foreach ($data as $k => $v) {
if (! in_array($k, $updatableFields, true)) {
continue;
}
if ((string) ($exists->{$k} ?? '') !== (string) ($v ?? '')) {
$updateData[$k] = $v;
$dirty = true;
}
}
if ($dirty) {
$updateData['updated_at'] = now();
DB::table('operazioni_contabili')->where('id', $exists->id)->update($updateData);
}
$count++;
continue;
}
$this->dynamicInsert('operazioni_contabili', $data);
$count++;
}
@ -3351,6 +3436,11 @@ private function stepOperazioni(?int $limit): int
private function purgeAuthoritativeLegacyReplica(array $steps): void
{
if ($this->option('update-only')) {
$this->line(' Opzione --update-only attiva: salto la pulizia dei dati esistenti per conservare le modifiche utente.');
return;
}
$stabileId = (int) ($this->stabileId ?? 0);
if ($stabileId <= 0) {
return;
@ -4336,7 +4426,7 @@ private function stepIncassi(?int $limit): int
$existsQuery->where('condominio_id', $this->stabileId);
}
$exists = $existsQuery->first();
if ($exists) {
if ($exists && ! $this->option('update-only')) {
continue;
}
@ -4441,6 +4531,44 @@ private function stepIncassi(?int $limit): int
'updated_at' => now(),
]);
}
if ($exists) {
$dirty = false;
$updateData = [];
$updatableFields = [
'data_incasso',
'importo',
'descrizione',
'riferimento_orig',
'ruolo_quota',
'cod_cond_gescon',
'cond_inquil',
'n_mese',
'o_r_s',
'anno_rif',
'dt_empag',
'importo_pagato',
'importo_pagato_euro',
'n_riferimento',
'cod_cassa_gescon',
'proviene_n_stra',
];
foreach ($data as $k => $v) {
if (! in_array($k, $updatableFields, true)) {
continue;
}
if ((string) ($exists->{$k} ?? '') !== (string) ($v ?? '')) {
$updateData[$k] = $v;
$dirty = true;
}
}
if ($dirty) {
$updateData['updated_at'] = now();
DB::table('incassi')->where('id', $exists->id)->update($updateData);
}
$count++;
continue;
}
$data['created_at'] = $data['created_at'] ?? now();
$data['updated_at'] = now();
$this->dynamicInsert('incassi', $data);
@ -4945,6 +5073,61 @@ private function stepDetrazioni(?int $limit): int
return $count;
}
private function stepAffitti(?int $limit): int
{
$basePath = (string) ($this->option('path') ?: '');
$partiComuniMdb = $this->resolvePartiComuniMdbPath($basePath);
if (!$partiComuniMdb || !is_file($partiComuniMdb)) {
$this->warn(" → File parti_comuni.mdb non trovato. Salto import affitti.");
return 0;
}
$codStabile = $this->option('stabile');
$this->info(" → Importazione affitti da {$partiComuniMdb} per lo stabile " . ($codStabile ?: 'tutti') . "...");
if ($this->option('dry-run')) {
$this->line(" (dry-run) import affitti skip");
return 0;
}
$params = [
'--mdb' => $partiComuniMdb,
'--refresh-canoni' => true,
];
if ($codStabile) {
$params['--stabile'] = $codStabile;
}
$this->call('affitti:import-mdb', $params);
return 1;
}
private function resolvePartiComuniMdbPath(string $basePath = ''): ?string
{
$candidates = [];
$basePath = trim($basePath);
if ($basePath !== '') {
$normalizedBase = rtrim($basePath, '/');
$candidates[] = $normalizedBase . '/parti_comuni.mdb';
$candidates[] = $normalizedBase . '/../parti_comuni.mdb';
$candidates[] = $normalizedBase . '/../../parti_comuni.mdb';
}
$candidates[] = '/mnt/gescon-archives/gescon/parti_comuni.mdb';
foreach (array_values(array_unique(array_filter($candidates))) as $candidate) {
if (is_file($candidate) && is_readable($candidate)) {
return $candidate;
}
}
return null;
}
private function stepAssemblee(?int $limit): int
{
if (! Schema::hasTable('assemblee')) {
@ -7580,6 +7763,20 @@ private function resolveGestioneContabileIdForStabile(?int $stabileId, ?string $
default => 'ordinaria',
};
if ($tipo === 'straordinaria' && $numeroStraordinaria !== null && $numeroStraordinaria > 1) {
$stabileCode = DB::table('stabili')->where('id', $stabileId)->value('codice_stabile');
$stabileCode = trim((string) ($stabileCode ?? ''));
if ($stabileCode !== '' && Schema::connection('gescon_import')->hasTable('straordinarie')) {
$existsInStaging = DB::connection('gescon_import')->table('straordinarie')
->where('cod_stabile', $stabileCode)
->where('codice', (string) $numeroStraordinaria)
->exists();
if (! $existsInStaging) {
$numeroStraordinaria = 1;
}
}
}
$q = DB::table('gestioni_contabili')
->where('anno_gestione', (int) $anno)
->where('tipo_gestione', $tipo);

View File

@ -108,18 +108,23 @@ private function ensureSaldoCasseTable(): void
$this->warn('Impossibile creare la tabella s_cassa su staging: ' . $e->getMessage());
}
}
private function ensureCondominColumns(): void
{
if (! Schema::connection('gescon_import')->hasTable('condomin')) {
return;
}
// Alcuni ambienti hanno gia' la tabella al limite della row size.
// I campi storici opzionali vanno gestiti come best-effort a valle,
// senza forzare ALTER TABLE durante il reload della staging.
}
$cols = ['inquil_nome', 'inquil_cod_fisc', 'e_mail_inquilino', 'inquil_tel1', 'inquil_indir'];
$connection = Schema::connection('gescon_import');
Schema::connection('gescon_import')->table('condomin', function (Blueprint $table) use ($cols, $connection): void {
foreach ($cols as $col) {
if (! $connection->hasColumn('condomin', $col)) {
$table->string($col)->nullable();
}
}
});
}
private function ensureSliceColumns(string $tableName): void
{
if (! Schema::connection('gescon_import')->hasTable($tableName)) {
@ -334,7 +339,7 @@ public function handle(): int
// Estrai CSV con header
$binExport = trim((string) shell_exec('command -v mdb-export')) ?: '/usr/bin/mdb-export';
$process = new Process([$binExport, '-D', '%Y-%m-%d', $mdb, $tableEffective]);
$process = new Process([$binExport, '-D', '%Y-%m-%d', '-T', '%Y-%m-%d %H:%M:%S', $mdb, $tableEffective]);
$process->setTimeout(120);
$process->run();
if (! $process->isSuccessful()) {
@ -525,6 +530,21 @@ public function handle(): int
if ($hasColumn('inquilino') && empty($filtered['inquilino'])) {
$filtered['inquilino'] = ! empty($assoc['inquil_nome']) ? 1 : 0;
}
if ($hasColumn('inquil_nome') && empty($filtered['inquil_nome'])) {
$filtered['inquil_nome'] = trim((string) ($assoc['inquil_nome'] ?? null)) ?: null;
}
if ($hasColumn('inquil_cod_fisc') && empty($filtered['inquil_cod_fisc'])) {
$filtered['inquil_cod_fisc'] = trim((string) ($assoc['inquil_cod_fisc'] ?? null)) ?: null;
}
if ($hasColumn('e_mail_inquilino') && empty($filtered['e_mail_inquilino'])) {
$filtered['e_mail_inquilino'] = trim((string) ($assoc['inquil_e_mail'] ?? $assoc['e_mail_inquilino'] ?? null)) ?: null;
}
if ($hasColumn('inquil_tel1') && empty($filtered['inquil_tel1'])) {
$filtered['inquil_tel1'] = trim((string) ($assoc['inquil_tel1'] ?? $assoc['inquil_tel'] ?? null)) ?: null;
}
if ($hasColumn('inquil_indir') && empty($filtered['inquil_indir'])) {
$filtered['inquil_indir'] = trim((string) ($assoc['inquil_indir'] ?? $assoc['inquil_presso'] ?? null)) ?: null;
}
if ($hasColumn('proprietario') && empty($filtered['proprietario'])) {
$filtered['proprietario'] = 1;
}

View File

@ -71,21 +71,15 @@ protected function getTableQuery(): Builder
$mostraRiscaldamento = (bool) data_get($stabile?->configurazione_avanzata, 'mostra_riscaldamento', false);
$query = GestioneContabile::query()
->where('stabile_id', $activeStabileId)
->where(function (Builder $builder) use ($user): void {
$tenantId = trim((string) ($user->tenant_id ?? ''));
->where('stabile_id', $activeStabileId);
$tenantId = trim((string) ($user->tenant_id ?? ''));
if ($tenantId !== '') {
$query->where(function (Builder $builder) use ($tenantId): void {
$builder->where('tenant_id', $tenantId)
->orWhere('tenant_id', '00000000');
return;
}
$builder->whereNull('tenant_id')
->orWhere('tenant_id', '')
->orWhere('tenant_id', '00000000');
});
}
if ($this->scope === 'archivio') {
$query->whereIn('stato', ['chiusa', 'consolidata']);
@ -486,7 +480,7 @@ public function table(Table $table): Table
Action::make('apri_archivio_gestioni')
->label('Apri archivio')
->url(fn() => \App\Filament\Pages\Condomini\StabilePage::getUrl(panel: 'admin-filament') . '?tab=gestioni&gestioni_tab=archivio', shouldOpenInNewTab: true),
->url(fn() => \App\Filament\Pages\Condomini\StabilePage::getUrl(panel: 'admin-filament') . '?tab=gestioni&gestioni_tab=archivio'),
Action::make('archivia_gestione')
->label('Sposta in archivio')
->icon('heroicon-o-archive-box')

View File

@ -30,9 +30,98 @@ class NominativiStabile extends Page implements HasTable
public ?int $stabileId = null;
public bool $cumulato = false;
/** @var array<int, string> */
private array $rubricaTitoloCache = [];
protected function getHeaderActions(): array
{
$user = Auth::user();
$activeStabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?: 0): 0;
if ($activeStabileId <= 0) {
return [];
}
if (! config('netgescon.legacy_import_enabled', true)) {
return [];
}
$stabile = Stabile::find($activeStabileId);
if (!$stabile) {
return [];
}
$codStabile = $this->resolveLegacyStabileCode($activeStabileId);
if ($codStabile === '') {
return [];
}
return [
Action::make('sync_legacy_nominativi')
->label('Sincronizza da Legacy')
->icon('heroicon-m-arrow-path')
->color('warning')
->requiresConfirmation()
->modalHeading('Sincronizza Nominativi e Unità da Legacy')
->modalDescription('Questo processo importerà le unità e i soggetti dal database legacy di staging del Gescon per lo stabile corrente.')
->action(function () {
$user = Auth::user();
$activeStabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?: 0): 0;
$stabile = Stabile::find($activeStabileId);
$codStabile = $this->resolveLegacyStabileCode($activeStabileId);
try {
$path = app(\App\Services\TenantArchivePathService::class)->stabileAbsolutePath($stabile);
// Trova l'anno legacy più recente importato in staging
$anno = (string) (DB::connection('gescon_import')
->table('condomin')
->where('cod_stabile', $codStabile)
->max('legacy_year') ?: '');
if ($anno === '') {
\Filament\Notifications\Notification::make()
->title('Errore Sincronizzazione')
->body('Nessun anno legacy trovato per lo stabile corrente in staging.')
->danger()
->send();
return;
}
// Esegui gli step: unita, relazioni, soggetti, diritti
$steps = ['unita', 'relazioni', 'soggetti', 'diritti'];
foreach ($steps as $step) {
\Illuminate\Support\Facades\Artisan::call('gescon:import-full', [
'--stabile' => $codStabile,
'--solo' => $step,
'--path' => $path,
'--anno' => $anno,
'--update-only' => true,
]);
}
// Forza la rigenerazione della vista
try {
DB::connection('gescon_import')->statement("DROP VIEW IF EXISTS vw_legacy_condomin_nominativi");
} catch (\Throwable $e) {}
$this->ensureLegacyViewExists();
\Filament\Notifications\Notification::make()
->title('Sincronizzazione completata')
->body('Le unità e i soggetti dello stabile sono stati sincronizzati con successo dal legacy.')
->success()
->send();
} catch (\Throwable $e) {
\Filament\Notifications\Notification::make()
->title('Errore Sincronizzazione')
->body($e->getMessage())
->danger()
->send();
}
}),
];
}
private function applyDomainNominativiSearch(Builder $query, string $search, int $activeStabileId): Builder
{
$search = trim($search);
@ -398,6 +487,13 @@ protected function getTableQuery(): Builder
->on('mx.interno', '=', 'vw_legacy_condomin_nominativi.interno')
->on('mx.legacy_year', '=', 'vw_legacy_condomin_nominativi.legacy_year');
})
->when($this->cumulato, function (Builder $q) use ($legacyTable): void {
$q->where(function (Builder $sub) use ($legacyTable) {
$sub->whereNull($legacyTable . '.cumulo_cond')
->orWhere($legacyTable . '.cumulo_cond', '')
->orWhereColumn($legacyTable . '.cumulo_cond', $legacyTable . '.cod_cond');
});
})
->orderBy($legacyTable . '.scala')
// Ordine richiesto: per interno (con fallback robusto)
->orderByRaw("CASE WHEN vw_legacy_condomin_nominativi.interno IS NULL OR vw_legacy_condomin_nominativi.interno = '' THEN 1 ELSE 0 END")
@ -416,12 +512,59 @@ protected function hasLegacyNominativiForStabile(string $codStabile): bool
->exists();
}
private function getAccumulatedUnits(string $codStabile, string $legacyYear, string $codCond): array
{
$units = DB::connection('gescon_import')
->table('vw_legacy_condomin_nominativi')
->where('cod_stabile', $codStabile)
->where('legacy_year', $legacyYear)
->where('cumulo_cond', $codCond)
->where('cod_cond', '<>', $codCond)
->orderBy('scala')
->orderBy('interno')
->get(['scala', 'interno', 'piano']);
$result = [];
foreach ($units as $u) {
$parts = [];
if (!empty($u->scala)) $parts[] = 'Sc. ' . trim($u->scala);
if (!empty($u->interno)) $parts[] = 'Int. ' . trim($u->interno);
if ($u->piano !== '' && $u->piano !== null) $parts[] = 'P. ' . trim($u->piano);
$result[] = $parts === [] ? '—' : implode(' · ', $parts);
}
return $result;
}
private function getAccumulatedDomainUnits(int $mainUnitId): array
{
$units = DB::table('unita_pertinenze as up')
->join('unita_immobiliari as ui', 'ui.id', '=', 'up.unita_accessoria_id')
->where('up.unita_principale_id', $mainUnitId)
->where('up.cumulo', true)
->orderBy('ui.scala')
->orderBy('ui.interno')
->get(['ui.scala', 'ui.interno', 'ui.piano']);
$result = [];
foreach ($units as $u) {
$parts = [];
if (!empty($u->scala)) $parts[] = 'Sc. ' . trim($u->scala);
if (!empty($u->interno)) $parts[] = 'Int. ' . trim($u->interno);
if ($u->piano !== '' && $u->piano !== null) $parts[] = 'P. ' . trim($u->piano);
$result[] = $parts === [] ? '—' : implode(' · ', $parts);
}
return $result;
}
private function ensureLegacyViewExists(): void
{
$conn = DB::connection('gescon_import');
$hasCumulo = false;
try {
$conn->table('vw_legacy_condomin_nominativi')->limit(1)->first();
} catch (\Throwable $e) {
$hasCumulo = DbSchema::connection('gescon_import')->hasColumn('vw_legacy_condomin_nominativi', 'cumulo_cond');
} catch (\Throwable $e) {}
if (!$hasCumulo) {
$cols = DbSchema::connection('gescon_import')->getColumnListing('condomin');
$desiredFields = [
'id', 'cod_stabile', 'legacy_year', 'legacy_period_label', 'legacy_file',
@ -433,7 +576,8 @@ private function ensureLegacyViewExists(): void
'inquil_tel2', 'cell_inq', 'fax_inq', 'e_mail_inquilino', 'pec_inquilino',
'inquil_cod_fisc', 'inquil_dal', 'inquil_al', 'inquil_contratto_dal',
'perc_diritto_reale', 'diritto_reale', 'diritto_godimento', 'note_cond',
'inquil_note', 'note'
'inquil_note', 'note',
'cumulo_cond', 'cumulo_inq', 'cumulo_cond_ec', 'cumulo_inq_ec'
];
$selects = [];
foreach ($desiredFields as $field) {
@ -448,6 +592,10 @@ private function ensureLegacyViewExists(): void
}
}
$selectSql = implode(', ', $selects);
try {
$conn->statement("DROP VIEW IF EXISTS vw_legacy_condomin_nominativi");
} catch (\Throwable $e) {}
$sql = $conn->getDriverName() === 'sqlite'
? "CREATE VIEW IF NOT EXISTS vw_legacy_condomin_nominativi AS SELECT {$selectSql} FROM condomin"
: "CREATE OR REPLACE VIEW vw_legacy_condomin_nominativi AS SELECT {$selectSql} FROM condomin";
@ -556,6 +704,15 @@ protected function buildDomainFallbackQuery(int $stabileId): Builder
return UnitaImmobiliare::query()
->where('stabile_id', $stabileId)
->whereNull('deleted_at')
->when($this->cumulato, function (Builder $query) use ($stabileId): void {
$query->whereNotExists(function ($sub) use ($stabileId) {
$sub->selectRaw('1')
->from('unita_pertinenze')
->where('stabile_id', $stabileId)
->whereColumn('unita_accessoria_id', 'unita_immobiliari.id')
->where('cumulo', true);
});
})
->where(function (Builder $q) use ($stabileId, $ownerRoles, $tenantRoles): void {
$q->whereExists(function ($sub) use ($stabileId, $ownerRoles) {
$sub->selectRaw('1')
@ -750,11 +907,92 @@ public function table(Table $table): Table
$q->whereNull('inquil_nome')->orWhere('inquil_nome', '');
}),
),
SelectFilter::make('soggetto')
->label('Cerca condomino')
->searchable()
->options(function () use ($codStabile, $legacyYear, $activeStabileId, $useLegacy): array {
if ($useLegacy) {
if ($codStabile === '') {
return [];
}
return DB::connection('gescon_import')
->table('vw_legacy_condomin_nominativi')
->where('cod_stabile', $codStabile)
->where('legacy_year', $legacyYear)
->whereNotNull('nom_cond')
->where('nom_cond', '<>', '')
->distinct()
->orderBy('nom_cond')
->pluck('nom_cond', 'nom_cond')
->all();
} else {
if ($activeStabileId <= 0) {
return [];
}
return DB::table('rubrica_ruoli as rr')
->join('rubrica_universale as ru', 'ru.id', '=', 'rr.rubrica_id')
->where('rr.stabile_id', $activeStabileId)
->where('rr.is_attivo', true)
->distinct()
->orderByRaw("TRIM(CONCAT(COALESCE(ru.nome, ''), ' ', COALESCE(ru.cognome, '')))")
->get(['ru.nome', 'ru.cognome', 'ru.ragione_sociale'])
->mapWithKeys(function($row) {
$nome = trim(($row->nome ?? '') . ' ' . ($row->cognome ?? ''));
$label = $nome !== '' ? $nome : ($row->ragione_sociale ?? '—');
return [$label => $label];
})
->all();
}
})
->query(function (Builder $query, array $data) use ($legacyTable, $useLegacy, $activeStabileId): Builder {
if (! isset($data['value']) || $data['value'] === '') {
return $query;
}
if ($useLegacy) {
return $query->where($legacyTable . '.nom_cond', $data['value']);
} else {
return $this->applyDomainNominativiSearch($query, $data['value'], $activeStabileId);
}
}),
SelectFilter::make('inquilino')
->label('Cerca inquilino')
->searchable()
->options(function () use ($codStabile, $legacyYear, $useLegacy): array {
if ($useLegacy) {
if ($codStabile === '') {
return [];
}
return DB::connection('gescon_import')
->table('vw_legacy_condomin_nominativi')
->where('cod_stabile', $codStabile)
->where('legacy_year', $legacyYear)
->whereNotNull('inquil_nome')
->where('inquil_nome', '<>', '')
->distinct()
->orderBy('inquil_nome')
->pluck('inquil_nome', 'inquil_nome')
->all();
} else {
return [];
}
})
->query(function (Builder $query, array $data) use ($legacyTable, $useLegacy, $activeStabileId): Builder {
if (! isset($data['value']) || $data['value'] === '') {
return $query;
}
if ($useLegacy) {
return $query->where($legacyTable . '.inquil_nome', $data['value']);
} else {
return $this->applyDomainNominativiSearch($query, $data['value'], $activeStabileId);
}
}),
])
->columns([
TextColumn::make('unita_ref')
->label('Unità')
->formatStateUsing(function ($state, $record): string {
->formatStateUsing(function ($state, $record) use ($useLegacy, $codStabile, $legacyYear): string {
$scala = trim((string) ($record->scala ?? ''));
$interno = trim((string) ($record->interno ?? ''));
$piano = trim((string) ($record->piano ?? ''));
@ -770,7 +1008,26 @@ public function table(Table $table): Table
$parts[] = 'P. ' . $piano;
}
return $parts === [] ? '—' : implode(' · ', $parts);
$mainLabel = $parts === [] ? '—' : implode(' · ', $parts);
if ($this->cumulato) {
if ($useLegacy) {
$codCond = $record->cod_cond ?? null;
if ($codCond) {
$accumulated = $this->getAccumulatedUnits($codStabile, $record->legacy_year ?? $legacyYear, $codCond);
if (!empty($accumulated)) {
return $mainLabel . ' (+ ' . implode(', ', $accumulated) . ')';
}
}
} else {
$accumulated = $this->getAccumulatedDomainUnits((int) $record->id);
if (!empty($accumulated)) {
return $mainLabel . ' (+ ' . implode(', ', $accumulated) . ')';
}
}
}
return $mainLabel;
})
->wrap(),
@ -911,7 +1168,7 @@ public function table(Table $table): Table
}
return UnitaImmobiliarePage::getUrl(panel: 'admin-filament') . '?unita_id=' . $unitaId;
}, shouldOpenInNewTab: true)
})
->visible(fn($record): bool => (bool) $this->resolveUnitaIdForRow($record, $activeStabileId)),
Action::make('apri_scheda_unica')
@ -924,7 +1181,7 @@ public function table(Table $table): Table
}
return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament');
}, shouldOpenInNewTab: true)
})
->visible(fn($record): bool => (bool) $this->resolveRubricaIdForRow($record)),
]);
}

File diff suppressed because it is too large Load Diff

View File

@ -42,11 +42,13 @@
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Livewire\WithFileUploads;
use UnitEnum;
class ServiziStabileArchivio extends Page implements HasTable
{
use InteractsWithTable;
use WithFileUploads;
protected static ?string $navigationLabel = 'Servizi / Beni comuni';
@ -81,6 +83,16 @@ class ServiziStabileArchivio extends Page implements HasTable
/** @var array<int, array<string, mixed>> */
public array $acquaCampagnaEditRows = [];
public ?string $acquaDataInizioFiltro = null;
public ?string $acquaDataFineFiltro = null;
public bool $showSendEmailModal = false;
public string $emailDestinatario = '';
public string $emailOggetto = '';
public string $emailCorpo = '';
public $electronicReadingsFile;
public static function canAccess(): bool
{
$user = Auth::user();
@ -95,11 +107,43 @@ public function mount(): void
$this->acquaTab = $this->normalizeAcquaTab((string) request()->query('tab', 'dashboard'));
$this->resetAcquaUiReadingForm();
$this->mountInteractsWithTable();
$this->loadAcquaFilterDates();
$this->initializeEmailDefaults();
}
public function setAcquaTab(string $tab): void
{
$this->acquaTab = $this->normalizeAcquaTab($tab);
$this->loadAcquaFilterDates();
}
private function loadAcquaFilterDates(): void
{
$stabileId = $this->resolveActiveStabileId();
$year = $this->resolveActiveAnnoGestione();
$service = $this->resolveActiveAcquaServizio();
$this->acquaDataInizioFiltro = null;
$this->acquaDataFineFiltro = null;
if ($service instanceof StabileServizio) {
$periods = $service->meta['periodi_esercizio'] ?? [];
if (!empty($periods[$year]['dal'])) {
$this->acquaDataInizioFiltro = $periods[$year]['dal'];
}
if (!empty($periods[$year]['al'])) {
$this->acquaDataFineFiltro = $periods[$year]['al'];
}
}
}
private function initializeEmailDefaults(): void
{
$stabileId = $this->resolveActiveStabileId();
$stabile = $stabileId ? Stabile::query()->find($stabileId) : null;
$this->emailOggetto = 'Riepilogo Ripartizione Acqua - Stabile ' . ($stabile?->denominazione ?? '');
$this->emailCorpo = "Gentile collaboratore,\nin allegato trovi il PDF del riepilogo contatori acqua per lo stabile in oggetto.\n\nCordiali saluti,\nL'Amministrazione";
}
public function toggleAcquaFeSelection(int $fatturaId): void
@ -338,6 +382,353 @@ public function deleteAcquaUiReading(int $readingId): void
Notification::make()->title('Lettura UI eliminata')->success()->send();
}
public function saveAcquaFilterDates(): void
{
$stabileId = $this->resolveActiveStabileId();
$year = $this->resolveActiveAnnoGestione();
$service = $this->resolveActiveAcquaServizio();
if (! $stabileId || ! $service instanceof StabileServizio) {
Notification::make()->title('Configura prima un servizio acqua attivo.')->warning()->send();
return;
}
$meta = $service->meta ?? [];
$periods = $meta['periodi_esercizio'] ?? [];
$dal = trim((string) $this->acquaDataInizioFiltro) ?: null;
$al = trim((string) $this->acquaDataFineFiltro) ?: null;
if ($dal || $al) {
$periods[$year] = [
'dal' => $dal,
'al' => $al
];
// PROPAGAZIONE AUTOMATICA ANNI PRECEDENTI E SUCCESSIVI
$dalCarbon = \Illuminate\Support\Carbon::parse($dal);
$alCarbon = \Illuminate\Support\Carbon::parse($al);
$otherYears = \App\Models\GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('tipo_gestione', 'ordinaria')
->pluck('anno_gestione')
->toArray();
foreach ($otherYears as $oy) {
if ($oy != $year && (! isset($periods[$oy]) || empty($periods[$oy]['dal']) || empty($periods[$oy]['al']))) {
$diff = $oy - $year;
$periods[$oy] = [
'dal' => $dalCarbon->copy()->addYears($diff)->format('Y-m-d'),
'al' => $alCarbon->copy()->addYears($diff)->format('Y-m-d'),
];
}
}
} else {
unset($periods[$year]);
}
$meta['periodi_esercizio'] = $periods;
$service->meta = $meta;
$service->save();
// Spostamento automatico delle fatture (Aggiornamento gestione_id)
if ($dal && $al) {
$gestione = \App\Models\GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('anno_gestione', $year)
->where('tipo_gestione', 'ordinaria')
->first();
if ($gestione) {
$fornitoreIds = StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'acqua')
->whereNotNull('fornitore_id')
->pluck('fornitore_id')
->map(fn($value): int => (int) $value)
->filter(fn(int $value): bool => $value > 0)
->values()
->all();
if ($fornitoreIds === []) {
$preferredSupplier = $this->resolvePreferredAcquaSupplier($stabileId);
if ($preferredSupplier instanceof Fornitore) {
$fornitoreIds = [(int) $preferredSupplier->id];
}
}
$fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds);
// Aggiorna le fatture in contabilita_fatture_fornitori
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$q = DB::table('contabilita_fatture_fornitori')
->where('stabile_id', $stabileId)
->whereBetween('data_documento', [$dal, $al]);
if ($fornitoreIds !== []) {
$q->whereIn('fornitore_id', $fornitoreIds);
}
$q->update(['gestione_id' => $gestione->id]);
// Inoltre, aggiorna le FE associate
$feIds = DB::table('fatture_elettroniche')
->where('stabile_id', $stabileId)
->whereBetween('data_fattura', [$dal, $al])
->when($fornitoreIds !== [], fn($b) => $b->whereIn('fornitore_id', $fornitoreIds))
->pluck('id')
->all();
if (!empty($feIds)) {
DB::table('contabilita_fatture_fornitori')
->where('stabile_id', $stabileId)
->whereIn('fattura_elettronica_id', $feIds)
->update(['gestione_id' => $gestione->id]);
}
}
}
}
Notification::make()->title('Filtro date salvato e fatture allineate con successo.')->success()->send();
// Refresh properties
$this->loadAcquaFilterDates();
}
public function sendRipartizioneEmail(): void
{
$email = trim($this->emailDestinatario);
if ($email === '') {
Notification::make()->title('Destinatario mancante')->danger()->send();
return;
}
$stabileId = $this->resolveActiveStabileId();
$year = $this->resolveActiveAnnoGestione();
try {
$request = new \Illuminate\Http\Request([
'stabile_id' => $stabileId,
'year' => $year,
]);
$selectedIds = $this->normalizeAcquaSelectedFeIds();
if ($selectedIds !== []) {
$request->merge(['ids' => implode(',', $selectedIds)]);
}
// Imposta l'utente loggato sulla request
$request->setUserResolver(fn() => Auth::user());
$printController = new \App\Http\Controllers\Admin\AcquaRipartizionePrintController();
$reportResponse = $printController->pdf($request);
$pdfContent = $reportResponse->getContent();
Mail::send([], [], function ($message) use ($email, $pdfContent, $year): void {
$message->to($email)
->subject($this->emailOggetto)
->html(nl2br(e($this->emailCorpo)))
->attachData($pdfContent, 'riepilogo-acqua-' . $year . '.pdf', [
'mime' => 'application/pdf',
]);
});
$this->showSendEmailModal = false;
Notification::make()->title('Email inviata con successo.')->success()->send();
} catch (\Throwable $e) {
Notification::make()->title('Errore durante l invio dell email')->danger()->body($e->getMessage())->send();
}
}
public function importElectronicReadings(): void
{
if (! $this->electronicReadingsFile) {
Notification::make()->title('Carica prima un file CSV.')->warning()->send();
return;
}
$stabileId = $this->resolveActiveStabileId();
$servizio = $this->resolveActiveAcquaServizio();
if (! $stabileId || ! $servizio) {
Notification::make()->title('Nessun servizio acqua configurato per lo stabile attivo.')->danger()->send();
return;
}
$user = Auth::user();
$filePath = $this->electronicReadingsFile->getRealPath();
$handle = fopen($filePath, 'r');
if (! $handle) {
Notification::make()->title('Impossibile aprire il file caricato.')->danger()->send();
return;
}
$headers = fgetcsv($handle, 0, ',');
if (! $headers) {
fclose($handle);
Notification::make()->title('File CSV vuoto o non valido.')->danger()->send();
return;
}
// Trova gli indici dei campi
$idIdx = -1;
$internoIdx = -1;
$letturaIdx = -1;
foreach ($headers as $idx => $header) {
$headerClean = strtolower(trim((string) $header));
if ($headerClean === '#id' || $headerClean === 'id' || $idx === 0) {
if ($idIdx === -1) $idIdx = $idx;
}
if ($headerClean === 'interno' || $headerClean === 'int') {
$internoIdx = $idx;
}
if ($headerClean === 'lettura' || $headerClean === 'valore') {
$letturaIdx = $idx;
}
}
// Fallbacks se non trova per nome
if ($idIdx === -1) $idIdx = 0;
if ($internoIdx === -1) $internoIdx = 6; // Default standard del tracciato
if ($letturaIdx === -1) $letturaIdx = 7; // Default standard del tracciato
$imported = 0;
$linked = 0;
$errors = 0;
$year = $this->resolveActiveAnnoGestione();
while (($row = fgetcsv($handle, 0, ',')) !== false) {
if (count($row) <= max($idIdx, $internoIdx, $letturaIdx)) {
continue;
}
$deviceId = trim((string) ($row[$idIdx] ?? ''));
$internoCsv = trim((string) ($row[$internoIdx] ?? ''));
$letturaStr = trim((string) ($row[$letturaIdx] ?? ''));
if ($deviceId === '' && $internoCsv === '') {
continue;
}
// Pulisci il valore della lettura
$letturaClean = preg_replace('/[^\d,\.]/', '', $letturaStr);
$letturaClean = str_replace(',', '.', $letturaClean);
if (! is_numeric($letturaClean)) {
$errors++;
continue;
}
$finalValue = round((float) $letturaClean, 3);
// Cerca l'unità immobiliare
$unit = null;
if ($deviceId !== '') {
$unit = UnitaImmobiliare::query()
->where('stabile_id', $stabileId)
->where('acqua_gateway_device_id', $deviceId)
->whereNull('deleted_at')
->first();
}
if (! $unit && $internoCsv !== '') {
$unit = UnitaImmobiliare::query()
->where('stabile_id', $stabileId)
->where(function($query) use ($internoCsv) {
$query->where('interno', $internoCsv)
->orWhere('codice_unita', $internoCsv);
})
->whereNull('deleted_at')
->first();
if ($unit && $deviceId !== '') {
$unit->acqua_gateway_device_id = $deviceId;
$unit->save();
$linked++;
}
}
if (! $unit) {
$errors++;
continue;
}
// Trova la lettura precedente
$previous = StabileServizioLettura::query()
->where('stabile_id', $stabileId)
->where('stabile_servizio_id', (int) $servizio->id)
->where('unita_immobiliare_id', $unit->id)
->whereNotNull('lettura_fine')
->orderByDesc('periodo_al')
->orderByDesc('id')
->first();
$previousValue = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null;
$consumo = ($previousValue !== null && $finalValue >= $previousValue) ? round($finalValue - $previousValue, 3) : null;
// Cerca se esiste già una lettura per quest'anno
$current = StabileServizioLettura::query()
->where('stabile_id', $stabileId)
->where('stabile_servizio_id', (int) $servizio->id)
->where('unita_immobiliare_id', $unit->id)
->whereYear('created_at', $year)
->first();
$payload = [
'stabile_id' => $stabileId,
'stabile_servizio_id' => (int) $servizio->id,
'unita_immobiliare_id' => $unit->id,
'fornitore_id' => $servizio->fornitore_id,
'periodo_dal' => $previous?->periodo_al,
'periodo_al' => now()->toDateString(),
'tipologia_lettura' => 'elettronica_remota',
'canale_acquisizione' => 'dispositivo_remoto',
'riferimento_acquisizione' => 'Gateway ID ' . $deviceId,
'workflow_stato' => 'ricevuta',
'rilevatore_tipo' => 'sistema_remoto',
'rilevatore_nome' => 'Gateway Elettronico',
'lettura_precedente_valore' => $previousValue,
'lettura_inizio' => $previousValue,
'lettura_fine' => $finalValue,
'consumo_valore' => $consumo,
'consumo_unita' => 'mc',
'raw' => [
'csv_import' => [
'device_id' => $deviceId,
'interno' => $internoCsv,
'original' => $row,
'imported_at' => now()->toIso8601String(),
'imported_by' => $user?->id,
]
]
];
if ($current) {
$current->fill($payload);
$current->save();
} else {
$payload['created_by'] = $user?->id;
StabileServizioLettura::query()->create($payload);
}
$imported++;
}
fclose($handle);
$this->electronicReadingsFile = null;
$msg = "Caricate con successo $imported letture.";
if ($linked > 0) {
$msg .= " Associate $linked nuove unità via interno.";
}
if ($errors > 0) {
$msg .= " Saltate/non abbinate $errors righe.";
}
Notification::make()->title('Importazione completata')->body($msg)->success()->send();
$this->dispatch('refresh-acqua-fe-selections');
}
private function normalizeAcquaTab(string $tab): string
{
$allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi'];
@ -1338,12 +1729,20 @@ private function resolveAcquaCandidateInvoices(?string $tipoGestione = null)
$fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds);
$dal = $this->acquaDataInizioFiltro;
$al = $this->acquaDataFineFiltro;
$query = FatturaElettronica::query()
->where('stabile_id', $stabileId)
->whereYear('data_fattura', $year)
->orderByDesc('data_fattura')
->orderByDesc('id');
if ($dal && $al) {
$query->whereBetween('data_fattura', [$dal, $al]);
} else {
$query->whereYear('data_fattura', $year);
}
if ($fornitoreIds !== []) {
$query->whereIn('fornitore_id', $fornitoreIds);
}
@ -1355,8 +1754,11 @@ private function resolveAcquaCandidateInvoices(?string $tipoGestione = null)
->whereNotNull('f.fattura_elettronica_id')
->when($fornitoreIds !== [], fn($builder) => $builder->whereIn('f.fornitore_id', $fornitoreIds))
->where('g.tipo_gestione', $tipoGestione)
->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($builder) => $builder->where('g.anno_gestione', $year))
->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($builder) => $builder->whereYear('f.data_documento', $year))
->when($dal && $al, fn($builder) => $builder->whereBetween('f.data_documento', [$dal, $al]))
->when(!($dal && $al), function($builder) use ($year) {
$builder->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($b) => $b->where('g.anno_gestione', $year))
->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($b) => $b->whereYear('f.data_documento', $year));
})
->pluck('f.fattura_elettronica_id')
->map(fn($value): int => (int) $value)
->filter(fn(int $value): bool => $value > 0)
@ -1665,10 +2067,23 @@ public function getAcquaLegacyOperazioniSummaryProperty(): array
$query->whereYear('dt_spe', $year);
}
$columns = Schema::connection('gescon_import')->getColumnListing('operazioni');
$sumFields = [];
if (in_array('importo_euro', $columns, true)) {
$sumFields[] = 'importo_euro';
}
if (in_array('importo', $columns, true)) {
$sumFields[] = 'importo';
}
$sumSql = '0';
if ($sumFields !== []) {
$sumSql = 'COALESCE(' . implode(', ', $sumFields) . ', 0)';
}
$rows = $query
->select('cod_spe')
->selectRaw('COUNT(*) as righe')
->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale')
->selectRaw("SUM($sumSql) as totale")
->groupBy('cod_spe')
->get();
@ -3132,7 +3547,7 @@ public function getAcquaCampagnaRowsProperty(): array
$currentYearByUnit[$unitId] = $row;
}
if ($row->lettura_fine !== null && ! isset($latestRealByUnit[$unitId])) {
if ($row->lettura_fine !== null && (int) $rowYear !== $year && ! isset($latestRealByUnit[$unitId])) {
$latestRealByUnit[$unitId] = $row;
}
}
@ -3166,7 +3581,7 @@ public function getAcquaCampagnaRowsProperty(): array
'latest_value' => $current?->lettura_fine,
'edit' => $this->resolveAcquaCampagnaEditRow((int) $unit->id, $current, $latestReal),
'public_url' => $this->buildPublicAcquaReadingUrl((int) $servizio->id, (int) $unit->id, $current?->id ? (int) $current->id : null),
'ticket_url' => TicketAcqua::getUrl([
'ticket_url' => \App\Filament\Pages\Supporto\TicketAcquaLight::getUrl([
'stabile' => $stabileId,
'servizio' => (int) $servizio->id,
'unita' => (int) $unit->id,
@ -3386,9 +3801,22 @@ public function getAcquaAltreVociLegacyProperty(): array
$query->whereYear('dt_spe', $year);
}
$columns = Schema::connection('gescon_import')->getColumnListing('operazioni');
$sumFields = [];
if (in_array('importo_euro', $columns, true)) {
$sumFields[] = 'importo_euro';
}
if (in_array('importo', $columns, true)) {
$sumFields[] = 'importo';
}
$sumSql = '0';
if ($sumFields !== []) {
$sumSql = 'COALESCE(' . implode(', ', $sumFields) . ', 0)';
}
$rows = $query
->select('cod_spe')
->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale')
->selectRaw("SUM($sumSql) as totale")
->groupBy('cod_spe')
->orderBy('cod_spe')
->get();

View File

@ -262,6 +262,12 @@ public function table(Table $table): Table
->default(1000)
->rules(['nullable', 'numeric', 'min:0']),
TextInput::make('nord')
->label('NORD')
->numeric()
->rules(['nullable', 'integer', 'min:0'])
->helperText('Ordinamento legacy (es. 10 per TAB.A, 20 per TAB.A1).'),
TextInput::make('ordine_visualizzazione')
->label('Ord. (UI)')
->numeric()
@ -314,6 +320,7 @@ public function table(Table $table): Table
'totale_millesimi' => is_numeric($data['totale_millesimi'] ?? null) ? (float) $data['totale_millesimi'] : null,
'ordine_visualizzazione' => is_numeric($data['ordine_visualizzazione'] ?? null) ? (int) $data['ordine_visualizzazione'] : null,
'ordinamento' => is_numeric($data['ordinamento'] ?? null) ? (int) $data['ordinamento'] : null,
'nord' => is_numeric($data['nord'] ?? null) ? (int) $data['nord'] : null,
'attiva' => (bool) ($data['attiva'] ?? true),
'note' => trim((string) ($data['note'] ?? '')) ?: null,
'meta_legacy' => $meta,
@ -526,12 +533,14 @@ public function table(Table $table): Table
->icon(fn(TabellaMillesimale $record): string => in_array((int) $record->id, $this->normalizeSelectedArchiveIds(), true) ? 'heroicon-o-check-circle' : 'heroicon-o-plus-circle')
->color(fn(TabellaMillesimale $record): string => in_array((int) $record->id, $this->normalizeSelectedArchiveIds(), true) ? 'primary' : 'gray')
->tooltip(fn(TabellaMillesimale $record): string => $this->getArchiveCleanupTooltip($record))
->action(fn(TabellaMillesimale $record): mixed => $this->toggleArchiveSelection((int) $record->id)),
->action(fn(TabellaMillesimale $record): mixed => $this->toggleArchiveSelection((int) $record->id))
->iconButton(),
Action::make('edit')
->label('Modifica')
->icon('heroicon-o-pencil-square')
->modalHeading('Modifica tabella millesimale')
->iconButton()
->form([
TextInput::make('codice_tabella')
->label('Codice')
@ -558,6 +567,12 @@ public function table(Table $table): Table
->numeric()
->rules(['nullable', 'numeric', 'min:0']),
TextInput::make('nord')
->label('NORD')
->numeric()
->rules(['nullable', 'integer', 'min:0'])
->helperText('Ordinamento legacy (es. 10 per TAB.A, 20 per TAB.A1).'),
TextInput::make('ordine_visualizzazione')
->label('Ord. (UI)')
->numeric()
@ -599,6 +614,7 @@ public function table(Table $table): Table
'totale_millesimi' => $record->getRawOriginal('totale_millesimi'),
'ordine_visualizzazione' => $record->ordine_visualizzazione,
'ordinamento' => $record->ordinamento,
'nord' => $record->nord,
'attiva' => (bool) ($record->attiva ?? true),
'note' => (string) ($record->note ?? ''),
];
@ -630,6 +646,7 @@ public function table(Table $table): Table
'totale_millesimi' => is_numeric($data['totale_millesimi'] ?? null) ? (float) $data['totale_millesimi'] : null,
'ordine_visualizzazione' => is_numeric($data['ordine_visualizzazione'] ?? null) ? (int) $data['ordine_visualizzazione'] : null,
'ordinamento' => is_numeric($data['ordinamento'] ?? null) ? (int) $data['ordinamento'] : null,
'nord' => is_numeric($data['nord'] ?? null) ? (int) $data['nord'] : null,
'attiva' => (bool) ($data['attiva'] ?? true),
'note' => trim((string) ($data['note'] ?? '')) ?: null,
'meta_legacy' => $meta,
@ -648,12 +665,14 @@ public function table(Table $table): Table
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn(TabellaMillesimale $record): mixed => $this->deleteSingleArchive($record)),
->action(fn(TabellaMillesimale $record): mixed => $this->deleteSingleArchive($record))
->iconButton(),
Action::make('prospetto')
->label('Prospetto')
->icon('heroicon-o-rectangle-group')
->url(fn(TabellaMillesimale $record) => static::getUrl(panel: 'admin-filament') . '?tab=prospetto&tabella_id=' . (int) $record->id),
->url(fn(TabellaMillesimale $record) => static::getUrl(panel: 'admin-filament') . '?tab=prospetto&tabella_id=' . (int) $record->id)
->iconButton(),
]);
}
@ -961,6 +980,14 @@ protected function loadTabelle(): void
$prev = $meta['tot_prev_euro'] ?? $meta['tot_prev'] ?? null;
$cons = $meta['tot_cons_euro'] ?? $meta['tot_cons'] ?? null;
$vociCount = 0;
if (Schema::hasTable('voci_spesa')) {
$vociCount = DB::table('voci_spesa')
->where('stabile_id', $this->stabileId)
->where('tabella_millesimale_default_id', $t->id)
->count();
}
return [
'id' => (int) $t->id,
'codice' => $codice,
@ -974,6 +1001,7 @@ protected function loadTabelle(): void
'is_bilanciata' => (bool) ($t->is_bilanciata ?? false),
'preventivo' => is_numeric($prev) ? (float) $prev : null,
'consuntivo' => is_numeric($cons) ? (float) $cons : null,
'voci_count' => $vociCount,
];
})
->values()
@ -1073,6 +1101,7 @@ protected function loadDettaglioTabella(): void
$percentuale = $totale > 0 ? ($millesimi / $totale) * 100 : 0;
return [
'id' => (int) $d->id,
'unita_id' => (int) ($u?->id ?? 0),
'codice_unita' => $u?->codice_unita ?? ($u?->codice_completo ?? null),
'codice_unita_display' => $this->formatCodiceUnita($u?->codice_unita ?? ($u?->codice_completo ?? null)),
@ -1084,6 +1113,8 @@ protected function loadDettaglioTabella(): void
'millesimi' => $millesimi,
'percentuale' => round($percentuale, 4),
'partecipa' => (bool) ($d->partecipa ?? true),
'nord' => $d->nord,
'ruolo_legacy' => $d->ruolo_legacy,
];
})
->values()
@ -1513,9 +1544,12 @@ public function saveRighe(): void
['righe' => $this->righe],
[
'righe' => ['array'],
'righe.*.id' => ['nullable', 'integer'],
'righe.*.unita_id' => ['required', 'integer', 'min:1'],
'righe.*.millesimi' => ['required', 'numeric', 'min:0'],
'righe.*.partecipa' => ['nullable', 'boolean'],
'righe.*.nord' => ['nullable', 'integer'],
'righe.*.ruolo_legacy' => ['nullable', 'string', 'max:100'],
]
)->validate();
@ -1548,16 +1582,28 @@ public function saveRighe(): void
$millesimi = (float) ($r['millesimi'] ?? 0);
$partecipa = ! empty($r['partecipa']) ? 1 : 0;
$nord = isset($r['nord']) && is_numeric($r['nord']) ? (int) $r['nord'] : null;
$ruolo = isset($r['ruolo_legacy']) ? trim((string) $r['ruolo_legacy']) : null;
$id = isset($r['id']) ? (int) $r['id'] : null;
$exists = null;
if ($id) {
$exists = DettaglioMillesimi::find($id);
}
if (! $exists) {
$exists = DettaglioMillesimi::query()
->where('tabella_millesimale_id', $this->tabellaId)
->where('unita_immobiliare_id', $unitaId)
->where('ruolo_legacy', $ruolo)
->first();
}
if ($exists) {
$exists->update([
'millesimi' => $millesimi,
'partecipa' => $partecipa,
'nord' => $nord,
'ruolo_legacy' => $ruolo ?: null,
]);
continue;
}
@ -1567,6 +1613,8 @@ public function saveRighe(): void
'unita_immobiliare_id' => $unitaId,
'millesimi' => $millesimi,
'partecipa' => $partecipa,
'nord' => $nord,
'ruolo_legacy' => $ruolo ?: null,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,

View File

@ -1,500 +0,0 @@
<?php
namespace App\Filament\Pages\Condomini;
use App\Models\DettaglioMillesimi;
use App\Models\TabellaMillesimale;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use UnitEnum;
class TabelleMillesimaliProspetto extends Page
{
protected int $stabileId = 0;
protected int $annoGestione = 0;
public ?int $tabellaId = null;
/**
* @var array<int, array<string, mixed>>
*/
public array $tabelle = [];
/**
* @var array<int, array<string, mixed>>
*/
public array $righe = [];
public ?array $tabellaInfo = null;
protected static ?string $navigationLabel = 'Tabelle millesimali · Prospetto';
protected static ?string $title = 'Tabelle millesimali · Prospetto';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-pie';
protected static UnitEnum|string|null $navigationGroup = 'Stabile';
protected static ?int $navigationSort = 41;
protected static ?string $slug = 'condomini/tabelle-millesimali/prospetto';
protected string $view = 'filament.pages.condomini.tabelle-millesimali-prospetto';
public function inizializzaRighe(): void
{
if (! $this->stabileId || ! $this->tabellaId) {
return;
}
if (! Schema::hasTable('dettaglio_millesimi')) {
Notification::make()
->title('Tabella dettagli non disponibile')
->body('La tabella dettaglio_millesimi non esiste nel database.')
->danger()
->send();
return;
}
$unitaIds = UnitaImmobiliare::query()
->where('stabile_id', $this->stabileId)
->orderBy('id')
->pluck('id')
->map(fn($v) => (int) $v)
->all();
if ($unitaIds === []) {
Notification::make()
->title('Nessuna unità')
->body('Non ci sono unità immobiliari nello stabile attivo.')
->warning()
->send();
return;
}
DB::transaction(function () use ($unitaIds): void {
$existing = DettaglioMillesimi::query()
->where('tabella_millesimale_id', $this->tabellaId)
->whereIn('unita_immobiliare_id', $unitaIds)
->pluck('unita_immobiliare_id')
->map(fn($v) => (int) $v)
->all();
$missing = array_values(array_diff($unitaIds, $existing));
if ($missing === []) {
return;
}
$userId = Auth::id();
$rows = array_map(function (int $unitaId) use ($userId): array {
return [
'tabella_millesimale_id' => (int) $this->tabellaId,
'unita_immobiliare_id' => $unitaId,
'millesimi' => 0,
'partecipa' => 1,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
];
}, $missing);
DB::table('dettaglio_millesimi')->insert($rows);
});
$this->loadDettaglioTabella();
Notification::make()
->title('Righe inizializzate')
->success()
->send();
}
public function saveRighe(): void
{
if (! $this->stabileId || ! $this->tabellaId) {
return;
}
$validated = Validator::make(
['righe' => $this->righe],
[
'righe' => ['array'],
'righe.*.unita_id' => ['required', 'integer', 'min:1'],
'righe.*.millesimi' => ['required', 'numeric', 'min:0'],
'righe.*.partecipa' => ['nullable', 'boolean'],
]
)->validate();
/** @var array<int, array<string, mixed>> $rows */
$rows = (array) ($validated['righe'] ?? []);
$unitaIds = array_values(array_unique(array_map(fn($r) => (int) ($r['unita_id'] ?? 0), $rows)));
$unitaIds = array_values(array_filter($unitaIds, fn(int $v) => $v > 0));
if ($unitaIds === []) {
return;
}
$allowedUnita = UnitaImmobiliare::query()
->where('stabile_id', $this->stabileId)
->whereIn('id', $unitaIds)
->pluck('id')
->map(fn($v) => (int) $v)
->all();
$allowedSet = array_fill_keys($allowedUnita, true);
$now = now();
$userId = Auth::id();
DB::transaction(function () use ($rows, $allowedSet, $now, $userId): void {
foreach ($rows as $r) {
$unitaId = (int) ($r['unita_id'] ?? 0);
if ($unitaId <= 0 || ! isset($allowedSet[$unitaId])) {
continue;
}
$millesimi = (float) ($r['millesimi'] ?? 0);
$partecipa = ! empty($r['partecipa']) ? 1 : 0;
$exists = DettaglioMillesimi::query()
->where('tabella_millesimale_id', $this->tabellaId)
->where('unita_immobiliare_id', $unitaId)
->first();
if ($exists) {
$exists->update([
'millesimi' => $millesimi,
'partecipa' => $partecipa,
]);
continue;
}
DettaglioMillesimi::query()->create([
'tabella_millesimale_id' => (int) $this->tabellaId,
'unita_immobiliare_id' => $unitaId,
'millesimi' => $millesimi,
'partecipa' => $partecipa,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
}
});
$this->loadDettaglioTabella();
Notification::make()
->title('Millesimi salvati')
->success()
->send();
}
public function getBackUrl(): ?string
{
$candidate = request()->query('back');
if (! is_string($candidate) || trim($candidate) === '') {
return null;
}
$candidate = trim($candidate);
// Allow relative URLs only.
if (str_starts_with($candidate, '/')) {
return $candidate;
}
$parts = parse_url($candidate);
if (! is_array($parts)) {
return null;
}
$host = $parts['host'] ?? null;
if ($host && $host !== request()->getHost()) {
return null;
}
$path = $parts['path'] ?? null;
if (! is_string($path) || $path === '') {
return null;
}
$query = isset($parts['query']) ? ('?' . $parts['query']) : '';
return $path . $query;
}
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$user = Auth::user();
if (! $user instanceof User) {
abort(403);
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()
->title('Nessuno stabile disponibile')
->body('Seleziona uno stabile per vedere le tabelle millesimali.')
->warning()
->send();
return;
}
$this->stabileId = $stabileId;
$this->annoGestione = AnnoGestioneContext::resolveActiveAnno($user);
$this->loadTabelle();
$requested = request()->integer('tabella_id') ?: null;
$this->tabellaId = $this->pickTabellaId($requested);
$this->loadDettaglioTabella();
}
protected function loadTabelle(): void
{
$q = TabellaMillesimale::query()
->perStabile($this->stabileId)
->when(Schema::hasColumn('tabelle_millesimali', 'anno_gestione') && $this->annoGestione, function ($qq) {
$qq->where(function ($q2) {
$q2->where('anno_gestione', $this->annoGestione)->orWhereNull('anno_gestione');
})->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END');
})
->ordinato();
$this->tabelle = $q
->get(['id', 'codice_tabella', 'nome_tabella', 'denominazione', 'tipo_tabella', 'tipo_calcolo', 'totale_millesimi', 'ordinamento', 'meta_legacy'])
->map(function (TabellaMillesimale $t) {
$codice = $t->codice_tabella ?: ($t->nome_tabella ?: ('TAB ' . $t->id));
$nome = $t->denominazione ?: ($t->nome_tabella_millesimale ?: ($t->nome_tabella ?: 'Tabella'));
$calcoloRaw = $this->resolveCalcoloRaw($t);
$isConsumo = $this->isConsumoCalcolo($calcoloRaw);
$calcoloLabel = $this->formatCalcoloLabel($calcoloRaw);
$tipoLabel = $this->resolveTipoLabel($t, $calcoloRaw);
$meta = is_array($t->meta_legacy)
? $t->meta_legacy
: (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null);
$meta = is_array($meta) ? $meta : [];
$prev = $meta['tot_prev_euro'] ?? $meta['tot_prev'] ?? null;
$cons = $meta['tot_cons_euro'] ?? $meta['tot_cons'] ?? null;
return [
'id' => (int) $t->id,
'codice' => $codice,
'nome' => $nome,
'tipo' => $tipoLabel,
'calcolo' => $calcoloLabel,
'is_consumo' => $isConsumo,
'totale' => is_numeric($t->totale_millesimi) ? (float) $t->totale_millesimi : null,
'ordinamento' => (int) ($t->ordinamento ?? 999999),
'is_bilanciata' => (bool) ($t->is_bilanciata ?? false),
'preventivo' => is_numeric($prev) ? (float) $prev : null,
'consuntivo' => is_numeric($cons) ? (float) $cons : null,
];
})
->values()
->all();
}
protected function pickTabellaId(?int $candidate): ?int
{
if ($candidate && collect($this->tabelle)->contains(fn($t) => (int) $t['id'] === $candidate)) {
return $candidate;
}
$first = $this->tabelle[0]['id'] ?? null;
return is_numeric($first) ? (int) $first : null;
}
protected function loadDettaglioTabella(): void
{
$this->righe = [];
$this->tabellaInfo = null;
if (! $this->tabellaId) {
return;
}
$tabella = TabellaMillesimale::query()
->perStabile($this->stabileId)
->with(['dettagliMillesimali.unitaImmobiliare', 'dettagliMillesimali.unitaImmobiliare.palazzinaObj'])
->when(Schema::hasColumn('tabelle_millesimali', 'anno_gestione') && $this->annoGestione, function ($qq) {
$qq->where(function ($q2) {
$q2->where('anno_gestione', $this->annoGestione)->orWhereNull('anno_gestione');
});
})
->whereKey($this->tabellaId)
->first();
if (! $tabella) {
return;
}
$totale = (float) ($tabella->calcolaTotaleMillesimi() ?? 0);
$codice = $tabella->codice_tabella ?: ($tabella->nome_tabella ?: ('TAB ' . $tabella->id));
$nome = $tabella->denominazione ?: ($tabella->nome_tabella_millesimale ?: ($tabella->nome_tabella ?: 'Tabella'));
$this->tabellaInfo = [
'id' => (int) $tabella->id,
'codice' => $codice,
'nome' => $nome,
'tipo' => $this->resolveTipoLabel($tabella, $this->resolveCalcoloRaw($tabella)),
'calcolo' => $this->formatCalcoloLabel($this->resolveCalcoloRaw($tabella)),
'is_consumo' => $this->isConsumoCalcolo($this->resolveCalcoloRaw($tabella)),
'totale' => $totale,
'is_bilanciata' => (bool) $tabella->isBilanciata(),
'preventivo' => $this->resolveLegacyImporto($tabella, 'tot_prev_euro', 'tot_prev'),
'consuntivo' => $this->resolveLegacyImporto($tabella, 'tot_cons_euro', 'tot_cons'),
];
$this->righe = $tabella->dettagliMillesimali
->filter(fn($d) => $d->unitaImmobiliare !== null)
->sortBy(function ($d) {
$u = $d->unitaImmobiliare;
return sprintf(
'%s|%s|%s|%s|%010d',
$u?->palazzina ?? '',
$u?->scala ?? '',
str_pad((string) ($u?->piano ?? 0), 6, '0', STR_PAD_LEFT),
$u?->interno ?? '',
(int) ($u?->id ?? 0)
);
})
->map(function ($d) use ($totale) {
$u = $d->unitaImmobiliare;
$millesimi = (float) ($d->millesimi ?? 0);
$percentuale = $totale > 0 ? ($millesimi / $totale) * 100 : 0;
return [
'unita_id' => (int) ($u?->id ?? 0),
'codice_unita' => $u?->codice_unita ?? ($u?->codice_completo ?? null),
'denominazione' => $u?->denominazione,
'palazzina' => $u?->palazzina,
'scala' => $u?->scala,
'piano' => $u?->piano,
'interno' => $u?->interno,
'millesimi' => $millesimi,
'percentuale' => round($percentuale, 4),
'partecipa' => (bool) ($d->partecipa ?? true),
];
})
->values()
->all();
}
private function resolveCalcoloRaw(TabellaMillesimale $t): ?string
{
$meta = is_array($t->meta_legacy)
? $t->meta_legacy
: (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null);
$v = is_array($meta) ? ($meta['calcolo'] ?? null) : null;
if (! $v) {
$v = $t->tipo_calcolo;
}
$v = strtolower(trim((string) ($v ?? '')));
if ($v === '') {
return null;
}
return match ($v) {
'm' => 'millesimi',
'a', 'c' => 'a_consumo',
'x' => 'conguagli',
'p' => 'personali',
default => $v,
};
}
private function formatCalcoloLabel(?string $calcolo): ?string
{
$c = strtolower(trim((string) ($calcolo ?? '')));
if ($c === '') {
return null;
}
return match ($c) {
'acqua', 'a', 'consumo', 'c', 'a_consumo' => 'A CONSUMO',
'millesimi', 'm' => 'MILLESIMI',
'conguagli', 'x' => 'CONGUAGLI',
'personali', 'p' => 'PERSONALI',
'parti' => 'PARTI',
'fisso' => 'FISSO',
default => strtoupper($c),
};
}
private function resolveTipoLabel(TabellaMillesimale $t, ?string $calcoloRaw): ?string
{
$meta = is_array($t->meta_legacy)
? $t->meta_legacy
: (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null);
$meta = is_array($meta) ? $meta : [];
$codice = strtoupper((string) ($t->codice_tabella ?? ''));
if ($codice === 'ACQUA' || $this->isConsumoCalcolo($calcoloRaw)) {
return 'A (Acqua)';
}
$ors = isset($meta['tipo']) ? strtoupper((string) $meta['tipo']) : null;
if ($ors) {
return match ($ors) {
'O' => 'O (Ordinaria)',
'R' => 'R (Riscaldamento)',
'S' => 'S (Straordinaria)',
default => $ors,
};
}
$tipologia = strtoupper((string) ($t->tipologia ?? ''));
if (in_array($tipologia, ['O', 'R', 'S'], true)) {
return match ($tipologia) {
'R' => 'R (Riscaldamento)',
'S' => 'S (Straordinaria)',
default => 'O (Ordinaria)',
};
}
$tipo = strtolower((string) ($t->tipo_tabella ?? ''));
if ($tipo === 'riscaldamento') return 'R (Riscaldamento)';
if ($tipo === 'straordinaria') return 'S (Straordinaria)';
if ($tipo === 'ordinaria') return 'O (Ordinaria)';
return $tipo !== '' ? strtoupper($tipo) : null;
}
private function resolveLegacyImporto(TabellaMillesimale $t, string $primary, string $fallback): ?float
{
$meta = is_array($t->meta_legacy)
? $t->meta_legacy
: (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null);
$meta = is_array($meta) ? $meta : [];
$value = $meta[$primary] ?? $meta[$fallback] ?? null;
return is_numeric($value) ? (float) $value : null;
}
private function isConsumoCalcolo(?string $calcolo): bool
{
$c = strtolower(trim((string) ($calcolo ?? '')));
return in_array($c, ['acqua', 'a', 'consumo', 'c', 'a_consumo'], true);
}
}

View File

@ -666,9 +666,13 @@ protected function getHeaderActions(): array
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream',
'application/x-mswrite',
'application/mswrite',
'.pdf',
'.csv',
'.txt',
'.wri',
'.qif',
'.xlsx',
]),
@ -818,6 +822,9 @@ protected function getHeaderActions(): array
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream',
'application/x-mswrite',
'application/mswrite',
'.csv',
'.txt',
'.wri',

View File

@ -34,6 +34,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use UnitEnum;
@ -41,6 +42,9 @@ class CasseBancheRiepilogo extends Page implements HasTable
{
use InteractsWithTable;
/** @var array<string, ?int> */
public array $interbankMappings = [];
protected static ?bool $supportsWindowFunctions = null;
protected static ?string $navigationLabel = 'Casse e banche';
@ -75,6 +79,7 @@ public static function canAccess(): bool
public function mount(): void
{
$this->mountInteractsWithTable();
$this->loadInterbankMappings();
}
public function getActiveStabile(): ?Stabile
@ -700,4 +705,371 @@ protected function resolveDefaultGestioneId(): ?string
return $match ? (string) $match->id : null;
}
public function loadInterbankMappings(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
return;
}
$codes = ['198', '219', '012', '048', '208', 'F24'];
$this->interbankMappings = [];
foreach ($codes as $code) {
$rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query()
->where('stabile_id', $stabileId)
->where('origine', 'banca')
->where('chiave', $code)
->first();
$this->interbankMappings[$code] = $rule ? (int) $rule->conto_avere_id : null;
}
}
public function saveInterbankMappings(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Seleziona uno stabile')->danger()->send();
return;
}
$bancaContoId = DB::table('contabilita_piano_conti')->where('codice', '1020')->value('id') ?: 7;
$labels = [
'198' => 'Canone Conto / Costo Fisso (198)',
'219' => 'Imposta di Bollo (219)',
'012' => 'Bollettino PagoPA / CBILL (012)',
'048' => 'Spese / Commissioni Bancarie (048)',
'208' => 'Interessi Passivi (208)',
'F24' => 'Deleghe F24 (RA)',
];
foreach ($this->interbankMappings as $code => $contoAvereId) {
if ($contoAvereId) {
\App\Modules\Contabilita\Models\RegolaPrimaNota::updateOrCreate([
'stabile_id' => $stabileId,
'origine' => 'banca',
'chiave' => $code,
], [
'label' => $labels[$code] ?? "Regola interbancaria {$code}",
'conto_dare_id' => $bancaContoId,
'conto_avere_id' => (int) $contoAvereId,
'attiva' => true,
]);
} else {
\App\Modules\Contabilita\Models\RegolaPrimaNota::query()
->where('stabile_id', $stabileId)
->where('origine', 'banca')
->where('chiave', $code)
->delete();
}
}
Notification::make()->title('Configurazione salvata con successo.')->success()->send();
}
/** @return array<string, string> */
public function getPianoContiOptions(): array
{
return DB::table('contabilita_piano_conti')
->where('attivo', true)
->orderBy('codice')
->get(['id', 'codice', 'denominazione'])
->mapWithKeys(fn($r) => [(string) $r->id => "{$r->codice} - {$r->denominazione}"])
->all();
}
public function riconciliaSpeseBancarieAutomaticamente(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Errore')->body('Seleziona uno stabile prima.')->danger()->send();
return;
}
$gestioneId = null;
if (Schema::hasTable('gestioni_contabili')) {
$gestioneId = $this->resolveDefaultGestioneId();
if ($gestioneId) {
$gestioneId = (int) $gestioneId;
}
}
if (! $gestioneId) {
Notification::make()->title('Errore')->body('Nessuna gestione contabile attiva e aperta trovata per questo stabile.')->danger()->send();
return;
}
$movements = \App\Modules\Contabilita\Models\MovimentoBanca::query()
->where('stabile_id', $stabileId)
->whereNull('registrazione_id')
->get();
if ($movements->isEmpty()) {
Notification::make()->title('Riconciliazione completata')->body('Nessun movimento bancario non registrato trovato.')->info()->send();
return;
}
$primaNotaService = new \App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService();
$fornitori = \App\Models\Fornitore::query()->get(['id', 'ragione_sociale']);
$registeredCosts = 0;
$matchedCbill = 0;
$matchedF24 = 0;
foreach ($movements as $m) {
$causale = trim((string) $m->causale);
$descText = strtoupper(($m->descrizione ?? '') . ' ' . ($m->descrizione_estesa ?? ''));
// Fallback per identificare spese bancarie, bollettini CBILL o F24 dalla descrizione
if (str_contains($descText, 'PAGAMENTO DELEGHE') || str_contains($descText, 'F24') || str_contains($descText, 'F23') || str_contains($descText, 'ENTRATEL') || str_contains($descText, 'PAGAMENTO FISCO')) {
$causale = 'F24';
} elseif ($causale === '' || ! in_array($causale, ['198', '219', '048', '208', '012'], true)) {
if (str_contains($descText, 'IMPOSTA BOLLO') || str_contains($descText, 'IMPOSTA DI BOLLO') || str_contains($descText, 'DPR642/72') || str_contains($descText, 'BOLLO CONTO')) {
$causale = '219';
} elseif (str_contains($descText, 'COMPETENZE') || str_contains($descText, 'INTERESSI') || str_contains($descText, 'ONERI') || str_contains($descText, 'TENUTA CONTO') || str_contains($descText, 'SPESE COMPILAZIONE')) {
$causale = '198';
} elseif (str_contains($descText, 'COMMISSION') || str_contains($descText, 'COMMESS')) {
$causale = '048';
} elseif (str_contains($descText, 'PAGAGO') || str_contains($descText, 'PAGOPA') || str_contains($descText, 'CBILL') || str_contains($descText, 'BOLLETTINO')) {
$causale = '012';
}
}
if ($causale !== $m->causale) {
$m->causale = $causale;
$m->save();
}
// A. Ritenute d'Acconto / F24
if ($causale === 'F24' && (float) $m->importo < 0) {
$outstandingRAs = \App\Models\RegistroRitenuteAcconto::query()
->where('stato_versamento', 'da_versare')
->whereHas('gestione', function ($q) use ($stabileId) {
$q->where('stabile_id', $stabileId);
})
->get();
$targetAmount = abs((float) $m->importo);
$matchedRAs = collect();
// Caso A.1: Cerca una singola ritenuta con importo esatto
$singleMatch = $outstandingRAs->first(fn($ra) => abs((float) $ra->importo_ritenuta - $targetAmount) < 0.01);
if ($singleMatch) {
$matchedRAs->push($singleMatch);
} else {
// Caso A.2: Cerca se la somma di tutte le ritenute in sospeso corrisponde esattamente al pagamento
$totalOutstandingSum = $outstandingRAs->sum(fn($ra) => (float) $ra->importo_ritenuta);
if (abs($totalOutstandingSum - $targetAmount) < 0.01) {
$matchedRAs = $outstandingRAs;
}
}
if ($matchedRAs->isNotEmpty()) {
foreach ($matchedRAs as $ra) {
$ra->stato_versamento = 'versata';
$ra->data_versamento = $m->data;
$ra->importo_versato = $ra->importo_ritenuta;
$ra->f24_riferimento = substr('F24 ' . $m->descrizione, 0, 100);
$ra->save();
}
// Registra in Prima Nota se è presente la regola F24
$rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query()
->where('stabile_id', $stabileId)
->where('origine', 'banca')
->where('chiave', 'F24')
->where('attiva', true)
->first();
if ($rule) {
try {
if (! $m->gestione_id) {
$m->gestione_id = $gestioneId;
$m->save();
}
$primaNotaService->generaRegistrazioneDaMovimento($user, $m);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error("Errore registrazione Prima Nota F24: " . $e->getMessage());
}
} else {
// Se non c'è una regola contabile configurata, creiamo comunque una registrazione di default per quadrare il movimento
try {
if (! $m->gestione_id) {
$m->gestione_id = $gestioneId;
$m->save();
}
$primaNotaService->generaRegistrazioneDaMovimento($user, $m);
} catch (\Throwable $e) {
// Ignora se fallisce
}
}
$matchedF24++;
continue;
}
}
// B. Spese Bancarie Standard (198, 219, 048, 208)
if (in_array($causale, ['198', '219', '048', '208'], true)) {
$rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query()
->where('stabile_id', $stabileId)
->where('origine', 'banca')
->where('chiave', $causale)
->where('attiva', true)
->first();
if ($rule) {
try {
if (! $m->gestione_id) {
$m->gestione_id = $gestioneId;
$m->save();
}
$primaNotaService->generaRegistrazioneDaMovimento($user, $m);
$registeredCosts++;
} catch (\Throwable $e) {
dump("EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString());
}
}
}
// C. PagoPA / CBILL (012)
if ($causale === '012' && (float) $m->importo < 0) {
// Tenta di estrarre un codice CBILL / avviso numerico di 15-20 cifre dalla descrizione
$cbillCode = null;
$descCombined = ($m->descrizione ?? '') . ' ' . ($m->descrizione_estesa ?? '');
if (preg_match('/\b([0-9]{15,20})\b/', $descCombined, $cbillMatches)) {
$cbillCode = trim($cbillMatches[1]);
}
$invoice = null;
$fornIdMatched = null;
if ($cbillCode) {
$invoiceRow = DB::table('contabilita_fatture_fornitori as f')
->join('fatture_elettroniche as fe', 'fe.id', '=', 'f.fattura_elettronica_id')
->where('f.stabile_id', $stabileId)
->where(function ($q) {
$q->where('f.stato', '!=', 'pagato')
->orWhereNull('f.stato')
->orWhereNull('f.data_pagamento');
})
->where(function ($q) use ($cbillCode) {
$q->where('fe.pagamento_cbill', $cbillCode)
->orWhere('fe.pagamento_codice_avviso', $cbillCode);
})
->select('f.*')
->first();
if ($invoiceRow) {
$invoice = $invoiceRow;
$fornIdMatched = $invoiceRow->fornitore_id;
}
}
// Fallback sul matching tramite ragione sociale del fornitore e importo
if (! $invoice) {
$descNormalized = $this->normalizeStringForMatching($descCombined);
foreach ($fornitori as $forn) {
$ragSociale = $this->normalizeStringForMatching((string) $forn->ragione_sociale);
if ($ragSociale !== '' && str_contains($descNormalized, $ragSociale)) {
$invoiceRow = DB::table('contabilita_fatture_fornitori')
->where('stabile_id', $stabileId)
->where('fornitore_id', $forn->id)
->where(function ($q) {
$q->where('stato', '!=', 'pagato')
->orWhereNull('stato')
->orWhereNull('data_pagamento');
})
->where(function ($q) use ($m) {
$targetVal = abs((float) $m->importo);
$q->where('totale', $targetVal)
->orWhere('netto_da_pagare', $targetVal);
})
->first();
if ($invoiceRow) {
$invoice = $invoiceRow;
$fornIdMatched = $forn->id;
break;
}
}
}
}
if ($invoice) {
DB::table('contabilita_fatture_fornitori')
->where('id', $invoice->id)
->update([
'stato' => 'pagato',
'data_pagamento' => $m->data,
'movimento_pagamento_id' => $m->id,
]);
$matchData = is_string($m->match_data) ? json_decode($m->match_data, true) : (is_array($m->match_data) ? $m->match_data : []);
$matchData['riconciliato_operativo'] = true;
$matchData['riconciliazione_tipo'] = 'fattura_fornitore';
$matchData['riconciliazione_label'] = 'Pagamento CBILL/PagoPA fattura #' . $invoice->numero_documento;
$matchData['riconciliazione_at'] = now()->toDateTimeString();
$matchData['fattura_fornitore_id'] = $invoice->id;
if ($fornIdMatched) {
$m->fornitore_id = $fornIdMatched;
}
$m->registrazione_id = $invoice->registrazione_id;
$m->match_data = $matchData;
if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) {
$m->da_confermare = false;
}
$m->save();
$matchedCbill++;
}
}
}
if ($registeredCosts > 0 || $matchedCbill > 0 || $matchedF24 > 0) {
Notification::make()
->title('Elaborazione completata')
->body("Registrate {$registeredCosts} spese bancarie. Riconciliati {$matchedCbill} bollettini CBILL e {$matchedF24} deleghe F24.")
->success()
->send();
} else {
Notification::make()->title('Nessuna operazione eseguita')->body('Non è stato possibile abbinare spese bancarie, bollettini CBILL o deleghe F24 con le regole attuali.')->warning()->send();
}
$this->loadInterbankMappings();
}
private function normalizeStringForMatching(string $str): string
{
$str = strtoupper($str);
// Rimuovi suffissi aziendali comuni per facilitare il matching
$str = str_replace(
['S.P.A.', 'S.R.L.', 'S.N.C.', 'S.P.A', 'S.R.L', 'S.N.C', ' SPA', ' SRL', ' SNC', ' COOP', ' S.S.', ' S.S', ' S.P.A.'],
' ',
$str
);
// Rimuovi punteggiatura e spazi multipli
$str = preg_replace('/[^A-Z0-9\s]/', '', $str);
$str = preg_replace('/\s+/', ' ', $str);
return trim($str);
}
}

View File

@ -0,0 +1,374 @@
<?php
namespace App\Filament\Pages\Contabilita;
use App\Models\User;
use App\Models\Stabile;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use UnitEnum;
class DebitiPagareHub extends Page
{
protected static ?string $title = 'Hub Pagamenti (Debiti da Pagare)';
protected static ?string $navigationLabel = 'Hub Pagamenti';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-credit-card';
protected static UnitEnum|string|null $navigationGroup = 'Contabilità';
protected static ?int $navigationSort = 12;
protected static ?string $slug = 'contabilita/debiti-da-pagare';
protected string $view = 'filament.pages.contabilita.debiti-pagare-hub';
public string $activeTab = 'debiti'; // 'debiti', 'movimenti', 'stato_debiti'
public ?string $targetDate = null; // Data di riferimento per calcolo debiti residui
/** @var array<int, array{id: int, numero_documento: string, data_documento: string, totale: float, netto_da_pagare: float, fornitore_ragione_sociale: string, iban: ?string, descrizione_bonifico: string}> */
public array $debiti = [];
/** @var array<int, array{id: int, data: string, descrizione: string, importo: float, stato: string, invoice_id: ?int, invoice_number: ?string, fornitore: ?string, advice: string}> */
public array $movimenti = [];
/** @var array<int, array{id: int, numero_documento: string, data_documento: string, totale: float, netto_da_pagare: float, fornitore: string, stato_corrente: string, data_chiusura: string}> */
public array $debitiStorici = [];
public float $totaleDebitiStorici = 0.0;
public static function canAccess(): bool
{
$user = Auth::user();
if (! $user instanceof User) {
return false;
}
if ($user->hasAnyRole(['super-admin', 'admin'])) {
return true;
}
return $user->can('contabilita.prima-nota') || $user->can('contabilita.fatture-fornitori');
}
public function getActiveStabile(): ?Stabile
{
$user = Auth::user();
if (! $user instanceof User) {
return null;
}
return StabileContext::getActiveStabile($user);
}
public function mount(): void
{
$stabile = $this->getActiveStabile();
if ($stabile) {
$gestioneId = $this->resolveActiveGestioneId((int) $stabile->id);
if ($gestioneId) {
$gestione = DB::table('gestioni_contabili')->where('id', $gestioneId)->first();
if ($gestione && $gestione->data_fine) {
$this->targetDate = $gestione->data_fine;
}
}
}
if (! $this->targetDate) {
$this->targetDate = now()->format('Y-12-31');
}
$this->loadTabContent();
}
public function setTab(string $tab): void
{
$this->activeTab = $tab;
$this->loadTabContent();
}
public function updatedTargetDate(): void
{
$this->loadTabContent();
}
public function loadTabContent(): void
{
if ($this->activeTab === 'debiti') {
$this->loadDebiti();
} elseif ($this->activeTab === 'movimenti') {
$this->loadMovimenti();
} elseif ($this->activeTab === 'stato_debiti') {
$this->loadDebitiStorici();
}
}
public function loadDebiti(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('contabilita_fatture_fornitori')) {
$this->debiti = [];
return;
}
$select = [
'f.id',
'f.numero_documento',
'f.data_documento',
'f.totale',
'f.netto_da_pagare',
'f.stato',
'f.fornitore_id',
'f.fattura_elettronica_id',
];
$query = DB::table('contabilita_fatture_fornitori as f')
->where('f.stabile_id', $stabileId)
->where(function ($q) {
$q->where('f.stato', '!=', 'pagato')
->orWhereNull('f.stato');
});
if (Schema::hasTable('fornitori')) {
$query->leftJoin('fornitori as forn', 'forn.id', '=', 'f.fornitore_id');
$select[] = 'forn.ragione_sociale as fornitore_ragione_sociale';
$select[] = 'forn.iban as fornitore_iban';
}
if (Schema::hasTable('fatture_elettroniche')) {
$query->leftJoin('fatture_elettroniche as fe', 'fe.id', '=', 'f.fattura_elettronica_id');
$select[] = 'fe.pagamento_iban as fe_pagamento_iban';
}
$dbRows = $query->select($select)
->orderBy('f.data_documento')
->orderBy('f.id')
->get();
$this->debiti = [];
foreach ($dbRows as $row) {
$iban = null;
if (isset($row->fe_pagamento_iban) && $row->fe_pagamento_iban) {
$iban = $row->fe_pagamento_iban;
}
if (! $iban && isset($row->fornitore_iban)) {
$iban = $row->fornitore_iban;
}
$id = (int) $row->id;
$fornitoreName = $row->fornitore_ragione_sociale ?? 'Fornitore Sconosciuto';
$numDoc = $row->numero_documento ?? 'N/D';
$prefix = "FAT-ID:{$id} ";
$suffix = " N.{$numDoc}";
$avail = 140 - strlen($prefix) - strlen($suffix);
$cleanName = substr($fornitoreName, 0, max(0, $avail));
$descrizione = $prefix . $cleanName . $suffix;
$this->debiti[] = [
'id' => $id,
'numero_documento' => $numDoc,
'data_documento' => $row->data_documento ? Carbon::parse($row->data_documento)->format('d/m/Y') : '—',
'totale' => (float) $row->totale,
'netto_da_pagare' => (float) ($row->netto_da_pagare ?? $row->totale),
'fornitore_ragione_sociale' => $fornitoreName,
'iban' => $iban ? strtoupper(str_replace(' ', '', $iban)) : null,
'descrizione_bonifico' => $descrizione,
];
}
}
public function loadMovimenti(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('contabilita_movimenti_banca')) {
$this->movimenti = [];
return;
}
$query = DB::table('contabilita_movimenti_banca as m')
->where('m.stabile_id', $stabileId);
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$query->leftJoin('contabilita_fatture_fornitori as f', 'f.movimento_pagamento_id', '=', 'm.id');
}
if (Schema::hasTable('fornitori')) {
$query->leftJoin('fornitori as forn', 'forn.id', '=', 'm.fornitore_id');
}
$select = [
'm.id',
'm.data',
'm.descrizione',
'm.descrizione_estesa',
'm.importo',
'm.registrazione_id',
'm.causale',
];
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$select[] = 'f.id as invoice_id';
$select[] = 'f.numero_documento as invoice_number';
}
if (Schema::hasTable('fornitori')) {
$select[] = 'forn.ragione_sociale as fornitore_ragione_sociale';
}
$rows = $query->select($select)
->orderByDesc('m.data')
->orderByDesc('m.id')
->get();
$this->movimenti = [];
foreach ($rows as $r) {
$invId = isset($r->invoice_id) ? $r->invoice_id : null;
$invNum = isset($r->invoice_number) ? $r->invoice_number : null;
$forn = isset($r->fornitore_ragione_sociale) ? $r->fornitore_ragione_sociale : null;
$isReconciled = ! empty($r->registrazione_id) || ! empty($invId);
$advice = '';
if (! $isReconciled) {
if ((float) $r->importo < 0) {
if (in_array((string) $r->causale, ['198', '219', '048', '208'], true)) {
$advice = 'Spesa bancaria diretta. Configura la regola interbancaria per registrarla in prima nota.';
} else {
$advice = 'Uscita bancaria. Registra la fattura fornitore corrispondente o riconcilia manualmente.';
}
} else {
$advice = 'Entrata/Incasso rate. Riconcilia con le rate o gli incassi del condominio.';
}
}
$this->movimenti[] = [
'id' => (int) $r->id,
'data' => $r->data ? Carbon::parse($r->data)->format('d/m/Y') : '—',
'descrizione' => $r->descrizione ?: 'Movimento banca',
'importo' => (float) $r->importo,
'stato' => $isReconciled ? 'quadrato' : 'da_quadrare',
'invoice_id' => $invId,
'invoice_number' => $invNum,
'fornitore' => $forn,
'advice' => $advice,
];
}
}
public function loadDebitiStorici(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId || ! Schema::hasTable('contabilita_fatture_fornitori') || ! $this->targetDate) {
$this->debitiStorici = [];
$this->totaleDebitiStorici = 0.0;
return;
}
$query = DB::table('contabilita_fatture_fornitori as f')
->where('f.stabile_id', $stabileId)
->where('f.data_documento', '<=', $this->targetDate)
->where(function ($q) {
$q->where('f.stato', '!=', 'pagato')
->orWhereNull('f.stato')
->orWhereNull('f.data_pagamento')
->orWhere('f.data_pagamento', '>', $this->targetDate);
});
if (Schema::hasTable('fornitori')) {
$query->leftJoin('fornitori as forn', 'forn.id', '=', 'f.fornitore_id');
}
$select = [
'f.id',
'f.numero_documento',
'f.data_documento',
'f.totale',
'f.netto_da_pagare',
'f.stato',
'f.data_pagamento',
];
if (Schema::hasTable('fornitori')) {
$select[] = 'forn.ragione_sociale as fornitore_ragione_sociale';
}
$rows = $query->select($select)
->orderBy('f.data_documento')
->orderBy('f.id')
->get();
$this->debitiStorici = [];
$this->totaleDebitiStorici = 0.0;
foreach ($rows as $row) {
$tot = (float) $row->totale;
$due = (float) ($row->netto_da_pagare ?? $row->totale);
$this->totaleDebitiStorici += $due;
$chiusoAl = 'Ancora aperto';
if ($row->stato === 'pagato' && $row->data_pagamento) {
$chiusoAl = Carbon::parse($row->data_pagamento)->format('d/m/Y');
}
$this->debitiStorici[] = [
'id' => (int) $row->id,
'numero_documento' => $row->numero_documento ?? 'N/D',
'data_documento' => $row->data_documento ? Carbon::parse($row->data_documento)->format('d/m/Y') : '—',
'totale' => $tot,
'netto_da_pagare' => $due,
'fornitore' => $row->fornitore_ragione_sociale ?? 'Fornitore Sconosciuto',
'stato_corrente' => $row->stato ?? 'N/D',
'data_chiusura' => $chiusoAl,
];
}
}
private function resolveActiveGestioneId(int $stabileId): ?int
{
$anno = \App\Support\AnnoGestioneContext::resolveActiveAnno();
$tipo = \App\Support\GestioneContext::resolveActiveGestione();
$match = DB::table('gestioni_contabili')
->where('stabile_id', $stabileId)
->where('anno_gestione', $anno)
->where('tipo_gestione', $tipo)
->where('stato', 'aperta')
->orderByDesc('gestione_attiva')
->orderByDesc('id')
->first();
if (! $match) {
$match = DB::table('gestioni_contabili')
->where('stabile_id', $stabileId)
->where('stato', 'aperta')
->orderByDesc('gestione_attiva')
->orderByDesc('id')
->first();
}
return $match ? (int) $match->id : null;
}
}

View File

@ -2843,6 +2843,138 @@ public function getFornitoreOverview(): ?array
];
}
/**
* @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.

View File

@ -517,13 +517,9 @@ public function downloadCassettoQuarter(string $quarter, string $dal, string $al
return;
}
$force = false;
if ($this->isCassettoQuarterCompleteFor($amministratoreId, (int) $stabile->id, $dal, $al)) {
Notification::make()
->title('Trimestre già completo')
->body($this->safeUtf8("{$quarter} {$anno} risulta già scaricato (COMPLETO). Per rieseguire lo stesso periodo usa 'Scarica da Cassetto Fiscale' e abilita 'Reimport / completa' oppure 'Non saltare'."))
->warning()
->send();
return;
$force = true;
}
$cfSoggetto = $this->resolveStabileSoggettoCf($stabile);
@ -544,7 +540,7 @@ public function downloadCassettoQuarter(string $quarter, string $dal, string $al
false,
(int) $user->id,
[
'force_update' => false,
'force_update' => $force,
'no_skip' => true,
'importa_righe' => 'auto',
'create_fornitore_if_missing' => true,

View File

@ -1,7 +1,7 @@
<?php
namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Condomini\TabelleMillesimaliProspetto;
use App\Filament\Pages\Condomini\TabelleMillesimaliArchivio;
use App\Filament\Pages\Contabilita\VoceSpesaMastrino;
use App\Models\GestioneContabile;
use App\Models\RipartizioneSpeseInquilini;
@ -103,7 +103,7 @@ protected function getHeaderActions(): array
Action::make('tabelle')
->label('Tabelle millesimali')
->icon('heroicon-o-rectangle-stack')
->url(TabelleMillesimaliProspetto::getUrl(panel: 'admin-filament')),
->url(TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') . '?tab=prospetto'),
Action::make('importaVociDaAnno')
->label('Importa voci da anno')

View File

@ -326,7 +326,7 @@ public function table(Table $table): Table
Action::make('apri_stabile_classica')
->label('Apri stabile (UI classica)')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn(GestioneContabile $record) => route('admin.stabili.show', $record->stabile_id), shouldOpenInNewTab: true),
->url(fn(GestioneContabile $record) => route('admin.stabili.show', $record->stabile_id)),
]);
}

View File

@ -54,6 +54,10 @@ class ImportazioneArchivi extends Page implements HasForms
public static function canAccess(): bool
{
if (! config('netgescon.legacy_import_enabled', true)) {
return false;
}
$user = Auth::user();
return ModuleVisibility::canAccessGesconInternal($user, ['super-admin', 'admin', 'amministratore'])
@ -408,12 +412,16 @@ public function form(Schema $schema): Schema
->label('Importa anche modello F24 da generale_stabile.mdb')
->helperText('Legge F24.cod_tributo_1..6, Rateiz_1..6, Anno_1..6 e importi correlati.'),
\Filament\Schemas\Components\Section::make('Seleziona dati da importare / sincronizzare')
->description('Seleziona i moduli da importare e clicca sul pulsante in basso per avviare l\'importazione dei soli dati selezionati.')
->columns(3)
->schema([
Checkbox::make('with_unita')->label('Unità'),
Checkbox::make('with_soggetti')->label('Anagrafiche (soggetti)'),
Checkbox::make('with_diritti')->label('Diritti'),
Checkbox::make('with_millesimi')->label('Millesimi'),
Checkbox::make('with_voci')
->label('Voci')
->label('Voci di spesa')
->reactive()
->afterStateUpdated(function (Set $set, $state): void {
if ($state) {
@ -424,7 +432,7 @@ public function form(Schema $schema): Schema
Checkbox::make('with_gestioni')->label('Gestioni (anni)'),
Checkbox::make('with_assemblee')->label('Assemblee legacy'),
Checkbox::make('with_operazioni')
->label('Operazioni')
->label('Operazioni (Movimenti)')
->reactive()
->afterStateUpdated(function (Set $set, $state): void {
if ($state) {
@ -452,6 +460,21 @@ public function form(Schema $schema): Schema
Checkbox::make('with_detrazioni')->label('Detrazioni fiscali'),
Checkbox::make('sync_staging')->label('Aggiorna staging prima di importare'),
Checkbox::make('dry_run')->label('Dry-run (non scrive nel DB)'),
Checkbox::make('update_only')
->label('Solo aggiornamento incrementale (non cancella i record esistenti)')
->default(true),
\Filament\Schemas\Components\Actions::make([
\Filament\Actions\Action::make('importa_selezionati_btn')
->label('Avvia Sincronizzazione / Importazione Dati Selezionati')
->icon('heroicon-m-arrow-down-tray')
->color('success')
->requiresConfirmation()
->action(function (\App\Services\GesconImport\EssentialImportService $service) {
$this->eseguiImportSelezionato($service);
})
])->columnSpanFull()
]),
]);
}
@ -1045,6 +1068,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'with_straord' => (bool) ($state['with_straord'] ?? false),
'with_addebiti' => (bool) ($state['with_addebiti'] ?? false),
'with_detrazioni' => (bool) ($state['with_detrazioni'] ?? false),
'update_only' => (bool) ($state['update_only'] ?? true),
];
if (! empty($options['with_operazioni'])) {
@ -1054,6 +1078,23 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
$options['with_unita'] = true;
$options['with_soggetti'] = true;
$options['with_diritti'] = true;
// Sincronizzazione automatica Fornitori collegata
try {
$fornMdb = (string) ($state['fornitori_mdb'] ?? '');
if ($fornMdb === '') {
$fornMdb = rtrim($options['path'], '/') . '/dbc/Fornitori.mdb';
}
if (is_file($fornMdb)) {
Artisan::call('gescon:auto-sync-fornitori', [
'--fornitori-mdb' => $fornMdb,
'--path-root' => $options['path'],
'--apply' => true,
]);
}
} catch (\Throwable $e) {
// Silently skip or log
}
}
if (! empty($options['with_rate'])) {
@ -1157,7 +1198,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'anagr_casse',
'--stabile' => $code,
'--legacy-year' => $legacyDir,
'--truncate' => $legacyDir === array_key_first($this->resolveLegacySingoloMdbPaths($code, (string) $options['path'], $legacyYearArg !== '' ? $legacyYearArg : null)),
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
}
@ -1169,7 +1210,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 's_cassa',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
@ -1178,7 +1219,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'operazioni',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
@ -1187,7 +1228,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'voc_spe',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
@ -1196,7 +1237,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'tabelle',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
@ -1208,7 +1249,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'incassi',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
}
@ -1219,7 +1260,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'emes_gen',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
@ -1228,7 +1269,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
'--table' => 'emes_det',
'--stabile' => $code,
'--legacy-year' => (string) ($options['anno'] ?? ''),
'--truncate' => true,
'--refresh-slice' => true,
]);
$outputs[] = trim((string) Artisan::output());
}
@ -1253,6 +1294,9 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
if (! empty($options['dry'])) {
$params['--dry-run'] = true;
}
if (! empty($options['update_only'])) {
$params['--update-only'] = true;
}
Artisan::call('gescon:import-full', $params);
$outputs[] = trim((string) Artisan::output());
@ -1700,6 +1744,7 @@ private function upsertSelectedStabileFromImportState(Amministratore $amm): Stab
'attivo' => true,
]);
$stabile->save();
$stabile->syncRubricaContatto();
return $stabile->fresh();
}

View File

@ -539,6 +539,8 @@ public function getLegacyGestioniHubProperty(): array
return [];
}
$totals = collect();
if (Schema::connection('gescon_import')->hasTable('operazioni')) {
$totals = DB::connection('gescon_import')
->table('operazioni')
->where('cod_stabile', $legacyCode)
@ -552,6 +554,7 @@ public function getLegacyGestioniHubProperty(): array
->groupBy('legacy_year', 'gestione')
->get()
->groupBy(fn($row) => trim((string) ($row->legacy_year ?? '')));
}
$rows = [];
foreach ($legacyAnnualMap as $cartella => $meta) {
@ -584,6 +587,10 @@ public function getAcquaSummaryProperty(): array
$legacyCode = $this->resolveLegacyCodiceStabile();
$legacyYear = $this->resolveLegacyYearForRiparto($legacyCode);
if (! Schema::connection('gescon_import')->hasTable('operazioni')) {
return [];
}
$rowsQuery = DB::connection('gescon_import')
->table('operazioni')
->where('gestione', 'O')
@ -734,6 +741,7 @@ private function normalizeAcquaNaturalToken(string $value): string
public ?int $dettPersNSpe = null;
public array $dettPersRows = [];
public string $dettPersSearch = '';
public float $dettPersTotale = 0.0;
public float $dettPersImportoOperazione = 0.0;
public float $dettPersDelta = 0.0;
@ -1435,6 +1443,8 @@ public function getConsuntivoProperty(): array
}
}
$i05Operazioni = collect();
if (Schema::connection('gescon_import')->hasTable('operazioni')) {
$i05Operazioni = DB::connection('gescon_import')
->table('operazioni')
->where('cod_spe', 'I05')
@ -1444,6 +1454,7 @@ public function getConsuntivoProperty(): array
)
->when($this->filterAnno, fn($q) => $q->whereYear('dt_spe', $this->filterAnno))
->get(['n_spe', 'importo_euro', 'importo']);
}
$nSpeI05 = $i05Operazioni
->pluck('n_spe')
@ -3112,6 +3123,10 @@ public function openDettPersModal(int $nSpe): void
{
$legacyCode = $this->resolveLegacyCodiceStabile();
if (! Schema::connection('gescon_import')->hasTable('operazioni')) {
return;
}
$operazione = DB::connection('gescon_import')
->table('operazioni')
->where('n_spe', $nSpe)
@ -3712,6 +3727,7 @@ private function buildOperazioniQuery()
$query->select([
'o.id as id_operaz',
'o.protocollo_numero as n_spe',
'o.data_operazione as dt_spe',
'g.anno_gestione as legacy_year',
DB::raw("case when g.tipo_gestione = 'ordinaria' then 'O' when g.tipo_gestione = 'riscaldamento' then 'R' else 'S' end as gestione"),
@ -3732,6 +3748,37 @@ private function buildOperazioniQuery()
return $query;
}
if (! Schema::connection('gescon_import')->hasTable('operazioni')) {
return DB::table('operazioni_contabili as o')
->leftJoin('gestioni_contabili as g', 'g.id', '=', 'o.gestione_id')
->whereRaw('1 = 0')
->select([
'o.id as id_operaz',
'o.protocollo_numero as n_spe',
'o.data_operazione as dt_spe',
'g.anno_gestione as legacy_year',
DB::raw("case when g.tipo_gestione = 'ordinaria' then 'O' when g.tipo_gestione = 'riscaldamento' then 'R' else 'S' end as gestione"),
'o.compet',
'o.conto_contabile as cod_spe',
DB::raw("null as tabella"),
DB::raw("null as cod_for"),
'o.descrizione as benef',
'o.n_stra',
'o.protocollo_completo',
DB::raw("coalesce(o.dare, o.avere, 0) as importo_euro"),
DB::raw("coalesce(o.dare, o.avere, 0) as importo"),
'o.num_fat',
'o.fe_uid',
'o.voce_spesa_snapshot',
'o.fornitore_snapshot',
DB::raw("0 as importo_spese"),
DB::raw("0 as importo_entrate"),
DB::raw("0 as importo_debiti"),
DB::raw("0 as importo_crediti"),
DB::raw("null as natura2"),
]);
}
$query = DB::connection('gescon_import')->table('operazioni');
$legacyCode = $this->resolveLegacyCodiceStabile();
@ -3973,4 +4020,40 @@ private function applyOperazioniSorting($query)
return $query;
}
public function riallineaScrittureLegacy(): void
{
$activeStabileId = \App\Support\StabileContext::resolveActiveStabileId(\Illuminate\Support\Facades\Auth::user());
if (! $activeStabileId) {
\Filament\Notifications\Notification::make()->title('Nessuno stabile attivo')->danger()->send();
return;
}
try {
$params = [
'--stabile-id' => $activeStabileId,
'--tipo' => 'all',
];
if ($this->filterAnno) {
$params['--year'] = (int) $this->filterAnno;
}
\Illuminate\Support\Facades\Artisan::call('gescon:sync-operazioni-prima-nota', $params);
\Filament\Notifications\Notification::make()
->title('Riallineamento completato')
->body('Le scritture contabili legacy sono state riallineate con successo.')
->success()
->send();
$this->resetPage();
} catch (\Throwable $e) {
\Filament\Notifications\Notification::make()
->title('Errore durante il riallineamento')
->body($e->getMessage())
->danger()
->send();
}
}
}

View File

@ -344,6 +344,51 @@ protected function getHeaderActions(): array
->action(function (array $data): void {
$this->createArchivioFolder($data);
}),
Action::make('esporta_archivio_xml')
->label('Esporta archivio (XML)')
->icon('heroicon-o-arrow-down-tray')
->color('info')
->visible(fn(): bool => $this->hubTab === 'archivio' && $this->archiveSection === 'fisico')
->action(function () {
$stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0);
if ($stabileId <= 0) {
Notification::make()->title('Errore')->body('Nessuno stabile attivo selezionato.')->danger()->send();
return;
}
$xml = $this->exportPhysicalArchiveToXml();
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'archivio_fisico_stabile_' . $stabileId . '.xml', [
'Content-Type' => 'application/xml',
]);
}),
Action::make('importa_archivio_xml')
->label('Importa archivio (XML)')
->icon('heroicon-o-arrow-up-tray')
->color('success')
->visible(fn(): bool => $this->hubTab === 'archivio' && $this->archiveSection === 'fisico')
->form([
FileUpload::make('xml_file')
->label('Seleziona il file XML')
->required()
->acceptedFileTypes(['application/xml', 'text/xml']),
])
->action(function (array $data): void {
$filePath = Storage::path($data['xml_file']);
if (!file_exists($filePath)) {
Notification::make()->title('Errore')->body('File non trovato.')->danger()->send();
return;
}
$content = file_get_contents($filePath);
try {
$imported = $this->importPhysicalArchiveFromXml($content);
Notification::make()->title('Importazione completata')->body("Importati con successo {$imported} elementi nell'archivio fisico.")->success()->send();
} catch (\Throwable $e) {
Notification::make()->title('Errore importazione')->body($e->getMessage())->danger()->send();
}
}),
];
}
@ -3804,4 +3849,164 @@ private function normalizeNullableText(mixed $value): ?string
return $value !== '' ? $value : null;
}
public function exportPhysicalArchiveToXml(): string
{
$stabile = $this->resolveActiveStabile();
$stabileId = (int) ($stabile?->id ?? 0);
if ($stabileId <= 0) {
return '<archive></archive>';
}
$items = DocumentoArchivioFisicoItem::query()
->where('stabile_id', $stabileId)
->with('parent')
->get();
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$root = $dom->createElement('netgescon_physical_archive');
$root->setAttribute('stabile_id', (string) $stabileId);
$root->setAttribute('denominazione', (string) ($stabile?->denominazione ?? ''));
$dom->appendChild($root);
$itemsNode = $dom->createElement('items');
$root->appendChild($itemsNode);
foreach ($items as $item) {
$itemNode = $dom->createElement('item');
$fields = [
'codice_univoco' => $item->codice_univoco,
'parent_code' => $item->parent?->codice_univoco ?? '',
'tipo_item' => $item->tipo_item,
'titolo' => $item->titolo,
'note' => $item->note,
'data_archiviazione' => $item->data_archiviazione?->toDateString() ?? '',
'supporto_fisico' => $item->supporto_fisico,
'modello_etichetta' => $item->modello_etichetta,
'magazzino' => $item->magazzino,
'scaffale' => $item->scaffale,
'ripiano' => $item->ripiano,
'ubicazione_dettaglio' => $item->ubicazione_dettaglio,
'stato' => $item->stato,
'sort_order' => $item->sort_order,
'voce_numero' => $item->voce_numero,
'voce_titolo' => $item->voce_titolo,
];
foreach ($fields as $key => $val) {
if ($val !== null && $val !== '') {
$child = $dom->createElement($key);
$child->appendChild($dom->createTextNode((string) $val));
$itemNode->appendChild($child);
}
}
$itemsNode->appendChild($itemNode);
}
return $dom->saveXML();
}
public function importPhysicalArchiveFromXml(string $xmlContent): int
{
$stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0);
if ($stabileId <= 0) {
throw new \Exception('Nessuno stabile attivo selezionato.');
}
$dom = new \DOMDocument();
if (!@$dom->loadXML($xmlContent)) {
throw new \Exception('Formato XML non valido.');
}
$xpath = new \DOMXPath($dom);
$nodes = $xpath->query('//item');
if ($nodes->length === 0) {
return 0;
}
$codeMap = []; // mappa old_code => new_item_id
// Raccogliamo tutti gli item in un array
$itemsData = [];
foreach ($nodes as $node) {
$data = [];
foreach ($node->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$data[$child->nodeName] = trim($child->nodeValue);
}
}
if (!empty($data['codice_univoco'])) {
$itemsData[] = $data;
}
}
// Importazione in passaggi successivi per rispettare la gerarchia
$insertedCount = 0;
$maxAttempts = 10;
$attempts = 0;
while (count($itemsData) > 0 && $attempts < $maxAttempts) {
$attempts++;
$remaining = [];
foreach ($itemsData as $data) {
$parentCode = $data['parent_code'] ?? '';
// Se ha un parent_code ma quel parent non è ancora stato importato, posticipiamo
if ($parentCode !== '' && !isset($codeMap[$parentCode])) {
// Controlliamo se il parent esiste già nel DB dello stabile attivo
$existingParent = DocumentoArchivioFisicoItem::query()
->where('stabile_id', $stabileId)
->where('codice_univoco', $parentCode)
->first();
if ($existingParent) {
$codeMap[$parentCode] = $existingParent->id;
} else {
// Il parent non è ancora nel DB né importato in questa sessione: posticipa
$remaining[] = $data;
continue;
}
}
$parentId = $parentCode !== '' ? ($codeMap[$parentCode] ?? null) : null;
// Generiamo un nuovo codice univoco per evitare conflitti con quelli esistenti dello stabile
$newCode = DocumentoArchivioFisicoItem::generateCodice($stabileId);
$newItem = DocumentoArchivioFisicoItem::create([
'stabile_id' => $stabileId,
'parent_id' => $parentId,
'codice_univoco' => $newCode,
'tipo_item' => $data['tipo_item'] ?? 'documento',
'titolo' => $data['titolo'] ?? 'Importato',
'note' => $data['note'] ?? null,
'data_archiviazione' => !empty($data['data_archiviazione']) ? $data['data_archiviazione'] : now()->toDateString(),
'supporto_fisico' => $data['supporto_fisico'] ?? null,
'modello_etichetta' => $data['modello_etichetta'] ?? '11354',
'magazzino' => $data['magazzino'] ?? null,
'scaffale' => $data['scaffale'] ?? null,
'ripiano' => $data['ripiano'] ?? null,
'ubicazione_dettaglio' => $data['ubicazione_dettaglio'] ?? null,
'stato' => $data['stato'] ?? 'disponibile',
'sort_order' => isset($data['sort_order']) ? (int) $data['sort_order'] : 0,
'voce_numero' => isset($data['voce_numero']) ? (int) $data['voce_numero'] : null,
'voce_titolo' => $data['voce_titolo'] ?? null,
'created_by' => Auth::id(),
]);
$codeMap[$data['codice_univoco']] = $newItem->id;
$insertedCount++;
}
$itemsData = $remaining;
}
return $insertedCount;
}
}

View File

@ -0,0 +1,337 @@
<?php
namespace App\Filament\Pages\SuperAdmin;
use App\Models\FatturaElettronica;
use App\Models\Documento;
use App\Models\User;
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\GasLucePdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiRiscaldamentoIngestionService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action as TableAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use UnitEnum;
class EstrattoreFatturePdf extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Estrattore PDF Utenze';
protected static ?string $title = 'Estrattore PDF Utenze';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
protected static UnitEnum|string|null $navigationGroup = 'Super Admin';
protected static ?int $navigationSort = 27;
protected static ?string $slug = 'superadmin/estrattore-pdf';
protected string $view = 'filament.pages.gescon.table';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User && $user->hasRole('super-admin');
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
protected function getTableQuery(): Builder
{
return FatturaElettronica::query()
->with(['stabile', 'fornitore'])
->whereNotNull('allegato_pdf_path')
->where('allegato_pdf_path', '!=', '');
}
public function table(Table $table): Table
{
return $table
->striped()
->defaultSort('data_fattura', 'desc')
->columns([
TextColumn::make('stabile.denominazione')
->label('Stabile')
->searchable()
->sortable(),
TextColumn::make('fornitore_denominazione')
->label('Fornitore')
->searchable()
->sortable()
->description(fn (FatturaElettronica $record): string => "P.IVA: " . $record->fornitore_piva),
TextColumn::make('numero_fattura')
->label('Numero')
->searchable()
->sortable(),
TextColumn::make('data_fattura')
->label('Data')
->date('d/m/Y')
->sortable(),
TextColumn::make('totale')
->label('Totale')
->money('EUR')
->sortable(),
IconColumn::make('has_ocr')
->label('OCR Ready')
->boolean()
->getStateUsing(function (FatturaElettronica $record): bool {
$doc = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', $record->id)
->first();
return $doc && !empty($doc->contenuto_ocr);
}),
TextColumn::make('dati_utenza')
->label('Dati Utenza Estratti')
->html()
->getStateUsing(function (FatturaElettronica $record): string {
$pdr = $record->acqua_contatore_matricola ?: '-';
$cliente = $record->acqua_codice_cliente ?: '-';
$contratto = $record->acqua_codice_contratto ?: '-';
$consumo = $record->consumo_valore !== null ? number_format((float) $record->consumo_valore, 2, ',', '.') . ' ' . $record->consumo_unita : '-';
// Controlla se c'è un payload salvato in consumo_raw
$raw = is_string($record->consumo_raw) ? json_decode($record->consumo_raw, true) : null;
if ($raw && isset($raw['codici'])) {
$pdr = $raw['codici']['pdr'] ?? ($raw['codici']['pod'] ?? ($raw['codici']['utenza'] ?? $pdr));
$cliente = $raw['codici']['cliente'] ?? $cliente;
$contratto = $raw['codici']['contratto'] ?? $contratto;
}
return "<div class='text-xs space-y-0.5'>"
. "<div><strong>Matr/POD/PDR:</strong> <code class='bg-slate-100 px-1 rounded'>$pdr</code></div>"
. "<div><strong>Cod. Cliente:</strong> $cliente</div>"
. "<div><strong>Contratto:</strong> $contratto</div>"
. "<div><strong>Consumo:</strong> <span class='font-semibold text-cyan-700'>$consumo</span></div>"
. "</div>";
}),
])
->actions([
TableAction::make('esegui_ocr')
->label('Esegui OCR')
->icon('heroicon-o-cpu-chip')
->action(function (FatturaElettronica $record): void {
$doc = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', $record->id)
->first();
if (! $doc) {
$doc = Documento::query()->create([
'documentable_type' => FatturaElettronica::class,
'documentable_id' => $record->id,
'nome_file' => $record->allegato_pdf_nome ?: 'allegato.pdf',
'path_file' => $record->allegato_pdf_path,
'mime_type' => $record->allegato_pdf_mime ?: 'application/pdf',
'stabile_id' => $record->stabile_id,
'nome' => 'Allegato PDF Fattura #' . $record->numero_fattura,
]);
}
try {
$res = app(PdfTextExtractionService::class)->extractAndStore($doc, true);
if (($res['status'] ?? null) === 'ok') {
Notification::make()
->title('OCR completato con successo')
->success()
->send();
} else {
Notification::make()
->title('Errore OCR')
->body($res['error'] ?? 'Impossibile estrarre il testo dal PDF')
->danger()
->send();
}
} catch (\Throwable $e) {
Notification::make()
->title('Errore OCR')
->body($e->getMessage())
->danger()
->send();
}
}),
TableAction::make('estrai_dati')
->label('Estrai Utenza')
->icon('heroicon-o-magnifying-glass-circle')
->color('info')
->form([
Select::make('tipo_utenza')
->label('Tipo Utenza / Parser')
->options([
'gas_luce' => 'Gas / Luce (Riscaldamento)',
'acqua' => 'Acqua (Idrico)',
])
->required()
->default(function (FatturaElettronica $record) {
// Auto-detect
$piva = $record->fornitore_piva;
$op = \App\Models\AreraOperatore::query()->where('partita_iva_normalizzata', $piva)->first();
if ($op) {
$settori = $op->settori->pluck('nome')->map('mb_strtolower')->all();
foreach ($settori as $s) {
if (str_contains($s, 'idric') || str_contains($s, 'acqua')) {
return 'acqua';
}
}
}
return 'gas_luce';
}),
])
->action(function (FatturaElettronica $record, array $data): void {
$doc = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', $record->id)
->first();
if (! $doc || empty($doc->contenuto_ocr)) {
Notification::make()
->title('Testo OCR non disponibile')
->body('Esegui prima l\'azione OCR sul documento.')
->warning()
->send();
return;
}
$text = (string) $doc->contenuto_ocr;
$userId = (int) Auth::id();
if ($data['tipo_utenza'] === 'acqua') {
$parsed = app(AcquaPdfTextParser::class)->parse($text);
$ingestion = app(ConsumiAcquaIngestionService::class)->ingest(
$record,
$parsed,
null,
$userId,
true
);
$record->update([
'acqua_codice_utenza' => $parsed['codici']['utenza'] ?? null,
'acqua_codice_cliente' => $parsed['codici']['cliente'] ?? null,
'acqua_codice_contratto' => $parsed['codici']['contratto'] ?? null,
'acqua_contatore_matricola' => $parsed['contatore']['matricola'] ?? null,
'consumo_valore' => isset($parsed['consumi'][0]['valore']) ? $parsed['consumi'][0]['valore'] : null,
'consumo_unita' => 'mc',
'consumo_raw' => json_encode($parsed),
]);
} else {
$parsed = app(GasLucePdfTextParser::class)->parse($text);
$ingestion = app(ConsumiRiscaldamentoIngestionService::class)->ingest(
$record,
$parsed,
null,
$userId,
true
);
$record->update([
'acqua_codice_utenza' => $parsed['codici']['pdr'] ?? ($parsed['codici']['pod'] ?? null),
'acqua_codice_cliente' => $parsed['codici']['cliente'] ?? null,
'acqua_codice_contratto' => $parsed['codici']['contratto'] ?? null,
'acqua_contatore_matricola' => $parsed['codici']['pdr'] ?? ($parsed['codici']['pod'] ?? null),
'consumo_valore' => isset($parsed['consumi'][0]['valore']) ? $parsed['consumi'][0]['valore'] : null,
'consumo_unita' => $parsed['codici']['pod'] ? 'kWh' : 'Smc',
'consumo_raw' => json_encode($parsed),
]);
}
Notification::make()
->title('Dati estratti e allineati')
->body('Servizio: ' . ($ingestion['status'] ?? 'n/d') . ' - Letture create: ' . ($ingestion['created_letture'] ?? 0))
->success()
->send();
}),
TableAction::make('modifica_utenza')
->label('Modifica Dati')
->icon('heroicon-o-pencil')
->color('success')
->mountUsing(function (TextInput $codice_cliente, TextInput $codice_contratto, TextInput $pdr_pod, TextInput $consumo_valore, Select $consumo_unita, FatturaElettronica $record) {
$raw = is_string($record->consumo_raw) ? json_decode($record->consumo_raw, true) : null;
$pdrVal = $record->acqua_contatore_matricola;
$cliVal = $record->acqua_codice_cliente;
$conVal = $record->acqua_codice_contratto;
if ($raw && isset($raw['codici'])) {
$pdrVal = $raw['codici']['pdr'] ?? ($raw['codici']['pod'] ?? ($raw['codici']['utenza'] ?? $pdrVal));
$cliVal = $raw['codici']['cliente'] ?? $cliVal;
$conVal = $raw['codici']['contratto'] ?? $conVal;
}
$codice_cliente->state($cliVal);
$codice_contratto->state($conVal);
$pdr_pod->state($pdrVal);
$consumo_valore->state($record->consumo_valore);
$consumo_unita->state($record->consumo_unita ?: 'Smc');
})
->form([
TextInput::make('codice_cliente')->label('Codice Cliente'),
TextInput::make('codice_contratto')->label('Codice Contratto'),
TextInput::make('pdr_pod')->label('Matricola / POD / PDR'),
TextInput::make('consumo_valore')->label('Valore Consumo')->numeric(),
Select::make('consumo_unita')
->label('Unità di Misura')
->options([
'Smc' => 'Smc (Gas)',
'mc' => 'mc (Acqua)',
'kWh' => 'kWh (Energia)',
]),
])
->action(function (FatturaElettronica $record, array $data): void {
$raw = is_string($record->consumo_raw) ? json_decode($record->consumo_raw, true) : [];
if (!is_array($raw)) {
$raw = [];
}
$raw['codici'] = [
'cliente' => $data['codice_cliente'],
'contratto' => $data['codice_contratto'],
'pdr' => $data['consumo_unita'] === 'Smc' ? $data['pdr_pod'] : null,
'pod' => $data['consumo_unita'] === 'kWh' ? $data['pdr_pod'] : null,
'utenza' => $data['consumo_unita'] === 'mc' ? $data['pdr_pod'] : null,
];
$record->update([
'acqua_codice_cliente' => $data['codice_cliente'],
'acqua_codice_contratto' => $data['codice_contratto'],
'acqua_codice_utenza' => $data['pdr_pod'],
'acqua_contatore_matricola' => $data['pdr_pod'],
'consumo_valore' => $data['consumo_valore'],
'consumo_unita' => $data['consumo_unita'],
'consumo_raw' => json_encode($raw),
]);
// Forza sincronizzazione della lettura collegata
$userId = (int) Auth::id();
if ($data['consumo_unita'] === 'mc') {
app(ConsumiAcquaIngestionService::class)->ingest($record, $raw, null, $userId, true);
} else {
app(ConsumiRiscaldamentoIngestionService::class)->ingest($record, $raw, null, $userId, true);
}
Notification::make()
->title('Dati utenza aggiornati e sincronizzati con successo')
->success()
->send();
}),
]);
}
}

View File

@ -47,6 +47,8 @@ class TicketAcqua extends Page
public ?string $rilevatoreNome = null;
public ?string $unitaContatoreSeriale = null;
public ?string $rilevatoreContatto = null;
public ?string $noteGenerali = null;
@ -559,7 +561,11 @@ private function loadUnitOptions(): void
->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici'])
->where('stabile_id', (int) $this->stabileId)
->where(function ($query): void {
if (Schema::hasColumn('unita_immobiliari', 'attiva')) {
$query->whereNull('attiva')->orWhere('attiva', true);
} else {
$query->whereNull('stato')->orWhere('stato', 'attiva');
}
})
->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno'])
->map(function (UnitaImmobiliare $unita): array {
@ -630,8 +636,17 @@ public function getLightUnitaSearchResultsProperty(): array
private function loadPreviousReading(): void
{
$this->previousReading = null;
$this->unitaContatoreSeriale = null;
if (! $this->servizioId || ! $this->unitaId) {
if (! $this->unitaId) {
return;
}
$this->unitaContatoreSeriale = UnitaImmobiliare::query()
->whereKey($this->unitaId)
->value('acqua_contatore_seriale');
if (! $this->servizioId) {
return;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
<?php
namespace App\Filament\Pages\Supporto;
use App\Models\User;
use BackedEnum;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class TicketRiscaldamentoLight extends TicketRiscaldamento
{
protected static ?string $navigationLabel = 'Ticket Riscaldamento Light';
protected static ?string $title = 'Ticket Riscaldamento Light';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-bolt';
protected static UnitEnum|string|null $navigationGroup = 'Mobile';
protected static ?int $navigationSort = 27;
protected static ?string $slug = 'supporto/ticket-riscaldamento-light';
protected string $view = 'filament.pages.supporto.ticket-riscaldamento-light';
public function mount(): void
{
parent::mount();
if (! request()->integer('unita')) {
$this->resetLightUnitSelection();
}
}
public function updatedStabileId(): void
{
parent::updatedStabileId();
$this->resetLightUnitSelection();
}
private function resetLightUnitSelection(): void
{
$user = Auth::user();
$this->unitaId = null;
$this->unitaSearch = '';
$this->previousReading = null;
$this->suggestedReaderName = null;
if ($user instanceof User) {
$this->rilevatoreNome = trim((string) $user->name) ?: $this->rilevatoreNome;
}
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\FatturaElettronica;
use App\Models\StabileServizioLettura;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Support\StabileContext;
use Dompdf\Dompdf;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
class RiscaldamentoRipartizionePrintController extends Controller
{
public function print(Request $request): View
{
return view('filament.print.riscaldamento-ripartizione-riepilogo', $this->buildReportData($request) + [
'autoprint' => $request->boolean('autoprint', true),
]);
}
public function pdf(Request $request): Response
{
$data = $this->buildReportData($request);
$html = view('filament.print.riscaldamento-ripartizione-riepilogo', $data + ['autoprint' => false])->render();
$dompdf = new Dompdf();
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return response($dompdf->output(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="riscaldamento-ripartizione-' . $data['year'] . '.pdf"',
]);
}
/** @return array<string,mixed> */
private function buildReportData(Request $request): array
{
$user = $request->user();
abort_unless($user instanceof User, 403);
$stabileId = (int) $request->integer('stabile_id');
$stabile = StabileContext::accessibleStabili($user)->firstWhere('id', $stabileId);
abort_unless($stabile, 403);
$year = max(2000, min(2100, (int) $request->integer('year', now()->year)));
$selectedIds = collect(explode(',', (string) $request->query('ids', '')))
->map(fn(string $value): int => (int) trim($value))
->filter(fn(int $value): bool => $value > 0)
->values()
->all();
$fattureRows = FatturaElettronica::query()
->where('stabile_id', (int) $stabile->id)
->whereYear('data_fattura', $year)
->when($selectedIds !== [], fn($query) => $query->whereIn('id', $selectedIds))
->whereNotNull('consumo_raw')
->orderByDesc('data_fattura')
->orderByDesc('id')
->get(['id', 'numero_fattura', 'data_fattura', 'totale', 'consumo_raw', 'consumo_riferimento', 'consumo_valore'])
->map(function (FatturaElettronica $fattura): ?array {
$payload = json_decode((string) $fattura->consumo_raw, true);
if (! $this->payloadLooksLikeRiscaldamento(is_array($payload) ? $payload : null)) {
return null;
}
return [
'id' => (int) $fattura->id,
'numero_fattura' => trim((string) ($fattura->numero_fattura ?? '')),
'data_fattura' => optional($fattura->data_fattura)->format('d/m/Y'),
'periodo' => trim((string) ($fattura->consumo_riferimento ?? '')),
'totale_mc' => is_numeric($fattura->consumo_valore) ? (float) $fattura->consumo_valore : 0.0,
'totale_spesa' => is_numeric($fattura->totale) ? (float) $fattura->totale : 0.0,
'utenza' => trim((string) data_get($payload, 'codici.utenza', '')),
'cliente' => trim((string) data_get($payload, 'codici.cliente', '')),
'contratto' => trim((string) data_get($payload, 'codici.contratto', '')),
'matricola' => trim((string) data_get($payload, 'contatore.matricola', '')),
'cbill' => trim((string) data_get($payload, 'pagamento.cbill', '')),
'codice_avviso' => trim((string) data_get($payload, 'pagamento.codice_avviso', '')),
];
})
->filter()
->values();
$unitLabels = UnitaImmobiliare::query()
->where('stabile_id', (int) $stabile->id)
->get(['id', 'codice_unita', 'scala', 'interno'])
->mapWithKeys(function (UnitaImmobiliare $unit): array {
$label = trim(implode(' ', array_filter([
trim((string) ($unit->codice_unita ?? '')),
trim((string) ($unit->scala ?? '')) !== '' ? 'Scala ' . trim((string) $unit->scala) : null,
trim((string) ($unit->interno ?? '')) !== '' ? 'Int. ' . trim((string) $unit->interno) : null,
])));
return [(int) $unit->id => ($label !== '' ? $label : ('UI #' . (int) $unit->id))];
})
->all();
$lettureRows = StabileServizioLettura::query()
->where('stabile_id', (int) $stabile->id)
->whereNotNull('unita_immobiliare_id')
->whereHas('servizio', fn($q) => $q->where('tipo', 'riscaldamento'))
->where(function ($query) use ($year): void {
$query->whereYear('periodo_al', $year)
->orWhere(function ($fallback) use ($year): void {
$fallback->whereNull('periodo_al')->whereYear('periodo_dal', $year);
})
->orWhere(function ($fallback) use ($year): void {
$fallback->whereNull('periodo_al')->whereNull('periodo_dal')->whereYear('created_at', $year);
});
})
->with(['servizio:id,nome,contatore_matricola'])
->orderByDesc('created_at')
->orderByDesc('id')
->get(['id', 'unita_immobiliare_id', 'created_at', 'canale_acquisizione', 'riferimento_acquisizione', 'periodo_dal', 'periodo_al', 'lettura_fine', 'consumo_valore', 'consumo_unita', 'stabile_servizio_id'])
->map(function (StabileServizioLettura $row) use ($unitLabels): array {
return [
'data_ricezione' => optional($row->created_at)->format('d/m/Y H:i'),
'servizio' => trim((string) ($row->servizio?->nome ?? 'Riscaldamento')),
'matricola' => trim((string) ($row->servizio?->contatore_matricola ?? '')),
'unita' => $unitLabels[(int) ($row->unita_immobiliare_id ?? 0)] ?? ('UI #' . (int) $row->unita_immobiliare_id),
'canale' => strtoupper(trim((string) ($row->canale_acquisizione ?? '—'))),
'riferimento' => trim((string) ($row->riferimento_acquisizione ?? '')),
'periodo' => trim(implode(' - ', array_filter([
optional($row->periodo_dal)->format('d/m/Y'),
optional($row->periodo_al)->format('d/m/Y'),
]))),
'lettura_fine' => is_numeric($row->lettura_fine) ? (float) $row->lettura_fine : null,
'consumo_valore' => is_numeric($row->consumo_valore) ? (float) $row->consumo_valore : null,
'consumo_unita' => trim((string) ($row->consumo_unita ?: 'mc')),
];
})
->all();
return [
'stabile' => $stabile,
'year' => $year,
'fattureRows' => $fattureRows,
'lettureRows' => $lettureRows,
'summary' => [
'fatture' => $fattureRows->count(),
'totale_mc' => round((float) $fattureRows->sum('totale_mc'), 3),
'totale_spesa' => round((float) $fattureRows->sum('totale_spesa'), 2),
'letture_ui' => count($lettureRows),
],
'generatedAt' => Carbon::now(),
];
}
private function payloadLooksLikeRiscaldamento(?array $payload): bool
{
if (! is_array($payload) || $payload === []) {
return false;
}
return ($payload['type'] ?? null) === 'riscaldamento'
|| is_array($payload['consumi'] ?? null)
|| is_array($payload['riepilogo_letture'] ?? null)
|| is_array($payload['finestra_autolettura'] ?? null)
|| is_array($payload['codici'] ?? null);
}
}

View File

@ -30,10 +30,16 @@ class StabileDatiBancariTable extends TableComponent
{
public int $stabileId;
public ?int $editingContoId = null;
public bool $isCreatingConto = false;
public array $contoFormState = [];
public array $contattiBancaOptions = [];
public function mount(int $stabileId): void
{
$this->stabileId = $stabileId;
$this->mountInteractsWithTable();
$this->loadContattiBancaOptions();
}
protected function getTableQuery(): Builder
@ -169,25 +175,29 @@ public function table(Table $table): Table
->label('Nuovo conto')
->icon('heroicon-o-plus')
->color('success')
->modalHeading('Nuovo conto')
->form($this->formSchema())
->action(function (array $data): void {
$user = Auth::user();
$uid = $user instanceof User ? (int) $user->id : null;
$payload = $this->normalizePayload($data);
$payload['stabile_id'] = (int) $this->stabileId;
if ($uid) {
$payload['creato_da'] = $uid;
$payload['modificato_da'] = $uid;
}
DatiBancari::create($payload);
Notification::make()
->title('Conto creato')
->success()
->send();
->action(function (): void {
$this->isCreatingConto = true;
$this->editingContoId = null;
$this->contoFormState = [
'denominazione_banca' => '',
'tipo_conto' => 'corrente',
'stato_conto' => 'attivo',
'iban' => '',
'numero_conto' => '',
'abi' => '',
'cab' => '',
'cin' => '',
'bic_swift' => '',
'cuc' => '',
'sia' => '',
'intestazione_conto' => '',
'data_saldo_iniziale' => null,
'saldo_iniziale' => 0,
'valuta' => 'EUR',
'is_nostro_conto' => false,
'contatto_id' => null,
'note' => '',
];
}),
])
->actions([
@ -208,9 +218,9 @@ public function table(Table $table): Table
->openUrlInNewTab(),
Action::make('saldi')
->label('Saldi')
->label('Saldi ed estratti')
->icon('heroicon-o-scale')
->url(fn(DatiBancari $record): string => SaldiContiArchivio::getUrl(['conto_id' => (int) $record->id], panel: 'admin-filament'))
->url(fn(DatiBancari $record): string => CasseBancheMovimenti::getUrl(panel: 'admin-filament', parameters: ['conto_id' => (int) $record->id, 'tab' => 'saldi']))
->openUrlInNewTab(),
Action::make('contatto')
@ -224,8 +234,10 @@ public function table(Table $table): Table
Action::make('modifica')
->label('Modifica')
->icon('heroicon-o-pencil-square')
->modalHeading('Modifica conto')
->fillForm(fn(DatiBancari $record): array=> [
->action(function (DatiBancari $record): void {
$this->editingContoId = $record->id;
$this->isCreatingConto = false;
$this->contoFormState = [
'denominazione_banca' => $record->denominazione_banca,
'tipo_conto' => $record->tipo_conto,
'stato_conto' => $record->stato_conto,
@ -238,32 +250,13 @@ public function table(Table $table): Table
'cuc' => $record->cuc,
'sia' => $record->sia,
'intestazione_conto' => $record->intestazione_conto,
'data_saldo_iniziale' => $record->data_saldo_iniziale,
'data_saldo_iniziale' => $record->data_saldo_iniziale?->format('Y-m-d'),
'saldo_iniziale' => $record->saldo_iniziale,
'valuta' => $record->valuta,
'is_nostro_conto' => (bool) $record->is_nostro_conto,
'contatto_id' => $record->contatto_id,
'note' => $record->note,
])
->form($this->formSchema())
->action(function (DatiBancari $record, array $data): void {
$user = Auth::user();
$uid = $user instanceof User ? (int) $user->id : null;
$payload = $this->normalizePayload($data);
if ($uid) {
$payload['modificato_da'] = $uid;
}
$record->fill($payload);
if ($record->isDirty()) {
$record->save();
}
Notification::make()
->title('Conto aggiornato')
->success()
->send();
];
}),
Action::make('elimina')
@ -489,4 +482,88 @@ private function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string
return implode(' · ', $parts);
}
public function loadContattiBancaOptions(): void
{
$records = RubricaUniversale::query()
->whereNull('deleted_at')
->orderBy('ragione_sociale')
->orderBy('cognome')
->orderBy('nome')
->get(['id', 'ragione_sociale', 'nome', 'cognome']);
$this->contattiBancaOptions = $records
->mapWithKeys(function (RubricaUniversale $r): array {
$label = trim((string) ($r->nome_completo ?? ''));
if ($label === '') {
$label = trim((string) ($r->ragione_sociale ?? ''));
}
if ($label === '') {
$label = trim((string) (($r->cognome ?? '') . ' ' . ($r->nome ?? '')));
}
if ($label === '') {
$label = 'Contatto #' . (int) $r->id;
}
return [(int) $r->id => $label];
})
->all();
}
public function saveContoInline(): void
{
$user = Auth::user();
$uid = $user instanceof User ? (int) $user->id : null;
$validatedData = validator($this->contoFormState, [
'denominazione_banca' => ['required', 'string', 'max:255'],
'tipo_conto' => ['required', 'string'],
'stato_conto' => ['required', 'string'],
'iban' => ['nullable', 'string', 'max:64'],
'numero_conto' => ['nullable', 'string', 'max:64'],
'abi' => ['nullable', 'string', 'max:16'],
'cab' => ['nullable', 'string', 'max:16'],
'cin' => ['nullable', 'string', 'max:8'],
'bic_swift' => ['nullable', 'string', 'max:32'],
'cuc' => ['nullable', 'string', 'max:32'],
'sia' => ['nullable', 'string', 'max:32'],
'intestazione_conto' => ['nullable', 'string', 'max:255'],
'data_saldo_iniziale' => ['nullable', 'date'],
'saldo_iniziale' => ['nullable', 'numeric'],
'valuta' => ['nullable', 'string', 'max:8'],
'is_nostro_conto' => ['boolean'],
'contatto_id' => ['nullable', 'integer'],
'note' => ['nullable', 'string'],
])->validate();
$payload = $this->normalizePayload($validatedData);
if ($this->isCreatingConto) {
$payload['stabile_id'] = (int) $this->stabileId;
if ($uid) {
$payload['creato_da'] = $uid;
$payload['modificato_da'] = $uid;
}
DatiBancari::create($payload);
Notification::make()->title('Conto creato con successo')->success()->send();
} else {
$record = DatiBancari::find($this->editingContoId);
if ($record) {
if ($uid) {
$payload['modificato_da'] = $uid;
}
$record->fill($payload);
$record->save();
Notification::make()->title('Conto aggiornato con successo')->success()->send();
}
}
$this->cancelInlineForm();
}
public function cancelInlineForm(): void
{
$this->isCreatingConto = false;
$this->editingContoId = null;
$this->contoFormState = [];
}
}

View File

@ -16,13 +16,17 @@ class DettaglioMillesimi extends Model
'unita_immobiliare_id',
'millesimi',
'partecipa',
'nord',
'ruolo_legacy',
'note',
'created_by'
];
protected $casts = [
'millesimi' => 'decimal:4',
'partecipa' => 'boolean'
'partecipa' => 'boolean',
'nord' => 'integer',
'ruolo_legacy' => 'string'
];
/**

View File

@ -219,6 +219,30 @@ public function calcolaRipartizione()
throw new \Exception('Tabella millesimale non specificata');
}
$totaleMillesimi = (float) ($this->tabellaMillesimale->totale_millesimi ?? 1000);
$sommaDettagli = (float) $this->tabellaMillesimale->dettagli()->sum('millesimi');
$isConsuntivo = false;
if ($this->voceSpesa) {
if (isset($this->voceSpesa->importo_consuntivo) && $this->voceSpesa->importo_consuntivo !== null) {
$isConsuntivo = true;
}
if ($this->voceSpesa->gestioneContabile && in_array($this->voceSpesa->gestioneContabile->stato, ['chiusa', 'consolidata'], true)) {
$isConsuntivo = true;
}
}
if (request() && request()->input('tipo_ripartizione') === 'consuntivo') {
$isConsuntivo = true;
}
if (abs($totaleMillesimi - $sommaDettagli) > 0.01) {
if ($isConsuntivo) {
throw new \Exception("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$this->tabellaMillesimale->codice_tabella} ({$totaleMillesimi}). Ripartizione bloccata a consuntivo.");
} else {
\Illuminate\Support\Facades\Log::warning("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$this->tabellaMillesimale->codice_tabella} ({$totaleMillesimi}).");
}
}
$unitaImmobiliari = $this->stabile->unitaImmobiliari;
$dettagliMillesimali = $this->tabellaMillesimale->dettagli;

View File

@ -118,6 +118,7 @@ class Stabile extends Model
'registro_anagrafe',
'documenti_path',
'attivo',
'rubrica_id',
];
protected $casts = [
@ -564,6 +565,39 @@ public function gestioniAttive()
return $this->gestioniCondominiali()->where('stato', 'ATTIVA');
}
/**
* Sincronizza o crea il contatto dello stabile nella Rubrica Universale.
*/
public function syncRubricaContatto(): RubricaUniversale
{
$rubrica = $this->rubrica;
$data = [
'amministratore_id' => $this->amministratore_id,
'codice_univoco' => 'STABILE-' . $this->codice_stabile,
'categoria' => 'condomino', // Usiamo condomino o altra categoria gestita
'tipo_contatto' => 'persona_giuridica',
'ragione_sociale' => $this->denominazione,
'cognome' => null,
'nome' => null,
'codice_fiscale' => $this->codice_fiscale,
'indirizzo' => $this->indirizzo,
'citta' => $this->citta,
'cap' => $this->cap,
'provincia' => $this->provincia,
'stato' => 'attivo',
];
if ($rubrica) {
$rubrica->update($data);
} else {
$rubrica = RubricaUniversale::create($data);
$this->update(['rubrica_id' => $rubrica->id]);
}
return $rubrica;
}
/**
* Collaboratori assegnati allo stabile (tramite pivot)
*/

View File

@ -253,8 +253,13 @@ public function unitaRecapitiServizio(): HasMany
// Scope methods
public function scopeAttive($query)
{
if (\Illuminate\Support\Facades\Schema::hasColumn('unita_immobiliari', 'attiva')) {
return $query->where('attiva', true);
}
return $query->where(function($q) {
$q->whereNull('stato')->orWhere('stato', 'attiva');
});
}
public function scopePerStabile($query, $stabileId)
{

View File

@ -42,6 +42,13 @@ protected static function booted(): void
{
static::creating(function (self $model): void {
$model->applyAutoProtocols();
if (empty($model->entry_date) && ! empty($model->data_registrazione)) {
$model->entry_date = $model->data_registrazione;
}
if (empty($model->data_registrazione) && ! empty($model->entry_date)) {
$model->data_registrazione = $model->entry_date;
}
});
}
@ -167,11 +174,23 @@ private function generateGlobalProtocol(int $stabileId, int $year): string
{
$base = 'PN' . $year;
if (DB::getDriverName() === 'sqlite') {
$max = DB::table('contabilita_registrazioni')
->where('stabile_id', $stabileId)
->where('protocol', 'like', $base . '-%')
->get(['protocol'])
->map(function ($r) {
$parts = explode('-', $r->protocol);
return (int) end($parts);
})
->max() ?? 0;
} else {
$max = DB::table('contabilita_registrazioni')
->where('stabile_id', $stabileId)
->where('protocol', 'like', $base . '-%')
->selectRaw('MAX(CAST(SUBSTRING_INDEX(protocol, "-", -1) AS UNSIGNED)) as mx')
->value('mx');
}
$next = ((int) $max) + 1;
@ -292,4 +311,14 @@ public static function resolveGestioneIdOrDefault(int $gestioneId, int $stabileI
return $match?->id ?: ($gestioneId > 0 ? $gestioneId : null);
}
public function getDescrizioneAttribute(): ?string
{
return $this->attributes['description'] ?? null;
}
public function setDescrizioneAttribute(?string $value): void
{
$this->attributes['description'] = $value;
}
}

View File

@ -109,10 +109,16 @@ public function generaRegistrazioneDaMovimento(User $user, MovimentoBanca $movim
: null),
'data_registrazione' => $data,
'descrizione' => $descr,
'created_by' => (int) $user->id,
'updated_by' => (int) $user->id,
];
if (Schema::hasColumn('contabilita_registrazioni', 'created_by')) {
$regPayload['created_by'] = (int) $user->id;
}
if (Schema::hasColumn('contabilita_registrazioni', 'updated_by')) {
$regPayload['updated_by'] = (int) $user->id;
}
if (Schema::hasColumn('contabilita_registrazioni', 'user_id')) {
$regPayload['user_id'] = (int) $user->id;
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Services\Consumi;
use App\Models\FatturaElettronica;
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use Illuminate\Support\Facades\Schema;
class ConsumiRiscaldamentoIngestionService
{
/**
* @param array{codici?: array{cliente?: string|null, contratto?: string|null, pdr?: string|null, pod?: string|null}, pagamento?: array{scadenza?: string|null}, consumi?: array<int, array{dal?: string|null, al?: string|null, valore?: float|null, unita?: string|null, tipologia_lettura?: string|null}>} $parsed
* @return array{status: string, servizio_id?: int, created_letture?: int, updated_letture?: int, tickets?: int, message?: string}
*/
public function ingest(
FatturaElettronica $fattura,
array $parsed,
?int $voceSpesaId,
int $userId,
bool $createTicketOnMismatch = true,
?int $forceServizioId = null,
): array {
if (! Schema::hasTable('stabile_servizi') || ! Schema::hasTable('stabile_servizio_letture')) {
return ['status' => 'skipped', 'message' => 'Tabelle servizi/letture non presenti'];
}
$stabileId = (int) ($fattura->stabile_id ?: 0);
if ($stabileId <= 0) {
return ['status' => 'error', 'message' => 'Stabile non disponibile'];
}
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$pagamento = is_array($parsed['pagamento'] ?? null) ? $parsed['pagamento'] : [];
$pdr = is_string($codici['pdr'] ?? null) ? trim((string) $codici['pdr']) : '';
$pod = is_string($codici['pod'] ?? null) ? trim((string) $codici['pod']) : '';
$cliente = is_string($codici['cliente'] ?? null) ? trim((string) $codici['cliente']) : '';
$contratto = is_string($codici['contratto'] ?? null) ? trim((string) $codici['contratto']) : '';
$tipoServizio = 'riscaldamento';
$unitaMisura = 'mc';
$identificativo = $pdr ?: $pod;
if ($pod !== '') {
$tipoServizio = 'riscaldamento'; // Rimaniamo su riscaldamento come contenitore per riscaldamento/utenze
$unitaMisura = 'kWh';
}
$servizio = null;
if ($forceServizioId) {
$servizio = StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'riscaldamento')
->whereKey($forceServizioId)
->first();
}
if (! $servizio) {
// Cerca per PDR/POD o codici
$servizio = StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'riscaldamento')
->where(function ($q) use ($pdr, $pod, $cliente, $contratto) {
if ($pdr !== '') $q->orWhere('codice_utenza', $pdr)->orWhere('contatore_matricola', $pdr);
if ($pod !== '') $q->orWhere('codice_utenza', $pod)->orWhere('contatore_matricola', $pod);
if ($cliente !== '') $q->orWhere('codice_cliente', $cliente);
if ($contratto !== '') $q->orWhere('codice_contratto', $contratto);
})
->first();
}
if (! $servizio) {
// Crea un nuovo servizio di riscaldamento
$name = 'Riscaldamento / Utenza';
if ($identificativo !== '') {
$name .= ' - ' . $identificativo;
}
$servizio = new StabileServizio([
'stabile_id' => $stabileId,
'tipo' => 'riscaldamento',
'nome' => $name,
'attivo' => true,
]);
}
if ((int) ($fattura->fornitore_id ?? 0) > 0 && ! $servizio->fornitore_id) {
$servizio->fornitore_id = (int) $fattura->fornitore_id;
}
if ($voceSpesaId && $voceSpesaId > 0) {
$servizio->voce_spesa_id = $voceSpesaId;
}
if ($pdr !== '' && empty($servizio->codice_utenza)) $servizio->codice_utenza = $pdr;
if ($pod !== '' && empty($servizio->codice_utenza)) $servizio->codice_utenza = $pod;
if ($cliente !== '' && empty($servizio->codice_cliente)) $servizio->codice_cliente = $cliente;
if ($contratto !== '' && empty($servizio->codice_contratto)) $servizio->codice_contratto = $contratto;
if ($identificativo !== '' && empty($servizio->contatore_matricola)) $servizio->contatore_matricola = $identificativo;
$servizio->save();
$created = 0;
$updated = 0;
foreach ($consumi as $c) {
$dal = $c['dal'] ?? null;
$al = $c['al'] ?? null;
$val = $c['valore'] ?? null;
$uni = $c['unita'] ?? $unitaMisura;
$lettura = StabileServizioLettura::query()->firstOrNew([
'stabile_id' => $stabileId,
'stabile_servizio_id' => (int) $servizio->id,
'fattura_elettronica_id' => (int) $fattura->id,
'periodo_dal' => $dal,
'periodo_al' => $al,
]);
$isNew = ! $lettura->exists;
$lettura->fill([
'fornitore_id' => (int) ($fattura->fornitore_id ?: 0) ?: null,
'voce_spesa_id' => $voceSpesaId ?: ($servizio->voce_spesa_id ?: null),
'tipologia_lettura' => $c['tipologia_lettura'] ?? 'rilevata',
'consumo_valore' => $val,
'consumo_unita' => $uni,
'importo_totale' => is_numeric($fattura->totale) ? (float) $fattura->totale : null,
'raw' => [
'source' => 'pdf_ocr_gas_luce',
'codici' => $codici,
'pagamento' => $pagamento,
'consumo' => $c,
],
'created_by' => $userId > 0 ? $userId : null,
]);
$lettura->save();
if ($isNew) {
$created++;
} else {
$updated++;
}
}
return [
'status' => 'ok',
'servizio_id' => (int) $servizio->id,
'created_letture' => $created,
'updated_letture' => $updated,
'tickets' => 0,
];
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Services\Consumi;
use App\Models\FatturaElettronica;
use App\Models\StabileServizioTariffa;
use Illuminate\Support\Facades\Schema;
class ConsumiRiscaldamentoTariffeIngestionService
{
/** @param array<string,mixed> $parsed */
public function ingest(FatturaElettronica $fattura, array $parsed, ?int $stabileServizioId = null): array
{
if (! Schema::hasTable('stabile_servizio_tariffe')) {
return ['status' => 'skipped', 'message' => 'Tabella stabile_servizio_tariffe non presente'];
}
$stabileId = (int) ($fattura->stabile_id ?? 0);
if ($stabileId <= 0) {
return ['status' => 'error', 'message' => 'Stabile non valido'];
}
$row = StabileServizioTariffa::query()->updateOrCreate(
['fattura_elettronica_id' => (int) $fattura->id],
[
'stabile_id' => $stabileId,
'stabile_servizio_id' => $stabileServizioId,
'fornitore_id' => (int) ($fattura->fornitore_id ?: 0) ?: null,
'data_fattura' => $fattura->data_fattura,
'decorrenza_tariffe' => $parsed['pagamento']['scadenza'] ?? null,
'profilo_tariffario' => 'standard',
'payload' => $parsed,
]
);
return [
'status' => 'ok',
'id' => (int) $row->id,
];
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Services\Consumi;
use Carbon\Carbon;
class GasLucePdfTextParser
{
/**
* @return array{
* codici: array{cliente: string|null, contratto: string|null, pdr: string|null, pod: string|null},
* pagamento: array{scadenza: string|null},
* consumi: array<int, array{dal: string|null, al: string|null, valore: float|null, unita: string, tipologia_lettura: string}>
* }
*/
public function parse(string $text): array
{
$text = str_replace("\r\n", "\n", $text);
$text = str_replace("\r", "\n", $text);
// Cliente
$cliente = $this->firstMatch($text, '/(?:Codice\s+Cliente|Cod\.\s*Cliente|Cliente\s*n\.|Client\s*code)\s*:?\s*([A-Z0-9_]{4,})/i');
// Contratto
$contratto = $this->firstMatch($text, '/(?:Contratto|Cod\.\s*Contratto|Codice\s+Contratto|Contract\s*n\.)\s*:?\s*([A-Z0-9_]{4,})/i');
// PDR / POD
$pdr = $this->firstMatch($text, '/\bPDR\b\s*:?\s*([0-9]{14})\b/i');
$pod = $this->firstMatch($text, '/\bPOD\b\s*:?\s*([A-Z0-9]{14,15})\b/i');
// Scadenza
$scadenza = $this->toIsoDate($this->firstMatch($text, '/(?:Scadenza|Entro\s+il|Data\s+scadenza)\s*:?\s*(\d{2}\/\d{2}\/\d{4})/i'));
// Cerca consumi del tipo: "Consumo Fatturato APRILE 2026 910 Smc"
// O genericamente "Consumo... [valore] Smc/kWh"
$consumi = [];
if (preg_match_all('/(?:Consumo\s+Fatturato|Consumo|Consumo\s+rilevato|Consumo\s+stimato)\s+(?:([A-Z]+)\s+(\d{4})\s+)?(\d+(?:[\.,]\d+)?)\s*(Smc|kWh)/i', $text, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$monthName = !empty($m[1]) ? strtoupper(trim($m[1])) : null;
$yearVal = !empty($m[2]) ? (int) $m[2] : null;
$valore = $this->toFloatIt($m[3]);
$unita = trim($m[4]);
$dal = null;
$al = null;
if ($monthName && $yearVal) {
$months = [
'GENNAIO' => 1, 'FEBBRAIO' => 2, 'MARZO' => 3, 'APRILE' => 4,
'MAGGIO' => 5, 'GIUGNO' => 6, 'LUGLIO' => 7, 'AGOSTO' => 8,
'SETTEMBRE' => 9, 'OTTOBRE' => 10, 'NOVEMBRE' => 11, 'DICEMBRE' => 12,
'JANUARY' => 1, 'FEBRUARY' => 2, 'MARCH' => 3, 'APRIL' => 4,
'MAY' => 5, 'JUNE' => 6, 'JULY' => 7, 'AUGUST' => 8,
'SEPTEMBER' => 9, 'OCTOBER' => 10, 'NOVEMBER' => 11, 'DECEMBER' => 12
];
$mNum = $months[$monthName] ?? null;
if ($mNum) {
$dal = Carbon::create($yearVal, $mNum, 1)->startOfMonth()->format('Y-m-d');
$al = Carbon::create($yearVal, $mNum, 1)->endOfMonth()->format('Y-m-d');
}
}
$consumi[] = [
'dal' => $dal,
'al' => $al,
'valore' => $valore,
'unita' => $unita,
'tipologia_lettura' => 'fatturata',
];
}
}
// Se non troviamo date dai consumi ma c'è un intervallo di date nel testo, tipo "dal 01/04/2026 al 30/04/2026"
if (count($consumi) === 0 && preg_match('/dal\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/i', $text, $mDates)) {
$dal = $this->toIsoDate($mDates[1]);
$al = $this->toIsoDate($mDates[2]);
// Prova a estrarre il valore di consumo vicino a Smc o kWh
$val = null;
$uni = 'Smc';
if (preg_match('/(\d+(?:[\.,]\d+)?)\s*(Smc|kWh)/i', $text, $mVal)) {
$val = $this->toFloatIt($mVal[1]);
$uni = $mVal[2];
}
if ($val !== null) {
$consumi[] = [
'dal' => $dal,
'al' => $al,
'valore' => $val,
'unita' => $uni,
'tipologia_lettura' => 'rilevata',
];
}
}
return [
'codici' => [
'cliente' => $cliente,
'contratto' => $contratto,
'pdr' => $pdr,
'pod' => $pod,
],
'pagamento' => [
'scadenza' => $scadenza,
],
'consumi' => $consumi,
];
}
private function firstMatch(string $text, string $pattern): ?string
{
if (preg_match($pattern, $text, $matches)) {
return trim($matches[1]);
}
return null;
}
private function toFloatIt(mixed $val): ?float
{
if ($val === null) return null;
$val = str_replace('.', '', $val);
$val = str_replace(',', '.', $val);
return is_numeric($val) ? (float) $val : null;
}
private function toIsoDate(?string $value): ?string
{
if (! $value) {
return null;
}
try {
return Carbon::createFromFormat('d/m/Y', $value)->format('Y-m-d');
} catch (\Throwable) {
return null;
}
}
}

View File

@ -81,6 +81,33 @@ protected function calcolaDettagliRipartizione(
$totaleMillesimi = 1000.0;
}
// Verifica corrispondenza millesimi
$sommaDettagli = (float) DB::table('dettaglio_millesimi')
->where('tabella_millesimale_id', $tabellaMillesimale->id)
->sum('millesimi');
$isConsuntivo = false;
$voceSpesa = $ripartizione->voceSpesa;
if ($voceSpesa) {
if (isset($voceSpesa->importo_consuntivo) && $voceSpesa->importo_consuntivo !== null) {
$isConsuntivo = true;
}
if (isset($voceSpesa->gestioneContabile) && $voceSpesa->gestioneContabile && in_array($voceSpesa->gestioneContabile->stato, ['chiusa', 'consolidata'], true)) {
$isConsuntivo = true;
}
}
if (request() && request()->input('tipo_ripartizione') === 'consuntivo') {
$isConsuntivo = true;
}
if (abs($totaleMillesimi - $sommaDettagli) > 0.01) {
if ($isConsuntivo) {
throw new \Exception("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$tabellaMillesimale->codice_tabella} ({$totaleMillesimi}). Ripartizione bloccata a consuntivo.");
} else {
\Illuminate\Support\Facades\Log::warning("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$tabellaMillesimale->codice_tabella} ({$totaleMillesimi}).");
}
}
foreach ($unitaConMillesimi as $unita) {
// Ottieni i millesimi dell'unità per questa tabella
$millesimi = $this->getMillesimiUnita($unita, $tabellaMillesimale);

View File

@ -42,7 +42,7 @@ public function stabileCode(Stabile | string $stabile): string
return $code;
}
$code = trim((string) ($stabile->codice_stabile ?? ''));
$code = trim((string) ($stabile->codice_univoco ?: $stabile->codice_stabile ?: ''));
if ($code === '') {
throw new InvalidArgumentException('Stabile senza codice archivio canonico.');

View File

@ -20,6 +20,8 @@
'modules' => [
'gescon_internal_visible' => env('NETGESCON_GESCON_INTERNAL_VISIBLE', in_array(env('APP_ENV', 'production'), ['local', 'development'], true)),
],
'legacy_import_enabled' => env('NETGESCON_LEGACY_IMPORT_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Layout Configuration

134
docs/DEVELOPER_GUIDE.md Normal file
View File

@ -0,0 +1,134 @@
# Guida allo Sviluppo e Architettura Modulare di NetGescon
Benvenuto nella guida ufficiale allo sviluppo di NetGescon. Questo documento descrive l'architettura tecnica del sistema, la struttura del database, i flussi di importazione dei dati legacy e le linee guida per lo sviluppo di moduli estensibili open-source.
L'obiettivo di NetGescon è fornire una piattaforma gestionale condominiale solida, sicura ed estensibile in stile **Odoo**, in cui moduli e componenti esterni possono essere agganciati facilmente al core dell'applicazione.
---
## 1. Visione Open-Source ed Estensibilità Modulare
NetGescon è strutturato per consentire a sviluppatori terzi di aggiungere funzionalità senza alterare il nucleo dell'applicazione.
### 1.1 La Directory `app/Modules/`
L'applicazione organizza le macro-funzionalità estendibili all'interno di `app/Modules/`. Attualmente sono presenti i moduli:
* **Contabilita**: Modelli, viste e servizi relativi alla contabilità, bilancio e prima nota.
* **StampeRate**: Logica di generazione e formattazione dei report e delle stampe per le rate.
### 1.2 Regole per Creare un Nuovo Modulo (es. `LettureAcqua`)
Ogni modulo deve essere autocontenuto:
1. **Struttura**: Crea una cartella in `app/Modules/NomeModulo` contenente sotto-cartelle per `Models`, `Services`, `Http` (o Livewire/Filament).
2. **Database**: Le tabelle di database di un modulo devono avere chiavi esterne verso le tabelle principali (es. `unita_immobiliari_id`, `stabili_id`) ma risiedere in tabelle separate per evitare collisioni.
3. **Filament Integration**: Le risorse Filament del modulo devono essere registrate dinamicamente nel pannello principale, garantendo la compatibilità con i temi e i permessi globali dell'applicazione.
---
## 2. Struttura del Database e Mappatura dei Dati
Il sistema gestisce due connessioni e schemi di database principali: la connessione di **Produzione** e la connessione di **Staging (`gescon_import`)**.
```mermaid
graph TD
MDB[File Legacy MDB] -->|Caricamento| Staging[Database Staging: gescon_import]
Staging -->|Pipeline di Importazione| Prod[Database di Produzione]
Prod -->|Pannello di Amministrazione| Filament[Filament v3 UI]
```
### 2.1 Connessione Staging (`gescon_import`)
Utilizzata per caricare temporaneamente i dati legacy dagli archivi MDB. Le tabelle principali di staging sono:
* **`condomin`**: Contiene le anagrafiche dei condomini, proprietari, inquilini e informazioni sulle unità.
* **`dett_tab`**: Dettaglio dei millesimi e importi associati a ciascuna tabella millesimale legacy.
* **`dett_pers`**: Contiene le spese personali/addebiti specifici per condomino o inquilino.
* **`operazioni`**: Elenco delle registrazioni e movimenti legacy.
> [!WARNING]
> Lo schema di staging è altamente flessibile. Colonne differenti possono essere presenti a seconda della versione del file MDB originario. Prima di effettuare query, verificare sempre la presenza delle colonne via:
> ```php
> Schema::connection('gescon_import')->getColumnListing($table)
> ```
### 2.2 Database di Produzione (Tabelle Chiave)
* **`stabili`**: Collegato alla rubrica universale tramite `rubrica_id`.
* **`unita_immobiliari`**: Rappresenta le singole unità (interni, scale, palazzine).
* **`tabelle_millesimali`**: Definizione delle tabelle (millesimi di proprietà, riscaldamento, ascensore, ecc.). Contiene la colonna `nord` per determinare l'ordinamento legacy di visualizzazione.
* **`dettaglio_millesimi`**: Associa le unità immobiliari alle tabelle millesimali.
* **Chiave composita unica**: `['tabella_millesimale_id', 'unita_immobiliare_id', 'ruolo_legacy']`.
* Questo consente di avere record separati sulla stessa unità per Condomino (`ruolo_legacy = C`) ed Inquilino (`ruolo_legacy = I`), garantendo la corretta attribuzione dei millesimi per tipo di soggetto.
* **`voci_spesa`**: Elenco delle voci di costo collegate ai bilanci e alle gestioni contabili.
---
## 3. Pipeline d'Importazione dei Dati (`ImportGesconFullPipeline.php`)
La pipeline esegue l'importazione sequenziale dei dati con un flusso incrementale:
1. **`stepStabili`**: Importa gli stabili e li sincronizza con la Rubrica Universale.
2. **`stepMillesimi`**:
* Associa i millesimi senza collassare le righe con ruoli differenti (`C` e `I`).
* Conserva e mappa il campo `nord` per l'ordinamento delle righe.
3. **`stepAffitti`**: Esegue l'importazione dei canoni e dei contratti dal database `parti_comuni.mdb`.
4. **`stepOperazioni`**: Sincronizza in modo incrementale i movimenti in prima nota.
---
## 4. Validazioni e Regole di Business Contabili
### 4.1 Quadratura Millesimi a Consuntivo (Blocco di Sicurezza)
Per evitare errori nei bilanci finali, la ripartizione contabile a **Consuntivo** è protetta da un blocco di sicurezza in [RipartizioneSpesaService.php](file:///home/michele/netgescon-day0-backup/app/Services/RipartizioneSpesaService.php):
* Se la somma dei millesimi di dettaglio presenti in `dettaglio_millesimi` per la tabella specificata **differisce** dal totale dichiarato in `tabelle_millesimali.totale_millesimi` (solitamente `1000.00`):
* **A Preventivo**: Il sistema solleva un warning non bloccante memorizzato nei log.
* **A Consuntivo**: Il sistema lancia un'eccezione che blocca l'esecuzione e impedisce il salvataggio della ripartizione.
---
## 5. Strategia per Moduli e Integrazioni Future (API & Gateway)
Questa sezione delinea l'architettura programmata per le prossime implementazioni, facilitando l'allacciamento di servizi esterni tramite API.
### 5.1 Portale Condomini/Inquilini ed Estratto Conto con Gateway Esterni
Per consentire il pagamento autonomo delle rate e dei debiti minimizzando i rischi di sicurezza:
* **Zero Dati Finanziari in Locale**: NetGescon **non** memorizzerà credenziali di carte di credito, IBAN o dati sensibili di pagamento nei propri server.
* **Flusso del Pagamento**:
1. Il condomino accede alla propria area riservata (Estratto Conto) e seleziona le rate da pagare.
2. Il sistema genera un token di sessione di pagamento univoco e reindirizza l'utente a un gateway sicuro esterno (es. Stripe, PayPal, PagoPA).
3. Al completamento del pagamento, il gateway esterno notifica NetGescon tramite un **Webhook sicuro**.
4. NetGescon elabora il webhook, verifica la firma e aggiorna lo stato della rata a `pagata` registrando l'incasso e la relativa operazione contabile.
```mermaid
sequenceDiagram
Condomino->>NetGescon: Richiesta pagamento rate
NetGescon->>Gateway Esterno: Crea sessione e reindirizza
Condomino->>Gateway Esterno: Inserisce dati e paga
Gateway Esterno-->>NetGescon: Webhook (Notifica Pagamento Avvenuto)
NetGescon->>NetGescon: Registra Incasso e Prima Nota
Gateway Esterno->>Condomino: Conferma Pagamento
```
### 5.2 Modulo Letturisti e Gestione Consumi Acqua (API-First)
Per agevolare chi effettua le letture e le installazioni sul campo, l'integrazione avverrà in modalità API-First:
* **Interfaccia Dedicata**: Una sezione autonoma per i tecnici letturisti in cui gestire:
* Installazione e anagrafica contatori.
* Inserimento rapido delle letture periodiche (anche offline/mobile).
* **Algoritmi di Consumo e Rilevamento Perdite Silenti**:
* NetGescon riceve le letture e applica algoritmi predittivi basati sullo storico dei consumi.
* Se viene rilevato un consumo continuo anomalo (es. prelievo d'acqua notturno costante per più giorni), il sistema invia automaticamente notifiche push o email di allarme al condomino e all'amministratore per prevenire danni da perdite d'acqua.
### 5.3 Integrazioni Governative (Italia)
Il core sarà espanso per interfacciarsi con le banche dati della pubblica amministrazione italiana:
* **ANPR / Agenzia delle Entrate**: Per verificare la correttezza dei codici fiscali e delle anagrafiche dei condomini importati.
* **SDI (Fatturazione Elettronica)**: Integrazione nativa per la ricezione automatica in prima nota delle fatture fornitori in formato XML.
---
## 6. Riepilogo Modifiche Recenti (Changelog Tecnico)
Durante questo ciclo di sviluppo sono stati implementati i seguenti miglioramenti architetturali e correttivi:
* **Configurazione e Toggle Globali**: Introdotta la chiave `legacy_import_enabled` in `config/netgescon.php` per condizionare l'accesso ed eliminare/nascondere le viste di importazione legacy se non attive.
* **Sincronizzazione Rubrica Universale**: Implementato il metodo `syncRubricaContatto()` sul modello `Stabile.php` per inserire automaticamente i contatti degli stabili in rubrica (categoria `'condomino'`, tipo `'persona_giuridica'`).
* **Importazione Canoni di Locazione**: Aggiunto lo step CLI `'affitti'` alla pipeline per avviare `affitti:import-mdb --refresh-canoni` sul database `parti_comuni.mdb`.
* **Campo `nord` e Count Voci**: Introdotta la colonna `nord` per l'ordinamento manuale legacy delle tabelle millesimali e calcolato/mostrato a schermo il conteggio delle voci di spesa collegate (`voci_count`).
* **Unificazione Prospetto Millesimi**: Eliminazione della pagina ridondante `TabelleMillesimaliProspetto` e conversione dei link con parametro `?tab=prospetto`. Iconizzazione delle azioni in griglia.
* **Preservazione Ruoli Comproprietari/Inquilini**: Modifica del processo di importazione millesimi per non fondere le righe con lo stesso condomino ed unità ma ruoli differenti, salvando sia `ruolo_legacy` sia `nord`.
* **Filtro Ricerca Addebiti**: Implementata la ricerca testuale nel modal addebiti personali (`section.blade.php`), salvaguardando la reattività e gli indici Livewire per le modifiche degli importi.
* **Quadratura Millesimi**: Integrazione della validazione bloccante a consuntivo per controllare la discrepanza tra la somma dei dettagli millesimi e il totale dichiarato della tabella.

View File

@ -97,7 +97,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $gestioniTab ===
@if($gestioniTab === 'gestioni')
<div class="text-sm text-gray-600 dark:text-gray-400">
Elenco completo delle gestioni collegate allo stabile (aperte e chiuse), con sincronizzazione automatica degli anni ordinari dal legacy. La stessa anagrafica è disponibile anche in
<a class="text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Gescon\GestioniArchivio::getUrl(panel: 'admin-filament') }}" target="_blank" rel="noopener">GESCON Gestioni</a>.
<a class="text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Gescon\GestioniArchivio::getUrl(panel: 'admin-filament') }}">GESCON Gestioni</a>.
</div>
@livewire(\App\Filament\Pages\Condomini\Components\GestioniStabileTable::class, ['scope' => 'all'])
@ -140,7 +140,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $gestioniTab ===
<input type="checkbox" wire:model.defer="checklistFineAnnoState.{{ $item->id }}" class="rounded border-gray-300 text-primary-600 shadow-sm focus:ring-primary-500" />
<span>{{ $item->label }}</span>
@if($item->action_url)
<a class="text-primary-600 hover:underline text-xs" href="{{ $item->action_url }}" target="_blank" rel="noopener">Apri</a>
<a class="text-primary-600 hover:underline text-xs" href="{{ $item->action_url }}">Apri</a>
@endif
</label>
@empty
@ -333,7 +333,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $straordTab ===
<div class="text-[10px] text-gray-500">{{ $tabella->denominazione }}</div>
@endif
@if($tabella)
<a class="text-[10px] text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') }}" target="_blank" rel="noopener">Apri tabelle</a>
<a class="text-[10px] text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') }}">Apri tabelle</a>
@endif
</td>
<td class="px-2 py-1 text-right">{{ $importo !== null ? number_format((float)$importo, 2, ',', '.') : '—' }}</td>
@ -381,7 +381,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $straordTab ===
<div class="text-[10px] text-gray-500">{{ $tabella->denominazione }}</div>
@endif
@if($tabella)
<a class="text-[10px] text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') }}" target="_blank" rel="noopener">Apri tabelle</a>
<a class="text-[10px] text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') }}">Apri tabelle</a>
@endif
</td>
<td class="px-2 py-1 text-right">{{ $cons !== null ? number_format((float)$cons, 2, ',', '.') : '—' }}</td>
@ -423,7 +423,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $straordTab ===
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
Mostra solo le gestioni <strong>aperte</strong> (non chiuse/consolidate). La stessa anagrafica è disponibile anche in
<a class="text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Gescon\GestioniArchivio::getUrl(panel: 'admin-filament') }}" target="_blank" rel="noopener">GESCON Gestioni</a>.
<a class="text-primary-600 hover:underline" href="{{ \App\Filament\Pages\Gescon\GestioniArchivio::getUrl(panel: 'admin-filament') }}">GESCON Gestioni</a>.
</div>
@livewire(\App\Filament\Pages\Condomini\Components\GestioniStabileTable::class)

View File

@ -90,6 +90,8 @@
.palazzine-layout { align-items: start; }
.palazzine-layout .left-col { position: static; top: auto; align-self: start; }
.unit-card:hover { box-shadow: 0 10px 15px -3px rgb(15 23 42 / 0.12); }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
<!-- KPI palazzine -->
@ -236,7 +238,13 @@
<div class="bg-gray-50 px-3 py-2 border-b text-sm text-gray-700 font-semibold">{{ $labelPiano((int) $piano) }}</div>
<div class="p-4">
@if($unitaPiano->isNotEmpty())
<div class="overflow-x-auto pb-3 -mx-4 px-4 scrollbar-thin">
<div class="relative group/floor">
<button type="button"
onclick="scrollFloor(this, 'left')"
class="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 z-20 w-8 h-8 rounded-full bg-white/95 shadow-md border border-gray-200 flex items-center justify-center text-gray-700 hover:bg-gray-50 focus:outline-none opacity-0 group-hover/floor:opacity-100 transition-opacity duration-200">
<i class="fas fa-chevron-left text-xs"></i>
</button>
<div class="overflow-x-auto pb-3 -mx-4 px-4 no-scrollbar scroll-smooth snap-x snap-mandatory">
<div class="flex flex-row flex-nowrap gap-5" style="width: max-content; min-width: 100%;">
@foreach($unitaPiano->sortBy(fn ($u) => \App\Support\InternoSort::key($u->interno !== null ? (string) $u->interno : null)) as $u)
@php
@ -277,7 +285,7 @@
default => 'bg-slate-100 text-slate-600 border-slate-200',
};
@endphp
<div class="w-[320px] flex-shrink-0 border border-gray-200 rounded-lg bg-white shadow-sm p-4 space-y-4 unit-card"
<div class="w-[320px] flex-shrink-0 border border-gray-200 rounded-lg bg-white shadow-sm p-4 space-y-4 unit-card snap-start"
data-unita-id="{{ $u->id }}"
data-scala="{{ strtoupper($scala ?? '') }}"
data-interno="{{ strtoupper($u->interno ?? '') }}">
@ -362,6 +370,13 @@
</div>
@endforeach
</div>
</div>
<button type="button"
onclick="scrollFloor(this, 'right')"
class="absolute right-0 top-1/2 -translate-y-1/2 -mr-2 z-20 w-8 h-8 rounded-full bg-white/95 shadow-md border border-gray-200 flex items-center justify-center text-gray-700 hover:bg-gray-50 focus:outline-none opacity-0 group-hover/floor:opacity-100 transition-opacity duration-200">
<i class="fas fa-chevron-right text-xs"></i>
</button>
</div>
@else
<p class="text-sm text-slate-500">Nessuna unità su questo piano.</p>
@endif
@ -416,7 +431,13 @@
<div class="bg-gray-50 px-3 py-2 border-b text-sm text-gray-700 font-semibold">{{ $labelPiano((int) $piano) }}</div>
<div class="p-4">
@if($unitaPiano->isNotEmpty())
<div class="overflow-x-auto pb-3 -mx-4 px-4 scrollbar-thin">
<div class="relative group/floor">
<button type="button"
onclick="scrollFloor(this, 'left')"
class="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 z-20 w-8 h-8 rounded-full bg-white/95 shadow-md border border-gray-200 flex items-center justify-center text-gray-700 hover:bg-gray-50 focus:outline-none opacity-0 group-hover/floor:opacity-100 transition-opacity duration-200">
<i class="fas fa-chevron-left text-xs"></i>
</button>
<div class="overflow-x-auto pb-3 -mx-4 px-4 no-scrollbar scroll-smooth snap-x snap-mandatory">
<div class="flex flex-row flex-nowrap gap-5" style="width: max-content; min-width: 100%;">
@foreach($unitaPiano->sortBy(fn ($u) => \App\Support\InternoSort::key($u->interno !== null ? (string) $u->interno : null)) as $u)
@php
@ -457,7 +478,7 @@
default => 'bg-slate-100 text-slate-600 border-slate-200',
};
@endphp
<div class="w-[320px] flex-shrink-0 border border-gray-200 rounded-lg bg-white shadow-sm p-4 space-y-4 unit-card"
<div class="w-[320px] flex-shrink-0 border border-gray-200 rounded-lg bg-white shadow-sm p-4 space-y-4 unit-card snap-start"
data-unita-id="{{ $u->id }}"
data-scala="{{ strtoupper($scala ?? '') }}"
data-interno="{{ strtoupper($u->interno ?? '') }}">
@ -542,6 +563,13 @@
</div>
@endforeach
</div>
</div>
<button type="button"
onclick="scrollFloor(this, 'right')"
class="absolute right-0 top-1/2 -translate-y-1/2 -mr-2 z-20 w-8 h-8 rounded-full bg-white/95 shadow-md border border-gray-200 flex items-center justify-center text-gray-700 hover:bg-gray-50 focus:outline-none opacity-0 group-hover/floor:opacity-100 transition-opacity duration-200">
<i class="fas fa-chevron-right text-xs"></i>
</button>
</div>
@else
<p class="text-sm text-slate-500">Nessuna unità su questo piano.</p>
@endif
@ -868,6 +896,14 @@ function highlightNeighbor(element, label, type) {
}
}
});
function scrollFloor(btn, direction) {
const container = btn.parentElement.querySelector('.overflow-x-auto');
if (!container) return;
const cardWidth = 340; // 320px card + 20px gap
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
container.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
</script>
</div>
</div>

View File

@ -44,34 +44,57 @@
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const persistSidebarScroll = () => {
const sidebar = document.querySelector('.fi-sidebar-nav') ||
(function() {
const getSidebar = () => {
return document.querySelector('.fi-sidebar-nav') ||
document.querySelector('aside nav') ||
document.querySelector('.fi-sidebar') ||
document.querySelector('.fi-sidebar-nav-groups');
if (!sidebar) return;
// Restore scroll position
const savedScroll = localStorage.getItem('fi-sidebar-scroll-top');
if (savedScroll !== null) {
sidebar.scrollTop = parseInt(savedScroll, 10);
}
// Save scroll position on scroll
sidebar.addEventListener('scroll', () => {
localStorage.setItem('fi-sidebar-scroll-top', sidebar.scrollTop);
}, { passive: true });
};
persistSidebarScroll();
const handleScroll = (e) => {
localStorage.setItem('fi-sidebar-scroll-top', e.currentTarget.scrollTop);
};
// Support for Livewire SPA navigation
document.addEventListener('livewire:navigated', () => {
persistSidebarScroll();
});
document.addEventListener('livewire:load', () => {
persistSidebarScroll();
});
const restoreScroll = () => {
const sidebar = getSidebar();
if (!sidebar) return;
const savedScroll = localStorage.getItem('fi-sidebar-scroll-top');
if (savedScroll !== null) {
const scrollTopVal = parseInt(savedScroll, 10);
sidebar.scrollTop = scrollTopVal;
requestAnimationFrame(() => {
sidebar.scrollTop = scrollTopVal;
});
setTimeout(() => {
sidebar.scrollTop = scrollTopVal;
}, 50);
setTimeout(() => {
sidebar.scrollTop = scrollTopVal;
}, 150);
}
};
const setupScrollListener = () => {
const sidebar = getSidebar();
if (!sidebar) return;
sidebar.removeEventListener('scroll', handleScroll);
sidebar.addEventListener('scroll', handleScroll, { passive: true });
};
const init = () => {
restoreScroll();
setupScrollListener();
};
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('livewire:navigated', init);
document.addEventListener('livewire:load', init);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
init();
}
})();
</script>

View File

@ -13,6 +13,7 @@
<a class="px-3 py-2 text-xs rounded bg-slate-50 text-slate-700 border border-slate-200" href="{{ \App\Filament\Pages\Condomini\StabilePage::getUrl(panel: 'admin-filament') }}">Scheda Stabile</a>
</div>
@if(config('netgescon.legacy_import_enabled', true))
<div class="border rounded-lg p-3 bg-gray-50">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-2 items-end">
<div class="lg:col-span-3">
@ -27,6 +28,7 @@
<pre class="mt-2 p-2 text-[11px] bg-white border rounded overflow-auto max-h-40">{{ $legacyLastSyncMessage }}</pre>
@endif
</div>
@endif
<div class="border-b">
<nav class="flex flex-wrap gap-4 text-sm">

View File

@ -19,6 +19,13 @@ class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-s
@if(!empty($stabili))
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-white px-3 py-2">
<div class="text-sm font-semibold text-gray-800">Nominativi</div>
<div class="flex items-center gap-4">
<!-- Toggle Cumulato -->
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" wire:model.live="cumulato" class="rounded border-gray-300 text-primary-600 shadow-sm focus:ring-primary-500">
<span class="text-xs font-medium text-gray-700">Visualizzazione cumulata (unificata)</span>
</label>
<div class="flex items-center gap-2">
<div class="text-xs text-gray-600">Stabile</div>
<select wire:model.live="stabileId" class="fi-input fi-input-wrp fi-select-input text-xs">
@ -28,6 +35,7 @@ class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-s
</select>
</div>
</div>
</div>
@endif
<div class="rounded-lg border bg-white overflow-hidden">

View File

@ -244,8 +244,8 @@ class="fi-btn fi-btn-color-primary fi-btn-size-sm"
@php $lastTipo = $tipo; @endphp
@endif
@php
$prospettoUrl = \App\Filament\Pages\Condomini\TabelleMillesimaliProspetto::getUrl(panel: 'admin-filament')
. '?tabella_id=' . (int) $r['tabella_id']
$prospettoUrl = \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament')
. '?tab=prospetto&tabella_id=' . (int) $r['tabella_id']
. '&back=' . urlencode((string) request()->getRequestUri());
@endphp
<tr>

View File

@ -0,0 +1,981 @@
<x-filament-panels::page>
@php
$stats = $this->riscaldamentoDashboardStats;
$annoGestioneAttivo = \App\Support\AnnoGestioneContext::resolveActiveAnno(auth()->user());
$fatturePerGestione = $this->riscaldamentoFatturePerGestione;
$feEstratteSummary = $this->riscaldamentoFeEstratteSummary;
$feSelezioneSummary = $this->riscaldamentoFeSelezioneSummary;
$feEstratteRows = $this->riscaldamentoFeEstratteRows;
$lettureCondomini = $this->riscaldamentoLettureCondomini;
$lettureGenerali = $this->riscaldamentoLettureGenerali;
$uiReadingForm = $this->riscaldamentoUiReadingForm;
$uiServiceOptions = $this->riscaldamentoUiServiceOptions;
$uiUnitOptions = $this->riscaldamentoUiUnitOptions;
$tariffeRows = $this->riscaldamentoTariffeRows;
$legacyArchive = $this->riscaldamentoLegacyArchiveAvailability;
$legacyExtraRows = $this->riscaldamentoAltreVociLegacy;
$legacyOpsSummary = $this->riscaldamentoLegacyOperazioniSummary;
$legacyOpsRows = $this->riscaldamentoLegacyOperazioniRows;
$riscaldamentoContratto = $this->riscaldamentoContrattoStabile;
$riscaldamentoRipartoNominativi = $this->riscaldamentoRipartoNominativiLegacy;
$campagnaStats = $this->riscaldamentoCampagnaStats;
$campagnaRows = $this->riscaldamentoCampagnaRows;
@endphp
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Servizi / Beni comuni - RISCALDAMENTO</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Cruscotto operativo per utenze, beni comuni, locali condivisi e contatori riscaldamento dello stabile. Qui convivono contratti, servizi ad uso comune, rimborsi spesa, contatori generali e contatori particolari con storico e tariffe.
</p>
<div class="mt-2 inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200">
Anno gestione attivo: {{ $annoGestioneAttivo }}
</div>
</div>
<div class="border-b border-gray-200 dark:border-gray-800">
<nav class="-mb-px flex flex-wrap gap-6" aria-label="Tabs">
<button type="button"
wire:click="setRiscaldamentoTab('dashboard')"
class="pb-3 px-1 border-b-2 font-medium text-sm {{ $riscaldamentoTab === 'dashboard' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Dashboard
</button>
<button type="button"
wire:click="setRiscaldamentoTab('fatture')"
class="pb-3 px-1 border-b-2 font-medium text-sm {{ $riscaldamentoTab === 'fatture' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Fatture per gestione
</button>
<button type="button"
wire:click="setRiscaldamentoTab('letture')"
class="pb-3 px-1 border-b-2 font-medium text-sm {{ $riscaldamentoTab === 'letture' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Lett. Contatori UI
</button>
<button type="button"
wire:click="setRiscaldamentoTab('generale')"
class="pb-3 px-1 border-b-2 font-medium text-sm {{ $riscaldamentoTab === 'generale' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Letture contatore generale
</button>
<button type="button"
wire:click="setRiscaldamentoTab('tariffe')"
class="pb-3 px-1 border-b-2 font-medium text-sm {{ $riscaldamentoTab === 'tariffe' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Rendiconto Spese / Bilancio
</button>
<button type="button"
wire:click="setRiscaldamentoTab('servizi')"
class="pb-3 px-1 border-b-2 font-medium text-sm {{ $riscaldamentoTab === 'servizi' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Servizi / Utenze (tabella)
</button>
</nav>
</div>
@if($riscaldamentoTab === 'dashboard')
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs text-gray-500">Totale fatture riscaldamento (import registrato)</div>
<div class="mt-2 text-xl font-semibold">EUR {{ number_format((float) ($stats['totale_fatture'] ?? 0), 2, ',', '.') }}</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs text-gray-500">Controllo legacy operazioni (R%)</div>
<div class="mt-2 text-xl font-semibold">EUR {{ number_format((float) ($stats['totale_operazioni_ac12_legacy'] ?? 0), 2, ',', '.') }}</div>
<div class="mt-1 text-xs {{ ((float) ($stats['delta_operazioni_vs_fatture'] ?? 0)) == 0.0 ? 'text-emerald-600' : 'text-amber-600' }}">
Delta fatture registrate - legacy R%: {{ number_format((float) ($stats['delta_operazioni_vs_fatture'] ?? 0), 2, ',', '.') }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs text-gray-500">Letture condomini registrate</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((int) ($stats['totale_letture'] ?? 0), 0, ',', '.') }}</div>
<div class="mt-1 text-xs text-gray-500">con riferimento acquisizione: {{ number_format((int) ($stats['totale_letture_con_riferimento'] ?? 0), 0, ',', '.') }}</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs text-gray-500">Consumi e tariffe</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((float) ($stats['totale_consumi_mc'] ?? 0), 3, ',', '.') }} mc</div>
<div class="mt-1 text-xs text-gray-500">snapshot tariffe: {{ number_format((int) ($stats['totale_tariffe'] ?? 0), 0, ',', '.') }}</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Copertura archivio legacy ACEA</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<div>Tabella <code>contratti_ACEA</code>: <span class="font-semibold">{{ ($legacyArchive['contratti_acea'] ?? false) ? 'presente' : 'non presente' }}</span></div>
<div>Tabella <code>Tariffe_ACEA_Standard</code>: <span class="font-semibold">{{ ($legacyArchive['tariffe_acea_standard'] ?? false) ? 'presente' : 'non presente' }}</span></div>
<div class="mt-1 text-xs text-gray-500">{{ $legacyArchive['note'] ?? '' }}</div>
</div>
</div>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/10">
<div class="mb-2 flex flex-wrap items-center justify-between gap-3">
<div class="text-sm font-semibold text-blue-900 dark:text-blue-200">Contratto servizio RISCALDAMENTO (stabile -> fornitore)</div>
<x-filament::button size="xs" color="info" wire:click="ensureRiscaldamentoAceaContract">
Allinea/crea contratto con Acea Ato2
</x-filament::button>
</div>
<div class="grid grid-cols-1 gap-2 text-xs text-blue-900 dark:text-blue-100 md:grid-cols-2">
<div>Servizio: <span class="font-semibold">{{ $riscaldamentoContratto['servizio_nome'] ?: 'non configurato' }}</span></div>
<div>Fornitore: <span class="font-semibold">{{ $riscaldamentoContratto['fornitore_nome'] ?: 'non assegnato' }}</span></div>
<div>Codice utenza: <span class="font-semibold">{{ $riscaldamentoContratto['codice_utenza'] ?: '—' }}</span></div>
<div>Codice cliente: <span class="font-semibold">{{ $riscaldamentoContratto['codice_cliente'] ?: '—' }}</span></div>
<div>Codice contratto: <span class="font-semibold">{{ $riscaldamentoContratto['codice_contratto'] ?: '—' }}</span></div>
<div>Matricola contatore: <span class="font-semibold">{{ $riscaldamentoContratto['matricola'] ?: '—' }}</span></div>
</div>
@if(!($riscaldamentoContratto['fornitore_nome'] ?? null) && ($riscaldamentoContratto['suggerito_fornitore'] ?? null))
<div class="mt-2 text-xs text-blue-700 dark:text-blue-300">Suggerito in anagrafica fornitori: {{ $riscaldamentoContratto['suggerito_fornitore'] }}</div>
@endif
</div>
@endif
@if($riscaldamentoTab === 'fatture')
<div class="space-y-4">
<!-- Filtro Periodo Esercizio Riscaldamento Personalizzato -->
<div class="rounded-lg border border-cyan-200 bg-white p-4 dark:border-cyan-700/50 dark:bg-gray-900 shadow-sm">
<div class="text-xs font-semibold uppercase tracking-wide text-cyan-900 dark:text-cyan-200 mb-3">Periodo Esercizio Riscaldamento (Personalizzato per Stabile)</div>
<div class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-[10px] font-medium text-gray-500 uppercase dark:text-gray-400">Data inizio periodo</label>
<input type="date" wire:model.live="riscaldamentoDataInizioFiltro" class="mt-1 block rounded-md border-gray-300 shadow-sm text-xs dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100 focus:border-cyan-500 focus:ring-cyan-500">
</div>
<div>
<label class="block text-[10px] font-medium text-gray-500 uppercase dark:text-gray-400">Data fine periodo</label>
<input type="date" wire:model.live="riscaldamentoDataFineFiltro" class="mt-1 block rounded-md border-gray-300 shadow-sm text-xs dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100 focus:border-cyan-500 focus:ring-cyan-500">
</div>
<x-filament::button type="button" size="sm" color="info" wire:click="saveRiscaldamentoFilterDates">Salva e allinea fatture</x-filament::button>
</div>
<div class="mt-2 text-[10px] text-gray-500 dark:text-gray-400">Impostando queste date, le FE dello stabile verranno filtrate per questo intervallo temporale anziché per l'anno solare, e verranno collegate alla gestione ordinaria corretta.</div>
</div>
<div class="rounded-lg border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-700/50 dark:bg-cyan-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-cyan-900 dark:text-cyan-200">Dati estratti dalle fatture FE riscaldamento</div>
<div class="mt-1 text-xs text-cyan-800 dark:text-cyan-300">Qui vedi consumo, contratto, cliente, matricola, CBILL e riepilogo letture letti dalle FE riscaldamento, con i collegamenti operativi verso scheda FE, ticket generale, contratti e archivio letture.</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ \App\Filament\Pages\Supporto\TicketRiscaldamentoGenerale::getUrl(panel: 'admin-filament') }}"
class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 text-xs font-medium text-cyan-800 hover:bg-cyan-100">
Contatore generale
</a>
<a href="{{ \App\Filament\Pages\Condomini\ContrattiHub::getUrl(panel: 'admin-filament') }}"
class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 text-xs font-medium text-cyan-800 hover:bg-cyan-100">
Contratti riscaldamento
</a>
</div>
</div>
<div class="mb-4 grid gap-3 md:grid-cols-5">
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">FE estratte</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((int) ($feEstratteSummary['fatture'] ?? 0), 0, ',', '.') }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Periodi letti</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((int) ($feEstratteSummary['periodi'] ?? 0), 0, ',', '.') }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Totale mc FE</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((float) ($feEstratteSummary['totale_mc'] ?? 0), 3, ',', '.') }} mc</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Totale spesa FE</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((float) ($feEstratteSummary['totale_spesa'] ?? 0), 2, ',', '.') }} EUR</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Finestra autolettura</div>
<div class="mt-2 text-xl font-semibold">{{ number_format((int) ($feEstratteSummary['con_finestra'] ?? 0), 0, ',', '.') }}</div>
<div class="mt-1 text-[11px] text-gray-500">ultima FE {{ !empty($feEstratteSummary['ultima_data']) ? \Carbon\Carbon::parse($feEstratteSummary['ultima_data'])->format('d/m/Y') : '—' }}</div>
</div>
</div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-cyan-200 bg-white px-4 py-3 text-xs dark:border-cyan-700/50 dark:bg-gray-900">
<div class="flex flex-wrap items-center gap-4 text-cyan-900 dark:text-cyan-100">
<span class="font-semibold">Selezione per ripartizione:</span>
<span>{{ number_format((int) ($feSelezioneSummary['fatture'] ?? 0), 0, ',', '.') }} FE</span>
<span>{{ number_format((float) ($feSelezioneSummary['totale_mc'] ?? 0), 3, ',', '.') }} mc</span>
<span>{{ number_format((float) ($feSelezioneSummary['totale_spesa'] ?? 0), 2, ',', '.') }} EUR</span>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="riconciliaAutomaticamentePagamenti" class="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-600 px-3 py-1.5 font-medium text-white hover:bg-emerald-500 shadow-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">Riconcilia Pagamenti (Banca)</button>
<button type="button" wire:click="selectAllRiscaldamentoFe" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">Seleziona tutte</button>
<button type="button" wire:click="clearRiscaldamentoFeSelection" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">Azzera selezione</button>
<a href="{{ $this->riscaldamentoRipartizionePrintUrl }}" target="_blank" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">Stampa riepilogo</a>
<a href="{{ $this->riscaldamentoRipartizionePdfUrl }}" target="_blank" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">PDF riepilogo</a>
<button type="button" wire:click="$set('showSendEmailModal', true)" class="inline-flex items-center rounded-md border border-cyan-300 bg-cyan-600 px-3 py-1.5 font-medium text-white hover:bg-cyan-500 shadow-sm focus:outline-none focus:ring-2 focus:ring-cyan-500">Invia via email</button>
</div>
</div>
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900 dark:border-amber-700/50 dark:bg-amber-900/10 dark:text-amber-100">
Le FE mostrate sono filtrate per anno gestione e fornitore riscaldamento dello stabile. Se il PDF non espone matricola o utenza, usa la selezione manuale per separare le fatture del contatore corretto prima della ripartizione.
</div>
<div class="overflow-x-visible">
<table class="w-full table-auto text-xs">
<thead>
<tr class="border-b border-cyan-200 dark:border-cyan-700/50">
<th class="px-2 py-2 text-left">Sel.</th>
<th class="px-2 py-2 text-left">Fattura</th>
<th class="px-2 py-2 text-left">Servizio / codici</th>
<th class="px-2 py-2 text-left">Periodo FE</th>
<th class="px-2 py-2 text-left">Finestra lettura</th>
<th class="px-2 py-2 text-right">Mc</th>
<th class="px-2 py-2 text-right">Spesa</th>
<th class="px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($feEstratteRows as $row)
<tr class="border-b border-cyan-100 dark:border-cyan-800/30">
<td class="px-2 py-2 align-top">
<input type="checkbox"
wire:click="toggleRiscaldamentoFeSelection({{ (int) $row['fattura_id'] }})"
@checked((bool) ($row['selected'] ?? false))
class="rounded border-cyan-300 text-cyan-600 focus:ring-cyan-500">
</td>
<td class="px-2 py-2 align-top">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $row['numero_fattura'] ?: ('FE #' . $row['fattura_id']) }}</div>
<div class="text-[10px] text-cyan-700 dark:text-cyan-300">{{ $row['data_fattura'] ?: '—' }}</div>
@if(!empty($row['legacy_n_spe']))
<div class="mt-1 text-[10px] bg-indigo-50 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-semibold inline-block">
Legacy: Reg. #{{ $row['legacy_id'] }} · Prot. {{ $row['legacy_n_spe'] }}
</div>
@endif
<div class="mt-1">
@if($row['registrata_stato'] === 'pagato')
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-950/30 px-1.5 py-0.5 text-[10px] font-semibold text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20">Pagata il {{ $row['registrata_data_pagamento'] }}</span>
@elseif($row['registrata_stato'] === 'da_pagare')
<span class="inline-flex items-center rounded-md bg-amber-50 dark:bg-amber-950/30 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20">Debito ( {{ number_format($row['registrata_netto_da_pagare'], 2, ',', '.') }})</span>
@else
<span class="inline-flex items-center rounded-md bg-gray-50 dark:bg-gray-950/30 px-1.5 py-0.5 text-[10px] font-semibold text-gray-700 dark:text-gray-300 ring-1 ring-inset ring-gray-600/20">Da registrare</span>
@endif
</div>
</td>
<td class="px-2 py-2">
<div class="font-medium text-gray-900 dark:text-gray-100" title="{{ $row['servizio'] ?: 'Riscaldamento' }}">{{ $row['servizio'] ?: 'Riscaldamento' }}</div>
<div class="break-words text-[10px] text-gray-500" title="Matr. {{ $row['matricola'] ?: '—' }} · Utenza {{ $row['codice_utenza'] ?: '—' }} · Cliente {{ $row['codice_cliente'] ?: '—' }} · Contratto {{ $row['codice_contratto'] ?: '—' }} · CBILL {{ $row['cbill_display'] ?: '—' }}{{ !empty($row['sia']) ? ' · SIA ' . $row['sia'] : '' }}">
Matr. {{ $row['matricola'] ?: '—' }} · Utenza {{ $row['codice_utenza'] ?: '—' }} · Cliente {{ $row['codice_cliente'] ?: '—' }} · Contratto {{ $row['codice_contratto'] ?: '—' }} · CBILL {{ $row['cbill_display'] ?: '—' }} @if(!empty($row['sia'])) · SIA {{ $row['sia'] }} @endif
</div>
<div class="mt-1 text-[10px] text-gray-500 font-medium">
Voce pre-assegnata fornitore: <span class="text-cyan-800 dark:text-cyan-400 font-semibold">{{ $row['voce_spesa_default_name'] }}</span>
</div>
@if(!empty($row['periodicita_fatturazione']) || !empty($row['numero_componenti']) || !empty($row['numero_unita_abitative']))
<div class="break-words text-[10px] text-cyan-700 dark:text-cyan-300">{{ $row['periodicita_fatturazione'] ?: 'periodicita n/d' }} @if(!empty($row['numero_componenti'])) · comp. {{ $row['numero_componenti'] }} @endif @if(!empty($row['numero_unita_abitative'])) · unita {{ $row['numero_unita_abitative'] }} @endif</div>
@endif
@if(!($row['identificativi_noti'] ?? false))
<div class="mt-1 text-[10px] text-amber-700 dark:text-amber-300">Identificativi non leggibili dal PDF: usare la selezione FE per il contatore corretto.</div>
@endif
</td>
<td class="px-2 py-2 align-top">
<div>{{ $row['periodo_label'] ?: '—' }}</div>
<div class="text-[10px] text-gray-500">periodi {{ number_format((int) ($row['periodi_count'] ?? 0), 0, ',', '.') }} · riepiloghi {{ number_format((int) ($row['letture_count'] ?? 0), 0, ',', '.') }}</div>
@if(!empty($row['letture_preview']))
<div class="mt-1 break-words text-[10px] text-cyan-700 dark:text-cyan-300" title="{{ implode(' | ', $row['letture_preview']) }}">
{{ implode(' | ', $row['letture_preview']) }}
</div>
@endif
</td>
<td class="px-2 py-2 align-top">
@if($row['ha_finestra'])
<span class="inline-block rounded bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-700">{{ $row['finestra_label'] }}</span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-2 py-2 text-right align-top whitespace-nowrap">{{ number_format((float) ($row['totale_mc'] ?? 0), 3, ',', '.') }}</td>
<td class="px-2 py-2 text-right align-top whitespace-nowrap">{{ number_format((float) ($row['totale_spesa'] ?? 0), 2, ',', '.') }}</td>
<td class="px-2 py-2 align-top">
<div class="flex flex-wrap gap-2">
<a href="{{ $row['scheda_fe_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">Scheda FE</a>
<a href="{{ $row['ticket_generale_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">Ticket generale</a>
<a href="{{ $row['contratti_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">Contratti</a>
<a href="{{ $row['letture_servizio_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">Archivio letture</a>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-2 py-4 text-center text-cyan-700 dark:text-cyan-300">Nessuna FE riscaldamento estratta nello storico attivo.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border border-sky-200 bg-sky-50 p-4 dark:border-sky-700/50 dark:bg-sky-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-sky-900 dark:text-sky-200">Estrazione dati riscaldamento dalle FE pregresse</div>
<div class="mt-1 text-xs text-sky-800 dark:text-sky-300">Rilegge le fatture elettroniche gia' presenti per lo stabile e la gestione selezionata, aggiorna consumo, letture e tariffe senza creare doppioni.</div>
</div>
<x-filament::button size="sm" color="info" wire:click="backfillRiscaldamentoFromFatturePregresse">
Estrai dati dalle FE pregresse
</x-filament::button>
</div>
<div class="grid gap-3 md:grid-cols-3">
<label class="text-xs font-medium text-sky-900 dark:text-sky-100">
Gestione
<select wire:model.live="riscaldamentoBackfillGestione" class="mt-1 block w-full rounded-md border-sky-300 bg-white text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-sky-700 dark:bg-gray-950 dark:text-gray-100">
<option value="ordinaria">Ordinaria</option>
<option value="riscaldamento">Riscaldamento</option>
<option value="straordinaria">Straordinaria</option>
<option value="tutte">Tutte</option>
</select>
</label>
<label class="flex items-center gap-2 rounded-md border border-sky-200 bg-white px-3 py-2 text-xs font-medium text-sky-900 dark:border-sky-700 dark:bg-gray-950 dark:text-sky-100">
<input type="checkbox" wire:model.live="riscaldamentoBackfillOnlyMissing" class="rounded border-sky-300 text-sky-600 focus:ring-sky-500">
Processa solo FE non ancora estratte
</label>
<div class="rounded-md border border-sky-200 bg-white px-3 py-2 text-xs text-sky-800 dark:border-sky-700 dark:bg-gray-950 dark:text-sky-100">
Dopo la creazione della regola servizio, l'import FE continua gia' in automatico sul nuovo scarico; qui recuperi il pregresso.
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Fatture registrate divise per gestione</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-800">
<th class="px-3 py-2 text-left">Gestione</th>
<th class="px-3 py-2 text-right">Numero fatture</th>
<th class="px-3 py-2 text-right">FE</th>
<th class="px-3 py-2 text-right">Manuali</th>
<th class="px-3 py-2 text-right">Totale EUR</th>
<th class="px-3 py-2 text-right">Mc FE</th>
<th class="px-3 py-2 text-left">Contratti / contatori</th>
</tr>
</thead>
<tbody>
@forelse($fatturePerGestione as $row)
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-3 py-2">{{ strtoupper((string) ($row['gestione'] ?? 'non definita')) }}</td>
<td class="px-3 py-2 text-right">{{ number_format((int) ($row['fatture_count'] ?? 0), 0, ',', '.') }}</td>
<td class="px-3 py-2 text-right">{{ number_format((int) ($row['fe_count'] ?? 0), 0, ',', '.') }}</td>
<td class="px-3 py-2 text-right">{{ number_format((int) ($row['manual_count'] ?? 0), 0, ',', '.') }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float) ($row['totale_fatture'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float) ($row['totale_mc'] ?? 0), 3, ',', '.') }}</td>
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">
<div>Contratti: {{ !empty($row['contratti']) ? implode(', ', $row['contratti']) : '—' }}</div>
<div>Matricole: {{ !empty($row['matricole']) ? implode(', ', $row['matricole']) : '—' }}</div>
@if(!empty($row['cbill_codes']))
<div>CBILL: {{ implode(', ', $row['cbill_codes']) }}</div>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-3 py-4 text-center text-gray-500 dark:text-gray-400">Nessuna fattura riscaldamento trovata per lo stabile selezionato.</td>
</tr>
@endforelse
</tbody>
@if(count($fatturePerGestione) > 0)
<tfoot>
<tr class="bg-gray-50 dark:bg-gray-950/30">
<td class="px-3 py-2 font-semibold">Totale</td>
<td class="px-3 py-2 text-right font-semibold">{{ number_format((int) collect($fatturePerGestione)->sum('fatture_count'), 0, ',', '.') }}</td>
<td class="px-3 py-2 text-right font-semibold">{{ number_format((int) collect($fatturePerGestione)->sum('fe_count'), 0, ',', '.') }}</td>
<td class="px-3 py-2 text-right font-semibold">{{ number_format((int) collect($fatturePerGestione)->sum('manual_count'), 0, ',', '.') }}</td>
<td class="px-3 py-2 text-right font-semibold">{{ number_format((float) collect($fatturePerGestione)->sum('totale_fatture'), 2, ',', '.') }}</td>
</tr>
</tfoot>
@endif
</table>
</div>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-4 dark:border-indigo-700/50 dark:bg-indigo-900/10">
<div class="mb-2 text-sm font-semibold text-indigo-900 dark:text-indigo-200">Archivio legacy operazioni Riscaldamento (R%)</div>
@if(!empty($legacyOpsSummary['scope_note']))
<div class="mb-3 text-[11px] text-indigo-800 dark:text-indigo-300">{{ $legacyOpsSummary['scope_note'] }}</div>
@endif
<div class="mb-3 grid grid-cols-1 gap-2 text-xs text-indigo-900 dark:text-indigo-100 md:grid-cols-2">
<div>Righe: <span class="font-semibold">{{ number_format((int) ($legacyOpsSummary['righe'] ?? 0), 0, ',', '.') }}</span></div>
<div>Totale Riscaldamento: <span class="font-semibold">{{ number_format((float) ($legacyOpsSummary['totale'] ?? 0), 2, ',', '.') }}</span></div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr class="border-b border-indigo-200 dark:border-indigo-700/50">
<th class="px-2 py-2 text-left">Data op.</th>
<th class="px-2 py-2 text-left">Voce</th>
<th class="px-2 py-2 text-left">Doc legacy</th>
<th class="px-2 py-2 text-left">Fornitore</th>
<th class="px-2 py-2 text-left">Descrizione</th>
<th class="px-2 py-2 text-right">Importo</th>
<th class="px-2 py-2 text-left">Match fattura locale</th>
</tr>
</thead>
<tbody>
@forelse($legacyOpsRows as $row)
<tr class="border-b border-indigo-100 dark:border-indigo-800/30">
<td class="px-2 py-2">
{{ $row['data_operazione'] ? \Carbon\Carbon::parse($row['data_operazione'])->format('d/m/Y') : '—' }}
@if(!empty($row['n_spe']))
<div class="text-[9px] text-gray-500 font-semibold" title="Protocollo Legacy">Prot. {{ $row['n_spe'] }}</div>
@endif
</td>
<td class="px-2 py-2 font-medium">{{ $row['cod_spe'] ?: '—' }}</td>
<td class="px-2 py-2">
{{ $row['num_fat'] ?: '—' }}
<div class="text-[10px] text-indigo-700 dark:text-indigo-300">{{ $row['dt_fat'] ? \Carbon\Carbon::parse($row['dt_fat'])->format('d/m/Y') : '—' }}</div>
</td>
<td class="px-2 py-2">{{ $row['fornitore_legacy'] ?: ('cod. ' . ($row['cod_for'] ?: '—')) }}</td>
<td class="px-2 py-2 max-w-[22rem] truncate" title="{{ $row['benef'] }}">{{ $row['benef'] ?: '—' }}</td>
<td class="px-2 py-2 text-right">{{ number_format((float) ($row['importo'] ?? 0), 2, ',', '.') }}</td>
<td class="px-2 py-2">
@if(($row['matched_fattura_id'] ?? null))
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700">OK {{ strtoupper((string) ($row['match_type'] ?? '')) }}</span>
<div class="text-[10px] text-emerald-700">ID {{ $row['matched_fattura_id'] }} @if($row['matched_fe_id']) / FE {{ $row['matched_fe_id'] }} @endif</div>
@else
<span class="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">DA COLLEGARE</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-2 py-4 text-center text-indigo-700 dark:text-indigo-300">Nessuna operazione di Riscaldamento trovata in archivio legacy.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if(false)
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700/50 dark:bg-amber-900/10">
<div class="mb-3 text-sm font-semibold text-amber-900 dark:text-amber-200">Altre voci AC (escluse AC1/AC2) - archivio legacy</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-amber-200 dark:border-amber-700/50">
<th class="px-3 py-2 text-left">Voce</th>
<th class="px-3 py-2 text-right">Totale EUR</th>
</tr>
</thead>
<tbody>
@forelse($legacyExtraRows as $row)
<tr class="border-b border-amber-100 dark:border-amber-800/30">
<td class="px-3 py-2">{{ $row['cod_spe'] }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float) ($row['totale'] ?? 0), 2, ',', '.') }}</td>
</tr>
@empty
<tr>
<td colspan="2" class="px-3 py-4 text-center text-amber-700 dark:text-amber-300">Nessuna voce AC diversa da AC1 trovata.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div>
@endif
@if($riscaldamentoTab === 'letture')
<div class="space-y-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-700/50 dark:bg-emerald-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-emerald-900 dark:text-emerald-200">Campagna letture riscaldamento per unità</div>
<div class="mt-1 text-xs text-emerald-800 dark:text-emerald-300">Vista operativa ordinabile e modificabile riga per riga: puoi salvare una riga o tutte insieme, inviare il link pubblico e aprire il ticket riscaldamento della singola unità.</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="setRiscaldamentoCampagnaSort('scala_interno')" class="inline-flex items-center rounded-md border px-3 py-1.5 text-xs font-medium {{ $this->riscaldamentoCampagnaSort === 'scala_interno' ? 'border-emerald-300 bg-emerald-100 text-emerald-900' : 'border-emerald-200 bg-white text-emerald-700' }}">Ordina per scala/interno</button>
<button type="button" wire:click="setRiscaldamentoCampagnaSort('nominativo')" class="inline-flex items-center rounded-md border px-3 py-1.5 text-xs font-medium {{ $this->riscaldamentoCampagnaSort === 'nominativo' ? 'border-emerald-300 bg-emerald-100 text-emerald-900' : 'border-emerald-200 bg-white text-emerald-700' }}">Ordina per nominativo</button>
<button type="button" wire:click="saveAllRiscaldamentoCampagnaRows" class="inline-flex items-center rounded-md border border-emerald-200 bg-white px-3 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-100">Salva tutte le righe</button>
<x-filament::button size="sm" color="success" wire:click="prepareRiscaldamentoReadingCampaign">
Prepara / aggiorna campagna letture
</x-filament::button>
</div>
</div>
<div class="grid gap-3 md:grid-cols-4">
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Unità</div>
<div class="mt-2 text-xl font-semibold">{{ $campagnaStats['totale'] }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Fatte</div>
<div class="mt-2 text-xl font-semibold text-emerald-700">{{ $campagnaStats['completate'] }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Pendenti</div>
<div class="mt-2 text-xl font-semibold text-amber-700">{{ $campagnaStats['pendenti'] }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Sollecitate</div>
<div class="mt-2 text-xl font-semibold text-sky-700">{{ $campagnaStats['sollecitate'] }}</div>
</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr class="border-b border-emerald-200 dark:border-emerald-700/50">
<th class="px-2 py-2 text-left">Unità</th>
<th class="px-2 py-2 text-left">Nominativo</th>
<th class="px-2 py-2 text-left">Email</th>
<th class="px-2 py-2 text-right">Lettura precedente</th>
<th class="px-2 py-2 text-left">Stato</th>
<th class="px-2 py-2 text-left">Richiesta</th>
<th class="px-2 py-2 text-left">Inserimento rapido</th>
<th class="px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($campagnaRows as $row)
<tr class="border-b border-emerald-100 dark:border-emerald-800/30">
<td class="px-2 py-2">{{ $row['unit_label'] }}</td>
<td class="px-2 py-2">{{ $row['nominativo'] }}</td>
<td class="px-2 py-2 text-[11px] text-gray-600">{{ $row['email'] ?: '—' }}</td>
<td class="px-2 py-2 text-right">{{ $row['previous_reading'] !== null ? number_format((float) $row['previous_reading'], 3, ',', '.') : '—' }}</td>
<td class="px-2 py-2">
@if($row['status'] === 'fatta')
<span class="rounded bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-700">Fatta</span>
@elseif($row['status'] === 'sollecito')
<span class="rounded bg-sky-100 px-1.5 py-0.5 font-semibold text-sky-700">Sollecito</span>
@elseif($row['status'] === 'richiesta')
<span class="rounded bg-amber-100 px-1.5 py-0.5 font-semibold text-amber-700">Richiesta inviata</span>
@else
<span class="rounded bg-slate-100 px-1.5 py-0.5 font-semibold text-slate-700">Da inviare</span>
@endif
</td>
<td class="px-2 py-2">
{{ $row['requested_at'] ?: '—' }}
@if($row['deadline_at'])
<div class="text-[10px] text-gray-500">Scadenza {{ $row['deadline_at'] }}</div>
@endif
</td>
<td class="px-2 py-2">
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1">
<input type="date" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.data_lettura" class="w-32 rounded-md border-emerald-200 text-[11px]" />
<input type="date" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.deadline_at" class="w-32 rounded-md border-emerald-200 text-[11px]" />
<select wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.canale" class="w-28 rounded-md border-emerald-200 text-[11px]">
<option value="manuale">Manuale</option>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="link_pubblico">Link pubblico</option>
</select>
<input type="text" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.riferimento" placeholder="Rif." class="w-32 rounded-md border-emerald-200 text-[11px]" />
<input type="number" step="0.001" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.previous_reading" placeholder="Prec." class="w-24 rounded-md border-emerald-200 text-[11px]" />
<input type="number" step="0.001" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.lettura_finale" placeholder="Finale" class="w-24 rounded-md border-emerald-200 text-[11px]" />
<input type="text" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.reader_name" placeholder="Rilevatore" class="w-28 rounded-md border-emerald-200 text-[11px]" />
<input type="text" wire:model.defer="riscaldamentoCampagnaEditRows.{{ $row['unit_id'] }}.note" placeholder="Nota" class="w-40 rounded-md border-emerald-200 text-[11px]" />
</div>
</td>
<td class="px-2 py-2">
<div class="flex flex-wrap gap-2">
<a href="{{ $row['public_url'] }}" target="_blank" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-800 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Apri link</a>
<a href="{{ $row['ticket_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-800 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Ticket riscaldamento</a>
<button type="button" wire:click="saveRiscaldamentoCampagnaRow({{ (int) $row['unit_id'] }})" class="inline-flex items-center rounded-md bg-emerald-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-emerald-500">Salva riga</button>
@if($row['request_id'] && $row['status'] !== 'fatta')
<button type="button" wire:click="markRiscaldamentoReadingReminder({{ (int) $row['request_id'] }})" class="inline-flex items-center rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-200">Sollecito</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-2 py-4 text-center text-emerald-700 dark:text-emerald-300">Nessuna unità disponibile per la campagna letture.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- Importazione Letture Contatori Elettronici (CSV) -->
<div class="rounded-lg border border-emerald-200 bg-white p-4 dark:border-emerald-700/50 dark:bg-gray-900 shadow-sm">
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-900 dark:text-emerald-200 mb-3">Importazione Letture Contatori Elettronici (CSV)</div>
<form wire:submit.prevent="importElectronicReadings" class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-[10px] font-medium text-gray-500 uppercase dark:text-gray-400">File CSV Letture</label>
<input type="file" wire:model="riscaldamentoReadingsFile" class="mt-1 block text-xs dark:text-gray-100">
@error('riscaldamentoReadingsFile') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<button type="submit" class="rounded-md bg-emerald-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-emerald-500 transition focus:outline-none focus:ring-2 focus:ring-emerald-500">Importa letture CSV</button>
</form>
<div class="mt-2 text-[10px] text-gray-500 dark:text-gray-400">Tracciato supportato: <code>#ID,Modulo n°,Contatore n°,Nome,Cognome,Indirizzo,Interno,Lettura...</code> (la colonna #ID verrà abbinata con <code>riscaldamento_gateway_device_id</code> o in alternativa con l'<code>interno</code>).</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700/50 dark:bg-slate-900/20">
<div class="mb-2 text-sm font-semibold text-slate-900 dark:text-slate-100">Base nominativi riparto RISCALDAMENTO (legacy) per inserimento letture manuali</div>
<div class="mb-2 text-xs text-slate-600 dark:text-slate-300">
Fonte letture manuali PDF: <code>/home/michele/netgescon/netgescon-laravel/Miki-Bug-workspace/RISCALDAMENTO-Acea/06 - 2024 VIA_A_DORIA_36.0.RIPARTO.2.pdf</code>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700/50">
<th class="px-2 py-2 text-left">Scala</th>
<th class="px-2 py-2 text-left">Interno</th>
<th class="px-2 py-2 text-left">Nominativo</th>
<th class="px-2 py-2 text-left">Ruolo</th>
<th class="px-2 py-2 text-right">Lettura iniziale</th>
<th class="px-2 py-2 text-right">Consumo mc</th>
<th class="px-2 py-2 text-right">Quota EUR (riparto)</th>
</tr>
</thead>
<tbody>
@forelse($riscaldamentoRipartoNominativi as $row)
<tr class="border-b border-slate-100 dark:border-slate-800/30">
<td class="px-2 py-2">{{ $row['scala'] ?: '—' }}</td>
<td class="px-2 py-2">{{ $row['interno'] ?: '—' }}</td>
<td class="px-2 py-2">{{ $row['nominativo'] ?: '—' }}</td>
<td class="px-2 py-2">{{ $row['ruolo'] ?: '—' }}</td>
<td class="px-2 py-2 text-right">{{ isset($row['lettura_iniziale']) && $row['lettura_iniziale'] !== null ? number_format((float) $row['lettura_iniziale'], 3, ',', '.') : '—' }}</td>
<td class="px-2 py-2 text-right">{{ isset($row['consumo_mc']) && $row['consumo_mc'] !== null ? number_format((float) $row['consumo_mc'], 3, ',', '.') : '—' }}</td>
<td class="px-2 py-2 text-right">{{ number_format((float) ($row['quota_euro'] ?? 0), 2, ',', '.') }}</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-2 py-4 text-center text-slate-500">Nominativi riparto legacy non disponibili.</td>
</tr>
@endforelse
</tbody>
@if(count($riscaldamentoRipartoNominativi) > 0)
<tfoot>
<tr class="bg-slate-100 dark:bg-slate-900/40 font-semibold">
<td class="px-2 py-2" colspan="5">Totali</td>
<td class="px-2 py-2 text-right">{{ number_format((float) collect($riscaldamentoRipartoNominativi)->sum(fn($r) => (float) ($r['consumo_mc'] ?? 0)), 3, ',', '.') }}</td>
<td class="px-2 py-2 text-right">{{ number_format((float) collect($riscaldamentoRipartoNominativi)->sum(fn($r) => (float) ($r['quota_euro'] ?? 0)), 2, ',', '.') }}</td>
</tr>
</tfoot>
@endif
</table>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Letture contatori UI (con data ricezione e riferimento)</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">CRUD rapido inline per le letture UI correnti; l'archivio completo resta disponibile per foto, riassegnazioni e storico esteso.</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="startCreateRiscaldamentoUiReading" class="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-100">Nuova lettura UI</button>
<a href="{{ \App\Filament\Pages\Condomini\LettureServiziArchivio::getUrl(['scope' => 'active']) }}"
class="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200">
Apri archivio letture UI / CRUD (anno {{ $annoGestioneAttivo }})
</a>
</div>
</div>
<div class="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-700/50 dark:bg-emerald-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{{ $this->riscaldamentoUiEditingId ? 'Modifica lettura UI' : 'Inserimento rapido lettura UI' }}</div>
@if($this->riscaldamentoUiEditingId)
<button type="button" wire:click="cancelRiscaldamentoUiEditing" class="inline-flex items-center rounded-md border border-emerald-300 bg-white px-2 py-1 text-[11px] font-medium text-emerald-800 hover:bg-emerald-100">Annulla modifica</button>
@endif
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Servizio riscaldamento
<select wire:model.defer="riscaldamentoUiReadingForm.stabile_servizio_id" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
<option value="">Seleziona servizio</option>
@foreach($uiServiceOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Unità immobiliare
<select wire:model.defer="riscaldamentoUiReadingForm.unita_immobiliare_id" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
<option value="">Seleziona unità</option>
@foreach($uiUnitOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Periodo dal
<input type="date" wire:model.defer="riscaldamentoUiReadingForm.periodo_dal" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Periodo al
<input type="date" wire:model.defer="riscaldamentoUiReadingForm.periodo_al" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Canale
<select wire:model.defer="riscaldamentoUiReadingForm.canale_acquisizione" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
<option value="manuale">Manuale</option>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="link_pubblico">Link pubblico</option>
<option value="import_file">Import file</option>
</select>
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Riferimento
<input type="text" wire:model.defer="riscaldamentoUiReadingForm.riferimento_acquisizione" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" placeholder="Es. mail, ticket, protocollo" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Lettura precedente
<input type="number" step="0.001" min="0" wire:model.defer="riscaldamentoUiReadingForm.lettura_precedente_valore" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Lettura finale
<input type="number" step="0.001" min="0" wire:model.defer="riscaldamentoUiReadingForm.lettura_fine" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Lettura inizio
<input type="number" step="0.001" min="0" wire:model.defer="riscaldamentoUiReadingForm.lettura_inizio" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Consumo
<input type="number" step="0.001" wire:model.defer="riscaldamentoUiReadingForm.consumo_valore" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Rilevatore
<input type="text" wire:model.defer="riscaldamentoUiReadingForm.rilevatore_nome" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
<label class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
Deadline
<input type="date" wire:model.defer="riscaldamentoUiReadingForm.deadline_lettura_at" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" />
</label>
</div>
<label class="mt-3 block text-xs font-medium text-emerald-900 dark:text-emerald-100">
Nota operativa
<textarea rows="2" wire:model.defer="riscaldamentoUiReadingForm.note" class="mt-1 block w-full rounded-md border-emerald-300 bg-white text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500" placeholder="Nota inline editor"></textarea>
</label>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" wire:click="saveRiscaldamentoUiReading" class="inline-flex items-center rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500">{{ $this->riscaldamentoUiEditingId ? 'Salva modifica' : 'Crea lettura UI' }}</button>
@if($this->riscaldamentoUiEditingId)
<button type="button" wire:click="cancelRiscaldamentoUiEditing" class="inline-flex items-center rounded-md border border-emerald-300 bg-white px-3 py-1.5 text-xs font-medium text-emerald-800 hover:bg-emerald-100">Chiudi</button>
@endif
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-800">
<th class="px-2 py-2 text-left">Data ricezione</th>
<th class="px-2 py-2 text-left">Servizio</th>
<th class="px-2 py-2 text-left">Unità</th>
<th class="px-2 py-2 text-left">Canale</th>
<th class="px-2 py-2 text-left">Riferimento / immagine</th>
<th class="px-2 py-2 text-left">Periodo</th>
<th class="px-2 py-2 text-right">Lettura/consumo</th>
<th class="px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($lettureCondomini as $row)
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-2">{{ $row['data_ricezione'] ?: '—' }}</td>
<td class="px-2 py-2">{{ $row['servizio'] ?: '—' }}<br><span class="text-[10px] text-gray-500">Matr. {{ $row['matricola'] ?: '—' }}</span></td>
<td class="px-2 py-2">{{ trim((string) ($row['unita'] ?? '')) !== '' ? $row['unita'] : '—' }}</td>
<td class="px-2 py-2">{{ strtoupper((string) ($row['canale'] ?? '—')) }}</td>
<td class="px-2 py-2 break-all">{{ $row['riferimento'] ?: '—' }}</td>
<td class="px-2 py-2">{{ $row['periodo'] ?: '—' }}</td>
<td class="px-2 py-2 text-right">
@if(isset($row['consumo_valore']) && $row['consumo_valore'] !== null)
{{ number_format((float) $row['consumo_valore'], 3, ',', '.') }} {{ $row['consumo_unita'] ?: 'mc' }}
@else
@endif
</td>
<td class="px-2 py-2">
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="startEditRiscaldamentoUiReading({{ (int) $row['id'] }})" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-800 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Modifica</button>
<button type="button" wire:click="deleteRiscaldamentoUiReading({{ (int) $row['id'] }})" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-rose-700 ring-1 ring-inset ring-rose-300 hover:bg-rose-100">Elimina</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-2 py-4 text-center text-gray-500">Nessuna lettura disponibile.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endif
@if($riscaldamentoTab === 'generale')
<div class="space-y-4">
<div class="rounded-lg border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-700/50 dark:bg-cyan-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-cyan-900 dark:text-cyan-200">Letture del contatore generale</div>
<div class="mt-1 text-xs text-cyan-800 dark:text-cyan-300">Storico operativo del solo contatore generale: letture manuali, stime, finestre Acea e agganci alle FE riscaldamento.</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ \App\Filament\Pages\Supporto\TicketRiscaldamentoGenerale::getUrl(panel: 'admin-filament') }}"
class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 text-xs font-medium text-cyan-800 hover:bg-cyan-100">
Ticket contatore generale
</a>
<a href="{{ \App\Filament\Pages\Condomini\LettureServiziArchivio::getUrl(['scope' => 'active']) }}"
class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 text-xs font-medium text-cyan-800 hover:bg-cyan-100">
Archivio letture / storico
</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr class="border-b border-cyan-200 dark:border-cyan-700/50">
<th class="px-2 py-2 text-left">Data ricezione</th>
<th class="px-2 py-2 text-left">Servizio / codici</th>
<th class="px-2 py-2 text-left">Canale / protocollo</th>
<th class="px-2 py-2 text-left">Periodo / finestra</th>
<th class="px-2 py-2 text-right">Lettura</th>
<th class="px-2 py-2 text-right">Consumo</th>
<th class="px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($lettureGenerali as $row)
<tr class="border-b border-cyan-100 dark:border-cyan-800/30">
<td class="px-2 py-2">{{ $row['data_ricezione'] ?: '—' }}</td>
<td class="px-2 py-2">
<div>{{ $row['servizio'] ?: 'Riscaldamento' }}</div>
<div class="text-[10px] text-gray-500">Matr. {{ $row['matricola'] ?: '—' }} · Utenza {{ $row['codice_utenza'] ?: '—' }}</div>
<div class="text-[10px] text-gray-500">Cliente {{ $row['codice_cliente'] ?: '—' }} · Contratto {{ $row['codice_contratto'] ?: '—' }}</div>
</td>
<td class="px-2 py-2">
<div>{{ strtoupper((string) ($row['canale'] ?? '—')) }}</div>
<div class="text-[10px] text-gray-500">{{ $row['protocollo'] ?: '—' }} · {{ $row['workflow'] ?: '—' }}</div>
<div class="text-[10px] text-gray-500">{{ $row['reader'] ?: '—' }}</div>
</td>
<td class="px-2 py-2">
<div>{{ $row['periodo'] ?: '—' }}</div>
@if(!empty($row['window_label']))
<div class="text-[10px] text-cyan-700 dark:text-cyan-300">Finestra {{ $row['window_label'] }}</div>
@endif
@if(!empty($row['visit_date']))
<div class="text-[10px] text-cyan-700 dark:text-cyan-300">Passaggio Acea {{ \Carbon\Carbon::parse($row['visit_date'])->format('d/m/Y') }}</div>
@endif
@if(!empty($row['fattura_label']))
<div class="text-[10px] text-gray-500">Origine FE {{ $row['fattura_label'] }}</div>
@endif
</td>
<td class="px-2 py-2 text-right">
{{ isset($row['lettura_fine']) && $row['lettura_fine'] !== null ? number_format((float) $row['lettura_fine'], 3, ',', '.') . ' mc' : '—' }}
</td>
<td class="px-2 py-2 text-right">
{{ isset($row['consumo_valore']) && $row['consumo_valore'] !== null ? number_format((float) $row['consumo_valore'], 3, ',', '.') . ' ' . ($row['consumo_unita'] ?: 'mc') : '—' }}
</td>
<td class="px-2 py-2">
<div class="flex flex-wrap gap-2">
<a href="{{ $row['ticket_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">Ticket</a>
<a href="{{ $row['archivio_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">Archivio</a>
@if(!empty($row['fattura_url']))
<a href="{{ $row['fattura_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-cyan-800 ring-1 ring-inset ring-cyan-300 hover:bg-cyan-100">FE</a>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-2 py-4 text-center text-cyan-700 dark:text-cyan-300">Nessuna lettura generale disponibile per l'anno attivo.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endif
@if($riscaldamentoTab === 'tariffe')
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900 shadow-sm">
<div class="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100 font-bold">Rendiconto Spese & Bilancio Riscaldamento</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs text-left">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-800">
<th class="px-3 py-3 font-semibold text-gray-700 dark:text-gray-300">Codice</th>
<th class="px-3 py-3 font-semibold text-gray-700 dark:text-gray-300">Descrizione Voce</th>
<th class="px-3 py-3 font-semibold text-gray-700 dark:text-gray-300">Tabella Millesimale</th>
<th class="px-3 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Preventivo (Default)</th>
<th class="px-3 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Consuntivo (Registrato)</th>
<th class="px-3 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Scostamento (Delta)</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@php($groupedRows = collect($tariffeRows)->groupBy('tabella'))
@forelse($groupedRows as $tabellaName => $rows)
<tr class="bg-gray-100/50 dark:bg-gray-800/50">
<td colspan="6" class="px-3 py-2 font-bold text-sm text-cyan-800 dark:text-cyan-400 border-b border-cyan-200/50">
Tabella Millesimale: {{ $tabellaName }}
</td>
</tr>
@foreach($rows as $row)
<tr class="hover:bg-gray-50/50 dark:hover:bg-gray-800/10">
<td class="px-3 py-3 font-medium text-gray-900 dark:text-gray-100">{{ $row['codice'] }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ $row['descrizione'] }}</td>
<td class="px-3 py-3 text-gray-500">{{ $row['tabella'] }}</td>
<td class="px-3 py-3 text-right text-gray-900 dark:text-gray-100"> {{ number_format($row['preventivo'], 2, ',', '.') }}</td>
<td class="px-3 py-3 text-right font-semibold text-gray-900 dark:text-gray-100"> {{ number_format($row['consuntivo'], 2, ',', '.') }}</td>
<td class="px-3 py-3 text-right font-semibold {{ $row['scostamento'] > 0 ? 'text-rose-600 dark:text-rose-400' : ($row['scostamento'] < 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-500') }}">
{{ $row['scostamento'] > 0 ? '+' : '' }} {{ number_format($row['scostamento'], 2, ',', '.') }}
</td>
</tr>
@endforeach
@empty
<tr>
<td colspan="6" class="px-3 py-4 text-center text-gray-500">Nessuna voce di spesa riscaldamento configurata per lo stabile.</td>
</tr>
@endforelse
</tbody>
@if(count($tariffeRows) > 0)
<tfoot>
<tr class="bg-gray-50 dark:bg-gray-950/30 font-bold border-t border-gray-200">
<td colspan="3" class="px-3 py-3">Totale</td>
<td class="px-3 py-3 text-right"> {{ number_format(collect($tariffeRows)->sum('preventivo'), 2, ',', '.') }}</td>
<td class="px-3 py-3 text-right"> {{ number_format(collect($tariffeRows)->sum('consuntivo'), 2, ',', '.') }}</td>
@php($totScostamento = collect($tariffeRows)->sum('scostamento'))
<td class="px-3 py-3 text-right {{ $totScostamento > 0 ? 'text-rose-600 dark:text-rose-400' : ($totScostamento < 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-900 dark:text-gray-100') }}">
{{ $totScostamento > 0 ? '+' : '' }} {{ number_format($totScostamento, 2, ',', '.') }}
</td>
</tr>
</tfoot>
@endif
</table>
</div>
</div>
@endif
@if($riscaldamentoTab === 'servizi')
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Servizi / Utenze (anagrafica completa)</div>
{{ $this->table }}
</div>
@endif
</div>
<!-- Modal per invio email ripartizione -->
@if($showSendEmailModal)
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-500 bg-opacity-75 p-4" style="background-color: rgba(0,0,0,0.5);">
<div class="w-full max-w-md transform overflow-hidden rounded-lg bg-white p-6 shadow-xl dark:bg-gray-900 transition-all">
<div class="flex items-center justify-between border-b pb-3 dark:border-gray-800">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Invia riepilogo contatori via email</h3>
<button type="button" wire:click="$set('showSendEmailModal', false)" class="text-gray-400 hover:text-gray-500">&times;</button>
</div>
<div class="mt-4 space-y-4 text-xs">
<div>
<label class="block font-medium text-gray-700 dark:text-gray-300">Email Destinatario *</label>
<input type="email" wire:model="emailDestinatario" placeholder="es. info@studio.it" class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-800 shadow-sm text-xs text-gray-900 dark:text-gray-100">
</div>
<div>
<label class="block font-medium text-gray-700 dark:text-gray-300">Oggetto</label>
<input type="text" wire:model="emailOggetto" class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-800 shadow-sm text-xs text-gray-900 dark:text-gray-100">
</div>
<div>
<label class="block font-medium text-gray-700 dark:text-gray-300">Corpo del messaggio</label>
<textarea wire:model="emailCorpo" rows="4" class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-800 shadow-sm text-xs text-gray-900 dark:text-gray-100"></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button type="button" wire:click="$set('showSendEmailModal', false)" class="rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50">Annulla</button>
<button type="button" wire:click="sendRipartizioneEmail" class="rounded-md bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-blue-500">Invia</button>
</div>
</div>
</div>
@endif
</x-filament-panels::page>

View File

@ -125,6 +125,23 @@ class="pb-3 px-1 border-b-2 font-medium text-sm {{ $acquaTab === 'servizi' ? 'bo
@if($acquaTab === 'fatture')
<div class="space-y-4">
<!-- Filtro Periodo Esercizio Acqua Personalizzato -->
<div class="rounded-lg border border-cyan-200 bg-white p-4 dark:border-cyan-700/50 dark:bg-gray-900 shadow-sm">
<div class="text-xs font-semibold uppercase tracking-wide text-cyan-900 dark:text-cyan-200 mb-3">Periodo Esercizio Acqua (Personalizzato per Stabile)</div>
<div class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-[10px] font-medium text-gray-500 uppercase dark:text-gray-400">Data inizio periodo</label>
<input type="date" wire:model.live="acquaDataInizioFiltro" class="mt-1 block rounded-md border-gray-300 shadow-sm text-xs dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100 focus:border-cyan-500 focus:ring-cyan-500">
</div>
<div>
<label class="block text-[10px] font-medium text-gray-500 uppercase dark:text-gray-400">Data fine periodo</label>
<input type="date" wire:model.live="acquaDataFineFiltro" class="mt-1 block rounded-md border-gray-300 shadow-sm text-xs dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100 focus:border-cyan-500 focus:ring-cyan-500">
</div>
<x-filament::button type="button" size="sm" color="info" wire:click="saveAcquaFilterDates">Salva e allinea fatture</x-filament::button>
</div>
<div class="mt-2 text-[10px] text-gray-500 dark:text-gray-400">Impostando queste date, le FE dello stabile verranno filtrate per questo intervallo temporale anziché per l'anno solare, e verranno collegate alla gestione ordinaria corretta.</div>
</div>
<div class="rounded-lg border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-700/50 dark:bg-cyan-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
@ -179,6 +196,7 @@ class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3
<button type="button" wire:click="clearAcquaFeSelection" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">Azzera selezione</button>
<a href="{{ $this->acquaRipartizionePrintUrl }}" target="_blank" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">Stampa riepilogo</a>
<a href="{{ $this->acquaRipartizionePdfUrl }}" target="_blank" class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3 py-1.5 font-medium text-cyan-800 hover:bg-cyan-100">PDF riepilogo</a>
<button type="button" wire:click="$set('showSendEmailModal', true)" class="inline-flex items-center rounded-md border border-cyan-300 bg-cyan-600 px-3 py-1.5 font-medium text-white hover:bg-cyan-500 shadow-sm focus:outline-none focus:ring-2 focus:ring-cyan-500">Invia via email</button>
</div>
</div>
@ -541,6 +559,20 @@ class="rounded border-cyan-300 text-cyan-600 focus:ring-cyan-500">
</div>
</div>
<!-- Importazione Letture Contatori Elettronici (CSV) -->
<div class="rounded-lg border border-emerald-200 bg-white p-4 dark:border-emerald-700/50 dark:bg-gray-900 shadow-sm">
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-900 dark:text-emerald-200 mb-3">Importazione Letture Contatori Elettronici (CSV)</div>
<form wire:submit.prevent="importElectronicReadings" class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-[10px] font-medium text-gray-500 uppercase dark:text-gray-400">File CSV Letture</label>
<input type="file" wire:model="electronicReadingsFile" class="mt-1 block text-xs dark:text-gray-100">
@error('electronicReadingsFile') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<button type="submit" class="rounded-md bg-emerald-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-emerald-500 transition focus:outline-none focus:ring-2 focus:ring-emerald-500">Importa letture CSV</button>
</form>
<div class="mt-2 text-[10px] text-gray-500 dark:text-gray-400">Tracciato supportato: <code>#ID,Modulo n°,Contatore n°,Nome,Cognome,Indirizzo,Interno,Lettura...</code> (la colonna #ID verrà abbinata con <code>acqua_gateway_device_id</code> o in alternativa con l'<code>interno</code>).</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700/50 dark:bg-slate-900/20">
<div class="mb-2 text-sm font-semibold text-slate-900 dark:text-slate-100">Base nominativi riparto ACQUA (legacy) per inserimento letture manuali</div>
<div class="mb-2 text-xs text-slate-600 dark:text-slate-300">
@ -882,4 +914,34 @@ class="inline-flex items-center rounded-md border border-cyan-300 bg-white px-3
</div>
@endif
</div>
<!-- Modal per invio email ripartizione -->
@if($showSendEmailModal)
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-500 bg-opacity-75 p-4" style="background-color: rgba(0,0,0,0.5);">
<div class="w-full max-w-md transform overflow-hidden rounded-lg bg-white p-6 shadow-xl dark:bg-gray-900 transition-all">
<div class="flex items-center justify-between border-b pb-3 dark:border-gray-800">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Invia riepilogo contatori via email</h3>
<button type="button" wire:click="$set('showSendEmailModal', false)" class="text-gray-400 hover:text-gray-500">&times;</button>
</div>
<div class="mt-4 space-y-4 text-xs">
<div>
<label class="block font-medium text-gray-700 dark:text-gray-300">Email Destinatario *</label>
<input type="email" wire:model="emailDestinatario" placeholder="es. info@studio.it" class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-800 shadow-sm text-xs text-gray-900 dark:text-gray-100">
</div>
<div>
<label class="block font-medium text-gray-700 dark:text-gray-300">Oggetto</label>
<input type="text" wire:model="emailOggetto" class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-800 shadow-sm text-xs text-gray-900 dark:text-gray-100">
</div>
<div>
<label class="block font-medium text-gray-700 dark:text-gray-300">Corpo del messaggio</label>
<textarea wire:model="emailCorpo" rows="4" class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-800 shadow-sm text-xs text-gray-900 dark:text-gray-100"></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button type="button" wire:click="$set('showSendEmailModal', false)" class="rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50">Annulla</button>
<button type="button" wire:click="sendRipartizioneEmail" class="rounded-md bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-blue-500">Invia</button>
</div>
</div>
</div>
@endif
</x-filament-panels::page>

View File

@ -1,207 +0,0 @@
<x-filament-panels::page>
<div class="mx-auto max-w-7xl space-y-4">
<x-filament::section class="p-4!">
@include('filament.components.page-breadcrumbs', [
'breadcrumbs' => [
['label' => 'Condomini', 'url' => route('admin.dashboards.condomini')],
['label' => 'Tabelle millesimali', 'url' => \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament')],
['label' => 'Prospetto'],
],
'backUrl' => $this->getBackUrl(),
'backLabel' => 'Torna indietro',
])
<div class="mt-3 flex items-start justify-between gap-4 flex-wrap">
<div class="space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-gray-900">
<x-filament::icon icon="heroicon-o-chart-pie" class="h-5 w-5 text-primary-600" />
<span>Tabelle millesimali · Prospetto</span>
</div>
<div class="text-sm text-gray-700">Seleziona una tabella a sinistra per vedere i dettagli (unità, millesimi, percentuali).</div>
</div>
@if(!empty($tabelle) && count($tabelle) > 1)
<div class="min-w-70">
<div class="text-xs text-gray-500 mb-1">Tabella</div>
<select
class="fi-input block w-full"
onchange="window.location = '{{ static::getUrl() }}' + '?tabella_id=' + this.value"
>
@foreach($tabelle as $t)
<option value="{{ $t['id'] }}" @selected((int) $tabellaId === (int) $t['id'])>
{{ $t['codice'] }} {{ $t['nome'] }}
</option>
@endforeach
</select>
</div>
@endif
</div>
</x-filament::section>
<div class="grid gap-4 lg:grid-cols-3">
<x-filament::section class="lg:col-span-1">
<x-slot name="heading">Elenco tabelle</x-slot>
<x-slot name="description">Stabile attivo</x-slot>
@if(empty($tabelle))
<div class="rounded-xl border border-dashed border-gray-200 bg-white p-6 text-center text-gray-500">Nessuna tabella millesimale.</div>
@else
<div class="space-y-2">
@foreach($tabelle as $t)
@php $selected = (int) $tabellaId === (int) $t['id']; @endphp
<a
href="{{ static::getUrl() }}?tabella_id={{ (int) $t['id'] }}"
class="block rounded-xl border p-3 transition
{{ $selected ? 'border-primary-300 bg-primary-50' : 'border-gray-200 bg-white hover:bg-gray-50' }}"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold {{ $selected ? 'text-primary-700' : 'text-gray-900' }}">
{{ $t['codice'] }}
</div>
<div class="text-xs text-gray-600">{{ $t['nome'] }}</div>
@if(!empty($t['tipo']) || !empty($t['calcolo']))
<div class="mt-1 text-xs text-gray-500">
{{ $t['tipo'] ? ('Tipo: ' . $t['tipo']) : '' }}
{{ $t['tipo'] && $t['calcolo'] ? ' · ' : '' }}
{{ $t['calcolo'] ? ('Calcolo: ' . $t['calcolo']) : '' }}
</div>
@endif
</div>
<div class="text-right">
<div class="text-xs text-gray-500">{{ !empty($t['is_consumo']) ? 'A consumo' : 'Bilanciata' }}</div>
@if(!empty($t['is_consumo']))
<div class="text-sm font-semibold text-emerald-700">SI</div>
@else
<div class="text-sm font-semibold {{ $t['is_bilanciata'] ? 'text-emerald-700' : 'text-amber-700' }}">
{{ $t['is_bilanciata'] ? 'SI' : 'NO' }}
</div>
@endif
</div>
</div>
</a>
@endforeach
</div>
@endif
</x-filament::section>
<x-filament::section class="lg:col-span-2">
<x-slot name="heading">Dettaglio</x-slot>
<x-slot name="description">Unità e millesimi</x-slot>
@if(!$tabellaInfo)
<div class="text-sm text-gray-500">Seleziona una tabella per vedere il dettaglio.</div>
@else
<div class="space-y-3">
<div class="flex items-center justify-end gap-2">
@if(empty($righe))
<button
type="button"
wire:click="inizializzaRighe"
class="fi-btn fi-btn-color-gray fi-btn-size-sm"
>
Inizializza righe
</button>
@endif
<button
type="button"
wire:click="saveRighe"
class="fi-btn fi-btn-color-primary fi-btn-size-sm"
>
Salva
</button>
</div>
<div class="rounded-xl border bg-gray-50 p-3">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<div class="text-sm font-semibold text-gray-900">{{ $tabellaInfo['codice'] }} {{ $tabellaInfo['nome'] }}</div>
<div class="text-xs text-gray-600">
@if(!empty($tabellaInfo['tipo'])) Tipo: {{ $tabellaInfo['tipo'] }} @endif
@if(!empty($tabellaInfo['tipo']) && !empty($tabellaInfo['calcolo'])) · @endif
@if(!empty($tabellaInfo['calcolo'])) Calcolo: {{ $tabellaInfo['calcolo'] }} @endif
</div>
@if(!empty($tabellaInfo['preventivo']) || !empty($tabellaInfo['consuntivo']))
<div class="text-xs text-gray-500">
@if(!empty($tabellaInfo['preventivo'])) Prev: {{ number_format((float) $tabellaInfo['preventivo'], 2, ',', '.') }} @endif
@if(!empty($tabellaInfo['preventivo']) && !empty($tabellaInfo['consuntivo'])) · @endif
@if(!empty($tabellaInfo['consuntivo'])) Cons: {{ number_format((float) $tabellaInfo['consuntivo'], 2, ',', '.') }} @endif
</div>
@endif
</div>
<div class="text-right">
<div class="text-xs text-gray-500">Totale (calcolato)</div>
<div class="text-sm font-semibold text-gray-900">{{ number_format((float) ($tabellaInfo['totale'] ?? 0), 3, ',', '.') }}</div>
@if(!empty($tabellaInfo['is_consumo']))
<div class="text-xs text-emerald-700">A consumo</div>
@else
<div class="text-xs {{ ($tabellaInfo['is_bilanciata'] ?? false) ? 'text-emerald-700' : 'text-amber-700' }}">
{{ ($tabellaInfo['is_bilanciata'] ?? false) ? 'Bilanciata (≈1000)' : 'Non bilanciata' }}
</div>
@endif
</div>
</div>
</div>
@if(empty($righe))
<div class="rounded-xl border border-dashed border-gray-200 bg-white p-6 text-center text-gray-500">Nessun dettaglio millesimi.</div>
@else
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b text-left text-gray-500">
<th class="py-2">Unità</th>
<th class="py-2">Posizione</th>
<th class="py-2 text-center">Partecipa</th>
<th class="py-2 text-right">Millesimi</th>
<th class="py-2 text-right">%</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach($righe as $r)
@php
$unitaUrl = \App\Filament\Pages\UnitaImmobiliarePage::getUrl(panel: 'admin-filament')
. '?unita_id=' . (int) $r['unita_id']
. '&back=' . urlencode(request()->fullUrl());
@endphp
<tr>
<td class="py-2">
<a href="{{ $unitaUrl }}" class="text-gray-900 font-semibold hover:text-primary-700">
{{ $r['codice_unita'] ?? ('Unità #' . $r['unita_id']) }}
</a>
@if(!empty($r['denominazione']))
<div class="text-xs text-gray-500">{{ $r['denominazione'] }}</div>
@endif
</td>
<td class="py-2 text-gray-700">
Pal. {{ $r['palazzina'] ?? '—' }} · Scala {{ $r['scala'] ?? '—' }} · Piano {{ $r['piano'] ?? '—' }} · Int. {{ $r['interno'] ?? '—' }}
</td>
<td class="py-2 text-center">
<input
type="checkbox"
class="fi-checkbox-input"
wire:model.defer="righe.{{ $loop->index }}.partecipa"
/>
</td>
<td class="py-2 text-right">
<input
type="number"
step="0.001"
inputmode="decimal"
class="fi-input block w-28 text-right"
wire:model.defer="righe.{{ $loop->index }}.millesimi"
/>
</td>
<td class="py-2 text-right text-gray-900">{{ number_format((float) $r['percentuale'], 2, ',', '.') }}%</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
</x-filament::section>
</div>
</div>
</x-filament-panels::page>

View File

@ -21,7 +21,7 @@
<div class="text-sm text-gray-500">Dati reali importati per lo stabile corrente.</div>
</div>
<div class="flex flex-wrap gap-2">
<a class="fi-btn fi-btn-color-gray fi-btn-size-sm" href="{{ \App\Filament\Pages\Condomini\TabelleMillesimaliProspetto::getUrl(panel: 'admin-filament') }}">
<a class="fi-btn fi-btn-color-gray fi-btn-size-sm" href="{{ \App\Filament\Pages\Condomini\TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') }}?tab=prospetto">
<span class="fi-btn-label">Apri prospetto millesimi</span>
</a>
</div>

View File

@ -91,6 +91,7 @@ class="block rounded-xl border p-3 transition
@endif
</div>
<div class="text-xs text-gray-600">{{ $t['nome'] }}</div>
<div class="mt-1 text-[11px] font-medium text-primary-600">{{ $t['voci_count'] ?? 0 }} voci collegate</div>
@if(!empty($t['tipo']) || !empty($t['calcolo']))
<div class="mt-1 text-xs text-gray-500">
{{ $t['tipo'] ? ('Tipo: ' . $t['tipo']) : '' }}
@ -183,6 +184,8 @@ class="fi-btn fi-btn-color-primary fi-btn-size-sm"
<tr class="border-b text-left text-gray-500">
<th class="py-2">Unità</th>
<th class="py-2">Posizione</th>
<th class="py-2">Ruolo</th>
<th class="py-2 text-right">NORD</th>
<th class="py-2 text-center">Partecipa</th>
<th class="py-2 text-right">Millesimi</th>
<th class="py-2 text-right">%</th>
@ -207,6 +210,20 @@ class="fi-btn fi-btn-color-primary fi-btn-size-sm"
<td class="py-2 text-gray-700">
Pal. {{ $r['palazzina'] ?? '—' }} · Scala {{ $r['scala'] ?? '—' }} · Piano {{ $r['piano'] ?? '—' }} · Int. {{ $r['interno'] ?? '—' }}
</td>
<td class="py-2">
<select wire:model.defer="righe.{{ $loop->index }}.ruolo_legacy" class="rounded-md border-gray-300 py-1 text-xs block w-24">
<option value="">Nessuno</option>
<option value="C">Condomino (C)</option>
<option value="I">Inquilino (I)</option>
</select>
</td>
<td class="py-2 text-right">
<input
type="number"
class="rounded-md border-gray-300 py-1 text-xs block w-16 text-right ml-auto"
wire:model.defer="righe.{{ $loop->index }}.nord"
/>
</td>
<td class="py-2 text-center">
<input
type="checkbox"
@ -219,7 +236,7 @@ class="fi-checkbox-input"
type="number"
step="0.001"
inputmode="decimal"
class="fi-input block w-28 text-right"
class="fi-input block w-28 text-right ml-auto"
wire:model.defer="righe.{{ $loop->index }}.millesimi"
/>
</td>
@ -267,6 +284,7 @@ class="block rounded-xl border p-3 transition
@endif
</div>
<div class="text-xs text-gray-600">{{ $t['nome'] }}</div>
<div class="mt-1 text-[11px] font-medium text-primary-600">{{ $t['voci_count'] ?? 0 }} voci collegate</div>
@if(!empty($t['tipo']) || !empty($t['calcolo']))
<div class="mt-1 text-xs text-gray-500">
{{ $t['tipo'] ? ('Tipo: ' . $t['tipo']) : '' }}

View File

@ -61,6 +61,60 @@
{{ $this->table }}
</x-filament::section>
<x-filament::section class="mt-4">
<x-slot name="heading">
<div class="flex items-center justify-between">
<span>Riconoscimento & Quadratura Spese Bancarie (Codici Interbancari)</span>
<div class="flex items-center gap-2">
<button type="button"
wire:click="riconciliaSpeseBancarieAutomaticamente"
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 shadow-sm focus:outline-none transition-all">
Registrazione Automatica Spese & CBILL
</button>
<button type="button"
wire:click="saveInterbankMappings"
class="inline-flex items-center justify-center rounded-md border border-cyan-300 bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500 shadow-sm focus:outline-none transition-all">
Salva Configurazione
</button>
</div>
</div>
</x-slot>
<x-slot name="description">
Mappa i codici interbancari letti dagli estratti conto alle voci del piano dei conti dello stabile per consentire la contabilizzazione automatica della prima nota e la quadratura con bollettini PagoPA/CBILL.
</x-slot>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-6">
@php
$options = $this->getPianoContiOptions();
$standardCodes = [
'198' => ['label' => 'Canone Conto / Costo Fisso (198)', 'desc' => 'Canoni di tenuta conto mensili / annuali.'],
'219' => ['label' => 'Imposta di Bollo (219)', 'desc' => 'Addebiti per imposta di bollo trimestrale o annuale.'],
'048' => ['label' => 'Spese / Commissioni (048)', 'desc' => 'Commissioni su bonifici o operazioni singole.'],
'208' => ['label' => 'Interessi Passivi (208)', 'desc' => 'Oneri e interessi passivi addebitati.'],
'012' => ['label' => 'Bollettini CBILL / PagoPA (012)', 'desc' => 'Addebiti CBILL. Saranno auto-abbinati per fornitore e importo.'],
'F24' => ['label' => 'Deleghe F24 (RA)', 'desc' => 'Versamenti F24 delle Ritenute d\'Acconto.'],
];
@endphp
@foreach($standardCodes as $code => $info)
<div class="rounded-lg border border-gray-200 dark:border-gray-800 p-3 bg-gray-50/50 dark:bg-gray-900/50 space-y-2">
<div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{{ $info['label'] }}</div>
<div class="text-[10px] text-gray-500 dark:text-gray-400 min-h-[30px] leading-relaxed">{{ $info['desc'] }}</div>
<div class="mt-2">
<select wire:model.live="interbankMappings.{{ $code }}"
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-cyan-500 focus:ring-cyan-500">
<option value="">-- Non configurata --</option>
@foreach($options as $val => $lbl)
<option value="{{ $val }}">{{ $lbl }}</option>
@endforeach
</select>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,314 @@
<x-filament-panels::page>
<div class="mx-auto max-w-7xl space-y-4">
<x-filament.components.page-breadcrumbs
:breadcrumbs="[
['label' => 'Contabilità', 'url' => null],
['label' => 'Hub Pagamenti', 'url' => null],
]"
/>
@php
$stabile = $this->getActiveStabile();
@endphp
@if(! $stabile)
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-400">Seleziona uno stabile per vedere l'Hub Pagamenti.</div>
</x-filament::section>
@else
<!-- Info stabile attivo -->
<x-filament::section>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm text-gray-500">Stabile attivo</div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ $stabile->codice_operatore ?? $stabile->codice_stabile ?? '—' }} {{ $stabile->denominazione ?? 'Stabile' }}
</div>
</div>
<div class="text-right">
<div class="text-xs text-gray-500">IBAN principale</div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $stabile->iban_principale ?? '—' }}</div>
</div>
</div>
</x-filament::section>
<!-- Navigazione Tab Premium -->
<div class="border-b border-gray-200 dark:border-gray-800">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button"
wire:click="setTab('debiti')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-all {{ $activeTab === 'debiti' ? 'border-cyan-500 text-cyan-600 dark:text-cyan-400' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' }}">
Da Pagare (Scadenze)
</button>
<button type="button"
wire:click="setTab('movimenti')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-all {{ $activeTab === 'movimenti' ? 'border-cyan-500 text-cyan-600 dark:text-cyan-400' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' }}">
Movimenti Bancari (Quadratura)
</button>
<button type="button"
wire:click="setTab('stato_debiti')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-all {{ $activeTab === 'stato_debiti' ? 'border-cyan-500 text-cyan-600 dark:text-cyan-400' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' }}">
Stato Debiti a Data
</button>
</nav>
</div>
<!-- Tab 1: Da Pagare -->
@if($activeTab === 'debiti')
<x-filament::section>
<x-slot name="heading">
<div class="flex items-center justify-between">
<span>Fatture Fornitori in Scadenza</span>
<span class="rounded-full bg-cyan-100 dark:bg-cyan-900/30 px-3 py-1 text-xs font-semibold text-cyan-800 dark:text-cyan-400">
{{ count($debiti) }} scadenze pendenti
</span>
</div>
</x-slot>
<x-slot name="description">
Copia rapidamente l'IBAN e la causale con tag `FAT-ID:{id}` per effettuare i bonifici e consentire la riconciliazione automatica degli estratti conto.
</x-slot>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
<thead class="bg-gray-50 text-left text-xs font-semibold text-gray-600 dark:bg-gray-900 dark:text-gray-300">
<tr>
<th class="px-4 py-3">Fornitore & Scadenza</th>
<th class="px-4 py-3 text-right">Netto da Pagare</th>
<th class="px-4 py-3">IBAN Beneficiario</th>
<th class="px-4 py-3">Causale Bonifico (Max 140 car.)</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($debiti as $d)
<tr class="hover:bg-cyan-50/20 dark:hover:bg-cyan-950/10 transition-colors">
<td class="px-4 py-3 align-top">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $d['fornitore_ragione_sociale'] }}</div>
<div class="mt-0.5 text-xs text-gray-500">
Doc. n. <span class="font-semibold">{{ $d['numero_documento'] }}</span> del {{ $d['data_documento'] }} (ID: #{{ $d['id'] }})
</div>
</td>
<td class="px-4 py-3 text-right font-semibold text-gray-900 dark:text-gray-100 align-top whitespace-nowrap">
{{ number_format($d['netto_da_pagare'], 2, ',', '.') }}
</td>
<td class="px-4 py-3 align-top whitespace-nowrap">
@if($d['iban'])
<div class="flex items-center gap-2">
<code class="rounded bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs font-semibold text-cyan-800 dark:text-cyan-400">
{{ $d['iban'] }}
</code>
<button type="button"
onclick="copyTextToClipboard(this, '{{ $d['iban'] }}')"
class="inline-flex items-center rounded-md bg-white dark:bg-gray-850 px-2 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-400 ring-1 ring-inset ring-cyan-300 dark:ring-cyan-800 hover:bg-cyan-50/30 transition shadow-sm">
<span>Copia</span>
</button>
</div>
@else
<span class="text-xs text-amber-600 font-semibold bg-amber-50 dark:bg-amber-950/20 px-2 py-1 rounded">IBAN non registrato</span>
@endif
</td>
<td class="px-4 py-3 align-top">
<div class="flex items-start gap-2">
<div class="flex-1 max-w-xs text-xs bg-gray-50 dark:bg-gray-900 px-2 py-1 rounded border border-gray-100 dark:border-gray-800 break-all select-all font-mono">
{{ $d['descrizione_bonifico'] }}
</div>
<button type="button"
onclick="copyTextToClipboard(this, '{{ addslashes($d['descrizione_bonifico']) }}')"
class="inline-flex items-center rounded-md bg-white dark:bg-gray-850 px-2 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-400 ring-1 ring-inset ring-cyan-300 dark:ring-cyan-800 hover:bg-cyan-50/30 transition shadow-sm">
<span>Copia</span>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500">
Nessun debito da pagare trovato per questo stabile.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
@endif
<!-- Tab 2: Movimenti Bancari -->
@if($activeTab === 'movimenti')
<x-filament::section>
<x-slot name="heading">Quadratura Movimenti Bancari</x-slot>
<x-slot name="description">
Monitora l'estratto conto importato. I movimenti quadrati sono associati alla prima nota o al pagamento delle fatture, mentre quelli da quadrare indicano i flussi ancora da registrare.
</x-slot>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
<thead class="bg-gray-50 text-left text-xs font-semibold text-gray-600 dark:bg-gray-900 dark:text-gray-300">
<tr>
<th class="px-4 py-3">Data</th>
<th class="px-4 py-3">Descrizione Movimento</th>
<th class="px-4 py-3 text-right">Importo</th>
<th class="px-4 py-3">Stato Riconciliazione</th>
<th class="px-4 py-3">Note / Collegamenti</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($movimenti as $m)
<tr class="hover:bg-cyan-50/20 dark:hover:bg-cyan-950/10 transition-colors">
<td class="px-4 py-3 align-top whitespace-nowrap">{{ $m['data'] }}</td>
<td class="px-4 py-3 align-top">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $m['descrizione'] }}</div>
</td>
<td class="px-4 py-3 text-right font-semibold align-top whitespace-nowrap {{ $m['importo'] < 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400' }}">
{{ number_format($m['importo'], 2, ',', '.') }}
</td>
<td class="px-4 py-3 align-top whitespace-nowrap">
@if($m['stato'] === 'quadrato')
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 dark:bg-emerald-900/30 px-2 py-1 text-xs font-semibold text-emerald-800 dark:text-emerald-400">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
Quadrato
</span>
@else
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 px-2 py-1 text-xs font-semibold text-amber-800 dark:text-amber-400">
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
Da quadrare
</span>
@endif
</td>
<td class="px-4 py-3 align-top text-xs text-gray-500">
@if($m['stato'] === 'quadrato')
@if($m['invoice_id'])
<span class="font-semibold text-gray-700 dark:text-gray-300">Pagata Fattura #{{ $m['invoice_number'] }}</span>
@if($m['fornitore'])
di <span class="font-medium">{{ $m['fornitore'] }}</span>
@endif
@else
<span class="italic text-gray-400">Registrato in prima nota</span>
@endif
@else
<span class="text-amber-700 dark:text-amber-500 leading-normal">{{ $m['advice'] }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500">
Nessun movimento bancario trovato.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
@endif
<!-- Tab 3: Stato Debiti a Data -->
@if($activeTab === 'stato_debiti')
<x-filament::section>
<x-slot name="heading">
<div class="flex items-center justify-between">
<span>Situazione Debiti ad una Data Specifica</span>
<div class="flex items-center gap-2">
<label for="targetDate" class="text-xs text-gray-500">Analizza alla data:</label>
<input type="date"
id="targetDate"
wire:model.live="targetDate"
class="rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-xs text-gray-900 dark:text-gray-100 shadow-sm focus:border-cyan-500 focus:ring-cyan-500" />
</div>
</div>
</x-slot>
<x-slot name="description">
Questo report mostra tutti i debiti (fatture fornitori) che erano aperti alla data indicata (es. data di fine gestione) e indica quando sono stati pagati (data di chiusura del debito), consentendo di analizzare i pagamenti avvenuti dopo il termine.
</x-slot>
<!-- Card Riepilogativa Saldo Debiti -->
<div class="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 p-4">
<div class="text-sm font-medium text-gray-500">Esposizione Debitoria Complessiva alla data</div>
<div class="mt-2 text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ number_format($totaleDebitiStorici, 2, ',', '.') }}
</div>
<div class="mt-1 text-xs text-gray-400">Somma delle fatture con data documento antecedente o uguale al {{ \Illuminate\Support\Carbon::parse($targetDate)->format('d/m/Y') }} e non ancora liquidate a quella data.</div>
</div>
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
<thead class="bg-gray-50 text-left text-xs font-semibold text-gray-600 dark:bg-gray-900 dark:text-gray-300">
<tr>
<th class="px-4 py-3">Fornitore</th>
<th class="px-4 py-3">Dettagli Fattura</th>
<th class="px-4 py-3 text-right">Importo Residuo</th>
<th class="px-4 py-3">Stato Corrente</th>
<th class="px-4 py-3">Data Chiusura Debito</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($debitiStorici as $ds)
<tr class="hover:bg-cyan-50/20 dark:hover:bg-cyan-950/10 transition-colors">
<td class="px-4 py-3 align-top font-medium text-gray-900 dark:text-gray-100">{{ $ds['fornitore'] }}</td>
<td class="px-4 py-3 align-top">
Doc. n. <span class="font-semibold">{{ $ds['numero_documento'] }}</span> del {{ $ds['data_documento'] }}
</td>
<td class="px-4 py-3 text-right font-semibold text-gray-900 dark:text-gray-100 align-top whitespace-nowrap">
{{ number_format($ds['netto_da_pagare'], 2, ',', '.') }}
</td>
<td class="px-4 py-3 align-top whitespace-nowrap">
@if($ds['stato_corrente'] === 'pagato')
<span class="rounded bg-emerald-100 dark:bg-emerald-900/30 px-2 py-0.5 text-xs font-semibold text-emerald-800 dark:text-emerald-400">
Pagato oggi
</span>
@else
<span class="rounded bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-semibold text-amber-800 dark:text-amber-400">
In attesa
</span>
@endif
</td>
<td class="px-4 py-3 align-top font-medium whitespace-nowrap">
@if($ds['data_chiusura'] === 'Ancora aperto')
<span class="text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 px-2 py-0.5 rounded text-xs">Ancora aperto</span>
@else
<span class="text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/20 px-2 py-0.5 rounded text-xs">
Chiuso il {{ $ds['data_chiusura'] }}
</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500">
Nessun debito aperto rilevato alla data del {{ \Illuminate\Support\Carbon::parse($targetDate)->format('d/m/Y') }}.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
@endif
@endif
</div>
<script>
function copyTextToClipboard(button, text) {
navigator.clipboard.writeText(text).then(() => {
const span = button.querySelector('span');
const originalText = span.textContent;
span.textContent = 'Copiato!';
button.classList.remove('text-cyan-800', 'ring-cyan-300');
button.classList.add('text-green-700', 'ring-green-400', 'bg-green-50');
setTimeout(() => {
span.textContent = originalText;
button.classList.add('text-cyan-800', 'ring-cyan-300');
button.classList.remove('text-green-700', 'ring-green-400', 'bg-green-50');
}, 1200);
}).catch(err => {
console.error('Could not copy text: ', err);
});
}
</script>
</x-filament-panels::page>

View File

@ -258,7 +258,38 @@ class="h-[80vh] w-full"
</div>
</x-filament::section>
@endif
@php($suggerimentiVoci = $this->getSuggerimentiVociSpesa())
@if(count($suggerimentiVoci) > 0)
<x-filament::section>
<x-slot name="heading">Voci spesa consigliate (ARERA)</x-slot>
<div class="space-y-2">
<p class="text-xs text-gray-500">Seleziona una voce per applicarla o aggiungerla alle righe della fattura.</p>
<div class="flex flex-col gap-2">
@foreach($suggerimentiVoci as $v)
<button
type="button"
wire:click="applicaVoceSpesaSuggerita({{ (int) $v['id'] }})"
class="flex items-center justify-between text-left text-xs p-2 rounded border bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 w-full transition"
>
<div class="truncate">
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ $v['codice'] }}</span>
<span class="text-gray-600 dark:text-gray-400"> {{ $v['descrizione'] }}</span>
</div>
@if($v['is_default'])
<span class="ml-2 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[9px] font-semibold text-emerald-800 dark:text-emerald-300">Default</span>
@else
<x-filament::icon icon="heroicon-m-plus-circle" class="w-4 h-4 text-gray-400" />
@endif
</button>
@endforeach
</div>
</div>
</x-filament::section>
@endif
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -158,13 +158,13 @@
<button
type="button"
class="netgescon-btn netgescon-btn-sm {{ ($isComplete || ($isDownloaded && ! $isVerify) || $isBusy || $isRunning || $isBlocked) ? 'netgescon-btn-outline-secondary opacity-50 cursor-not-allowed' : 'netgescon-btn-primary' }}"
{{ ($isComplete || ($isDownloaded && ! $isVerify) || $isBusy || $isRunning || $isBlocked) ? 'disabled' : '' }}
class="netgescon-btn netgescon-btn-sm {{ ($isBusy || $isRunning || $isBlocked) ? 'netgescon-btn-outline-secondary opacity-50 cursor-not-allowed' : (($isComplete || ($isDownloaded && ! $isVerify)) ? 'netgescon-btn-outline-secondary' : 'netgescon-btn-primary') }}"
{{ ($isBusy || $isRunning || $isBlocked) ? 'disabled' : '' }}
wire:loading.attr="disabled"
wire:target="downloadCassettoQuarter"
wire:click="downloadCassettoQuarter('{{ $row['quarter'] }}','{{ $row['dal'] }}','{{ $row['al'] }}',{{ $anno }})"
>
<span wire:loading.remove wire:target="downloadCassettoQuarter">{{ $isVerify ? 'Ricontrolla' : 'Scarica' }}</span>
<span wire:loading.remove wire:target="downloadCassettoQuarter">{{ $isComplete ? 'Aggiorna' : ($isVerify ? 'Ricontrolla' : 'Scarica') }}</span>
<span wire:loading wire:target="downloadCassettoQuarter">Avvio...</span>
</button>
</div>
@ -265,13 +265,14 @@ class="netgescon-btn netgescon-btn-sm {{ ($isComplete || ($isDownloaded && ! $is
<td class="px-3 py-3">
<button
type="button"
class="netgescon-btn netgescon-btn-sm {{ ($isComplete || ($isDownloaded && ! $isVerify) || $isBusy || $isRunning || $isBlocked) ? 'netgescon-btn-outline-secondary opacity-50 cursor-not-allowed' : 'netgescon-btn-primary' }}"
{{ ($isComplete || ($isDownloaded && ! $isVerify) || $isBusy || $isRunning || $isBlocked) ? 'disabled' : '' }}
class="netgescon-btn netgescon-btn-sm {{ ($isBusy || $isRunning || $isBlocked) ? 'netgescon-btn-outline-secondary opacity-50 cursor-not-allowed' : (($isComplete || ($isDownloaded && ! $isVerify)) ? 'netgescon-btn-outline-secondary' : 'netgescon-btn-primary') }}"
{{ ($isBusy || $isRunning || $isBlocked) ? 'disabled' : '' }}
wire:loading.attr="disabled"
wire:target="downloadCassettoQuarter"
wire:click="downloadCassettoQuarter('{{ $row['quarter'] }}','{{ $row['dal'] }}','{{ $row['al'] }}',{{ $anno }})"
>
{{ $isVerify ? 'Ricontrolla' : 'Scarica' }}
<span wire:loading.remove wire:target="downloadCassettoQuarter">{{ $isComplete ? 'Aggiorna' : ($isVerify ? 'Ricontrolla' : 'Scarica') }}</span>
<span wire:loading wire:target="downloadCassettoQuarter">Avvio...</span>
</button>
</td>
</tr>

View File

@ -16,27 +16,56 @@
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-wrap items-start justify-between gap-3 border-b pb-3 border-slate-200">
<div>
<div class="text-sm font-semibold text-slate-900">Azioni operative</div>
<div class="mt-1 text-xs text-slate-600">Prima esegui i pulsanti di preparazione, poi il flusso di import, infine gli eventuali riallineamenti.</div>
<div class="mt-1 text-xs text-slate-600">Strumenti per la gestione e sincronizzazione dei dati dello stabile.</div>
</div>
<div class="text-xs text-slate-600">Stabile attuale: <span class="font-semibold text-slate-900">{{ $selectedLabel !== '' ? $selectedLabel : 'nessuno' }}</span></div>
<div class="text-xs text-slate-600">Stabile attuale: <span class="font-semibold text-slate-900 bg-slate-100 px-2 py-1 rounded">{{ $selectedLabel !== '' ? $selectedLabel : 'nessuno' }}</span></div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button type="button" size="sm" wire:click="loadStabili">Carica elenco stabili</x-filament::button>
<x-filament::button type="button" size="sm" color="gray" wire:click="caricaAnniDaGenerale" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Carica anni</x-filament::button>
<x-filament::button type="button" size="sm" color="gray" wire:click="caricaSetupDaArchivio" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Carica setup inline</x-filament::button>
<x-filament::button type="button" size="sm" color="success" wire:click="creaStabileDaMdb" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Crea / apri stabile</x-filament::button>
<x-filament::button type="button" size="sm" color="gray" wire:click="eseguiImportSelezionato" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Importa dati selezionati</x-filament::button>
<x-filament::button type="button" size="sm" color="primary" wire:click="eseguiImportAllineamentoCompleto" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Import + allineamento</x-filament::button>
<x-filament::button type="button" size="sm" color="info" wire:click="salvaSetupStabileImportazione" :disabled="blank(data_get($data ?? [], 'stabile_code'))">Salva stabile inline</x-filament::button>
<x-filament::button type="button" size="sm" color="warning" wire:click="salvaSetupGestioneImportazione" :disabled="blank(data_get($data ?? [], 'stabile_code'))">Salva gestione inline</x-filament::button>
<x-filament::button type="button" size="sm" color="info" wire:click="aggiornaFornitoriGescon" :disabled="blank(data_get($data ?? [], 'path_root'))">Aggiorna fornitori</x-filament::button>
<x-filament::button type="button" size="sm" color="warning" wire:click="risincronizzaRelazioniImportate" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Risincronizza relazioni</x-filament::button>
<x-filament::button type="button" size="sm" color="info" wire:click="aggiornaAnagraficheDaCondomin" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Sync anagrafiche</x-filament::button>
<x-filament::button type="button" size="sm" color="warning" wire:click="refreshImportTagLegacyFornitoriGlobale" :disabled="blank(data_get($data ?? [], 'path_root'))">Refresh TAG fornitori</x-filament::button>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-4">
<!-- Col 1: Preparazione -->
<div class="space-y-2 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<div class="text-xs font-bold uppercase tracking-wider text-slate-600">1. Preparazione</div>
<div class="flex flex-col gap-2">
<x-filament::button type="button" size="xs" color="gray" icon="heroicon-m-arrow-path" wire:click="loadStabili" class="justify-start">Carica elenco stabili</x-filament::button>
<x-filament::button type="button" size="xs" color="gray" icon="heroicon-m-calendar" wire:click="caricaAnniDaGenerale" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Carica anni</x-filament::button>
<x-filament::button type="button" size="xs" color="gray" icon="heroicon-m-cog-6-tooth" wire:click="caricaSetupDaArchivio" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Carica setup inline</x-filament::button>
<x-filament::button type="button" size="xs" color="success" icon="heroicon-m-folder-plus" wire:click="creaStabileDaMdb" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Crea / apri stabile</x-filament::button>
</div>
</div>
<!-- Col 2: Import Principale -->
<div class="space-y-2 rounded-xl border border-primary-100 bg-primary-50/10 p-3 flex flex-col justify-between">
<div>
<div class="text-xs font-bold uppercase tracking-wider text-primary-700">2. Flusso Consigliato</div>
<div class="text-[10px] text-slate-500 mt-1">Importa tutti i dati dello stabile ed esegue automaticamente la riconciliazione e la quadratura contabile.</div>
</div>
<x-filament::button type="button" size="md" color="primary" icon="heroicon-m-play-circle" wire:click="eseguiImportAllineamentoCompleto" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))" class="w-full mt-2">
Import + allineamento completo
</x-filament::button>
</div>
<!-- Col 3: Sincronizzazione & Utility -->
<div class="space-y-2 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<div class="text-xs font-bold uppercase tracking-wider text-slate-600">3. Utility & Sincronizzazioni</div>
<div class="flex flex-col gap-2">
<x-filament::button type="button" size="xs" color="warning" icon="heroicon-m-arrow-path-rounded-square" wire:click="risincronizzaRelazioniImportate" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Risincronizza relazioni</x-filament::button>
<x-filament::button type="button" size="xs" color="info" icon="heroicon-m-users" wire:click="aggiornaAnagraficheDaCondomin" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Sync anagrafiche</x-filament::button>
<x-filament::button type="button" size="xs" color="info" icon="heroicon-m-user-group" wire:click="aggiornaFornitoriGescon" :disabled="blank(data_get($data ?? [], 'path_root'))" class="justify-start">Aggiorna fornitori</x-filament::button>
<x-filament::button type="button" size="xs" color="warning" icon="heroicon-m-tag" wire:click="refreshImportTagLegacyFornitoriGlobale" :disabled="blank(data_get($data ?? [], 'path_root'))" class="justify-start">Refresh TAG fornitori</x-filament::button>
</div>
</div>
<!-- Col 4: Salvataggio inline -->
<div class="space-y-2 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<div class="text-xs font-bold uppercase tracking-wider text-slate-600">4. Salvataggio Setup</div>
<div class="flex flex-col gap-2">
<x-filament::button type="button" size="xs" color="info" icon="heroicon-m-document-check" wire:click="salvaSetupStabileImportazione" :disabled="blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Salva stabile inline</x-filament::button>
<x-filament::button type="button" size="xs" color="warning" icon="heroicon-m-calendar-days" wire:click="salvaSetupGestioneImportazione" :disabled="blank(data_get($data ?? [], 'stabile_code'))" class="justify-start">Salva gestione inline</x-filament::button>
</div>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">

View File

@ -173,6 +173,15 @@
<label class="flex items-center gap-2 text-xs text-gray-600">
<input type="checkbox" wire:model="filterRdaOnly" /> Solo RDA
</label>
<x-filament::button
size="sm"
color="warning"
wire:click="riallineaScrittureLegacy"
wire:loading.attr="disabled"
>
<span wire:loading.remove>Riallinea Legacy</span>
<span wire:loading>Riallineamento...</span>
</x-filament::button>
</div>
@if(!($this->legacyOperazioniScopedByStabile ?? false))
@ -330,7 +339,12 @@
<tbody>
@forelse($operazioni ?? [] as $row)
<tr class="border-t">
<td class="px-3 py-2">{{ $row->id_operaz ?? '—' }}</td>
<td class="px-3 py-2">
{{ $row->id_operaz ?? '—' }}
@if(!empty($row->n_spe))
<div class="text-[9px] text-gray-500 font-semibold" title="Protocollo Legacy">Prot. {{ $row->n_spe }}</div>
@endif
</td>
<td class="px-3 py-2">
@if(!empty($row->dt_spe))
{{ \Carbon\Carbon::parse($row->dt_spe)->format('d/m/Y') }}
@ -1506,6 +1520,14 @@ class="space-y-2"
</div>
@endif
</div>
<div class="mb-3">
<input
type="search"
class="w-full rounded-md border-gray-300 text-xs"
placeholder="Cerca per nominativo o unità..."
wire:model.live="dettPersSearch"
/>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-white">
@ -1517,7 +1539,17 @@ class="space-y-2"
</tr>
</thead>
<tbody>
@forelse($this->dettPersRows ?? [] as $r)
@php $renderedCount = 0; @endphp
@forelse($this->dettPersRows ?? [] as $idx => $r)
@php
$search = trim(strtolower($this->dettPersSearch ?? ''));
$soggetto = strtolower($r['soggetto'] ?? '');
$unita = strtolower($r['unita'] ?? '');
if ($search !== '' && strpos($soggetto, $search) === false && strpos($unita, $search) === false) {
continue;
}
$renderedCount++;
@endphp
<tr class="border-t">
<td class="px-3 py-2">{{ $r['soggetto'] ?? '—' }}</td>
<td class="px-3 py-2">{{ $r['unita'] ?? '—' }}</td>
@ -1528,8 +1560,8 @@ class="space-y-2"
type="number"
step="0.01"
inputmode="decimal"
class="fi-input block w-28 text-right text-xs"
wire:model.live="dettPersRows.{{ $loop->index }}.importo"
class="fi-input block w-28 text-right text-xs ml-auto"
wire:model.live="dettPersRows.{{ $idx }}.importo"
/>
@else
{{ number_format((float)($r['importo'] ?? 0), 2, ',', '.') }}
@ -1539,6 +1571,9 @@ class="fi-input block w-28 text-right text-xs"
@empty
<tr><td colspan="4" class="px-3 py-2 text-gray-500">Nessun dettaglio.</td></tr>
@endforelse
@if($renderedCount === 0 && !empty($this->dettPersRows))
<tr><td colspan="4" class="px-3 py-2 text-gray-500 text-center">Nessun risultato corrisponde alla ricerca.</td></tr>
@endif
</tbody>
@if(!empty($this->dettPersRows))
<tfoot class="bg-gray-50">

View File

@ -97,6 +97,9 @@
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-sm font-semibold text-slate-900">Ultima lettura nota</div>
@if($unitaContatoreSeriale)
<div class="mt-1 text-xs font-semibold text-cyan-600 dark:text-cyan-400">Matricola Contatore UI: {{ $unitaContatoreSeriale }}</div>
@endif
@if($previousReading)
<div class="mt-3 space-y-1 text-sm text-slate-700">
<div><span class="font-medium">Valore:</span> {{ number_format((float) $previousReading['value'], 3, ',', '.') }}</div>

View File

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Riepilogo ripartizione riscaldamento</title>
<style>
body { font-family: DejaVu Sans, Arial, sans-serif; color: #0f172a; margin: 24px; font-size: 12px; }
h1, h2 { margin: 0; }
.header { border: 1px solid #cbd5e1; background: linear-gradient(135deg, #ecfeff, #f8fafc 50%, #ecfdf5); padding: 20px; border-radius: 16px; }
.muted { color: #475569; }
.stats { width: 100%; margin-top: 16px; border-collapse: separate; border-spacing: 10px 0; }
.stats td { width: 25%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 12px; background: #fff; vertical-align: top; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; }
.value { font-size: 20px; font-weight: 700; margin-top: 8px; }
table.grid { width: 100%; border-collapse: collapse; margin-top: 16px; }
table.grid th, table.grid td { border: 1px solid #cbd5e1; padding: 8px; vertical-align: top; }
table.grid th { background: #e0f2fe; text-align: left; }
.small { font-size: 10px; color: #475569; }
.page-break { page-break-before: always; }
@media print { body { margin: 12px; } }
</style>
@if(!empty($autoprint))
<script>window.addEventListener('load', () => window.print());</script>
@endif
</head>
<body>
<div class="header">
<div class="label">Studio / Ripartizione riscaldamento</div>
<h1 style="margin-top:8px;">Riepilogo fatture da ripartire</h1>
<div class="muted" style="margin-top:8px;">
{{ $stabile->denominazione ?: ('Stabile #' . $stabile->id) }}
@if(!empty($stabile->codice_stabile)) · {{ $stabile->codice_stabile }} @endif
· anno gestione {{ $year }}
</div>
<div class="small" style="margin-top:6px;">Generato il {{ $generatedAt->format('d/m/Y H:i') }}</div>
<table class="stats">
<tr>
<td><div class="label">FE incluse</div><div class="value">{{ $summary['fatture'] }}</div></td>
<td><div class="label">Totale mc</div><div class="value">{{ number_format((float) $summary['totale_mc'], 3, ',', '.') }}</div></td>
<td><div class="label">Totale spesa</div><div class="value"> {{ number_format((float) $summary['totale_spesa'], 2, ',', '.') }}</div></td>
<td><div class="label">Letture UI</div><div class="value">{{ $summary['letture_ui'] }}</div></td>
</tr>
</table>
</div>
<h2 style="margin-top:24px;">Fatture selezionate per ripartizione</h2>
<table class="grid">
<thead>
<tr>
<th>Fattura</th>
<th>Codici estratti</th>
<th>Periodo FE</th>
<th>Pagamento</th>
<th style="text-align:right;">Mc</th>
<th style="text-align:right;">Spesa</th>
</tr>
</thead>
<tbody>
@forelse($fattureRows as $row)
<tr>
<td>
<div><strong>{{ $row['numero_fattura'] ?: ('FE #' . $row['id']) }}</strong></div>
<div class="small">{{ $row['data_fattura'] ?: 'n/d' }}</div>
</td>
<td>
<div>Utenza {{ $row['utenza'] ?: '—' }}</div>
<div>Cliente {{ $row['cliente'] ?: '—' }}</div>
<div>Contratto {{ $row['contratto'] ?: '—' }}</div>
<div>Matricola {{ $row['matricola'] ?: '—' }}</div>
</td>
<td>{{ $row['periodo'] ?: '—' }}</td>
<td>
<div>CBILL {{ $row['cbill'] ?: '—' }}</div>
<div>Avviso {{ $row['codice_avviso'] ?: '—' }}</div>
</td>
<td style="text-align:right;">{{ number_format((float) $row['totale_mc'], 3, ',', '.') }}</td>
<td style="text-align:right;"> {{ number_format((float) $row['totale_spesa'], 2, ',', '.') }}</td>
</tr>
@empty
<tr>
<td colspan="6">Nessuna FE riscaldamento disponibile per il riepilogo.</td>
</tr>
@endforelse
</tbody>
</table>
<div class="page-break"></div>
<h2>Letture contatori UI</h2>
<div class="small" style="margin-top:6px;">Seconda pagina: storico operativo corrente usato come base letture dalla tab UI.</div>
<table class="grid">
<thead>
<tr>
<th>Data ricezione</th>
<th>Servizio</th>
<th>Unità</th>
<th>Canale</th>
<th>Riferimento</th>
<th>Periodo</th>
<th style="text-align:right;">Lettura</th>
<th style="text-align:right;">Consumo</th>
</tr>
</thead>
<tbody>
@forelse($lettureRows as $row)
<tr>
<td>{{ $row['data_ricezione'] ?: '—' }}</td>
<td>
<div>{{ $row['servizio'] ?: '—' }}</div>
<div class="small">Matr. {{ $row['matricola'] ?: '—' }}</div>
</td>
<td>{{ $row['unita'] ?: '—' }}</td>
<td>{{ $row['canale'] ?: '—' }}</td>
<td>{{ $row['riferimento'] ?: '—' }}</td>
<td>{{ $row['periodo'] ?: '—' }}</td>
<td style="text-align:right;">{{ $row['lettura_fine'] !== null ? number_format((float) $row['lettura_fine'], 3, ',', '.') . ' mc' : '—' }}</td>
<td style="text-align:right;">{{ $row['consumo_valore'] !== null ? number_format((float) $row['consumo_valore'], 3, ',', '.') . ' ' . ($row['consumo_unita'] ?: 'mc') : '—' }}</td>
</tr>
@empty
<tr>
<td colspan="8">Nessuna lettura UI disponibile per l'anno selezionato.</td>
</tr>
@endforelse
</tbody>
</table>
</body>
</html>

View File

@ -1,3 +1,160 @@
<div>
@if($isCreatingConto || $editingContoId)
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900 animate-fade-in">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-slate-200 pb-4 mb-6 dark:border-gray-800">
<div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
{{ $isCreatingConto ? 'Nuovo Conto Bancario / Cassa' : 'Modifica Conto Bancario / Cassa' }}
</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
Inserisci tutti i dettagli del conto. I campi contrassegnati con * sono obbligatori.
</p>
</div>
<x-filament::button type="button" size="sm" color="gray" icon="heroicon-m-arrow-left" wire:click="cancelInlineForm">
Torna alla lista
</x-filament::button>
</div>
<form wire:submit.prevent="saveContoInline" class="space-y-6">
<!-- Sezione 1: Dati Generali -->
<div class="space-y-4">
<h4 class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 border-l-2 border-sky-500 pl-2">
1. Dati generali del conto
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Denominazione banca/cassa *</label>
<input type="text" wire:model.defer="contoFormState.denominazione_banca" required class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Tipo conto *</label>
<select wire:model.defer="contoFormState.tipo_conto" required class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100">
<option value="corrente">Corrente</option>
<option value="deposito">Deposito</option>
<option value="risparmio">Risparmio</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Stato *</label>
<select wire:model.defer="contoFormState.stato_conto" required class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100">
<option value="attivo">Attivo</option>
<option value="sospeso">Sospeso</option>
<option value="chiuso">Chiuso</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Intestazione conto</label>
<input type="text" wire:model.defer="contoFormState.intestazione_conto" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div class="flex items-center pt-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model.defer="contoFormState.is_nostro_conto" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700" />
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Nostro conto (conto principale di gestione)</span>
</label>
</div>
</div>
</div>
<!-- Sezione 2: Coordinate Bancarie -->
<div class="space-y-4 pt-4 border-t border-slate-100 dark:border-gray-800">
<h4 class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 border-l-2 border-sky-500 pl-2">
2. Coordinate bancarie (IBAN e codici)
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">IBAN</label>
<input type="text" wire:model.defer="contoFormState.iban" placeholder="IT00..." class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Numero conto</label>
<input type="text" wire:model.defer="contoFormState.numero_conto" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">ABI</label>
<input type="text" wire:model.defer="contoFormState.abi" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">CAB</label>
<input type="text" wire:model.defer="contoFormState.cab" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">CIN</label>
<input type="text" wire:model.defer="contoFormState.cin" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">BIC / SWIFT</label>
<input type="text" wire:model.defer="contoFormState.bic_swift" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
</div>
</div>
<!-- Sezione 3: Codici Operativi e Saldo Iniziale -->
<div class="space-y-4 pt-4 border-t border-slate-100 dark:border-gray-800">
<h4 class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 border-l-2 border-sky-500 pl-2">
3. Codici operativi e saldi di partenza
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Codice CUC</label>
<input type="text" wire:model.defer="contoFormState.cuc" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Codice SIA</label>
<input type="text" wire:model.defer="contoFormState.sia" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Valuta</label>
<input type="text" wire:model.defer="contoFormState.valuta" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Saldo iniziale</label>
<input type="number" step="0.01" wire:model.defer="contoFormState.saldo_iniziale" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Data saldo iniziale</label>
<input type="date" wire:model.defer="contoFormState.data_saldo_iniziale" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100" />
</div>
</div>
</div>
<!-- Sezione 4: Referenze e Note -->
<div class="space-y-4 pt-4 border-t border-slate-100 dark:border-gray-800">
<h4 class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 border-l-2 border-sky-500 pl-2">
4. Referenze e note operative
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="md:col-span-1">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Contatto banca (rubrica)</label>
<select wire:model.defer="contoFormState.contatto_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100">
<option value=""> Nessun contatto </option>
@foreach($contattiBancaOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Note interne</label>
<textarea wire:model.defer="contoFormState.note" rows="2" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm text-sm focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100"></textarea>
</div>
</div>
</div>
<!-- Footer Azioni -->
<div class="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-4 dark:border-gray-800">
<x-filament::button type="button" size="md" color="gray" wire:click="cancelInlineForm">
Annulla
</x-filament::button>
<x-filament::button type="submit" size="md" color="primary" icon="heroicon-m-check">
Salva dati conto
</x-filament::button>
</div>
</form>
</div>
@else
{{ $this->table }}
@endif
</div>

View File

@ -183,6 +183,8 @@
// Fatture elettroniche ricevute (azioni)
Route::get('acqua-ripartizione/print', [AcquaRipartizionePrintController::class, 'print'])->name('acqua-ripartizione.print');
Route::get('acqua-ripartizione/pdf', [AcquaRipartizionePrintController::class, 'pdf'])->name('acqua-ripartizione.pdf');
Route::get('riscaldamento-ripartizione/print', [\App\Http\Controllers\Admin\RiscaldamentoRipartizionePrintController::class, 'print'])->name('riscaldamento-ripartizione.print');
Route::get('riscaldamento-ripartizione/pdf', [\App\Http\Controllers\Admin\RiscaldamentoRipartizionePrintController::class, 'pdf'])->name('riscaldamento-ripartizione.pdf');
Route::get('fatture-elettroniche/{fattura}/download-xml', [\App\Http\Controllers\Admin\FattureElettronicheRicevuteController::class, 'downloadXml'])
->name('fatture-elettroniche.download-xml');

View File

@ -0,0 +1,731 @@
<?php
use App\Models\Fornitore;
use App\Models\Stabile;
use App\Models\User;
use App\Models\Amministratore;
use App\Models\GestioneContabile;
use App\Models\DatiBancari;
use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\RegolaPrimaNota;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\Registrazione;
use App\Filament\Pages\Contabilita\DebitiPagareHub;
use App\Filament\Pages\Contabilita\CasseBancheRiepilogo;
use App\Filament\Pages\Condomini\RiscaldamentoStabileArchivio;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
test('it loads payments hub with correct details and description', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'ordinaria',
'denominazione' => 'Gestione Ordinaria 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'aperta',
'protocollo_prefix' => 'O2026',
]);
$fornitore = Fornitore::create([
'amministratore_id' => $amministratore->id,
'ragione_sociale' => 'ENEL ENERGIA SPA',
'codice_fiscale' => '00325478911',
'partita_iva' => '00325478911',
'iban' => 'IT99X00000000000000000001234',
]);
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 888,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_documento' => '2026-INV-99',
'totale' => 750.50,
'netto_da_pagare' => 750.50,
'stato' => 'da_pagare',
'data_documento' => '2026-06-15',
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new DebitiPagareHub();
$page->mount();
expect($page->debiti)->toHaveCount(1);
$debito = $page->debiti[0];
expect($debito['id'])->toBe(888);
expect($debito['numero_documento'])->toBe('2026-INV-99');
expect($debito['totale'])->toBe(750.50);
expect($debito['iban'])->toBe('IT99X00000000000000000001234');
expect($debito['descrizione_bonifico'])->toContain('FAT-ID:888');
expect($debito['descrizione_bonifico'])->toContain('ENEL ENERGIA SPA');
});
test('it reconciles automatically via tracking tag FAT-ID', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'riscaldamento',
'denominazione' => 'Gestione Riscaldamento 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'aperta',
'protocollo_prefix' => 'R2026',
]);
$conto = DatiBancari::create([
'stabile_id' => $stabile->id,
'tipo_conto' => 'corrente',
'denominazione_banca' => 'Unicredit',
'numero_conto' => '123456',
'iban' => 'IT02X0000000000000000000',
'saldo_iniziale' => 10000.00,
'stato_conto' => 'attivo',
'is_nostro_conto' => true,
]);
$fornitore = Fornitore::create([
'amministratore_id' => $amministratore->id,
'ragione_sociale' => 'A2A ENERGIA',
'codice_fiscale' => '00325478955',
'partita_iva' => '00325478955',
'old_id' => '9991',
]);
// Create service to allow finding this supplier
\App\Models\StabileServizio::create([
'stabile_id' => $stabile->id,
'fornitore_id' => $fornitore->id,
'tipo' => 'riscaldamento',
'nome' => 'Riscaldamento',
'attivo' => true,
]);
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 777,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_documento' => 'FATT-A2A-1',
'totale' => 1500.00,
'netto_da_pagare' => 1500.00,
'stato' => 'da_pagare',
'data_documento' => '2026-06-01',
]);
DB::table('contabilita_movimenti_banca')->insert([
'id' => 303,
'stabile_id' => $stabile->id,
'conto_id' => $conto->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-06-15',
'valuta' => '2026-06-15',
'descrizione' => 'Bonifico a A2A ENERGIA ref FAT-ID:777',
'descrizione_estesa' => 'Bonifico a A2A ENERGIA ref FAT-ID:777',
'importo' => -1500.00,
'fornitore_id' => $fornitore->id,
'row_hash' => md5('test_movement_303'),
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new RiscaldamentoStabileArchivio();
$page->riconciliaAutomaticamentePagamenti();
$updatedInvoice = DB::table('contabilita_fatture_fornitori')->where('id', 777)->first();
expect($updatedInvoice->stato)->toBe('pagato');
expect($updatedInvoice->movimento_pagamento_id)->toBe(303);
});
test('it configures interbank mapping and auto registers bank costs and CBILL bollettini', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'ordinaria',
'denominazione' => 'Gestione Ordinaria 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'aperta',
'protocollo_prefix' => 'O2026',
]);
$contoBancario = DatiBancari::create([
'stabile_id' => $stabile->id,
'tipo_conto' => 'corrente',
'denominazione_banca' => 'Unicredit',
'numero_conto' => '123456',
'iban' => 'IT02X0000000000000000000',
'saldo_iniziale' => 10000.00,
'stato_conto' => 'attivo',
'is_nostro_conto' => true,
]);
// 1. Setup global chart accounts
// Ensure PianoConti accounts 7 (Banca) and 27 (Oneri) exist
DB::table('contabilita_piano_conti')->insertOrIgnore([
'id' => 7,
'codice' => '1020',
'denominazione' => 'Banca c/c',
'tipo_conto' => 'ATTIVO',
'attivo' => 1,
'livello' => 2,
'codice_padre' => '100',
'saldo_iniziale' => 0.00,
]);
DB::table('contabilita_piano_conti')->insertOrIgnore([
'id' => 27,
'codice' => '5150',
'denominazione' => 'Oneri bancari e postali',
'tipo_conto' => 'COSTO',
'attivo' => 1,
'livello' => 2,
'codice_padre' => '500',
'saldo_iniziale' => 0.00,
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new CasseBancheRiepilogo();
// Propose mappings
$page->interbankMappings = [
'198' => 27, // Map code 198 to account 27
'219' => 27, // Map code 219 to account 27
'012' => 27,
];
$page->saveInterbankMappings();
// Verify rules were persisted
$rule198 = RegolaPrimaNota::where('stabile_id', $stabile->id)
->where('origine', 'banca')
->where('chiave', '198')
->first();
expect($rule198)->not->toBeNull();
expect((int)$rule198->conto_avere_id)->toBe(27);
// Mock direct bank movements
DB::table('contabilita_movimenti_banca')->insert([
'id' => 401,
'stabile_id' => $stabile->id,
'conto_id' => $contoBancario->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-07-01',
'valuta' => '2026-07-01',
'descrizione' => 'IMPRENDO CONDOMINIO LIGHT COSTO FISSO MESE DI GIUGNO 2026',
'descrizione_estesa' => 'IMPRENDO CONDOMINIO LIGHT COSTO FISSO MESE DI GIUGNO 2026',
'importo' => -22.00,
'causale' => '198',
'row_hash' => md5('test_movement_401'),
]);
// Mock a CBILL utility payment movement (causale 012)
$fornitoreCbill = Fornitore::create([
'amministratore_id' => $amministratore->id,
'ragione_sociale' => 'ACEA ATO 2 SPA',
'codice_fiscale' => '00325478977',
'partita_iva' => '00325478977',
]);
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 999,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitoreCbill->id,
'numero_documento' => 'ACEA-BOL-2',
'totale' => 1310.76,
'netto_da_pagare' => 1310.76,
'stato' => 'da_pagare',
'data_documento' => '2026-05-01',
]);
DB::table('contabilita_movimenti_banca')->insert([
'id' => 402,
'stabile_id' => $stabile->id,
'conto_id' => $contoBancario->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-05-11',
'valuta' => '2026-05-11',
'descrizione' => 'DISPOSIZIONE DI ADDEBITO GENERICA BOLLETTINO ACEA ATO 2 S.P.A. (PROFILO PAGOPA)',
'descrizione_estesa' => 'DISPOSIZIONE DI ADDEBITO GENERICA BOLLETTINO ACEA ATO 2 S.P.A. (PROFILO PAGOPA)',
'importo' => -1310.76,
'causale' => '012',
'row_hash' => md5('test_movement_402'),
]);
// Run batch reconciliation
$page->riconciliaSpeseBancarieAutomaticamente();
// Verify 401 has been registered in Prima Nota
$m401 = MovimentoBanca::find(401);
expect($m401->registrazione_id)->not->toBeNull();
// Verify 402 has matched and paid the Acea invoice
$invoiceAcea = DB::table('contabilita_fatture_fornitori')->where('id', 999)->first();
expect($invoiceAcea->stato)->toBe('pagato');
expect($invoiceAcea->movimento_pagamento_id)->toBe(402);
});
test('it renders the operations staging query even if the table does not exist', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new \App\Filament\Pages\Gescon\Ordinarie();
$page->netgesconWorkflowMode = false;
// Verify calling getOperazioniProperty() does NOT crash, even though operations table is not in sqlite test db
$result = $page->getOperazioniProperty();
expect($result)->not->toBeNull();
});
test('it loads movements and debiti storici tab contents correctly', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'ordinaria',
'denominazione' => 'Gestione Ordinaria 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'aperta',
'protocollo_prefix' => 'O2026',
]);
$fornitore = Fornitore::create([
'amministratore_id' => $amministratore->id,
'ragione_sociale' => 'A2A ENERGIA',
'codice_fiscale' => '00325478955',
'partita_iva' => '00325478955',
]);
// 1. Invoice 1: Document date 2026-06-01, paid late on 2027-01-15
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 991,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_documento' => 'A2A-LATE-1',
'totale' => 500.00,
'netto_da_pagare' => 500.00,
'stato' => 'pagato',
'data_documento' => '2026-06-01',
'data_pagamento' => '2027-01-15',
]);
// 2. Invoice 2: Document date 2026-06-02, still unpaid
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 992,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_documento' => 'A2A-UNPAID',
'totale' => 600.00,
'netto_da_pagare' => 600.00,
'stato' => 'da_pagare',
'data_documento' => '2026-06-02',
]);
// 3. Invoice 3: Document date after management period, e.g. 2027-01-01
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 993,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_documento' => 'A2A-FUTURE',
'totale' => 300.00,
'netto_da_pagare' => 300.00,
'stato' => 'da_pagare',
'data_documento' => '2027-01-01',
]);
// 4. Mock bank movement
$conto = DatiBancari::create([
'stabile_id' => $stabile->id,
'tipo_conto' => 'corrente',
'denominazione_banca' => 'Unicredit',
'numero_conto' => '123456',
'iban' => 'IT02X0000000000000000000',
'saldo_iniziale' => 10000.00,
'stato_conto' => 'attivo',
'is_nostro_conto' => true,
]);
DB::table('contabilita_movimenti_banca')->insert([
'id' => 501,
'stabile_id' => $stabile->id,
'conto_id' => $conto->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-07-01',
'valuta' => '2026-07-01',
'descrizione' => 'Test movement unreconciled',
'importo' => -100.00,
'row_hash' => md5('test_movement_501'),
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new DebitiPagareHub();
$page->mount();
// Verify movements tab loads correctly
$page->setTab('movimenti');
expect($page->movimenti)->toHaveCount(1);
expect($page->movimenti[0]['id'])->toBe(501);
expect($page->movimenti[0]['stato'])->toBe('da_quadrare');
// Verify historical debts at target date (2026-12-31)
$page->setTab('stato_debiti');
$page->targetDate = '2026-12-31';
$page->loadDebitiStorici();
// 991 (paid late on 2027-01-15) and 992 (unpaid) are debts at 2026-12-31.
// 993 (dated 2027-01-01) is NOT a debt at 2026-12-31.
expect($page->debitiStorici)->toHaveCount(2);
$ids = collect($page->debitiStorici)->pluck('id')->all();
expect($ids)->toContain(991);
expect($ids)->toContain(992);
expect($ids)->not->toContain(993);
// Verify pay dates/closures
$invoice992 = collect($page->debitiStorici)->firstWhere('id', 992);
expect($invoice992['data_chiusura'])->toBe('Ancora aperto');
expect($page->totaleDebitiStorici)->toBe(1100.0);
});
test('it automatically reconciles F24 withholding tax and CBILL via numeric code and fallback text', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'ordinaria',
'denominazione' => 'Gestione Ordinaria 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'aperta',
'protocollo_prefix' => 'O2026',
]);
$conto = DatiBancari::create([
'stabile_id' => $stabile->id,
'tipo_conto' => 'corrente',
'denominazione_banca' => 'Unicredit',
'numero_conto' => '123456',
'iban' => 'IT02X0000000000000000000',
'saldo_iniziale' => 10000.00,
'stato_conto' => 'attivo',
'is_nostro_conto' => true,
]);
// Create a rule for F24 and Bollo/Competenze cost accounts
DB::table('contabilita_piano_conti')->insertOrIgnore([
['id' => 7, 'codice' => '1020', 'denominazione' => 'Banca c/c', 'tipo_conto' => 'ATTIVO', 'attivo' => 1, 'livello' => 2, 'codice_padre' => '100', 'saldo_iniziale' => 0.00],
['id' => 822, 'codice' => '822', 'denominazione' => 'Spese postali e bolli', 'tipo_conto' => 'COSTO', 'attivo' => 1, 'livello' => 1, 'codice_padre' => null, 'saldo_iniziale' => 0.00],
['id' => 198, 'codice' => '1980', 'denominazione' => 'Spese tenuta conto', 'tipo_conto' => 'COSTO', 'attivo' => 1, 'livello' => 1, 'codice_padre' => null, 'saldo_iniziale' => 0.00],
['id' => 900, 'codice' => '900', 'denominazione' => 'Erario c/ritenute', 'tipo_conto' => 'PASSIVO', 'attivo' => 1, 'livello' => 1, 'codice_padre' => null, 'saldo_iniziale' => 0.00],
]);
RegolaPrimaNota::create([
'stabile_id' => $stabile->id,
'origine' => 'banca',
'chiave' => '219', // Bollo
'conto_dare_id' => 7,
'conto_avere_id' => 822,
'attiva' => true,
'label' => 'Bollo',
]);
RegolaPrimaNota::create([
'stabile_id' => $stabile->id,
'origine' => 'banca',
'chiave' => '198', // Competenze
'conto_dare_id' => 7,
'conto_avere_id' => 198,
'attiva' => true,
'label' => 'Competenze',
]);
RegolaPrimaNota::create([
'stabile_id' => $stabile->id,
'origine' => 'banca',
'chiave' => 'F24', // F24
'conto_dare_id' => 7,
'conto_avere_id' => 900,
'attiva' => true,
'label' => 'F24',
]);
$fornitore = Fornitore::create([
'amministratore_id' => $amministratore->id,
'ragione_sociale' => 'ACEA ATO 2 SPA',
'codice_fiscale' => '00325478955',
'partita_iva' => '00325478955',
]);
// Mock Withholding Tax (RA) record
$ra = \App\Models\RegistroRitenuteAcconto::create([
'tenant_id' => 'default',
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_progressivo' => 1,
'data_competenza' => '2026-06-15',
'imponibile' => 1000.00,
'aliquota_ritenuta' => 20.00,
'importo_ritenuta' => 200.00,
'stato_versamento' => 'da_versare',
'data_scadenza_versamento' => '2026-07-16',
]);
// 1. Bank movement for F24
DB::table('contabilita_movimenti_banca')->insert([
'id' => 601,
'stabile_id' => $stabile->id,
'conto_id' => $conto->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-07-15',
'valuta' => '2026-07-15',
'descrizione' => 'PAGAMENTO DELEGHE F23/F24 PRENOTATE PAGAMENTO FISCO/INPS/REGIONI DA ENTRATEL',
'importo' => -200.00,
'causale' => '', // Empty causale to test text fallback!
'row_hash' => md5('test_601'),
]);
// 2. Bank movement for stamp duty with different code but text match
DB::table('contabilita_movimenti_banca')->insert([
'id' => 602,
'stabile_id' => $stabile->id,
'conto_id' => $conto->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-07-01',
'valuta' => '2026-07-01',
'descrizione' => 'IMPOSTA BOLLO CONTO CORRENTE DPR642/72-DM24/5/2012',
'importo' => -29.41,
'causale' => '822', // Mismatched code but description matches
'row_hash' => md5('test_602'),
]);
$fe = \App\Models\FatturaElettronica::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'fornitore_id' => $fornitore->id,
'numero_fattura' => '2026-CBILL-9',
'data_fattura' => '2026-06-12',
'fornitore_piva' => '00325478955',
'fornitore_denominazione' => 'ACEA ATO 2 SPA',
'imponibile' => 1266.97,
'iva' => 278.73,
'totale' => 1545.70,
'pagamento_cbill' => '125007300041415467',
'pagamento_tipo' => 'MP08',
'file_nome' => 'test.xml',
]);
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 995,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'fattura_elettronica_id' => $fe->id,
'numero_documento' => '2026-CBILL-9',
'totale' => 1545.70,
'netto_da_pagare' => 1545.70,
'stato' => 'da_pagare',
'data_documento' => '2026-06-12',
]);
DB::table('contabilita_movimenti_banca')->insert([
'id' => 603,
'stabile_id' => $stabile->id,
'conto_id' => $conto->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => '2026-07-20',
'valuta' => '2026-07-20',
'descrizione' => 'DISPOSIZIONE DI ADDEBITO GENERICA BOLLETTINO ACEA ATO2 SPA - 125007300041415467',
'importo' => -1545.70,
'causale' => '012',
'row_hash' => md5('test_603'),
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new CasseBancheRiepilogo();
$page->riconciliaSpeseBancarieAutomaticamente();
// 1. Verify RA F24 payment was reconciled
$raReloaded = \App\Models\RegistroRitenuteAcconto::find($ra->id);
expect($raReloaded->stato_versamento)->toBe('versata');
expect($raReloaded->data_versamento->toDateString())->toBe('2026-07-15');
expect($raReloaded->f24_riferimento)->toContain('ENTRATEL');
// 2. Verify 601 and 602 got a contabile registration (Prima Nota)
$m601 = MovimentoBanca::find(601);
expect($m601->registrazione_id)->not->toBeNull();
$m602 = MovimentoBanca::find(602);
expect($m602->registrazione_id)->not->toBeNull();
// 3. Verify CBILL numeric code matched and paid the invoice
$invoice = DB::table('contabilita_fatture_fornitori')->where('id', 995)->first();
expect($invoice->stato)->toBe('pagato');
expect($invoice->movimento_pagamento_id)->toBe(603);
});

View File

@ -0,0 +1,66 @@
<?php
use App\Models\Stabile;
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Filament\Pages\Condomini\ServiziStabileArchivio;
use Illuminate\Http\UploadedFile;
use Livewire\Livewire;
test('it imports electronic readings from CSV and maps correctly', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$stabile = Stabile::factory()->create();
$servizio = StabileServizio::query()->create([
'stabile_id' => $stabile->id,
'tipo' => 'acqua',
'attivo' => true,
'nome' => 'Acqua Potabile'
]);
$unit1 = UnitaImmobiliare::factory()->create([
'stabile_id' => $stabile->id,
'interno' => '6',
'acqua_gateway_device_id' => null
]);
$unit2 = UnitaImmobiliare::factory()->create([
'stabile_id' => $stabile->id,
'interno' => '7',
'acqua_gateway_device_id' => '22919618'
]);
\App\Support\StabileContext::setActiveStabileId($user, $stabile->id);
\App\Support\AnnoGestioneContext::setActiveAnno(2024);
$csvContent = "#ID,Modulo n°,Contatore n°,Nome,Cognome,Indirizzo,Interno,Lettura,Auto-lettura,Tensione,Date e tipo Frode,Flusso inverso (m3),Data allarme perdita,Gennaio,Febbraio,Marzo,Aprile,Maggio,Giugno,Luglio,Agosto,Settembre,Ottobre,Novembre,Dicembre\n" .
"22919617,,,,,,6,\"411,560 m3\",0,,,\"0,000 m3\",,,,,\n" .
"22919618,,,,,,7,\"662,184 m3\",0,,,\"0,000 m3\",,,,,";
$file = UploadedFile::fake()->createWithContent('readings.csv', $csvContent);
Livewire::test(ServiziStabileArchivio::class)
->set('electronicReadingsFile', $file)
->call('importElectronicReadings')
->assertHasNoErrors();
$unit1->refresh();
expect($unit1->acqua_gateway_device_id)->toBe('22919617');
$readings = StabileServizioLettura::query()
->where('stabile_id', $stabile->id)
->get();
expect($readings)->toHaveCount(2);
$r1 = $readings->firstWhere('unita_immobiliare_id', $unit1->id);
expect((float)$r1->lettura_fine)->toEqual(411.560);
expect($r1->tipologia_lettura)->toBe('elettronica_remota');
$r2 = $readings->firstWhere('unita_immobiliare_id', $unit2->id);
expect((float)$r2->lettura_fine)->toEqual(662.184);
});

View File

@ -0,0 +1,122 @@
<?php
use App\Models\User;
use App\Models\Stabile;
use App\Models\Amministratore;
use App\Models\GestioneContabile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Artisan;
test('it imports and updates operations incrementally using update-only option', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test Contabilita',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'ordinaria',
'denominazione' => 'Gestione Ordinaria 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'aperta',
'protocollo_prefix' => 'O2026',
]);
// Setup staging operations table
Schema::connection('gescon_import')->dropIfExists('operazioni');
Schema::connection('gescon_import')->create('operazioni', function ($table) {
$table->id('id_operaz');
$table->string('cod_stabile')->nullable();
$table->string('legacy_year')->nullable();
$table->string('compet')->nullable();
$table->string('gestione')->nullable();
$table->integer('n_stra')->nullable();
$table->integer('n_spe')->nullable();
$table->string('cod_for')->nullable();
$table->string('cod_spe')->nullable();
$table->string('natura2')->nullable();
$table->decimal('importo_euro', 15, 2)->nullable();
$table->decimal('importo_spese', 15, 2)->nullable();
$table->decimal('importo_entrate', 15, 2)->nullable();
$table->string('note')->nullable();
$table->string('natura')->nullable();
$table->date('dt_spe')->nullable();
});
// Seed initial staging operations data
DB::connection('gescon_import')->table('operazioni')->insert([
'id_operaz' => 888,
'cod_stabile' => '9999',
'legacy_year' => '2026',
'compet' => 'C',
'gestione' => 'O',
'n_stra' => 0,
'importo_euro' => 150.00,
'importo_spese' => 150.00,
'importo_entrate' => 0.00,
'note' => 'Original Operation',
'dt_spe' => '2026-07-01',
]);
// Run first import to create the domain entry
Artisan::call('gescon:import-full', [
'--stabile' => '9999',
'--solo' => 'operazioni',
]);
// Verify it was created
$imported = DB::table('operazioni_contabili')->where('legacy_id', 888)->first();
expect($imported)->not->toBeNull();
expect($imported->descrizione)->toBe('Original Operation');
// Simulate user editing/reconciling this transaction in NetGescon
DB::table('operazioni_contabili')->where('id', $imported->id)->update([
'stato_operazione' => 'reconciled_by_user',
]);
// Update staging data in MDB (e.g. amount or note changes at source)
DB::connection('gescon_import')->table('operazioni')->where('id_operaz', 888)->update([
'note' => 'Updated Operation Note',
]);
// Run pipeline again WITH --update-only to check if the user modifications and record are preserved
Artisan::call('gescon:import-full', [
'--stabile' => '9999',
'--solo' => 'operazioni',
'--update-only' => true,
]);
$reimported = DB::table('operazioni_contabili')->where('legacy_id', 888)->first();
expect($reimported)->not->toBeNull();
// Verify the record was NOT deleted and recreated (which would lose the status edited by user)
expect($reimported->id)->toBe($imported->id);
expect($reimported->stato_operazione)->toBe('reconciled_by_user');
// Verify the note was updated incrementally
expect($reimported->descrizione)->toBe('Updated Operation Note');
// Clean up staging
Schema::connection('gescon_import')->dropIfExists('operazioni');
});

View File

@ -0,0 +1,85 @@
<?php
use App\Models\DocumentoArchivioFisicoItem;
use App\Models\Stabile;
use App\Models\User;
use App\Models\Amministratore;
use App\Filament\Pages\Strumenti\DocumentiArchivio;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
uses(RefreshDatabase::class);
test('it exports physical archive to xml and imports it back successfully', function () {
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test XML',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
// Simula resolveActiveStabile tramite mock o set nel contesto sessione
session(['netgescon.stabile_attivo_id' => $stabile->id]);
// Crea elementi di test (faldone -> cartellina -> documento)
$faldone = DocumentoArchivioFisicoItem::create([
'stabile_id' => $stabile->id,
'codice_univoco' => 'AF-' . $stabile->id . '-0001',
'tipo_item' => 'faldone_anelli',
'titolo' => 'Faldone Generale 2026',
'data_archiviazione' => now()->toDateString(),
'stato' => 'disponibile',
]);
$cartellina = DocumentoArchivioFisicoItem::create([
'stabile_id' => $stabile->id,
'parent_id' => $faldone->id,
'codice_univoco' => 'AF-' . $stabile->id . '-0002',
'tipo_item' => 'cartellina',
'titolo' => 'Cartellina Riscaldamento',
'data_archiviazione' => now()->toDateString(),
'stato' => 'disponibile',
]);
$page = new DocumentiArchivio();
$xml = $page->exportPhysicalArchiveToXml();
expect($xml)->toContain('netgescon_physical_archive')
->toContain('Faldone Generale 2026')
->toContain('Cartellina Riscaldamento');
// Cancella gli elementi per testare il re-import
DocumentoArchivioFisicoItem::query()->delete();
expect(DocumentoArchivioFisicoItem::count())->toBe(0);
// Re-importa
$importedCount = $page->importPhysicalArchiveFromXml($xml);
expect($importedCount)->toBe(2);
expect(DocumentoArchivioFisicoItem::count())->toBe(2);
$importedFaldone = DocumentoArchivioFisicoItem::where('tipo_item', 'faldone_anelli')->first();
$importedCartellina = DocumentoArchivioFisicoItem::where('tipo_item', 'cartellina')->first();
expect($importedFaldone->titolo)->toBe('Faldone Generale 2026');
expect($importedCartellina->titolo)->toBe('Cartellina Riscaldamento');
expect($importedCartellina->parent_id)->toBe($importedFaldone->id);
});

View File

@ -0,0 +1,172 @@
<?php
use App\Models\Fornitore;
use App\Models\FornitoreStabileImpostazione;
use App\Models\Stabile;
use App\Models\StabileServizio;
use App\Models\User;
use App\Models\Amministratore;
use App\Models\VoceSpesa;
use App\Models\GestioneContabile;
use App\Models\DatiBancari;
use App\Filament\Pages\Condomini\RiscaldamentoStabileArchivio;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
test('it auto-assigns default voce spesa from legacy and reconciles bank payments', function () {
// 1. Setup User and Stabile
$user = User::factory()->create();
$user->assignRole('super-admin');
$this->actingAs($user);
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'denominazione' => 'Amministrazione Test',
'codice_fiscale' => '12345678901',
]);
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'codice_stabile' => '9999',
'denominazione' => 'Condominio Test XML',
'codice_fiscale' => '12345678901',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_destinatario_sdi' => '0000000',
]);
// Create a GestioneContabile record to satisfy foreign keys
$gestione = GestioneContabile::create([
'tenant_id' => 'default',
'stabile_id' => $stabile->id,
'anno_gestione' => 2026,
'tipo_gestione' => 'riscaldamento',
'denominazione' => 'Gestione Riscaldamento 2026',
'data_inizio' => '2026-01-01',
'data_fine' => '2026-12-31',
'stato' => 'attiva',
'protocollo_prefix' => 'R2026',
]);
// Create a DatiBancari record to satisfy foreign keys
$conto = DatiBancari::create([
'stabile_id' => $stabile->id,
'tipo_conto' => 'corrente',
'denominazione_banca' => 'Unicredit',
'numero_conto' => '123456',
'iban' => 'IT02X0000000000000000000',
'saldo_iniziale' => 10000.00,
'stato_conto' => 'attivo',
'is_nostro_conto' => true,
]);
// 2. Setup Fornitore and StabileServizio
$fornitore = Fornitore::create([
'amministratore_id' => $amministratore->id,
'ragione_sociale' => 'ENI PLENITUDE',
'codice_fiscale' => '00215478900',
'partita_iva' => '00215478900',
'old_id' => '1234', // legacy ID matching operations
]);
$service = StabileServizio::create([
'stabile_id' => $stabile->id,
'fornitore_id' => $fornitore->id,
'tipo' => 'riscaldamento',
'nome' => 'Riscaldamento Centrale',
'attivo' => true,
]);
// 3. Setup VoceSpesa
$voceSpesa = VoceSpesa::create([
'stabile_id' => $stabile->id,
'codice' => 'R1',
'descrizione' => 'Spese Riscaldamento Metano',
'categoria' => 'riscaldamento',
'tipo' => 'riscaldamento',
'attiva' => true,
]);
// 4. Setup staging database environment for MDB operations mapping
Schema::connection('gescon_import')->dropIfExists('operazioni');
Schema::connection('gescon_import')->create('operazioni', function ($table) {
$table->id();
$table->string('cod_stabile')->nullable();
$table->string('cod_for')->nullable();
$table->string('cod_spe')->nullable();
$table->decimal('importo_euro', 15, 2)->nullable();
});
DB::connection('gescon_import')->table('operazioni')->insert([
'cod_stabile' => '9999',
'cod_for' => '1234',
'cod_spe' => 'R1',
'importo_euro' => 1500.00,
]);
session(['netgescon.stabile_attivo_id' => $stabile->id]);
$page = new RiscaldamentoStabileArchivio();
// Call auto-link default mapping method
$page->mount();
// Verify FornitoreStabileImpostazione was created
$imp = FornitoreStabileImpostazione::where('stabile_id', $stabile->id)
->where('fornitore_id', $fornitore->id)
->first();
expect($imp)->not->toBeNull();
expect((int)$imp->voce_spesa_default_id)->toBe($voceSpesa->id);
// 5. Test bank reconciliation
DB::table('contabilita_fatture_fornitori')->insert([
'id' => 101,
'stabile_id' => $stabile->id,
'gestione_id' => $gestione->id,
'fornitore_id' => $fornitore->id,
'numero_documento' => '10045/A',
'totale' => 1250.00,
'netto_da_pagare' => 1250.00,
'stato' => 'da_pagare',
'data_documento' => now()->toDateString(),
'created_at' => now(),
'updated_at' => now(),
]);
// Create a mock bank movement entry
DB::table('contabilita_movimenti_banca')->insert([
'id' => 202,
'stabile_id' => $stabile->id,
'conto_id' => $conto->id,
'gestione_id' => $gestione->id,
'iban' => 'IT02X0000000000000000000',
'data' => now()->toDateString(),
'valuta' => now()->toDateString(),
'descrizione' => 'Pagam. Fatt. 10045/A ENI PLENITUDE',
'descrizione_estesa' => 'Pagam. Fatt. 10045/A ENI PLENITUDE',
'importo' => -1250.00, // payment is negative
'fornitore_id' => $fornitore->id,
'row_hash' => md5('test_movement_202'),
'created_at' => now(),
'updated_at' => now(),
]);
// Run reconciliation
$page->riconciliaAutomaticamentePagamenti();
$updatedInvoice = DB::table('contabilita_fatture_fornitori')->where('id', 101)->first();
expect($updatedInvoice->stato)->toBe('pagato');
expect($updatedInvoice->movimento_pagamento_id)->toBe(202);
// Cleanup staging tables
Schema::connection('gescon_import')->dropIfExists('operazioni');
});

View File

@ -188,4 +188,27 @@ public function test_calcola_statistiche()
$this->assertEquals(450.00, $statistiche['importo_max_unita']);
$this->assertEquals(330.000, $statistiche['millesimi_totali']);
}
public function test_calcola_ripartizione_consuntivo_error_if_millesimi_mismatch()
{
$user = User::factory()->create();
$this->actingAs($user);
$stabile = Stabile::factory()->create();
$tabellaMillesimale = TabellaMillesimale::factory()->create([
'stabile_id' => $stabile->id,
'totale_millesimi' => 1000,
]);
$voceSpesa = VoceSpesa::factory()->create([
'stabile_id' => $stabile->id,
'tabella_millesimale_default_id' => $tabellaMillesimale->id,
'importo_consuntivo' => 500.00,
]);
$this->expectException(\Exception::class);
$this->expectExceptionMessage("La somma dei millesimi di dettaglio");
$this->service->calcolaRipartizione($voceSpesa, 500.00);
}
}