chore: checkpoint current workspace state

This commit is contained in:
michele 2026-04-18 08:47:02 +00:00
parent 09022d5753
commit b3e626b613
30 changed files with 4517 additions and 730 deletions

View File

@ -4,6 +4,8 @@ # Changelog
## [Unreleased]
- Consolidated the banking workflow around Contabilita > Casse e banche > Movimenti, now used as a 4-tab hub for account overview, official balances and statement PDFs, imported bank ledger movements, and first-pass reconciliation candidates.
- Moved official bank balance and statement management out of Situazione iniziale, fixed bank document URLs to use storage-backed paths, and anchored balance reconstruction on `conto_id` so legacy accounts without IBAN still reconcile correctly.
- Added stable-level Documentazione, Posta ufficiale and Protocollo comunicazioni tabs, plus a real admin-wide Comunicazioni regia page reusing Google accounts, Gmail import, Drive templates and protocol records.
- Seeded the official Gmail path for stabile `0023` `GERMANICO 79` (`viagermanico79@gmail.com`), hardened PEC studio persistence against the missing `pec_amministratore` DB column, and linked the new stable documentation hub to the existing Google settings workflow.
- Applied `gescon:auto-sync-anagrafiche` for `GERMANICO 79` with stable/unit role linking and imported the 2024 legacy water dataset from `generale_stabile.mdb` into servizi/letture without closing historic years.

View File

