feat: miglioramenti stabili, millesimi, ripartizioni e inserimento docs/DEVELOPER_GUIDE.md
This commit is contained in:
parent
9bc1a481c9
commit
6f7b26a38d
|
|
@ -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 ?? '')));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
4182
app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php
Executable file
4182
app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php
Executable file
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
374
app/Filament/Pages/Contabilita/DebitiPagareHub.php
Normal file
374
app/Filament/Pages/Contabilita/DebitiPagareHub.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
337
app/Filament/Pages/SuperAdmin/EstrattoreFatturePdf.php
Normal file
337
app/Filament/Pages/SuperAdmin/EstrattoreFatturePdf.php
Normal 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();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
1190
app/Filament/Pages/Supporto/TicketRiscaldamento.php
Executable file
1190
app/Filament/Pages/Supporto/TicketRiscaldamento.php
Executable file
File diff suppressed because it is too large
Load Diff
1210
app/Filament/Pages/Supporto/TicketRiscaldamentoGenerale.php
Executable file
1210
app/Filament/Pages/Supporto/TicketRiscaldamentoGenerale.php
Executable file
File diff suppressed because it is too large
Load Diff
54
app/Filament/Pages/Supporto/TicketRiscaldamentoLight.php
Executable file
54
app/Filament/Pages/Supporto/TicketRiscaldamentoLight.php
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Admin/RiscaldamentoRipartizionePrintController.php
Executable file
167
app/Http/Controllers/Admin/RiscaldamentoRipartizionePrintController.php
Executable 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
155
app/Services/Consumi/ConsumiRiscaldamentoIngestionService.php
Normal file
155
app/Services/Consumi/ConsumiRiscaldamentoIngestionService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
136
app/Services/Consumi/GasLucePdfTextParser.php
Normal file
136
app/Services/Consumi/GasLucePdfTextParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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
134
docs/DEVELOPER_GUIDE.md
Normal 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.
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">×</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>
|
||||
|
|
@ -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">×</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']) : '' }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
128
resources/views/filament/print/riscaldamento-ripartizione-riepilogo.blade.php
Executable file
128
resources/views/filament/print/riscaldamento-ripartizione-riepilogo.blade.php
Executable 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>
|
||||
|
|
@ -1,3 +1,160 @@
|
|||
<div>
|
||||
{{ $this->table }}
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
731
tests/Feature/ContabilitaReconciliationHubTest.php
Normal file
731
tests/Feature/ContabilitaReconciliationHubTest.php
Normal 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);
|
||||
});
|
||||
66
tests/Feature/ElectronicReadingsTest.php
Normal file
66
tests/Feature/ElectronicReadingsTest.php
Normal 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);
|
||||
});
|
||||
122
tests/Feature/ImportGesconFullPipelineTest.php
Normal file
122
tests/Feature/ImportGesconFullPipelineTest.php
Normal 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');
|
||||
});
|
||||
85
tests/Feature/PhysicalArchiveXmlTest.php
Normal file
85
tests/Feature/PhysicalArchiveXmlTest.php
Normal 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);
|
||||
});
|
||||
172
tests/Feature/RiscaldamentoReconciliationTest.php
Normal file
172
tests/Feature/RiscaldamentoReconciliationTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user