Compare commits

...

4 Commits

16 changed files with 971 additions and 75 deletions

View File

@ -223,6 +223,7 @@ public function handle(): int
$fornitorePayload = [
'amministratore_id' => $amministratoreId,
'cod_forn' => $codForn,
'rubrica_id' => $rubrica->id,
'codice_univoco' => (is_string($rubricaCode) && $rubricaCode !== '') ? $rubricaCode : null,
'ragione_sociale' => $ragione ?? $rubrica->ragione_sociale,
@ -256,6 +257,12 @@ public function handle(): int
->where('old_id', $legacyId)
->first();
}
if (! $existing && $codForn !== null && Schema::hasColumn('fornitori', 'cod_forn')) {
$existing = Fornitore::query()
->where('amministratore_id', $amministratoreId)
->where('cod_forn', $codForn)
->first();
}
if (! $existing && ! empty($fornitorePayload['rubrica_id'])) {
$existing = Fornitore::query()
->where('amministratore_id', $amministratoreId)

View File

@ -250,6 +250,11 @@ private function resolveLocalFornitore(object $legacy): ?Fornitore
$cf = trim((string) ($legacy->codice_fiscale ?? ''));
$ragione = trim((string) ($legacy->ragione_sociale ?? ''));
$byLegacyCode = $this->resolveLocalFornitoreByLegacyIds($legacyId, $legacyCod, $piva, $cf, $this->normalizeComparableString($ragione));
if ($byLegacyCode instanceof Fornitore) {
return $byLegacyCode;
}
if ($piva !== '') {
$found = Fornitore::query()->where('partita_iva', $piva)->first();
if ($found instanceof Fornitore) {
@ -280,20 +285,49 @@ private function resolveLocalFornitore(object $legacy): ?Fornitore
}
}
return $this->resolveLocalFornitoreByLegacyIds($legacyId, $legacyCod, $piva, $cf, $normalizedRagione);
return null;
}
private function resolveLocalFornitoreByLegacyIds(int $legacyId, string $legacyCod, string $piva, string $cf, string $normalizedRagione): ?Fornitore
{
$candidateIds = collect([
$legacyId > 0 ? $legacyId : null,
$candidateCodes = collect([
($legacyCod !== '' && ctype_digit($legacyCod)) ? (int) $legacyCod : null,
$legacyId > 0 ? $legacyId : null,
])
->filter(fn($value): bool => is_int($value) && $value > 0)
->unique()
->values();
foreach ($candidateIds as $candidateId) {
if (Schema::hasColumn('fornitori', 'cod_forn')) {
foreach ($candidateCodes as $candidateCode) {
$matches = Fornitore::query()
->where('cod_forn', (int) $candidateCode)
->get(['id', 'cod_forn', 'partita_iva', 'codice_fiscale', 'ragione_sociale']);
if ($matches->count() === 1) {
return $matches->first();
}
$filtered = $matches->filter(function (Fornitore $fornitore) use ($piva, $cf, $normalizedRagione): bool {
if ($piva !== '' && trim((string) ($fornitore->partita_iva ?? '')) === $piva) {
return true;
}
if ($cf !== '' && trim((string) ($fornitore->codice_fiscale ?? '')) === $cf) {
return true;
}
return $normalizedRagione !== ''
&& $this->normalizeComparableString((string) ($fornitore->ragione_sociale ?? '')) === $normalizedRagione;
})->values();
if ($filtered->count() === 1) {
return $filtered->first();
}
}
}
foreach ($candidateCodes as $candidateId) {
$matches = Fornitore::query()
->where('old_id', (int) $candidateId)
->get(['id', 'partita_iva', 'codice_fiscale', 'ragione_sociale']);
@ -361,6 +395,11 @@ private function extractKeywordTags(string $input): array
}
$keywords = [
'informat' => 'informatica',
'assist' => 'assistenza',
'computer' => 'pc',
' pc ' => 'pc',
'apple' => 'apple',
'idr' => 'idraulico',
'termoidraul' => 'termoidraulico',
'elettric' => 'elettricista',
@ -425,6 +464,10 @@ private function canonicalizeTag(string $raw): ?string
{
$clean = trim(mb_strtolower($raw), " \t\n\r\0\x0B-_,.;:");
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
if ($clean === 'pc') {
return 'pc';
}
if ($clean === '' || mb_strlen($clean) < 3) {
return null;
}
@ -438,6 +481,10 @@ private function canonicalizeTag(string $raw): ?string
}
$map = [
'informat' => 'informatica',
'assist' => 'assistenza',
'computer' => 'pc',
'apple' => 'apple',
'idr' => 'idraulico',
'idraul' => 'idraulico',
'elett' => 'elettricista',

View File

@ -20,11 +20,13 @@
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@ -224,8 +226,17 @@ public function table(Table $table): Table
}),
])
->columns([
TextColumn::make('periodo_dal')->label('Dal')->date('d/m/Y')->sortable()->toggleable(),
TextColumn::make('periodo_al')->label('Al')->date('d/m/Y')->sortable(),
ImageColumn::make('miniatura_foto')
->label('Foto')
->getStateUsing(fn(StabileServizioLettura $record): ?string => $this->resolveReadingPrimaryPhotoPath($record))
->disk('public')
->square()
->size(42)
->toggleable()
->extraAttributes(['class' => 'py-2']),
TextColumn::make('periodo_dal')->label('Dal')->date('d/m/Y')->sortable()->toggleable()->extraAttributes(['class' => 'text-[11px] leading-tight']),
TextColumn::make('periodo_al')->label('Al')->date('d/m/Y')->sortable()->extraAttributes(['class' => 'text-[11px] leading-tight']),
TextColumn::make('stabile.denominazione')
->label('Stabile')
@ -240,7 +251,19 @@ public function table(Table $table): Table
return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
})
->wrap()
->limit(36)
->tooltip(function (StabileServizioLettura $record): string {
$stabile = $record->stabile;
if (! $stabile instanceof Stabile) {
return '—';
}
$codice = trim((string) ($stabile->codice_stabile ?? ''));
$nome = trim((string) ($stabile->denominazione ?? ''));
return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
})
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: $this->archivioScope !== 'all'),
TextColumn::make('servizio.nome')
@ -254,23 +277,37 @@ public function table(Table $table): Table
$tipo = trim((string) ($record->servizio?->tipo ?? ''));
return $tipo !== '' ? strtoupper($tipo) : '—';
})
->wrap()
->limit(30)
->tooltip(function (StabileServizioLettura $record): string {
$nome = trim((string) ($record->servizio?->nome ?? ''));
if ($nome !== '') {
return $nome;
}
$tipo = trim((string) ($record->servizio?->tipo ?? ''));
return $tipo !== '' ? strtoupper($tipo) : '—';
})
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('servizio.contatore_matricola')->label('Matricola')->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('servizio.contatore_matricola')->label('Matricola')->toggleable(isToggledHiddenByDefault: true)->extraAttributes(['class' => 'text-[11px] leading-tight']),
TextColumn::make('unitaImmobiliare.denominazione')
->label('Attribuzione')
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
return $this->resolveReadingAssignmentLabel($record);
})
->wrap()
->limit(34)
->tooltip(fn(StabileServizioLettura $record): string => $this->resolveReadingAssignmentLabel($record))
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('rilevatore_nome')
->label('Letturista / nominativo')
->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—')
->wrap()
->limit(28)
->tooltip(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('consumo_valore')
@ -284,13 +321,15 @@ public function table(Table $table): Table
$unit = trim((string) ($record->consumo_unita ?? ''));
return trim((string) $val) . ($unit !== '' ? (' ' . $unit) : '');
})
->sortable(),
->sortable()
->extraAttributes(['class' => 'text-[11px] leading-tight font-medium']),
TextColumn::make('importo_totale')->label('Importo')->money('EUR')->sortable()->toggleable(),
TextColumn::make('importo_totale')->label('Importo')->money('EUR')->sortable()->toggleable()->extraAttributes(['class' => 'text-[11px] leading-tight']),
TextColumn::make('tipologia_lettura')
->label('Tipo lettura')
->formatStateUsing(fn($state) => $tipologiaOptions[strtolower(trim((string) $state))] ?? (string) ($state ?? '—'))
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('canale_acquisizione')
@ -307,12 +346,14 @@ public function table(Table $table): Table
default => $v !== '' ? strtoupper($v) : '—',
};
})
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('workflow_stato')
->label('Workflow')
->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? (string) $state : 'Acquisita')
->badge()
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('raw.consistency_check.summary')
@ -329,7 +370,12 @@ public function table(Table $table): Table
default => 'gray',
};
})
->wrap()
->limit(28)
->tooltip(function (StabileServizioLettura $record): string {
$summary = trim((string) data_get($record->raw, 'consistency_check.summary', ''));
return $summary !== '' ? $summary : '—';
})
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('lettura_foto_original_name')
@ -342,6 +388,18 @@ public function table(Table $table): Table
return filled($record->lettura_foto_path) ? '1 foto' : '—';
})
->tooltip(function (StabileServizioLettura $record): string {
$photos = $this->getReadingPhotoEntries($record);
if ($photos === []) {
return 'Nessuna foto';
}
return implode("\n", array_map(
fn(array $photo): string => (string) ($photo['name'] ?? basename((string) ($photo['path'] ?? 'foto'))),
$photos
));
})
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('protocollo_numero')
@ -356,55 +414,72 @@ public function table(Table $table): Table
return trim($categoria . ($numero !== '' ? (' #' . $numero) : ''));
})
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('prossima_lettura_scadenza_at')
->label('Scadenza prossima lettura')
->dateTime('d/m/Y H:i')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('deadline_lettura_at')
->label('Deadline campagna')
->dateTime('d/m/Y H:i')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('sollecito_inviato_at')
->label('Sollecito')
->dateTime('d/m/Y H:i')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('rilevatore_tipo')
->label('Rilevatore')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('lettura_precedente_valore')
->label('Lettura precedente')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('riferimento_acquisizione')->label('Riferimento')->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('riferimento_acquisizione')->label('Riferimento')->toggleable(isToggledHiddenByDefault: true)->extraAttributes(['class' => 'text-[11px] leading-tight']),
TextColumn::make('archivio_documentale_path')
->label('Archivio documentale')
->wrap()
->limit(36)
->tooltip(fn($state): string => trim((string) ($state ?? '')) !== '' ? trim((string) $state) : '—')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('fatturaElettronica.numero_fattura')
->label('Fattura FE')
->url(fn(StabileServizioLettura $record): ?string => $record->fattura_elettronica_id
? FatturaElettronicaScheda::getUrl(['id' => (int) $record->fattura_elettronica_id], panel : 'admin-filament')
: null)
->openUrlInNewTab()
->formatStateUsing(fn($state): string => trim((string) ($state ?? '')) !== '' ? trim((string) $state) : '—')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(),
TextColumn::make('voceSpesa.descrizione')->label('Voce spesa')->wrap()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('voceSpesa.descrizione')
->label('Voce spesa')
->limit(30)
->tooltip(fn($state): string => trim((string) ($state ?? '')) !== '' ? trim((string) $state) : '—')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('voceSpesa.tipo_gestione')
->label('Gestione')
->formatStateUsing(fn($state) => $gestioneOptions[strtolower(trim((string) $state))] ?? (string) ($state ?? '—'))
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('fornitore.ragione_sociale')->label('Fornitore')->wrap()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('fornitore.ragione_sociale')
->label('Fornitore')
->limit(30)
->tooltip(fn($state): string => trim((string) ($state ?? '')) !== '' ? trim((string) $state) : '—')
->extraAttributes(['class' => 'text-[11px] leading-tight'])
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')->label('Creato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')->label('Creato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true)->extraAttributes(['class' => 'text-[11px] leading-tight']),
])
->headerActions([
Action::make('toggleScope')
@ -574,20 +649,47 @@ public function table(Table $table): Table
Action::make('apriFoto')
->label('Foto')
->icon('heroicon-o-photo')
->url(fn(StabileServizioLettura $record): ?string => $this->resolveReadingPrimaryPhotoUrl($record))
->openUrlInNewTab()
->visible(fn(StabileServizioLettura $record): bool => $this->resolveReadingPrimaryPhotoUrl($record) !== null),
->button()
->color('gray')
->modalHeading('Foto contatore')
->modalWidth('5xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Chiudi')
->modalContent(function (StabileServizioLettura $record): View {
return view('filament.pages.condomini.partials.lettura-servizio-foto-modal', [
'record' => $record,
'photos' => $this->getReadingPhotoEntries($record),
'mapUrl' => $this->resolveReadingMapUrl($record),
'assigned' => $this->resolveReadingAssignmentLabel($record),
]);
})
->visible(fn(StabileServizioLettura $record): bool => $this->getReadingPhotoEntries($record) !== []),
Action::make('mappaFoto')
->label('Mappa')
->icon('heroicon-o-map')
->button()
->color('gray')
->url(fn(StabileServizioLettura $record): ?string => $this->resolveReadingMapUrl($record))
->openUrlInNewTab()
->visible(fn(StabileServizioLettura $record): bool => $this->resolveReadingMapUrl($record) !== null),
Action::make('apriFatturaFe')
->label('FE')
->icon('heroicon-o-receipt-percent')
->button()
->color('primary')
->url(fn(StabileServizioLettura $record): ?string => $record->fattura_elettronica_id
? FatturaElettronicaScheda::getUrl(['record' => (int) $record->fattura_elettronica_id], panel: 'admin-filament')
: null)
->openUrlInNewTab()
->visible(fn(StabileServizioLettura $record): bool => (int) ($record->fattura_elettronica_id ?? 0) > 0),
Action::make('riattribuisci')
->label('Riattribuisci')
->icon('heroicon-o-user-circle')
->button()
->color('gray')
->form([
Select::make('unita_immobiliare_id')
->label('Unità / nominativo')
@ -646,6 +748,7 @@ public function table(Table $table): Table
Action::make('spostaContatoreComune')
->label('Sposta a contatore comune')
->icon('heroicon-o-arrow-path-rounded-square')
->button()
->color('warning')
->form([
Select::make('stabile_servizio_id')
@ -728,6 +831,8 @@ public function table(Table $table): Table
Action::make('edit')
->label('Modifica')
->icon('heroicon-o-pencil-square')
->button()
->color('gray')
->form([
Select::make('stabile_servizio_id')
->label('Servizio')
@ -847,6 +952,7 @@ public function table(Table $table): Table
Action::make('delete')
->label('Elimina')
->icon('heroicon-o-trash')
->button()
->color('danger')
->requiresConfirmation()
->action(fn(StabileServizioLettura $record) => $record->delete()),
@ -1345,21 +1451,55 @@ private function resolveReadingAssignmentLabel(StabileServizioLettura $record):
return $parts !== [] ? implode(' - ', $parts) : 'Contatore generale / bene comune';
}
private function resolveReadingPrimaryPhotoPath(StabileServizioLettura $record): ?string
/**
* @return array<int, array{name: string, path: string, url: string, latitude: float|null, longitude: float|null}>
*/
private function getReadingPhotoEntries(StabileServizioLettura $record): array
{
$entries = [];
$append = function (string $path, ?string $name = null, $lat = null, $lng = null) use (&$entries): void {
$path = trim($path);
if ($path === '' || isset($entries[$path]) || ! Storage::disk('public')->exists($path)) {
return;
}
$entries[$path] = [
'name' => trim((string) ($name ?? '')) !== '' ? trim((string) $name) : basename($path),
'path' => $path,
'url' => Storage::disk('public')->url($path),
'latitude' => is_numeric($lat) ? (float) $lat : null,
'longitude' => is_numeric($lng) ? (float) $lng : null,
];
};
if (filled($record->lettura_foto_path)) {
return (string) $record->lettura_foto_path;
$append(
(string) $record->lettura_foto_path,
(string) ($record->lettura_foto_original_name ?? ''),
data_get($record->lettura_foto_metadata, 'gps_decimal.lat'),
data_get($record->lettura_foto_metadata, 'gps_decimal.lng')
);
}
$photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : [];
foreach ($photos as $photo) {
$path = trim((string) ($photo['path'] ?? ''));
if ($path !== '') {
return $path;
}
$append(
(string) ($photo['path'] ?? ''),
(string) ($photo['original_name'] ?? $photo['stored_basename'] ?? ''),
data_get($photo, 'metadata.gps_decimal.lat'),
data_get($photo, 'metadata.gps_decimal.lng')
);
}
return null;
return array_values($entries);
}
private function resolveReadingPrimaryPhotoPath(StabileServizioLettura $record): ?string
{
$photos = $this->getReadingPhotoEntries($record);
return $photos !== [] ? (string) ($photos[0]['path'] ?? '') : null;
}
private function resolveReadingPrimaryPhotoUrl(StabileServizioLettura $record): ?string

View File

@ -2,10 +2,15 @@
namespace App\Filament\Pages\Contabilita;
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
use App\Models\Documento;
use App\Jobs\RunCassettoFiscaleDownload;
use App\Models\FatturaElettronica;
use App\Models\Stabile;
use App\Models\User;
use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\FattureElettroniche\CassettoFiscaleDownloadService;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaProtocolloService;
@ -110,6 +115,169 @@ private function safeUtf8(string $value): string
return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $value) ?? $value;
}
/**
* @return array{status: string, created_letture?: int, updated_letture?: int, tickets?: int, message?: string}
*/
private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): array
{
$fattura = FatturaElettronica::query()->find($fatturaId);
if (! $fattura instanceof FatturaElettronica) {
return ['status' => 'skipped', 'message' => 'Fattura non trovata'];
}
$hasPdf = is_string($fattura->allegato_pdf_path ?? null) && trim((string) $fattura->allegato_pdf_path) !== '';
$hasStoredPayload = is_string($fattura->consumo_raw ?? null) && trim((string) $fattura->consumo_raw) !== '';
if (! $hasPdf && ! $hasStoredPayload) {
return ['status' => 'skipped', 'message' => 'Nessun PDF/payload acqua disponibile'];
}
try {
if ($userId > 0) {
app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($fattura, $userId);
$fattura->refresh();
}
} catch (\Throwable) {
// ignore
}
$documento = Documento::query()
->where('documentable_type', FatturaElettronica::class)
->where('documentable_id', (int) $fattura->id)
->first();
$parsed = [];
if ($documento instanceof Documento) {
$text = is_string($documento->contenuto_ocr) ? trim((string) $documento->contenuto_ocr) : '';
if ($text === '') {
try {
$result = app(PdfTextExtractionService::class)->extractAndStore($documento, false);
if (($result['status'] ?? null) === 'ok') {
$documento->refresh();
$text = is_string($documento->contenuto_ocr) ? trim((string) $documento->contenuto_ocr) : '';
}
} catch (\Throwable) {
$text = '';
}
}
if ($text !== '') {
$parsed = app(AcquaPdfTextParser::class)->parse($text);
}
}
$storedRaw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : '';
if ($storedRaw !== '' && $parsed === []) {
$decoded = json_decode($storedRaw, true);
if (is_array($decoded)) {
$parsed = $decoded;
}
}
$codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [];
$contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [];
$consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : [];
$generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : [];
$finestra = is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : [];
$letture = is_array($parsed['riepilogo_letture'] ?? null) ? $parsed['riepilogo_letture'] : [];
$quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : [];
$tariffe = is_array($parsed['tariffe'] ?? null) ? $parsed['tariffe'] : [];
$ivaInfo = is_array($parsed['iva'] ?? null) ? $parsed['iva'] : [];
$hasAny = false;
foreach ([$codici['utenza'] ?? null, $codici['cliente'] ?? null, $codici['contratto'] ?? null, $contatore['matricola'] ?? null] as $value) {
if (is_string($value) && trim($value) !== '') {
$hasAny = true;
break;
}
}
$hasTariffData = count(array_filter([
$generale['periodicita_fatturazione'] ?? null,
$tariffe['profilo'] ?? null,
$ivaInfo['codice'] ?? null,
$quadro['quota_fissa'] ?? null,
], static fn($value): bool => $value !== null && $value !== '')) > 0;
if (! $hasAny && count($consumi) < 1 && ! $hasTariffData) {
return ['status' => 'skipped', 'message' => 'Nessun dato acqua utile rilevato'];
}
$payload = [
'type' => 'acqua',
'source' => 'pdf_ocr',
'codici' => [
'utenza' => $codici['utenza'] ?? null,
'cliente' => $codici['cliente'] ?? null,
'contratto' => $codici['contratto'] ?? null,
],
'contatore' => [
'matricola' => $contatore['matricola'] ?? null,
],
'consumi' => array_values($consumi),
'generale' => $generale,
'finestra_autolettura' => $finestra,
'riepilogo_letture' => array_values($letture),
'quadro_dettaglio' => $quadro,
'tariffe' => $tariffe,
'iva' => [
'codice' => $ivaInfo['codice'] ?? null,
'aliquota_percentuale' => $ivaInfo['aliquota_percentuale'] ?? null,
'descrizione' => $ivaInfo['descrizione'] ?? null,
],
'parsed_at' => now()->toISOString(),
];
$totalMc = null;
$reference = null;
if ($consumi !== []) {
$sum = 0.0;
$hasNumeric = false;
foreach ($consumi as $consumo) {
if (isset($consumo['valore']) && is_numeric($consumo['valore'])) {
$sum += (float) $consumo['valore'];
$hasNumeric = true;
}
}
if ($hasNumeric) {
$totalMc = $sum;
}
$first = $consumi[0] ?? [];
$dal = $first['dal'] ?? null;
$al = $first['al'] ?? null;
if (is_string($dal) && is_string($al) && $dal !== '' && $al !== '') {
$reference = $dal . ' - ' . $al;
}
}
$fattura->consumo_unita = 'mc';
$fattura->consumo_valore = $totalMc;
$fattura->consumo_riferimento = $reference;
$fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$fattura->save();
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest($fattura, $parsed, null);
} catch (\Throwable) {
// best-effort
}
$ingestion = app(ConsumiAcquaIngestionService::class)->ingest($fattura, $parsed, null, $userId, false, null);
if (($ingestion['status'] ?? null) === 'ok') {
try {
app(ConsumiAcquaTariffeIngestionService::class)->ingest(
$fattura,
$parsed,
isset($ingestion['servizio_id']) && is_numeric($ingestion['servizio_id']) ? (int) $ingestion['servizio_id'] : null
);
} catch (\Throwable) {
// best-effort
}
}
return $ingestion;
}
public int $cassettoAnno;
/**
@ -1006,6 +1174,8 @@ protected function getHeaderActions(): array
$imported = 0;
$duplicates = 0;
$errors = 0;
$waterCreated = 0;
$waterUpdated = 0;
$duplicateDetails = [];
$errorDetails = [];
@ -1125,6 +1295,14 @@ protected function getHeaderActions(): array
// ignore
}
if (in_array((string) ($result['status'] ?? ''), ['imported', 'duplicate'], true) && is_numeric($result['id'] ?? null)) {
$waterResult = $this->autoLinkWaterReadingsForInvoice((int) $result['id'], (int) $user->id);
if (($waterResult['status'] ?? null) === 'ok') {
$waterCreated += (int) ($waterResult['created_letture'] ?? 0);
$waterUpdated += (int) ($waterResult['updated_letture'] ?? 0);
}
}
if (($result['status'] ?? null) === 'imported') {
$imported++;
} elseif (($result['status'] ?? null) === 'duplicate') {
@ -1228,6 +1406,14 @@ protected function getHeaderActions(): array
// ignore
}
if (in_array((string) ($result['status'] ?? ''), ['imported', 'duplicate'], true) && is_numeric($result['id'] ?? null)) {
$waterResult = $this->autoLinkWaterReadingsForInvoice((int) $result['id'], (int) $user->id);
if (($waterResult['status'] ?? null) === 'ok') {
$waterCreated += (int) ($waterResult['created_letture'] ?? 0);
$waterUpdated += (int) ($waterResult['updated_letture'] ?? 0);
}
}
if (($result['status'] ?? null) === 'imported') {
$imported++;
} elseif (($result['status'] ?? null) === 'duplicate') {
@ -1314,7 +1500,7 @@ protected function getHeaderActions(): array
Notification::make()
->title('Import completato')
->body($this->safeUtf8("Importate: {$imported} · Duplicate: {$duplicates} · Errori: {$errors}" . $logNote))
->body($this->safeUtf8("Importate: {$imported} · Duplicate: {$duplicates} · Errori: {$errors} · Letture acqua create: {$waterCreated} · aggiornate: {$waterUpdated}" . $logNote))
->success()
->send();

View File

@ -8,6 +8,7 @@
use App\Models\Stabile;
use App\Models\User;
use App\Support\ArchivioPaths;
use App\Support\GoogleAccountStore;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -130,12 +131,18 @@ public function importTecnoRepairFromArchive(bool $dryRun = false): void
return;
}
$mdbPath = trim($this->tecnorepairMdbPath);
if ($mdbPath === '' || ! is_file($mdbPath)) {
Notification::make()->title('Percorso MDB non valido')->warning()->send();
$mdbPath = $this->resolveTecnoRepairMdbPath($this->tecnorepairMdbPath);
if ($mdbPath === null) {
Notification::make()
->title('Percorso MDB non valido')
->body('Indica un file .mdb esistente sul server oppure salva prima l\'upload nell\'archivio fornitore.')
->warning()
->send();
return;
}
$this->tecnorepairMdbPath = $mdbPath;
$adminId = (int) ($this->fornitore->amministratore_id ?? 0);
if ($adminId <= 0) {
Notification::make()->title('Amministratore del fornitore non trovato')->danger()->send();
@ -390,7 +397,7 @@ private function refreshArchiveSummary(): void
$this->loadOperationalWorkflowSettings();
$this->moduleRows = [
['module' => 'Rubrica fornitore', 'status' => 'pronto', 'note' => 'Vista fornitore filtrata ma agganciata all\'anagrafica unica.'],
['module' => 'Google Workspace', 'status' => data_get($this->googleWorkspaceStatus, 'connected', false) ? 'collegato' : 'da collegare', 'note' => 'Rubrica e sincronizzazioni smartphone riusano l\'account Google del contesto amministratore.'],
['module' => 'Google Workspace', 'status' => data_get($this->googleWorkspaceStatus, 'connected', false) ? 'collegato' : 'da collegare', 'note' => 'Account dedicato al fornitore, isolato con chiave tecnica separata pur restando governato dal backend amministratore.'],
['module' => 'TecnoRepair MDB', 'status' => ((int) ($stats['total'] ?? 0) > 0) ? 'importato' : 'da importare', 'note' => 'Schede: ' . (int) ($stats['total'] ?? 0) . ' · aperte: ' . (int) ($stats['open'] ?? 0) . ' · chiuse: ' . (int) ($stats['closed'] ?? 0)],
['module' => 'Catalogo fornitore', 'status' => 'attivo', 'note' => 'Il catalogo vero resta nella pagina Prodotti.'],
['module' => 'Chiavi e reperibilità', 'status' => count($this->stabileAccessRows) > 0 ? 'configurato' : 'da configurare', 'note' => 'Istruzioni per accesso, chi suonare e dove recuperare chiavi per ciascuno stabile.'],
@ -545,16 +552,73 @@ private function resolveGoogleWorkspaceStatus(): ?array
return null;
}
$oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []);
$account = app(GoogleAccountStore::class)->get($amministratore, $this->getSupplierGoogleAccountKey());
if (! is_array($account)) {
return [
'connected' => false,
'email' => '',
'name' => '',
'connected_at' => '',
];
}
return [
'connected' => (bool) ($oauth['connected'] ?? false),
'email' => (string) ($oauth['email'] ?? ''),
'name' => (string) ($oauth['name'] ?? ''),
'connected_at' => (string) ($oauth['connected_at'] ?? ''),
'connected' => (bool) ($account['connected'] ?? false),
'email' => (string) ($account['email'] ?? ''),
'name' => (string) ($account['name'] ?? ''),
'connected_at' => (string) ($account['connected_at'] ?? ''),
];
}
private function getSupplierGoogleAccountKey(): string
{
return 'fornitore-' . (int) ($this->fornitore?->id ?? 0);
}
private function resolveTecnoRepairMdbPath(?string $rawPath): ?string
{
$path = trim((string) $rawPath);
if ($path === '') {
return null;
}
$storageBase = str_replace('\\', '/', storage_path('app'));
$normalizedInput = str_replace('\\', '/', $path);
$candidatePaths = [];
if (is_file($path)) {
$candidatePaths[] = $path;
}
if (str_starts_with($normalizedInput, $storageBase . '/')) {
$relative = ltrim(substr($normalizedInput, strlen($storageBase)), '/');
if ($relative !== '' && Storage::disk('local')->exists($relative)) {
$candidatePaths[] = storage_path('app/' . $relative);
}
}
if (! str_starts_with($normalizedInput, '/')) {
$relative = ltrim($normalizedInput, '/');
if (Storage::disk('local')->exists($relative)) {
$candidatePaths[] = storage_path('app/' . $relative);
}
}
foreach (array_values(array_unique($candidatePaths)) as $candidate) {
$real = realpath($candidate);
if (is_string($real) && $real !== '' && is_file($real)) {
return $real;
}
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
private function getArchiveRelativeBase(): ?string
{
if (! $this->fornitore instanceof Fornitore) {

View File

@ -753,11 +753,19 @@ private function splitTags(string $value): array
private function canonicalizeTag(string $raw): ?string
{
$clean = trim(mb_strtolower($raw));
if ($clean === 'pc') {
return 'pc';
}
if ($clean === '' || mb_strlen($clean) < 3) {
return null;
}
$map = [
'informat' => 'informatica',
'assist' => 'assistenza',
'computer' => 'pc',
'apple' => 'apple',
'idr' => 'idraulico',
'idraul' => 'idraulico',
'elett' => 'elettricista',
@ -782,6 +790,7 @@ private function hydrateBoxData(User $user): void
{
$this->fattureAssociate = [];
$this->raVersateRows = [];
$identityCandidates = $this->getSupplierIdentityCandidates();
$this->box = [
'stabile_id' => null,
@ -967,17 +976,8 @@ private function hydrateBoxData(User $user): void
if (Schema::hasTable('fatture_elettroniche')) {
$feBase = FatturaElettronica::query()
->where('stabile_id', $activeStabileId)
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($ids): void {
$q->where('fornitore_id', $this->fornitore->id);
if (! empty($ids)) {
$q->orWhere(function (\Illuminate\Database\Eloquent\Builder $qq) use ($ids): void {
foreach ($ids as $id) {
$qq->orWhereRaw("REPLACE(UPPER(fornitore_piva), ' ', '') = ?", [$id])
->orWhereRaw("REPLACE(UPPER(fornitore_cf), ' ', '') = ?", [$id]);
}
});
}
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($identityCandidates): void {
$this->applySupplierIdentityFilterToFeQuery($q, $identityCandidates);
});
$this->box['fe']['count'] = (int) (clone $feBase)->count();
@ -1008,7 +1008,16 @@ private function hydrateBoxData(User $user): void
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$contabBase = ContabilitaFatturaFornitore::query()
->where('stabile_id', $activeStabileId)
->where('fornitore_id', (int) $this->fornitore->id);
->where('fornitore_id', (int) $this->fornitore->id)
->when($identityCandidates !== [], function ($query) use ($identityCandidates): void {
$query->where(function (\Illuminate\Database\Eloquent\Builder $inner) use ($identityCandidates): void {
$inner->whereNull('fattura_elettronica_id')
->orWhereDoesntHave('fatturaElettronica')
->orWhereHas('fatturaElettronica', function (\Illuminate\Database\Eloquent\Builder $feQuery) use ($identityCandidates): void {
$this->applySupplierIdentityFilterToFeQuery($feQuery, $identityCandidates);
});
});
});
// Coerente con la logica della scheda contabile: aperto = totale - pagato.
$totNetto = (float) (clone $contabBase)->selectRaw('COALESCE(SUM(netto_da_pagare), 0) as netto')->value('netto');
@ -1088,6 +1097,65 @@ private function hydrateBoxData(User $user): void
}
}
/**
* @return array<int, string>
*/
private function getSupplierIdentityCandidates(): array
{
$values = [];
foreach ([(string) ($this->fornitore->partita_iva ?? ''), (string) ($this->fornitore->codice_fiscale ?? '')] as $raw) {
$normalized = strtoupper(str_replace(' ', '', trim($raw)));
if ($normalized === '' || $normalized === 'ND') {
continue;
}
$values[] = $normalized;
if (str_starts_with($normalized, 'IT') && strlen($normalized) > 2) {
$values[] = substr($normalized, 2);
}
}
$values = array_values(array_unique(array_filter($values, fn(string $value): bool => $value !== '')));
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
return $values;
}
/**
* @param array<int, string> $identityCandidates
*/
private function applySupplierIdentityFilterToFeQuery(\Illuminate\Database\Eloquent\Builder $query, array $identityCandidates): void
{
$fornitoreId = (int) $this->fornitore->id;
if ($identityCandidates === []) {
$query->where('fornitore_id', $fornitoreId);
return;
}
$query->where(function (\Illuminate\Database\Eloquent\Builder $outer) use ($identityCandidates, $fornitoreId): void {
$outer->where(function (\Illuminate\Database\Eloquent\Builder $identityQuery) use ($identityCandidates): void {
foreach ($identityCandidates as $identity) {
$identityQuery->orWhereRaw("REPLACE(UPPER(fornitore_piva), ' ', '') = ?", [$identity])
->orWhereRaw("REPLACE(UPPER(fornitore_cf), ' ', '') = ?", [$identity]);
}
})->orWhere(function (\Illuminate\Database\Eloquent\Builder $fallbackQuery) use ($fornitoreId): void {
$fallbackQuery->where('fornitore_id', $fornitoreId)
->where(function (\Illuminate\Database\Eloquent\Builder $missingPiva): void {
$missingPiva->whereNull('fornitore_piva')
->orWhere('fornitore_piva', '')
->orWhere('fornitore_piva', 'ND');
})
->where(function (\Illuminate\Database\Eloquent\Builder $missingCf): void {
$missingCf->whereNull('fornitore_cf')
->orWhere('fornitore_cf', '')
->orWhere('fornitore_cf', 'ND');
});
});
});
}
protected function getHeaderActions(): array
{
return [

View File

@ -65,6 +65,10 @@ class PostItGestione extends Page
public string $selectedGroupModalMode = 'open';
public string $appuntiOwnerFilter = 'mine';
public ?int $selectedAppuntoId = null;
/** @var array<int,string> */
public array $riaperturaNote = [];
@ -88,11 +92,16 @@ public function mount(): void
$focus = (int) request()->query('focus_post_it', 0);
$this->focusPostItId = $focus > 0 ? $focus : null;
if (in_array((string) request()->query('tab'), ['storico', 'lavagna', 'archivio'], true)) {
if (in_array((string) request()->query('tab'), ['storico', 'appunti', 'lavagna', 'archivio'], true)) {
$this->activeTab = (string) request()->query('tab');
}
}
public function selectAppunto(int $postItId): void
{
$this->selectedAppuntoId = $postItId > 0 ? $postItId : null;
}
public function getPostItInserimentoUrl(): string
{
return PostIt::getUrl(panel: 'admin-filament');
@ -274,6 +283,62 @@ private function shouldShowPostIt(ChiamataPostIt $postIt): bool
return ! $this->isLegacyFilteredSmdrPostIt($postIt);
}
private function hasOperationalNoteContent(ChiamataPostIt $postIt): bool
{
return trim($this->extractOperationalNoteContent($postIt)) !== '';
}
private function extractOperationalNoteContent(ChiamataPostIt $postIt): string
{
$note = trim((string) ($postIt->nota ?? ''));
if ($note === '') {
return '';
}
$origin = strtolower(trim((string) ($postIt->origine ?? '')));
if (! in_array($origin, ['smdr', 'smdr_table', 'smdr_live_banner', 'topbar_live_call', 'panasonic_csta_table', 'csta_table'], true)) {
return $note;
}
$lines = preg_split('/\r\n|\r|\n/', $note) ?: [];
$kept = [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
if (preg_match('/^\d{2}\/\d{2}\/\d{2}\s+\d{2}:\d{2}\s+\d+\b/i', $line) === 1) {
continue;
}
if (preg_match('/^chiamata ricevuta dal numero\b/i', $line) === 1) {
continue;
}
if (preg_match('/^interno\/gruppo chiamato:\b/i', $line) === 1) {
continue;
}
if (preg_match('/^nota operativa:\s*\d{2}\/\d{2}\/\d{2}/i', $line) === 1) {
continue;
}
if (preg_match('/^dettaglio sorgente:\s*\d{2}\/\d{2}\/\d{2}/i', $line) === 1) {
continue;
}
if (preg_match('/^contatto:\s*contatto non in rubrica$/i', $line) === 1) {
continue;
}
$kept[] = $line;
}
return trim(implode("\n", $kept));
}
public function getRecentiApertiVisibiliProperty()
{
return $this->recentiVisibili
@ -298,6 +363,60 @@ public function getRecentiChiusiRaggruppatiProperty()
return $this->buildGroupedCollection($this->recentiChiusiVisibili);
}
public function getAppuntiOperativiProperty()
{
if (! $this->isPostItTableReady()) {
return collect();
}
try {
$query = ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->orderByDesc('chiamata_il')
->orderByDesc('id');
$authId = (int) (Auth::id() ?? 0);
if ($this->appuntiOwnerFilter === 'mine' && $authId > 0) {
$query->where(function ($inner) use ($authId): void {
$inner->where('creato_da_user_id', $authId)
->orWhere('assegnato_a_user_id', $authId);
});
}
$items = $query
->limit(200)
->get()
->filter(fn(ChiamataPostIt $postIt): bool => $this->shouldShowPostIt($postIt) && $this->hasOperationalNoteContent($postIt))
->values();
if ($items->isNotEmpty() && ! $items->contains(fn(ChiamataPostIt $postIt): bool => (int) $postIt->id === (int) $this->selectedAppuntoId)) {
$this->selectedAppuntoId = $this->focusPostItId && $items->contains(fn(ChiamataPostIt $postIt): bool => (int) $postIt->id === (int) $this->focusPostItId)
? (int) $this->focusPostItId
: (int) $items->first()->id;
}
return $items;
} catch (QueryException) {
return collect();
}
}
public function getSelectedAppuntoProperty(): ?ChiamataPostIt
{
if (! $this->isPostItTableReady() || ! $this->selectedAppuntoId) {
return null;
}
return ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->find($this->selectedAppuntoId);
}
public function getOperationalNoteSummary(ChiamataPostIt $postIt, int $limit = 180): string
{
return Str::limit($this->extractOperationalNoteContent($postIt), $limit);
}
public function openGroupModal(string $modalKey, string $mode = 'open'): void
{
$this->selectedGroupModalKey = $modalKey;

View File

@ -1,6 +1,7 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\DettaglioRipartizioneSpese;
use App\Models\GestioneContabile;
use App\Models\Incasso;
@ -1690,10 +1691,12 @@ protected function hydrateRelazioni(): void
$nome = $contatto ? 'Contatto #' . $contatto->id : 'Contatto non definito';
}
$tipoRaw = strtolower(trim((string) ($relazione->ruolo_standard ?? '')));
$tipoLabel = $labelsRuoli[$tipoRaw] ?? ($tipoRaw !== '' ? $tipoRaw : 'Altro');
if (! empty($relazione->ruolo_custom)) {
$tipoLabel .= ' (' . (string) $relazione->ruolo_custom . ')';
$tipoRaw = strtolower(trim((string) ($relazione->ruolo_standard ?? '')));
$customRaw = trim((string) ($relazione->ruolo_custom ?? ''));
$hasMeaningfulCustomRole = $customRaw !== '' && ! in_array(strtolower($customRaw), ['p', 'i', 'c', 'u'], true);
$tipoLabel = $labelsRuoli[$tipoRaw] ?? ($tipoRaw !== '' ? $tipoRaw : 'Altro');
if ($hasMeaningfulCustomRole) {
$tipoLabel .= ' (' . $customRaw . ')';
}
$quota = data_get($relazione->meta, 'right_percent');
@ -1716,16 +1719,22 @@ protected function hydrateRelazioni(): void
return [
'id' => (int) $relazione->id,
'persona_id' => (int) ($relazione->rubrica_id ?? 0),
'rubrica_id' => (int) ($relazione->rubrica_id ?? 0),
'nome' => $nome,
'codice_fiscale' => $contatto?->codice_fiscale,
'tipo_raw' => $tipoRaw,
'tipo' => $tipoLabel,
'ruolo_custom' => $hasMeaningfulCustomRole ? $customRaw : null,
'has_custom_role' => $hasMeaningfulCustomRole,
'ruolo_rate' => $ruoloRate,
'quota' => $quota,
'quota_label' => $quota !== null ? number_format($quota, 2, ',', '.') : null,
'data_inizio' => $formatDate($relazione->data_inizio),
'data_fine' => $formatDate($relazione->data_fine),
'attivo' => true,
'telefono' => trim((string) ($contatto?->telefono_cellulare ?: $contatto?->telefono_ufficio ?: $contatto?->telefono_casa ?: '')),
'email' => trim((string) ($contatto?->email ?? '')),
'rubrica_url' => $contatto ? RubricaUniversaleScheda::getUrl(['record' => (int) $contatto->id], panel : 'admin-filament'): null,
'riceve_comunicazioni' => false,
'riceve_convocazioni' => false,
'vota_assemblea' => false,
@ -1771,19 +1780,22 @@ protected function hydrateRelazioni(): void
$altri = $relazioniMapped
->filter(function ($rel) {
return $rel['attivo'] && ! in_array(strtolower($rel['tipo_raw'] ?? ''), [
'condomino',
'proprietario',
'comproprietario',
'nudo_proprietario',
'usufruttuario',
'usufrutto',
'inquilino',
'locatario',
'conduttore',
], true);
return $rel['attivo'] && (
! in_array(strtolower($rel['tipo_raw'] ?? ''), [
'condomino',
'proprietario',
'comproprietario',
'nudo_proprietario',
'usufruttuario',
'usufrutto',
'inquilino',
'locatario',
'conduttore',
], true)
|| ! empty($rel['has_custom_role'])
);
})
->unique(fn(array $r) => (strtolower((string) ($rel['tipo_raw'] ?? 'altro')) . '|' . (int) ($r['persona_id'] ?? 0)))
->unique(fn(array $r) => strtolower((string) ($r['tipo_raw'] ?? 'altro')) . '|' . strtolower((string) ($r['ruolo_custom'] ?? '')) . '|' . (int) ($r['persona_id'] ?? 0))
->values();
if ($proprietari->isEmpty()) {

View File

@ -17,6 +17,7 @@ class Fornitore extends Model
protected $fillable = [
'amministratore_id',
'cod_forn',
'rubrica_id',
'codice_univoco',
'ragione_sociale',
@ -57,6 +58,7 @@ class Fornitore extends Model
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'cod_forn' => 'integer',
'escludi_righe_fe' => 'boolean',
'fe_features' => 'array',
'operational_config' => 'array',

View File

@ -1453,7 +1453,9 @@ private function updateExisting(FatturaElettronica $existing, string $xml, ?stri
$fornitorePiva = $existing->fornitore_piva ?: 'ND';
}
$fornitoreId = $this->matchFornitoreId($fornitorePiva, $fornitoreCf);
$stabile = Stabile::query()->select(['id', 'amministratore_id'])->find($stabileId);
$adminId = (int) ($stabile?->amministratore_id ?: 0);
$fornitoreId = $this->matchFornitoreIdForAmministratore($adminId, $fornitorePiva, $fornitoreCf);
$importRighe = $this->shouldImportRighe($fornitoreId, $extra);
return DB::transaction(function () use ($existing, $data, $xml, $originalFilename, $extra, $stabileId, $fornitoreId, $fornitorePiva, $importRighe) {

View File

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('fornitori')) {
return;
}
Schema::table('fornitori', function (Blueprint $table): void {
if (! Schema::hasColumn('fornitori', 'cod_forn')) {
$table->unsignedBigInteger('cod_forn')->nullable()->after('old_id');
}
});
DB::table('fornitori')
->whereNull('cod_forn')
->whereNotNull('old_id')
->update(['cod_forn' => DB::raw('old_id')]);
$indexes = collect(DB::select("SHOW INDEX FROM fornitori"))
->pluck('Key_name');
Schema::table('fornitori', function (Blueprint $table) use ($indexes): void {
if (! $indexes->contains('fornitori_amministratore_cod_forn_index')) {
$table->index(['amministratore_id', 'cod_forn'], 'fornitori_amministratore_cod_forn_index');
}
});
}
public function down(): void
{
if (! Schema::hasTable('fornitori')) {
return;
}
$indexes = collect(DB::select("SHOW INDEX FROM fornitori"))
->pluck('Key_name');
Schema::table('fornitori', function (Blueprint $table) use ($indexes): void {
if ($indexes->contains('fornitori_amministratore_cod_forn_index')) {
$table->dropIndex('fornitori_amministratore_cod_forn_index');
}
if (Schema::hasColumn('fornitori', 'cod_forn')) {
$table->dropColumn('cod_forn');
}
});
}
};

View File

@ -10,6 +10,8 @@ ## Stato operativo reale alla chiusura sessione
- rimuovere il footer Filament invasivo su mobile
- riallineare pagina `tabelle-millesimali` a `nord`, `tipo`, `calcolo`
- eliminare i blocchi Blade fragili nelle viste `ticket-acqua` e `ticket-acqua-generale`
- compattare la UI di `letture-servizi` con pulsanti, tooltip, miniatura e modal foto
- agganciare automaticamente il parsing acqua alle FE ricevute importate quando esiste PDF/payload utilizzabile
## Verifiche fatte e confermate
@ -21,9 +23,28 @@ ## Verifiche fatte e confermate
- Il problema operativo dello scarico FE nasceva dal contesto dello stabile attivo in topbar/sessione, non dalla chiamata HTTP finale.
- Nella pagina FE è stato aggiunto un riepilogo esplicito del `Codice fiscale soggetto usato per lo scarico`.
- Le due Blade acqua sono state semplificate spostando il rendering metadati foto in partial condivisa, per evitare parse error residui in staging dovuti a blocchi Blade annidati.
- La pagina `fatture-ricevute` continua a filtrare per `stabile_id` attivo; nel DB locale per `stabile_id=25` esistono FE 2025 ricevute.
- Per `stabile_id=25` esistono FE 2025 con `allegato_pdf_path` e in diversi casi `consumo_raw`, quindi il nuovo aggancio acqua post-import ha input reali da elaborare.
- `php artisan view:clear && php artisan view:cache` continua a passare dopo i ritocchi UI e il nuovo post-processing FE.
- `php artisan route:list` continua a caricare `755` route.
## File toccati in questa fase
- `app/Filament/Pages/Condomini/LettureServiziArchivio.php`
- colonna miniatura foto
- testi piu compatti (`text-[11px]`)
- tooltip al posto di testo lungo per servizio/attribuzione/check/voce/fornitore
- azioni riga rese pulsanti
- modal foto invece dell'apertura diretta del file
- pulsante FE dedicato in riga
- `resources/views/filament/pages/condomini/partials/lettura-servizio-foto-modal.blade.php`
- nuova vista modal per anteprima foto contatore
- `app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php`
- nuovo post-processing `autoLinkWaterReadingsForInvoice()` dopo import FE
- sincronizzazione `consumo_raw`/consumi FE
- chiamata a `ConsumiAcquaIngestionService` e `ConsumiAcquaTariffeIngestionService` in best-effort
- notifica finale estesa con conteggio letture acqua create/aggiornate
- `app/Providers/Filament/AdminFilamentPanelProvider.php`
- chain Filament riparata
- footer hook rimosso dal panel

View File

@ -0,0 +1,46 @@
@php
$photos = is_array($photos ?? null) ? $photos : [];
$primary = $photos[0] ?? null;
@endphp
<div class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $assigned ?? 'Contatore' }}</div>
<div class="text-xs text-slate-500">
Riga #{{ (int) ($record->id ?? 0) }}
@if(! empty($record->periodo_dal) || ! empty($record->periodo_al))
· Periodo {{ optional($record->periodo_dal)->format('d/m/Y') ?: '—' }} {{ optional($record->periodo_al)->format('d/m/Y') ?: '—' }}
@endif
</div>
</div>
@if(is_string($mapUrl ?? null) && $mapUrl !== '')
<a href="{{ $mapUrl }}" target="_blank" rel="noreferrer" class="inline-flex items-center rounded-md bg-slate-800 px-3 py-2 text-xs font-medium text-white hover:bg-slate-700">
Apri mappa
</a>
@endif
</div>
@if($primary)
<div class="overflow-hidden rounded-xl border border-slate-200 bg-slate-50">
<img src="{{ $primary['url'] }}" alt="{{ $primary['name'] }}" class="max-h-[60vh] w-full object-contain bg-white" />
</div>
@endif
@if(count($photos) > 1)
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
@foreach($photos as $photo)
<a href="{{ $photo['url'] }}" target="_blank" rel="noreferrer" class="overflow-hidden rounded-lg border border-slate-200 bg-white transition hover:border-slate-400">
<img src="{{ $photo['url'] }}" alt="{{ $photo['name'] }}" class="h-28 w-full object-cover" />
<div class="border-t border-slate-100 px-2 py-2 text-[11px] text-slate-600">{{ $photo['name'] }}</div>
</a>
@endforeach
</div>
@endif
@if($photos === [])
<div class="rounded-lg border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500">
Nessuna foto disponibile per questa lettura.
</div>
@endif
</div>

View File

@ -43,7 +43,7 @@
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">TecnoRepair MDB</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare un archivio MDB dalla pagina web oppure indicare un percorso già disponibile sul server, poi lanciare la sincronizzazione verso il fornitore.</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare un archivio MDB dalla pagina web oppure indicare un percorso già disponibile sul server. Sono accettati sia path assoluti sia percorsi relativi dentro <span class="font-mono">storage/app</span>.</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="block text-sm md:col-span-2">
@ -248,7 +248,7 @@
</form>
@endif
</div>
<div class="mt-2 text-xs text-gray-500">Questo collegamento prepara la sincronizzazione rubrica lato fornitore e l'uso dei dati anche sul cellulare tramite l'account Google già governato dal backend.</div>
<div class="mt-2 text-xs text-gray-500">Questo collegamento usa una chiave account dedicata al fornitore, così rubrica e sincronizzazioni non si mischiano con il profilo amministratore anche se il backend resta sotto lo stesso tenant.</div>
@else
<div class="mt-3 text-sm text-gray-500">Contesto amministratore Google non disponibile per questo fornitore.</div>
@endif

View File

@ -15,6 +15,9 @@
<button type="button" wire:click="$set('activeTab', 'storico')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'storico' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
Storico Post-it
</button>
<button type="button" wire:click="$set('activeTab', 'appunti')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'appunti' ? 'bg-emerald-700 text-white' : 'bg-emerald-50 text-emerald-900 hover:bg-emerald-100' }}">
Appunti chiamata
</button>
<button type="button" wire:click="$set('activeTab', 'lavagna')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'lavagna' ? 'bg-amber-600 text-white' : 'bg-amber-50 text-amber-900 hover:bg-amber-100' }}">
Lavagna chiamate
</button>
@ -111,6 +114,111 @@
@endforelse
</div>
</div>
@elseif($activeTab === 'appunti')
<div class="rounded-xl border border-emerald-200 bg-white p-4">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold text-emerald-900">Appunti presi durante la chiamata</h2>
<p class="text-sm text-emerald-800">Elenco separato dagli eventi telefonici grezzi, con focus sui Post-it che contengono note operative reali.</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="$set('appuntiOwnerFilter', 'mine')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $appuntiOwnerFilter === 'mine' ? 'bg-emerald-700 text-white' : 'bg-emerald-50 text-emerald-900 hover:bg-emerald-100' }}">
I miei appunti
</button>
<button type="button" wire:click="$set('appuntiOwnerFilter', 'all')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $appuntiOwnerFilter === 'all' ? 'bg-slate-700 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tutti
</button>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<div class="space-y-3">
@forelse($this->appuntiOperativi as $appunto)
<button type="button" wire:click="selectAppunto({{ (int) $appunto->id }})" class="block w-full rounded-xl border p-4 text-left transition {{ (int) ($selectedAppuntoId ?? 0) === (int) $appunto->id ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'bg-white hover:bg-gray-50' }}">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $appunto->oggetto ?: 'Senza oggetto' }}</div>
<div class="mt-1 text-xs text-slate-600">
{{ $appunto->nome_chiamante ?: 'Chiamante non identificato' }}
@if($appunto->telefono)
· {{ $appunto->telefono }}
@endif
@if($appunto->rubrica)
· Rubrica #{{ (int) $appunto->rubrica->id }}
@endif
</div>
</div>
<div class="text-[11px] text-slate-500">{{ optional($appunto->chiamata_il)->format('d/m/Y H:i') }}</div>
</div>
<div class="mt-3 whitespace-pre-wrap text-sm text-slate-700">{{ $this->getOperationalNoteSummary($appunto) }}</div>
<div class="mt-3 flex flex-wrap gap-2 text-[11px]">
<span class="rounded-md bg-slate-100 px-2 py-1 text-slate-700">{{ $appunto->stato }}</span>
@if($appunto->creatoDa)
<span class="rounded-md bg-blue-100 px-2 py-1 text-blue-800">Creato da {{ $appunto->creatoDa->name }}</span>
@endif
@if($appunto->assegnatoAUser)
<span class="rounded-md bg-emerald-100 px-2 py-1 text-emerald-800">Assegnato a {{ $appunto->assegnatoAUser->name }}</span>
@endif
@if($appunto->ticket_id)
<span class="rounded-md bg-indigo-100 px-2 py-1 text-indigo-800">Ticket #{{ (int) $appunto->ticket_id }}</span>
@endif
</div>
</button>
@empty
<div class="rounded-lg border border-dashed border-emerald-300 bg-emerald-50/50 p-4 text-sm text-emerald-900">Nessun appunto operativo trovato per il filtro corrente.</div>
@endforelse
</div>
<div class="rounded-2xl border bg-slate-50 p-4">
@if($this->selectedAppunto)
<div class="space-y-3">
<div>
<div class="text-sm font-semibold text-slate-900">Post-it #{{ (int) $this->selectedAppunto->id }} · {{ $this->selectedAppunto->oggetto ?: 'Senza oggetto' }}</div>
<div class="mt-1 text-xs text-slate-500">
{{ $this->selectedAppunto->nome_chiamante ?: 'Chiamante non identificato' }}
@if($this->selectedAppunto->telefono)
· {{ $this->selectedAppunto->telefono }}
@endif
@if($this->selectedAppunto->stabile)
· {{ $this->selectedAppunto->stabile->denominazione ?: ('Stabile #' . $this->selectedAppunto->stabile->id) }}
@endif
</div>
</div>
<div class="rounded-xl border bg-white p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Appunto completo</div>
<div class="mt-2 whitespace-pre-wrap text-sm text-slate-800">{{ $this->selectedAppunto->nota ?: '—' }}</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-xl border bg-white p-3 text-xs text-slate-600">
<div class="font-semibold text-slate-900">Creato da</div>
<div class="mt-1">{{ $this->selectedAppunto->creatoDa?->name ?: '—' }}</div>
</div>
<div class="rounded-xl border bg-white p-3 text-xs text-slate-600">
<div class="font-semibold text-slate-900">Assegnazione</div>
<div class="mt-1">{{ $this->selectedAppunto->assegnatoAUser?->name ?: ($this->selectedAppunto->assegnatoAFornitore?->ragione_sociale ?: 'Non assegnato') }}</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getPostItPageUrl((int) $this->selectedAppunto->id) }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Apri scheda Post-it</a>
@if($this->selectedAppunto->rubrica)
<a href="{{ \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => (int) $this->selectedAppunto->rubrica->id], panel: 'admin-filament') }}" class="inline-flex items-center rounded-md border px-3 py-1.5 text-xs text-indigo-700">Apri rubrica</a>
@endif
@if($this->selectedAppunto->ticket_id)
<a href="{{ \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament') }}#ticket-{{ (int) $this->selectedAppunto->ticket_id }}" class="inline-flex items-center rounded-md border px-3 py-1.5 text-xs text-slate-700">Apri ticket</a>
@endif
</div>
</div>
@else
<div class="rounded-lg border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500">Seleziona un appunto per vedere la scheda completa.</div>
@endif
</div>
</div>
</div>
@elseif($activeTab === 'lavagna')
<div class="rounded-xl border border-amber-200 bg-amber-50/40 p-4">
<div class="mb-4">

View File

@ -473,6 +473,24 @@ class="inline-flex items-center rounded-lg border px-4 py-2 text-sm font-semibol
<div class="text-sm font-semibold text-gray-900">{{ $relazione['nome'] }}</div>
<div class="text-xs text-gray-500 mt-1">CF: {{ $relazione['codice_fiscale'] ?? '—' }}</div>
<div class="text-xs text-gray-500 mt-1">Quota: {{ $relazione['quota_label'] ?? '—' }}%</div>
@if(!empty($relazione['telefono']))
<div class="text-xs text-gray-500 mt-1">Telefono: {{ $relazione['telefono'] }}</div>
@endif
@if(!empty($relazione['email']))
<div class="text-xs text-gray-500 mt-1">Email: {{ $relazione['email'] }}</div>
@endif
@if(!empty($relazione['data_inizio']) || !empty($relazione['data_fine']))
<div class="text-xs text-gray-500 mt-1">
Periodo:
@if(!empty($relazione['data_inizio'])) Da {{ $relazione['data_inizio'] }} @endif
@if(!empty($relazione['data_fine'])) · fino a {{ $relazione['data_fine'] }} @endif
</div>
@endif
@if(!empty($relazione['rubrica_url']))
<div class="mt-2">
<a href="{{ $relazione['rubrica_url'] }}" class="inline-flex items-center rounded-md border border-indigo-200 bg-indigo-50 px-2 py-1 text-[11px] font-medium text-indigo-700 hover:bg-indigo-100">Apri scheda rubrica</a>
</div>
@endif
</div>
@endforeach
</div>