@ -1,5 +1,4 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
@ -38,15 +37,16 @@ public function handle(): int
return self::FAILURE;
}
$tableCodes = $legacyVoci
->pluck('tabella')
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->values();
$legacyHeaders = $this->loadLegacyTableHeaders($stableCode);
if ($legacyHeaders === []) {
$this->error('Nessuna intestazione tabella legacy trovata per stabile ' . $stableCode . '.');
return self::FAILURE;
}
$tableCodes = collect(array_keys($legacyHeaders))->values();
$legacyDetails = $this->loadLegacyDettagli($stableCode, $year, $tableCodes);
$legacyHeaders = $this->loadLegacyTableHeaders($stableCode, $tableCodes);
$this->assertLegacyVociHaveKnownTables($legacyVoci, $tableCodes);
DB::transaction(function () use ($stableCode, $stableId, $year, $gestioneId, $legacyVoci, $legacyDetails, $tableCodes, $legacyHeaders): void {
$this->ensureLegacyUnitCoverage($stableCode, $stableId, $legacyDetails);
@ -159,9 +159,9 @@ private function loadLegacyDettagli(string $stableCode, int $year, Collection $t
/**
* @return array<string, object>
*/
private function loadLegacyTableHeaders(string $stableCode, Collection $tableCodes): array
private function loadLegacyTableHeaders(string $stableCode): array
{
if ($tableCodes->isEmpty() || ! Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) {
if (! Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) {
return [];
}
@ -179,8 +179,13 @@ private function loadLegacyTableHeaders(string $stableCode, Collection $tableCod
$query->where('cod_stabile', $stableCode);
}
if (in_array('nord', $columns, true)) {
$query
->orderByRaw('CASE WHEN nord IS NULL THEN 1 ELSE 0 END')
->orderBy('nord');
}
$rows = $query
->whereIn($codeColumn, $tableCodes->all())
->orderBy($codeColumn)
->get();
@ -198,12 +203,29 @@ private function loadLegacyTableHeaders(string $stableCode, Collection $tableCod
return $headers;
}
private function assertLegacyVociHaveKnownTables(Collection $legacyVoci, Collection $tableCodes): void
{
$knownTables = array_fill_keys($tableCodes->all(), true);
$unknown = $legacyVoci
->pluck('tabella')
->filter(fn($value): bool => is_string($value) && trim($value) !== '')
->map(fn(string $value): string => trim($value))
->reject(fn(string $value): bool => isset($knownTables[$value]))
->unique()
->values()
->all();
if ($unknown !== []) {
throw new \RuntimeException('Tabelle legacy referenziate dalle voci ma assenti nelle intestazioni archive: ' . implode(', ', $unknown) . '.');
}
}
private function ensureLegacyUnitCoverage(string $stableCode, int $stableId, Collection $legacyDetails): void
{
$legacyIds = $legacyDetails
->pluck('id_cond')
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->filter(fn($value): bool => is_string($value) && trim($value) !== '')
->map(fn(string $value): string => trim($value))
->unique()
->values();
@ -212,7 +234,7 @@ private function ensureLegacyUnitCoverage(string $stableCode, int $stableId, Col
->whereNull('deleted_at')
->whereIn('legacy_cond_id', $legacyIds->all())
->pluck('legacy_cond_id')
->map(fn ($value): string => trim((string) $value))
->map(fn($value): string => trim((string) $value))
->all();
$existingSet = array_fill_keys($existingIds, true);
@ -301,11 +323,44 @@ private function createMissingLegacyUnit(string $stableCode, int $stableId, stri
*/
private function syncTabelleMillesimali(int $stableId, int $year, Collection $tableCodes, Collection $legacyDetails, array $legacyHeaders): array
{
$existingRows = DB::table('tabelle_millesimali')
->where('stabile_id', $stableId)
->get(['id', 'codice_tabella', 'nome_tabella']);
$existingByCode = [];
foreach ($existingRows as $existingRow) {
foreach ([
strtoupper(trim((string) ($existingRow->codice_tabella ?? ''))),
strtoupper(trim((string) ($existingRow->nome_tabella ?? ''))),
] as $existingCode) {
if ($existingCode === '' || isset($existingByCode[$existingCode])) {
continue;
}
$existingByCode[$existingCode] = (int) $existingRow->id;
}
}
$tableIdsToRefresh = [];
foreach ($tableCodes as $tableCode) {
$normalizedCode = strtoupper(trim((string) $tableCode));
$existingId = $existingByCode[$normalizedCode] ?? null;
if ($existingId) {
$tableIdsToRefresh[] = $existingId;
}
}
if ($tableIdsToRefresh !== []) {
DB::table('dettaglio_millesimi')
->whereIn('tabella_millesimale_id', $tableIdsToRefresh)
->delete();
}
$detailSummary = $legacyDetails
->groupBy('cod_tab')
->map(function (Collection $rows): array {
return [
'total_mm' => (float) $rows->sum(fn ($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0),
'total_mm' => (float) $rows->sum(fn($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0),
'rows' => $rows->count(),
];
});
@ -361,14 +416,14 @@ private function syncTabelleMillesimali(int $stableId, int $year, Collection $ta
'updated_at' => now(),
];
$existing = DB::table('tabelle_millesimali')
->where('stabile_id', $stableId)
->where('codice_tabella', $tableCode)
->first(['id']);
$existingId = $existingByCode[$normalizedCode] ?? null;
if ($existing) {
DB::table('tabelle_millesimali')->where('id', (int) $existing->id)->update($payload);
$tableMap[$tableCode] = (int) $existing->id;
if ($existingId) {
DB::table('tabelle_millesimali')
->where('id', $existingId)
->update($payload);
$tableMap[$tableCode] = (int) $existingId;
} else {
$payload['created_at'] = now();
DB::table('tabelle_millesimali')->insert($payload);
@ -381,9 +436,49 @@ private function syncTabelleMillesimali(int $stableId, int $year, Collection $ta
$sortOrder++;
}
$this->pruneUnusedTablesOutsideLegacySet($stableId, $tableCodes);
return $tableMap;
}
private function pruneUnusedTablesOutsideLegacySet(int $stableId, Collection $tableCodes): void
{
$allowedCodes = $tableCodes
->map(fn($value): string => strtoupper(trim((string) $value)))
->filter(fn(string $value): bool => $value !== '')
->values()
->all();
$rows = DB::table('tabelle_millesimali')
->where('stabile_id', $stableId)
->get(['id', 'codice_tabella', 'nome_tabella']);
foreach ($rows as $row) {
$code = strtoupper(trim((string) ($row->codice_tabella ?: $row->nome_tabella ?: '')));
if ($code === '' || in_array($code, $allowedCodes, true)) {
continue;
}
$tableId = (int) $row->id;
$hasVoci = DB::table('voci_spesa')
->where('stabile_id', $stableId)
->where('tabella_millesimale_default_id', $tableId)
->exists();
$hasDettagli = DB::table('dettaglio_millesimi')
->where('tabella_millesimale_id', $tableId)
->exists();
if ($hasVoci || $hasDettagli) {
continue;
}
DB::table('tabelle_millesimali')
->where('id', $tableId)
->delete();
}
}
/**
* @param array<string, int> $tableMap
*/
@ -424,9 +519,9 @@ private function syncDettagliMillesimali(int $stableId, int $year, Collection $l
throw new \RuntimeException('Unita NetGescon non risolta per legacy_cond_id ' . $legacyId . '.');
}
$millesimi = (float) $rows->sum(fn ($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0);
$valorePrev = (float) $rows->sum(fn ($row): float => is_numeric($row->prev_euro ?? null) ? (float) $row->prev_euro : 0.0);
$valoreCons = (float) $rows->sum(fn ($row): float => is_numeric($row->cons_euro ?? null) ? (float) $row->cons_euro : 0.0);
$millesimi = (float) $rows->sum(fn($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0);
$valorePrev = (float) $rows->sum(fn($row): float => is_numeric($row->prev_euro ?? null) ? (float) $row->prev_euro : 0.0);
$valoreCons = (float) $rows->sum(fn($row): float => is_numeric($row->cons_euro ?? null) ? (float) $row->cons_euro : 0.0);
DB::table('dettaglio_millesimi')->insert([
'tabella_millesimale_id' => $tableId,
@ -456,6 +551,11 @@ private function syncDettagliMillesimali(int $stableId, int $year, Collection $l
*/
private function syncVociSpesa(int $stableId, int $gestioneId, int $year, Collection $legacyVoci, array $tableMap): void
{
DB::table('voci_spesa')
->where('stabile_id', $stableId)
->where('gestione_contabile_id', $gestioneId)
->delete();
$sortOrder = 1;
foreach ($legacyVoci as $voce) {
@ -498,18 +598,8 @@ private function syncVociSpesa(int $stableId, int $gestioneId, int $year, Collec
'updated_at' => now(),
];
$existing = DB::table('voci_spesa')
->where('stabile_id', $stableId)
->where('gestione_contabile_id', $gestioneId)
->where('codice', $code)
->first(['id']);
if ($existing) {
DB::table('voci_spesa')->where('id', (int) $existing->id)->update($payload);
} else {
$payload['created_at'] = now();
DB::table('voci_spesa')->insert($payload);
}
$sortOrder++;
}
@ -577,7 +667,6 @@ private function resolveTipoTabellaFromLegacy(?string $tipoLegacy, ?string $calc
return match ($tipoLegacy) {
'R' => 'riscaldamento',
'S' => 'straordinaria',
default => 'custom',
};
}

View File

@ -215,8 +215,8 @@ private function collectUniqueCustomers(Collection $rows): Collection
}
foreach (['display_name', 'phone', 'phone_alt', 'email', 'indirizzo', 'cap', 'citta', 'provincia', 'partita_iva', 'codice_fiscale', 'note', 'imported_from_path'] as $field) {
if (($customers[$identity][$field] ?? null) === null && ${Str::camel($field)} !== null) {
$customers[$identity][$field] = ${Str::camel($field)};
if (($customers[$identity][$field] ?? null) === null && ${Str::camel($field);} !== null) {
$customers[$identity][$field] = ${Str::camel($field);}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
namespace App\Filament\Pages\Contabilita;
use App\Models\DatiBancari;
use App\Models\DocumentoStabile;
use App\Models\GestioneContabile;
use App\Models\Stabile;
use App\Models\User;
@ -343,6 +344,19 @@ public function table(Table $table): Table
->getStateUsing(fn(DatiBancari $record): ?float => $this->resolveSaldoConto($record))
->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : '—'),
TextColumn::make('saldo_ufficiale')
->label('Saldo ufficiale')
->alignEnd()
->getStateUsing(fn(DatiBancari $record): ?float => $this->resolveLatestSaldoSnapshotValue($record))
->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : '—')
->toggleable(),
TextColumn::make('ultimo_estratto')
->label('Ultimo estratto')
->getStateUsing(fn(DatiBancari $record): string => $this->resolveLatestSaldoSnapshotLabel($record))
->wrap()
->toggleable(),
TextColumn::make('stato_conto')
->label('Stato')
->toggleable(isToggledHiddenByDefault: true),
@ -379,56 +393,18 @@ public function getTotaleDisponibilita(): ?float
$conti = DatiBancari::query()
->where('stabile_id', $stabileId)
->whereNotNull('iban')
->where('iban', '!=', '')
->get(['iban', 'saldo_iniziale']);
->get();
if ($conti->isEmpty()) {
return 0.0;
}
$ibans = $conti->pluck('iban')->map(fn($v) => trim((string) $v))->filter()->values()->all();
if ($ibans === []) {
return 0.0;
}
$movQ = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->whereIn('iban', $ibans);
if ($from) {
$movQ->whereDate('data', '>=', $from);
}
if ($to) {
$movQ->whereDate('data', '<=', $to);
}
if ($gestioneIds !== []) {
$movQ->whereIn('gestione_id', $gestioneIds);
}
$sums = $movQ
->groupBy('iban')
->selectRaw('iban, COALESCE(SUM(importo), 0) AS saldo_movimenti')
->pluck('saldo_movimenti', 'iban')
->map(fn($v) => (float) $v)
->all();
$total = 0.0;
foreach ($conti as $c) {
$iban = trim((string) ($c->iban ?? ''));
if ($iban === '') {
continue;
$saldo = $this->resolveSaldoConto($c);
if ($saldo !== null) {
$total += $saldo;
}
$saldoIniziale = is_numeric($c->saldo_iniziale) ? (float) $c->saldo_iniziale : 0.0;
if ($from) {
$alt = $this->resolveSaldoIniziale($stabileId, $iban, (string) $from);
if ($alt !== null) {
$saldoIniziale = $alt;
}
}
$total += $saldoIniziale + ($sums[$iban] ?? 0.0);
}
return $total;
@ -527,6 +503,109 @@ protected function resolveSaldoIniziale(int $stabileId, string $iban, string $fr
return $row ? (float) $row->saldo : null;
}
protected function resolveLatestSaldoSnapshot(DatiBancari $record): ?SaldoConto
{
static $cache = [];
$cacheKey = (int) $record->id;
if (array_key_exists($cacheKey, $cache)) {
return $cache[$cacheKey];
}
if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) {
return $cache[$cacheKey] = null;
}
return $cache[$cacheKey] = SaldoConto::query()
->with('documento')
->where('stabile_id', (int) $record->stabile_id)
->where('conto_id', (int) $record->id)
->orderByDesc('data_saldo')
->orderByDesc('id')
->first();
}
protected function resolveLatestSaldoSnapshotValue(DatiBancari $record): ?float
{
$snapshot = $this->resolveLatestSaldoSnapshot($record);
return $snapshot ? (float) $snapshot->saldo : null;
}
protected function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string
{
$snapshot = $this->resolveLatestSaldoSnapshot($record);
if (! $snapshot) {
return 'Nessuno estratto registrato';
}
$parts = [];
$parts[] = $snapshot->tipo_estratto
? ($this->getTipoEstrattoOptions()[$snapshot->tipo_estratto] ?? 'Saldo registrato')
: 'Saldo registrato';
if ($snapshot->periodo_da || $snapshot->periodo_a) {
$from = $snapshot->periodo_da?->format('d/m/Y');
$to = $snapshot->periodo_a?->format('d/m/Y');
$parts[] = trim(($from ?: '...') . ' - ' . ($to ?: '...'));
}
if ($snapshot->documento instanceof DocumentoStabile) {
$parts[] = $snapshot->documento->nome_originale ?: 'Documento allegato';
}
return implode(' · ', $parts);
}
protected function getTipoEstrattoOptions(): array
{
return [
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'riconciliazione' => 'Riconciliazione',
'altro' => 'Altro',
];
}
protected function getContiOptions(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
return [];
}
return DatiBancari::query()
->where('stabile_id', $stabileId)
->orderBy('denominazione_banca')
->orderBy('legacy_cod_cassa')
->orderBy('numero_conto')
->orderBy('id')
->get(['id', 'denominazione_banca', 'iban', 'legacy_cod_cassa', 'numero_conto'])
->mapWithKeys(function (DatiBancari $conto): array {
$parts = [];
$parts[] = trim((string) ($conto->denominazione_banca ?: 'Conto'));
$legacyCodCassa = is_string($conto->legacy_cod_cassa) ? trim((string) $conto->legacy_cod_cassa) : '';
$numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : '';
$iban = is_string($conto->iban) ? trim((string) $conto->iban) : '';
if ($legacyCodCassa !== '') {
$parts[] = 'cassa ' . strtoupper($legacyCodCassa);
}
if ($iban !== '') {
$parts[] = $iban;
} elseif ($numeroConto !== '') {
$parts[] = 'n. ' . $numeroConto;
}
return [(int) $conto->id => implode(' · ', $parts)];
})
->all();
}
/** @return array<string,string> */
protected function getIbanOptions(): array
{

View File

@ -3,12 +3,14 @@
namespace App\Filament\Pages\Contabilita;
use App\Models\DatiBancari;
use App\Models\DocumentoStabile;
use App\Models\User;
use App\Modules\Contabilita\Models\SaldoConto;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@ -117,6 +119,24 @@ public function table(Table $table): Table
->alignEnd()
->formatStateUsing(fn($state) => '€ ' . number_format((float) $state, 2, ',', '.')),
TextColumn::make('tipo_estratto')
->label('Tipo')
->getStateUsing(fn(SaldoConto $record): string => $this->formatTipoEstrattoLabel($record->tipo_estratto))
->toggleable(),
TextColumn::make('periodo_estratto')
->label('Periodo estratto')
->getStateUsing(fn(SaldoConto $record): string => $this->formatPeriodoEstrattoLabel($record->periodo_da, $record->periodo_a))
->wrap()
->toggleable(),
TextColumn::make('documento')
->label('Documento')
->getStateUsing(fn(SaldoConto $record): string => $record->documento?->nome_originale ?: '—')
->url(fn(SaldoConto $record): ?string => $record->documento?->url_view)
->openUrlInNewTab()
->toggleable(),
TextColumn::make('note')
->label('Note')
->wrap()
@ -140,7 +160,30 @@ public function table(Table $table): Table
->required(),
DatePicker::make('data_saldo')->label('Data')->required(),
DatePicker::make('periodo_da')->label('Periodo dal'),
DatePicker::make('periodo_a')->label('Periodo al'),
TextInput::make('saldo')->label('Saldo')->numeric()->required(),
Select::make('tipo_estratto')
->label('Tipo riferimento')
->native(false)
->options($this->getTipoEstrattoOptions())
->default('estratto_conto'),
FileUpload::make('documento')
->label('Allega estratto')
->directory('tmp/banca')
->disk('local')
->acceptedFileTypes([
'application/pdf',
'text/plain',
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pdf',
'.csv',
'.txt',
'.qif',
'.xlsx',
]),
Textarea::make('note')->label('Note (opzionale)')->rows(2)->maxLength(255),
])
->action(function (array $data): void {
@ -161,18 +204,26 @@ public function table(Table $table): Table
$conto = DatiBancari::query()
->where('stabile_id', $stabileId)
->where('id', $contoId)
->first(['id', 'iban']);
->first(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']);
$iban = $conto && is_string($conto->iban) ? trim((string) $conto->iban) : null;
$iban = is_string($iban) && $iban !== '' ? $iban : null;
$documento = $conto
? $this->storeSaldoDocumento($stabileId, $conto, $data['documento'] ?? null, $user, $data)
: null;
SaldoConto::query()->create([
'stabile_id' => (int) $stabileId,
'conto_id' => $contoId,
'iban' => $iban,
'data_saldo' => $data['data_saldo'] ?? null,
'periodo_da' => $data['periodo_da'] ?? null,
'periodo_a' => $data['periodo_a'] ?? null,
'saldo' => isset($data['saldo']) ? (float) $data['saldo'] : 0.0,
'tipo_estratto' => $this->normalizeTipoEstrattoValue($data['tipo_estratto'] ?? null),
'note' => isset($data['note']) && is_string($data['note']) ? trim((string) $data['note']) : null,
'documento_stabile_id' => $documento?->id,
'created_by' => (int) ($user->id ?? 0) ?: null,
'updated_by' => (int) ($user->id ?? 0) ?: null,
]);
@ -184,20 +235,53 @@ public function table(Table $table): Table
->icon('heroicon-o-pencil-square')
->form([
DatePicker::make('data_saldo')->label('Data')->required(),
DatePicker::make('periodo_da')->label('Periodo dal'),
DatePicker::make('periodo_a')->label('Periodo al'),
TextInput::make('saldo')->label('Saldo')->numeric()->required(),
Select::make('tipo_estratto')
->label('Tipo riferimento')
->native(false)
->options($this->getTipoEstrattoOptions()),
FileUpload::make('documento')
->label('Sostituisci / aggiungi allegato')
->directory('tmp/banca')
->disk('local')
->acceptedFileTypes([
'application/pdf',
'text/plain',
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pdf',
'.csv',
'.txt',
'.qif',
'.xlsx',
]),
Textarea::make('note')->label('Note (opzionale)')->rows(2)->maxLength(255),
])
->fillForm(fn(SaldoConto $record): array => [
'data_saldo' => $record->data_saldo,
'periodo_da' => $record->periodo_da,
'periodo_a' => $record->periodo_a,
'saldo' => (float) $record->saldo,
'tipo_estratto' => $record->tipo_estratto,
'note' => $record->note,
])
->action(function (SaldoConto $record, array $data): void {
$user = Auth::user();
$documento = $record->conto
? $this->storeSaldoDocumento((int) $record->stabile_id, $record->conto, $data['documento'] ?? null, $user instanceof User ? $user : null, $data)
: null;
$record->fill([
'data_saldo' => $data['data_saldo'] ?? $record->data_saldo,
'periodo_da' => $data['periodo_da'] ?? null,
'periodo_a' => $data['periodo_a'] ?? null,
'saldo' => isset($data['saldo']) ? (float) $data['saldo'] : (float) $record->saldo,
'tipo_estratto' => $this->normalizeTipoEstrattoValue($data['tipo_estratto'] ?? null),
'note' => isset($data['note']) && is_string($data['note']) ? trim((string) $data['note']) : null,
'documento_stabile_id' => $documento?->id ?: $record->documento_stabile_id,
'updated_by' => $user instanceof User ? ((int) $user->id ?: null) : null,
]);
$record->save();
@ -243,4 +327,84 @@ protected function getContiOptions(): array
})
->all();
}
protected function getTipoEstrattoOptions(): array
{
return [
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'riconciliazione' => 'Riconciliazione',
'altro' => 'Altro',
];
}
protected function normalizeTipoEstrattoValue(mixed $value): ?string
{
$value = is_string($value) ? trim((string) $value) : '';
if ($value === '') {
return null;
}
return array_key_exists($value, $this->getTipoEstrattoOptions()) ? $value : 'altro';
}
protected function formatTipoEstrattoLabel(?string $value): string
{
$normalized = $this->normalizeTipoEstrattoValue($value);
if (! $normalized) {
return 'Saldo registrato';
}
return $this->getTipoEstrattoOptions()[$normalized] ?? 'Saldo registrato';
}
protected function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string
{
$fromDate = $from ? \Illuminate\Support\Carbon::parse((string) $from) : null;
$toDate = $to ? \Illuminate\Support\Carbon::parse((string) $to) : null;
if ($fromDate && $toDate) {
return $fromDate->format('d/m/Y') . ' - ' . $toDate->format('d/m/Y');
}
if ($toDate) {
return 'Fino al ' . $toDate->format('d/m/Y');
}
if ($fromDate) {
return 'Dal ' . $fromDate->format('d/m/Y');
}
return 'Periodo non indicato';
}
protected function storeSaldoDocumento(int $stabileId, DatiBancari $conto, mixed $tempPath, ?User $user, array $data): ?DocumentoStabile
{
$path = is_string($tempPath) ? trim((string) $tempPath) : '';
if ($path === '' || ! $user instanceof User) {
return null;
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$extension = $extension !== '' ? $extension : 'bin';
$filename = 'estratto-conto-' . $stabileId . '-' . (int) $conto->id . '-' . now()->format('YmdHis') . '.' . $extension;
$publicPath = 'documenti/stabili/' . $stabileId . '/bancari/' . $filename;
\Illuminate\Support\Facades\Storage::disk('public')->put($publicPath, \Illuminate\Support\Facades\Storage::disk('local')->get($path));
try {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
} catch (\Throwable) {
// ignore
}
return DocumentoStabile::query()->create([
'stabile_id' => $stabileId,
'nome_file' => $filename,
'nome_originale' => $filename,
'percorso_file' => $publicPath,
'categoria' => 'bancari',
'tipo_mime' => \Illuminate\Support\Facades\Storage::disk('public')->mimeType($publicPath),
'dimensione' => \Illuminate\Support\Facades\Storage::disk('public')->size($publicPath),
'descrizione' => 'Estratto conto ' . trim((string) ($conto->denominazione_banca ?: 'Conto')) . ' · ' . $this->formatPeriodoEstrattoLabel($data['periodo_da'] ?? null, $data['periodo_a'] ?? null),
'caricato_da' => (int) $user->id,
]);
}
}

View File

@ -3,10 +3,14 @@
namespace App\Filament\Pages\Contabilita;
use App\Models\DatiBancari;
use App\Models\DocumentoStabile;
use App\Models\FatturaElettronica;
use App\Models\GestioneContabile;
use App\Models\PersonaUnitaRelazione;
use App\Models\RegistroRitenuteAcconto;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use App\Models\UnitaImmobiliareNominativo;
use App\Models\User;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\SaldoConto;
@ -16,12 +20,17 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Url;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use UnitEnum;
use BackedEnum;
class SituazioneIniziale extends Page
{
use WithFileUploads;
protected static ?string $navigationLabel = 'Situazione iniziale';
protected static ?string $title = 'Situazione iniziale';
@ -116,6 +125,25 @@ class SituazioneIniziale extends Page
public array $detailRows = [];
public ?string $saldoContoId = null;
public ?string $saldoSnapshotData = null;
public ?string $saldoSnapshotPeriodoDa = null;
public ?string $saldoSnapshotPeriodoA = null;
public ?string $saldoSnapshotSaldo = null;
public string $saldoSnapshotTipo = 'estratto_conto';
public ?string $saldoSnapshotNote = null;
public TemporaryUploadedFile|string|null $saldoSnapshotPdf = null;
/** @var array<int, array<string, mixed>> */
public array $saldoSnapshots = [];
/** @var array<string, string> */
public array $vocSpeOptions = [];
// Editing "a riga" (riduce la pagina: si modifica una riga alla volta)
@ -239,7 +267,7 @@ public function cancelEditDettTabRow(): void
private function loadCondominiLabels(string $codStabileLegacy, ?string $legacyYear): array
{
$out = [];
$out = $this->loadLocalCondominiLabels();
// Tabella "condomini" (più snella) se presente
if (Schema::connection('gescon_import')->hasTable('condomini')) {
@ -260,12 +288,14 @@ private function loadCondominiLabels(string $codStabileLegacy, ?string $legacyYe
if ($label === '') {
$label = trim((string) ($r->cod_cond ?? ''));
}
if (! isset($out[$id]) || trim((string) $out[$id]) === '') {
$out[$id] = $label !== '' ? $label : $id;
}
}
}
// Fallback su "condomin" (più completa)
if (empty($out) && Schema::connection('gescon_import')->hasTable('condomin')) {
if (Schema::connection('gescon_import')->hasTable('condomin')) {
$q = DB::connection('gescon_import')->table('condomin')
->where('cod_stabile', $codStabileLegacy);
@ -289,13 +319,144 @@ private function loadCondominiLabels(string $codStabileLegacy, ?string $legacyYe
if ($label === '') {
$label = trim((string) ($r->cod_cond ?? ''));
}
if (! isset($out[$id]) || trim((string) $out[$id]) === '') {
$out[$id] = $label !== '' ? $label : $id;
}
}
}
return $out;
}
/**
* @return array<string, string>
*/
private function loadLocalCondominiLabels(): array
{
$stabileId = $this->getActiveStabile()?->id;
if (! $stabileId || ! Schema::hasTable('unita_immobiliari') || ! Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) {
return [];
}
$referenceDate = $this->resolveReferenceDate();
$units = UnitaImmobiliare::query()
->where('stabile_id', (int) $stabileId)
->whereNotNull('legacy_cond_id')
->get(['id', 'legacy_cond_id', 'scala', 'interno', 'denominazione']);
if ($units->isEmpty()) {
return [];
}
$unitIds = $units->pluck('id')->map(fn ($id) => (int) $id)->all();
$namesByUnit = [];
if (Schema::hasTable('persone_unita_relazioni')) {
$relations = PersonaUnitaRelazione::query()
->with('persona')
->whereIn('unita_id', $unitIds)
->where('attivo', true)
->where(function ($query) use ($referenceDate): void {
$query->whereNull('data_inizio')->orWhere('data_inizio', '<=', $referenceDate->toDateString());
})
->where(function ($query) use ($referenceDate): void {
$query->whereNull('data_fine')->orWhere('data_fine', '>=', $referenceDate->toDateString());
})
->get();
foreach ($relations as $relation) {
$unitId = (int) $relation->unita_id;
$role = strtoupper((string) ($relation->ruolo_rate ?: PersonaUnitaRelazione::deriveRuoloRate($relation->tipo_relazione)));
if (! in_array($role, ['C', 'I'], true)) {
continue;
}
$name = trim((string) ($relation->persona?->nome_display ?? $relation->persona?->nome_completo ?? ''));
if ($name === '') {
continue;
}
$namesByUnit[$unitId][$role][$name] = $name;
}
}
if (Schema::hasTable('unita_immobiliare_nominativi')) {
$legacyNames = UnitaImmobiliareNominativo::query()
->where('stabile_id', (int) $stabileId)
->whereIn('unita_immobiliare_id', $unitIds)
->where(function ($query) use ($referenceDate): void {
$query->whereNull('data_inizio')->orWhere('data_inizio', '<=', $referenceDate->toDateString());
})
->where(function ($query) use ($referenceDate): void {
$query->whereNull('data_fine')->orWhere('data_fine', '>=', $referenceDate->toDateString());
})
->get(['unita_immobiliare_id', 'ruolo', 'nominativo']);
foreach ($legacyNames as $row) {
$unitId = (int) ($row->unita_immobiliare_id ?? 0);
$role = strtoupper((string) ($row->ruolo ?? ''));
if (! in_array($role, ['C', 'I'], true)) {
continue;
}
$name = trim((string) ($row->nominativo ?? ''));
if ($name === '') {
continue;
}
$namesByUnit[$unitId][$role][$name] = $name;
}
}
$labels = [];
foreach ($units as $unit) {
$legacyCondId = trim((string) ($unit->legacy_cond_id ?? ''));
if ($legacyCondId === '') {
continue;
}
$pieces = [];
$scala = trim((string) ($unit->scala ?? ''));
$interno = trim((string) ($unit->interno ?? ''));
$denominazione = trim((string) ($unit->denominazione ?? ''));
if ($scala !== '' || $interno !== '') {
$unitLabel = trim(($scala !== '' ? ('Scala ' . $scala) : '') . ($interno !== '' ? (' Int. ' . $interno) : ''));
if ($unitLabel !== '') {
$pieces[] = $unitLabel;
}
} elseif ($denominazione !== '') {
$pieces[] = $denominazione;
}
$owners = array_values($namesByUnit[(int) $unit->id]['C'] ?? []);
$tenants = array_values($namesByUnit[(int) $unit->id]['I'] ?? []);
if ($owners !== []) {
$pieces[] = 'Prop: ' . implode(', ', $owners);
}
if ($tenants !== []) {
$pieces[] = 'Inq: ' . implode(', ', $tenants);
}
if ($pieces === [] && $denominazione !== '') {
$pieces[] = $denominazione;
}
$labels[$legacyCondId] = $pieces !== [] ? implode(' · ', $pieces) : $legacyCondId;
}
return $labels;
}
private function resolveReferenceDate(): Carbon
{
try {
return $this->dataBilancio ? Carbon::parse($this->dataBilancio)->startOfDay() : now()->startOfDay();
} catch (\Throwable) {
return now()->startOfDay();
}
}
public function updatedLegacyYear(): void
{
$this->legacyYear = is_string($this->legacyYear) ? trim((string) $this->legacyYear) : null;
@ -317,6 +478,13 @@ public function updatedSaldoBancaManuale(): void
$this->reloadData();
}
public function updatedSaldoContoId(): void
{
$this->saldoContoId = is_string($this->saldoContoId) ? trim((string) $this->saldoContoId) : null;
$this->saldoContoId = $this->saldoContoId !== '' ? $this->saldoContoId : null;
$this->loadSaldoSnapshots();
}
public function updatedDett(): void
{
$this->dett = is_string($this->dett) ? trim((string) $this->dett) : null;
@ -359,6 +527,13 @@ private function reloadData(): void
$stabileId = $stabile?->id ? (int) $stabile->id : null;
$this->conti = $stabileId ? $this->loadContiOptions($stabileId) : [];
if (($this->saldoContoId === null || $this->saldoContoId === '') && $this->conti !== []) {
$this->saldoContoId = (string) array_key_first($this->conti);
}
if ($this->saldoContoId !== null && $this->saldoContoId !== '' && ! array_key_exists($this->saldoContoId, $this->conti)) {
$this->saldoContoId = $this->conti !== [] ? (string) array_key_first($this->conti) : null;
}
if (! $this->dataBilancio) {
$this->dataBilancio = $this->defaultDataBilancio();
}
@ -370,6 +545,7 @@ private function reloadData(): void
$this->conguagliExcelCols = [];
$this->detailMeta = [];
$this->detailRows = [];
$this->saldoSnapshots = [];
return;
}
@ -379,7 +555,8 @@ private function reloadData(): void
$this->vocSpeOptions = $this->loadVocSpeOptions($codStabileLegacy, $this->legacyYear);
if ($this->tab === 'conguagli') {
[$this->conguagliExcelCols, $this->conguagliExcel] = $this->computeConguagliExcel($codStabileLegacy, $this->legacyYear);
$this->conguagliExcel = [];
$this->conguagliExcelCols = [];
[$this->dettTabRows, $this->dettTabEdit] = $this->loadDettTabCrudRows($codStabileLegacy, $this->legacyYear);
} else {
$this->conguagliExcel = [];
@ -405,6 +582,219 @@ private function reloadData(): void
}
$this->reloadDettaglio($codStabileLegacy, $this->legacyYear);
$this->loadSaldoSnapshots();
}
public function saveSaldoSnapshot(): void
{
$stabile = $this->getActiveStabile();
$stabileId = $stabile?->id ? (int) $stabile->id : null;
if (! $stabileId) {
$this->addError('saldoContoId', 'Seleziona prima uno stabile attivo.');
return;
}
$validated = $this->validate([
'saldoContoId' => ['required', 'string'],
'saldoSnapshotData' => ['required', 'date'],
'saldoSnapshotPeriodoDa' => ['nullable', 'date'],
'saldoSnapshotPeriodoA' => ['nullable', 'date'],
'saldoSnapshotSaldo' => ['required', 'string'],
'saldoSnapshotTipo' => ['required', 'string', 'in:estratto_conto,saldo_banca,riconciliazione,altro'],
'saldoSnapshotNote' => ['nullable', 'string', 'max:255'],
'saldoSnapshotPdf' => ['nullable', 'file', 'mimes:pdf', 'max:20480'],
]);
$contoId = (int) ($validated['saldoContoId'] ?? 0);
$conto = DatiBancari::query()
->where('stabile_id', $stabileId)
->where('id', $contoId)
->first(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']);
if (! $conto) {
$this->addError('saldoContoId', 'Conto non valido per lo stabile attivo.');
return;
}
$saldo = $this->parseMoney((string) ($validated['saldoSnapshotSaldo'] ?? ''));
if ($saldo === null) {
$this->addError('saldoSnapshotSaldo', 'Saldo non valido.');
return;
}
$user = Auth::user();
$documento = $this->storeSaldoSnapshotDocumento($stabileId, $conto, $this->saldoSnapshotPdf, $validated);
SaldoConto::query()->updateOrCreate(
[
'stabile_id' => $stabileId,
'conto_id' => $contoId,
'data_saldo' => $validated['saldoSnapshotData'],
],
[
'iban' => $this->normalizeIbanValue($conto->iban),
'periodo_da' => $validated['saldoSnapshotPeriodoDa'] ?? null,
'periodo_a' => $validated['saldoSnapshotPeriodoA'] ?? null,
'saldo' => $saldo,
'tipo_estratto' => $validated['saldoSnapshotTipo'],
'note' => isset($validated['saldoSnapshotNote']) ? trim((string) $validated['saldoSnapshotNote']) : null,
'documento_stabile_id' => $documento?->id,
'created_by' => (int) ($user?->id ?? 0) ?: null,
'updated_by' => (int) ($user?->id ?? 0) ?: null,
]
);
$this->resetSaldoSnapshotForm(false);
$this->reloadData();
}
public function deleteSaldoSnapshot(int $snapshotId): void
{
if ($snapshotId <= 0) {
return;
}
$stabile = $this->getActiveStabile();
$stabileId = $stabile?->id ? (int) $stabile->id : null;
if (! $stabileId) {
return;
}
$snapshot = SaldoConto::query()
->where('stabile_id', $stabileId)
->where('id', $snapshotId)
->first();
if (! $snapshot) {
return;
}
$documentoId = (int) ($snapshot->documento_stabile_id ?? 0);
$snapshot->delete();
if ($documentoId > 0) {
DocumentoStabile::query()->where('id', $documentoId)->delete();
}
$this->reloadData();
}
private function loadSaldoSnapshots(): void
{
$this->saldoSnapshots = [];
$stabile = $this->getActiveStabile();
$stabileId = $stabile?->id ? (int) $stabile->id : null;
$contoId = is_numeric($this->saldoContoId) ? (int) $this->saldoContoId : 0;
if (! $stabileId || $contoId <= 0 || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) {
return;
}
$this->saldoSnapshots = SaldoConto::query()
->with('documento')
->where('stabile_id', $stabileId)
->where('conto_id', $contoId)
->orderByDesc('data_saldo')
->orderByDesc('id')
->get()
->map(function (SaldoConto $row): array {
return [
'id' => (int) $row->id,
'data_saldo' => $row->data_saldo?->format('d/m/Y'),
'periodo' => $this->formatPeriodoEstrattoLabel($row->periodo_da, $row->periodo_a),
'tipo' => $this->formatTipoEstrattoLabel($row->tipo_estratto),
'saldo' => (float) $row->saldo,
'note' => (string) ($row->note ?? ''),
'documento_nome' => $row->documento?->nome_originale,
'documento_url' => $row->documento?->url_view,
];
})
->all();
}
private function resetSaldoSnapshotForm(bool $resetConto = true): void
{
if ($resetConto) {
$this->saldoContoId = $this->conti !== [] ? (string) array_key_first($this->conti) : null;
}
$this->saldoSnapshotData = $this->dataBilancio;
$this->saldoSnapshotPeriodoDa = null;
$this->saldoSnapshotPeriodoA = null;
$this->saldoSnapshotSaldo = null;
$this->saldoSnapshotTipo = 'estratto_conto';
$this->saldoSnapshotNote = null;
$this->saldoSnapshotPdf = null;
$this->resetValidation([
'saldoContoId',
'saldoSnapshotData',
'saldoSnapshotPeriodoDa',
'saldoSnapshotPeriodoA',
'saldoSnapshotSaldo',
'saldoSnapshotTipo',
'saldoSnapshotNote',
'saldoSnapshotPdf',
]);
}
private function formatTipoEstrattoLabel(?string $value): string
{
return match (trim((string) $value)) {
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'riconciliazione' => 'Riconciliazione',
'altro' => 'Altro',
default => 'Saldo registrato',
};
}
private function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string
{
$fromDate = $from instanceof Carbon ? $from : ($from ? Carbon::parse((string) $from) : null);
$toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null);
if ($fromDate && $toDate) {
return $fromDate->format('d/m/Y') . ' - ' . $toDate->format('d/m/Y');
}
if ($toDate) {
return 'Fino al ' . $toDate->format('d/m/Y');
}
if ($fromDate) {
return 'Dal ' . $fromDate->format('d/m/Y');
}
return 'Periodo non indicato';
}
private function normalizeIbanValue(mixed $value): ?string
{
$iban = is_string($value) ? trim((string) $value) : '';
return $iban !== '' ? $iban : null;
}
private function storeSaldoSnapshotDocumento(int $stabileId, DatiBancari $conto, TemporaryUploadedFile|string|null $file, array $validated): ?DocumentoStabile
{
if (! $file instanceof TemporaryUploadedFile) {
return null;
}
$originalName = trim((string) $file->getClientOriginalName());
$extension = strtolower((string) $file->getClientOriginalExtension());
$extension = $extension !== '' ? $extension : 'pdf';
$storedName = 'estratto-conto-' . $stabileId . '-' . (int) $conto->id . '-' . now()->format('YmdHis') . '.' . $extension;
$storedPath = $file->storeAs('documenti/stabili/' . $stabileId . '/bancari', $storedName, 'public');
return DocumentoStabile::query()->create([
'stabile_id' => $stabileId,
'nome_file' => $storedName,
'nome_originale' => $originalName !== '' ? $originalName : $storedName,
'percorso_file' => $storedPath,
'categoria' => 'bancari',
'tipo_mime' => Storage::disk('public')->mimeType($storedPath),
'dimensione' => Storage::disk('public')->size($storedPath),
'descrizione' => 'Saldo/estratto ' . ($this->conti[(string) $conto->id] ?? ('Conto #' . $conto->id)) . ' · ' . $this->formatPeriodoEstrattoLabel($validated['saldoSnapshotPeriodoDa'] ?? null, $validated['saldoSnapshotPeriodoA'] ?? null),
'caricato_da' => Auth::id(),
]);
}
/**
@ -427,14 +817,18 @@ private function loadLegacyYearLabels(string $codStabileLegacy): array
if ($cartella === '') {
continue;
}
$label = trim((string) ($r->anno_riscaldamento ?? ''));
if ($label === '') {
$label = trim((string) ($r->anno_ordinario ?? ''));
$ord = trim((string) ($r->anno_ordinario ?? ''));
$risc = trim((string) ($r->anno_riscaldamento ?? ''));
$parts = [];
if ($ord !== '') {
$parts[] = 'Ord. ' . $ord;
}
if ($label === '') {
$label = $cartella;
if ($risc !== '' && $risc !== $ord) {
$parts[] = 'Risc. ' . $risc;
}
$out[$cartella] = $label;
$parts[] = 'Dir. ' . $cartella;
$out[$cartella] = implode(' · ', $parts);
}
return $out;
}
@ -884,7 +1278,10 @@ private function loadDettTabCrudRows(string $codStabileLegacy, ?string $legacyYe
}
$q = DB::connection('gescon_import')->table('dett_tab')
->where('cod_tab', 'like', 'CONG.%');
->where('cod_tab', 'CONG.O')
->where(function ($query) {
$query->whereNull('n_stra')->orWhere('n_stra', '<=', 0);
});
if (Schema::connection('gescon_import')->hasColumn('dett_tab', 'cod_stabile')) {
$hasForStabile = DB::connection('gescon_import')->table('dett_tab')
@ -1061,12 +1458,12 @@ public function saveDettTabRow(int $id): void
DB::connection('gescon_import')->table('dett_tab')
->where('id', $id)
->update([
'cod_tab' => is_string($edit['cod_tab'] ?? null) ? trim((string) $edit['cod_tab']) : null,
'cod_tab' => 'CONG.O',
'id_cond' => is_string($edit['id_cond'] ?? null) ? trim((string) $edit['id_cond']) : null,
'cond_inquil' => is_string($edit['cond_inquil'] ?? null) ? trim((string) $edit['cond_inquil']) : null,
'mm' => is_numeric($edit['mm'] ?? null) ? (float) $edit['mm'] : null,
'cons_euro' => round((float) $cons, 2),
'n_stra' => is_numeric($edit['n_stra'] ?? null) ? (int) $edit['n_stra'] : null,
'n_stra' => null,
'unico' => ! empty($edit['unico']) ? 1 : 0,
'updated_at' => now(),
]);
@ -1097,7 +1494,7 @@ public function addDettTabRow(): void
return;
}
$codTab = is_string($this->formDettTabAdd['cod_tab'] ?? null) ? trim((string) $this->formDettTabAdd['cod_tab']) : '';
$codTab = 'CONG.O';
$idCond = is_string($this->formDettTabAdd['id_cond'] ?? null) ? trim((string) $this->formDettTabAdd['id_cond']) : '';
if ($codTab === '' || $idCond === '') {
return;
@ -1114,7 +1511,7 @@ public function addDettTabRow(): void
'cond_inquil' => is_string($this->formDettTabAdd['cond_inquil'] ?? null) ? trim((string) $this->formDettTabAdd['cond_inquil']) : null,
'mm' => is_numeric($this->formDettTabAdd['mm'] ?? null) ? (float) $this->formDettTabAdd['mm'] : null,
'cons_euro' => round((float) $cons, 2),
'n_stra' => is_numeric($this->formDettTabAdd['n_stra'] ?? null) ? (int) $this->formDettTabAdd['n_stra'] : null,
'n_stra' => null,
'unico' => ! empty($this->formDettTabAdd['unico']) ? 1 : 0,
'created_at' => now(),
'updated_at' => now(),
@ -1283,11 +1680,14 @@ private function loadContiOptions(int $stabileId): array
return DatiBancari::query()
->where('stabile_id', $stabileId)
->orderBy('id')
->get(['id', 'denominazione_banca', 'iban'])
->get(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa'])
->mapWithKeys(function (DatiBancari $c): array {
$iban = is_string($c->iban) ? trim((string) $c->iban) : '';
$numeroConto = is_string($c->numero_conto) ? trim((string) ($c->numero_conto ?? '')) : '';
$legacyCodCassa = is_string($c->legacy_cod_cassa) ? strtoupper(trim((string) ($c->legacy_cod_cassa ?? ''))) : '';
$label = trim((string) ($c->denominazione_banca ?? ''));
$label = $label !== '' ? ($label . ($iban !== '' ? (' · ' . $iban) : '')) : ($iban !== '' ? $iban : ('Conto #' . $c->id));
$identifier = $iban !== '' ? $iban : ($numeroConto !== '' ? $numeroConto : ($legacyCodCassa !== '' ? ('Cassa ' . $legacyCodCassa) : ('Conto #' . $c->id)));
$label = $label !== '' ? ($label . ' · ' . $identifier) : $identifier;
return [(string) $c->id => $label];
})
->all();
@ -1367,9 +1767,6 @@ private function computeBucketOrdinaria(string $codStabileLegacy, ?string $legac
$debiti = $this->sumCreDebPreced($codStabileLegacy, $legacyYear, 'D', 'non_stra')
+ $this->sumManualVoci('ordinaria', 'debiti', null, $legacyYear);
[$congPos, $congNeg] = $this->sumConguagli($codStabileLegacy, $legacyYear, 'CONG.O', 'non_stra');
[$mPos, $mNeg] = $this->sumManualConguagli($legacyYear, 'CONG.O', 'non_stra');
$congPos += $mPos;
$congNeg += $mNeg;
$att = round($crediti + $congPos, 2);
$pas = round($debiti + $congNeg, 2);
@ -1393,9 +1790,6 @@ private function computeBucketRiscaldamento(string $codStabileLegacy, ?string $l
$crediti = 0.0;
$debiti = 0.0;
[$congPos, $congNeg] = $this->sumConguagli($codStabileLegacy, $legacyYear, 'CONG.R', 'non_stra');
[$mPos, $mNeg] = $this->sumManualConguagli($legacyYear, 'CONG.R', 'non_stra');
$congPos += $mPos;
$congNeg += $mNeg;
$att = round($crediti + $congPos, 2);
$pas = round($debiti + $congNeg, 2);
@ -1417,9 +1811,6 @@ private function computeBucketStraordinaria(string $codStabileLegacy, ?string $l
$debiti = $this->sumCreDebPreced($codStabileLegacy, $legacyYear, 'D', $nStra)
+ $this->sumManualVoci('straordinaria', 'debiti', $nStra, $legacyYear);
[$congPos, $congNeg] = $this->sumConguagli($codStabileLegacy, $legacyYear, null, $nStra);
[$mPos, $mNeg] = $this->sumManualConguagli($legacyYear, null, $nStra);
$congPos += $mPos;
$congNeg += $mNeg;
$titolo = $this->resolveStraordinariaTitolo($codStabileLegacy, $legacyYear, $nStra);
@ -1830,10 +2221,13 @@ private function computeRisorseFinanziarie(int $stabileId, string $date): array
$conti = DatiBancari::query()
->where('stabile_id', $stabileId)
->whereNotNull('iban')
->where('iban', '!=', '')
->when(Schema::hasColumn('dati_bancari', 'stato_conto'), function ($query) {
$query->where(function ($query) {
$query->whereNull('stato_conto')->orWhere('stato_conto', '!=', 'chiuso');
});
})
->orderBy('id')
->get(['id', 'denominazione_banca', 'iban', 'saldo_iniziale']);
->get(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa', 'saldo_iniziale', 'data_saldo_iniziale']);
if ($conti->isEmpty()) {
return [];
@ -1843,24 +2237,13 @@ private function computeRisorseFinanziarie(int $stabileId, string $date): array
$out = [];
foreach ($conti as $conto) {
$iban = is_string($conto->iban) ? trim((string) $conto->iban) : '';
if ($iban === '') {
continue;
}
$saldoIniziale = is_numeric($conto->saldo_iniziale) ? (float) $conto->saldo_iniziale : 0.0;
$sumImporti = (float) (MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('conto_id', (int) $conto->id)
->where('iban', $iban)
->whereDate('data', '<=', $dateC->toDateString())
->sum('importo') ?? 0.0);
$offset = $this->resolveSaldoOffsetDaSaldi($stabileId, (int) $conto->id, $iban, $dateC);
$saldo = round($saldoIniziale + $sumImporti + $offset, 2);
$numeroConto = is_string($conto->numero_conto) ? trim((string) ($conto->numero_conto ?? '')) : '';
$legacyCodCassa = is_string($conto->legacy_cod_cassa) ? strtoupper(trim((string) ($conto->legacy_cod_cassa ?? ''))) : '';
$saldo = $this->resolveContoSaldoAllaData($stabileId, $conto, $dateC);
$label = trim((string) ($conto->denominazione_banca ?? ''));
$label = $label !== '' ? ($label . ' · ' . $iban) : $iban;
$identifier = $iban !== '' ? $iban : ($numeroConto !== '' ? $numeroConto : ($legacyCodCassa !== '' ? ('Cassa ' . $legacyCodCassa) : ('Conto #' . $conto->id)));
$label = $label !== '' ? ($label . ' · ' . $identifier) : $identifier;
$out[] = [
'conto_id' => (int) $conto->id,
@ -1873,6 +2256,66 @@ private function computeRisorseFinanziarie(int $stabileId, string $date): array
return $out;
}
private function resolveContoSaldoAllaData(int $stabileId, DatiBancari $conto, Carbon $date): float
{
$contoId = (int) $conto->id;
$snapshot = SaldoConto::query()
->where('stabile_id', $stabileId)
->where('conto_id', $contoId)
->whereDate('data_saldo', '<=', $date->toDateString())
->orderByDesc('data_saldo')
->orderByDesc('id')
->first(['data_saldo', 'saldo']);
if ($snapshot) {
$baseDate = $snapshot->data_saldo instanceof Carbon
? $snapshot->data_saldo->copy()->startOfDay()
: Carbon::parse((string) $snapshot->data_saldo)->startOfDay();
$delta = (float) (MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('conto_id', $contoId)
->whereDate('data', '>', $baseDate->toDateString())
->whereDate('data', '<=', $date->toDateString())
->sum('importo') ?? 0.0);
return round((float) ($snapshot->saldo ?? 0.0) + $delta, 2);
}
$saldoBase = is_numeric($conto->saldo_iniziale) ? (float) $conto->saldo_iniziale : 0.0;
$dataSaldoIniziale = $conto->data_saldo_iniziale instanceof Carbon
? $conto->data_saldo_iniziale->copy()->startOfDay()
: ($conto->data_saldo_iniziale ? Carbon::parse((string) $conto->data_saldo_iniziale)->startOfDay() : null);
$movimenti = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('conto_id', $contoId);
if ($dataSaldoIniziale instanceof Carbon) {
if ($dataSaldoIniziale->lte($date)) {
$delta = (float) (clone $movimenti)
->whereDate('data', '>', $dataSaldoIniziale->toDateString())
->whereDate('data', '<=', $date->toDateString())
->sum('importo');
return round($saldoBase + $delta, 2);
}
$delta = (float) (clone $movimenti)
->whereDate('data', '>', $date->toDateString())
->whereDate('data', '<=', $dataSaldoIniziale->toDateString())
->sum('importo');
return round($saldoBase - $delta, 2);
}
$delta = (float) (clone $movimenti)
->whereDate('data', '<=', $date->toDateString())
->sum('importo');
return round($saldoBase + $delta, 2);
}
private function computeSaldoBancaTotaleLetto(array $risorseFinanziarie): ?float
{
if ($risorseFinanziarie === []) {
@ -2082,6 +2525,8 @@ private function loadDettTabRows(string $codStabileLegacy, ?string $legacyYear,
return [];
}
$labels = $this->loadCondominiLabels($codStabileLegacy, $legacyYear);
$q = DB::connection('gescon_import')->table('dett_tab')
->where('cod_tab', 'like', 'CONG.%');
@ -2127,6 +2572,7 @@ private function loadDettTabRows(string $codStabileLegacy, ?string $legacyYear,
->map(fn ($r) => [
'cod_tab' => (string) ($r->cod_tab ?? ''),
'id_cond' => (string) ($r->id_cond ?? ''),
'cond_label' => (string) ($labels[trim((string) ($r->id_cond ?? ''))] ?? ''),
'cond_inquil' => (string) ($r->cond_inquil ?? ''),
'mm' => $r->mm !== null ? (float) $r->mm : null,
'cons_euro' => (float) ($r->cons_euro ?? 0),

View File

@ -85,13 +85,6 @@ public function mount(): void
$this->tipoGestioneTab = in_array($tab, ['ordinaria', 'acqua', 'riscaldamento', 'straordinaria'], true) ? $tab : 'ordinaria';
$this->gestioneContabileId = $this->resolveGestioneContabileId();
if (! $this->gestioneContabileId) {
$opts = $this->getGestioniOptions();
$firstKey = array_key_first($opts);
if (is_numeric($firstKey)) {
$this->gestioneContabileId = (int) $firstKey;
}
}
$preset = (string) request()->query('preset', 'manuale');
$this->presetRipartizione = in_array($preset, ['manuale', 'confedilizia'], true) ? $preset : 'manuale';
@ -202,9 +195,7 @@ private function mapTabellaToAnno(?int $sourceTabellaId, int $stabileId, int $ta
}
if ($hasAnno) {
$q->where(function (Builder $qq) use ($targetAnno) {
$qq->where('anno_gestione', $targetAnno)->orWhereNull('anno_gestione');
})->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END');
$q->where('anno_gestione', $targetAnno);
}
$id = $q->orderBy('ordine_visualizzazione')
@ -464,9 +455,7 @@ public function getTabelleMillesimaliOptions(): array
->orderBy('nome_tabella');
if ($hasAnno) {
$q->where(function (Builder $qq) use ($anno) {
$qq->where('anno_gestione', $anno)->orWhereNull('anno_gestione');
})->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END');
$q->where('anno_gestione', $anno);
}
if ($hasLegacyTipo) {
@ -625,24 +614,9 @@ private function applyTipoGestioneFilter(Builder $q, string $tipoTab, bool $hasL
{
$tipo = $this->normalizeTipoGestione($tipoTab);
if ($hasLegacyTipo) {
$legacy = $this->getLegacyTipoValues($tipo);
$q->where(function (Builder $qq) use ($legacy, $tipo) {
$qq->whereIn('tm.tipo', $legacy)
->orWhereIn('voci_spesa.tipo_gestione', $legacy)
->orWhere('voci_spesa.tipo_gestione', $tipo)
->orWhereNull('tm.tipo');
});
} else {
if ($tipo === 'ordinaria') {
$q->where(function (Builder $qq) {
$qq->where('voci_spesa.tipo_gestione', 'ordinaria')
->orWhereNull('voci_spesa.tipo_gestione');
});
} else {
if (Schema::hasColumn('voci_spesa', 'tipo_gestione')) {
$q->where('voci_spesa.tipo_gestione', $tipo);
}
}
if ($tipoTab === 'acqua') {
$q->where(function (Builder $qq) {
@ -650,9 +624,7 @@ private function applyTipoGestioneFilter(Builder $q, string $tipoTab, bool $hasL
->orWhere('voci_spesa.categoria', 'acqua');
});
} elseif ($tipoTab === 'ordinaria') {
$q->where(function (Builder $qq) {
$qq->whereNull('tm.codice_tabella')->orWhereRaw('UPPER(TRIM(tm.codice_tabella)) != ?', ['ACQUA']);
});
$q->whereRaw('(tm.codice_tabella IS NULL OR UPPER(TRIM(tm.codice_tabella)) != ?)', ['ACQUA']);
$q->where(function (Builder $qq) {
$qq->whereNull('voci_spesa.categoria')->orWhere('voci_spesa.categoria', '!=', 'acqua');
});
@ -747,9 +719,7 @@ private function getNordOptions(): array
$q = TabellaMillesimale::query()->where('stabile_id', $stabileId);
if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) {
$anno = AnnoGestioneContext::resolveActiveAnno($user);
$q->where(function (Builder $qq) use ($anno) {
$qq->where('anno_gestione', $anno)->orWhereNull('anno_gestione');
});
$q->where('anno_gestione', $anno);
}
$values = $q->selectRaw('DISTINCT ' . $expr . ' as nord_val')
@ -779,9 +749,7 @@ private function getFirstTabellaCodesToHide(int $stabileId): array
$q = TabellaMillesimale::query()->where('stabile_id', $stabileId);
if ($hasAnno) {
$q->where(function (Builder $qq) use ($anno) {
$qq->where('anno_gestione', $anno)->orWhereNull('anno_gestione');
})->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END');
$q->where('anno_gestione', $anno);
}
if ($hasLegacyTipo) {
@ -950,16 +918,6 @@ protected function resolveGestioneContabileId(): ?int
->orderByDesc('id')
->first();
if (! $match && $tipo === 'ordinaria') {
$match = GestioneContabile::query()
->where('stabile_id', $stabileId)
->where('stato', 'aperta')
->orderByDesc('gestione_attiva')
->orderByDesc('anno_gestione')
->orderByDesc('id')
->first();
}
return $match?->id ? (int) $match->id : null;
}
@ -975,6 +933,10 @@ protected function getTableQuery(): Builder
return VoceSpesa::query()->whereRaw('1 = 0');
}
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) {
return VoceSpesa::query()->whereRaw('1 = 0');
}
$tipoTab = $this->tipoGestioneTab;
// Requisito (legacy): tabs filtrano per `tabelle.tipo` e ordinano per `tabelle.nord`.
@ -1028,6 +990,10 @@ public function getTotalePreventivo(): float
return 0.0;
}
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) {
return 0.0;
}
$tipoTab = $this->tipoGestioneTab;
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
@ -1044,6 +1010,20 @@ public function getTotalePreventivo(): float
return (float) $q->sum('importo_default');
}
public function getTotalePreventivoAcqua(): float
{
return $this->getTotaleByTipoTab('acqua', 'importo_default');
}
public function getTotalePreventivoGenerale(): float
{
if (($this->tipoGestioneTab ?? 'ordinaria') === 'acqua') {
return $this->getTotalePreventivo();
}
return $this->getTotalePreventivo() + $this->getTotalePreventivoAcqua();
}
public function getTotaleConsuntivo(): float
{
$user = Auth::user();
@ -1056,6 +1036,10 @@ public function getTotaleConsuntivo(): float
return 0.0;
}
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) {
return 0.0;
}
if (! Schema::hasColumn('voci_spesa', 'importo_consuntivo')) {
return 0.0;
}
@ -1074,6 +1058,53 @@ public function getTotaleConsuntivo(): float
return (float) $q->sum('importo_consuntivo');
}
public function getTotaleConsuntivoAcqua(): float
{
return $this->getTotaleByTipoTab('acqua', 'importo_consuntivo');
}
public function getTotaleConsuntivoGenerale(): float
{
if (($this->tipoGestioneTab ?? 'ordinaria') === 'acqua') {
return $this->getTotaleConsuntivo();
}
return $this->getTotaleConsuntivo() + $this->getTotaleConsuntivoAcqua();
}
private function getTotaleByTipoTab(string $tipoTab, string $column): float
{
$user = Auth::user();
if (! $user instanceof User) {
return 0.0;
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
if (! $activeStabileId) {
return 0.0;
}
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) {
return 0.0;
}
if (! Schema::hasColumn('voci_spesa', $column)) {
return 0.0;
}
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$q = VoceSpesa::query()->where('voci_spesa.stabile_id', $activeStabileId);
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && $this->gestioneContabileId) {
$q->where('gestione_contabile_id', $this->gestioneContabileId);
}
$q->leftJoin('tabelle_millesimali as tm', 'tm.id', '=', 'voci_spesa.tabella_millesimale_default_id');
$this->applyTipoGestioneFilter($q, $tipoTab, $hasLegacyTipo);
return (float) $q->sum($column);
}
/**
* Totali per tabella (codice_tabella) e per Prev/Cons.
* @return array<int, array{tabella: string, totale_prev: float, totale_cons: float}>
@ -1090,11 +1121,12 @@ public function getTotaliPerTabella(): array
return [];
}
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) {
return [];
}
$tipoTab = $this->tipoGestioneTab;
$hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo');
$hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione');
$anno = AnnoGestioneContext::resolveActiveAnno($user);
$q = VoceSpesa::query()
->leftJoin('tabelle_millesimali as tm', 'tm.id', '=', 'voci_spesa.tabella_millesimale_default_id')
->where('voci_spesa.stabile_id', $activeStabileId)
@ -1102,12 +1134,13 @@ public function getTotaliPerTabella(): array
$qq->where('voci_spesa.gestione_contabile_id', $this->gestioneContabileId);
});
$q->selectRaw('COALESCE(tm.codice_tabella, "—") as tabella, SUM(voci_spesa.importo_default) as totale_prev, SUM(COALESCE(voci_spesa.importo_consuntivo, 0)) as totale_cons');
$q->selectRaw('COALESCE(tm.codice_tabella, "—") as tabella, COALESCE(tm.nord, tm.ordine_visualizzazione, tm.ordinamento, 9999) as tabella_nord, SUM(voci_spesa.importo_default) as totale_prev, SUM(COALESCE(voci_spesa.importo_consuntivo, 0)) as totale_cons');
$this->applyTipoGestioneFilter($q, $tipoTab, $hasLegacyTipo);
return $q
->groupBy('tabella')
->groupBy('tabella', 'tabella_nord')
->orderBy('tabella_nord')
->orderByRaw('tabella = "—"')
->orderBy('tabella')
->get()
@ -1159,50 +1192,52 @@ public function table(Table $table): Table
->label('Descrizione')
->searchable()
->wrap()
->extraAttributes(['class' => 'text-[11px]']),
->extraAttributes(['class' => 'text-[13px] leading-tight']),
TextColumn::make('importo_default')
->label('Prev.')
->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.'))
->alignRight()
->summarize(Sum::make()->label('Subtotale')->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.')))
->extraAttributes(['class' => 'text-[11px]']),
->extraAttributes(['class' => 'text-[13px] leading-tight']),
TextColumn::make('importo_consuntivo')
->label('Cons.')
->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.'))
->alignRight()
->summarize(Sum::make()->label('Subtotale')->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.')))
->extraAttributes(['class' => 'text-[11px]']),
->extraAttributes(['class' => 'text-[13px] leading-tight']),
TextColumn::make('percentuale_condomino')
->label('% Prop.')
->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.'))
->alignRight()
->extraAttributes(['class' => 'text-[11px]']),
->extraAttributes(['class' => 'text-[13px] leading-tight']),
TextColumn::make('percentuale_inquilino')
->label('% Inq.')
->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.'))
->alignRight()
->extraAttributes(['class' => 'text-[11px]']),
->extraAttributes(['class' => 'text-[13px] leading-tight']),
IconColumn::make('attiva')
->label('Attiva')
->boolean()
->extraAttributes(['class' => 'text-[11px]']),
->extraAttributes(['class' => 'text-[13px] leading-tight']),
])
->actions([
Action::make('mastrino')
->label('Mastrino')
->hiddenLabel()
->icon('heroicon-o-document-chart-bar')
->tooltip('Mastrino')
->url(fn (VoceSpesa $record) => VoceSpesaMastrino::getUrl([
'record' => (int) $record->id,
'gestione' => $this->gestioneContabileId,
], panel: 'admin-filament')),
Action::make('modifica')
->label('Modifica')
->hiddenLabel()
->icon('heroicon-o-pencil-square')
->tooltip('Modifica')
->modalHeading('Modifica voce di spesa')
->form([
Select::make('tabella_millesimale_default_id')
@ -1279,8 +1314,9 @@ public function table(Table $table): Table
$this->dispatch('$refresh');
}),
Action::make('assegnaTabella')
->label('Tabella')
->hiddenLabel()
->icon('heroicon-o-rectangle-stack')
->tooltip('Tabella')
->modalHeading('Imposta tabella di riferimento')
->form([
Select::make('tabella_millesimale_default_id')
@ -1303,8 +1339,8 @@ public function table(Table $table): Table
$this->dispatch('$refresh');
}),
])
->paginationPageOptions([16, 32, 64, 100])
->defaultPaginationPageOption(32)
->paginationPageOptions([24, 48, 96, 150])
->defaultPaginationPageOption(48)
->bulkActions([
BulkAction::make('bulkAssegnaTabella')
->label('Assegna tabella')

View File

@ -3,6 +3,7 @@
use App\Models\User;
use App\Models\UserSetting;
use App\Services\Contabilita\PreventivoOrdinarioPreviewService;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
@ -65,6 +66,16 @@ public static function canAccess(): bool
public array $mastrinoRows = [];
public array $acquaRipartoRows = [];
public array $preventivoEditorRows = [];
public array $preventivoSummaryRows = [];
public array $preventivoTabellaOptions = [];
public array $ripartoPreviewColumns = [];
public array $ripartoPreviewRows = [];
public array $ripartoRateMonths = [];
public array $ripartoFocusBreakdown = [];
public array $personeCollegateAudit = [];
public array $preventivoGestioneMeta = [];
public ?int $ripartoFocusUnitId = null;
public function mount(): void
{
@ -74,7 +85,7 @@ public function mount(): void
}
$tab = request()->query('tab');
if (in_array($tab, ['operazioni', 'consuntivo', 'straordinarie', 'acqua', 'incassi', 'fatture'], true)) {
if (in_array($tab, ['operazioni', 'preventivo', 'riparto', 'persone', 'consuntivo', 'straordinarie', 'acqua', 'incassi', 'fatture'], true)) {
$this->viewTab = $tab;
}
@ -93,6 +104,183 @@ public function mount(): void
$this->sortField = 'dt_spe';
$this->sortDirection = 'asc';
}
$this->hydratePreventivoWorkspace();
}
public function updatedFilterAnno(): void
{
$this->hydratePreventivoWorkspace();
}
public function updatedRipartoFocusUnitId(): void
{
$this->applyFocusRipartoBreakdown();
}
public function savePreventivoOrdinario(): void
{
$activeStabileId = StabileContext::resolveActiveStabileId(Auth::user());
$gestioneId = (int) ($this->preventivoGestioneMeta['id'] ?? 0);
if (! $activeStabileId || ! $gestioneId || ! Schema::hasTable('voci_spesa')) {
Notification::make()
->title('Preventivo non disponibile')
->body('Manca una gestione ordinaria attiva o la struttura dati delle voci spesa.')
->danger()
->send();
return;
}
DB::transaction(function (): void {
foreach ($this->preventivoEditorRows as $row) {
$payload = [
'importo_default' => is_numeric($row['importo_default'] ?? null) ? round((float) $row['importo_default'], 2) : 0.0,
'importo_consuntivo' => is_numeric($row['importo_consuntivo'] ?? null) ? round((float) $row['importo_consuntivo'], 2) : 0.0,
'percentuale_inquilino' => is_numeric($row['percentuale_inquilino'] ?? null) ? round((float) $row['percentuale_inquilino'], 2) : 0.0,
'percentuale_condomino' => max(0, 100 - (is_numeric($row['percentuale_inquilino'] ?? null) ? round((float) $row['percentuale_inquilino'], 2) : 0.0)),
'tabella_millesimale_default_id' => ! empty($row['tabella_millesimale_default_id']) ? (int) $row['tabella_millesimale_default_id'] : null,
];
DB::table('voci_spesa')
->where('id', (int) ($row['id'] ?? 0))
->where('stabile_id', (int) $activeStabileId)
->where('gestione_contabile_id', $gestioneId)
->update($payload);
}
});
$this->hydratePreventivoWorkspace();
Notification::make()
->title('Preventivo aggiornato')
->body('Le voci della gestione ordinaria sono state salvate e il riparto è stato ricalcolato.')
->success()
->send();
}
protected function hydratePreventivoWorkspace(): void
{
$this->preventivoEditorRows = [];
$this->preventivoSummaryRows = [];
$this->preventivoTabellaOptions = [];
$this->ripartoPreviewColumns = [];
$this->ripartoPreviewRows = [];
$this->ripartoRateMonths = [];
$this->ripartoFocusBreakdown = [];
$this->personeCollegateAudit = [];
$this->preventivoGestioneMeta = [];
$activeStabileId = StabileContext::resolveActiveStabileId(Auth::user());
if (! $activeStabileId || ! $this->filterAnno) {
return;
}
$service = app(PreventivoOrdinarioPreviewService::class);
$payload = $service->build((int) $activeStabileId, (int) $this->filterAnno);
$this->preventivoGestioneMeta = $payload['gestione'] ?? [];
$this->preventivoEditorRows = $payload['preventivo_rows'] ?? [];
$this->preventivoSummaryRows = $payload['preventivo_summary'] ?? [];
$this->preventivoTabellaOptions = $payload['tabella_options'] ?? [];
$this->ripartoPreviewColumns = $payload['riparto_columns'] ?? [];
$this->ripartoPreviewRows = $payload['riparto_rows'] ?? [];
$this->ripartoRateMonths = $payload['mesi_rate'] ?? [];
$this->personeCollegateAudit = $payload['people_audit'] ?? [];
$focusId = $payload['focus_unit_id'] ?? null;
if ($this->ripartoFocusUnitId === null || ! collect($this->ripartoPreviewRows)->contains(fn(array $row): bool => (int) $row['unit_id'] === (int) $this->ripartoFocusUnitId)) {
$this->ripartoFocusUnitId = $focusId ? (int) $focusId : null;
}
$this->applyFocusRipartoBreakdown($payload['focus_breakdown'] ?? []);
}
protected function applyFocusRipartoBreakdown(array $fallback = []): void
{
if ($this->ripartoFocusUnitId === null) {
$this->ripartoFocusBreakdown = [];
return;
}
if ($fallback !== []) {
$this->ripartoFocusBreakdown = $fallback;
return;
}
$row = collect($this->ripartoPreviewRows)->firstWhere('unit_id', (int) $this->ripartoFocusUnitId);
if (! is_array($row)) {
$this->ripartoFocusBreakdown = [];
return;
}
$subjects = [];
$owners = $this->expandRipartoSubjects($row, 'owners');
$tenants = $this->expandRipartoSubjects($row, 'tenants');
foreach ($owners as $owner) {
$subjects[] = $owner;
}
foreach ($tenants as $tenant) {
$subjects[] = $tenant;
}
$this->ripartoFocusBreakdown = $subjects;
}
protected function expandRipartoSubjects(array $row, string $bucket): array
{
$source = $bucket === 'owners' ? ($row['owners'] ?? []) : ($row['tenants'] ?? []);
$pieces = [];
if (is_array($source) && $source !== []) {
foreach ($source as $item) {
$pieces[] = [
'name' => (string) ($item['name'] ?? 'Soggetto'),
'quota' => is_numeric($item['quota'] ?? null) ? (float) $item['quota'] : null,
];
}
}
if ($pieces === []) {
$summary = (string) ($row['people_summary'] ?? '');
$names = $bucket === 'owners'
? preg_split('/\s*,\s*/', preg_replace('/^Prop:\s*/', '', preg_match('/Prop:\s*([^·]+)/', $summary, $m) ? $m[1] : ''), -1, PREG_SPLIT_NO_EMPTY)
: preg_split('/\s*,\s*/', preg_replace('/^Inq:\s*/', '', preg_match('/Inq:\s*(.+)$/', $summary, $m) ? $m[1] : ''), -1, PREG_SPLIT_NO_EMPTY);
$pieces = array_map(fn(string $name): array=> ['name' => $name, 'quota' => null], $names ?: []);
}
if ($pieces === []) {
return [];
}
$count = count($pieces);
$declared = collect($pieces)->sum(fn(array $item): float => is_numeric($item['quota'] ?? null) ? (float) $item['quota'] : 0.0);
$baseKey = $bucket === 'owners' ? 'totale_condomini' : 'totale_inquilini';
$rows = [];
foreach ($pieces as $item) {
$share = $declared > 0 ? (((float) ($item['quota'] ?? 0.0)) / $declared) : (1 / max($count, 1));
$columns = [];
$totale = 0.0;
foreach (($row['table_breakdown'] ?? []) as $code => $values) {
$base = (float) ($values[$bucket === 'owners' ? 'condomini' : 'inquilini'] ?? 0.0);
$quota = $base * $share;
$columns[$code] = $quota;
$totale += $quota;
}
$rows[] = [
'name' => $item['name'],
'role' => $bucket === 'owners' ? 'Condomino' : 'Inquilino',
'quota_percent' => $share * 100,
'columns' => $columns,
'totale' => $totale > 0 ? $totale : ((float) ($row[$baseKey] ?? 0.0) * $share),
];
}
return $rows;
}
public function getStraordinarieGestioniProperty(): array
@ -267,7 +455,7 @@ public function getAcquaSummaryProperty(): array
->where('cod_tab', 'ACQUA')
->when($legacyCode, fn($q) => $q->where('cod_stabile', $legacyCode))
->when($legacyYear, fn($q) => $q->where('legacy_year', $legacyYear))
->sum(DB::raw('COALESCE(cons_euro, 0)'));
->sum(DB::raw('COALESCE(prev_euro, 0)'));
$totaleFatture = array_sum($byCode);
@ -319,7 +507,7 @@ public function getAcquaRipartoProperty(): array
$ripartoRows = $ripartoQuery
->orderBy('id_cond')
->orderBy('cond_inquil')
->get(['id_cond', 'cond_inquil', 'cons_euro']);
->get(['id_cond', 'cond_inquil', 'prev_euro']);
$out = [];
$totale = 0.0;
@ -349,7 +537,7 @@ public function getAcquaRipartoProperty(): array
$nominativo = '—';
}
$val = (float) ($r->cons_euro ?? 0);
$val = (float) ($r->prev_euro ?? 0);
$totale += $val;
$out[] = [
'id_cond' => $r->id_cond,
@ -358,13 +546,35 @@ public function getAcquaRipartoProperty(): array
'scala' => $cond->scala ?? null,
'interno' => $cond->interno ?? null,
'piano' => $cond->piano ?? null,
'cons_euro' => $val,
'prev_euro' => $val,
];
}
usort($out, function (array $left, array $right): int {
return strnatcasecmp($this->buildAcquaRipartoSortKey($left), $this->buildAcquaRipartoSortKey($right));
});
return ['rows' => $out, 'totale' => $totale];
}
private function buildAcquaRipartoSortKey(array $row): string
{
return implode('|', [
$this->normalizeAcquaNaturalToken((string) ($row['scala'] ?? '')),
$this->normalizeAcquaNaturalToken((string) ($row['interno'] ?? '')),
$this->normalizeAcquaNaturalToken((string) ($row['piano'] ?? '')),
$this->normalizeAcquaNaturalToken((string) ($row['nominativo'] ?? '')),
str_pad((string) ((int) ($row['id_cond'] ?? 0)), 10, '0', STR_PAD_LEFT),
]);
}
private function normalizeAcquaNaturalToken(string $value): string
{
$normalized = strtoupper(trim($value));
return preg_replace('/[\s\.\-\/]+/', '', $normalized) ?? '';
}
public ?int $dettPersNSpe = null;
public array $dettPersRows = [];
public float $dettPersTotale = 0.0;

View File

@ -52,6 +52,7 @@ class PostIt extends Page
public ?int $assegnazioneFornitoreId = null;
public ?int $selectedSuggerimentoId = null;
public bool $disableAutoStabileAssociation = false;
/** @var \Illuminate\Support\Collection<int, RubricaUniversale> */
public $suggerimentiRubrica;
@ -65,6 +66,8 @@ public function mount(): void
$focus = (int) request()->query('focus_post_it', 0);
$this->focusPostItId = $focus > 0 ? $focus : null;
$this->selectedPostItId = $this->focusPostItId;
$this->applyQueryPrefill();
}
public function getGestionePostItUrl(): string
@ -201,10 +204,12 @@ public function updatedTelefono(?string $value): void
$this->nomeChiamante = $match->nome_completo ?: $match->ragione_sociale;
}
if (! $this->disableAutoStabileAssociation) {
$stabile = Stabile::query()->where('rubrica_id', $match->id)->first();
if ($stabile) {
$this->stabileId = $stabile->id;
}
}
if (blank($this->ricercaChiamante)) {
$this->ricercaChiamante = $match->nome_completo ?: $match->ragione_sociale;
@ -228,8 +233,10 @@ public function selezionaSuggerimentoRubrica(int $rubricaId): void
$this->telefono = $match->telefono_cellulare ?: ($match->telefono_ufficio ?: $match->telefono_casa);
$this->ricercaChiamante = $match->nome_completo ?: ($match->ragione_sociale ?: '');
if (! $this->disableAutoStabileAssociation) {
$stabile = Stabile::query()->where('rubrica_id', $match->id)->first();
$this->stabileId = $stabile?->id;
}
$this->suggerimentiRubrica = $this->searchRubrica((string) $this->ricercaChiamante);
}
@ -393,15 +400,38 @@ public function getRecentiProperty()
$items = ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->orderByDesc('chiamata_il')
->limit(25)
->limit(60)
->get();
if (! $this->selectedPostItId && $items->isNotEmpty()) {
$this->selectedPostItId = (int) ($this->focusPostItId ?: $items->first()->id);
$grouped = $items
->groupBy(fn(ChiamataPostIt $item): string => $this->buildRecenteGroupKey($item))
->map(function (Collection $group): array {
/** @var ChiamataPostIt $primary */
$primary = $group->first();
$callerLabel = $this->resolveRecenteCallerLabel($group);
$phoneLabel = $this->resolveRecentePhoneLabel($group);
return [
'primary' => $primary,
'items' => $group->values(),
'count' => $group->count(),
'caller_label' => $callerLabel,
'phone' => $phoneLabel,
'latest_at' => optional($primary->chiamata_il)->format('d/m/Y H:i'),
'contains_selected' => $this->selectedPostItId
? $group->contains(fn(ChiamataPostIt $item): bool => (int) $item->id === (int) $this->selectedPostItId)
: false,
];
})
->values();
if (! $this->selectedPostItId && $grouped->isNotEmpty()) {
$first = $grouped->first();
$this->selectedPostItId = (int) ($this->focusPostItId ?: ($first['primary']->id ?? 0));
$this->loadSelectedPostItAssignment();
}
return $items;
return $grouped;
} catch (QueryException) {
return collect();
}
@ -518,6 +548,181 @@ private function createPostItRecord(): ChiamataPostIt
]);
}
private function applyQueryPrefill(): void
{
$source = trim((string) request()->query('source', ''));
$this->disableAutoStabileAssociation = request()->boolean('lock_stabile') || $source === 'topbar_live_call';
$telefono = trim((string) request()->query('telefono', ''));
$nome = trim((string) request()->query('nome', ''));
$nota = trim((string) request()->query('nota', ''));
$oggetto = trim((string) request()->query('oggetto', ''));
$direzione = trim((string) request()->query('direzione', ''));
$rubricaId = (int) request()->query('rubrica_id', 0);
if ($telefono !== '') {
$this->telefono = $telefono;
}
if ($nome !== '') {
$this->nomeChiamante = $nome;
$this->ricercaChiamante = $nome;
}
if ($nota !== '') {
$this->nota = $nota;
}
if ($oggetto !== '') {
$this->oggetto = $oggetto;
}
if (in_array($direzione, ['in_arrivo', 'in_uscita', 'persa'], true)) {
$this->direzione = $direzione;
}
if ($rubricaId > 0) {
$rubrica = RubricaUniversale::query()->find($rubricaId);
if ($rubrica) {
$this->rubricaId = (int) $rubrica->id;
if ($this->nomeChiamante === null || trim((string) $this->nomeChiamante) === '') {
$this->nomeChiamante = $rubrica->nome_completo ?: $rubrica->ragione_sociale;
}
}
}
if ($this->disableAutoStabileAssociation) {
$this->stabileId = null;
}
if ($source === 'topbar_live_call' && $telefono !== '') {
$this->hydrateRubricaForPhonePrefill($telefono, $nome);
if ($this->nota === null || trim((string) $this->nota) === '') {
$this->nota = 'Chiamata in ingresso da ' . $telefono;
}
if ($this->oggetto === null || trim((string) $this->oggetto) === '') {
$this->oggetto = 'Chiamata in arrivo da valutare';
}
}
}
private function hydrateRubricaForPhonePrefill(string $phone, string $name = ''): void
{
$match = $this->findRubricaByPhone($phone);
if (! $match && $this->canCreateRubricaDraftFromPhone($phone)) {
$match = $this->createRubricaDraftFromPhone($phone, $name);
}
if (! $match) {
return;
}
$this->rubricaId = (int) $match->id;
if ($this->nomeChiamante === null || trim((string) $this->nomeChiamante) === '') {
$this->nomeChiamante = $match->nome_completo ?: $match->ragione_sociale;
}
if ($this->ricercaChiamante === null || trim((string) $this->ricercaChiamante) === '') {
$this->ricercaChiamante = $match->nome_completo ?: ($match->ragione_sociale ?: '');
}
}
private function canCreateRubricaDraftFromPhone(string $phone): bool
{
$digits = preg_replace('/\D+/', '', $phone) ?: '';
return strlen($digits) >= 6;
}
private function createRubricaDraftFromPhone(string $phone, string $name = ''): ?RubricaUniversale
{
$digits = preg_replace('/\D+/', '', $phone) ?: '';
if ($digits === '') {
return null;
}
$normalizedPhone = PhoneNumber::toE164Italy($phone) ?? $digits;
$adminId = $this->resolveCurrentAmministratoreId();
$cleanName = trim($name);
$payload = [
'amministratore_id' => $adminId > 0 ? $adminId : null,
'tipo_contatto' => str_contains($cleanName, ' ') ? 'persona_fisica' : 'persona_giuridica',
'telefono_cellulare' => $normalizedPhone,
'categoria' => 'altro',
'stato' => 'attivo',
'data_inserimento' => now()->toDateString(),
'data_ultima_modifica' => now()->toDateString(),
'creato_da' => Auth::id(),
'modificato_da' => Auth::id(),
'note_segreteria' => 'Bozza creata dal box Post-it in topbar per una chiamata in ingresso.',
];
if ($cleanName !== '' && str_contains($cleanName, ' ')) {
$parts = preg_split('/\s+/', $cleanName) ?: [];
$payload['nome'] = array_shift($parts) ?: null;
$payload['cognome'] = $parts !== [] ? implode(' ', $parts) : null;
} elseif ($cleanName !== '') {
$payload['ragione_sociale'] = $cleanName;
} else {
$payload['ragione_sociale'] = 'Contatto ' . $digits;
}
return RubricaUniversale::query()->create($payload);
}
private function buildRecenteGroupKey(ChiamataPostIt $item): string
{
$phone = preg_replace('/\D+/', '', (string) ($item->telefono ?? '')) ?: '';
if ($phone !== '') {
return 'phone:' . $phone;
}
if ((int) ($item->rubrica_id ?? 0) > 0) {
return 'rubrica:' . (int) $item->rubrica_id;
}
return 'caller:' . mb_strtolower(trim((string) ($item->nome_chiamante ?? 'sconosciuto')));
}
private function resolveRecenteCallerLabel(Collection $group): string
{
foreach ($group as $item) {
if ($item instanceof ChiamataPostIt && $item->rubrica) {
return $item->rubrica->nome_completo ?: ($item->rubrica->ragione_sociale ?: 'Contatto');
}
}
foreach ($group as $item) {
$label = trim((string) ($item->nome_chiamante ?? ''));
if ($label !== '' && ! $this->isGenericRecenteCallerLabel($label, (string) ($item->telefono ?? ''))) {
return $label;
}
}
return 'Contatto non in Rubrica';
}
private function resolveRecentePhoneLabel(Collection $group): string
{
foreach ($group as $item) {
$phone = trim((string) ($item->telefono ?? ''));
if ($phone !== '') {
return $phone;
}
}
return '';
}
private function isGenericRecenteCallerLabel(string $label, string $phone = ''): bool
{
$normalizedLabel = mb_strtolower(trim($label));
if ($normalizedLabel === '' || in_array($normalizedLabel, ['chiamante non identificato', 'contatto non in rubrica'], true)) {
return true;
}
$labelDigits = preg_replace('/\D+/', '', $label) ?: '';
$phoneDigits = preg_replace('/\D+/', '', $phone) ?: '';
return $labelDigits !== '' && $phoneDigits !== '' && $labelDigits === $phoneDigits;
}
private function loadSelectedPostItAssignment(): void
{
$postIt = $this->selectedPostIt;

View File

@ -57,6 +57,9 @@ class TicketAcqua extends Page
/** @var array<int,mixed> */
public array $pendingWaterPhotos = [];
/** @var array<int,mixed> */
public array $pendingWaterLibraryPhotos = [];
/** @var array<int,string> */
public array $waterPhotoDescriptions = [];
@ -151,6 +154,11 @@ public function updatedPendingWaterPhotos(): void
$this->appendPendingWaterPhotos();
}
public function updatedPendingWaterLibraryPhotos(): void
{
$this->appendPendingWaterLibraryPhotos();
}
public function removeWaterPhoto(int $index): void
{
$file = $this->newWaterPhotos[$index] ?? null;
@ -648,10 +656,39 @@ private function appendPendingWaterPhotos(): void
$this->syncWaterDescriptionSlots();
}
private function appendPendingWaterLibraryPhotos(): void
{
$pending = array_values(array_filter($this->pendingWaterLibraryPhotos, fn($file) => is_object($file)));
if ($pending === []) {
return;
}
$available = max(0, 4 - count($this->newWaterPhotos));
if ($available <= 0) {
$this->pendingWaterLibraryPhotos = [];
Notification::make()
->title('Limite foto raggiunto')
->body('Puoi tenere in coda al massimo 4 foto per una lettura acqua.')
->warning()
->send();
return;
}
$accepted = array_slice($pending, 0, $available);
$currentTotal = count($this->newWaterPhotos);
$this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted));
$this->pendingWaterLibraryPhotos = [];
$this->assignDraftUploadCodes($accepted, $currentTotal);
$this->syncWaterDescriptionSlots();
}
private function resetWaterDraftUploadState(): void
{
$this->newWaterPhotos = [];
$this->pendingWaterPhotos = [];
$this->pendingWaterLibraryPhotos = [];
$this->waterPhotoDescriptions = [];
$this->waterUploadCodes = [];
$this->waterClientMetadata = [];

View File

@ -3,11 +3,13 @@
namespace App\Livewire\Condomini;
use App\Filament\Pages\Contabilita\CasseBancheMovimenti;
use App\Filament\Pages\Contabilita\SaldiContiArchivio;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\DatiBancari;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Models\User;
use App\Modules\Contabilita\Models\SaldoConto;
use App\Services\GesconImport\StabileEnrichmentService;
use App\Support\StabileContext;
use Filament\Actions\Action;
@ -93,6 +95,18 @@ public function table(Table $table): Table
->date('d/m/Y')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('saldo_ufficiale')
->label('Saldo ufficiale')
->stateUsing(fn(DatiBancari $record): ?float => $this->resolveLatestSaldoSnapshotValue($record))
->money('EUR')
->toggleable(),
TextColumn::make('ultimo_estratto')
->label('Ultimo estratto')
->stateUsing(fn(DatiBancari $record): string => $this->resolveLatestSaldoSnapshotLabel($record))
->wrap()
->toggleable(),
TextColumn::make('contatto.nome_completo')
->label('Contatto')
->wrap()
@ -198,6 +212,12 @@ public function table(Table $table): Table
})
->openUrlInNewTab(),
Action::make('saldi')
->label('Saldi')
->icon('heroicon-o-scale')
->url(fn(): string => SaldiContiArchivio::getUrl(panel: 'admin-filament'))
->openUrlInNewTab(),
Action::make('contatto')
->label('Contatto')
->icon('heroicon-o-identification')
@ -419,4 +439,59 @@ private function normalizePayload(array $data): array
'note' => isset($data['note']) ? trim((string) $data['note']) : null,
];
}
private function resolveLatestSaldoSnapshot(DatiBancari $record): ?SaldoConto
{
static $cache = [];
$cacheKey = (int) $record->id;
if (array_key_exists($cacheKey, $cache)) {
return $cache[$cacheKey];
}
return $cache[$cacheKey] = SaldoConto::query()
->with('documento')
->where('stabile_id', (int) $record->stabile_id)
->where('conto_id', (int) $record->id)
->orderByDesc('data_saldo')
->orderByDesc('id')
->first();
}
private function resolveLatestSaldoSnapshotValue(DatiBancari $record): ?float
{
$snapshot = $this->resolveLatestSaldoSnapshot($record);
return $snapshot ? (float) $snapshot->saldo : null;
}
private function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string
{
$snapshot = $this->resolveLatestSaldoSnapshot($record);
if (! $snapshot) {
return 'Nessun estratto registrato';
}
$parts = [];
if ($snapshot->tipo_estratto) {
$parts[] = match ($snapshot->tipo_estratto) {
'estratto_conto' => 'Estratto conto',
'saldo_banca' => 'Saldo banca',
'riconciliazione' => 'Riconciliazione',
default => 'Altro',
};
}
if ($snapshot->periodo_da || $snapshot->periodo_a) {
$from = $snapshot->periodo_da?->format('d/m/Y') ?: '...';
$to = $snapshot->periodo_a?->format('d/m/Y') ?: '...';
$parts[] = $from . ' - ' . $to;
} else {
$parts[] = $snapshot->data_saldo?->format('d/m/Y') ?: 'Data non indicata';
}
if ($snapshot->documento?->nome_originale) {
$parts[] = $snapshot->documento->nome_originale;
}
return implode(' · ', $parts);
}
}

View File

@ -1,12 +1,9 @@
<?php
namespace App\Livewire\Filament;
use App\Filament\Pages\Strumenti\PostItGestione;
use App\Models\ChiamataPostIt;
use App\Filament\Pages\Strumenti\PostIt;
use App\Models\User;
use App\Services\Cti\LiveIncomingCallService;
use App\Support\StabileContext;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@ -38,34 +35,16 @@ public function createPostIt(): void
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Seleziona prima uno stabile attivo')->warning()->send();
return;
}
$postIt = ChiamataPostIt::query()->create([
'stabile_id' => (int) $stabileId,
'creato_da_user_id' => (int) $user->id,
'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null,
$this->redirect(
PostIt::getUrl([
'source' => 'topbar_live_call',
'telefono' => (string) ($this->liveIncomingCall['phone'] ?? ''),
'nome_chiamante' => (string) (($this->liveIncomingCall['rubrica_nome'] ?? '') ?: ('Chiamata ' . ($this->liveIncomingCall['phone'] ?? ''))),
'nome' => (string) ($this->liveIncomingCall['rubrica_nome'] ?? ''),
'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null,
'direzione' => 'in_arrivo',
'origine' => 'topbar_live_call',
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
'oggetto' => 'Chiamata in arrivo da valutare',
'nota' => (string) ($this->liveIncomingCall['line'] ?? ('Chiamata in ingresso da ' . ($this->liveIncomingCall['phone'] ?? ''))),
'priorita' => 'Media',
'stato' => 'post_it',
'chiamata_il' => now(),
]);
Notification::make()->title('Post-it creato dalla testata')->success()->send();
$this->redirect(
PostItGestione::getUrl([
'tab' => 'storico',
'focus_post_it' => (int) $postIt->id,
'lock_stabile' => 1,
], panel: 'admin-filament'),
navigate: true,
);

View File

@ -170,7 +170,11 @@ public function getStatoScadenzaAttribute()
*/
public function getUrlDownloadAttribute()
{
return route('admin.documenti.download', $this->id);
if (is_string($this->percorso_file) && trim($this->percorso_file) !== '' && Storage::disk('public')->exists($this->percorso_file)) {
return Storage::disk('public')->url($this->percorso_file);
}
return null;
}
/**
@ -178,7 +182,7 @@ public function getUrlDownloadAttribute()
*/
public function getUrlViewAttribute()
{
return route('admin.documenti.view', $this->id);
return $this->url_download;
}
/**
@ -195,7 +199,9 @@ public function incrementaDownload()
*/
public function fileEsiste()
{
return Storage::exists($this->percorso_file);
return is_string($this->percorso_file)
&& trim($this->percorso_file) !== ''
&& (Storage::disk('public')->exists($this->percorso_file) || Storage::disk('local')->exists($this->percorso_file));
}
/**
@ -203,9 +209,18 @@ public function fileEsiste()
*/
public function eliminaFile()
{
if ($this->fileEsiste()) {
return Storage::delete($this->percorso_file);
if (! is_string($this->percorso_file) || trim($this->percorso_file) === '') {
return true;
}
if (Storage::disk('public')->exists($this->percorso_file)) {
return Storage::disk('public')->delete($this->percorso_file);
}
if (Storage::disk('local')->exists($this->percorso_file)) {
return Storage::disk('local')->delete($this->percorso_file);
}
return true;
}

View File

@ -3,6 +3,7 @@
namespace App\Modules\Contabilita\Models;
use App\Models\DatiBancari;
use App\Models\DocumentoStabile;
use Illuminate\Database\Eloquent\Model;
class SaldoConto extends Model
@ -14,14 +15,20 @@ class SaldoConto extends Model
'conto_id',
'iban',
'data_saldo',
'periodo_da',
'periodo_a',
'saldo',
'tipo_estratto',
'note',
'documento_stabile_id',
'created_by',
'updated_by',
];
protected $casts = [
'data_saldo' => 'date',
'periodo_da' => 'date',
'periodo_a' => 'date',
'saldo' => 'decimal:2',
];
@ -29,4 +36,9 @@ public function conto()
{
return $this->belongsTo(DatiBancari::class, 'conto_id');
}
public function documento()
{
return $this->belongsTo(DocumentoStabile::class, 'documento_stabile_id');
}
}

View File

@ -1,10 +1,9 @@
<?php
namespace App\Services\Contabilita;
use App\Models\DatiBancari;
use App\Models\RubricaUniversale;
use App\Models\Fornitore;
use App\Models\RubricaUniversale;
use App\Modules\Contabilita\Models\MovimentoBanca;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@ -61,7 +60,10 @@ public function importUnicreditSemicolonCsvForConto(
$max = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
if (! $d) {
continue;
}
$min = $min ? ($d->lt($min) ? $d : $min) : $d;
$max = $max ? ($d->gt($max) ? $d : $max) : $d;
}
@ -373,7 +375,10 @@ private function importParsedRowsForConto(
$max = null;
foreach ($rows as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
if (! $d) {
continue;
}
$min = $min ? ($d->lt($min) ? $d : $min) : $d;
$max = $max ? ($d->gt($max) ? $d : $max) : $d;
}
@ -386,7 +391,10 @@ private function importParsedRowsForConto(
$maxDate = null;
foreach ($rows as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
if (! $d) {
continue;
}
$minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d;
$maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d;
}
@ -528,7 +536,7 @@ public function importUnicreditWri(
$duplicates = 0;
DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, &$imported, &$duplicates) {
if ($replace && !empty($parsed['rows'])) {
if ($replace && ! empty($parsed['rows'])) {
$minDate = null;
$maxDate = null;
foreach ($parsed['rows'] as $r) {
@ -763,7 +771,10 @@ public function importExcelXlsxForConto(
$max = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
if (! $d) {
continue;
}
$min = $min ? ($d->lt($min) ? $d : $min) : $d;
$max = $max ? ($d->gt($max) ? $d : $max) : $d;
}
@ -930,7 +941,9 @@ private function parseDescrizioneEstesa(string $text): array
{
$res = [];
$text = trim(preg_replace('/\s+/', ' ', $text));
if ($text === '') return $res;
if ($text === '') {
return $res;
}
if (preg_match('/COD\.\s*DISP\.:\s*([A-Z0-9]+)/i', $text, $m)) {
$res['cod_disp'] = trim($m[1]);
@ -991,7 +1004,10 @@ private function parseDescrizioneEstesa(string $text): array
private function matchRubricaId(string $name): ?int
{
$term = trim($name);
if ($term === '') return null;
if ($term === '') {
return null;
}
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$rows = RubricaUniversale::query()
@ -1008,7 +1024,10 @@ private function matchRubricaId(string $name): ?int
private function matchFornitoreId(string $name): ?int
{
$term = trim($name);
if ($term === '') return null;
if ($term === '') {
return null;
}
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$rows = Fornitore::query()

View File

@ -1,5 +1,4 @@
<?php
namespace App\Services\Contabilita;
use Carbon\Carbon;

View File

@ -0,0 +1,761 @@
<?php
namespace App\Services\Contabilita;
use App\Models\PersonaUnitaRelazione;
use App\Models\UnitaImmobiliareNominativo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class PreventivoOrdinarioPreviewService
{
public function build(int $stabileId, int $annoGestione, ?int $gestioneId = null): array
{
$gestione = $this->resolveGestione($stabileId, $annoGestione, $gestioneId);
$gestioneId = $gestione?->id ? (int) $gestione->id : $gestioneId;
if (! $gestioneId) {
return [
'gestione' => [
'id' => null,
'anno' => $annoGestione,
'denominazione' => (string) ($gestione?->denominazione ?? ''),
],
'mesi_rate' => [],
'preventivo_rows' => [],
'preventivo_summary' => [],
'tabella_options' => [],
'riparto_columns' => [],
'riparto_rows' => [],
'focus_unit_id' => null,
'focus_breakdown' => [],
'people_audit' => [],
];
}
$mesiRate = $this->normalizeMonths($gestione?->mesi_rate_ordinaria ?? null);
$voci = $this->loadVoci($stabileId, $gestioneId);
$tabellaOptions = $this->buildTabellaOptions($stabileId, $annoGestione);
$tabellaMap = $tabellaOptions['map'];
$summaryByTable = $this->buildTableSummary($voci, $tabellaMap);
$unitaRows = $this->loadUnitaRows($stabileId);
$detailRows = $this->loadDetailRows($stabileId, $annoGestione);
$peopleMap = $this->loadPeopleMap($stabileId, $unitaRows->pluck('id')->all());
$matrix = $this->buildMatrixRows($unitaRows, $summaryByTable, $detailRows, $peopleMap, $mesiRate);
$focusUnitId = $this->resolveFocusUnitId($matrix);
$focusBreakdown = $this->buildFocusBreakdown($matrix, $focusUnitId);
return [
'gestione' => [
'id' => $gestioneId,
'anno' => $annoGestione,
'denominazione' => (string) ($gestione?->denominazione ?? ''),
],
'mesi_rate' => $mesiRate,
'preventivo_rows' => $voci->map(function (array $row) use ($tabellaMap): array {
$tabella = $tabellaMap[$row['tabella_millesimale_default_id'] ?? 0] ?? null;
return [
'id' => $row['id'],
'codice' => $row['codice'],
'descrizione' => $row['descrizione'],
'tabella_millesimale_default_id' => $row['tabella_millesimale_default_id'],
'tabella_codice' => $tabella['codice'] ?? '',
'tabella_nome' => $tabella['label'] ?? '',
'importo_default' => $row['importo_default'],
'importo_consuntivo' => $row['importo_consuntivo'],
'percentuale_inquilino' => $row['percentuale_inquilino'],
'percentuale_condomino' => $row['percentuale_condomino'],
'conto_pd' => $row['conto_pd'],
'sottoconto_pd' => $row['sottoconto_pd'],
];
})->all(),
'preventivo_summary' => array_values($summaryByTable),
'tabella_options' => $tabellaOptions['options'],
'riparto_columns' => array_values(array_map(fn(array $table): array=> [
'codice' => $table['codice'],
'label' => $table['codice'],
'descrizione' => $table['label'],
], $summaryByTable)),
'riparto_rows' => $matrix->values()->all(),
'focus_unit_id' => $focusUnitId,
'focus_breakdown' => $focusBreakdown,
'people_audit' => $this->buildPeopleAudit($matrix, $peopleMap),
];
}
private function resolveGestione(int $stabileId, int $annoGestione, ?int $gestioneId): ?object
{
if (! Schema::hasTable('gestioni_contabili')) {
return null;
}
$query = DB::table('gestioni_contabili')
->where('stabile_id', $stabileId)
->where('tipo_gestione', 'ordinaria');
if ($gestioneId) {
$query->where('id', $gestioneId);
} else {
$query->where('anno_gestione', $annoGestione)
->orderByDesc('gestione_attiva')
->orderByDesc('id');
}
return $query->first();
}
private function normalizeMonths(mixed $raw): array
{
$months = [];
if (is_array($raw)) {
$months = $raw;
} elseif (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$months = $decoded;
}
}
$months = collect($months)
->map(fn($month) => (int) $month)
->filter(fn(int $month) => $month >= 1 && $month <= 12)
->unique()
->sort()
->values()
->all();
return array_map(fn(int $month): array=> [
'month' => $month,
'label' => $this->monthLabel($month),
], $months);
}
private function buildTabellaOptions(int $stabileId, int $annoGestione): array
{
$query = DB::table('tabelle_millesimali')
->where('stabile_id', $stabileId);
if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) {
$query->where('anno_gestione', $annoGestione);
}
$rows = $query
->orderByRaw('COALESCE(nord, ordine_visualizzazione, ordinamento, 999999)')
->orderBy('codice_tabella')
->get([
'id',
'codice_tabella',
'denominazione',
'totale_millesimi',
'tipo_calcolo',
'meta_legacy',
'nord',
'ordine_visualizzazione',
'ordinamento',
]);
$options = [];
$map = [];
foreach ($rows as $row) {
$metaLegacy = $this->decodeJson($row->meta_legacy ?? null);
$code = trim((string) ($row->codice_tabella ?? ''));
$label = trim((string) ($row->denominazione ?? ''));
$display = $code !== '' ? $code . ' · ' . ($label !== '' ? $label : $code) : ($label !== '' ? $label : 'Tabella');
$nord = null;
foreach ([$row->nord ?? null, $row->ordine_visualizzazione ?? null, $row->ordinamento ?? null] as $orderValue) {
if (is_numeric($orderValue)) {
$nord = (int) $orderValue;
break;
}
}
$options[(int) $row->id] = $display;
$map[(int) $row->id] = [
'id' => (int) $row->id,
'codice' => $code,
'label' => $label !== '' ? $label : $code,
'totale_millesimi' => is_numeric($row->totale_millesimi ?? null) ? (float) $row->totale_millesimi : 0.0,
'tipo_calcolo' => $row->tipo_calcolo,
'legacy_tipo' => $metaLegacy['tipo'] ?? null,
'nord' => $nord,
];
}
return ['options' => $options, 'map' => $map];
}
private function loadVoci(int $stabileId, ?int $gestioneId): Collection
{
if (! Schema::hasTable('voci_spesa')) {
return collect();
}
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $gestioneId) {
return collect();
}
$query = DB::table('voci_spesa')->where('stabile_id', $stabileId);
if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && $gestioneId) {
$query->where('gestione_contabile_id', $gestioneId);
}
if (Schema::hasColumn('voci_spesa', 'tipo_gestione')) {
$query->where('tipo_gestione', 'ordinaria');
}
$rows = $query
->orderByRaw('COALESCE(nord, ordinamento, 999999)')
->orderBy('codice')
->get([
'id',
'codice',
'descrizione',
'tabella_millesimale_default_id',
'importo_default',
'importo_consuntivo',
'percentuale_condomino',
'percentuale_inquilino',
'conto_pd',
'sottoconto_pd',
]);
return $rows->map(fn(object $row): array=> [
'id' => (int) $row->id,
'codice' => (string) ($row->codice ?? ''),
'descrizione' => (string) ($row->descrizione ?? ''),
'tabella_millesimale_default_id' => $row->tabella_millesimale_default_id ? (int) $row->tabella_millesimale_default_id : 0,
'importo_default' => is_numeric($row->importo_default ?? null) ? (float) $row->importo_default : 0.0,
'importo_consuntivo' => is_numeric($row->importo_consuntivo ?? null) ? (float) $row->importo_consuntivo : 0.0,
'percentuale_condomino' => is_numeric($row->percentuale_condomino ?? null) ? (float) $row->percentuale_condomino : 100.0,
'percentuale_inquilino' => is_numeric($row->percentuale_inquilino ?? null) ? (float) $row->percentuale_inquilino : 0.0,
'conto_pd' => (string) ($row->conto_pd ?? ''),
'sottoconto_pd' => (string) ($row->sottoconto_pd ?? ''),
]);
}
private function buildTableSummary(Collection $voci, array $tabellaMap): array
{
return $voci
->groupBy(fn(array $row): int => (int) ($row['tabella_millesimale_default_id'] ?? 0))
->map(function (Collection $rows, int $tabellaId) use ($tabellaMap): array {
$tabella = $tabellaMap[$tabellaId] ?? [
'codice' => '',
'label' => '',
'tipo_calcolo' => null,
'legacy_tipo' => null,
'totale_millesimi' => 0.0,
'nord' => 999999,
];
$totalePreventivo = (float) $rows->sum('importo_default');
$totaleConsuntivo = (float) $rows->sum('importo_consuntivo');
$quotaInquilini = (float) $rows->sum(function (array $row): float {
return $row['importo_default'] * (($row['percentuale_inquilino'] ?? 0.0) / 100);
});
return [
'tabella_id' => $tabellaId,
'codice' => $tabella['codice'],
'label' => $tabella['label'],
'tipo_calcolo' => $tabella['tipo_calcolo'],
'legacy_tipo' => $tabella['legacy_tipo'],
'nord' => $tabella['nord'] ?? 999999,
'totale_millesimi' => $tabella['totale_millesimi'],
'totale_preventivo' => $totalePreventivo,
'totale_consuntivo' => $totaleConsuntivo,
'quota_inquilini' => $quotaInquilini,
'quota_condomini' => $totalePreventivo - $quotaInquilini,
'voci' => $rows->values()->all(),
];
})
->sortBy(fn(array $row): string => sprintf('%06d|%s', (int) ($row['nord'] ?? 999999), (string) ($row['codice'] ?? '')))
->mapWithKeys(fn(array $row): array=> [$row['codice'] => $row])
->all();
}
private function loadUnitaRows(int $stabileId): Collection
{
$query = DB::table('unita_immobiliari')->where('stabile_id', $stabileId);
if (Schema::hasColumn('unita_immobiliari', 'deleted_at')) {
$query->whereNull('deleted_at');
}
return $query
->orderBy('scala')
->orderBy('piano')
->orderBy('interno')
->orderBy('id')
->get([
'id',
'codice_unita',
'denominazione',
'scala',
'piano',
'interno',
'stato_occupazione',
'legacy_cond_id',
])
->map(fn(object $row): array=> [
'id' => (int) $row->id,
'label' => $this->buildUnitLabel($row),
'sort_key' => $this->buildUnitSortKey($row),
'stato_occupazione' => (string) ($row->stato_occupazione ?? ''),
'legacy_cond_id' => $row->legacy_cond_id !== null ? (string) $row->legacy_cond_id : null,
]);
}
private function loadDetailRows(int $stabileId, int $annoGestione): Collection
{
if (! Schema::hasTable('dettaglio_millesimi') || ! Schema::hasTable('tabelle_millesimali')) {
return collect();
}
$query = DB::table('dettaglio_millesimi as dm')
->join('tabelle_millesimali as tm', 'tm.id', '=', 'dm.tabella_millesimale_id')
->where('tm.stabile_id', $stabileId);
if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) {
$query->where('tm.anno_gestione', $annoGestione);
}
return $query
->get([
'dm.unita_immobiliare_id',
'tm.codice_tabella',
'tm.totale_millesimi',
'dm.millesimi',
'dm.valore_prev',
'dm.ruolo_legacy',
])
->map(fn(object $row): array=> [
'unit_id' => (int) $row->unita_immobiliare_id,
'tabella_codice' => (string) ($row->codice_tabella ?? ''),
'totale_millesimi' => is_numeric($row->totale_millesimi ?? null) ? (float) $row->totale_millesimi : 0.0,
'millesimi' => is_numeric($row->millesimi ?? null) ? (float) $row->millesimi : 0.0,
'valore_prev' => is_numeric($row->valore_prev ?? null) ? (float) $row->valore_prev : 0.0,
'ruolo_legacy' => strtoupper(trim((string) ($row->ruolo_legacy ?? ''))),
]);
}
private function buildMatrixRows(Collection $unitaRows, array $summaryByTable, Collection $detailRows, array $peopleMap, array $mesiRate): Collection
{
$detailsByUnitTable = $detailRows->groupBy(fn(array $row): string => $row['unit_id'] . '|' . $row['tabella_codice']);
return $unitaRows->map(function (array $unit) use ($summaryByTable, $detailsByUnitTable, $peopleMap, $mesiRate): array {
$columns = [];
$breakdown = [];
$totaleUnit = 0.0;
$totaleCondomini = 0.0;
$totaleInquilini = 0.0;
$owners = $peopleMap[$unit['id']]['owners'] ?? [];
$tenants = $peopleMap[$unit['id']]['tenants'] ?? [];
foreach ($summaryByTable as $tabellaCode => $table) {
$detail = collect($detailsByUnitTable->get($unit['id'] . '|' . $tabellaCode, []));
$computed = $this->computeUnitTableAllocation($table, $detail);
$columns[$tabellaCode] = $computed['totale'];
$breakdown[$tabellaCode] = $computed;
$totaleUnit += $computed['totale'];
$totaleCondomini += $computed['condomini'];
$totaleInquilini += $computed['inquilini'];
}
return [
'unit_id' => $unit['id'],
'unit_label' => $unit['label'],
'stato_occupazione' => $unit['stato_occupazione'],
'people_summary' => $peopleMap[$unit['id']]['summary'] ?? 'Nessun soggetto collegato',
'owners' => $owners,
'tenants' => $tenants,
'legacy_cond_id' => $unit['legacy_cond_id'],
'columns' => $columns,
'table_breakdown' => $breakdown,
'totale_unita' => $totaleUnit,
'totale_condomini' => $totaleCondomini,
'totale_inquilini' => $totaleInquilini,
'rate' => $this->splitAcrossMonths($totaleUnit, $mesiRate),
];
})->sort(function (array $left, array $right): int {
return strnatcasecmp((string) ($left['sort_key'] ?? $left['unit_label'] ?? ''), (string) ($right['sort_key'] ?? $right['unit_label'] ?? ''));
})->values();
}
private function computeUnitTableAllocation(array $table, Collection $detailRows): array
{
$totaleTabella = (float) ($table['totale_preventivo'] ?? 0.0);
$tabellaMillesimi = (float) ($table['totale_millesimi'] ?? 0.0);
$millesimiUnita = (float) $detailRows
->filter(fn(array $row): bool => $row['ruolo_legacy'] !== 'I')
->sum('millesimi');
$ratio = $tabellaMillesimi > 0 ? ($millesimiUnita / $tabellaMillesimi) : 0.0;
$impProp = (float) $detailRows
->filter(fn(array $row): bool => $row['ruolo_legacy'] !== 'I')
->sum('valore_prev');
$impInq = (float) $detailRows
->filter(fn(array $row): bool => $row['ruolo_legacy'] === 'I')
->sum('valore_prev');
$impTot = $impProp + $impInq;
$hasImporti = abs($impTot) > 0.00001;
$useImportiSplit = $hasImporti && abs($impInq) > 0.00001;
$quotaUnit = $hasImporti ? $impTot : ($totaleTabella * $ratio);
$quotaCondomini = 0.0;
$quotaInquilini = 0.0;
if ($useImportiSplit) {
$quotaCondomini = $impProp;
$quotaInquilini = $impInq;
} else {
foreach ($table['voci'] as $voce) {
$share = $totaleTabella != 0.0 ? ((float) $voce['importo_default'] / $totaleTabella) : 0.0;
$quotaVoce = $hasImporti ? ($quotaUnit * $share) : ((float) $voce['importo_default'] * $ratio);
$quotaInqVoce = $quotaVoce * (((float) ($voce['percentuale_inquilino'] ?? 0.0)) / 100);
$quotaCondomini += $quotaVoce - $quotaInqVoce;
$quotaInquilini += $quotaInqVoce;
}
}
return [
'totale' => $quotaUnit,
'condomini' => $quotaCondomini,
'inquilini' => $quotaInquilini,
'millesimi_unita' => $millesimiUnita,
'millesimi_tabella' => $tabellaMillesimi,
];
}
private function loadPeopleMap(int $stabileId, array $unitIds): array
{
$map = [];
if ($unitIds === []) {
return $map;
}
if (Schema::hasTable('persone_unita_relazioni')) {
$relations = PersonaUnitaRelazione::query()
->with('persona')
->whereIn('unita_id', $unitIds)
->attive()
->get();
foreach ($relations as $relation) {
$unitId = (int) $relation->unita_id;
$role = strtoupper((string) ($relation->ruolo_rate ?: PersonaUnitaRelazione::deriveRuoloRate($relation->tipo_relazione)));
if (! in_array($role, ['C', 'I'], true)) {
continue;
}
$map[$unitId]['relations'][$role][] = [
'name' => (string) ($relation->persona?->nome_display ?? $relation->persona?->nome_completo ?? 'Persona'),
'quota' => is_numeric($relation->quota_relazione ?? null) ? (float) $relation->quota_relazione : null,
'source' => 'relazione_attiva',
];
}
}
if (Schema::hasTable('unita_immobiliare_nominativi')) {
$today = now()->toDateString();
$legacyRows = UnitaImmobiliareNominativo::query()
->where('stabile_id', $stabileId)
->whereIn('unita_immobiliare_id', $unitIds)
->where(function ($query) use ($today): void {
$query->whereNull('data_inizio')->orWhere('data_inizio', '<=', $today);
})
->where(function ($query) use ($today): void {
$query->whereNull('data_fine')->orWhere('data_fine', '>=', $today);
})
->get();
foreach ($legacyRows as $row) {
$unitId = (int) $row->unita_immobiliare_id;
$role = strtoupper((string) ($row->ruolo ?? ''));
if (! in_array($role, ['C', 'I'], true)) {
continue;
}
$map[$unitId]['legacy'][$role][] = [
'name' => (string) ($row->nominativo ?? 'Nominativo legacy'),
'quota' => is_numeric($row->percentuale ?? null) ? (float) $row->percentuale : null,
'source' => (string) ($row->fonte ?? 'legacy'),
];
}
}
foreach ($unitIds as $unitId) {
$owners = $map[$unitId]['relations']['C'] ?? $map[$unitId]['legacy']['C'] ?? [];
$tenants = $map[$unitId]['relations']['I'] ?? $map[$unitId]['legacy']['I'] ?? [];
$ownerNames = collect($owners)->pluck('name')->filter()->values()->all();
$tenantNames = collect($tenants)->pluck('name')->filter()->values()->all();
$summary = [];
if ($ownerNames !== []) {
$summary[] = 'Prop: ' . implode(', ', $ownerNames);
}
if ($tenantNames !== []) {
$summary[] = 'Inq: ' . implode(', ', $tenantNames);
}
$map[$unitId]['owners'] = $owners;
$map[$unitId]['tenants'] = $tenants;
$map[$unitId]['summary'] = $summary === [] ? 'Nessun soggetto collegato' : implode(' · ', $summary);
}
return $map;
}
private function buildPeopleAudit(Collection $matrix, array $peopleMap): array
{
return $matrix->map(function (array $row) use ($peopleMap): array {
$people = $peopleMap[$row['unit_id']] ?? ['owners' => [], 'tenants' => [], 'relations' => [], 'legacy' => []];
$owners = $people['owners'] ?? [];
$tenants = $people['tenants'] ?? [];
$ownerQuota = $this->sumDeclaredQuota($owners);
$tenantQuota = $this->sumDeclaredQuota($tenants);
$flags = [];
if ($owners === []) {
$flags[] = 'Manca proprietario attivo';
}
if ($row['stato_occupazione'] === 'occupata_inquilino' && $tenants === []) {
$flags[] = 'Unità occupata da inquilino senza inquilino attivo';
}
if ($owners !== [] && $ownerQuota !== null && abs($ownerQuota - 100.0) > 0.01) {
$flags[] = 'Quote proprietari ' . number_format($ownerQuota, 2, ',', '.') . '%';
}
if (($people['relations']['C'] ?? []) === [] && ($people['legacy']['C'] ?? []) !== []) {
$flags[] = 'Solo nominativi legacy per proprietari';
}
if (($people['relations']['I'] ?? []) === [] && ($people['legacy']['I'] ?? []) !== []) {
$flags[] = 'Solo nominativi legacy per inquilini';
}
return [
'unit_id' => $row['unit_id'],
'unit_label' => $row['unit_label'],
'owners' => implode(', ', array_map(fn(array $item): string => $item['name'], $owners)),
'tenants' => implode(', ', array_map(fn(array $item): string => $item['name'], $tenants)),
'owner_quota' => $ownerQuota,
'tenant_quota' => $tenantQuota,
'flags' => $flags,
];
})->all();
}
private function buildFocusBreakdown(Collection $matrix, ?int $focusUnitId): array
{
if (! $focusUnitId) {
return [];
}
$row = $matrix->firstWhere('unit_id', $focusUnitId);
if (! is_array($row)) {
return [];
}
$subjects = [];
$ownerShares = $this->normalizePeopleShares($row['table_breakdown'], 'owners');
$tenantShares = $this->normalizePeopleShares($row['table_breakdown'], 'tenants');
foreach ($ownerShares as $share) {
$subjects[] = $share;
}
foreach ($tenantShares as $share) {
$subjects[] = $share;
}
return $subjects;
}
private function normalizePeopleShares(array $tableBreakdown, string $bucket): array
{
$first = reset($tableBreakdown);
if (! is_array($first) || ! isset($first[$bucket]) || ! is_array($first[$bucket])) {
return [];
}
$people = $first[$bucket];
$totalBase = $bucket === 'owners' ? 'condomini' : 'inquilini';
$shares = $this->normalizeShares($people);
$rows = [];
foreach ($shares as $person) {
$totale = 0.0;
$columns = [];
foreach ($tableBreakdown as $code => $values) {
$base = (float) ($values[$totalBase] ?? 0.0);
$quota = $base * $person['share'];
$columns[$code] = $quota;
$totale += $quota;
}
$rows[] = [
'name' => $person['name'],
'role' => $bucket === 'owners' ? 'Condomino' : 'Inquilino',
'quota_percent' => $person['share'] * 100,
'columns' => $columns,
'totale' => $totale,
];
}
return $rows;
}
private function normalizeShares(array $people): array
{
if ($people === []) {
return [];
}
$declared = collect($people)->sum(fn(array $row): float => is_numeric($row['quota'] ?? null) ? (float) $row['quota'] : 0.0);
$count = count($people);
return array_map(function (array $row) use ($declared, $count): array {
$share = $declared > 0
? (((float) ($row['quota'] ?? 0.0)) / $declared)
: (1 / max($count, 1));
return [
'name' => $row['name'],
'share' => $share,
];
}, $people);
}
private function sumDeclaredQuota(array $people): ?float
{
$declared = collect($people)
->filter(fn(array $row): bool => is_numeric($row['quota'] ?? null))
->sum(fn(array $row): float => (float) $row['quota']);
return $declared > 0 ? $declared : null;
}
private function splitAcrossMonths(float $total, array $mesiRate): array
{
$count = count($mesiRate);
if ($count === 0) {
return [];
}
$base = round($total / $count, 2);
$out = [];
$allocated = 0.0;
foreach ($mesiRate as $index => $month) {
$amount = $index === ($count - 1) ? round($total - $allocated, 2) : $base;
$allocated += $amount;
$out[] = [
'month' => $month['month'],
'label' => $month['label'],
'amount' => $amount,
];
}
return $out;
}
private function resolveFocusUnitId(Collection $matrix): ?int
{
$first = $matrix->first();
return is_array($first) ? (int) ($first['unit_id'] ?? 0) : null;
}
private function buildUnitLabel(object $row): string
{
$parts = [];
$scala = trim((string) ($row->scala ?? ''));
$piano = trim((string) ($row->piano ?? ''));
$interno = trim((string) ($row->interno ?? ''));
$denominazione = trim((string) ($row->denominazione ?? ''));
$codice = trim((string) ($row->codice_unita ?? ''));
if ($scala !== '') {
$parts[] = 'Scala ' . $scala;
}
if ($piano !== '') {
$parts[] = 'Piano ' . $piano;
}
if ($interno !== '') {
$parts[] = 'Int. ' . $interno;
}
$label = implode(' · ', $parts);
if ($label === '') {
$label = $denominazione !== '' ? $denominazione : ($codice !== '' ? $codice : 'Unità #' . $row->id);
}
if ($codice !== '') {
$label .= ' [' . $codice . ']';
}
return $label;
}
private function buildUnitSortKey(object $row): string
{
return implode('|', [
$this->normalizeNaturalToken((string) ($row->scala ?? '')),
$this->normalizeNaturalToken((string) ($row->piano ?? '')),
$this->normalizeNaturalToken((string) ($row->interno ?? '')),
$this->normalizeNaturalToken((string) ($row->denominazione ?? '')),
str_pad((string) ((int) ($row->id ?? 0)), 10, '0', STR_PAD_LEFT),
]);
}
private function normalizeNaturalToken(string $value): string
{
$normalized = strtoupper(trim($value));
return preg_replace('/[\s\.\-\/]+/', '', $normalized) ?? '';
}
private function monthLabel(int $month): string
{
return [
1 => 'Gen',
2 => 'Feb',
3 => 'Mar',
4 => 'Apr',
5 => 'Mag',
6 => 'Giu',
7 => 'Lug',
8 => 'Ago',
9 => 'Set',
10 => 'Ott',
11 => 'Nov',
12 => 'Dic',
][$month] ?? (string) $month;
}
private function decodeJson(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
if (is_array($decoded)) {
return $decoded;
}
}
return [];
}
}

View File

@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('dettaglio_millesimi') || ! Schema::hasColumn('dettaglio_millesimi', 'ruolo_legacy')) {

View File

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('contabilita_saldi_conti')) {
return;
}
Schema::table('contabilita_saldi_conti', function (Blueprint $table) {
if (! Schema::hasColumn('contabilita_saldi_conti', 'periodo_da')) {
$table->date('periodo_da')->nullable()->after('data_saldo');
}
if (! Schema::hasColumn('contabilita_saldi_conti', 'periodo_a')) {
$table->date('periodo_a')->nullable()->after('periodo_da');
}
if (! Schema::hasColumn('contabilita_saldi_conti', 'tipo_estratto')) {
$table->string('tipo_estratto', 50)->nullable()->after('saldo');
}
if (! Schema::hasColumn('contabilita_saldi_conti', 'documento_stabile_id')) {
$table->foreignId('documento_stabile_id')
->nullable()
->after('note')
->constrained('documenti_stabili')
->nullOnDelete();
}
});
}
public function down(): void
{
if (! Schema::hasTable('contabilita_saldi_conti')) {
return;
}
Schema::table('contabilita_saldi_conti', function (Blueprint $table) {
if (Schema::hasColumn('contabilita_saldi_conti', 'documento_stabile_id')) {
$table->dropConstrainedForeignId('documento_stabile_id');
}
if (Schema::hasColumn('contabilita_saldi_conti', 'tipo_estratto')) {
$table->dropColumn('tipo_estratto');
}
if (Schema::hasColumn('contabilita_saldi_conti', 'periodo_a')) {
$table->dropColumn('periodo_a');
}
if (Schema::hasColumn('contabilita_saldi_conti', 'periodo_da')) {
$table->dropColumn('periodo_da');
}
});
}
};

View File

@ -42454,3 +42454,80 @@ ### Prossimo step operativo
### Prompt di ripartenza
Riparti da docs/ai/SESSION_HANDOFF.md, usa l'ultimo CHECKPOINT, continua dal "Prossimo step operativo" senza rifare analisi generale.
---
## CHECKPOINT 2026-04-18 08:46:35
### Stato rapido
- Branch: main
- Ultimo commit: 09022d5 2026-04-16 Stabilize supplier ops and strict legacy sync
### File modificati (git status --short)
```
M CHANGELOG.md
M app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php
M app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php
M app/Filament/Pages/Contabilita/CasseBancheMovimenti.php
M app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php
M app/Filament/Pages/Contabilita/SaldiContiArchivio.php
MM app/Filament/Pages/Contabilita/SituazioneIniziale.php
M app/Filament/Pages/Contabilita/VociSpesaArchivio.php
M app/Filament/Pages/Fornitore/Contabilita.php
M app/Filament/Pages/Gescon/Ordinarie.php
M app/Filament/Pages/Strumenti/PostIt.php
M app/Filament/Pages/Supporto/TicketAcqua.php
M app/Livewire/Condomini/StabileDatiBancariTable.php
M app/Livewire/Filament/TopbarLiveCall.php
M app/Models/DocumentoStabile.php
M app/Modules/Contabilita/Models/SaldoConto.php
M app/Providers/Filament/AdminFilamentPanelProvider.php
M app/Services/Contabilita/MovimentiBancaImporter.php
M app/Services/Contabilita/MpsQifParser.php
M database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php
M resources/views/filament/pages/contabilita/casse-banche-movimenti.blade.php
MM resources/views/filament/pages/contabilita/situazione-iniziale.blade.php
M resources/views/filament/pages/contabilita/voci-spesa-prospetto.blade.php
M resources/views/filament/pages/gescon/section.blade.php
M resources/views/filament/pages/strumenti/post-it.blade.php
M resources/views/filament/pages/supporto/ticket-acqua-light.blade.php
M resources/views/livewire/filament/topbar-live-call.blade.php
?? app/Services/Contabilita/PreventivoOrdinarioPreviewService.php
?? database/migrations/2026_04_17_120000_add_statement_metadata_to_contabilita_saldi_conti.php
```
### Ultimi file toccati (max 20)
```
CHANGELOG.md
app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php
app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php
app/Filament/Pages/Contabilita/CasseBancheMovimenti.php
app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php
app/Filament/Pages/Contabilita/SaldiContiArchivio.php
app/Filament/Pages/Contabilita/SituazioneIniziale.php
app/Filament/Pages/Contabilita/VociSpesaArchivio.php
app/Filament/Pages/Fornitore/Contabilita.php
app/Filament/Pages/Gescon/Ordinarie.php
app/Filament/Pages/Strumenti/PostIt.php
app/Filament/Pages/Supporto/TicketAcqua.php
app/Livewire/Condomini/StabileDatiBancariTable.php
app/Livewire/Filament/TopbarLiveCall.php
app/Models/DocumentoStabile.php
app/Modules/Contabilita/Models/SaldoConto.php
app/Providers/Filament/AdminFilamentPanelProvider.php
app/Services/Contabilita/MovimentiBancaImporter.php
app/Services/Contabilita/MpsQifParser.php
database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php
```
### Bug trovati / verificati in questa sessione
-
### Decisioni prese
-
### Prossimo step operativo
-
### Prompt di ripartenza
Riparti da docs/ai/SESSION_HANDOFF.md, usa l'ultimo CHECKPOINT, continua dal "Prossimo step operativo" senza rifare analisi generale.

View File

@ -7,21 +7,297 @@
]"
/>
@if(! $this->getActiveStabile())
<x-filament::section>
<div class="text-sm text-gray-600">Seleziona uno stabile per importare e verificare i movimenti banca.</div>
</x-filament::section>
@endif
@php
$stabile = $this->getActiveStabile();
$contiRows = $this->getHubContiRows();
$contiOptions = $this->getContiImportabili();
$hdr = $this->getHeaderSaldoInfo();
$periodo = $this->periodoTotali;
$periodoOptions = $this->periodoOptions;
$periodoRateOptions = $this->periodoRateOptions;
$periodoRate = $this->periodoRateTotali;
$contoImport = $this->getSelectedContoImportInfo();
$snapshotCards = $this->getSaldoSnapshotCards();
$saldoArchivio = $this->getSaldoArchivioByYear();
$ricStats = $this->getRiconciliazioneStats();
$ricQueue = $this->getRiconciliazioneQueue();
$ricCurrent = $this->getRiconciliazioneCurrent();
$ricCandidates = $this->getRiconciliazioneCandidates();
@endphp
@if(! $stabile)
<x-filament::section>
<div class="text-sm text-gray-600">Seleziona uno stabile per importare e verificare i movimenti banca.</div>
</x-filament::section>
@else
<x-filament::section>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.8fr)]">
<div>
<div class="text-sm text-gray-500">Stabile attivo</div>
<div class="text-lg font-semibold text-gray-900">
{{ $stabile->codice_operatore ?? $stabile->codice_stabile ?? '—' }} {{ $stabile->denominazione ?? 'Stabile' }}
</div>
<div class="mt-1 text-xs text-gray-600">Hub operativo del conto corrente: conti, saldi ufficiali, movimenti importati e riconciliazione.</div>
</div>
<div class="rounded-xl border bg-slate-50 p-4">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Conto attivo</div>
<div class="mt-2">
<select wire:model.live="contoId" class="w-full rounded-lg border-gray-300 text-sm">
<option value="">Seleziona conto</option>
@foreach($contiOptions as $conto)
<option value="{{ $conto['id'] }}">{{ $conto['label'] }}</option>
@endforeach
</select>
</div>
<div class="mt-3 text-sm text-slate-700">
@if($contoImport)
<div class="font-medium">{{ $contoImport['label'] }}</div>
@else
<div>Nessun conto selezionato.</div>
@endif
</div>
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('importa_estratto_unificato')">
Importa estratto
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('registra_saldo_estratto')">
Registra saldo
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
<x-filament::tabs>
<x-filament::tabs.item :active="$this->hubTab === 'conti'" wire:click="goToHubTab('conti')">Conti</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'saldi'" wire:click="goToHubTab('saldi')">Saldi e estratti</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'movimenti'" wire:click="goToHubTab('movimenti')">Movimenti</x-filament::tabs.item>
<x-filament::tabs.item :active="$this->hubTab === 'riconciliazione'" wire:click="goToHubTab('riconciliazione')">Riconciliazione</x-filament::tabs.item>
</x-filament::tabs>
@if($this->hubTab === 'conti')
<x-filament::section>
<x-slot name="heading">Conti dello stabile</x-slot>
<x-slot name="description">Vista generale equivalente a “Casse e banche”, ma dentro l'hub operativo del conto corrente.</x-slot>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
@forelse($contiRows as $row)
<div @class([
'rounded-xl border p-4',
'border-primary-300 bg-primary-50/40' => $row['selected'],
'bg-white' => ! $row['selected'],
])>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $row['label'] }}</div>
<div class="mt-1 text-xs text-slate-600">
@if($row['snapshot_data'])
Ultimo saldo ufficiale: {{ $row['snapshot_data'] }}
@if($row['snapshot_tipo'])
· {{ $row['snapshot_tipo'] }}
@endif
@else
Nessun saldo ufficiale registrato.
@endif
</div>
</div>
@if($row['selected'])
<span class="rounded-full bg-primary-600 px-2.5 py-1 text-[11px] font-semibold text-white">Attivo</span>
@endif
</div>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-slate-500">Saldo attuale</div>
<div class="mt-1 text-lg font-semibold text-slate-900"> {{ number_format((float) ($row['saldo_attuale'] ?? 0), 2, ',', '.') }}</div>
</div>
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-slate-500">Movimenti importati</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ $row['movimenti_count'] ?? 0 }}</div>
<div class="text-xs text-slate-500">Ultima data: {{ $row['ultima_movimentazione'] ?? '—' }}</div>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button size="sm" color="gray" wire:click="selectContoAndTab({{ $row['id'] }}, 'saldi')">Saldi / estratti</x-filament::button>
<x-filament::button size="sm" color="primary" wire:click="selectContoAndTab({{ $row['id'] }}, 'movimenti')">Movimenti</x-filament::button>
<x-filament::button size="sm" color="success" wire:click="selectContoAndTab({{ $row['id'] }}, 'riconciliazione')">Riconciliazione</x-filament::button>
</div>
</div>
@empty
<div class="rounded-lg border border-dashed bg-white p-4 text-sm text-slate-500 lg:col-span-2">Nessun conto configurato per lo stabile attivo.</div>
@endforelse
</div>
</x-filament::section>
@elseif($this->hubTab === 'saldi')
<x-filament::section>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-lg font-semibold text-slate-900">Archivio saldi ufficiali ed estratti conto</div>
<div class="mt-1 text-sm text-slate-600">Vista annuale del conto selezionato, separata dal mastrino dei movimenti.</div>
@if($contoImport)
<div class="mt-2 text-xs font-medium text-slate-700">Conto: {{ $contoImport['label'] }}</div>
@endif
</div>
<x-filament::button type="button" color="primary" wire:click="mountAction('registra_saldo_estratto')">
Nuovo saldo / estratto
</x-filament::button>
</div>
<div class="mt-4 space-y-6">
@forelse($saldoArchivio as $yearBlock)
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div class="text-base font-semibold text-slate-900">Anno {{ $yearBlock['year'] }}</div>
<div class="text-xs text-slate-500">{{ count($yearBlock['rows']) }} estratti / saldi</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-left text-slate-500">
<tr>
<th class="py-2 pr-4">Data saldo</th>
<th class="py-2 pr-4">Periodo</th>
<th class="py-2 pr-4">Tipo</th>
<th class="py-2 pr-4 text-right">Saldo</th>
<th class="py-2 pr-4">Documento</th>
<th class="py-2 pr-4">Note</th>
<th class="py-2 pr-0 text-right">Azioni</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach($yearBlock['rows'] as $row)
<tr>
<td class="py-3 pr-4 font-medium text-slate-900">{{ $row['data'] }}</td>
<td class="py-3 pr-4">
<div>{{ $row['periodo_breve'] }}</div>
<div class="text-xs text-slate-500">{{ $row['periodo'] }}</div>
</td>
<td class="py-3 pr-4">{{ $row['tipo'] }}</td>
<td class="py-3 pr-4 text-right font-semibold"> {{ number_format((float) $row['saldo'], 2, ',', '.') }}</td>
<td class="py-3 pr-4">
@if($row['documento_url'] && $row['documento_label'])
<a href="{{ $row['documento_url'] }}" target="_blank" class="text-primary-600 hover:underline">{{ $row['documento_label'] }}</a>
@else
<span class="text-slate-400"></span>
@endif
</td>
<td class="py-3 pr-4 text-slate-600">{{ $row['note'] !== '' ? $row['note'] : '—' }}</td>
<td class="py-3 pr-0 text-right">
<x-filament::button size="sm" color="gray" wire:click="openSaldoPeriodo({{ $row['id'] }})">Apri movimenti</x-filament::button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@empty
<div class="rounded-lg border border-dashed bg-white p-4 text-sm text-slate-500">Nessun saldo ufficiale registrato per il conto selezionato.</div>
@endforelse
</div>
</x-filament::section>
@elseif($this->hubTab === 'riconciliazione')
<x-filament::section>
<x-slot name="heading">Riconciliazione guidata</x-slot>
<x-slot name="description">Pre-riconciliazione automatica con candidati. Loperatore verifica un movimento alla volta e usa il mastrino dove il match non è certo.</x-slot>
<div class="grid grid-cols-2 gap-3 xl:grid-cols-4">
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-slate-500">Totale movimenti</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ $ricStats['totale'] ?? 0 }}</div>
</div>
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-slate-500">Senza prima nota</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ $ricStats['senza_prima_nota'] ?? 0 }}</div>
</div>
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-slate-500">Già collegati</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ $ricStats['collegati'] ?? 0 }}</div>
</div>
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-slate-500">Da confermare</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ $ricStats['da_confermare'] ?? 0 }}</div>
</div>
</div>
@if(! $contoImport)
<div class="mt-4 rounded-lg border border-dashed bg-white p-4 text-sm text-slate-500">Seleziona un conto per avviare la riconciliazione.</div>
@elseif(! $ricCurrent)
<div class="mt-4 rounded-lg border border-dashed bg-white p-4 text-sm text-slate-500">Nessun movimento in coda. Il conto sembra già lavorato oppure i movimenti sono tutti collegati.</div>
@else
<div class="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.9fr)]">
<div class="rounded-xl border bg-slate-50 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Movimento in lavorazione</div>
<div class="mt-1 text-sm text-slate-600">{{ ($this->riconciliazioneOffset ?? 0) + 1 }} di {{ count($ricQueue) }}</div>
</div>
<div class="flex gap-2">
<x-filament::button size="sm" color="gray" wire:click="previousRiconciliazione">Precedente</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="nextRiconciliazione">Successivo</x-filament::button>
</div>
</div>
<div class="mt-4 rounded-lg border bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $ricCurrent['data'] }}</div>
<div class="mt-1 text-xs text-slate-500">Stato: {{ $ricCurrent['stato'] }}</div>
</div>
<div class="text-right text-lg font-semibold {{ $ricCurrent['importo'] >= 0 ? 'text-emerald-700' : 'text-rose-700' }}">
{{ number_format((float) $ricCurrent['importo'], 2, ',', '.') }}
</div>
</div>
<div class="mt-3 text-sm text-slate-700">{{ $ricCurrent['descrizione'] }}</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button size="sm" color="primary" wire:click="goToHubTab('movimenti')">Apri nel mastrino</x-filament::button>
@if($ricCurrent['has_registrazione'])
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700">Ha già una prima nota</span>
@else
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700">Da collegare</span>
@endif
</div>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Candidati automatici</div>
<div class="mt-1 text-xs text-slate-600">
@if($ricCurrent['importo'] >= 0)
Match su incassi registrati per importo, data e testo della causale.
@else
Match su fatture fornitore per importo, data e denominazione del fornitore.
@endif
</div>
<div class="mt-4 space-y-3">
@forelse($ricCandidates as $candidate)
<div class="rounded-lg border bg-slate-50 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">{{ $candidate['tipo'] }}</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{{ $candidate['titolo'] }}</div>
</div>
<div class="rounded-full bg-primary-100 px-2.5 py-1 text-xs font-semibold text-primary-700">{{ $candidate['score'] }}%</div>
</div>
<div class="mt-2 text-sm text-slate-700">{{ $candidate['dettaglio'] }}</div>
<div class="mt-2 flex flex-wrap gap-4 text-xs text-slate-500">
<span>Data: {{ $candidate['data'] }}</span>
<span>Importo: {{ number_format((float) $candidate['importo'], 2, ',', '.') }}</span>
</div>
<div class="mt-2 text-xs text-slate-600">{{ $candidate['motivo'] }}</div>
</div>
@empty
<div class="rounded-lg border border-dashed bg-slate-50 p-4 text-sm text-slate-500">Nessun candidato convincente. In questo caso loperatore lavora dal mastrino e conferma manualmente il collegamento.</div>
@endforelse
</div>
</div>
</div>
@endif
</x-filament::section>
@else
<x-filament::section>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@ -51,6 +327,19 @@
</div>
<div class="text-xs text-gray-600">Filtra per periodo/gestione dai filtri tabella.</div>
</div>
@if($this->movimentiFocusFrom || $this->movimentiFocusTo)
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-amber-50 p-3 text-sm text-amber-900">
<div>
Filtro rapido attivo sul periodo
<span class="font-semibold">{{ $this->movimentiFocusFrom ?: '—' }}</span>
<span class="font-semibold">{{ $this->movimentiFocusTo ?: '—' }}</span>
</div>
<x-filament::button size="sm" color="gray" wire:click="clearMovimentiFocus">Rimuovi focus</x-filament::button>
</div>
@endif
<div class="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-4">
<div class="rounded-lg border bg-white p-3">
<div class="text-xs text-gray-500">Periodo</div>
@ -96,9 +385,42 @@
<div class="mt-3 text-lg font-semibold"> {{ number_format((float)($periodo['saldo'] ?? 0), 2, ',', '.') }}</div>
</div>
</div>
<div class="mt-4 rounded-lg border bg-slate-50 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold text-slate-900">Snapshot ufficiali / estratti conto</div>
<div class="text-xs text-slate-600">I saldi ufficiali ancorano il ricalcolo del conto anche quando il conto non ha IBAN.</div>
</div>
<x-filament::button type="button" size="sm" color="gray" wire:click="mountAction('registra_saldo_estratto')">
Registra saldo / estratto
</x-filament::button>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
@forelse($snapshotCards as $card)
<div class="rounded-lg border bg-white p-3 text-sm">
<div class="flex items-start justify-between gap-2">
<div class="font-medium text-slate-900">{{ $card['tipo'] }}</div>
<div class="text-xs text-slate-500">{{ $card['data'] ?? '—' }}</div>
</div>
<div class="mt-2 text-lg font-semibold text-slate-900"> {{ number_format((float) ($card['saldo'] ?? 0), 2, ',', '.') }}</div>
<div class="mt-1 text-xs text-slate-600">{{ $card['periodo'] }}</div>
@if(!empty($card['documento_url']) && !empty($card['documento_label']))
<a href="{{ $card['documento_url'] }}" target="_blank" class="mt-2 inline-flex text-xs font-medium text-indigo-700 hover:underline">{{ $card['documento_label'] }}</a>
@endif
</div>
@empty
<div class="rounded-lg border border-dashed bg-white p-3 text-sm text-slate-500 md:col-span-2 xl:col-span-3">Nessuno snapshot ufficiale registrato per il conto selezionato.</div>
@endforelse
</div>
</div>
<div class="mt-4">
{{ $this->table }}
</div>
</x-filament::section>
@endif
@endif
</div>
</x-filament-panels::page>

