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 []; } }