netgescon-day0/app/Services/Contabilita/IncassiHubReadService.php

1677 lines
73 KiB
PHP
Executable File

<?php
namespace App\Services\Contabilita;
use App\Support\GestioneVisibility;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class IncassiHubReadService
{
public function visibleOrdinaryCompetenceLabels(int $stabileId): array
{
$policy = GestioneVisibility::policyForStabile($stabileId);
$years = array_values(array_filter(
array_map('intval', $policy['visible_years'] ?? []),
static fn(int $year): bool => $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;
}
}
}