Supporto/fornitori sync and Google OAuth refresh hardening

This commit is contained in:
michele 2026-04-08 16:04:23 +00:00
parent a9204e125b
commit 432f0565ab
28 changed files with 816 additions and 184 deletions

View File

@ -67,10 +67,10 @@ public function handle(): int
} }
$stats = [ $stats = [
'processed' => 0, 'processed' => 0,
'persona_updated' => 0, 'persona_updated' => 0,
'rubrica_updated' => 0, 'rubrica_updated' => 0,
'no_match' => 0, 'no_match' => 0,
'phone_conflicts' => 0, 'phone_conflicts' => 0,
]; ];
@ -100,9 +100,9 @@ public function handle(): int
} }
/** @var RubricaUniversale $best */ /** @var RubricaUniversale $best */
$best = $rubriche->first(); $best = $rubriche->first();
$canonicalRubricaId = $this->extractRubricaIdFromPersonaNote((string) ($row->note ?? '')); $canonicalRubricaId = $this->extractRubricaIdFromPersonaNote((string) ($row->note ?? ''));
$canonicalRubrica = $canonicalRubricaId > 0 ? RubricaUniversale::query()->find($canonicalRubricaId) : null; $canonicalRubrica = $canonicalRubricaId > 0 ? RubricaUniversale::query()->find($canonicalRubricaId) : null;
if (! $canonicalRubrica instanceof RubricaUniversale) { if (! $canonicalRubrica instanceof RubricaUniversale) {
$canonicalRubrica = $best; $canonicalRubrica = $best;
} }
@ -223,8 +223,8 @@ private function buildPersonaUpdates(object $row, RubricaUniversale $best, array
$bestMobile = $this->normalizePhone((string) ($best->telefono_cellulare ?: $best->telefono_ufficio ?: $best->telefono_casa)); $bestMobile = $this->normalizePhone((string) ($best->telefono_cellulare ?: $best->telefono_ufficio ?: $best->telefono_casa));
$bestOffice = $this->normalizePhone((string) ($best->telefono_ufficio ?: $best->telefono_casa)); $bestOffice = $this->normalizePhone((string) ($best->telefono_ufficio ?: $best->telefono_casa));
$bestEmail = $this->normalizeEmail((string) ($best->email ?? '')); $bestEmail = $this->normalizeEmail((string) ($best->email ?? ''));
$bestPec = $this->normalizeEmail((string) ($best->pec ?? '')); $bestPec = $this->normalizeEmail((string) ($best->pec ?? ''));
if (! filled($row->telefono_principale) && $bestMobile !== null && ! $this->phoneExistsOnOtherPersona($bestMobile, (int) $row->persona_id)) { if (! filled($row->telefono_principale) && $bestMobile !== null && ! $this->phoneExistsOnOtherPersona($bestMobile, (int) $row->persona_id)) {
$updates['telefono_principale'] = $bestMobile; $updates['telefono_principale'] = $bestMobile;
@ -267,4 +267,4 @@ private function buildRubricaUpdates(RubricaUniversale $canonical, RubricaUniver
return $updates; return $updates;
} }
} }

View File

