*/ public function normalizeStabile(int $stabileId, int $userId): array { $stats = [ 'services' => 0, 'synced' => 0, 'contracts_before' => 0, 'contracts_after' => 0, 'merged' => 0, ]; if ($stabileId <= 0) { return $stats; } $stats['contracts_before'] = StabileContratto::query() ->where('stabile_id', $stabileId) ->where('tipo_contratto', 'acqua') ->count(); $services = StabileServizio::query() ->with('fornitore:id,ragione_sociale,nome,cognome') ->where('stabile_id', $stabileId) ->where('tipo', 'acqua') ->orderByDesc('attivo') ->orderBy('id') ->get(); $stats['services'] = $services->count(); foreach ($services as $service) { if ($this->syncForService($service, $userId) instanceof StabileContratto) { $stats['synced']++; } } $stats['contracts_after'] = StabileContratto::query() ->where('stabile_id', $stabileId) ->where('tipo_contratto', 'acqua') ->count(); $stats['merged'] = max(0, $stats['contracts_before'] - $stats['contracts_after']); return $stats; } public function syncForService(StabileServizio $service, int $userId): ?StabileContratto { if ((string) ($service->tipo ?? '') !== 'acqua') { return null; } $service->loadMissing('fornitore:id,ragione_sociale,nome,cognome'); $contracts = StabileContratto::query() ->where('stabile_id', (int) $service->stabile_id) ->where('tipo_contratto', 'acqua') ->orderByRaw("CASE WHEN stato = 'attivo' THEN 0 ELSE 1 END") ->orderByDesc('updated_at') ->orderByDesc('id') ->get(); $contract = $this->resolveCanonicalContract($service, $contracts); if (! $contract instanceof StabileContratto) { $contract = new StabileContratto([ 'stabile_id' => (int) $service->stabile_id, 'tipo_contratto' => 'acqua', ]); } $serviceIdentifiers = $this->collectServiceIdentifiers($service); $fornitoreLabel = trim((string) ($service->fornitore?->ragione_sociale ?: trim(($service->fornitore?->nome ?? '') . ' ' . ($service->fornitore?->cognome ?? '')))); $title = trim('Contratto acqua' . ($fornitoreLabel !== '' ? ' - ' . $fornitoreLabel : '')); $contract->fill([ 'fornitore_id' => (int) ($service->fornitore_id ?? 0) ?: null, 'titolo' => $title !== '' ? $title : 'Contratto acqua', 'categoria_impianto' => 'acqua', 'servizio_label' => $service->nome, 'codice_contratto' => $this->nullableString($service->codice_contratto), 'riferimento_esterno' => $this->nullableString($service->codice_utenza ?: $service->codice_cliente), 'stato' => $contract->exists ? ($contract->stato ?: 'attivo'): 'attivo', 'decorrenza_dal' => $contract->decorrenza_dal ?: now()->startOfYear()->toDateString(), 'frequenza_scadenze' => $contract->frequenza_scadenze ?: 'annuale', 'giorni_preavviso' => $contract->giorni_preavviso ?: 30, 'rinnovo_automatico' => $contract->exists ? (bool) $contract->rinnovo_automatico : true, 'codici_collegati' => $this->buildLinkedCodes($service), 'dati_contratto' => array_merge(is_array($contract->dati_contratto) ? $contract->dati_contratto : [], [ 'origine' => 'acqua_contract_sync', 'servizio_id' => (int) $service->id, 'last_synced_at' => now()->toIso8601String(), 'service_identifiers' => $serviceIdentifiers, ]), 'updated_by' => $userId, ]); if (! $contract->exists) { $contract->created_by = $userId; $contract->valuta = 'EUR'; } $contract->save(); $duplicates = $this->resolveDuplicateContracts($contracts, $service, $contract); if ($duplicates !== []) { $this->mergeDuplicateContracts($contract, $duplicates, $userId); $contract->refresh(); } $this->syncGeneratedScadenze($contract, $service); return $contract; } /** @param \\Illuminate\\Support\\Collection $contracts */ private function resolveCanonicalContract(StabileServizio $service, $contracts): ?StabileContratto { $serviceIdentifiers = $this->collectServiceIdentifiers($service); $serviceStrongIdentifiers = $this->collectServiceStrongIdentifiers($service); $serviceWeakIdentifiers = $this->collectServiceWeakIdentifiers($service); $serviceCount = StabileServizio::query() ->where('stabile_id', (int) $service->stabile_id) ->where('tipo', 'acqua') ->count(); $best = null; $bestScore = 0; foreach ($contracts as $contract) { $score = 0; $contractIdentifiers = $this->collectContractIdentifiers($contract); $contractStrongIdentifiers = $this->collectContractStrongIdentifiers($contract); $contractWeakIdentifiers = $this->collectContractWeakIdentifiers($contract); $linkedServiceId = (int) data_get($contract->dati_contratto ?? [], 'servizio_id', 0); if ($linkedServiceId > 0 && $linkedServiceId === (int) $service->id) { $score += 250; } $serviceContract = $this->normalizeIdentifier((string) ($service->codice_contratto ?? '')); $contractCode = $this->normalizeIdentifier((string) ($contract->codice_contratto ?? '')); if ($serviceContract !== '' && $contractCode !== '' && $serviceContract === $contractCode) { $score += 200; } $serviceReference = $this->normalizeIdentifier((string) ($service->codice_utenza ?: '')); $contractReference = $this->normalizeIdentifier((string) ($contract->riferimento_esterno ?? '')); if ($serviceReference !== '' && $contractReference !== '' && $serviceReference === $contractReference) { $score += 90; } $overlap = array_intersect($serviceStrongIdentifiers, $contractStrongIdentifiers); if ($overlap !== []) { $score += 50 * count($overlap); } if ($serviceStrongIdentifiers === [] && $serviceCount === 1) { $weakOverlap = array_intersect($serviceWeakIdentifiers, $contractWeakIdentifiers); if ($weakOverlap !== []) { $score += 20 * count($weakOverlap); } } if ((string) ($contract->stato ?? '') === 'attivo') { $score += 10; } if ((int) ($contract->fornitore_id ?? 0) > 0 && (int) ($contract->fornitore_id ?? 0) === (int) ($service->fornitore_id ?? 0)) { $score += 5; } if ($score > $bestScore) { $best = $contract; $bestScore = $score; } } if ($best instanceof StabileContratto && $bestScore >= 50) { return $best; } if ($contracts->count() === 1 && $serviceCount === 1) { return $contracts->first(); } return null; } /** @param \\Illuminate\\Support\\Collection $contracts * @return array */ private function resolveDuplicateContracts($contracts, StabileServizio $service, StabileContratto $canonical): array { $canonicalCode = $this->normalizeIdentifier((string) ($canonical->codice_contratto ?? '')); $canonicalIdentifiers = $this->collectContractStrongIdentifiers($canonical); $serviceIdentifiers = $this->collectServiceStrongIdentifiers($service); return $contracts ->filter(function (StabileContratto $contract) use ($canonical, $canonicalCode, $canonicalIdentifiers, $serviceIdentifiers): bool { if ((int) $contract->id === (int) $canonical->id) { return false; } $contractCode = $this->normalizeIdentifier((string) ($contract->codice_contratto ?? '')); if ($canonicalCode !== '' && $contractCode !== '' && $canonicalCode === $contractCode) { return true; } $contractIdentifiers = $this->collectContractStrongIdentifiers($contract); if ($canonicalIdentifiers !== [] && $contractIdentifiers !== [] && array_intersect($canonicalIdentifiers, $contractIdentifiers) !== []) { return true; } return $serviceIdentifiers !== [] && $contractIdentifiers !== [] && array_intersect($serviceIdentifiers, $contractIdentifiers) !== []; }) ->values() ->all(); } /** @param array $duplicates */ private function mergeDuplicateContracts(StabileContratto $canonical, array $duplicates, int $userId): void { $mergedIds = []; $linkedCodes = is_array($canonical->codici_collegati) ? $canonical->codici_collegati : []; $meta = is_array($canonical->dati_contratto) ? $canonical->dati_contratto : []; $dirty = false; foreach ($duplicates as $duplicate) { $mergedIds[] = (int) $duplicate->id; if (! $canonical->documento_principale_id && $duplicate->documento_principale_id) { $canonical->documento_principale_id = $duplicate->documento_principale_id; $dirty = true; } if (! $canonical->data_stipula && $duplicate->data_stipula) { $canonical->data_stipula = $duplicate->data_stipula; $dirty = true; } if (! $canonical->decorrenza_dal && $duplicate->decorrenza_dal) { $canonical->decorrenza_dal = $duplicate->decorrenza_dal; $dirty = true; } if (! $canonical->decorrenza_al && $duplicate->decorrenza_al) { $canonical->decorrenza_al = $duplicate->decorrenza_al; $dirty = true; } if (! filled($canonical->note) && filled($duplicate->note)) { $canonical->note = $duplicate->note; $dirty = true; } $linkedCodes = $this->mergeLinkedCodes($linkedCodes, is_array($duplicate->codici_collegati) ? $duplicate->codici_collegati : []); Scadenza::query() ->where('stabile_contratto_id', (int) $duplicate->id) ->update(['stabile_contratto_id' => (int) $canonical->id]); $duplicate->delete(); } if ($mergedIds !== []) { $meta['merged_from_contract_ids'] = array_values(array_unique(array_map('intval', array_merge( array_map('intval', (array) ($meta['merged_from_contract_ids'] ?? [])), $mergedIds, )))); $meta['last_merged_at'] = now()->toIso8601String(); $meta['last_merged_by'] = $userId; $canonical->codici_collegati = $linkedCodes; $canonical->dati_contratto = $meta; $canonical->updated_by = $userId; $dirty = true; } if ($dirty) { $canonical->save(); } } private function syncGeneratedScadenze(StabileContratto $contract, StabileServizio $service): void { Scadenza::query() ->where('stabile_contratto_id', (int) $contract->id) ->where(function ($query): void { $query->where('natura', 'contratto_stabile') ->orWhere(function ($nested): void { $nested->where('natura', 'acqua') ->where('legacy_payload->source', 'acqua_contract_sync'); }); }) ->delete(); foreach ($this->buildContractScheduleDates($contract) as $date) { $popupDal = $date->copy()->subDays(max(0, (int) ($contract->giorni_preavviso ?? 0))); Scadenza::query()->create([ 'stabile_id' => (int) $contract->stabile_id, 'stabile_contratto_id' => (int) $contract->id, 'descrizione_sintetica' => $contract->titolo, 'data_scadenza' => $date->toDateString(), 'natura' => 'contratto_stabile', 'natura_label' => 'Scadenza contratto', 'gestione_tipo' => $contract->tipo_contratto, 'anno' => $date->format('Y'), 'fatto' => 'N', 'tipo_riga' => trim((string) ($contract->servizio_label ?: $contract->tipo_contratto)), 'richiede_pop_up' => true, 'giorni_anticipo' => (int) ($contract->giorni_preavviso ?? 0), 'popup_dal' => $popupDal->toDateString(), 'legacy_payload' => [ 'source' => 'acqua_contract_sync', 'schedule_type' => 'contract', 'codice_contratto' => $contract->codice_contratto, 'riferimento_esterno' => $contract->riferimento_esterno, 'codici_collegati' => $contract->codici_collegati ?? [], ], ]); } $this->syncGeneralReadingScadenze($contract, $service); $this->syncUnitReadingScadenze($contract, $service); } private function syncGeneralReadingScadenze(StabileContratto $contract, StabileServizio $service): void { $rows = StabileServizioLettura::query() ->where('stabile_id', (int) $service->stabile_id) ->where('stabile_servizio_id', (int) $service->id) ->whereNull('unita_immobiliare_id') ->whereNull('lettura_fine') ->whereNotNull('deadline_lettura_at') ->orderBy('deadline_lettura_at') ->get(['id', 'stabile_id', 'deadline_lettura_at', 'riferimento_acquisizione', 'periodo_dal', 'periodo_al']); foreach ($rows as $reading) { $deadline = $reading->deadline_lettura_at instanceof Carbon ? $reading->deadline_lettura_at->copy() : Carbon::parse((string) $reading->deadline_lettura_at); Scadenza::query()->create([ 'stabile_id' => (int) $service->stabile_id, 'stabile_contratto_id' => (int) $contract->id, 'descrizione_sintetica' => 'Lettura acqua generale ' . $this->serviceContractLabel($service), 'data_scadenza' => $deadline->toDateString(), 'natura' => 'acqua', 'natura_label' => 'Lettura acqua generale', 'gestione_tipo' => 'operativa', 'anno' => $deadline->format('Y'), 'fatto' => 'N', 'tipo_riga' => trim((string) ($service->nome ?: 'Acqua')), 'richiede_pop_up' => true, 'giorni_anticipo' => 2, 'popup_dal' => $deadline->copy()->subDays(2)->toDateString(), 'legacy_payload' => [ 'source' => 'acqua_contract_sync', 'schedule_type' => 'general_reading', 'stabile_servizio_id' => (int) $service->id, 'reading_id' => (int) $reading->id, 'reading_reference' => $reading->riferimento_acquisizione, 'periodo_dal' => optional($reading->periodo_dal)->toDateString(), 'periodo_al' => optional($reading->periodo_al)->toDateString(), 'action_url' => TicketAcquaGenerale::getUrl([ 'stabile' => (int) $service->stabile_id, 'servizio' => (int) $service->id, ], panel: 'admin-filament'), ], ]); } } private function syncUnitReadingScadenze(StabileContratto $contract, StabileServizio $service): void { $rows = StabileServizioLettura::query() ->with('unitaImmobiliare:id,codice_unita,scala,interno') ->where('stabile_id', (int) $service->stabile_id) ->where('stabile_servizio_id', (int) $service->id) ->whereNotNull('unita_immobiliare_id') ->whereNull('lettura_fine') ->whereNotNull('deadline_lettura_at') ->orderBy('deadline_lettura_at') ->get(['id', 'stabile_id', 'stabile_servizio_id', 'unita_immobiliare_id', 'deadline_lettura_at', 'riferimento_acquisizione']); foreach ($rows as $reading) { $deadline = $reading->deadline_lettura_at instanceof Carbon ? $reading->deadline_lettura_at->copy() : Carbon::parse((string) $reading->deadline_lettura_at); $unitLabel = trim(implode(' ', array_filter([ trim((string) ($reading->unitaImmobiliare?->codice_unita ?? '')), trim((string) ($reading->unitaImmobiliare?->interno ?? '')), ]))); Scadenza::query()->create([ 'stabile_id' => (int) $service->stabile_id, 'stabile_contratto_id' => (int) $contract->id, 'descrizione_sintetica' => 'Autolettura acqua ' . ($unitLabel !== '' ? $unitLabel : ('UI #' . (int) $reading->unita_immobiliare_id)), 'data_scadenza' => $deadline->toDateString(), 'natura' => 'acqua', 'natura_label' => 'Autolettura acqua unità', 'gestione_tipo' => 'operativa', 'anno' => $deadline->format('Y'), 'fatto' => 'N', 'tipo_riga' => trim((string) ($service->nome ?: 'Acqua')), 'richiede_pop_up' => true, 'giorni_anticipo' => 3, 'popup_dal' => $deadline->copy()->subDays(3)->toDateString(), 'legacy_payload' => [ 'source' => 'acqua_contract_sync', 'schedule_type' => 'unit_reading', 'stabile_servizio_id' => (int) $service->id, 'reading_id' => (int) $reading->id, 'unita_immobiliare_id' => (int) $reading->unita_immobiliare_id, 'reading_reference' => $reading->riferimento_acquisizione, 'action_url' => URL::temporarySignedRoute( 'public.water-reading.show', now()->addDays(90), [ 'servizio' => (int) $service->id, 'unita' => (int) $reading->unita_immobiliare_id, 'request' => (int) $reading->id, ] ), ], ]); } } /** @return array */ private function buildContractScheduleDates(StabileContratto $contract): array { $start = $contract->decorrenza_dal instanceof Carbon ? $contract->decorrenza_dal->copy() : ($contract->decorrenza_al instanceof Carbon ? $contract->decorrenza_al->copy() : now()->copy()); $end = $contract->decorrenza_al instanceof Carbon ? $contract->decorrenza_al->copy() : null; $frequency = trim((string) ($contract->frequenza_scadenze ?? 'una_tantum')); if ($contract->giorno_scadenza) { $start->day(min(28, (int) $contract->giorno_scadenza)); } if ($frequency === '' || $frequency === 'una_tantum') { return [$end ?: $start]; } $dates = []; $cursor = $start->copy(); $limit = $end ? $end->copy() : ($contract->rinnovo_automatico ? $start->copy()->addMonths(24) : $start->copy()->addMonths(12)); while ($cursor <= $limit && count($dates) < 60) { $dates[] = $cursor->copy(); $cursor = match ($frequency) { 'mensile' => $cursor->copy()->addMonthNoOverflow(), 'bimestrale' => $cursor->copy()->addMonthsNoOverflow(2), 'trimestrale' => $cursor->copy()->addMonthsNoOverflow(3), 'semestrale' => $cursor->copy()->addMonthsNoOverflow(6), default => $cursor->copy()->addYearNoOverflow(), }; if ($contract->giorno_scadenza) { $cursor->day(min(28, (int) $contract->giorno_scadenza)); } } return $dates; } /** @return array */ private function collectServiceIdentifiers(StabileServizio $service): array { return array_values(array_filter(array_unique([ $this->normalizeIdentifier((string) ($service->codice_utenza ?? '')), $this->normalizeIdentifier((string) ($service->codice_cliente ?? '')), $this->normalizeIdentifier((string) ($service->codice_contratto ?? '')), $this->normalizeIdentifier((string) ($service->contatore_matricola ?? '')), ]))); } /** @return array */ private function collectServiceStrongIdentifiers(StabileServizio $service): array { return array_values(array_filter(array_unique([ $this->normalizeIdentifier((string) ($service->codice_utenza ?? '')), $this->normalizeIdentifier((string) ($service->codice_contratto ?? '')), $this->normalizeIdentifier((string) ($service->contatore_matricola ?? '')), ]))); } /** @return array */ private function collectServiceWeakIdentifiers(StabileServizio $service): array { return array_values(array_filter(array_unique([ $this->normalizeIdentifier((string) ($service->codice_cliente ?? '')), ]))); } /** @return array */ private function collectContractIdentifiers(StabileContratto $contract): array { $identifiers = [ $this->normalizeIdentifier((string) ($contract->codice_contratto ?? '')), $this->normalizeIdentifier((string) ($contract->riferimento_esterno ?? '')), ]; foreach ((array) ($contract->codici_collegati ?? []) as $row) { if (! is_array($row)) { continue; } $identifiers[] = $this->normalizeIdentifier((string) ($row['valore'] ?? '')); } return array_values(array_filter(array_unique($identifiers))); } /** @return array */ private function collectContractStrongIdentifiers(StabileContratto $contract): array { $identifiers = [ $this->normalizeIdentifier((string) ($contract->codice_contratto ?? '')), ]; foreach ((array) ($contract->codici_collegati ?? []) as $row) { if (! is_array($row)) { continue; } $key = (string) ($row['chiave'] ?? ''); if (! in_array($key, ['utenza', 'codice_contratto', 'matricola_contatore'], true)) { continue; } $identifiers[] = $this->normalizeIdentifier((string) ($row['valore'] ?? '')); } if ($identifiers === [] || count(array_filter($identifiers)) === 0) { $identifiers[] = $this->normalizeIdentifier((string) ($contract->riferimento_esterno ?? '')); } return array_values(array_filter(array_unique($identifiers))); } /** @return array */ private function collectContractWeakIdentifiers(StabileContratto $contract): array { $identifiers = []; foreach ((array) ($contract->codici_collegati ?? []) as $row) { if (! is_array($row) || (string) ($row['chiave'] ?? '') !== 'codice_cliente') { continue; } $identifiers[] = $this->normalizeIdentifier((string) ($row['valore'] ?? '')); } if ($identifiers === []) { $identifiers[] = $this->normalizeIdentifier((string) ($contract->riferimento_esterno ?? '')); } return array_values(array_filter(array_unique($identifiers))); } /** @return array */ private function buildLinkedCodes(StabileServizio $service): array { return array_values(array_filter([ filled($service->codice_utenza) ? ['chiave' => 'utenza', 'valore' => (string) $service->codice_utenza] : null, filled($service->codice_cliente) ? ['chiave' => 'codice_cliente', 'valore' => (string) $service->codice_cliente] : null, filled($service->codice_contratto) ? ['chiave' => 'codice_contratto', 'valore' => (string) $service->codice_contratto] : null, filled($service->contatore_matricola) ? ['chiave' => 'matricola_contatore', 'valore' => (string) $service->contatore_matricola] : null, ])); } /** @param array> $left * @param array> $right * @return array */ private function mergeLinkedCodes(array $left, array $right): array { $map = []; foreach (array_merge($left, $right) as $row) { if (! is_array($row)) { continue; } $chiave = trim((string) ($row['chiave'] ?? '')); $valore = trim((string) ($row['valore'] ?? '')); if ($chiave === '' || $valore === '') { continue; } $map[$chiave . '|' . $valore] = [ 'chiave' => $chiave, 'valore' => $valore, ]; } return array_values($map); } private function normalizeIdentifier(string $value): string { return preg_replace('/[^A-Z0-9]/', '', strtoupper(trim($value))) ?? ''; } private function nullableString(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } private function serviceContractLabel(StabileServizio $service): string { $bits = array_filter([ trim((string) ($service->nome ?? 'Acqua')), trim((string) ($service->codice_contratto ?? '')), trim((string) ($service->codice_utenza ?? '')), ]); return implode(' ', $bits); } }