$year >= 2000 && $year <= 2100, )); rsort($years); return array_values(array_unique(array_map( fn(int $year): string => $this->legacyGestioneLabel($year), $years, ))); } public function operativeOrdinaryContext(int $stabileId): array { $policy = GestioneVisibility::policyForStabile($stabileId); $fallbackYears = array_values(array_filter( array_map('intval', $policy['countable_years'] ?? []), static fn(int $year): bool => $year >= 2000 && $year <= 2100, )); if ($stabileId <= 0 || ! Schema::hasTable('gestioni_contabili')) { return [ 'years' => $fallbackYears, 'labels' => array_map(fn(int $year): string => $this->legacyGestioneLabel($year), $fallbackYears), 'date_start' => null, 'date_end' => null, ]; } $rows = DB::table('gestioni_contabili') ->where('stabile_id', $stabileId) ->where('tipo_gestione', 'ordinaria') ->where(function ($query): void { $query->where('gestione_attiva', true) ->orWhere('stato', 'aperta'); }) ->orderBy('data_inizio') ->orderBy('id') ->get(['denominazione', 'anno_gestione', 'data_inizio', 'data_fine']); if ($rows->isEmpty()) { return [ 'years' => $fallbackYears, 'labels' => array_map(fn(int $year): string => $this->legacyGestioneLabel($year), $fallbackYears), 'date_start' => null, 'date_end' => null, ]; } $labels = []; $years = []; $start = null; $end = null; foreach ($rows as $row) { $year = is_numeric($row->anno_gestione ?? null) ? (int) $row->anno_gestione : null; if ($year) { $years[] = $year; } $label = $this->extractLegacyGestioneLabel((string) ($row->denominazione ?? '')); if ($label === null && $year) { $label = $this->legacyGestioneLabel($year); } if ($label !== null) { $labels[] = $label; } $rowStart = $this->normalizeDate($row->data_inizio ?? null); $rowEnd = $this->normalizeDate($row->data_fine ?? null); if ($rowStart !== null && ($start === null || $rowStart < $start)) { $start = $rowStart; } if ($rowEnd !== null && ($end === null || $rowEnd > $end)) { $end = $rowEnd; } } $years = array_values(array_unique(array_map('intval', $years))); sort($years); $labels = array_values(array_unique(array_filter($labels))); return [ 'years' => $years !== [] ? $years : $fallbackYears, 'labels' => $labels !== [] ? $labels : array_map(fn(int $year): string => $this->legacyGestioneLabel($year), $fallbackYears), 'date_start' => $start, 'date_end' => $end, ]; } public function searchReceivableTargets(int $stabileId, string $term, int $limit = 15): array { $needle = trim($term); if ($stabileId <= 0 || $needle === '' || ! Schema::hasTable('unita_immobiliari')) { return []; } $rows = DB::table('unita_immobiliari as ui') ->leftJoin('soggetti as s', 's.old_id', '=', 'ui.legacy_cond_id') ->where('ui.stabile_id', $stabileId) ->whereNotNull('ui.legacy_cond_id') ->where(function ($query) use ($needle): void { $like = '%' . $needle . '%'; $query->where('ui.codice_unita', 'like', $like) ->orWhere('ui.denominazione', 'like', $like) ->orWhere('ui.scala', 'like', $like) ->orWhere('ui.interno', 'like', $like) ->orWhereRaw("CONCAT(COALESCE(ui.scala, ''), '/', COALESCE(ui.interno, '')) like ?", [$like]) ->orWhere('s.nome', 'like', $like) ->orWhere('s.cognome', 'like', $like) ->orWhere('s.ragione_sociale', 'like', $like) ->orWhere('s.codice_fiscale', 'like', $like); }) ->orderBy('ui.scala') ->orderBy('ui.interno') ->orderBy('ui.id') ->limit(max(1, min($limit, 50))) ->get([ 'ui.id as unita_id', 'ui.codice_unita', 'ui.denominazione', 'ui.scala', 'ui.interno', 'ui.legacy_cond_id', 's.id as soggetto_id', 's.nome as soggetto_nome', 's.cognome as soggetto_cognome', 's.ragione_sociale as soggetto_rs', 's.codice_fiscale as soggetto_cf', ]); return $rows->map(function (object $row): array { $subjectLabel = trim((string) (($row->soggetto_rs ?? '') ?: trim((string) (($row->soggetto_nome ?? '') . ' ' . ($row->soggetto_cognome ?? ''))))); $unitLabelParts = array_filter([ trim((string) ($row->codice_unita ?? '')), trim((string) ($row->denominazione ?? '')), trim((string) ($row->scala ?? '')) !== '' || trim((string) ($row->interno ?? '')) !== '' ? ('Scala ' . trim((string) ($row->scala ?? '—')) . ' / Int. ' . trim((string) ($row->interno ?? '—'))) : null, ]); return [ 'legacy_cond_id' => trim((string) ($row->legacy_cond_id ?? '')), 'unita_id' => is_numeric($row->unita_id ?? null) ? (int) $row->unita_id : null, 'soggetto_id' => is_numeric($row->soggetto_id ?? null) ? (int) $row->soggetto_id : null, 'label' => implode(' · ', $unitLabelParts), 'subject_label' => $subjectLabel, 'subject_cf' => trim((string) ($row->soggetto_cf ?? '')) ?: null, 'scala' => trim((string) ($row->scala ?? '')) ?: null, 'interno' => trim((string) ($row->interno ?? '')) ?: null, ]; })->all(); } public function ledgerForLegacyCondomin(int $stabileId, string $legacyCondId, ?string $paymentDate = null, int $limit = 120, ?array $allowedGestioni = null): array { $stabile = $this->resolveStabile($stabileId); $codiceStabile = trim((string) ($stabile['codice_stabile'] ?? '')); $legacyCondId = trim($legacyCondId); $operativeContext = $this->operativeOrdinaryContext($stabileId); $allowedGestioni = $allowedGestioni !== null ? array_values(array_filter($allowedGestioni)) : array_values(array_filter($operativeContext['labels'] ?? [])); if ($stabileId <= 0 || $codiceStabile === '' || $legacyCondId === '' || ! Schema::connection('gescon_import')->hasTable('rate_emissioni_dettaglio')) { return [ 'meta' => ['stabile_id' => $stabileId, 'codice_stabile' => $codiceStabile ?: null, 'legacy_cond_id' => $legacyCondId ?: null], 'paid' => [], 'due' => [], 'due_ordinary' => [], 'due_extraordinary' => [], 'totals' => ['paid' => 0.0, 'due' => 0.0], ]; } $ordinaryQuery = DB::connection('gescon_import') ->table('rate_emissioni_dettaglio') ->where('cod_stabile', $codiceStabile) ->where('cod_cond', $legacyCondId); if ($allowedGestioni !== []) { $ordinaryQuery->where(function ($builder) use ($allowedGestioni): void { foreach ($allowedGestioni as $label) { $builder->orWhere('anno_gestione', $label); } $builder->orWhereNull('anno_gestione'); }); } if ($paymentDate) { $end = Carbon::parse($paymentDate)->copy()->addDays(60)->format('Y-m-d'); $start = Carbon::parse($paymentDate)->copy()->subMonths(24)->format('Y-m-d'); $ordinaryQuery->where(function ($builder) use ($start, $end): void { $builder->whereBetween('data_emissione', [$start, $end]) ->orWhereNull('data_emissione'); }); } $rows = $ordinaryQuery ->orderBy('data_emissione') ->orderBy('numero_ricevuta') ->limit(max(1, min($limit, 300))) ->get([ 'id', 'numero_emissione', 'anno_emissione', 'anno_gestione', 'cod_cond', 'cond_inq', 'data_emissione', 'descrizione', 'numero_ricevuta', 'numero_mese', 'importo_dovuto_euro', 'gia_pagato', 'residuo_emesso', 'raggruppamento', 'numero_straordinaria', 'legacy_year', ]) ->map(function (object $row): array { $importoOriginario = (float) ($row->importo_dovuto_euro ?? 0); $residuo = $row->residuo_emesso !== null ? (float) $row->residuo_emesso : $importoOriginario; $pagato = (float) ($row->gia_pagato ?? 0); return [ 'id' => (int) $row->id, 'numero_emissione' => is_numeric($row->numero_emissione ?? null) ? (int) $row->numero_emissione : null, 'anno_emissione' => trim((string) ($row->anno_emissione ?? '')) ?: null, 'gestione' => $row->anno_gestione, 'cod_cond' => $row->cod_cond, 'ci' => $row->cond_inq, 'data_emissione' => $this->normalizeDate($row->data_emissione ?? null), 'descrizione' => trim((string) ($row->descrizione ?? '')) ?: null, 'ricevuta' => $row->numero_ricevuta, 'mese' => $row->numero_mese, 'importo_originario' => $importoOriginario, 'gia_pagato' => $pagato, 'residuo' => $residuo, 'raggruppamento' => $row->raggruppamento, 'numero_straordinaria' => is_numeric($row->numero_straordinaria ?? null) ? (int) $row->numero_straordinaria : null, 'legacy_year' => trim((string) ($row->legacy_year ?? '')) ?: null, ]; }) ->values(); $extraordinaryContext = $this->buildExtraordinaryContext($stabileId, $codiceStabile); $extraordinaryRows = $this->loadPersistentExtraordinaryRows($codiceStabile, $legacyCondId, $limit, $extraordinaryContext, $allowedGestioni); $allRows = $rows->merge($extraordinaryRows)->unique('id')->values(); $emissionLabels = $this->loadEmissionDescriptions($codiceStabile, $allRows->pluck('numero_emissione')->filter()->all()); $classifiedRows = $allRows ->map(fn(array $row): array=> $this->classifyLedgerRow($row, $emissionLabels, $extraordinaryContext)) ->values(); $paid = $classifiedRows ->filter(fn(array $row): bool => abs((float) ($row['gia_pagato'] ?? 0)) > 0.0001) ->values() ->all(); $due = $classifiedRows ->filter(fn(array $row): bool => abs((float) ($row['residuo'] ?? 0)) > 0.0001) ->values() ->all(); $dueOrdinary = array_values(array_filter($due, fn(array $row): bool => ($row['bucket'] ?? 'ordinary') !== 'extraordinary')); $dueExtraordinary = array_values(array_filter($due, fn(array $row): bool => ($row['bucket'] ?? null) === 'extraordinary')); $summary = [ 'by_bucket' => $this->summarizeRowsByBucket($dueOrdinary, $dueExtraordinary), 'by_gestione' => $this->summarizeRowsByGestione($due), 'extraordinary_by_work' => $this->summarizeExtraordinaryByWork($dueExtraordinary), 'extraordinary_missing_works' => $this->detectMissingExtraordinaryWorks($dueExtraordinary, $extraordinaryContext), ]; return [ 'meta' => [ 'stabile_id' => $stabileId, 'codice_stabile' => $codiceStabile, 'legacy_cond_id' => $legacyCondId, ], 'paid' => $paid, 'due' => $due, 'due_ordinary' => $dueOrdinary, 'due_extraordinary' => $dueExtraordinary, 'summary' => $summary, 'totals' => [ 'paid' => array_reduce($paid, fn(float $carry, array $row): float => $carry + (float) ($row['gia_pagato'] ?? 0), 0.0), 'due' => array_reduce($due, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0), 'due_ordinary' => array_reduce($dueOrdinary, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0), 'due_extraordinary' => array_reduce($dueExtraordinary, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0), ], ]; } public function forBankMovement(int $movementId, int $rateLimit = 30): array { if (! Schema::hasTable('contabilita_movimenti_banca')) { return [ 'meta' => ['source' => 'contabilita_movimenti_banca', 'records_count' => 0], 'records' => [], 'export_text' => '', ]; } $movement = DB::table('contabilita_movimenti_banca') ->where('id', $movementId) ->first(); if (! $movement) { return [ 'meta' => ['source' => 'contabilita_movimenti_banca', 'records_count' => 0], 'records' => [], 'export_text' => '', ]; } $stabile = $this->resolveStabile((int) ($movement->stabile_id ?? 0)); $description = trim((string) (($movement->descrizione_estesa ?? '') ?: ($movement->descrizione ?? ''))); $parsed = $this->parseBankDescription($description); $legacyCondomin = $this->resolveLegacyCondominByMovement((int) ($movement->stabile_id ?? 0), (string) ($stabile['codice_stabile'] ?? ''), $parsed); $legacyCondId = trim((string) ($legacyCondomin['legacy_cond_id'] ?? '')) ?: null; $scala = $parsed['scala'] ?? $legacyCondomin['scala'] ?? null; $interno = $parsed['interno'] ?? $legacyCondomin['interno'] ?? null; $nominativo = $legacyCondomin['nome'] ?? $parsed['nominativo'] ?? null; $operativeContext = $this->operativeOrdinaryContext((int) ($movement->stabile_id ?? 0)); $visibleLabels = $this->visibleOrdinaryCompetenceLabels((int) ($movement->stabile_id ?? 0)); $subjectId = is_numeric($legacyCondomin['soggetto_id'] ?? null) ? (int) $legacyCondomin['soggetto_id'] : null; $unitId = is_numeric($legacyCondomin['unita_id'] ?? null) ? (int) $legacyCondomin['unita_id'] : null; $candidateRates = $this->findRateCandidates( (string) ($stabile['codice_stabile'] ?? ''), $legacyCondId, null, $this->normalizeDate($movement->data ?? null), $visibleLabels !== [] ? $visibleLabels : ($operativeContext['labels'] ?? []), $rateLimit, ); $record = [ 'key' => 'mov:' . (int) $movement->id, 'source' => 'contabilita_movimenti_banca', 'protocollo' => null, 'num_incasso' => null, 'data_pag' => $this->normalizeDate($movement->data ?? null), 'data_pag_label' => ! empty($movement->data) ? Carbon::parse((string) $movement->data)->format('d/m/Y') : '—', 'id_condomino' => $legacyCondId, 'sc_int' => $scala && $interno ? strtoupper((string) $scala) . '/' . $interno : null, 'nome_condomino' => $nominativo, 'descrizione_aggintiva' => $description, 'importo' => (float) ($movement->importo ?? 0), 'importo_label' => '€ ' . number_format((float) ($movement->importo ?? 0), 2, ',', '.'), 'subject_id' => $subjectId, 'unit_id' => $unitId, 'payer_reference' => [ 'holder_name' => $parsed['ordinante_nome'] ?? null, 'holder_iban' => $parsed['ordinante_iban'] ?? null, 'bank_label' => $parsed['banca_ordinante'] ?? null, 'bic' => $parsed['banca_bic'] ?? null, 'cro' => $parsed['cro'] ?? null, 'fingerprint' => $parsed['bank_reference_fingerprint'] ?? null, 'matched_from_reference' => (bool) ($legacyCondomin['matched_from_reference'] ?? false), ], 'incasso_candidates' => [], 'movimento_candidates' => [[ 'id' => (int) $movement->id, 'score' => 100.0, 'data' => $this->normalizeDate($movement->data ?? null), 'data_label' => ! empty($movement->data) ? Carbon::parse((string) $movement->data)->format('d/m/Y') : '—', 'importo' => (float) ($movement->importo ?? 0), 'importo_label' => '€ ' . number_format((float) ($movement->importo ?? 0), 2, ',', '.'), 'label' => $description, 'nome' => $nominativo, 'scala_interno' => $scala && $interno ? strtoupper((string) $scala) . '/' . $interno : null, 'riferimento' => 'movimento_banca', 'type' => 'movimento_operativo', ]], 'candidate_rates' => $candidateRates, 'ai_payload' => [ 'source' => 'contabilita_movimenti_banca', 'stabile_id' => (int) ($movement->stabile_id ?? 0), 'codice_stabile' => $stabile['codice_stabile'] ?? null, 'bank_movement' => [ 'id' => (int) $movement->id, 'data' => $this->normalizeDate($movement->data ?? null), 'importo' => (float) ($movement->importo ?? 0), 'descrizione_estesa' => $description, 'scala' => $scala, 'interno' => $interno, 'nominativo' => $nominativo, 'ordinante_nome' => $parsed['ordinante_nome'] ?? null, 'ordinante_iban' => $parsed['ordinante_iban'] ?? null, 'banca_ordinante' => $parsed['banca_ordinante'] ?? null, 'banca_bic' => $parsed['banca_bic'] ?? null, 'cro' => $parsed['cro'] ?? null, 'bank_reference_fingerprint' => $parsed['bank_reference_fingerprint'] ?? null, ], 'candidate_rates' => $candidateRates, ], ]; $record['ai_payload_pretty'] = json_encode($record['ai_payload'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $meta = [ 'stabile_id' => (int) ($movement->stabile_id ?? 0), 'codice_stabile' => $stabile['codice_stabile'] ?? null, 'stabile_label' => $stabile['label'] ?? null, 'source' => 'contabilita_movimenti_banca', 'source_label' => 'Movimento banca operativo', 'records_count' => 1, ]; return [ 'meta' => $meta, 'records' => [$record], 'export_text' => $this->buildExportText($meta, [$record]), ]; } public function forStabile(int $stabileId, int $limit = 20): array { $limit = max(1, min($limit, 100)); $stabile = $this->resolveStabile($stabileId); $rows = $this->loadDomainRows($stabileId, $limit); $source = 'incassi_estratto_conto'; if ($rows === []) { $rows = $this->loadStagingRows($stabile['codice_stabile'] ?? null, $limit); $source = 'gescon_import.in_da_ec'; } if ($rows === []) { $rows = $this->loadApplicationIncassiRows($stabileId, $limit); $source = 'netgescon.incassi'; } $records = array_map(function (object $row) use ($stabileId, $stabile, $source): array { return $this->normalizeRow($row, $stabileId, $stabile, $source); }, $rows); $meta = [ 'stabile_id' => $stabileId, 'codice_stabile' => $stabile['codice_stabile'] ?? null, 'stabile_label' => $stabile['label'] ?? null, 'source' => $source, 'source_label' => match ($source) { 'incassi_estratto_conto' => 'Dominio incassi_estratto_conto', 'netgescon.incassi' => 'Incassi importati da singolo_anno.mdb', default => 'Staging gescon_import.in_da_ec', }, 'records_count' => count($records), ]; return [ 'meta' => $meta, 'records' => $records, 'export_text' => $this->buildExportText($meta, $records), ]; } private function resolveStabile(int $stabileId): array { if (! Schema::hasTable('stabili')) { return []; } $row = DB::table('stabili') ->where('id', $stabileId) ->first(['id', 'codice_stabile', 'denominazione']); if (! $row) { return []; } $codice = trim((string) ($row->codice_stabile ?? '')); $denominazione = trim((string) ($row->denominazione ?? '')); return [ 'id' => (int) $row->id, 'codice_stabile' => $codice !== '' ? $codice : null, 'label' => trim($codice . ' ' . $denominazione) ?: ($codice !== '' ? $codice : ('Stabile #' . $row->id)), ]; } private function loadDomainRows(int $stabileId, int $limit): array { if (! Schema::hasTable('incassi_estratto_conto') || ! Schema::hasTable('gestioni_contabili')) { return []; } return DB::table('incassi_estratto_conto as iec') ->join('gestioni_contabili as gc', 'gc.id', '=', 'iec.gestione_id') ->where('gc.stabile_id', $stabileId) ->orderByDesc('iec.data_operazione') ->orderByDesc('iec.id') ->limit($limit) ->get([ 'iec.*', 'gc.stabile_id as gestione_stabile_id', 'gc.anno_gestione as gestione_anno', ]) ->all(); } private function loadStagingRows(?string $codiceStabile, int $limit): array { if ($codiceStabile === null || ! Schema::connection('gescon_import')->hasTable('in_da_ec')) { return []; } $trimmed = ltrim($codiceStabile, '0'); return DB::connection('gescon_import') ->table('in_da_ec') ->where(function ($query) use ($codiceStabile, $trimmed): void { $query->where('cod_stabile', $codiceStabile); if ($trimmed !== '' && $trimmed !== $codiceStabile) { $query->orWhere('cod_stabile', $trimmed); } }) ->orderByDesc('data_pag') ->orderByDesc('protocollo') ->limit($limit) ->get() ->all(); } private function loadApplicationIncassiRows(int $stabileId, int $limit): array { if (! Schema::hasTable('incassi')) { return []; } return DB::table('incassi') ->where('condominio_id', $stabileId) ->where(function ($query): void { $query->whereNull('movimento_bancario_id') ->orWhere('movimento_bancario_id', 0); }) ->where(function ($query): void { $query->whereNull('stato_riconciliazione') ->orWhereNotIn('stato_riconciliazione', ['automatica', 'manuale', 'riconciliato']); }) ->orderByDesc('data_pagamento') ->orderByDesc('dt_empag') ->orderByDesc('id') ->limit($limit) ->get([ 'id', 'id_incasso_gescon', 'cod_cond_gescon', 'cond_inquil', 'n_riferimento', 'n_ricevuta', 'anno_rif', 'importo_pagato', 'importo_pagato_euro', 'dt_empag', 'data_pagamento', 'descrizione', 'stato_riconciliazione', 'metadati_gescon', ]) ->all(); } private function normalizeRow(object $row, int $stabileId, array $stabile, string $source): array { $meta = $this->decodeArray($row->metadati_gescon ?? $row->payload ?? null); $protocollo = $meta['protocollo'] ?? $row->protocollo ?? $row->id_gescon ?? $row->id_incasso_gescon ?? null; $numIncasso = $meta['num_incasso'] ?? $row->num_incasso ?? $row->riferimento_bancario ?? $row->n_ricevuta ?? $row->n_riferimento ?? null; $idCondomino = $meta['id_condomino'] ?? $row->id_condomino ?? $row->cod_cond_gescon ?? null; $scalaInterno = trim((string) ($meta['sc_int'] ?? $row->sc_int ?? '')); if ($scalaInterno === '' && ! empty($row->pattern_scala) && ! empty($row->pattern_interno)) { $scalaInterno = strtoupper(trim((string) $row->pattern_scala)) . '/' . trim((string) $row->pattern_interno); } $nomeCondomino = trim((string) ($meta['nome_condomino'] ?? $meta['nominativo'] ?? $row->nome_condomino ?? $row->ordinante ?? $row->pattern_nome_estratto ?? '')); $descrizioneAggiuntiva = trim((string) ($meta['descrizione_aggintiva'] ?? $row->descrizione_aggintiva ?? $row->descrizione ?? '')); $importo = (float) ($meta['importo'] ?? $row->importo ?? $row->importo_pagato_euro ?? $row->importo_pagato ?? 0); $dataRaw = $meta['data_pag'] ?? $row->data_pag ?? $row->data_operazione ?? $row->data_pagamento ?? $row->dt_empag ?? null; $data = $this->normalizeDate($dataRaw); $incassoCandidates = $source === 'netgescon.incassi' ? [[ 'id' => (int) ($row->id ?? 0), 'score' => 100.0, 'data' => $data, 'data_label' => $data ? Carbon::parse($data)->format('d/m/Y') : '—', 'importo' => $importo, 'importo_label' => '€ ' . number_format($importo, 2, ',', '.'), 'label' => $descrizioneAggiuntiva !== '' ? $descrizioneAggiuntiva : null, 'nome' => $nomeCondomino !== '' ? $nomeCondomino : null, 'scala_interno' => $scalaInterno !== '' ? $scalaInterno : null, 'riferimento' => trim((string) (($row->n_ricevuta ?? '') !== '' ? 'Ric. ' . $row->n_ricevuta : ($row->n_riferimento ?? ''))), 'type' => 'incasso', ]] : $this->findIncassoCandidates($stabileId, $importo, $data, $nomeCondomino, $scalaInterno); $movimentoCandidates = $this->findMovimentoCandidates($stabileId, $importo, $data, $nomeCondomino, $scalaInterno); $payload = [ 'source' => $source, 'stabile_id' => $stabileId, 'codice_stabile' => $stabile['codice_stabile'] ?? null, 'legacy_entry' => [ 'incasso_id' => $source === 'netgescon.incassi' ? (int) ($row->id ?? 0) : null, 'protocollo' => $protocollo, 'num_incasso' => $numIncasso, 'data_pag' => $data, 'id_condomino' => $idCondomino, 'sc_int' => $scalaInterno !== '' ? $scalaInterno : null, 'nome_condomino' => $nomeCondomino !== '' ? $nomeCondomino : null, 'descrizione_aggintiva' => $descrizioneAggiuntiva !== '' ? $descrizioneAggiuntiva : null, 'importo' => $importo, ], 'incasso_candidates' => $incassoCandidates, 'movimento_candidates' => $movimentoCandidates, 'candidate_rates' => $this->findRateCandidates( (string) ($stabile['codice_stabile'] ?? ''), trim((string) $idCondomino) ?: null, trim((string) ($row->cond_inquil ?? $meta['cond_inquil'] ?? '')) ?: null, $data, $this->visibleOrdinaryCompetenceLabels($stabileId), 20, ), ]; return [ 'key' => match ($source) { 'incassi_estratto_conto' => 'dom:', 'netgescon.incassi' => 'inc:', default => 'stg:', } . (string) ($row->id ?? $protocollo ?? spl_object_id($row)), 'source' => $source, 'protocollo' => $protocollo, 'num_incasso' => $numIncasso, 'data_pag' => $data, 'data_pag_label' => $data ? Carbon::parse($data)->format('d/m/Y') : '—', 'id_condomino' => $idCondomino, 'sc_int' => $scalaInterno !== '' ? $scalaInterno : null, 'nome_condomino' => $nomeCondomino !== '' ? $nomeCondomino : null, 'descrizione_aggintiva' => $descrizioneAggiuntiva !== '' ? $descrizioneAggiuntiva : null, 'importo' => $importo, 'importo_label' => '€ ' . number_format($importo, 2, ',', '.'), 'incasso_candidates' => $incassoCandidates, 'movimento_candidates' => $movimentoCandidates, 'candidate_rates' => $payload['candidate_rates'], 'ai_payload' => $payload, 'ai_payload_pretty' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]; } private function findRateCandidates(string $codiceStabile, ?string $legacyCondId, ?string $condInq, ?string $paymentDate, array $allowedGestioni = [], int $limit = 30): array { if ($codiceStabile === '' || $legacyCondId === null || ! Schema::connection('gescon_import')->hasTable('rate_emissioni_dettaglio')) { return []; } $query = DB::connection('gescon_import') ->table('rate_emissioni_dettaglio') ->where('cod_stabile', $codiceStabile) ->where('cod_cond', $legacyCondId); if ($condInq !== null && $condInq !== '') { $query->where('cond_inq', strtoupper($condInq)); } if ($paymentDate) { $end = Carbon::parse($paymentDate)->copy()->addDays(45)->format('Y-m-d'); $start = Carbon::parse($paymentDate)->copy()->subMonths(18)->format('Y-m-d'); $query->where(function ($builder) use ($start, $end): void { $builder->whereBetween('data_emissione', [$start, $end]) ->orWhereNull('data_emissione'); }); $allowedGestioni = $allowedGestioni !== [] ? array_values(array_unique(array_filter($allowedGestioni))) : $this->nearbyLegacyGestioneLabels($paymentDate); $query->where(function ($builder) use ($allowedGestioni): void { foreach ($allowedGestioni as $label) { $builder->orWhere('anno_gestione', $label); } $builder ->orWhereNull('anno_gestione'); }); } elseif ($allowedGestioni !== []) { $allowedGestioni = array_values(array_unique(array_filter($allowedGestioni))); $query->where(function ($builder) use ($allowedGestioni): void { foreach ($allowedGestioni as $label) { $builder->orWhere('anno_gestione', $label); } $builder->orWhereNull('anno_gestione'); }); } $query->where(function ($builder): void { $builder->where('residuo_emesso', '!=', 0) ->orWhere(function ($inner): void { $inner->whereNull('residuo_emesso') ->where('importo_dovuto_euro', '!=', 0); }); }); return $query ->orderBy('data_emissione') ->orderBy('numero_ricevuta') ->limit(max(1, min($limit, 100))) ->get([ 'id', 'anno_gestione', 'cod_cond', 'cond_inq', 'data_emissione', 'descrizione', 'numero_ricevuta', 'numero_mese', 'importo_dovuto_euro', 'gia_pagato', 'residuo_emesso', 'raggruppamento', ]) ->map(function (object $row): array { $importo = (float) ($row->residuo_emesso ?? $row->importo_dovuto_euro ?? 0); if (abs($importo) < 0.0001) { $importo = (float) ($row->importo_dovuto_euro ?? 0); } return [ 'id' => (int) $row->id, 'gestione' => $row->anno_gestione, 'cod_cond' => $row->cod_cond, 'ci' => $row->cond_inq, 'data_emissione' => $this->normalizeDate($row->data_emissione ?? null), 'descrizione' => trim((string) ($row->descrizione ?? '')) ?: null, 'ricevuta' => $row->numero_ricevuta, 'mese' => $row->numero_mese, 'importo' => $importo, 'importo_originario' => (float) ($row->importo_dovuto_euro ?? 0), 'gia_pagato' => (float) ($row->gia_pagato ?? 0), 'residuo' => $row->residuo_emesso !== null ? (float) $row->residuo_emesso : null, 'raggruppamento' => $row->raggruppamento, ]; }) ->all(); } private function parseBankDescription(string $description): array { $scala = null; $interno = null; if (preg_match('/\bsc\.?\s*([A-Z])\s*(?:\/|-|\s)?\s*int\.?\s*(\d+[A-Z]?)/i', $description, $matches)) { $scala = strtoupper($matches[1]); $interno = strtoupper($matches[2]); } $nominativo = null; if (preg_match('/A VOSTRO FAVORE\s+(.+?)\s+Data Regolamento:/i', $description, $matches)) { $nominativo = ucwords(strtolower(trim($matches[1]))); } if (preg_match('/\b(BARONE\s+MICHELE)\b/i', $description, $matches)) { $nominativo = ucwords(strtolower($matches[1])); } $ordinanteIban = null; if (preg_match('/Coord\.Ordinante:\s*(.+?)(?:\s+Banca Ordinante:|\s+Cro:|\s+Note:|\s+Id\.Operazione:|$)/i', $description, $matches)) { $ordinanteIban = $this->normalizeIbanValue($matches[1]); } $bancaOrdinante = null; $bancaBic = null; if (preg_match('/Banca Ordinante:\s*(.+?)(?:\s+Cro:|\s+Note:|\s+Id\.Operazione:|$)/i', $description, $matches)) { $bancaOrdinante = trim($matches[1]); if (preg_match('/([A-Z0-9]{8,11})\s*$/', $bancaOrdinante, $bicMatches)) { $bancaBic = strtoupper($bicMatches[1]); } } $cro = null; if (preg_match('/Cro:\s*([^\s]+)(?:\s+Note:|\s+Id\.Operazione:|$)/i', $description, $matches)) { $cro = trim($matches[1]); } $ordinanteNome = $nominativo; $fingerprint = $this->buildBankReferenceFingerprint($ordinanteIban, $bancaOrdinante, $bancaBic, $ordinanteNome); return [ 'scala' => $scala, 'interno' => $interno, 'nominativo' => $nominativo, 'ordinante_nome' => $ordinanteNome, 'ordinante_iban' => $ordinanteIban, 'banca_ordinante' => $bancaOrdinante, 'banca_bic' => $bancaBic, 'cro' => $cro, 'bank_reference_fingerprint' => $fingerprint, ]; } private function nearbyLegacyGestioneLabels(string $paymentDate): array { $date = Carbon::parse($paymentDate); $year = (int) $date->format('Y'); $month = (int) $date->format('m'); $currentStart = $month >= 10 ? $year : $year - 1; return [ $this->legacyGestioneLabel($currentStart - 1), $this->legacyGestioneLabel($currentStart), ]; } private function legacyGestioneLabel(int $startYear): string { return $startYear . '/' . substr((string) ($startYear + 1), -2); } private function extractLegacyGestioneLabel(string $value): ?string { if (preg_match('/\b((?:19|20)\d{2}\/\d{2})\b/', $value, $matches)) { return $matches[1]; } return null; } private function resolveLegacyCondominByMovement(int $stabileId, string $codiceStabile, array $parsed): array { $matchedReference = $this->matchSoggettoByBankReference($stabileId, $parsed); if ($matchedReference !== []) { return $matchedReference; } if ($codiceStabile === '' || ! Schema::connection('gescon_import')->hasTable('condomin')) { return []; } $query = DB::connection('gescon_import') ->table('condomin') ->where('cod_stabile', $codiceStabile); if (! empty($parsed['scala']) && ! empty($parsed['interno'])) { $query->where('scala', $parsed['scala'])->where('interno', $parsed['interno']); } elseif (! empty($parsed['nominativo'])) { $condominCols = Schema::connection('gescon_import')->getColumnListing('condomin'); $concatFields = []; foreach (['nom_cond', 'proprietario_denominazione', 'presso', 'e_mail_condomino'] as $f) { if (in_array($f, $condominCols, true)) { $concatFields[] = "COALESCE({$f}, '')"; } } $concatSql = $concatFields !== [] ? "LOWER(CONCAT_WS(' ', " . implode(', ', $concatFields) . "))" : "''"; $tokens = $this->normalizeSearchTokens((string) $parsed['nominativo']); $query->where(function ($builder) use ($tokens, $concatSql): void { foreach ($tokens as $token) { $builder->whereRaw( $concatSql . " like ?", ['%' . $token . '%'] ); } }); } else { return []; } $condominCols = Schema::connection('gescon_import')->getColumnListing('condomin'); $fields = ['cod_cond', 'id_cond', 'scala', 'interno']; foreach (['legacy_id_cond', 'nom_cond', 'proprietario_denominazione'] as $f) { if (in_array($f, $condominCols, true)) { $fields[] = $f; } } $row = $query ->orderByDesc('legacy_year') ->orderByDesc('id') ->first($fields); if (! $row) { return []; } $nome = null; if (in_array('nom_cond', $condominCols, true) && ! empty($row->nom_cond)) { $nome = $row->nom_cond; } elseif (in_array('proprietario_denominazione', $condominCols, true) && ! empty($row->proprietario_denominazione)) { $nome = $row->proprietario_denominazione; } return [ 'legacy_cond_id' => trim((string) ($row->id_cond ?? (in_array('legacy_id_cond', $condominCols, true) ? ($row->legacy_id_cond ?? '') : '') ?? $row->cod_cond ?? '')), 'cod_cond' => trim((string) ($row->cod_cond ?? '')), 'scala' => trim((string) ($row->scala ?? '')) ?: null, 'interno' => trim((string) ($row->interno ?? '')) ?: null, 'nome' => trim((string) $nome) ?: null, 'matched_from_reference' => false, ]; } private function matchSoggettoByBankReference(int $stabileId, array $parsed): array { if ($stabileId <= 0 || ! Schema::hasTable('soggetti_riferimenti_bancari')) { return []; } $iban = $this->normalizeIbanValue($parsed['ordinante_iban'] ?? null); $fingerprint = trim((string) ($parsed['bank_reference_fingerprint'] ?? '')); if ($iban === null && $fingerprint === '') { return []; } $query = DB::table('soggetti_riferimenti_bancari as srb') ->leftJoin('soggetti as s', 's.id', '=', 'srb.soggetto_id') ->where('srb.stabile_id', $stabileId) ->where('srb.attivo', true) ->orderByDesc('srb.ultimo_movimento_data') ->orderByDesc('srb.id'); $query->where(function ($builder) use ($iban, $fingerprint): void { if ($iban !== null) { $builder->orWhere('srb.ordinante_iban', $iban); } if ($fingerprint !== '') { $builder->orWhere('srb.bank_reference_fingerprint', $fingerprint); } }); $row = $query->first([ 'srb.legacy_cond_id', 'srb.soggetto_id', 'srb.unita_id', 'srb.ordinante_nome', 'srb.bank_reference_fingerprint', 's.nome as soggetto_nome', 's.cognome as soggetto_cognome', 's.ragione_sociale as soggetto_rs', ]); if (! $row) { return []; } $name = trim((string) (($row->soggetto_rs ?? '') ?: trim((string) (($row->soggetto_nome ?? '') . ' ' . ($row->soggetto_cognome ?? ''))))); if ($name === '') { $name = trim((string) ($row->ordinante_nome ?? '')); } return [ 'legacy_cond_id' => trim((string) ($row->legacy_cond_id ?? '')) ?: null, 'soggetto_id' => is_numeric($row->soggetto_id ?? null) ? (int) $row->soggetto_id : null, 'unita_id' => is_numeric($row->unita_id ?? null) ? (int) $row->unita_id : null, 'nome' => $name !== '' ? $name : null, 'matched_from_reference' => true, ]; } private function normalizeIbanValue(mixed $value): ?string { $normalized = strtoupper(preg_replace('/\s+/', '', trim((string) ($value ?? ''))) ?? ''); return $normalized !== '' ? $normalized : null; } private function buildBankReferenceFingerprint(?string $ordinanteIban, ?string $bankLabel, ?string $bic, ?string $holderName): ?string { $parts = array_values(array_filter([ $this->normalizeIbanValue($ordinanteIban), trim((string) ($bankLabel ?? '')) !== '' ? strtoupper(trim((string) $bankLabel)) : null, trim((string) ($bic ?? '')) !== '' ? strtoupper(trim((string) $bic)) : null, trim((string) ($holderName ?? '')) !== '' ? strtoupper(trim((string) $holderName)) : null, ])); if ($parts === []) { return null; } return hash('sha256', implode('|', $parts)); } private function normalizeSearchTokens(string $value): array { $value = mb_strtolower(trim($value)); $value = preg_replace('/\b(avv|avv\.|dott|dott\.|ing|ing\.)\b/u', '', $value) ?? $value; $parts = preg_split('/[^a-z0-9]+/u', $value) ?: []; return array_values(array_filter(array_unique($parts), fn(string $part): bool => mb_strlen($part) >= 3)); } private function findIncassoCandidates(int $stabileId, float $importo, ?string $data, string $nomeCondomino, string $scalaInterno): array { if (! Schema::hasTable('incassi')) { return []; } $query = DB::table('incassi') ->where('condominio_id', $stabileId) ->whereBetween('importo_pagato_euro', [$importo - 1, $importo + 1]) ->orderByDesc('id') ->limit(30) ->get([ 'id', 'importo_pagato_euro', 'data_pagamento', 'dt_empag', 'descrizione', 'n_ricevuta', 'n_riferimento', 'metadati_gescon', ]); return $this->scoreAndNormalizeCandidates($query->all(), $importo, $data, $nomeCondomino, $scalaInterno, 'incasso'); } private function findMovimentoCandidates(int $stabileId, float $importo, ?string $data, string $nomeCondomino, string $scalaInterno): array { $operative = $this->findOperativeBankMovementCandidates($stabileId, $importo, $data, $nomeCondomino, $scalaInterno); if ($operative !== []) { return $operative; } if (! Schema::hasTable('movimenti_bancari') || ! Schema::hasTable('gestioni_contabili')) { return []; } $gestioniIds = DB::table('gestioni_contabili') ->where('stabile_id', $stabileId) ->pluck('id') ->map(fn($id) => (int) $id) ->all(); if ($gestioniIds === []) { return []; } $query = DB::table('movimenti_bancari') ->whereIn('gestione_id', $gestioniIds) ->where('tipo_movimento', 'entrata') ->whereBetween('importo', [$importo - 1, $importo + 1]) ->orderByDesc('data_operazione') ->orderByDesc('id') ->limit(30) ->get([ 'id', 'importo', 'data_operazione', 'descrizione_completa', 'controparte_nome', 'pattern_scala', 'pattern_interno', 'stato_riconciliazione', ]); return $this->scoreAndNormalizeCandidates($query->all(), $importo, $data, $nomeCondomino, $scalaInterno, 'movimento'); } private function findOperativeBankMovementCandidates(int $stabileId, float $importo, ?string $data, string $nomeCondomino, string $scalaInterno): array { if (! Schema::hasTable('contabilita_movimenti_banca')) { return []; } $query = DB::table('contabilita_movimenti_banca') ->where('stabile_id', $stabileId) ->where('importo', '>', 0) ->whereBetween('importo', [$importo - 1, $importo + 1]) ->orderByDesc('data') ->orderByDesc('id') ->limit(30) ->get([ 'id', 'importo', 'data', 'descrizione', 'descrizione_estesa', 'registrazione_id', 'match_data', ]); return $this->scoreAndNormalizeCandidates($query->all(), $importo, $data, $nomeCondomino, $scalaInterno, 'movimento_operativo'); } private function scoreAndNormalizeCandidates(array $rows, float $targetImporto, ?string $targetData, string $targetNome, string $targetScalaInterno, string $type): array { $normalized = []; $targetNomeLower = mb_strtolower(trim($targetNome)); $targetScalaInternoLower = mb_strtolower(trim($targetScalaInterno)); foreach ($rows as $row) { $dateValue = $type === 'incasso' ? $this->normalizeDate($row->data_pagamento ?? $row->dt_empag ?? null) : $this->normalizeDate($row->data_operazione ?? $row->data ?? null); $importo = (float) ($type === 'incasso' ? ($row->importo_pagato_euro ?? 0) : ($row->importo ?? 0)); $label = $type === 'incasso' ? trim((string) ($row->descrizione ?? '')) : trim((string) ($row->descrizione_completa ?? $row->descrizione_estesa ?? $row->descrizione ?? '')); $name = $type === 'movimento' ? trim((string) ($row->controparte_nome ?? '')) : ($type === 'movimento_operativo' ? trim((string) ($row->descrizione_estesa ?? $row->descrizione ?? '')) : trim((string) ($this->decodeArray($row->metadati_gescon ?? null)['nominativo'] ?? ''))); $scalaInterno = $type === 'movimento' ? trim((string) (($row->pattern_scala ?? '') !== '' && ($row->pattern_interno ?? '') !== '' ? strtoupper(trim((string) $row->pattern_scala)) . '/' . trim((string) $row->pattern_interno) : '')) : ''; $score = 0; $score += max(0, 40 - (abs($targetImporto - $importo) * 40)); if ($targetData && $dateValue) { $days = abs(Carbon::parse($targetData)->diffInDays(Carbon::parse($dateValue))); $score += max(0, 30 - min($days, 30)); } if ($targetNomeLower !== '' && $name !== '' && str_contains(mb_strtolower($name), $targetNomeLower)) { $score += 20; } if ($targetScalaInternoLower !== '' && $scalaInterno !== '' && mb_strtolower($scalaInterno) === $targetScalaInternoLower) { $score += 10; } $normalized[] = [ 'id' => (int) $row->id, 'score' => round($score, 2), 'data' => $dateValue, 'data_label' => $dateValue ? Carbon::parse($dateValue)->format('d/m/Y') : '—', 'importo' => $importo, 'importo_label' => '€ ' . number_format($importo, 2, ',', '.'), 'label' => $label !== '' ? $label : null, 'nome' => $name !== '' ? $name : null, 'scala_interno' => $scalaInterno !== '' ? $scalaInterno : null, 'riferimento' => $type === 'incasso' ? trim((string) (($row->n_ricevuta ?? '') !== '' ? 'Ric. ' . $row->n_ricevuta : ($row->n_riferimento ?? ''))) : trim((string) ($row->stato_riconciliazione ?? (($row->registrazione_id ?? null) ? 'registrato' : 'da_verificare'))), 'type' => $type, ]; } usort($normalized, fn(array $a, array $b): int => $b['score'] <=> $a['score']); return array_slice($normalized, 0, 3); } private function buildExportText(array $meta, array $records): string { $lines = [ '# HUB incassi read-only', 'stabile_id: ' . ($meta['stabile_id'] ?? '—'), 'codice_stabile: ' . ($meta['codice_stabile'] ?? '—'), 'fonte: ' . ($meta['source_label'] ?? '—'), 'righe: ' . (string) ($meta['records_count'] ?? 0), '', ]; foreach ($records as $index => $record) { $lines[] = '## Riga ' . ($index + 1); $lines[] = $record['ai_payload_pretty']; $lines[] = ''; } return trim(implode("\n", $lines)); } private function decodeArray(mixed $value): array { if (is_array($value)) { return $value; } if (! is_string($value) || trim($value) === '') { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } private function loadPersistentExtraordinaryRows(string $codiceStabile, string $legacyCondId, int $limit, array $extraordinaryContext, array $allowedGestioni = []): \Illuminate\Support\Collection { if (! Schema::connection('gescon_import')->hasTable('rate_emissioni_dettaglio')) { return collect(); } $relevantWorks = array_values(array_filter($extraordinaryContext['works'] ?? [], function (array $work): bool { $text = (string) ($work['search_text'] ?? ''); return str_contains($text, 'atto aer') || str_contains($text, 'asc') || str_contains($text, 'facciat') || str_contains($text, 'corrido'); })); $gestioni = array_values(array_unique(array_filter(array_map( fn(array $work): string => trim((string) ($work['gestione_label'] ?? '')), $relevantWorks, )))); $query = DB::connection('gescon_import') ->table('rate_emissioni_dettaglio') ->where('cod_stabile', $codiceStabile) ->where('cod_cond', $legacyCondId) ->where(function ($builder) use ($gestioni): void { $builder->whereNotNull('numero_straordinaria') ->orWhereIn('numero_emissione', function ($sub): void { $sub->select('numero_emissione') ->from('rate_emissioni') ->where(function ($q): void { $q->where('descrizione', 'like', '%capitolat%') ->orWhere('descrizione', 'like', '%metr%') ->orWhere('descrizione', 'like', '%straord%') ->orWhere('descrizione', 'like', '%facciat%') ->orWhere('descrizione', 'like', '%detraz%') ->orWhere('descrizione', 'like', '%atto aer%') ->orWhere('descrizione', 'like', '%corrido%') ->orWhere('descrizione', 'like', '%asc%'); }); }); if ($gestioni !== []) { $builder->orWhereIn('anno_gestione', $gestioni); } }) ->orderBy('data_emissione') ->orderBy('numero_ricevuta') ->limit(max(1, min($limit, 300))); if ($allowedGestioni !== []) { $query->where(function ($builder) use ($allowedGestioni): void { foreach ($allowedGestioni as $label) { $builder->orWhere('anno_gestione', $label); } $builder->orWhereNull('anno_gestione'); }); } return $query ->get([ 'id', 'numero_emissione', 'anno_emissione', 'anno_gestione', 'cod_cond', 'cond_inq', 'data_emissione', 'descrizione', 'numero_ricevuta', 'numero_mese', 'importo_dovuto_euro', 'gia_pagato', 'residuo_emesso', 'raggruppamento', 'numero_straordinaria', 'legacy_year', ]) ->map(function (object $row): array { $importoOriginario = (float) ($row->importo_dovuto_euro ?? 0); $residuo = $row->residuo_emesso !== null ? (float) $row->residuo_emesso : $importoOriginario; return [ 'id' => (int) $row->id, 'numero_emissione' => is_numeric($row->numero_emissione ?? null) ? (int) $row->numero_emissione : null, 'anno_emissione' => trim((string) ($row->anno_emissione ?? '')) ?: null, 'gestione' => $row->anno_gestione, 'cod_cond' => $row->cod_cond, 'ci' => $row->cond_inq, 'data_emissione' => $this->normalizeDate($row->data_emissione ?? null), 'descrizione' => trim((string) ($row->descrizione ?? '')) ?: null, 'ricevuta' => $row->numero_ricevuta, 'mese' => $row->numero_mese, 'importo_originario' => $importoOriginario, 'gia_pagato' => (float) ($row->gia_pagato ?? 0), 'residuo' => $residuo, 'raggruppamento' => $row->raggruppamento, 'numero_straordinaria' => is_numeric($row->numero_straordinaria ?? null) ? (int) $row->numero_straordinaria : null, 'legacy_year' => trim((string) ($row->legacy_year ?? '')) ?: null, ]; }) ->values(); } private function loadEmissionDescriptions(string $codiceStabile, array $numeroEmissioni): array { $numeroEmissioni = array_values(array_unique(array_filter(array_map('intval', $numeroEmissioni), fn(int $value): bool => $value > 0))); if ($codiceStabile === '' || $numeroEmissioni === [] || ! Schema::connection('gescon_import')->hasTable('rate_emissioni')) { return []; } return DB::connection('gescon_import') ->table('rate_emissioni') ->where('cod_stabile', $codiceStabile) ->whereIn('numero_emissione', $numeroEmissioni) ->get(['numero_emissione', 'descrizione']) ->mapWithKeys(function (object $row): array { return [ (int) $row->numero_emissione => trim((string) ($row->descrizione ?? '')), ]; }) ->all(); } private function buildExtraordinaryContext(int $stabileId, string $codiceStabile): array { $titles = []; $works = []; if ($stabileId > 0 && Schema::hasTable('lavori_straordinari') && Schema::hasTable('gestioni_contabili')) { $workRows = DB::table('lavori_straordinari as ls') ->join('gestioni_contabili as gc', 'gc.id', '=', 'ls.gestione_id') ->where('gc.stabile_id', $stabileId) ->where('gc.tipo_gestione', 'straordinaria') ->orderBy('gc.anno_gestione') ->orderBy('gc.numero_straordinaria') ->orderBy('ls.id') ->get([ 'ls.id', 'ls.legacy_id', 'ls.descrizione', 'gc.anno_gestione', 'gc.numero_straordinaria', 'gc.denominazione', ]); $titles = array_merge( $titles, $workRows ->pluck('descrizione') ->filter() ->map(fn($value): string => trim((string) $value)) ->all() ); $works = array_merge($works, $workRows->map(function (object $row): array { $gestioneYear = is_numeric($row->anno_gestione ?? null) ? (int) $row->anno_gestione : null; return [ 'id' => (int) $row->id, 'legacy_id' => is_numeric($row->legacy_id ?? null) ? (int) $row->legacy_id : null, 'descrizione' => trim((string) ($row->descrizione ?? '')), 'gestione_year' => $gestioneYear, 'gestione_label' => $gestioneYear ? $this->legacyGestioneLabel($gestioneYear) : trim((string) ($row->denominazione ?? '')), 'numero_straordinaria' => is_numeric($row->numero_straordinaria ?? null) ? (int) $row->numero_straordinaria : null, 'search_text' => mb_strtolower(trim((string) ($row->descrizione ?? ''))), 'tokens' => $this->extractClassificationTokens((string) ($row->descrizione ?? '')), ]; })->all()); } if ($codiceStabile !== '' && Schema::connection('gescon_import')->hasTable('straordinarie')) { $titles = array_merge( $titles, DB::connection('gescon_import') ->table('straordinarie') ->where('cod_stabile', $codiceStabile) ->orderBy('id') ->get(['descriz_prev_cons', 'descriz_ricev', 'descriz_ccp']) ->flatMap(function (object $row): array { return [ trim((string) ($row->descriz_prev_cons ?? '')), trim((string) ($row->descriz_ricev ?? '')), trim((string) ($row->descriz_ccp ?? '')), ]; }) ->filter() ->all() ); } $titles = array_values(array_unique(array_filter($titles))); $keywords = []; foreach ($titles as $title) { $keywords = array_merge($keywords, $this->extractClassificationTokens($title)); } $keywords = array_values(array_unique(array_merge($keywords, [ 'straord', 'capitolat', 'metric', 'facciat', 'detraz', 'corrido', 'asc', 'aer', ]))); return [ 'titles' => $titles, 'keywords' => $keywords, 'works' => $works, ]; } private function classifyLedgerRow(array $row, array $emissionLabels, array $extraordinaryContext): array { $emissionLabel = null; $numeroEmissione = (int) ($row['numero_emissione'] ?? 0); if ($numeroEmissione > 0) { $emissionLabel = trim((string) ($emissionLabels[$numeroEmissione] ?? '')) ?: null; } $texts = array_filter([ trim((string) ($row['descrizione'] ?? '')), trim((string) ($emissionLabel ?? '')), ]); $normalizedText = implode(' ', array_map(fn(string $value): string => mb_strtolower($value), $texts)); $matchedWork = $this->matchExtraordinaryWork($row, $emissionLabel, $extraordinaryContext); $isExtraordinary = is_numeric($row['numero_straordinaria'] ?? null) && (int) $row['numero_straordinaria'] > 0; if (! $isExtraordinary) { foreach (($extraordinaryContext['keywords'] ?? []) as $keyword) { if ($keyword !== '' && str_contains($normalizedText, $keyword)) { $isExtraordinary = true; break; } } } if (! $isExtraordinary && is_array($matchedWork)) { $isExtraordinary = true; } $row['emission_label'] = $emissionLabel; $row['bucket'] = $isExtraordinary ? 'extraordinary' : 'ordinary'; $rowDescription = trim((string) ($row['descrizione'] ?? '')); if ($isExtraordinary) { if (is_array($matchedWork)) { $row['extraordinary_work_label'] = $matchedWork['descrizione']; $row['extraordinary_work_key'] = $matchedWork['work_key']; $row['extraordinary_work_gestione'] = $matchedWork['gestione_label']; $row['extraordinary_work_numero'] = $matchedWork['numero_straordinaria']; } } $row['display_description'] = $row['extraordinary_work_label'] ?? ($rowDescription !== '' ? $rowDescription : $emissionLabel); return $row; } private function matchExtraordinaryWork(array $row, ?string $emissionLabel, array $extraordinaryContext): ?array { $works = array_values(array_filter($extraordinaryContext['works'] ?? [], fn(array $work): bool => ($work['descrizione'] ?? '') !== '')); if ($works === []) { return null; } $texts = array_filter([ trim((string) ($row['descrizione'] ?? '')), trim((string) ($emissionLabel ?? '')), ]); $normalizedText = mb_strtolower(implode(' ', $texts)); $gestioneLabel = trim((string) ($row['gestione'] ?? '')); $rowDate = is_string($row['data_emissione'] ?? null) ? $row['data_emissione'] : null; $best = null; $bestScore = 0; foreach ($works as $work) { $score = 0; $workTokens = array_values(array_filter($work['tokens'] ?? [])); $workText = (string) ($work['search_text'] ?? ''); if ($gestioneLabel !== '' && $gestioneLabel === ($work['gestione_label'] ?? '')) { $score += 30; } foreach ($workTokens as $token) { if ($token !== '' && str_contains($normalizedText, $token)) { $score += 20; } } if (str_contains($normalizedText, 'capitolat') || str_contains($normalizedText, 'metric')) { if (str_contains($workText, 'facciat')) { $score += 40; } if (str_contains($workText, 'asc')) { $score += 10; } } if ($rowDate !== null) { if ($rowDate >= '2024-07-01' && $rowDate <= '2024-08-31' && (str_contains($workText, 'atto aer') || str_contains($workText, 'aer')) && (str_contains($normalizedText, '1/2 rata') || str_contains($normalizedText, '2/2 rata'))) { $score += 50; } if ($rowDate >= '2025-03-01' && $rowDate <= '2025-03-31') { if (str_contains($workText, 'asc') && ! str_contains($normalizedText, 'capitolat')) { $score += 35; } if (str_contains($workText, 'facciat') && str_contains($normalizedText, 'capitolat')) { $score += 25; } } if ($rowDate >= '2026-01-01' && str_contains($workText, 'corrido')) { $score += 45; } } if ($score > $bestScore) { $bestScore = $score; $best = $work; } } if (! is_array($best) || $bestScore < 45) { return null; } return [ 'work_key' => ($best['gestione_label'] ?? 'S') . '#' . ($best['numero_straordinaria'] ?? '0') . ':' . ($best['id'] ?? '0'), 'descrizione' => $best['descrizione'], 'gestione_label' => $best['gestione_label'] ?? null, 'numero_straordinaria' => $best['numero_straordinaria'] ?? null, ]; } private function summarizeRowsByBucket(array $dueOrdinary, array $dueExtraordinary): array { return [ ['label' => 'Ordinarie', 'bucket' => 'ordinary', 'rows' => count($dueOrdinary), 'total' => array_reduce($dueOrdinary, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0)], ['label' => 'Straordinarie', 'bucket' => 'extraordinary', 'rows' => count($dueExtraordinary), 'total' => array_reduce($dueExtraordinary, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0)], ['label' => 'Totale generale dovuto', 'bucket' => 'all', 'rows' => count($dueOrdinary) + count($dueExtraordinary), 'total' => array_reduce($dueOrdinary, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0) + array_reduce($dueExtraordinary, fn(float $carry, array $row): float => $carry + (float) ($row['residuo'] ?? 0), 0.0)], ]; } private function summarizeRowsByGestione(array $rows): array { $summary = []; foreach ($rows as $row) { $label = trim((string) ($row['gestione'] ?? 'Senza gestione')) ?: 'Senza gestione'; if (! isset($summary[$label])) { $summary[$label] = [ 'gestione' => $label, 'ordinary_total' => 0.0, 'extraordinary_total' => 0.0, 'general_total' => 0.0, ]; } $amount = (float) ($row['residuo'] ?? 0); $summary[$label]['general_total'] += $amount; if (($row['bucket'] ?? 'ordinary') === 'extraordinary') { $summary[$label]['extraordinary_total'] += $amount; } else { $summary[$label]['ordinary_total'] += $amount; } } uasort($summary, fn(array $a, array $b): int => strcmp((string) $a['gestione'], (string) $b['gestione'])); return array_values($summary); } private function summarizeExtraordinaryByWork(array $rows): array { $summary = []; foreach ($rows as $row) { $key = trim((string) ($row['extraordinary_work_key'] ?? '')); $label = trim((string) ($row['extraordinary_work_label'] ?? $row['display_description'] ?? $row['descrizione'] ?? 'Straordinaria')) ?: 'Straordinaria'; $gestione = trim((string) ($row['extraordinary_work_gestione'] ?? $row['gestione'] ?? '—')) ?: '—'; if ($key === '') { $key = $gestione . ':' . $label; } if (! isset($summary[$key])) { $summary[$key] = [ 'key' => $key, 'label' => $label, 'gestione' => $gestione, 'rows' => 0, 'total' => 0.0, ]; } $summary[$key]['rows']++; $summary[$key]['total'] += (float) ($row['residuo'] ?? 0); } usort($summary, fn(array $a, array $b): int => strcmp($a['gestione'] . $a['label'], $b['gestione'] . $b['label'])); return array_values($summary); } private function detectMissingExtraordinaryWorks(array $rows, array $extraordinaryContext): array { $matchedKeys = array_values(array_unique(array_filter(array_map( fn(array $row): string => trim((string) ($row['extraordinary_work_key'] ?? '')), $rows, )))); $rowGestioni = array_values(array_unique(array_filter(array_map( fn(array $row): string => trim((string) ($row['gestione'] ?? '')), $rows, )))); $works = array_values(array_filter($extraordinaryContext['works'] ?? [], function (array $work): bool { $text = (string) ($work['search_text'] ?? ''); return str_contains($text, 'atto aer') || str_contains($text, 'asc') || str_contains($text, 'facciat') || str_contains($text, 'corrido'); })); if ($rowGestioni !== []) { $works = array_values(array_filter($works, fn(array $work): bool => in_array(trim((string) ($work['gestione_label'] ?? '')), $rowGestioni, true))); } $missing = []; foreach ($works as $work) { $key = ($work['gestione_label'] ?? 'S') . '#' . ($work['numero_straordinaria'] ?? '0') . ':' . ($work['id'] ?? '0'); if (in_array($key, $matchedKeys, true)) { continue; } $missing[] = [ 'label' => $work['descrizione'], 'gestione' => $work['gestione_label'] ?? '—', 'numero_straordinaria' => $work['numero_straordinaria'] ?? null, 'message' => 'Lavoro straordinario presente nelle gestioni ma senza riga residua materializzata per questo C/I: controllare import di addebiti/rate.', ]; } return $missing; } private function extractClassificationTokens(string $value): array { $normalized = mb_strtolower($value); $normalized = preg_replace('/[^a-z0-9]+/u', ' ', $normalized) ?? ''; $parts = preg_split('/\s+/', trim($normalized)) ?: []; $stopwords = [ 'anno', 'anni', 'lavori', 'lavoro', 'rata', 'rate', 'della', 'delle', 'degli', 'dello', 'dell', 'del', 'dei', 'di', 'da', 'per', 'con', 'nel', 'nella', 'sc', 'scala', 'lato', 'quote', 'est', 'ovest', 'sud', 'nord', 'parrocchia', 'messa', 'sicurezza', 'ripristino', ]; return array_values(array_filter(array_unique(array_map( static fn(string $part): string => mb_substr($part, 0, 8), $parts, )), static fn(string $part): bool => mb_strlen($part) >= 4 && ! in_array($part, $stopwords, true))); } private function normalizeDate(mixed $value): ?string { if ($value === null || $value === '') { return null; } try { return Carbon::parse((string) $value)->format('Y-m-d'); } catch (\Throwable) { return null; } } }