@ -0,0 +1,399 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class NetgesconQaMillesimiSpeseCommand extends Command
{
protected $signature = 'netgescon:qa-millesimi-spese
{--stabile= : Codice stabile legacy/domino}
{--stabile_id= : ID stabile dominio}
{--anno= : Legacy year staging (es. 0001) per filtrare il confronto}
{--show-missing=20 : Massimo numero di codici mancanti da mostrare}';
protected $description = 'QA su tabelle millesimali, voci spesa e frazionamenti, evitando fallback vuoti e verificando l allineamento con nominativi/unita.';
public function handle(): int
{
if (! Schema::hasTable('stabili')) {
$this->error('Tabella stabili non disponibile.');
return self::FAILURE;
}
[$stabileId, $codiceStabile] = $this->resolveStabileScope();
if (! $stabileId || $codiceStabile === '') {
$this->error('Serve --stabile=CODICE oppure --stabile_id=ID valido.');
return self::FAILURE;
}
$legacyYear = $this->normalizeOption($this->option('anno'));
$showMissing = max(1, (int) ($this->option('show-missing') ?: 20));
$this->info('QA millesimi/spese per stabile ' . $codiceStabile . ' (#' . $stabileId . ')' . ($legacyYear ? ' anno ' . $legacyYear : ''));
$activeUnitsQuery = DB::table('unita_immobiliari')
->where('stabile_id', $stabileId)
->whereNull('deleted_at');
$activeUnits = (clone $activeUnitsQuery)->count();
$unitsWithNominativi = 0;
if (Schema::hasTable('unita_immobiliare_nominativi')) {
$unitsWithNominativi = DB::table('unita_immobiliare_nominativi as uin')
->join('unita_immobiliari as ui', 'ui.id', '=', 'uin.unita_immobiliare_id')
->where('ui.stabile_id', $stabileId)
->whereNull('ui.deleted_at')
->distinct('uin.unita_immobiliare_id')
->count('uin.unita_immobiliare_id');
}
$unitsWithoutNominativi = max(0, $activeUnits - $unitsWithNominativi);
$tabelleCount = Schema::hasTable('tabelle_millesimali')
? DB::table('tabelle_millesimali')->where('stabile_id', $stabileId)->count()
: 0;
$detailRows = 0;
$detailZeroRows = 0;
$detailZeroOnUnitsWithoutNominativi = 0;
$tablesWithoutPositiveRows = collect();
if (Schema::hasTable('dettaglio_millesimi') && Schema::hasTable('tabelle_millesimali')) {
$detailBase = DB::table('dettaglio_millesimi as dm')
->join('tabelle_millesimali as tm', 'tm.id', '=', 'dm.tabella_millesimale_id')
->where('tm.stabile_id', $stabileId);
$detailRows = (clone $detailBase)->count();
$detailZeroRows = (clone $detailBase)
->where(function ($q): void {
$q->whereNull('dm.millesimi')->orWhere('dm.millesimi', '<=', 0);
})
->count();
if (Schema::hasTable('unita_immobiliare_nominativi')) {
$detailZeroOnUnitsWithoutNominativi = (clone $detailBase)
->join('unita_immobiliari as ui', 'ui.id', '=', 'dm.unita_immobiliare_id')
->where(function ($q): void {
$q->whereNull('dm.millesimi')->orWhere('dm.millesimi', '<=', 0);
})
->whereNotExists(function ($sub): void {
$sub->selectRaw('1')
->from('unita_immobiliare_nominativi as uin')
->whereColumn('uin.unita_immobiliare_id', 'dm.unita_immobiliare_id');
})
->count();
}
$tablesWithoutPositiveRows = DB::table('tabelle_millesimali as tm')
->leftJoin('dettaglio_millesimi as dm', function ($join): void {
$join->on('dm.tabella_millesimale_id', '=', 'tm.id')
->where('dm.millesimi', '>', 0);
})
->where('tm.stabile_id', $stabileId)
->groupBy('tm.id', 'tm.codice_tabella', 'tm.nome_tabella', 'tm.denominazione')
->havingRaw('COUNT(dm.id) = 0')
->selectRaw('COALESCE(tm.codice_tabella, tm.nome_tabella, tm.denominazione, CAST(tm.id AS CHAR)) as codice')
->pluck('codice');
}
$vociCount = Schema::hasTable('voci_spesa')
? DB::table('voci_spesa')->where('stabile_id', $stabileId)->count()
: 0;
$vociSenzaTabella = Schema::hasTable('voci_spesa')
? DB::table('voci_spesa')
->where('stabile_id', $stabileId)
->where(function ($q) : void {
$q->whereNull('tabella_millesimale_default_id')->orWhere('tabella_millesimale_default_id', 0);
})
->count()
: 0;
$vociConTabellaOrfana = (Schema::hasTable('voci_spesa') && Schema::hasTable('tabelle_millesimali'))
? DB::table('voci_spesa as vs')
->leftJoin('tabelle_millesimali as tm', 'tm.id', '=', 'vs.tabella_millesimale_default_id')
->where('vs.stabile_id', $stabileId)
->whereNotNull('vs.tabella_millesimale_default_id')
->whereNull('tm.id')
->count()
: 0;
$staging = $this->collectStagingMetrics($stabileId, $codiceStabile, $legacyYear);
$this->line('Unita attive: ' . $activeUnits);
$this->line('Unita con nominativi: ' . $unitsWithNominativi);
$this->line('Unita senza nominativi: ' . $unitsWithoutNominativi);
$this->line('Tabelle millesimali dominio: ' . $tabelleCount);
$this->line('Righe dettaglio millesimi: ' . $detailRows);
$this->line('Righe dettaglio a zero/null: ' . $detailZeroRows);
$this->line('Righe a zero su unita senza nominativi: ' . $detailZeroOnUnitsWithoutNominativi);
$this->line('Voci spesa dominio: ' . $vociCount);
$this->line('Voci senza tabella default: ' . $vociSenzaTabella);
$this->line('Voci con tabella orfana: ' . $vociConTabellaOrfana);
if ($staging['available']) {
$this->line('Staging tabelle legacy: ' . $staging['headers_count'] . ' intestazioni, ' . $staging['detail_codes_count'] . ' codici con dettagli');
$this->line('Staging voci legacy: ' . $staging['voci_count']);
$this->line('Staging straordinarie legacy: ' . $staging['straord_count']);
$this->line('Staging frazionamenti: Fraz_gen=' . $staging['fraz_gen_count'] . ', fraz_dett=' . $staging['fraz_dett_count']);
} else {
$this->warn('Connessione/tabelle staging non disponibili per il confronto completo.');
}
$exitCode = self::SUCCESS;
if ($tablesWithoutPositiveRows->isNotEmpty()) {
$exitCode = self::INVALID;
$this->warn('Tabelle senza alcuna riga millesimale positiva: ' . $tablesWithoutPositiveRows->take($showMissing)->implode(', '));
}
if ($staging['available']) {
if (! empty($staging['missing_table_codes'])) {
$exitCode = self::INVALID;
$this->warn('Codici tabella presenti in staging ma mancanti nel dominio: ' . implode(', ', array_slice($staging['missing_table_codes'], 0, $showMissing)));
}
if (! empty($staging['missing_voci_codes'])) {
$exitCode = self::INVALID;
$this->warn('Codici voce presenti in staging ma mancanti nel dominio: ' . implode(', ', array_slice($staging['missing_voci_codes'], 0, $showMissing)));
}
if ($staging['fraz_gen_count'] > 0 || $staging['fraz_dett_count'] > 0) {
$this->warn('Archivio frazionamenti legacy presente: verificare Z01/Z02 prima del via libera contabile.');
if (($staging['z_fraz_voci_missing'] ?? []) !== []) {
$exitCode = self::INVALID;
$this->warn('Voci Z con frazionamento presenti in staging ma non allineate nel dominio: ' . implode(', ', array_slice($staging['z_fraz_voci_missing'], 0, $showMissing)));
}
}
}
if ($unitsWithoutNominativi > 0 || $detailZeroOnUnitsWithoutNominativi > 0 || $vociConTabellaOrfana > 0) {
$exitCode = self::INVALID;
}
if ($exitCode === self::SUCCESS) {
$this->info('QA completato senza anomalie bloccanti.');
} else {
$this->warn('QA completato con anomalie da correggere prima del riallineamento definitivo.');
}
return $exitCode;
}
private function resolveStabileScope(): array
{
$stabileId = (int) ($this->option('stabile_id') ?: 0);
if ($stabileId > 0) {
$codice = DB::table('stabili')->where('id', $stabileId)->value('codice_stabile');
return [$stabileId, $this->normalizeOption($codice) ?? ''];
}
$codiceInput = $this->normalizeCode((string) ($this->option('stabile') ?? ''));
if ($codiceInput === '') {
return [0, ''];
}
$trimmed = ltrim($codiceInput, '0');
$stabile = DB::table('stabili')
->where(function ($q) use ($codiceInput, $trimmed): void {
$q->where('codice_stabile', $codiceInput);
if ($trimmed !== '' && $trimmed !== $codiceInput) {
$q->orWhere('codice_stabile', $trimmed);
}
})
->select('id', 'codice_stabile')
->first();
if (! $stabile) {
return [0, ''];
}
return [(int) $stabile->id, (string) $stabile->codice_stabile];
}
private function collectStagingMetrics(int $stabileId, string $codiceStabile, ?string $legacyYear): array
{
$result = [
'available' => false,
'headers_count' => 0,
'detail_codes_count' => 0,
'voci_count' => 0,
'straord_count' => 0,
'fraz_gen_count' => 0,
'fraz_dett_count' => 0,
'missing_table_codes' => [],
'missing_voci_codes' => [],
'z_fraz_voci_missing' => [],
];
if (! config('database.connections.gescon_import')) {
return $result;
}
$result['available'] = true;
$code = $this->normalizeCode($codiceStabile);
$domainTableCodes = Schema::hasTable('tabelle_millesimali')
? DB::table('tabelle_millesimali')
->where('stabile_id', $stabileId)
->selectRaw('UPPER(TRIM(COALESCE(codice_tabella, nome_tabella, denominazione))) as codice')
->pluck('codice')
->filter()
->unique()
->values()
->all()
: [];
$domainVoceCodes = Schema::hasTable('voci_spesa')
? DB::table('voci_spesa')
->where('stabile_id', $stabileId)
->selectRaw('UPPER(TRIM(codice)) as codice')
->pluck('codice')
->filter()
->unique()
->values()
->all()
: [];
if (Schema::hasTable('voci_spesa') && Schema::hasColumn('voci_spesa', 'legacy_codice')) {
$domainLegacyVoceCodes = DB::table('voci_spesa')
->where('stabile_id', $stabileId)
->selectRaw('UPPER(TRIM(legacy_codice)) as codice')
->pluck('codice')
->filter()
->unique()
->values()
->all();
$domainVoceCodes = array_values(array_unique(array_merge($domainVoceCodes, $domainLegacyVoceCodes)));
}
if (Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) {
$headersQuery = DB::connection('gescon_import')->table('tabelle_millesimali');
if (Schema::connection('gescon_import')->hasColumn('tabelle_millesimali', 'cod_stabile')) {
$headersQuery->where('cod_stabile', $code);
}
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('tabelle_millesimali', 'legacy_year')) {
$headersQuery->where('legacy_year', $legacyYear);
}
$headerCodes = $headersQuery
->selectRaw('UPPER(TRIM(COALESCE(cod_tabella, denominazione, descrizione))) as codice')
->pluck('codice')
->filter()
->unique()
->values()
->all();
$result['headers_count'] = count($headerCodes);
$result['missing_table_codes'] = array_values(array_diff($headerCodes, $domainTableCodes));
}
if (Schema::connection('gescon_import')->hasTable('dett_tab')) {
$detailQuery = DB::connection('gescon_import')->table('dett_tab as d');
if (Schema::connection('gescon_import')->hasTable('condomin')) {
$detailQuery->join('condomin as c', 'c.cod_cond', '=', 'd.id_cond')
->where('c.cod_stabile', $code);
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('condomin', 'legacy_year')) {
$detailQuery->where('c.legacy_year', $legacyYear);
}
}
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('dett_tab', 'legacy_year')) {
$detailQuery->where('d.legacy_year', $legacyYear);
}
$detailCodes = $detailQuery
->selectRaw('UPPER(TRIM(d.cod_tab)) as codice')
->pluck('codice')
->filter()
->unique()
->values()
->all();
$result['detail_codes_count'] = count($detailCodes);
if ($result['missing_table_codes'] === []) {
$result['missing_table_codes'] = array_values(array_diff($detailCodes, $domainTableCodes));
}
}
if (Schema::connection('gescon_import')->hasTable('voc_spe')) {
$vociQuery = DB::connection('gescon_import')->table('voc_spe');
if (Schema::connection('gescon_import')->hasColumn('voc_spe', 'cod_stabile')) {
$vociQuery->where('cod_stabile', $code);
}
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('voc_spe', 'legacy_year')) {
$vociQuery->where('legacy_year', $legacyYear);
}
$vocCodes = $vociQuery
->selectRaw('UPPER(TRIM(cod)) as codice')
->pluck('codice')
->filter()
->unique()
->values()
->all();
$result['voci_count'] = count($vocCodes);
$result['missing_voci_codes'] = array_values(array_diff($vocCodes, $domainVoceCodes));
$zFractionsQuery = DB::connection('gescon_import')->table('voc_spe')
->where('cod_stabile', $code);
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('voc_spe', 'legacy_year')) {
$zFractionsQuery->where('legacy_year', $legacyYear);
}
$zFractions = $zFractionsQuery
->selectRaw('UPPER(TRIM(cod)) as codice')
->whereIn('cod', ['Z01', 'Z02'])
->pluck('codice')
->filter()
->unique()
->values()
->all();
$result['z_fraz_voci_missing'] = array_values(array_diff($zFractions, $domainVoceCodes));
}
if (Schema::connection('gescon_import')->hasTable('straordinarie')) {
$straordQuery = DB::connection('gescon_import')->table('straordinarie');
if (Schema::connection('gescon_import')->hasColumn('straordinarie', 'cod_stabile')) {
$straordQuery->where('cod_stabile', $code);
}
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('straordinarie', 'legacy_year')) {
$straordQuery->where('legacy_year', $legacyYear);
}
$result['straord_count'] = $straordQuery->count();
}
if (Schema::connection('gescon_import')->hasTable('fraz_gen')) {
$query = DB::connection('gescon_import')->table('fraz_gen');
if (Schema::connection('gescon_import')->hasColumn('fraz_gen', 'cod_stabile')) {
$query->where('cod_stabile', $code);
}
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('fraz_gen', 'legacy_year')) {
$query->where('legacy_year', $legacyYear);
}
$result['fraz_gen_count'] = $query->count();
}
if (Schema::connection('gescon_import')->hasTable('fraz_dett')) {
$query = DB::connection('gescon_import')->table('fraz_dett');
if ($legacyYear !== null && Schema::connection('gescon_import')->hasColumn('fraz_dett', 'legacy_year')) {
$query->where('legacy_year', $legacyYear);
}
$result['fraz_dett_count'] = $query->count();
}
return $result;
}
private function normalizeOption(mixed $value): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
}
private function normalizeCode(string $code): string
{
$code = trim($code);
if ($code === '') {
return '';
}
if (is_numeric($code)) {
return str_pad((string) ((int) $code), 4, '0', STR_PAD_LEFT);
}
return strtoupper($code);
}
}

View File