View File

@ -23,7 +23,7 @@
@endif
<div class="w-64">
<div class="text-xs text-gray-500 mb-1">Periodo legacy</div>
<div class="text-xs text-gray-500 mb-1">Esercizio archivio</div>
<x-filament::input.wrapper>
<select name="legacy_year" class="w-full bg-transparent">
<option value="">Tutti</option>
@ -57,40 +57,35 @@
<x-filament::tabs>
<x-filament::tabs.item
:active="$tab === 'bilancio'"
:href="\App\Filament\Pages\Contabilita\SituazioneIniziale::getUrl(panel: 'admin-filament', parameters: array_merge($query, ['tab' => 'bilancio', 'dett' => null]))"
wire:click.prevent="$set('tab','bilancio'); $set('dett', null)"
wire:click="$set('tab', 'bilancio'); $set('dett', null)"
>
Bilancio
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$tab === 'crediti'"
:href="\App\Filament\Pages\Contabilita\SituazioneIniziale::getUrl(panel: 'admin-filament', parameters: array_merge($query, ['tab' => 'crediti', 'dett' => null]))"
wire:click.prevent="$set('tab','crediti'); $set('dett', null)"
wire:click="$set('tab', 'crediti'); $set('dett', null)"
>
Crediti
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$tab === 'debiti'"
:href="\App\Filament\Pages\Contabilita\SituazioneIniziale::getUrl(panel: 'admin-filament', parameters: array_merge($query, ['tab' => 'debiti', 'dett' => null]))"
wire:click.prevent="$set('tab','debiti'); $set('dett', null)"
wire:click="$set('tab', 'debiti'); $set('dett', null)"
>
Debiti
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$tab === 'conguagli'"
:href="\App\Filament\Pages\Contabilita\SituazioneIniziale::getUrl(panel: 'admin-filament', parameters: array_merge($query, ['tab' => 'conguagli', 'dett' => null]))"
wire:click.prevent="$set('tab','conguagli'); $set('dett', null)"
wire:click="$set('tab', 'conguagli'); $set('dett', null)"
>
Conguagli (Excel)
Conguagli apertura
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$tab === 'dettaglio'"
:href="\App\Filament\Pages\Contabilita\SituazioneIniziale::getUrl(panel: 'admin-filament', parameters: array_merge($query, ['tab' => 'dettaglio']))"
wire:click.prevent="$set('tab','dettaglio')"
wire:click="$set('tab', 'dettaglio')"
>
Dettaglio
</x-filament::tabs.item>
@ -148,7 +143,10 @@
@forelse(($detailRows ?? []) as $r)
<tr>
<td class="py-2 pr-4">{{ $r['cod_tab'] }}</td>
<td class="py-2 pr-4">{{ $r['id_cond'] }}</td>
<td class="py-2 pr-4">
<div class="font-medium">{{ $r['id_cond'] }}</div>
<div class="text-xs text-gray-500">{{ $r['cond_label'] ?? '' }}</div>
</td>
<td class="py-2 pr-4">{{ $r['cond_inquil'] ?: '—' }}</td>
<td class="py-2 pr-4">{{ $r['mm'] !== null ? number_format((float) $r['mm'], 3, ',', '.') : '—' }}</td>
<td class="py-2 pr-4">{{ $r['n_stra'] ?? '—' }}</td>
@ -168,95 +166,19 @@
</x-filament::section>
@elseif ($tab === 'conguagli')
<x-filament::section>
<x-slot name="heading">Conguagli (stile Excel)</x-slot>
<x-slot name="description">Raggruppati per condomino e per tabella CONG.* (include straordinarie quando presenti).</x-slot>
@php
$manualConguagliEnabled = \Illuminate\Support\Facades\Schema::hasTable('contabilita_situazione_iniziale_conguagli');
@endphp
@if ($manualConguagliEnabled)
<form wire:submit.prevent="addConguaglioManuale" class="mb-4 grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<div class="text-xs text-gray-500 mb-1">Tabella</div>
<x-filament::input.wrapper>
<select wire:model="formConguaglio.cod_tab" class="w-full bg-transparent">
<option value="CONG.O">CONG.O (Ordinaria)</option>
<option value="CONG.R">CONG.R (Riscaldamento)</option>
<option value="CONG.S">CONG.S</option>
<option value="CONG">CONG</option>
</select>
</x-filament::input.wrapper>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">n_stra (opzionale)</div>
<x-filament::input.wrapper>
<input type="number" wire:model="formConguaglio.n_stra" class="w-full bg-transparent" />
</x-filament::input.wrapper>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">Condomino (id_cond)</div>
<x-filament::input.wrapper>
<input type="text" wire:model="formConguaglio.id_cond" class="w-full bg-transparent" />
</x-filament::input.wrapper>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">Consuntivo (positivo=incasso, negativo=rimborso)</div>
<x-filament::input.wrapper>
<input type="text" wire:model="formConguaglio.cons_euro" class="w-full bg-transparent" placeholder="es. 123,45 oppure -50,00" />
</x-filament::input.wrapper>
</div>
<div class="md:col-span-4">
<x-filament::button type="submit">Aggiungi conguaglio (manuale)</x-filament::button>
</div>
</form>
@endif
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-left text-gray-500">
<tr>
<th class="py-2 pr-4">Condomino</th>
@foreach(($conguagliExcelCols ?? []) as $c)
<th class="py-2 pr-4 text-right whitespace-nowrap">{{ $c['label'] }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y">
@forelse(($conguagliExcel ?? []) as $r)
<tr>
<td class="py-2 pr-4">
<div class="font-medium">{{ $r['id_cond'] }}</div>
<div class="text-xs text-gray-500">{{ $r['cond_label'] ?? '' }}</div>
</td>
@foreach(($conguagliExcelCols ?? []) as $c)
@php $v = $r['cells'][$c['key']] ?? 0.0; @endphp
<td class="py-2 pr-4 text-right">{{ $fmt($v) }}</td>
@endforeach
</tr>
@empty
<tr>
<td class="py-3 text-gray-500" colspan="{{ 1 + count($conguagliExcelCols ?? []) }}">Nessun conguaglio disponibile.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<x-slot name="heading">Conguagli di apertura</x-slot>
<x-slot name="description">Fonte unica: <span class="font-medium">gescon_import.dett_tab</span> con <span class="font-medium">cod_tab = CONG.O</span>. Qui manteniamo solo i residui iniziali ordinari del passaggio consegne.</x-slot>
<div class="mt-6">
<div class="text-sm font-semibold mb-2">Modifica righe (dett_tab)</div>
<div class="text-xs text-gray-500 mb-2">Suggerimento: clicca una riga per modificarla (una alla volta).</div>
<form wire:submit.prevent="addDettTabRow" class="mb-3 grid grid-cols-1 md:grid-cols-6 gap-2">
<form wire:submit.prevent="addDettTabRow" class="mb-3 grid grid-cols-1 md:grid-cols-5 gap-2">
<div>
<div class="text-xs text-gray-500 mb-1">Tabella</div>
<div class="text-xs text-gray-500 mb-1">Fonte</div>
<x-filament::input.wrapper>
<input type="text" wire:model="formDettTabAdd.cod_tab" class="w-full bg-transparent" />
<input type="text" value="CONG.O" class="w-full bg-transparent" readonly />
</x-filament::input.wrapper>
</div>
<div>
@ -265,12 +187,6 @@
<input type="text" wire:model="formDettTabAdd.id_cond" class="w-full bg-transparent" />
</x-filament::input.wrapper>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">n_stra</div>
<x-filament::input.wrapper>
<input type="number" wire:model="formDettTabAdd.n_stra" class="w-full bg-transparent" />
</x-filament::input.wrapper>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">cons_euro</div>
<x-filament::input.wrapper>
@ -295,7 +211,6 @@
<th class="py-2 pr-3">Tabella</th>
<th class="py-2 pr-3">id_cond</th>
<th class="py-2 pr-3">Condomino</th>
<th class="py-2 pr-3">n_stra</th>
<th class="py-2 pr-3 text-right">Cons. </th>
<th class="py-2 pr-3 text-right">MM</th>
<th class="py-2 pr-3">Unico</th>
@ -328,15 +243,6 @@
<td class="py-2 pr-3">
<div class="text-xs text-gray-500">{{ $r['cond_label'] ?? '' }}</div>
</td>
<td class="py-2 pr-3">
@if($isEditing)
<x-filament::input.wrapper>
<input type="number" wire:model.defer="dettTabEdit.{{ $id }}.n_stra" class="w-full bg-transparent" wire:click.stop />
</x-filament::input.wrapper>
@else
{{ $r['n_stra'] ?? '—' }}
@endif
</td>
<td class="py-2 pr-3 text-right">
@if($isEditing)
<x-filament::input.wrapper>
@ -374,7 +280,7 @@
</tr>
@empty
<tr>
<td colspan="8" class="py-3 text-gray-500">Nessuna riga dett_tab disponibile.</td>
<td colspan="7" class="py-3 text-gray-500">Nessuna riga CONG.O disponibile.</td>
</tr>
@endforelse
</tbody>

View File

@ -67,7 +67,7 @@ class="fi-input fi-input-wrp fi-select-input text-xs"
</div>
</div>
<div class="rounded-lg border bg-white text-[11px] leading-tight">
<div class="rounded-lg border bg-white text-[12px] leading-tight [&_th]:py-1.5 [&_td]:py-1.5 [&_.fi-ta-cell]:py-1 [&_.fi-ta-actions]:gap-1 [&_.fi-ta-actions]:whitespace-nowrap">
{{ $this->table }}
</div>
@ -100,8 +100,8 @@ class="fi-input fi-input-wrp fi-select-input text-xs"
@php($voci = is_array($focus['voci'] ?? null) ? $focus['voci'] : [])
<div class="mt-2 overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="text-xs text-gray-500">
<table class="min-w-full text-[12px] leading-tight">
<thead class="text-[11px] text-gray-500">
<tr class="border-b">
<th class="py-2 pr-3 text-left">Cod.</th>
<th class="py-2 pr-3 text-left">Descrizione</th>
@ -117,13 +117,13 @@ class="fi-input fi-input-wrp fi-select-input text-xs"
@php($code = (string) ($v['codice'] ?? ''))
@php($desc = (string) ($v['descrizione'] ?? ''))
<tr>
<td class="py-2 pr-3 text-gray-700 font-semibold">{{ $code !== '' ? $code : '—' }}</td>
<td class="py-2 pr-3 text-gray-700">{{ $desc !== '' ? $desc : '—' }}</td>
<td class="py-2 pr-3 text-right">{{ number_format((float) ($v['prev'] ?? 0), 2, ',', '.') }}</td>
<td class="py-2 pr-3 text-right">{{ number_format((float) ($v['cons'] ?? 0), 2, ',', '.') }}</td>
<td class="py-2 pr-3 text-right">{{ number_format((float) ($v['prop'] ?? 0), 2, ',', '.') }}</td>
<td class="py-2 pr-3 text-right">{{ number_format((float) ($v['inq'] ?? 0), 2, ',', '.') }}</td>
<td class="py-2 pr-0 text-right">{{ !empty($v['attiva']) ? 'Sì' : 'No' }}</td>
<td class="py-1.5 pr-3 text-gray-700 font-semibold">{{ $code !== '' ? $code : '—' }}</td>
<td class="py-1.5 pr-3 text-gray-700">{{ $desc !== '' ? $desc : '—' }}</td>
<td class="py-1.5 pr-3 text-right">{{ number_format((float) ($v['prev'] ?? 0), 2, ',', '.') }}</td>
<td class="py-1.5 pr-3 text-right">{{ number_format((float) ($v['cons'] ?? 0), 2, ',', '.') }}</td>
<td class="py-1.5 pr-3 text-right">{{ number_format((float) ($v['prop'] ?? 0), 2, ',', '.') }}</td>
<td class="py-1.5 pr-3 text-right">{{ number_format((float) ($v['inq'] ?? 0), 2, ',', '.') }}</td>
<td class="py-1.5 pr-0 text-right">{{ !empty($v['attiva']) ? 'Sì' : 'No' }}</td>
</tr>
@empty
<tr>
@ -146,6 +146,24 @@ class="fi-input fi-input-wrp fi-select-input text-xs"
<span class="text-gray-500">Totale Cons:</span>
<span class="font-semibold">{{ number_format((float) $this->getTotaleConsuntivo(), 2, ',', '.') }}</span>
</div>
@if(($this->tipoGestioneTab ?? 'ordinaria') !== 'acqua' && ((float) $this->getTotalePreventivoAcqua() > 0 || (float) $this->getTotaleConsuntivoAcqua() > 0))
<div>
<span class="text-gray-500">ACQUA Prev:</span>
<span class="font-semibold">{{ number_format((float) $this->getTotalePreventivoAcqua(), 2, ',', '.') }}</span>
</div>
<div>
<span class="text-gray-500">ACQUA Cons:</span>
<span class="font-semibold">{{ number_format((float) $this->getTotaleConsuntivoAcqua(), 2, ',', '.') }}</span>
</div>
<div>
<span class="text-gray-500">Totale Gen. Prev:</span>
<span class="font-semibold">{{ number_format((float) $this->getTotalePreventivoGenerale(), 2, ',', '.') }}</span>
</div>
<div>
<span class="text-gray-500">Totale Gen. Cons:</span>
<span class="font-semibold">{{ number_format((float) $this->getTotaleConsuntivoGenerale(), 2, ',', '.') }}</span>
</div>
@endif
</div>
</div>
</div>

View File

@ -114,6 +114,21 @@
wire:click="$set('viewTab','consuntivo')"
>Consuntivo</x-filament::button>
@if(! $isStraordPage)
<x-filament::button
size="sm"
:color="$activeTab === 'preventivo' ? 'primary' : 'gray'"
wire:click="$set('viewTab','preventivo')"
>Preventivo</x-filament::button>
<x-filament::button
size="sm"
:color="$activeTab === 'riparto' ? 'primary' : 'gray'"
wire:click="$set('viewTab','riparto')"
>Riparto</x-filament::button>
<x-filament::button
size="sm"
:color="$activeTab === 'persone' ? 'primary' : 'gray'"
wire:click="$set('viewTab','persone')"
>Persone</x-filament::button>
<x-filament::button
size="sm"
:color="$activeTab === 'straordinarie' ? 'primary' : 'gray'"
@ -380,6 +395,330 @@
</div>
@endif
@if($activeTab === 'preventivo')
@php
$preventivoSummaryCollection = collect($this->preventivoSummaryRows ?? []);
$preventivoRowsCollection = collect($this->preventivoEditorRows ?? []);
$preventivoRowsGrouped = $preventivoSummaryCollection
->map(function ($summary) use ($preventivoRowsCollection) {
$code = (string) ($summary['codice'] ?? '');
return [
'summary' => $summary,
'rows' => $preventivoRowsCollection
->filter(fn ($row) => (string) ($row['tabella_codice'] ?? '') === $code)
->values()
->all(),
];
})
->filter(fn (array $group) => ! empty($group['rows']) || ! empty($group['summary']))
->values();
$preventivoOrdTotal = (float) $preventivoSummaryCollection
->reject(fn ($summary) => strtoupper((string) ($summary['codice'] ?? '')) === 'ACQUA')
->sum(fn ($summary) => (float) ($summary['totale_preventivo'] ?? 0));
$preventivoAcquaTotal = (float) $preventivoSummaryCollection
->filter(fn ($summary) => strtoupper((string) ($summary['codice'] ?? '')) === 'ACQUA')
->sum(fn ($summary) => (float) ($summary['totale_preventivo'] ?? 0));
$preventivoGeneralTotal = $preventivoOrdTotal + $preventivoAcquaTotal;
@endphp
<div class="mt-6 space-y-6">
<div class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-900">
<div class="font-semibold">Preventivo ordinario operativo</div>
<div class="mt-1">
Gestione {{ $this->preventivoGestioneMeta['denominazione'] ?? 'ordinaria' }}
@if(!empty($this->preventivoGestioneMeta['anno']))
· anno {{ $this->preventivoGestioneMeta['anno'] }}
@endif
· rate emesse su
@if(!empty($this->ripartoRateMonths))
{{ collect($this->ripartoRateMonths)->pluck('label')->implode(', ') }}
@else
mesi non configurati
@endif
</div>
<div class="mt-1 text-[11px] text-blue-800">Visualizzazione ordinata per tabella, con ACQUA separata ma riportata anche nel totale finale.</div>
</div>
<div class="overflow-hidden rounded-lg border">
<div class="flex items-center justify-between gap-3 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">
<span>Voci di spesa per tabella</span>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-white">
<tr>
<th class="px-3 py-2 text-left">Codice</th>
<th class="px-3 py-2 text-left">Descrizione</th>
<th class="px-3 py-2 text-left">Tabella</th>
<th class="px-3 py-2 text-right">Preventivo </th>
<th class="px-3 py-2 text-right">Consuntivo </th>
<th class="px-3 py-2 text-right">% Inquilino</th>
<th class="px-3 py-2 text-left">Conto PD</th>
</tr>
</thead>
<tbody>
@forelse($preventivoRowsGrouped as $group)
@php
$summary = $group['summary'] ?? [];
@endphp
<tr class="border-t bg-gray-50">
<td class="px-3 py-2 font-semibold text-gray-900" colspan="7">
{{ $summary['codice'] ?? '—' }}
@if(!empty($summary['label']))
<span class="font-normal text-gray-600">· {{ $summary['label'] }}</span>
@endif
</td>
</tr>
@foreach(($group['rows'] ?? []) as $row)
<tr class="border-t align-top">
<td class="px-3 py-2 font-medium text-gray-900">{{ $row['codice'] }}</td>
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ $row['descrizione'] }}</div>
</td>
<td class="px-3 py-2 text-gray-600">{{ ($row['tabella_codice'] ?? '—') !== '' ? $row['tabella_codice'] : '—' }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) ($row['importo_default'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) ($row['importo_consuntivo'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) ($row['percentuale_inquilino'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-[11px] text-gray-600">
{{ $row['conto_pd'] ?: '—' }}
@if(!empty($row['sottoconto_pd']))
<div>{{ $row['sottoconto_pd'] }}</div>
@endif
</td>
</tr>
@endforeach
<tr class="border-t bg-gray-50">
<td class="px-3 py-2 font-semibold" colspan="3">Subtotale {{ $summary['codice'] ?? '—' }}</td>
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) ($summary['totale_preventivo'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) ($summary['totale_consuntivo'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
</tr>
@empty
<tr>
<td colspan="7" class="px-3 py-4 text-gray-500">Nessuna voce ordinaria trovata per la gestione attiva.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if(!empty($this->preventivoSummaryRows))
<div class="overflow-hidden rounded-lg border">
<div class="bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">Riepilogo per tabella</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-white">
<tr>
<th class="px-3 py-2 text-left">Tabella</th>
<th class="px-3 py-2 text-left">Tipo</th>
<th class="px-3 py-2 text-right">Preventivo </th>
<th class="px-3 py-2 text-right">Consuntivo </th>
<th class="px-3 py-2 text-right">Quota condomini </th>
<th class="px-3 py-2 text-right">Quota inquilini </th>
</tr>
</thead>
<tbody>
@foreach($this->preventivoSummaryRows as $summary)
<tr class="border-t">
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ $summary['codice'] }}</div>
<div class="text-[10px] text-gray-500">{{ $summary['label'] }}</div>
</td>
<td class="px-3 py-2 text-gray-700">{{ $summary['legacy_tipo'] ?? $summary['tipo_calcolo'] ?? '—' }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $summary['totale_preventivo'], 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $summary['totale_consuntivo'], 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $summary['quota_condomini'], 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $summary['quota_inquilini'], 2, ',', '.') }}</td>
</tr>
@endforeach
<tr class="border-t bg-gray-50">
<td class="px-3 py-2 font-semibold" colspan="2">Totale ordinarie senza ACQUA</td>
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) $preventivoOrdTotal, 2, ',', '.') }}</td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
</tr>
@if($preventivoAcquaTotal > 0)
<tr class="border-t bg-blue-50">
<td class="px-3 py-2 font-semibold" colspan="2">ACQUA</td>
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) $preventivoAcquaTotal, 2, ',', '.') }}</td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
</tr>
@endif
<tr class="border-t bg-gray-100">
<td class="px-3 py-2 font-semibold" colspan="2">Totale generale</td>
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) $preventivoGeneralTotal, 2, ',', '.') }}</td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
<td class="px-3 py-2"></td>
</tr>
</tbody>
</table>
</div>
</div>
@endif
</div>
@endif
@if($activeTab === 'riparto')
<div class="mt-6 space-y-6">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-xs text-emerald-900">
<div class="font-semibold">Ripartizione simulata per unità</div>
<div class="mt-1">Sequenza applicata: totale voce tabella/unità quota condomini e quota inquilini rate sui mesi configurati.</div>
<div class="mt-1 text-[11px] text-emerald-800">Le colonne `TAB.A`, `TAB.B`, `ACQUA` ecc. sono il formato base stampabile richiesto per distribuire il riparto per unità immobiliari.</div>
</div>
<div class="overflow-hidden rounded-lg border">
<div class="bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">Matrice ripartizione per unità</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-white">
<tr>
<th class="px-3 py-2 text-left">Unità</th>
<th class="px-3 py-2 text-left">Soggetti</th>
@foreach($this->ripartoPreviewColumns as $column)
<th class="px-3 py-2 text-right">{{ $column['label'] }}</th>
@endforeach
<th class="px-3 py-2 text-right">Condomini </th>
<th class="px-3 py-2 text-right">Inquilini </th>
<th class="px-3 py-2 text-right">Totale </th>
@foreach($this->ripartoRateMonths as $month)
<th class="px-3 py-2 text-right">{{ $month['label'] }}</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse($this->ripartoPreviewRows as $row)
<tr class="border-t align-top">
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ $row['unit_label'] }}</div>
<div class="text-[10px] text-gray-500">{{ $row['stato_occupazione'] ?: 'occupazione non indicata' }}</div>
</td>
<td class="px-3 py-2 text-[11px] text-gray-700">{{ $row['people_summary'] }}</td>
@foreach($this->ripartoPreviewColumns as $column)
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) ($row['columns'][$column['codice']] ?? 0), 2, ',', '.') }}</td>
@endforeach
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $row['totale_condomini'], 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $row['totale_inquilini'], 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) $row['totale_unita'], 2, ',', '.') }}</td>
@foreach($row['rate'] as $rate)
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $rate['amount'], 2, ',', '.') }}</td>
@endforeach
</tr>
@empty
<tr>
<td colspan="99" class="px-3 py-4 text-gray-500">Riparto non disponibile per lo stabile o per l'anno selezionato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if(!empty($this->ripartoPreviewRows))
<div class="rounded-lg border">
<div class="flex flex-wrap items-center justify-between gap-3 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">
<span>Dettaglio soggetti per unità</span>
<select wire:model.live="ripartoFocusUnitId" class="rounded border-gray-300 text-xs">
@foreach($this->ripartoPreviewRows as $row)
<option value="{{ $row['unit_id'] }}">{{ $row['unit_label'] }}</option>
@endforeach
</select>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-white">
<tr>
<th class="px-3 py-2 text-left">Soggetto</th>
<th class="px-3 py-2 text-left">Ruolo</th>
<th class="px-3 py-2 text-right">Quota %</th>
@foreach($this->ripartoPreviewColumns as $column)
<th class="px-3 py-2 text-right">{{ $column['label'] }}</th>
@endforeach
<th class="px-3 py-2 text-right">Totale </th>
</tr>
</thead>
<tbody>
@forelse($this->ripartoFocusBreakdown as $subject)
<tr class="border-t">
<td class="px-3 py-2 font-medium text-gray-900">{{ $subject['name'] }}</td>
<td class="px-3 py-2">{{ $subject['role'] }}</td>
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) $subject['quota_percent'], 2, ',', '.') }}</td>
@foreach($this->ripartoPreviewColumns as $column)
<td class="px-3 py-2 text-right tabular-nums">{{ number_format((float) ($subject['columns'][$column['codice']] ?? 0), 2, ',', '.') }}</td>
@endforeach
<td class="px-3 py-2 text-right font-semibold tabular-nums">{{ number_format((float) $subject['totale'], 2, ',', '.') }}</td>
</tr>
@empty
<tr>
<td colspan="99" class="px-3 py-4 text-gray-500">Nessun soggetto disponibile per il dettaglio dell'unità selezionata.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div>
@endif
@if($activeTab === 'persone')
<div class="mt-6 space-y-4">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
<div class="font-semibold">Controllo persone collegate alle unità</div>
<div class="mt-1">Questo controllo evidenzia unità senza proprietario attivo, unità con solo nominativi legacy e unità occupate da inquilino senza un inquilino attivo collegato.</div>
</div>
<div class="overflow-hidden rounded-lg border">
<div class="bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">Audit collegamenti stabile attivo</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-white">
<tr>
<th class="px-3 py-2 text-left">Unità</th>
<th class="px-3 py-2 text-left">Proprietari</th>
<th class="px-3 py-2 text-left">Inquilini</th>
<th class="px-3 py-2 text-right">Quota prop. %</th>
<th class="px-3 py-2 text-left">Segnalazioni</th>
</tr>
</thead>
<tbody>
@forelse($this->personeCollegateAudit as $audit)
<tr class="border-t align-top">
<td class="px-3 py-2 font-medium text-gray-900">{{ $audit['unit_label'] }}</td>
<td class="px-3 py-2">{{ $audit['owners'] ?: '—' }}</td>
<td class="px-3 py-2">{{ $audit['tenants'] ?: '—' }}</td>
<td class="px-3 py-2 text-right tabular-nums">
@if($audit['owner_quota'] !== null)
{{ number_format((float) $audit['owner_quota'], 2, ',', '.') }}
@else
@endif
</td>
<td class="px-3 py-2">
@if(!empty($audit['flags']))
{{ implode(' · ', $audit['flags']) }}
@else
<span class="text-emerald-700">Nessuna anomalia immediata</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-3 py-4 text-gray-500">Nessun dato persone/unità disponibile per lo stabile attivo.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endif
@if($activeTab === 'incassi')
<div class="mt-6 overflow-hidden rounded-lg border">
<div class="bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-600">
@ -823,7 +1162,7 @@
<div class="font-semibold"> {{ number_format((float)($acqua['totale'] ?? 0), 2, ',', '.') }}</div>
</div>
<div>
<div class="text-[10px] text-gray-500">Totale riparto (dett_tab.cons_euro)</div>
<div class="text-[10px] text-gray-500">Totale riparto (dett_tab.prev_euro)</div>
<div class="font-semibold"> {{ number_format((float)($acqua['riparto_totale'] ?? 0), 2, ',', '.') }}</div>
</div>
<div>
@ -844,7 +1183,7 @@
<th class="px-3 py-2 text-left">Scala</th>
<th class="px-3 py-2 text-left">Interno</th>
<th class="px-3 py-2 text-left">Piano</th>
<th class="px-3 py-2 text-right">Consuntivo </th>
<th class="px-3 py-2 text-right">Preventivo </th>
</tr>
</thead>
<tbody class="divide-y">
@ -855,7 +1194,7 @@
<td class="px-3 py-2">{{ $row['scala'] ?? '—' }}</td>
<td class="px-3 py-2">{{ $row['interno'] ?? '—' }}</td>
<td class="px-3 py-2">{{ $row['piano'] ?? '—' }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float)($row['cons_euro'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float)($row['prev_euro'] ?? 0), 2, ',', '.') }}</td>
</tr>
@empty
<tr>
@ -975,7 +1314,7 @@
<th class="px-3 py-2 text-left">Scala</th>
<th class="px-3 py-2 text-left">Interno</th>
<th class="px-3 py-2 text-left">Piano</th>
<th class="px-3 py-2 text-right">Consuntivo </th>
<th class="px-3 py-2 text-right">Preventivo </th>
</tr>
</thead>
<tbody class="divide-y">
@ -986,7 +1325,7 @@
<td class="px-3 py-2">{{ $row['scala'] ?? '—' }}</td>
<td class="px-3 py-2">{{ $row['interno'] ?? '—' }}</td>
<td class="px-3 py-2">{{ $row['piano'] ?? '—' }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float)($row['cons_euro'] ?? 0), 2, ',', '.') }}</td>
<td class="px-3 py-2 text-right">{{ number_format((float)($row['prev_euro'] ?? 0), 2, ',', '.') }}</td>
</tr>
@empty
<tr>

