netgescon-day0/app/Services/Consumi/ConsumiAcquaIngestionService.php

461 lines
22 KiB
PHP
Executable File

<?php
namespace App\Services\Consumi;
use App\Models\CategoriaTicket;
use App\Models\FatturaElettronica;
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use App\Models\Ticket;
use Illuminate\Support\Facades\Schema;
class ConsumiAcquaIngestionService
{
/**
* @param array{codici?: array{utenza?: string|null, cliente?: string|null, contratto?: string|null}, contatore?: array{matricola?: string|null}, consumi?: array<int, array{dal?: string|null, al?: string|null, valore?: float|null, unita?: string|null, tipologia_lettura?: string|null}>} $parsed
* @return array{status: string, servizio_id?: int, created_letture?: int, updated_letture?: int, tickets?: int, message?: string}
*/
public function ingest(
FatturaElettronica $fattura,
array $parsed,
?int $voceSpesaId,
int $userId,
bool $createTicketOnMismatch = true,
?int $forceServizioId = null,
): array {
if (! Schema::hasTable('stabile_servizi') || ! Schema::hasTable('stabile_servizio_letture')) {
return ['status' => 'skipped', 'message' => 'Tabelle servizi/letture non presenti'];
}
$stabileId = (int) ($fattura->stabile_id ?: 0);
if ($stabileId <= 0) {
return ['status' => 'error', 'message' => 'Stabile non disponibile'];
}
$storedPayload = [];
$storedRaw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : '';
if ($storedRaw !== '') {
$decoded = json_decode($storedRaw, true);
if (is_array($decoded) && (($decoded['type'] ?? null) === 'acqua')) {
$storedPayload = $decoded;
}
}
if ((empty($parsed['codici']) || ! is_array($parsed['codici'])) && is_array($storedPayload['codici'] ?? null)) {
$parsed['codici'] = $storedPayload['codici'];
}
if ((empty($parsed['contatore']) || ! is_array($parsed['contatore'])) && is_array($storedPayload['contatore'] ?? null)) {
$parsed['contatore'] = $storedPayload['contatore'];
}
if ((empty($parsed['consumi']) || ! is_array($parsed['consumi'])) && is_array($storedPayload['consumi'] ?? null)) {
$parsed['consumi'] = $storedPayload['consumi'];
}
if ((empty($parsed['finestra_autolettura']) || ! is_array($parsed['finestra_autolettura'])) && is_array($storedPayload['finestra_autolettura'] ?? null)) {
$parsed['finestra_autolettura'] = $storedPayload['finestra_autolettura'];
}
if ((empty($parsed['riepilogo_letture']) || ! is_array($parsed['riepilogo_letture'])) && is_array($storedPayload['riepilogo_letture'] ?? null)) {
$parsed['riepilogo_letture'] = $storedPayload['riepilogo_letture'];
}
if ((empty($parsed['pagamento']) || ! is_array($parsed['pagamento'])) && is_array($storedPayload['pagamento'] ?? null)) {
$parsed['pagamento'] = $storedPayload['pagamento'];
}
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$matricola = is_string($contatore['matricola'] ?? null) ? trim((string) $contatore['matricola']) : '';
$utenza = is_string($codici['utenza'] ?? null) ? trim((string) $codici['utenza']) : '';
$cliente = is_string($codici['cliente'] ?? null) ? trim((string) $codici['cliente']) : '';
$contratto = is_string($codici['contratto'] ?? null) ? trim((string) $codici['contratto']) : '';
$norm = static function (?string $value): string {
$v = is_string($value) ? $value : '';
$v = strtoupper(trim($v));
$v = preg_replace('/\s+/', '', $v) ?? $v;
return $v;
};
$error = is_string($parsed['error'] ?? null) ? trim((string) $parsed['error']) : '';
$tickets = 0;
// Se non abbiamo dati utili, è molto probabile che il layout PDF sia cambiato o che non sia un PDF “utenze”.
// In modalità batch, vogliamo segnalarlo con un ticket (se richiesto).
$hasAnyId = ($matricola !== '') || ($utenza !== '') || ($cliente !== '') || ($contratto !== '');
if (! $hasAnyId && count($consumi) < 1) {
if ($createTicketOnMismatch) {
$tickets += $this->openTicket(
$stabileId,
$userId,
(int) ($fattura->fornitore_id ?: 0) ?: null,
'Parser acqua: dati non trovati',
'FE #' . (int) $fattura->id . ' (' . ($fattura->sdi_file ?? 'n/d') . ")\n"
. ($error !== '' ? ('Errore: ' . $error . "\n") : '')
. 'Nessun codice utenza/cliente/contratto, nessuna matricola contatore e nessun periodo consumo rilevato.',
) ? 1 : 0;
}
return [
'status' => 'no-data',
'tickets' => $tickets,
'message' => $error !== '' ? $error : 'Dati acqua non trovati nel PDF',
];
}
$servizio = null;
if ($forceServizioId) {
$servizio = StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'acqua')
->whereKey($forceServizioId)
->first();
if (! $servizio) {
return ['status' => 'error', 'message' => 'Servizio acqua selezionato non trovato'];
}
// Mismatch detection: se il servizio ha già un identificativo e il PDF ne porta un altro, apri ticket e non agganciare.
$mismatch = false;
$why = [];
if (is_string($servizio->contatore_matricola) && $servizio->contatore_matricola !== '' && $matricola !== '' && $servizio->contatore_matricola !== $matricola) {
$mismatch = true;
$why[] = 'matricola diversa (servizio ' . $servizio->contatore_matricola . ' vs PDF ' . $matricola . ')';
}
if (is_string($servizio->codice_utenza) && $servizio->codice_utenza !== '' && $utenza !== '' && $servizio->codice_utenza !== $utenza) {
$mismatch = true;
$why[] = 'utenza diversa (servizio ' . $servizio->codice_utenza . ' vs PDF ' . $utenza . ')';
}
if (is_string($servizio->codice_cliente) && $servizio->codice_cliente !== '' && $cliente !== '' && $servizio->codice_cliente !== $cliente) {
$mismatch = true;
$why[] = 'cliente diverso (servizio ' . $servizio->codice_cliente . ' vs PDF ' . $cliente . ')';
}
if (is_string($servizio->codice_contratto) && $servizio->codice_contratto !== '' && $contratto !== '' && $servizio->codice_contratto !== $contratto) {
$mismatch = true;
$why[] = 'contratto diverso (servizio ' . $servizio->codice_contratto . ' vs PDF ' . $contratto . ')';
}
if ($mismatch && $createTicketOnMismatch) {
$tickets += $this->openTicket(
$stabileId,
$userId,
(int) ($fattura->fornitore_id ?: 0) ?: null,
'Parser acqua: mismatch utenza/contatore',
'FE #' . (int) $fattura->id . ' (' . ($fattura->sdi_file ?? 'n/d') . ")\n" . implode("; ", $why),
) ? 1 : 0;
return ['status' => 'mismatch', 'tickets' => $tickets, 'message' => implode('; ', $why)];
}
}
if (! $servizio) {
$candidates = StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'acqua')
->orderByDesc('attivo')
->orderByDesc('id')
->limit(50)
->get();
$targetMatricola = $norm($matricola);
$targetUtenza = $norm($utenza);
$targetCliente = $norm($cliente);
$targetContratto = $norm($contratto);
$best = null;
$bestScore = 0;
$bestTie = 0;
foreach ($candidates as $cand) {
$score = 0;
$candMatricola = $norm(is_string($cand->contatore_matricola) ? $cand->contatore_matricola : null);
$candUtenza = $norm(is_string($cand->codice_utenza) ? $cand->codice_utenza : null);
$candCliente = $norm(is_string($cand->codice_cliente) ? $cand->codice_cliente : null);
$candContratto = $norm(is_string($cand->codice_contratto) ? $cand->codice_contratto : null);
if ($targetMatricola !== '' && $candMatricola !== '' && $targetMatricola === $candMatricola) {
$score += 4;
}
if ($targetUtenza !== '' && $candUtenza !== '' && $targetUtenza === $candUtenza) {
$score += 3;
}
if ($targetCliente !== '' && $candCliente !== '' && $targetCliente === $candCliente) {
$score += 2;
}
if ($targetContratto !== '' && $candContratto !== '' && $targetContratto === $candContratto) {
$score += 2;
}
if ($score > $bestScore) {
$best = $cand;
$bestScore = $score;
$bestTie = 0;
} elseif ($score > 0 && $score === $bestScore) {
$bestTie++;
}
}
if ($best && $bestScore > 0) {
if ($bestTie > 0 && $createTicketOnMismatch) {
$tickets += $this->openTicket(
$stabileId,
$userId,
(int) ($fattura->fornitore_id ?: 0) ?: null,
'Parser acqua: utenza ambigua',
'FE #' . (int) $fattura->id . ' (' . ($fattura->sdi_file ?? 'n/d') . ")\n"
. 'Più utenze acqua compatibili con gli identificativi del PDF. ' .
'Matricola=' . ($matricola !== '' ? $matricola : 'n/d') . ', Utenza=' . ($utenza !== '' ? $utenza : 'n/d') . ', Cliente=' . ($cliente !== '' ? $cliente : 'n/d') . ', Contratto=' . ($contratto !== '' ? $contratto : 'n/d'),
) ? 1 : 0;
}
$servizio = $best;
}
if (! $servizio) {
$name = 'Acqua';
if ($matricola !== '') {
$name .= ' - Contatore ' . $matricola;
} elseif ($utenza !== '') {
$name .= ' - Utenza ' . $utenza;
}
if ($candidates->count() > 0 && $createTicketOnMismatch) {
$tickets += $this->openTicket(
$stabileId,
$userId,
(int) ($fattura->fornitore_id ?: 0) ?: null,
'Parser acqua: utenza non riconosciuta',
'FE #' . (int) $fattura->id . ' (' . ($fattura->sdi_file ?? 'n/d') . ")\n"
. 'Identificativi PDF non agganciati a nessuna utenza acqua esistente. ' .
'Matricola=' . ($matricola !== '' ? $matricola : 'n/d') . ', Utenza=' . ($utenza !== '' ? $utenza : 'n/d') . ', Cliente=' . ($cliente !== '' ? $cliente : 'n/d') . ', Contratto=' . ($contratto !== '' ? $contratto : 'n/d'),
) ? 1 : 0;
}
$servizio = new StabileServizio([
'stabile_id' => $stabileId,
'tipo' => 'acqua',
'nome' => $name,
'attivo' => true,
]);
}
}
if ((int) ($fattura->fornitore_id ?? 0) > 0 && ! $servizio->fornitore_id) {
$servizio->fornitore_id = (int) $fattura->fornitore_id;
}
if ($voceSpesaId && $voceSpesaId > 0) {
$servizio->voce_spesa_id = $voceSpesaId;
}
// Aggiorna codici servizio solo se non presenti (non sovrascrive).
if ($utenza !== '' && (! is_string($servizio->codice_utenza) || $servizio->codice_utenza === '')) {
$servizio->codice_utenza = $utenza;
}
if ($cliente !== '' && (! is_string($servizio->codice_cliente) || $servizio->codice_cliente === '')) {
$servizio->codice_cliente = $cliente;
}
if ($contratto !== '' && (! is_string($servizio->codice_contratto) || $servizio->codice_contratto === '')) {
$servizio->codice_contratto = $contratto;
}
if ($matricola !== '' && (! is_string($servizio->contatore_matricola) || $servizio->contatore_matricola === '')) {
$servizio->contatore_matricola = $matricola;
}
$servizio->save();
$generalRowTouched = $this->syncGeneralReadingRow(
$fattura,
$servizio,
$parsed,
$voceSpesaId,
$userId,
);
$created = 0;
$updated = 0;
$importo = null;
if (count($consumi) === 1 && is_numeric($fattura->totale)) {
$importo = (float) $fattura->totale;
}
foreach ($consumi as $c) {
$dal = is_string($c['dal'] ?? null) ? (string) $c['dal'] : null;
$al = is_string($c['al'] ?? null) ? (string) $c['al'] : null;
$val = isset($c['valore']) && is_numeric($c['valore']) ? (float) $c['valore'] : null;
$unita = is_string($c['unita'] ?? null) ? trim((string) $c['unita']) : '';
$tipo = is_string($c['tipologia_lettura'] ?? null) ? trim((string) $c['tipologia_lettura']) : null;
$lettura = StabileServizioLettura::query()->firstOrNew([
'stabile_id' => $stabileId,
'stabile_servizio_id' => (int) $servizio->id,
'fattura_elettronica_id' => (int) $fattura->id,
'periodo_dal' => $dal,
'periodo_al' => $al,
]);
$isNew = ! $lettura->exists;
$lettura->fill([
'fornitore_id' => (int) ($fattura->fornitore_id ?: 0) ?: null,
'voce_spesa_id' => $voceSpesaId ?: ($servizio->voce_spesa_id ? (int) $servizio->voce_spesa_id : null),
'tipologia_lettura' => $tipo,
'consumo_valore' => $val,
'consumo_unita' => $unita !== '' ? $unita : 'mc',
'importo_totale' => $importo,
'raw' => [
'source' => 'pdf_ocr',
'codici' => $parsed['codici'] ?? null,
'contatore' => $parsed['contatore'] ?? null,
'consumo' => $c,
],
'created_by' => $userId > 0 ? $userId : null,
]);
$lettura->save();
if ($isNew) {
$created++;
} else {
$updated++;
}
}
return [
'status' => 'ok',
'servizio_id' => (int) $servizio->id,
'created_letture' => $created,
'updated_letture' => $updated,
'general_row' => $generalRowTouched,
'tickets' => $tickets,
];
}
/** @param array<string,mixed> $parsed */
private function syncGeneralReadingRow(
FatturaElettronica $fattura,
StabileServizio $servizio,
array $parsed,
?int $voceSpesaId,
int $userId,
): bool {
$window = is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : [];
$riepilogo = is_array($parsed['riepilogo_letture'] ?? null) ? array_values($parsed['riepilogo_letture']) : [];
$consumi = is_array($parsed['consumi'] ?? null) ? array_values($parsed['consumi']) : [];
if ($window === [] && $riepilogo === []) {
return false;
}
$firstSummary = is_array($riepilogo[0] ?? null) ? $riepilogo[0] : [];
$firstConsumo = is_array($consumi[0] ?? null) ? $consumi[0] : [];
$periodoDal = $window['dal'] ?? ($firstSummary['data_da'] ?? ($firstConsumo['dal'] ?? null));
$periodoAl = $window['al'] ?? ($firstSummary['data_a'] ?? ($firstConsumo['al'] ?? null));
$tipologia = is_string($firstSummary['tipologia'] ?? null)
? trim((string) $firstSummary['tipologia'])
: (is_string($firstConsumo['tipologia_lettura'] ?? null) ? trim((string) $firstConsumo['tipologia_lettura']) : 'finestra_autolettura');
$letturaInizio = isset($firstSummary['valore_da']) && is_numeric($firstSummary['valore_da']) ? (float) $firstSummary['valore_da'] : null;
$letturaFine = isset($firstSummary['valore_a']) && is_numeric($firstSummary['valore_a']) ? (float) $firstSummary['valore_a'] : null;
$consumoValore = isset($firstSummary['consumo']) && is_numeric($firstSummary['consumo'])
? (float) $firstSummary['consumo']
: (isset($firstConsumo['valore']) && is_numeric($firstConsumo['valore']) ? (float) $firstConsumo['valore'] : null);
$tipoNorm = mb_strtolower($tipologia);
$workflow = str_contains($tipoNorm, 'stim')
? 'lettura_stimata_fe'
: (str_contains($tipoNorm, 'effett') ? 'lettura_effettiva_fe' : (str_contains($tipoNorm, 'auto') ? 'autolettura_utente_fe' : 'estrazione_fe'));
$row = StabileServizioLettura::query()->firstOrNew([
'stabile_id' => (int) $fattura->stabile_id,
'stabile_servizio_id' => (int) $servizio->id,
'fattura_elettronica_id' => (int) $fattura->id,
'unita_immobiliare_id' => null,
]);
$row->fill([
'fornitore_id' => (int) ($fattura->fornitore_id ?: 0) ?: null,
'voce_spesa_id' => $voceSpesaId ?: ($servizio->voce_spesa_id ? (int) $servizio->voce_spesa_id : null),
'periodo_dal' => $periodoDal,
'periodo_al' => $periodoAl,
'tipologia_lettura' => $tipologia !== '' ? $tipologia : null,
'canale_acquisizione' => 'pdf_ocr',
'riferimento_acquisizione' => trim('FE ' . (string) ($fattura->numero_fattura ?? ('#' . $fattura->id))),
'workflow_stato' => $workflow,
'prossima_lettura_scadenza_at' => $window['al'] ?? null,
'deadline_lettura_at' => $window['al'] ?? null,
'lettura_precedente_valore' => $letturaInizio,
'lettura_inizio' => $letturaInizio,
'lettura_fine' => $letturaFine,
'consumo_valore' => $consumoValore,
'consumo_unita' => 'mc',
'importo_totale' => is_numeric($fattura->totale) ? (float) $fattura->totale : null,
'raw' => [
'source' => 'pdf_ocr',
'codici' => is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [],
'contatore' => is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [],
'pagamento' => is_array($parsed['pagamento'] ?? null) ? $parsed['pagamento'] : [],
'riepilogo_letture' => $riepilogo,
'acea_schedule' => [
'window_start' => $window['dal'] ?? null,
'window_end' => $window['al'] ?? null,
'message' => $window['messaggio'] ?? null,
],
],
'created_by' => $userId > 0 ? $userId : null,
]);
$row->save();
return true;
}
private function openTicket(int $stabileId, int $userId, ?int $fornitoreId, string $title, string $body): bool
{
try {
$cat = CategoriaTicket::query()->firstOrCreate(
['nome' => 'Parser PDF'],
['descrizione' => 'Anomalie parsing PDF (acqua/energia/gas) e import consumi']
);
$feId = null;
if (preg_match('/\bFE\s*#\s*(\d+)\b/i', $body, $m)) {
$feId = (int) $m[1];
if ($feId <= 0) {
$feId = null;
}
}
if ($feId !== null) {
$existing = Ticket::query()
->where('stabile_id', $stabileId)
->where('categoria_ticket_id', (int) $cat->id)
->where('titolo', $title)
->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])
->when($fornitoreId !== null, fn($q) => $q->where('assegnato_a_fornitore_id', $fornitoreId))
->where('descrizione', 'like', 'FE #' . $feId . '%')
->orderByDesc('id')
->first();
if ($existing) {
return false;
}
}
Ticket::query()->create([
'stabile_id' => $stabileId,
'aperto_da_user_id' => $userId > 0 ? $userId : 1,
'categoria_ticket_id' => (int) $cat->id,
'assegnato_a_fornitore_id' => $fornitoreId,
'titolo' => $title,
'descrizione' => $body,
'stato' => 'Aperto',
'priorita' => 'Media',
]);
return true;
} catch (\Throwable) {
return false;
}
}
}