@ -193,15 +193,15 @@ public function handle(): int
'status' => 'received', 'status' => 'received',
'received_at' => $receivedAt, 'received_at' => $receivedAt,
'metadata' => [ 'metadata' => [
'raw_line' => $line, 'raw_line' => $line,
'source_host' => $host, 'source_host' => $host,
'source_port' => $port, 'source_port' => $port,
'assigned_user_id' => $assignedUserId, 'assigned_user_id' => $assignedUserId,
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
'amministratore_id'=> $amministratoreId, 'amministratore_id' => $amministratoreId,
'studio_role' => $routing['studio_role'], 'studio_role' => $routing['studio_role'],
'studio_mode' => $routing['studio_mode'], 'studio_mode' => $routing['studio_mode'],
'smdr' => [ 'smdr' => [
'date' => $parsed['date'], 'date' => $parsed['date'],
'time' => $parsed['time'], 'time' => $parsed['time'],
'extension' => $parsed['extension'], 'extension' => $parsed['extension'],
@ -245,6 +245,8 @@ public function handle(): int
try { try {
$lineSuffix = ! empty($parsed['co']) ? ' - linea ' . (string) $parsed['co'] : ''; $lineSuffix = ! empty($parsed['co']) ? ' - linea ' . (string) $parsed['co'] : '';
$isMissedResponseGroup = $this->isMissedResponseGroupCall($parsed, $routing);
ChiamataPostIt::query()->create([ ChiamataPostIt::query()->create([
'telefono' => $phone, 'telefono' => $phone,
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
@ -253,7 +255,7 @@ public function handle(): int
'oggetto' => 'Chiamata in entrata da SMDR' . $lineSuffix, 'oggetto' => 'Chiamata in entrata da SMDR' . $lineSuffix,
'nota' => $line, 'nota' => $line,
'direzione' => 'in_arrivo', 'direzione' => 'in_arrivo',
'esito' => ($durationSeconds ?? 0) > 0 ? 'answered' : 'missed', 'esito' => $isMissedResponseGroup || ($durationSeconds ?? 0) <= 0 ? 'missed' : 'answered',
'origine' => 'smdr', 'origine' => 'smdr',
'origine_id' => $fingerprint, 'origine_id' => $fingerprint,
'durata_secondi' => $durationSeconds, 'durata_secondi' => $durationSeconds,
@ -518,4 +520,18 @@ private function shouldCreateOperationalPostIt(array $parsed): bool
return ! empty($parsed['co']); return ! empty($parsed['co']);
} }
/** @param array<string,mixed> $parsed */
private function isMissedResponseGroupCall(array $parsed, array $routing): bool
{
$groupExtensions = ['601', '603'];
$groupExtension = preg_replace('/\D+/', '', (string) ($parsed['extension'] ?? '')) ?: '';
if (! in_array($groupExtension, $groupExtensions, true)) {
return false;
}
$targetExtension = preg_replace('/\D+/', '', (string) ($routing['target_extension'] ?? '')) ?: '';
return $targetExtension === '' || in_array($targetExtension, $groupExtensions, true);
}
} }

View File

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class SyncGesconFornitoriLegacyCommand extends Command
{
protected $signature = 'gescon:sync-fornitori-legacy
{amministratore_id : ID amministratore a cui associare i fornitori}
{--mdb=/mnt/gescon-archives/gescon/dbc/Fornitori.mdb : Percorso file Fornitori.mdb}
{--table=Fornitori : Nome tabella Access}
{--truncate-staging : Svuota la tabella staging prima dell\'import}
{--skip-tags : Salta l\'aggiornamento tag legacy}
{--dry-run : Esegue preview staging e dry-run dell\'import locale senza scrivere}
{--preview-limit=10 : Limite righe preview staging in dry-run}';
protected $description = 'Esegue la sync completa fornitori legacy -> staging -> archivio locale -> tag legacy.';
public function handle(): int
{
$adminId = (int) $this->argument('amministratore_id');
$mdb = (string) $this->option('mdb');
$table = (string) $this->option('table');
$dryRun = (bool) $this->option('dry-run');
$skipTags = (bool) $this->option('skip-tags');
$truncateStaging = (bool) $this->option('truncate-staging');
$previewLimit = max(1, (int) ($this->option('preview-limit') ?: 10));
if ($adminId <= 0) {
$this->error('amministratore_id non valido.');
return self::FAILURE;
}
$this->info('Sync fornitori legacy per amministratore #' . $adminId . ($dryRun ? ' [DRY-RUN]' : ''));
$stageParams = [
'tenant' => (string) $adminId,
'--mdb' => $mdb,
'--table' => $table,
];
if ($dryRun) {
$stageParams['--preview'] = true;
$stageParams['--limit'] = $previewLimit;
}
if ($truncateStaging && ! $dryRun) {
$stageParams['--truncate'] = true;
}
$stageExit = Artisan::call('import:gescon-fornitori', $stageParams);
$stageOutput = trim((string) Artisan::output());
$this->line('Step staging exit=' . $stageExit);
if ($stageOutput !== '') {
$this->line($stageOutput);
}
if ($stageExit !== 0) {
return self::FAILURE;
}
$importParams = [
'amministratore_id' => (string) $adminId,
'--mdb' => $mdb,
'--table' => $table,
];
if ($dryRun) {
$importParams['--dry-run'] = true;
}
$importExit = Artisan::call('gescon:import-fornitori-legacy', $importParams);
$importOutput = trim((string) Artisan::output());
$this->line('Step archivio locale exit=' . $importExit);
if ($importOutput !== '') {
$this->line($importOutput);
}
if ($importExit !== 0) {
return self::FAILURE;
}
if (! $skipTags) {
$tagParams = [];
if ($dryRun) {
$tagParams['--dry-run'] = true;
}
$tagExit = Artisan::call('fornitori:import-legacy-tags', $tagParams);
$tagOutput = trim((string) Artisan::output());
$this->line('Step tag legacy exit=' . $tagExit);
if ($tagOutput !== '') {
$this->line($tagOutput);
}
if ($tagExit !== 0) {
return self::FAILURE;
}
}
$this->info($dryRun
? 'Dry-run sync fornitori legacy completato.'
: 'Sync fornitori legacy completato.');
return self::SUCCESS;
}
}

View File

@ -192,10 +192,10 @@ protected function buildInterventoRow(TicketIntervento $intervento): array
*/ */
protected function extractCallerData(string $descrizione): array protected function extractCallerData(string $descrizione): array
{ {
$contatto = '-'; $contatto = '-';
$telefono = ''; $telefono = '';
$problema = ''; $problema = '';
$email = ''; $email = '';
$riferimento = ''; $riferimento = '';
$lines = preg_split('/\r\n|\r|\n/', $descrizione) ?: []; $lines = preg_split('/\r\n|\r|\n/', $descrizione) ?: [];
@ -230,7 +230,7 @@ protected function extractCallerData(string $descrizione): array
} }
if (str_starts_with($trim, 'Riferimento unità:')) { if (str_starts_with($trim, 'Riferimento unità:')) {
$unita = trim((string) str_replace('Riferimento unità:', '', $trim)); $unita = trim((string) str_replace('Riferimento unità:', '', $trim));
$riferimento = $riferimento !== '' ? ($riferimento . ' · ' . $unita) : $unita; $riferimento = $riferimento !== '' ? ($riferimento . ' · ' . $unita) : $unita;
} }
} }

View File

@ -46,7 +46,7 @@ public static function canAccess(): bool
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
} }
public function mount(): void public function mount(): void
@ -58,7 +58,7 @@ public function mount(): void
return; return;
} }
$this->fornitoreId = (int) $fornitore->id; $this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore); $this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->refreshRows(); $this->refreshRows();
} }
@ -75,7 +75,7 @@ public function refreshRows(): void
return; return;
} }
$term = trim($this->search); $term = trim($this->search);
$normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? ''; $normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? '';
$products = Product::query() $products = Product::query()
@ -106,20 +106,18 @@ public function refreshRows(): void
$this->rows = $products->map(function (Product $product): array { $this->rows = $products->map(function (Product $product): array {
$identifier = $product->identifiers $identifier = $product->identifiers
->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitoreId) ->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitoreId) ?? $product->identifiers->first();
?? $product->identifiers->first();
$offer = $product->offers $offer = $product->offers
->first(fn(ProductOffer $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitoreId) ->first(fn(ProductOffer $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitoreId) ?? $product->offers->first();
?? $product->offers->first();
return [ return [
'id' => (int) $product->id, 'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''), 'internal_code' => (string) ($product->internal_code ?? ''),
'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), 'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))),
'barcode' => (string) ($identifier->code_value ?? ''), 'barcode' => (string) ($identifier->code_value ?? ''),
'source' => (string) ($offer?->source_name ?: 'Catalogo interno'), 'source' => (string) ($offer?->source_name ?: 'Catalogo interno'),
'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : '-', 'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : '-',
'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)),
]; ];
})->all(); })->all();
} }
@ -144,4 +142,4 @@ protected function buildAmazonSearchUrl(string $term): string
return 'https://www.amazon.it/s?' . http_build_query($query); return 'https://www.amazon.it/s?' . http_build_query($query);
} }
} }

View File

@ -48,7 +48,7 @@ public static function canAccess(): bool
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
} }
public function mount(): void public function mount(): void
@ -60,9 +60,9 @@ public function mount(): void
return; return;
} }
$this->fornitoreId = (int) $fornitore->id; $this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore); $this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus($fornitore); $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus($fornitore);
$this->refreshRows(); $this->refreshRows();
} }
@ -91,7 +91,7 @@ public function refreshRows(): void
foreach ($items as $intervento) { foreach ($items as $intervento) {
$contact = $this->resolveCallerDataForRubrica($intervento); $contact = $this->resolveCallerDataForRubrica($intervento);
$key = $contact['identity_key']; $key = $contact['identity_key'];
if ($key === '') { if ($key === '') {
$key = 'ticket-' . (int) $intervento->ticket_id; $key = 'ticket-' . (int) $intervento->ticket_id;
@ -113,14 +113,14 @@ public function refreshRows(): void
if (! isset($grouped[$key])) { if (! isset($grouped[$key])) {
$grouped[$key] = [ $grouped[$key] = [
'contatto' => $contact['contatto'], 'contatto' => $contact['contatto'],
'telefono' => $contact['telefono'], 'telefono' => $contact['telefono'],
'email' => $contact['email'], 'email' => $contact['email'],
'stabile' => $contact['stabile'], 'stabile' => $contact['stabile'],
'origine' => $contact['origine'], 'origine' => $contact['origine'],
'ultimo_ticket_id' => (int) $intervento->ticket_id, 'ultimo_ticket_id' => (int) $intervento->ticket_id,
'ultima_scheda_url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'), 'ultima_scheda_url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'),
'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-', 'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'totale_interventi' => 1, 'totale_interventi' => 1,
]; ];
continue; continue;
@ -162,19 +162,19 @@ protected function resolveCallerDataForRubrica(TicketIntervento $intervento): ar
$contatto = $parsed['contatto']; $contatto = $parsed['contatto'];
$telefono = $parsed['telefono']; $telefono = $parsed['telefono'];
$email = ''; $email = '';
$origine = 'Descrizione ticket'; $origine = 'Descrizione ticket';
if ($ticket?->soggettoRichiedente) { if ($ticket?->soggettoRichiedente) {
$soggetto = $ticket->soggettoRichiedente; $soggetto = $ticket->soggettoRichiedente;
$contatto = trim((string) ($soggetto->ragione_sociale ?: trim(($soggetto->nome ?? '') . ' ' . ($soggetto->cognome ?? '')))) ?: $contatto; $contatto = trim((string) ($soggetto->ragione_sociale ?: trim(($soggetto->nome ?? '') . ' ' . ($soggetto->cognome ?? '')))) ?: $contatto;
$telefono = trim((string) ($soggetto->telefono ?? '')) ?: $telefono; $telefono = trim((string) ($soggetto->telefono ?? '')) ?: $telefono;
$email = trim((string) ($soggetto->email ?? '')); $email = trim((string) ($soggetto->email ?? ''));
$origine = 'Richiedente ticket'; $origine = 'Richiedente ticket';
} elseif ($ticket?->apertoDaUser) { } elseif ($ticket?->apertoDaUser) {
$contatto = trim((string) ($ticket->apertoDaUser->name ?? '')) ?: $contatto; $contatto = trim((string) ($ticket->apertoDaUser->name ?? '')) ?: $contatto;
$email = trim((string) ($ticket->apertoDaUser->email ?? '')); $email = trim((string) ($ticket->apertoDaUser->email ?? ''));
$origine = 'Utente apertura ticket'; $origine = 'Utente apertura ticket';
} }
$identityKey = ''; $identityKey = '';
@ -187,11 +187,11 @@ protected function resolveCallerDataForRubrica(TicketIntervento $intervento): ar
} }
return [ return [
'contatto' => $contatto !== '' ? $contatto : 'Contatto non identificato', 'contatto' => $contatto !== '' ? $contatto : 'Contatto non identificato',
'telefono' => $telefono, 'telefono' => $telefono,
'email' => $email, 'email' => $email,
'origine' => $origine, 'origine' => $origine,
'stabile' => (string) ($ticket?->stabile?->denominazione ?? '-'), 'stabile' => (string) ($ticket?->stabile?->denominazione ?? '-'),
'identity_key' => $identityKey, 'identity_key' => $identityKey,
]; ];
} }
@ -209,10 +209,10 @@ protected function resolveGoogleWorkspaceStatus(Fornitore $fornitore): ?array
$oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []); $oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []);
return [ return [
'connected' => (bool) ($oauth['connected'] ?? false), 'connected' => (bool) ($oauth['connected'] ?? false),
'email' => (string) ($oauth['email'] ?? ''), 'email' => (string) ($oauth['email'] ?? ''),
'name' => (string) ($oauth['name'] ?? ''), 'name' => (string) ($oauth['name'] ?? ''),
'connected_at' => (string) ($oauth['connected_at'] ?? ''), 'connected_at' => (string) ($oauth['connected_at'] ?? ''),
]; ];
} }
} }