View File

@ -120,25 +120,28 @@
</div>
<div class="space-y-3">
@forelse($this->recenti as $riga)
<button type="button" wire:click="selectPostIt({{ (int) $riga->id }})" class="block w-full rounded-lg border p-3 text-left {{ (int) $selectedPostItId === (int) $riga->id ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'hover:bg-gray-50' }}">
@forelse($this->recenti as $gruppo)
@php($riga = $gruppo['primary'])
<button type="button" wire:click="selectPostIt({{ (int) $riga->id }})" class="block w-full rounded-lg border p-3 text-left {{ ($gruppo['contains_selected'] ?? false) ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'hover:bg-gray-50' }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<div class="font-medium">{{ $riga->oggetto ?: 'Senza oggetto' }}</div>
<div class="font-medium">{{ $gruppo['caller_label'] ?: ($riga->oggetto ?: 'Senza oggetto') }}</div>
<div class="text-sm text-gray-600">
{{ $riga->nome_chiamante ?: 'Chiamante non identificato' }}
@if($riga->telefono)
· {{ $riga->telefono }}
@if(!empty($gruppo['phone']))
{{ $gruppo['phone'] }}
@elseif($riga->nome_chiamante)
{{ $riga->nome_chiamante }}
@endif
@if($riga->stabile)
· {{ $riga->stabile->denominazione ?: ('Stabile #' . $riga->stabile->id) }}
@endif
</div>
</div>
<div class="text-xs text-gray-500">{{ optional($riga->chiamata_il)->format('d/m/Y H:i') }}</div>
<div class="text-xs text-gray-500">{{ $gruppo['latest_at'] ?? optional($riga->chiamata_il)->format('d/m/Y H:i') }}</div>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[11px]">
<span class="rounded-md bg-slate-100 px-2 py-1 text-slate-700">{{ (int) ($gruppo['count'] ?? 1) }} chiamate</span>
<span class="rounded-md bg-slate-100 px-2 py-1 text-slate-700">{{ $riga->stato }}</span>
<span class="rounded-md bg-blue-100 px-2 py-1 text-blue-800">{{ $riga->priorita }}</span>
@if($riga->assegnazione_tipo)

View File

@ -197,9 +197,9 @@
);
});
},
async buildClientMetadata(file) {
async buildClientMetadata(file, source = 'camera') {
const base = {
source: 'camera',
source,
name: file?.name || '',
size: file?.size || 0,
captured_at: new Date(file?.lastModified || Date.now()).toISOString(),
@ -222,7 +222,7 @@
this.localWaterClientMetadata = Array.from(existing.values());
this.persistClientMetadata();
},
async addFiles(event) {
async addFiles(event, source = 'camera') {
const batchId = Date.now();
const files = Array.from(event?.target?.files || []);
const mapped = files.map((file, index) => ({
@ -235,7 +235,7 @@
const existing = new Map(this.localWaterPreviews.map((item) => [item.key, item]));
mapped.forEach((item) => existing.set(item.key, item));
this.localWaterPreviews = Array.from(existing.values());
this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file))));
this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file, source))));
this.persist();
if (event?.target) {
@ -258,11 +258,17 @@
x-on:ticket-acqua-draft-reset.window="clearAll()"
>
<span class="mb-2 block font-medium">Foto contatore</span>
<div class="flex flex-wrap gap-2">
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta o aggiungi foto
<input type="file" wire:model="pendingWaterPhotos" multiple accept="image/*" capture="environment" class="hidden" x-on:change="addFiles($event)" />
Scatta foto
<input type="file" wire:model="pendingWaterPhotos" multiple accept="image/*" capture="environment" class="hidden" x-on:change="addFiles($event, 'camera')" />
</label>
<div class="mt-1 text-xs text-gray-500">Massimo 4 foto. Se la camera taglia EXIF, salvo comunque GPS/data dal browser quando disponibili.</div>
<label class="inline-flex cursor-pointer items-center rounded-md bg-sky-600 px-3 py-2 text-xs font-medium text-white hover:bg-sky-500">
Aggiungi da libreria
<input type="file" wire:model="pendingWaterLibraryPhotos" multiple accept="image/*" class="hidden" x-on:change="addFiles($event, 'attachment')" />
</label>
</div>
<div class="mt-1 text-xs text-gray-500">Massimo 4 foto. Camera e libreria confluiscono nella stessa coda; quando disponibili salvo EXIF e coordinate GPS senza inventare metadati.</div>
@if(count($this->selectedWaterUploads) > 0)
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@ -295,10 +301,28 @@
<span class="inline-flex items-center rounded-full bg-sky-100 px-2 py-0.5 text-[10px] font-semibold text-sky-800">EXIF</span>
@endif
</div>
@php
$detailRows = [];
if ($hasGps) {
$detailRows[] = [
'label' => 'Coordinate',
'value' => number_format((float) $gpsLat, 5, '.', '') . ', ' . number_format((float) $gpsLng, 5, '.', ''),
];
}
if ($exifDate !== '') {
$detailRows[] = ['label' => 'Data EXIF', 'value' => $exifDate];
}
$device = trim((string) data_get($metadata, 'device', ''));
if ($device !== '') {
$detailRows[] = ['label' => 'Dispositivo', 'value' => $device];
}
@endphp
@include('filament.pages.supporto.partials.photo-metadata', ['badgeRows' => [], 'detailRows' => $detailRows])
<label class="mt-2 block">
<span class="mb-1 block text-[11px] font-medium">Nota sotto la foto</span>
<input type="text" wire:model.defer="waterPhotoDescriptions.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. lettura chiara" />
</label>
<button type="button" wire:click="removeWaterPhoto({{ (int) $upload['index'] }})" class="mt-2 inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
@endforeach
</div>
@ -312,7 +336,7 @@
<textarea rows="3" wire:model.defer="noteGenerali" class="w-full rounded-lg border-gray-300" placeholder="Annotazioni rapide per lo studio..."></textarea>
</label>
<div class="mt-3">
<x-filament::button wire:click="registraLetturaAcqua" wire:loading.attr="disabled" wire:target="registraLetturaAcqua,pendingWaterPhotos">
<x-filament::button wire:click="registraLetturaAcqua" wire:loading.attr="disabled" wire:target="registraLetturaAcqua,pendingWaterPhotos,pendingWaterLibraryPhotos">
<span wire:loading.remove wire:target="registraLetturaAcqua">Registra lettura light</span>
<span wire:loading wire:target="registraLetturaAcqua">Salvataggio...</span>
</x-filament::button>

View File

@ -19,16 +19,8 @@
<div class="flex shrink-0 flex-wrap items-center gap-2">
<button type="button" wire:click="createPostIt" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">
Post-it
Apri Post-it
</button>
<button type="button" wire:click="openTicketMobile" class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-800">
Prendi nota
</button>
@if(!empty($liveIncomingCall['rubrica_url']))
<a href="{{ $liveIncomingCall['rubrica_url'] }}" 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">
Rubrica
</a>
@endif
</div>
</div>
@else