View File

@ -50,16 +50,16 @@ class TicketInterventoScheda extends Page
/** @var array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string,rubrica_url:?string,estratto_url:?string,stabile_url:?string,unita_label:string} */ /** @var array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string,rubrica_url:?string,estratto_url:?string,stabile_url:?string,unita_label:string} */
public array $caller = [ public array $caller = [
'contatto' => '-', 'contatto' => '-',
'telefono' => '', 'telefono' => '',
'email' => '', 'email' => '',
'problema' => '', 'problema' => '',
'sorgente' => '', 'sorgente' => '',
'riferimento' => '', 'riferimento' => '',
'rubrica_url' => null, 'rubrica_url' => null,
'estratto_url'=> null, 'estratto_url' => null,
'stabile_url' => null, 'stabile_url' => null,
'unita_label' => '', 'unita_label' => '',
]; ];
/** @var array<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
@ -1094,16 +1094,16 @@ protected function resolveCallerData(?Ticket $ticket): array
$parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? '')); $parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? ''));
$caller = [ $caller = [
'contatto' => $parsed['contatto'], 'contatto' => $parsed['contatto'],
'telefono' => $parsed['telefono'], 'telefono' => $parsed['telefono'],
'email' => $parsed['email'] ?? '', 'email' => $parsed['email'] ?? '',
'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''), 'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''),
'sorgente' => 'Descrizione ticket', 'sorgente' => 'Descrizione ticket',
'riferimento' => $this->buildCallerReference($ticket, (string) ($parsed['riferimento'] ?? '')), 'riferimento' => $this->buildCallerReference($ticket, (string) ($parsed['riferimento'] ?? '')),
'rubrica_url' => null, 'rubrica_url' => null,
'estratto_url'=> null, 'estratto_url' => null,
'stabile_url' => $ticket?->stabile ? StabileScheda::getUrl(['record' => (int) $ticket->stabile->id], panel: 'admin-filament') : null, 'stabile_url' => $ticket?->stabile ? StabileScheda::getUrl(['record' => (int) $ticket->stabile->id], panel : 'admin-filament'): null,
'unita_label' => $ticket?->unitaImmobiliare ? $this->formatUnitaLabel($ticket->unitaImmobiliare) : '', 'unita_label' => $ticket?->unitaImmobiliare ? $this->formatUnitaLabel($ticket->unitaImmobiliare) : '',
]; ];
if (($caller['contatto'] ?? '-') === '-' || trim((string) ($caller['contatto'] ?? '')) === '') { if (($caller['contatto'] ?? '-') === '-' || trim((string) ($caller['contatto'] ?? '')) === '') {
@ -1120,9 +1120,9 @@ protected function resolveCallerData(?Ticket $ticket): array
$caller['contatto'] = $label; $caller['contatto'] = $label;
} }
$caller['telefono'] = trim((string) ($soggetto->telefono ?? '')) ?: $caller['telefono']; $caller['telefono'] = trim((string) ($soggetto->telefono ?? '')) ?: $caller['telefono'];
$caller['email'] = trim((string) ($soggetto->email ?? '')) ?: $caller['email']; $caller['email'] = trim((string) ($soggetto->email ?? '')) ?: $caller['email'];
$caller['sorgente'] = 'Richiedente collegato al ticket'; $caller['sorgente'] = 'Richiedente collegato al ticket';
$caller['estratto_url'] = EstrattoContoSoggetto::getUrl(['record' => (int) $soggetto->id], panel: 'admin-filament'); $caller['estratto_url'] = EstrattoContoSoggetto::getUrl(['record' => (int) $soggetto->id], panel: 'admin-filament');
$rubrica = $this->resolveCallerRubrica($ticket, $caller); $rubrica = $this->resolveCallerRubrica($ticket, $caller);
@ -1204,7 +1204,7 @@ private function resolveCallerRubrica(?Ticket $ticket, array $caller): ?RubricaU
{ {
$adminId = (int) ($ticket?->stabile?->amministratore_id ?? 0); $adminId = (int) ($ticket?->stabile?->amministratore_id ?? 0);
$queries = []; $queries = [];
$soggetto = $ticket?->soggettoRichiedente; $soggetto = $ticket?->soggettoRichiedente;
if ($soggetto && filled($soggetto->codice_fiscale)) { if ($soggetto && filled($soggetto->codice_fiscale)) {
$queries[] = fn() => RubricaUniversale::query()->where('codice_fiscale', strtoupper(trim((string) $soggetto->codice_fiscale))); $queries[] = fn() => RubricaUniversale::query()->where('codice_fiscale', strtoupper(trim((string) $soggetto->codice_fiscale)));

View File

@ -717,29 +717,23 @@ public function aggiornaFornitoriGescon(): void
} }
try { try {
$exit1 = Artisan::call('import:gescon-fornitori', [ $exit = Artisan::call('gescon:sync-fornitori-legacy', [
'tenant' => (string) $amm->id,
'--mdb' => $mdb,
]);
$out1 = trim((string) Artisan::output());
$exit2 = Artisan::call('gescon:import-fornitori-legacy', [
'amministratore_id' => (string) $amm->id, 'amministratore_id' => (string) $amm->id,
'--mdb' => $mdb, '--mdb' => $mdb,
]); ]);
$out2 = trim((string) Artisan::output()); $out = trim((string) Artisan::output());
} catch (\Throwable $e) { } catch (\Throwable $e) {
Notification::make()->title('Aggiornamento fornitori fallito')->body($e->getMessage())->danger()->send(); Notification::make()->title('Aggiornamento fornitori fallito')->body($e->getMessage())->danger()->send();
return; return;
} }
if ($exit1 !== 0 || $exit2 !== 0) { if ($exit !== 0) {
$snippet = $out2 !== '' ? $out2 : $out1; $snippet = $out !== '' ? $out : '';
$snippet = $snippet !== '' ? mb_substr($snippet, 0, 800) : ''; $snippet = $snippet !== '' ? mb_substr($snippet, 0, 800) : '';
Notification::make() Notification::make()
->title('Aggiornamento fornitori: errore') ->title('Aggiornamento fornitori: errore')
->body('Exit staging=' . $exit1 . ' | exit import=' . $exit2 . ($snippet !== '' ? "\n\n" . $snippet : '')) ->body('Exit sync=' . $exit . ($snippet !== '' ? "\n\n" . $snippet : ''))
->danger() ->danger()
->send(); ->send();
return; return;
@ -792,24 +786,12 @@ public function refreshImportTagLegacyFornitoriGlobale(): void
} }
try { try {
$exit1 = Artisan::call('import:gescon-fornitori', [ $exit = Artisan::call('gescon:sync-fornitori-legacy', [
'tenant' => (string) $amm->id,
'--mdb' => $mdb,
]);
$out1 = trim((string) Artisan::output());
$exit2 = Artisan::call('gescon:import-fornitori-legacy', [
'amministratore_id' => (string) $amm->id, 'amministratore_id' => (string) $amm->id,
'--mdb' => $mdb, '--mdb' => $mdb,
'--dry-run' => $dryRun,
]); ]);
$out2 = trim((string) Artisan::output()); $out = trim((string) Artisan::output());
$tagParams = [];
if ($dryRun) {
$tagParams['--dry-run'] = true;
}
$exit3 = Artisan::call('fornitori:import-legacy-tags', $tagParams);
$out3 = trim((string) Artisan::output());
} catch (\Throwable $e) { } catch (\Throwable $e) {
Notification::make() Notification::make()
->title('Refresh + import TAG fallito') ->title('Refresh + import TAG fallito')
@ -819,13 +801,13 @@ public function refreshImportTagLegacyFornitoriGlobale(): void
return; return;
} }
if ($exit1 !== 0 || $exit2 !== 0 || $exit3 !== 0) { if ($exit !== 0) {
$snippet = $out3 !== '' ? $out3 : ($out2 !== '' ? $out2 : $out1); $snippet = $out !== '' ? $out : '';
$snippet = $snippet !== '' ? mb_substr($snippet, 0, 800) : ''; $snippet = $snippet !== '' ? mb_substr($snippet, 0, 800) : '';
Notification::make() Notification::make()
->title('Refresh + import TAG: errore') ->title('Refresh + import TAG: errore')
->body('Exit staging=' . $exit1 . ' | import=' . $exit2 . ' | tags=' . $exit3 . ($snippet !== '' ? "\n\n" . $snippet : '')) ->body('Exit sync=' . $exit . ($snippet !== '' ? "\n\n" . $snippet : ''))
->danger() ->danger()
->send(); ->send();
return; return;
@ -1013,22 +995,16 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
if (! empty($options['with_operazioni'])) { if (! empty($options['with_operazioni'])) {
try { try {
$exit1 = Artisan::call('import:gescon-fornitori', [ $exit = Artisan::call('gescon:sync-fornitori-legacy', [
'tenant' => (string) $amm->id,
'--mdb' => (string) ($state['fornitori_mdb'] ?? rtrim($options['path'], '/') . '/dbc/Fornitori.mdb'),
]);
$outputs[] = trim((string) Artisan::output());
$exit2 = Artisan::call('gescon:import-fornitori-legacy', [
'amministratore_id' => (string) $amm->id, 'amministratore_id' => (string) $amm->id,
'--mdb' => (string) ($state['fornitori_mdb'] ?? rtrim($options['path'], '/') . '/dbc/Fornitori.mdb'), '--mdb' => (string) ($state['fornitori_mdb'] ?? rtrim($options['path'], '/') . '/dbc/Fornitori.mdb'),
]); ]);
$outputs[] = trim((string) Artisan::output()); $outputs[] = trim((string) Artisan::output());
if ($exit1 !== 0 || $exit2 !== 0) { if ($exit !== 0) {
Notification::make() Notification::make()
->title('Aggiornamento fornitori: errore') ->title('Aggiornamento fornitori: errore')
->body('Exit staging=' . $exit1 . ' | exit import=' . $exit2) ->body('Exit sync=' . $exit)
->danger() ->danger()
->send(); ->send();
return; return;

View File

@ -19,4 +19,4 @@ class AggiornamentoLauncher extends Modifiche
protected static ?string $slug = 'supporto/aggiornamento-nodo'; protected static ?string $slug = 'supporto/aggiornamento-nodo';
protected string $view = 'filament.pages.supporto.aggiornamento-launcher'; protected string $view = 'filament.pages.supporto.aggiornamento-launcher';
} }

View File

@ -36,7 +36,7 @@ class GestioneAssicurazioni extends Page
public array $totals = [ public array $totals = [
'aperte' => 0, 'aperte' => 0,
'chiuse' => 0, 'chiuse' => 0,
'tutte' => 0, 'tutte' => 0,
]; ];
public static function canAccess(): bool public static function canAccess(): bool
@ -44,12 +44,12 @@ public static function canAccess(): bool
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
} }
public function mount(): void public function mount(): void
{ {
$this->status = (string) request()->query('status', 'aperte'); $this->status = (string) request()->query('status', 'aperte');
$this->focusTicketId = (int) request()->query('ticket', 0) ?: null; $this->focusTicketId = (int) request()->query('ticket', 0) ?: null;
$this->refreshData(); $this->refreshData();
} }
@ -69,7 +69,7 @@ public function refreshData(): void
{ {
$stabileIds = $this->resolveAccessibleStabileIds(); $stabileIds = $this->resolveAccessibleStabileIds();
if ($stabileIds === []) { if ($stabileIds === []) {
$this->rows = []; $this->rows = [];
$this->totals = ['aperte' => 0, 'chiuse' => 0, 'tutte' => 0]; $this->totals = ['aperte' => 0, 'chiuse' => 0, 'tutte' => 0];
return; return;
} }
@ -96,23 +96,23 @@ public function refreshData(): void
->get() ->get()
->map(function (InsuranceClaim $claim): array { ->map(function (InsuranceClaim $claim): array {
return [ return [
'id' => (int) $claim->id, 'id' => (int) $claim->id,
'ticket_id' => (int) $claim->ticket_id, 'ticket_id' => (int) $claim->ticket_id,
'ticket_title' => (string) ($claim->ticket->titolo ?? '-'), 'ticket_title' => (string) ($claim->ticket->titolo ?? '-'),
'stabile' => (string) ($claim->ticket->stabile->denominazione ?? '-'), 'stabile' => (string) ($claim->ticket->stabile->denominazione ?? '-'),
'claim_number' => (string) ($claim->claim_number ?? ''), 'claim_number' => (string) ($claim->claim_number ?? ''),
'policy_reference' => (string) ($claim->policy_reference ?? ''), 'policy_reference' => (string) ($claim->policy_reference ?? ''),
'policy_label' => trim(implode(' · ', array_filter([ 'policy_label' => trim(implode(' · ', array_filter([
(string) ($claim->insurancePolicy->company_name ?? ''), (string) ($claim->insurancePolicy->company_name ?? ''),
(string) ($claim->insurancePolicy->policy_number ?? ''), (string) ($claim->insurancePolicy->policy_number ?? ''),
]))), ]))),
'status' => (string) ($claim->status ?? 'aperta'), 'status' => (string) ($claim->status ?? 'aperta'),
'opened_at' => optional($claim->opened_at)->format('d/m/Y H:i') ?: '-', 'opened_at' => optional($claim->opened_at)->format('d/m/Y H:i') ?: '-',
'next_action' => (string) (data_get($claim->metadata, 'next_action') ?? ''), 'next_action' => (string) (data_get($claim->metadata, 'next_action') ?? ''),
'documents_sent_at' => filled(data_get($claim->metadata, 'documents_sent_at')) ? (string) data_get($claim->metadata, 'documents_sent_at') : '', 'documents_sent_at' => filled(data_get($claim->metadata, 'documents_sent_at')) ? (string) data_get($claim->metadata, 'documents_sent_at') : '',
'ticket_url' => TicketGestione::getUrl([ 'ticket_url' => TicketGestione::getUrl([
'ticket' => (int) $claim->ticket_id, 'ticket' => (int) $claim->ticket_id,
'tab' => 'assicurazione', 'tab' => 'assicurazione',
], panel: 'admin-filament'), ], panel: 'admin-filament'),
]; ];
}) })
@ -121,7 +121,7 @@ public function refreshData(): void
$this->totals = [ $this->totals = [
'aperte' => (clone $base)->whereNotIn('status', ['chiusa', 'chiuso', 'archiviata'])->count(), 'aperte' => (clone $base)->whereNotIn('status', ['chiusa', 'chiuso', 'archiviata'])->count(),
'chiuse' => (clone $base)->whereIn('status', ['chiusa', 'chiuso', 'archiviata'])->count(), 'chiuse' => (clone $base)->whereIn('status', ['chiusa', 'chiuso', 'archiviata'])->count(),
'tutte' => (clone $base)->count(), 'tutte' => (clone $base)->count(),
]; ];
} }
@ -142,4 +142,4 @@ private function resolveAccessibleStabileIds(): array
->values() ->values()
->all(); ->all();
} }
} }

View File

@ -231,8 +231,8 @@ public function canRunUpdate(): bool
$user = Auth::user(); $user = Auth::user();
return is_object($user) return is_object($user)
&& method_exists($user, 'hasAnyRole') && method_exists($user, 'hasAnyRole')
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']);
} }
public function reloadDashboardData(): void public function reloadDashboardData(): void

View File

@ -610,18 +610,18 @@ public function creaTicketRapido(): void
$descrizione .= "\nTipo intervento: " . $label; $descrizione .= "\nTipo intervento: " . $label;
} }
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
'unita_immobiliare_id'=> $callerContext['unita_id'] ?? null, 'unita_immobiliare_id' => $callerContext['unita_id'] ?? null,
'soggetto_richiedente_id' => $callerContext['soggetto_id'] ?? null, 'soggetto_richiedente_id' => $callerContext['soggetto_id'] ?? null,
'aperto_da_user_id' => $user->id, 'aperto_da_user_id' => $user->id,
'titolo' => (string) $this->newTicketTitolo, 'titolo' => (string) $this->newTicketTitolo,
'descrizione' => $descrizione, 'descrizione' => $descrizione,
'categoria_ticket_id' => $this->newTicketCategoriaId, 'categoria_ticket_id' => $this->newTicketCategoriaId,
'luogo_intervento' => $this->newTicketLuogo, 'luogo_intervento' => $this->newTicketLuogo,
'data_apertura' => now(), 'data_apertura' => now(),
'stato' => 'Aperto', 'stato' => 'Aperto',
'priorita' => $this->newTicketPriorita, 'priorita' => $this->newTicketPriorita,
]); ]);
$savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id); $savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id);
@ -1178,7 +1178,7 @@ public function excerpt(?string $text, int $limit = 160): string
public function getCallerStabileLabel(int $rubricaId): ?string public function getCallerStabileLabel(int $rubricaId): ?string
{ {
$caller = RubricaUniversale::query()->find($rubricaId); $caller = RubricaUniversale::query()->find($rubricaId);
$stabile = $caller instanceof RubricaUniversale $stabile = $caller instanceof RubricaUniversale
? $this->resolveCallerStabile($caller) ? $this->resolveCallerStabile($caller)
: null; : null;
@ -1213,7 +1213,7 @@ private function resolveCallerTicketContext(?RubricaUniversale $caller, ?int $fa
return [ return [
'stabile_id' => $stabile?->id, 'stabile_id' => $stabile?->id,
'stabile_label' => $stabile ? (string) ($stabile->denominazione ?: ('Stabile #' . $stabile->id)) : '', 'stabile_label' => $stabile ? (string) ($stabile->denominazione ?: ('Stabile #' . $stabile->id)): '',
'soggetto_id' => $soggetto?->id, 'soggetto_id' => $soggetto?->id,
'unita_id' => $unita?->id, 'unita_id' => $unita?->id,
'unita_label' => $unita ? $this->formatUnitaLabel($unita) : '', 'unita_label' => $unita ? $this->formatUnitaLabel($unita) : '',

View File

@ -35,6 +35,7 @@ public function redirect(): RedirectResponse
} }
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []); $google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
$clientId = (string) ($google['client_id'] ?? config('services.google.client_id')); $clientId = (string) ($google['client_id'] ?? config('services.google.client_id'));
$clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret')); $clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret'));
@ -48,16 +49,21 @@ public function redirect(): RedirectResponse
$state = bin2hex(random_bytes(20)); $state = bin2hex(random_bytes(20));
session()->put('google_oauth_state', $state); session()->put('google_oauth_state', $state);
$query = http_build_query([ $queryParams = [
'client_id' => $clientId, 'client_id' => $clientId,
'redirect_uri' => $redirectUri, 'redirect_uri' => $redirectUri,
'response_type' => 'code', 'response_type' => 'code',
'scope' => implode(' ', self::GOOGLE_SCOPES), 'scope' => implode(' ', self::GOOGLE_SCOPES),
'access_type' => 'offline', 'access_type' => 'offline',
'prompt' => 'consent',
'include_granted_scopes' => 'true', 'include_granted_scopes' => 'true',
'state' => $state, 'state' => $state,
]); ];
if (trim((string) Arr::get($oauth, 'refresh_token', '')) === '') {
$queryParams['prompt'] = 'consent';
}
$query = http_build_query($queryParams);
return redirect()->away('https://accounts.google.com/o/oauth2/v2/auth?' . $query); return redirect()->away('https://accounts.google.com/o/oauth2/v2/auth?' . $query);
} }
@ -71,6 +77,7 @@ public function callback(): RedirectResponse
} }
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []); $google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$existingOauth = Arr::get($google, 'oauth', []);
$clientId = (string) ($google['client_id'] ?? config('services.google.client_id')); $clientId = (string) ($google['client_id'] ?? config('services.google.client_id'));
$clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret')); $clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret'));
$redirectUri = (string) ($google['redirect_uri'] ?? config('services.google.redirect')); $redirectUri = (string) ($google['redirect_uri'] ?? config('services.google.redirect'));
@ -106,6 +113,9 @@ public function callback(): RedirectResponse
$payload = $tokenResponse->json(); $payload = $tokenResponse->json();
$accessToken = (string) Arr::get($payload, 'access_token', ''); $accessToken = (string) Arr::get($payload, 'access_token', '');
$refreshToken = (string) Arr::get($payload, 'refresh_token', ''); $refreshToken = (string) Arr::get($payload, 'refresh_token', '');
if ($refreshToken === '') {
$refreshToken = (string) Arr::get($existingOauth, 'refresh_token', '');
}
$expiresIn = (int) Arr::get($payload, 'expires_in', 3600); $expiresIn = (int) Arr::get($payload, 'expires_in', 3600);
$profileResponse = Http::withToken($accessToken) $profileResponse = Http::withToken($accessToken)
@ -128,6 +138,7 @@ public function callback(): RedirectResponse
'refresh_token' => $refreshToken, 'refresh_token' => $refreshToken,
'expires_in' => $expiresIn, 'expires_in' => $expiresIn,
'connected_at' => now()->toDateTimeString(), 'connected_at' => now()->toDateTimeString(),
'refreshed_at' => now()->toDateTimeString(),
], ],
]); ]);

View File

@ -77,4 +77,4 @@ public function closedByUser()
{ {
return $this->belongsTo(User::class, 'closed_by_user_id'); return $this->belongsTo(User::class, 'closed_by_user_id');
} }
} }

View File

@ -109,17 +109,17 @@ public function resolveByIncomingLine(?string $line): array
$targetRouting = $targetExtension !== '' $targetRouting = $targetExtension !== ''
? $this->resolveByExtension($targetExtension) ? $this->resolveByExtension($targetExtension)
: ($routeGroup !== '' ? $this->resolveByExtension($routeGroup) : [ : ($routeGroup !== '' ? $this->resolveByExtension($routeGroup) : [
'user_id' => null, 'user_id' => null,
'stabile_id' => null, 'stabile_id' => null,
'user' => null, 'user' => null,
]); ]);
if (($targetRouting['user'] ?? null) === null) { if (($targetRouting['user'] ?? null) === null) {
$mappedUser = $this->findUserByLineMapping($amministratore, $normalized); $mappedUser = $this->findUserByLineMapping($amministratore, $normalized);
if ($mappedUser instanceof User) { if ($mappedUser instanceof User) {
$targetRouting = [ $targetRouting = [
'user_id' => (int) $mappedUser->id, 'user_id' => (int) $mappedUser->id,
'stabile_id' => method_exists($mappedUser, 'stabiliAssegnati') ? ((int) ($mappedUser->stabiliAssegnati()->value('stabili.id') ?? 0) ?: null) : null, 'stabile_id' => method_exists($mappedUser, 'stabiliAssegnati') ? ((int) ($mappedUser->stabiliAssegnati()->value('stabili.id') ?? 0) ?: null): null,
'user' => $mappedUser, 'user' => $mappedUser,
]; ];
} }

View File

@ -83,8 +83,8 @@ private function shouldOptimizeImage(array $metadata, array $options = []): bool
private function hasEmbeddedImageMetadata(array $metadata): bool private function hasEmbeddedImageMetadata(array $metadata): bool
{ {
return filled($metadata['exif_datetime'] ?? null) return filled($metadata['exif_datetime'] ?? null)
|| filled($metadata['device'] ?? null) || filled($metadata['device'] ?? null)
|| isset($metadata['gps_decimal']) || isset($metadata['gps_decimal'])
|| ((int) ($metadata['orientation'] ?? 1)) !== 1; || ((int) ($metadata['orientation'] ?? 1)) !== 1;
} }

View File

@ -57,4 +57,4 @@ public function down(): void
Schema::dropIfExists('ticket_intervento_sessioni'); Schema::dropIfExists('ticket_intervento_sessioni');
} }
} }
}; };

View File

@ -3,6 +3,7 @@ # NetGescon — TODO Copilot Tracker
Data aggiornamento: 6 aprile 2026 Data aggiornamento: 6 aprile 2026
Questo file tiene traccia di: Questo file tiene traccia di:
- cosa è stato fatto (implementato e/o corretto) - cosa è stato fatto (implementato e/o corretto)
- cosa resta da fare - cosa resta da fare
- priorità e dipendenze - priorità e dipendenze
@ -12,22 +13,27 @@ # NetGescon — TODO Copilot Tracker
## 1) Fatto (stato attuale) ## 1) Fatto (stato attuale)
### UX / Filament (navigazione “web-like”) ### UX / Filament (navigazione “web-like”)
- Breadcrumbs compatibili Filament: wrapper view per `<x-filament.components.page-breadcrumbs>`. - Breadcrumbs compatibili Filament: wrapper view per `<x-filament.components.page-breadcrumbs>`.
- Pagine Filament con back/breadcrumb coerenti (dove già applicato). - Pagine Filament con back/breadcrumb coerenti (dove già applicato).
### Anagrafica ### Anagrafica
- `Persona`: gestione multi-ruolo via JSON `persone.ruoli` + UI di editing in Filament. - `Persona`: gestione multi-ruolo via JSON `persone.ruoli` + UI di editing in Filament.
### Contabilità — base ### Contabilità — base
- Tabelle contabilità e migrazioni sbloccate (incl. `contabilita_regole_prima_nota`, movimenti banca). - Tabelle contabilità e migrazioni sbloccate (incl. `contabilita_regole_prima_nota`, movimenti banca).
- Import movimenti banca UniCredit `.wri` con dedup (`row_hash`). - Import movimenti banca UniCredit `.wri` con dedup (`row_hash`).
- Azione Filament su movimenti banca per generare registrazione/movimenti contabili da regole prima nota (scaffold funzionante). - Azione Filament su movimenti banca per generare registrazione/movimenti contabili da regole prima nota (scaffold funzionante).
### Fatture ricevute XML (FatturaPA) ### Fatture ricevute XML (FatturaPA)
- Archivio + scheda Filament per fatture ricevute XML. - Archivio + scheda Filament per fatture ricevute XML.
- Import multiplo XML con dedup (hash + chiave logica) e persistenza su `fatture_elettroniche`. - Import multiplo XML con dedup (hash + chiave logica) e persistenza su `fatture_elettroniche`.
### Scheda Stabile (riabilitata) ### Scheda Stabile (riabilitata)
- Ripristinato accesso dalla Filament “Cruscotto stabile” alla scheda stabile completa (UI classica) con: - Ripristinato accesso dalla Filament “Cruscotto stabile” alla scheda stabile completa (UI classica) con:
- Dati stabile - Dati stabile
- Banche/dati finanziari - Banche/dati finanziari
@ -37,10 +43,12 @@ ### Scheda Stabile (riabilitata)
- Aggiunto deep-link `?tab=...` sulla scheda stabile UI classica per aprire direttamente una tab specifica. - Aggiunto deep-link `?tab=...` sulla scheda stabile UI classica per aprire direttamente una tab specifica.
### “Vista Excel” (riabilitata) ### “Vista Excel” (riabilitata)
- Ripristinato accesso immediato dalla Filament “Cruscotto stabile” alla tab “Tabelle millesimali” (UI classica) tramite deep-link. - Ripristinato accesso immediato dalla Filament “Cruscotto stabile” alla tab “Tabelle millesimali” (UI classica) tramite deep-link.
- Esiste anche la Filament “Tabelle millesimali · Prospetto” che è già una vista tabellare/Excel-like. - Esiste anche la Filament “Tabelle millesimali · Prospetto” che è già una vista tabellare/Excel-like.
### Nuovo lavoro già avviato (base dati + UI) ### Nuovo lavoro già avviato (base dati + UI)
- Nuove tabelle per “fatture fornitori” (testata + righe, importi `_euro`, ritenuta, tab pagamento). - Nuove tabelle per “fatture fornitori” (testata + righe, importi `_euro`, ritenuta, tab pagamento).
- Prime pagine Filament: - Prime pagine Filament:
- Archivio fatture fornitori - Archivio fatture fornitori
@ -51,17 +59,21 @@ ### Nuovo lavoro già avviato (base dati + UI)
## 2) In corso (priorità alta) ## 2) In corso (priorità alta)
### A) Riallineare le “Gestioni” per la contabilità ### A) Riallineare le “Gestioni” per la contabilità
Obiettivo: avere gestione ordinaria/riscaldamento/straordinaria *coerenti* e selezionabili per stabile, perché la contabilità deve agganciarsi a: Obiettivo: avere gestione ordinaria/riscaldamento/straordinaria *coerenti* e selezionabili per stabile, perché la contabilità deve agganciarsi a:
- anno gestione / esercizio - anno gestione / esercizio
- tipo gestione - tipo gestione
- conti/casse/banca - conti/casse/banca
- registrazioni/movimenti - registrazioni/movimenti
Output atteso: Output atteso:
- schermate Filament “Gestione ordinaria / riscaldamento / straordinaria” che leggono e usano la gestione attiva (non solo lo stabile attivo) - schermate Filament “Gestione ordinaria / riscaldamento / straordinaria” che leggono e usano la gestione attiva (non solo lo stabile attivo)
- link e contesti coerenti tra: Prima Nota, Casse e banche, Fatture, Movimenti banca - link e contesti coerenti tra: Prima Nota, Casse e banche, Fatture, Movimenti banca
Dipendenze: Dipendenze:
- definire/standardizzare “gestione attiva” (session/key, o tabella/relazione) - definire/standardizzare “gestione attiva” (session/key, o tabella/relazione)
--- ---
@ -69,6 +81,7 @@ ### A) Riallineare le “Gestioni” per la contabilità
## 3) Da fare (funzionalità richieste, in ordine consigliato) ## 3) Da fare (funzionalità richieste, in ordine consigliato)
### 3.0 Area fornitore/manutentore 2026 ### 3.0 Area fornitore/manutentore 2026
- Unificare la rubrica operativa usando la stessa maschera principale condivisa tra amministratore, fornitore e moduli collegati; differenziare accesso e persistenza per ruolo, non l impianto della view. - Unificare la rubrica operativa usando la stessa maschera principale condivisa tra amministratore, fornitore e moduli collegati; differenziare accesso e persistenza per ruolo, non l impianto della view.
- Aggiungere una pagina ad hoc per i ticket interni del fornitore con elenco unico di lavorazioni: ticket passati dall amministratore + lavorazioni interne + storico operativo. - Aggiungere una pagina ad hoc per i ticket interni del fornitore con elenco unico di lavorazioni: ticket passati dall amministratore + lavorazioni interne + storico operativo.
- Estendere `fornitore/collaboratori` con gestione orari di lavoro, disponibilita e futura assegnazione lavori ricorrenti. - Estendere `fornitore/collaboratori` con gestione orari di lavoro, disponibilita e futura assegnazione lavori ricorrenti.
@ -77,6 +90,7 @@ ### 3.0 Area fornitore/manutentore 2026
- Portare avanti catalogo prodotti unificato con vari fornitori, listini acquisto/vendita multipli, seriali, varianti modello/colore, foto, allegati e riferimenti esterni tipo ICECAT. - Portare avanti catalogo prodotti unificato con vari fornitori, listini acquisto/vendita multipli, seriali, varianti modello/colore, foto, allegati e riferimenti esterni tipo ICECAT.
### 3.1 Contabilità — fatture fornitori “alla Gescon” ### 3.1 Contabilità — fatture fornitori “alla Gescon”
- Rendere la maschera fattura fornitore più fedele allo screenshot (nomi/ordine tab + colonna azioni). - Rendere la maschera fattura fornitore più fedele allo screenshot (nomi/ordine tab + colonna azioni).
- Aggancio/creazione fornitore (contabilità + anagrafica unica/rubrica) direttamente dalla maschera. - Aggancio/creazione fornitore (contabilità + anagrafica unica/rubrica) direttamente dalla maschera.
- Contabilizzazione: - Contabilizzazione:
@ -85,6 +99,7 @@ ### 3.1 Contabilità — fatture fornitori “alla Gescon”
- RA (ritenuta) come debito separato da versare - RA (ritenuta) come debito separato da versare
### 3.2 Ritenute dacconto + F24 ### 3.2 Ritenute dacconto + F24
- Gestione codici tributo: 1019/1020 e 1040. - Gestione codici tributo: 1019/1020 e 1040.
- Regole: - Regole:
- RA si versa con F24 entro il 16 del mese successivo al pagamento. - RA si versa con F24 entro il 16 del mese successivo al pagamento.
@ -94,6 +109,7 @@ ### 3.2 Ritenute dacconto + F24
- (fase successiva) aggregazione per mese/tributo e stampa/distinta - (fase successiva) aggregazione per mese/tributo e stampa/distinta
### 3.3 Import MDB (GESCON) → contabilità operativa ### 3.3 Import MDB (GESCON) → contabilità operativa
- Predisporre import da `singolo_stabile.MDB` e `generale_stabile.mdb`: - Predisporre import da `singolo_stabile.MDB` e `generale_stabile.mdb`:
- operazioni/fatture/fornitori - operazioni/fatture/fornitori
- rispettare sempre i campi `*_euro` - rispettare sempre i campi `*_euro`
@ -103,6 +119,7 @@ ### 3.3 Import MDB (GESCON) → contabilità operativa
- collegamenti a tabelle millesimali/voci spesa - collegamenti a tabelle millesimali/voci spesa
### 3.4 XML multiplo → passaggio in contabilità ### 3.4 XML multiplo → passaggio in contabilità
- Oggi: import XML alimenta archivio `fatture_elettroniche`. - Oggi: import XML alimenta archivio `fatture_elettroniche`.
- Da fare: - Da fare:
- creazione/aggancio fornitore automatico (rubrica + fornitore) - creazione/aggancio fornitore automatico (rubrica + fornitore)
@ -113,31 +130,35 @@ ### 3.4 XML multiplo → passaggio in contabilità
## 4) Parti discusse ma non ancora implementate (da non perdere) ## 4) Parti discusse ma non ancora implementate (da non perdere)
### Ticket ### Ticket
- Esiste base legacy (controller/CRUD e categorie ticket). Da definire: - Esiste base legacy (controller/CRUD e categorie ticket). Da definire:
- UI Filament per ticket (lista, scheda, stati, priorità) - UI Filament per ticket (lista, scheda, stati, priorità)
- permessi/ruoli - permessi/ruoli
- collegamento ticket ↔ stabile ↔ documenti - collegamento ticket ↔ stabile ↔ documenti
### Gestione documentale ### Gestione documentale
- Esiste sistema “Documenti collegati” (con rotte e API statistiche/scadenze). Da definire: - Esiste sistema “Documenti collegati” (con rotte e API statistiche/scadenze). Da definire:
- UI Filament per documenti del condominio - UI Filament per documenti del condominio
- workflow scadenze/rinnovi - workflow scadenze/rinnovi
- permessi e visibilità per ruolo - permessi e visibilità per ruolo
### Protocollo documentazione ### Protocollo documentazione
- Da definire: - Da definire:
- numerazione protocolli per stabile/anno - numerazione protocolli per stabile/anno
- registrazione (data, mittente/destinatario, oggetto) - registrazione (data, mittente/destinatario, oggetto)
- collegamento a documenti/pec - collegamento a documenti/pec
### Consumi / acqua / contatori ### Consumi / acqua / contatori
- Backend (tabelle contatori/letture/algoritmi) esiste. - Backend (tabelle contatori/letture/algoritmi) esiste.
- UI Filament non ancora esposta: oggi la gestione è in view legacy. - UI Filament non ancora esposta: oggi la gestione è in view legacy.
--- ---
## 5) Note operative / rischi ## 5) Note operative / rischi
- Coesistenza Filament + UI classica: va mantenuta, ma i punti chiave devono essere raggiungibili dal “Cruscotto stabile”. - Coesistenza Filament + UI classica: va mantenuta, ma i punti chiave devono essere raggiungibili dal “Cruscotto stabile”.
- “Gestione attiva” è il nodo per rendere contabilità consistente (oltre a “stabile attivo”). - “Gestione attiva” è il nodo per rendere contabilità consistente (oltre a “stabile attivo”).
- Import MDB richiede mapping rigoroso (soprattutto `_euro`) per evitare contabilità sbilanciata. - Import MDB richiede mapping rigoroso (soprattutto `_euro`) per evitare contabilità sbilanciata.

View File

@ -6,4 +6,5 @@ ## Topics
- [Fatture Fornitori](topics/fatture-fornitori.md) - [Fatture Fornitori](topics/fatture-fornitori.md)
- [Filo Logico Contabilita](topics/filo-logico-contabilita.md) - [Filo Logico Contabilita](topics/filo-logico-contabilita.md)
- [Import Unita Cod Cond](topics/import-unita-cod-cond.md)
- [Affitti ISTAT e Contratti](topics/affitti-istat-contratti.md) - [Affitti ISTAT e Contratti](topics/affitti-istat-contratti.md)

View File

@ -1 +1 @@
Filo Logico Contabilita Import Unita Cod Cond

View File

@ -0,0 +1,57 @@
# Topic: Import Unita Cod Cond
## Obiettivo
- Rendere l'import legacy di unita, nominativi, millesimi e voci coerente con la realta archivistica Gescon, evitando fallback vuoti o identificazioni deboli.
## Regole vincolanti
- L'ultimo anno disponibile e la fotografia autorevole dello stato corrente.
- La chiave certa dell'unita e `condomin.cod_cond`.
- `scala`, `interno`, `catasto_sub` e il codice mnemonico servono per riconciliazione e visualizzazione, non come chiave inventata in fallback.
- Il codice visibile all'operatore deve restare leggibile in forma `Palazzina/Scala/Interno`.
- Gli anni precedenti servono a costruire la cronologia dei soggetti collegati all'unita, non a sostituire il nominativo corrente trovato nell'ultimo anno.
- Non creare righe di completamento artificiale a zero per millesimi o nominativi se la sorgente non le fornisce.
## Modello archivistico chiarito dall'utente
- `condomin.cod_cond` identifica l'unita legacy in modo stabile.
- `comproprietari` integra la storia proprietari e quote.
- `emes_gen` definisce il contesto di emissione e l'anno gestionale.
- `emes_det.cod_cond` collega la rata emessa all'unita.
- `incassi.cod_cond` collega l'incasso all'unita.
- `cond_inq` distingue il ruolo storico: condomino/proprietario (`C`) o inquilino (`I`).
## Stato attuale del codice
- `app/Console/Commands/ImportGesconFullPipeline.php`
- fixato il riempimento automatico di dettagli millesimali a zero: ora e opt-in con `--fill-missing-millesimi`
- fixato il fallback di aggancio tabella per `stepVoci()`
- fixato il preload staging per `legacy_year`
- `app/Console/Commands/NetgesconQaMillesimiSpeseCommand.php`
- aggiunto per QA su allineamento unita/nominativi/millesimi/voci
## Evidenze validate su 0021
- Lo staging dell'ultimo anno utile non veniva caricato correttamente finche il preflight controllava solo `cod_stabile`.
- Dopo il fix sul preload, `0021/0004` viene caricato e reso disponibile per confronto/import.
- Le unita ancora senza nominativo corrente non vanno risolte con fallback automatici: almeno i casi `A/0`, `A/42`, `A/43`, `A/215`, `A/216` non hanno corrispondenza trovata nel controllo staging eseguito.
## Prossimi step operativi
1. Rifattorizzare la costruzione snapshot corrente unita/nominativi a partire dall'ultimo anno disponibile.
2. Usare `cod_cond` come chiave di merge tra staging e dominio.
3. Inserire gli anni precedenti solo come variazioni storiche in `unita_immobiliare_nominativi`.
4. Verificare che rate, incassi e ruoli `C`/`I` restino riconciliabili per anno e unita.
5. Gestire le unita dominio senza sorgente corrente con una decisione esplicita di review/disattivazione, non con dati inventati.
## Comandi utili gia usati
- `php artisan netgescon:qa-millesimi-spese --stabile=0021 --anno=0003`
- `php artisan netgescon:qa-unita-nominativi --stabile_id=25`
- `php artisan gescon:import-align --stabile=0021 --path=/mnt/gescon-archives/gescon --years=2025,2026 --water-years=2025,2026 --open-years=2025,2026`
- `php artisan gescon:import-full --stabile=0021 --solo=unita --anno=0004 --dry-run`
## Vincolo di continuita
- Le vecchie note in `docs/ai` o nei chat export che propongono fallback tipo unita inventate, `interno=0` come riempitivo o codici sintetici non fondati sulla sorgente vanno considerate superate se confliggono con questo topic.

View File

@ -121,6 +121,16 @@ ## Rilascio operativo 2026-04-06
Questo e il blocco che puo essere aggiornato adesso con la procedura Git-first senza correzioni manuali extra su staging. Questo e il blocco che puo essere aggiornato adesso con la procedura Git-first senza correzioni manuali extra su staging.
### Sync fornitori legacy 2026-04-08
- La procedura fornitori va eseguita con un solo entry point: `php artisan gescon:sync-fornitori-legacy <amministratore_id> --mdb=/mnt/gescon-archives/gescon/dbc/Fornitori.mdb`
- La sync esegue in sequenza:
- import `Fornitori.mdb` in staging
- aggiornamento archivio locale `fornitori` + `rubrica_universale`
- aggiornamento TAG da descrizione legacy
- In `--dry-run` la staging resta in preview e i passaggi locali non persistono, cosi il legacy resta la sorgente da verificare prima del consolidamento.
- Regola operativa: per i fornitori il legacy aggiorna l archivio locale, non il contrario.
Incluso nel rilascio: Incluso nel rilascio:
- Ticket Mobile: anteprima immediata locale di foto e allegati prima dell invio definitivo. - Ticket Mobile: anteprima immediata locale di foto e allegati prima dell invio definitivo.
@ -136,10 +146,11 @@ ## Rilascio operativo 2026-04-06
3. usare `Aggiorna staging da Gitea` 3. usare `Aggiorna staging da Gitea`
4. aspettare la conclusione del sync Git e della QA sicura inclusa 4. aspettare la conclusione del sync Git e della QA sicura inclusa
5. validare subito i punti visibili: 5. validare subito i punti visibili:
- Ticket Mobile mostra l anteprima prima di inviare
- Ticket Gestione mostra `Tab 4 - Assicurazioni` - Ticket Mobile mostra l anteprima prima di inviare
- `/admin-filament/fornitore/tickets/{id}` non produce 403 per i casi operativi normali - Ticket Gestione mostra `Tab 4 - Assicurazioni`
- la lista ticket fornitore apre anche la modal rapida - `/admin-filament/fornitore/tickets/{id}` non produce 403 per i casi operativi normali
- la lista ticket fornitore apre anche la modal rapida
Regola per evitare nuovi problemi: Regola per evitare nuovi problemi:

View File

@ -162,4 +162,4 @@ ## 10. Prossimi slice consigliati
2. estendere checkpoint presenza con QR/BLE/NFC dove il device/browser lo consente davvero 2. estendere checkpoint presenza con QR/BLE/NFC dove il device/browser lo consente davvero
3. estensione collaboratori con disponibilita/orari 3. estensione collaboratori con disponibilita/orari
4. ticket interni/lavorazioni ricorrenti dentro `Lavorazioni operative` 4. ticket interni/lavorazioni ricorrenti dentro `Lavorazioni operative`
5. staging SQL TecnoRepair per rubrica, schede e seriali 5. staging SQL TecnoRepair per rubrica, schede e seriali

View File

@ -3,6 +3,37 @@
/** @var \App\Models\Stabile|null $stabileAttivo */ /** @var \App\Models\Stabile|null $stabileAttivo */
$activeId = $stabileAttivo?->id; $activeId = $stabileAttivo?->id;
$stabileHasRiscaldamento = static function (?\App\Models\Stabile $stabile): bool {
if (! $stabile) {
return false;
}
if ((bool) ($stabile->riscaldamento_centralizzato ?? false)) {
return true;
}
$rateRiscaldamento = $stabile->rate_riscaldamento_mesi ?? [];
if (is_string($rateRiscaldamento)) {
$rateRiscaldamento = json_decode($rateRiscaldamento, true);
}
if (is_array($rateRiscaldamento) && $rateRiscaldamento !== []) {
return true;
}
$config = $stabile->configurazione_avanzata ?? [];
if (is_string($config)) {
$config = json_decode($config, true);
}
if (is_array($config) && array_key_exists('mostra_riscaldamento', $config) && $config['mostra_riscaldamento']) {
return true;
}
return \Illuminate\Support\Facades\DB::table('gestioni_contabili')
->where('stabile_id', $stabile->id)
->where('tipo_gestione', 'riscaldamento')
->exists();
};
$stabileLabel = static function (?\App\Models\Stabile $stabile): string { $stabileLabel = static function (?\App\Models\Stabile $stabile): string {
if (! $stabile) { if (! $stabile) {
return ''; return '';
@ -28,7 +59,7 @@
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
Ordinari: {{ ($stabileAttivo->numero_rate_ordinarie ?? 0) > 0 ? 'Sì' : 'No' }} · Ordinari: {{ ($stabileAttivo->numero_rate_ordinarie ?? 0) > 0 ? 'Sì' : 'No' }} ·
Riscaldamento: {{ (bool) ($stabileAttivo->riscaldamento_centralizzato ?? false) ? 'Sì' : 'No' }} Riscaldamento: {{ $stabileHasRiscaldamento($stabileAttivo) ? 'Sì' : 'No' }}
</div> </div>
</div> </div>
@endif @endif

View File

@ -1,6 +1,8 @@
<x-filament-panels::page> <x-filament-panels::page>
@php($supportUpdateAuthUser = auth()->user()) @php
@php($supportCanRunUpdate = $supportUpdateAuthUser && method_exists($supportUpdateAuthUser, 'hasAnyRole') && $supportUpdateAuthUser->hasAnyRole(['super-admin', 'admin', 'amministratore'])) $supportUpdateAuthUser = auth()->user();
$supportCanRunUpdate = $supportUpdateAuthUser && method_exists($supportUpdateAuthUser, 'hasAnyRole') && $supportUpdateAuthUser->hasAnyRole(['super-admin', 'admin', 'amministratore']);
@endphp
<div class="space-y-4 text-xs"> <div class="space-y-4 text-xs">
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4"> <div class="rounded-xl border border-sky-200 bg-sky-50 p-4">

View File

@ -88,7 +88,9 @@
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
@php($selected = $this->selectedScheda) @php
$selected = $this->selectedScheda;
@endphp
@if(! $selected) @if(! $selected)
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800"> <div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Seleziona una riga dall'elenco per aprire la scheda tecnica legacy con cliente, seriali, allegati e note operative. Seleziona una riga dall'elenco per aprire la scheda tecnica legacy con cliente, seriali, allegati e note operative.
@ -135,7 +137,9 @@
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Fornitore e allegati</div> <div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Fornitore e allegati</div>
<div class="mt-1 text-sm text-slate-800"> <div class="mt-1 text-sm text-slate-800">
@if($selected->fornitore) @if($selected->fornitore)
@php($fornitoreUrl = $this->getFornitoreUrl((int) $selected->fornitore->id)) @php
$fornitoreUrl = $this->getFornitoreUrl((int) $selected->fornitore->id);
@endphp
@if($fornitoreUrl) @if($fornitoreUrl)
<a href="{{ $fornitoreUrl }}" class="font-medium text-sky-700 hover:text-sky-600">{{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }}</a> <a href="{{ $fornitoreUrl }}" class="font-medium text-sky-700 hover:text-sky-600">{{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }}</a>
@else @else

View File

@ -18,7 +18,9 @@
Ticket non selezionato o non accessibile. Apri questa pagina da Gestione Ticket. Ticket non selezionato o non accessibile. Apri questa pagina da Gestione Ticket.
</div> </div>
@else @else
@php($ticket = $this->ticket) @php
$ticket = $this->ticket;
@endphp
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<div class="text-lg font-semibold">#{{ $ticket->id }} - {{ $ticket->titolo }}</div> <div class="text-lg font-semibold">#{{ $ticket->id }} - {{ $ticket->titolo }}</div>
<div class="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600">