Allinea sviluppo Day0: Gescon, PBX hybrid e supporto operativo

This commit is contained in:
michele 2026-04-01 21:44:32 +00:00
parent cd5d004561
commit 89dabf9d72
63 changed files with 5765 additions and 1754 deletions

View File

@ -4,6 +4,8 @@ # Changelog
## [Unreleased]
- Initial open-source release prep.
- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea.
- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`.
## [0.1.0] - 2026-01-17
- Docker production compose and update workflow.

View File

@ -1,10 +1,9 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\Admin\CodeQualityController;
use Illuminate\Console\Command;
use Illuminate\Http\Request;
use App\Http\Controllers\Admin\CodeQualityController;
class CodeQualityScan extends Command
{
@ -13,6 +12,8 @@ class CodeQualityScan extends Command
{--severity=all : Severità (all|critical|error|warning|info)}
{--module=all : Modulo (es. gescon-import)}
{--menu=all : Menù (es. gescon-import)}
{--stabile= : Codice stabile/id per QA archivi e Ordinarie}
{--anno= : Anno da usare nei controlli QA data-aware}
{--no-data-checks : Disattiva i controlli integrità archivi (DB)}
{--format=csv : Formato export (csv|json)}
{--output= : File di output (se omesso stampa su stdout)}';
@ -21,29 +22,33 @@ class CodeQualityScan extends Command
public function handle()
{
$path = $this->option('path');
$severity = $this->option('severity');
$module = $this->option('module');
$menu = $this->option('menu');
$includeDataChecks = !$this->option('no-data-checks');
$format = strtolower($this->option('format'));
$output = $this->option('output');
$path = $this->option('path');
$severity = $this->option('severity');
$module = $this->option('module');
$menu = $this->option('menu');
$stabile = $this->option('stabile');
$anno = $this->option('anno');
$includeDataChecks = ! $this->option('no-data-checks');
$format = strtolower($this->option('format'));
$output = $this->option('output');
/** @var CodeQualityController $controller */
$controller = app(CodeQualityController::class);
// Usa l'endpoint export per generare CSV/JSON consistente con la UI
$request = Request::create('/admin/code-quality/export', 'POST', [
'path' => $path,
'severity' => $severity,
'module' => $module,
'menu' => $menu,
'path' => $path,
'severity' => $severity,
'module' => $module,
'menu' => $menu,
'stabile' => $stabile,
'anno' => $anno,
'include_data_checks' => $includeDataChecks ? 1 : 0,
'format' => $format,
'format' => $format,
]);
$response = $controller->export($request);
$content = $response->getContent();
$content = $response->getContent();
if ($output) {
file_put_contents($output, $content);

View File

@ -1,14 +1,13 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\User;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Models\User;
use App\Services\TenantArchivePathService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
class CreateAmministratore extends Command
{
@ -44,9 +43,9 @@ public function handle()
$this->info('🏗️ Creazione nuovo amministratore...');
// Raccolta dati
$nome = $this->argument('nome');
$cognome = $this->argument('cognome');
$email = $this->argument('email');
$nome = $this->argument('nome');
$cognome = $this->argument('cognome');
$email = $this->argument('email');
$password = $this->option('password') ?: $this->generatePassword();
// Verifica email univoca
@ -72,19 +71,19 @@ public function handle()
// 3. Crea record amministratore
$this->info("🏢 Creazione record amministratore...");
$amministratore = Amministratore::create([
'user_id' => $user->id,
'nome' => $nome,
'cognome' => $cognome,
'user_id' => $user->id,
'nome' => $nome,
'cognome' => $cognome,
'denominazione_studio' => $this->option('studio') ?: "Studio {$cognome}",
'partita_iva' => $this->option('piva') ?: null,
'partita_iva' => $this->option('piva') ?: null,
'codice_fiscale_studio' => $this->option('cf') ?: null,
'telefono_studio' => $this->option('telefono') ?: null,
'indirizzo_studio' => $this->option('indirizzo') ?: null,
'cap_studio' => $this->option('cap') ?: null,
'citta_studio' => $this->option('citta') ?: null,
'provincia_studio' => $this->option('provincia') ?: null,
'email_studio' => $email,
'pec_studio' => $this->option('pec') ?: null,
'telefono_studio' => $this->option('telefono') ?: null,
'indirizzo_studio' => $this->option('indirizzo') ?: null,
'cap_studio' => $this->option('cap') ?: null,
'citta_studio' => $this->option('citta') ?: null,
'provincia_studio' => $this->option('provincia') ?: null,
'email_studio' => $email,
'pec_studio' => $this->option('pec') ?: null,
]);
// 4. Crea struttura cartelle
@ -138,7 +137,7 @@ private function generatePassword(): string
*/
private function createAdminFolders(Amministratore $amministratore): void
{
$basePath = "amministratori/{$amministratore->codice_univoco}";
$basePath = app(TenantArchivePathService::class)->amministratoreRelativePath($amministratore);
$folders = [
'documenti/allegati',
@ -181,13 +180,13 @@ private function createExampleStabile(Amministratore $amministratore): void
$stabile = Stabile::create([
'amministratore_id' => $amministratore->id,
'denominazione' => $denominazione,
'indirizzo' => $this->ask('Indirizzo', 'Via Roma 1'),
'cap' => $this->ask('CAP', '00100'),
'citta' => $this->ask('Città', 'Roma'),
'provincia' => $this->ask('Provincia', 'RM'),
'codice_fiscale' => strtoupper(substr($denominazione, 0, 3)) . rand(100000, 999999),
'stato' => 'attivo',
'denominazione' => $denominazione,
'indirizzo' => $this->ask('Indirizzo', 'Via Roma 1'),
'cap' => $this->ask('CAP', '00100'),
'citta' => $this->ask('Città', 'Roma'),
'provincia' => $this->ask('Provincia', 'RM'),
'codice_fiscale' => strtoupper(substr($denominazione, 0, 3)) . rand(100000, 999999),
'stato' => 'attivo',
]);
$this->info("🏢 Stabile creato: {$stabile->denominazione}");

View File

@ -1,29 +1,31 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Amministratore;
use App\Services\DatabaseManagerService;
use App\Services\TenantArchivePathService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class CreateAmministratoreStructure extends Command
{
protected $signature = 'netgescon:create-admin-structure {codice : Codice amministratore} {--force : Forza ricreazione se esiste}';
protected $signature = 'netgescon:create-admin-structure {codice : Codice amministratore} {--force : Forza ricreazione se esiste}';
protected $description = 'Crea struttura completa per amministratore (database + cartelle)';
protected DatabaseManagerService $dbManager;
protected TenantArchivePathService $tenantArchivePath;
public function __construct(DatabaseManagerService $dbManager)
public function __construct(DatabaseManagerService $dbManager, TenantArchivePathService $tenantArchivePath)
{
parent::__construct();
$this->dbManager = $dbManager;
$this->dbManager = $dbManager;
$this->tenantArchivePath = $tenantArchivePath;
}
public function handle(): int
{
$codice = $this->argument('codice');
$force = $this->option('force');
$force = $this->option('force');
$this->info("🚀 Creazione struttura per amministratore: {$codice}");
@ -32,13 +34,13 @@ public function handle(): int
->orWhere('codice_univoco', $codice)
->first();
if (!$amministratore) {
if (! $amministratore) {
$this->error("❌ Amministratore {$codice} non trovato");
return 1;
}
// Verifica se struttura esiste già
if ($amministratore->hasDedicatedDatabase() && !$force) {
if ($amministratore->hasDedicatedDatabase() && ! $force) {
$this->warn("⚠️ Struttura già esistente. Usa --force per ricreare");
return 0;
}
@ -51,7 +53,7 @@ public function handle(): int
$this->info("📦 Creazione database dedicato...");
$dbResult = $this->dbManager->createAmministratoreDatabase($amministratore);
if (!$dbResult['success']) {
if (! $dbResult['success']) {
$this->error("❌ Errore creazione database: " . $dbResult['error']);
return 1;
}
@ -67,7 +69,7 @@ public function handle(): int
private function createAdvancedFolderStructure(Amministratore $amministratore): void
{
$basePath = "amministratori/{$amministratore->codice_amministratore}";
$basePath = $this->tenantArchivePath->amministratoreRelativePath($amministratore);
$folders = [
// Documenti studio
@ -124,47 +126,47 @@ private function createAdvancedFolderStructure(Amministratore $amministratore):
private function createConfigurations(Amministratore $amministratore): void
{
$basePath = "amministratori/{$amministratore->codice_amministratore}";
$basePath = $this->tenantArchivePath->amministratoreRelativePath($amministratore);
// 1. Configurazione principale
$mainConfig = [
'amministratore' => [
'codice_amministratore' => $amministratore->codice_amministratore,
'nome_completo' => $amministratore->nome_completo,
'denominazione_studio' => $amministratore->denominazione_studio,
'database_principale' => $amministratore->getDatabaseName(),
'created_at' => $amministratore->created_at->toISOString(),
'version' => '2.1.0',
'nome_completo' => $amministratore->nome_completo,
'denominazione_studio' => $amministratore->denominazione_studio,
'database_principale' => $amministratore->getDatabaseName(),
'created_at' => $amministratore->created_at->toISOString(),
'version' => '2.1.0',
],
'database' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $amministratore->getDatabaseName(),
'backup_path' => 'database/backups',
'database' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $amministratore->getDatabaseName(),
'backup_path' => 'database/backups',
'retention_days' => 90,
],
'backup' => [
'enabled' => true,
'frequency' => 'daily',
'time' => '02:00',
'retention_days' => 30,
'include_files' => true,
'backup' => [
'enabled' => true,
'frequency' => 'daily',
'time' => '02:00',
'retention_days' => 30,
'include_files' => true,
'include_database' => true,
'compress' => true,
'compress' => true,
],
'folders' => [
'folders' => [
'documenti' => 'documenti',
'database' => 'database',
'stabili' => 'stabili',
'reports' => 'reports',
'temp' => 'temp',
'archivio' => 'archivio',
'database' => 'database',
'stabili' => 'stabili',
'reports' => 'reports',
'temp' => 'temp',
'archivio' => 'archivio',
],
'security' => [
'log_level' => 'info',
'max_file_size' => '50MB',
'security' => [
'log_level' => 'info',
'max_file_size' => '50MB',
'allowed_extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'png'],
'encryption' => false,
'encryption' => false,
],
];

View File

@ -1,50 +1,54 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Services\DatabaseManagerService;
use App\Services\TenantArchivePathService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class CreateStabileStructure extends Command
{
protected $signature = 'netgescon:create-stabile-structure {admin_code : Codice amministratore} {stabile_code : Codice stabile} {--force : Forza ricreazione}';
protected $signature = 'netgescon:create-stabile-structure {admin_code : Codice amministratore} {stabile_code : Codice stabile} {--force : Forza ricreazione}';
protected $description = 'Crea struttura completa per stabile (database + cartelle)';
protected DatabaseManagerService $dbManager;
protected TenantArchivePathService $tenantArchivePath;
public function __construct(DatabaseManagerService $dbManager)
public function __construct(DatabaseManagerService $dbManager, TenantArchivePathService $tenantArchivePath)
{
parent::__construct();
$this->dbManager = $dbManager;
$this->dbManager = $dbManager;
$this->tenantArchivePath = $tenantArchivePath;
}
public function handle(): int
{
$adminCode = $this->argument('admin_code');
$adminCode = $this->argument('admin_code');
$stabileCode = $this->argument('stabile_code');
$force = $this->option('force');
$force = $this->option('force');
$this->info("🏗️ Creazione struttura stabile: {$stabileCode} (Admin: {$adminCode})");
// Trova amministratore
$amministratore = Amministratore::where('codice_univoco', $adminCode)->first();
if (!$amministratore) {
$amministratore = Amministratore::where('codice_amministratore', $adminCode)
->orWhere('codice_univoco', $adminCode)
->first();
if (! $amministratore) {
$this->error("❌ Amministratore {$adminCode} non trovato");
return 1;
}
// Trova stabile
$stabile = $amministratore->stabili()->where('codice_stabile', $stabileCode)->first();
if (!$stabile) {
if (! $stabile) {
$this->error("❌ Stabile {$stabileCode} non trovato per amministratore {$adminCode}");
return 1;
}
// Verifica se struttura esiste già
if ($stabile->database_dedicato && !$force) {
if ($stabile->database_dedicato && ! $force) {
$this->warn("⚠️ Struttura stabile già esistente. Usa --force per ricreare");
return 0;
}
@ -53,7 +57,7 @@ public function handle(): int
$this->info("💾 Creazione database dedicato stabile...");
$dbResult = $this->dbManager->createStabileDatabase($stabile);
if (!$dbResult['success']) {
if (! $dbResult['success']) {
$this->error("❌ Errore creazione database: " . $dbResult['error']);
return 1;
}
@ -79,9 +83,7 @@ public function handle(): int
private function createDetailedStabileStructure(Stabile $stabile): void
{
$adminCode = $stabile->amministratore->codice_univoco;
$stabileCode = $stabile->codice_stabile;
$basePath = "amministratori/{$adminCode}/stabili/{$stabileCode}";
$basePath = $this->tenantArchivePath->stabileRelativePath($stabile);
$folders = [
// Documenti amministrativi
@ -180,9 +182,9 @@ private function createDetailedStabileStructure(Stabile $stabile): void
// File informativi per cartelle principali
$infoFiles = [
'documenti/README.md' => $this->generateDocumentiReadme($stabile),
'contabilita/README.md' => $this->generateContabilitaReadme($stabile),
'anagrafica/README.md' => $this->generateAnagraficaReadme($stabile),
'documenti/README.md' => $this->generateDocumentiReadme($stabile),
'contabilita/README.md' => $this->generateContabilitaReadme($stabile),
'anagrafica/README.md' => $this->generateAnagraficaReadme($stabile),
'manutenzioni/README.md' => $this->generateManutenzioniReadme($stabile),
];
@ -193,71 +195,69 @@ private function createDetailedStabileStructure(Stabile $stabile): void
private function createStabileConfigurations(Stabile $stabile): void
{
$adminCode = $stabile->amministratore->codice_univoco;
$stabileCode = $stabile->codice_stabile;
$basePath = "amministratori/{$adminCode}/stabili/{$stabileCode}";
$basePath = $this->tenantArchivePath->stabileRelativePath($stabile);
// 1. Configurazione principale stabile
$config = [
'stabile' => [
'codice' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'stabile' => [
'codice' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'indirizzo_completo' => $stabile->indirizzo . ', ' . $stabile->cap . ' ' . $stabile->citta . ' (' . $stabile->provincia . ')',
'amministratore' => [
'codice' => $stabile->amministratore->codice_univoco,
'amministratore' => [
'codice' => $stabile->amministratore->codice_univoco,
'nome_completo' => $stabile->amministratore->nome_completo,
'studio' => $stabile->amministratore->denominazione_studio,
'studio' => $stabile->amministratore->denominazione_studio,
],
'database_dedicato' => $stabile->database_dedicato,
'created_at' => $stabile->created_at->toISOString(),
'version' => '2.1.0',
'database_dedicato' => $stabile->database_dedicato,
'created_at' => $stabile->created_at->toISOString(),
'version' => '2.1.0',
],
'database' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $stabile->database_dedicato,
'backup_path' => 'database/backups',
'database' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $stabile->database_dedicato,
'backup_path' => 'database/backups',
'retention_days' => 90,
],
'backup' => [
'enabled' => true,
'frequency' => 'daily',
'time' => '03:00',
'retention_days' => 90,
'include_files' => true,
'backup' => [
'enabled' => true,
'frequency' => 'daily',
'time' => '03:00',
'retention_days' => 90,
'include_files' => true,
'include_database' => true,
'compress' => true,
'compress' => true,
],
'folders' => [
'documenti' => 'documenti',
'contabilita' => 'contabilita',
'anagrafica' => 'anagrafica',
'manutenzioni' => 'manutenzioni',
'database' => 'database',
'reports' => 'reports',
'folders' => [
'documenti' => 'documenti',
'contabilita' => 'contabilita',
'anagrafica' => 'anagrafica',
'manutenzioni' => 'manutenzioni',
'database' => 'database',
'reports' => 'reports',
'corrispondenza' => 'corrispondenza',
'archivio' => 'archivio',
'temp' => 'temp',
'logs' => 'logs',
'archivio' => 'archivio',
'temp' => 'temp',
'logs' => 'logs',
],
'contabilita' => [
'anno_contabile' => date('Y'),
'moneta' => 'EUR',
'arrotondamento' => 2,
'iva_default' => 22,
'anno_contabile' => date('Y'),
'moneta' => 'EUR',
'arrotondamento' => 2,
'iva_default' => 22,
'millesimi_totali' => 1000,
],
'anagrafica' => [
'validazione_cf' => true,
'anagrafica' => [
'validazione_cf' => true,
'richiedi_documenti' => true,
'privacy_gdpr' => true,
'privacy_gdpr' => true,
],
'reports' => [
'automatici' => true,
'reports' => [
'automatici' => true,
'frequenza_mensili' => true,
'email_notifiche' => true,
'formato_default' => 'pdf',
]
'email_notifiche' => true,
'formato_default' => 'pdf',
],
];
Storage::disk('local')->put(
@ -287,14 +287,14 @@ private function populateStabileDatabase(Stabile $stabile): void
$connection->table('stabili_ref')->updateOrInsert(
['codice_stabile' => $stabile->codice_stabile],
[
'denominazione' => $stabile->denominazione,
'indirizzo' => $stabile->indirizzo,
'cap' => $stabile->cap,
'citta' => $stabile->citta,
'provincia' => $stabile->provincia,
'denominazione' => $stabile->denominazione,
'indirizzo' => $stabile->indirizzo,
'cap' => $stabile->cap,
'citta' => $stabile->citta,
'provincia' => $stabile->provincia,
'amministratore_codice' => $stabile->amministratore->codice_univoco,
'updated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
'created_at' => now(),
]
);
@ -591,7 +591,7 @@ private function generateManutenzioniReadme(Stabile $stabile): string
private function generateStabileMaintenanceScript(Stabile $stabile): string
{
$adminCode = $stabile->amministratore->codice_univoco;
$adminCode = $stabile->amministratore->codice_univoco;
$stabileCode = $stabile->codice_stabile;
return "#!/bin/bash

View File

@ -970,6 +970,28 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
if ($found) {
return $found;
}
$orphan = RubricaUniversale::query()
->whereNull('amministratore_id')
->where(function ($q) use ($cf, $piva) {
if ($cf) {
$q->orWhere('codice_fiscale', $cf);
if (preg_match('/^0+\d+$/', $cf)) {
$q->orWhere('codice_fiscale', ltrim($cf, '0'));
}
}
if ($piva) {
$q->orWhere('partita_iva', $piva);
if (preg_match('/^0+\d+$/', $piva)) {
$q->orWhere('partita_iva', ltrim($piva, '0'));
}
}
})
->orderBy('id')
->first();
if ($orphan) {
return $orphan;
}
}
$email = $this->normalizeEmail($data['email'] ?? null);
@ -981,6 +1003,15 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
if ($found) {
return $found;
}
$orphan = RubricaUniversale::query()
->whereNull('amministratore_id')
->where('email', $email)
->orderBy('id')
->first();
if ($orphan) {
return $orphan;
}
}
$telefono = $this->normalizePhone($data['telefono_ufficio'] ?? null);
@ -1000,6 +1031,21 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
if ($found) {
return $found;
}
$orphan = RubricaUniversale::query()
->whereNull('amministratore_id')
->where(function ($q) use ($phones) {
foreach ($phones as $p) {
$q->orWhere('telefono_ufficio', $p)
->orWhere('telefono_cellulare', $p)
->orWhere('telefono_casa', $p);
}
})
->orderBy('id')
->first();
if ($orphan) {
return $orphan;
}
}
return null;

View File

@ -0,0 +1,239 @@
<?php
namespace App\Console\Commands;
use App\Models\RubricaUniversale;
use App\Models\Titolo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
class GesconBackfillRubricaLegacyIdentityCommand extends Command
{
protected $signature = 'gescon:backfill-rubrica-legacy-identity
{--apply : Applica realmente le modifiche}
{--limit=5000 : Numero massimo di record da analizzare}
{--id=* : Limita l\'analisi a specifici ID rubrica}';
protected $description = 'Sposta i dati anagrafici legacy dalle note ai campi strutturati della rubrica_universale.';
public function handle(): int
{
if (! Schema::hasTable('rubrica_universale')) {
$this->error('Tabella rubrica_universale non disponibile.');
return self::FAILURE;
}
$apply = (bool) $this->option('apply');
$limit = max(1, min((int) $this->option('limit'), 50000));
$ids = array_values(array_filter(array_map(fn($value) => is_numeric($value) ? (int) $value : 0, (array) $this->option('id'))));
$query = RubricaUniversale::query()
->where('note', 'like', '%Dati anagrafici legacy:%')
->orderBy('id')
->limit($limit);
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$rows = $query->get();
if ($rows->isEmpty()) {
$this->info('Nessun record da bonificare.');
return self::SUCCESS;
}
$processed = 0;
$updated = 0;
foreach ($rows as $rubrica) {
$processed++;
$legacy = $this->extractLegacyIdentityFromNote($rubrica->note);
$changes = [];
if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) {
$changes['titolo_id'] = (int) $legacy['titolo_id'];
}
if (! $rubrica->sesso && ! empty($legacy['sesso'])) {
$changes['sesso'] = $legacy['sesso'];
}
if ((! $rubrica->data_nascita || (int) optional($rubrica->data_nascita)->format('Y') > (int) now()->format('Y') + 1) && ! empty($legacy['data_nascita'])) {
$changes['data_nascita'] = $legacy['data_nascita'];
}
if (! $rubrica->luogo_nascita && ! empty($legacy['luogo_nascita'])) {
$changes['luogo_nascita'] = $legacy['luogo_nascita'];
}
if (! $rubrica->provincia_nascita && ! empty($legacy['provincia_nascita'])) {
$changes['provincia_nascita'] = $legacy['provincia_nascita'];
}
$cleanNote = $legacy['note_clean'] ?? null;
if (($rubrica->note ?? null) !== $cleanNote) {
$changes['note'] = $cleanNote;
}
if ($changes === []) {
continue;
}
$updated++;
if ($apply) {
$rubrica->fill($changes);
$rubrica->save();
}
}
$this->info(($apply ? 'Bonifica eseguita' : 'Dry-run bonifica') . ": processati {$processed}, aggiornabili {$updated}");
return self::SUCCESS;
}
/** @return array<string, mixed> */
private function extractLegacyIdentityFromNote(mixed $note): array
{
$raw = trim((string) ($note ?? ''));
if ($raw === '') {
return [
'note_clean' => null,
'titolo_id' => null,
'sesso' => null,
'data_nascita' => null,
'luogo_nascita' => null,
'provincia_nascita' => null,
];
}
$parsed = [
'titolo_id' => null,
'sesso' => null,
'data_nascita' => null,
'luogo_nascita' => null,
'provincia_nascita' => null,
];
$cleanLines = [];
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
$trimmed = trim($line);
if (! str_starts_with($trimmed, 'Dati anagrafici legacy:')) {
if ($trimmed !== '') {
$cleanLines[] = $line;
}
continue;
}
$payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:')));
$bits = preg_split('/\s*\|\s*/', $payload) ?: [];
foreach ($bits as $bit) {
[$key, $value] = array_pad(explode(':', $bit, 2), 2, null);
$key = mb_strtolower(trim((string) $key));
$value = trim((string) $value);
if ($value === '') {
continue;
}
if ($key === 'titolo') {
$parsed['titolo_id'] = $this->resolveTitoloId($value);
continue;
}
if ($key === 'sesso') {
$parsed['sesso'] = $this->normalizeSessoValue($value);
continue;
}
if ($key === 'data nascita') {
$parsed['data_nascita'] = $this->normalizeLegacyDateValue($value);
continue;
}
if ($key === 'luogo nascita') {
$parsed['luogo_nascita'] = $value;
continue;
}
if ($key === 'provincia nascita') {
$parsed['provincia_nascita'] = $this->normalizeProvince($value);
}
}
}
return $parsed + [
'note_clean' => $cleanLines === [] ? null : implode("\n", $cleanLines),
];
}
private function normalizeLegacyDateValue(mixed $value): ?string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
foreach (['Y-m-d', 'd/m/Y', 'd-m-Y', 'm/d/Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'm/d/y', 'd.m.y'] as $format) {
$dt = \DateTime::createFromFormat($format, $raw);
if ($dt instanceof \DateTime) {
return $this->normalizeBirthDateYear($dt);
}
}
$timestamp = strtotime($raw);
if ($timestamp === false) {
return null;
}
return $this->normalizeBirthDateYear((new \DateTime())->setTimestamp($timestamp));
}
private function normalizeBirthDateYear(\DateTime $date): ?string
{
$year = (int) $date->format('Y');
$currentYear = (int) now()->format('Y');
if ($year > $currentYear + 1) {
$date->modify('-100 years');
$year = (int) $date->format('Y');
}
if ($year < 1900 || $year > $currentYear + 1) {
return null;
}
return $date->format('Y-m-d');
}
private function normalizeSessoValue(mixed $value): ?string
{
$raw = strtoupper(trim((string) ($value ?? '')));
return match (true) {
$raw === 'M', str_starts_with($raw, 'MAS') => 'M',
$raw === 'F', str_starts_with($raw, 'FEM') => 'F',
default => null,
};
}
private function normalizeProvince(mixed $value): ?string
{
$raw = strtoupper(trim((string) ($value ?? '')));
if ($raw === '') {
return null;
}
return mb_substr($raw, 0, 2);
}
private function resolveTitoloId(string $value): ?int
{
if (! Schema::hasTable('titoli')) {
return null;
}
$key = mb_strtolower(trim($value));
if ($key === '') {
return null;
}
$titolo = Titolo::query()
->whereRaw('LOWER(sigla) = ?', [$key])
->orWhereRaw('LOWER(nome) = ?', [$key])
->first();
return $titolo?->id ? (int) $titolo->id : null;
}
}

View File

@ -147,10 +147,6 @@ public function handle(): int
$legacyLuoNas ? ('Luogo nascita: ' . $legacyLuoNas) : null,
$legacyPrNas ? ('Provincia nascita: ' . $legacyPrNas) : null,
]));
if ($legacyIdentityBits !== []) {
$block = 'Dati anagrafici legacy: ' . implode(' | ', $legacyIdentityBits);
$note = $note ? ($note . "\n" . $block) : $block;
}
$piva = $this->clean($row['p_iva'] ?? null);
$cf = $this->clean($row['cod_fisc'] ?? null);
@ -167,6 +163,10 @@ public function handle(): int
'cognome' => $cognome,
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'sesso' => $legacySesso,
'data_nascita' => $legacyDtNas,
'luogo_nascita' => $legacyLuoNas,
'provincia_nascita' => $legacyPrNas,
'indirizzo' => $this->clean($row['indirizzo'] ?? null),
'cap' => $this->clean($row['cap'] ?? null),
'citta' => $this->clean($row['citta'] ?? null),
@ -628,21 +628,38 @@ private function normalizeLegacyDate(mixed $value): ?string
}
$raw = trim($raw);
foreach (['d/m/Y', 'd-m-Y', 'Y-m-d', 'm/d/Y', 'd.m.Y'] as $format) {
foreach (['d/m/Y', 'd-m-Y', 'Y-m-d', 'm/d/Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'm/d/y', 'd.m.y'] as $format) {
$dt = \DateTime::createFromFormat($format, $raw);
if ($dt instanceof \DateTime) {
return $dt->format('Y-m-d');
return $this->normalizeBirthDateYear($dt);
}
}
$ts = strtotime($raw);
if ($ts !== false) {
return date('Y-m-d', $ts);
return $this->normalizeBirthDateYear((new \DateTime())->setTimestamp($ts));
}
return null;
}
private function normalizeBirthDateYear(\DateTime $date): ?string
{
$year = (int) $date->format('Y');
$currentYear = (int) now()->format('Y');
if ($year > $currentYear + 1) {
$date->modify('-100 years');
$year = (int) $date->format('Y');
}
if ($year < 1900 || $year > $currentYear + 1) {
return null;
}
return $date->format('Y-m-d');
}
private function normalizeSesso(mixed $value): ?string
{
$raw = strtoupper((string) $this->clean($value));

View File

@ -0,0 +1,659 @@
<?php
namespace App\Console\Commands;
use App\Models\Persona;
use App\Models\RubricaUniversale;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class GesconMaterializePersoneRelazioniCommand extends Command
{
protected $signature = 'gescon:materialize-persone-relazioni
{--stabile= : Codice stabile dominio/legacy da filtrare}
{--limit=10000 : Max righe nominativi da processare}
{--apply : Esegue realmente le scritture. Senza flag e dry-run}';
protected $description = 'Materializza persone e persone_unita_relazioni a partire da unita_immobiliare_nominativi.';
public function handle(): int
{
$apply = (bool) $this->option('apply');
$stabile = trim((string) ($this->option('stabile') ?? ''));
$limit = max(1, min(50000, (int) ($this->option('limit') ?? 10000)));
foreach (['unita_immobiliare_nominativi', 'unita_immobiliari', 'stabili', 'persone', 'persone_unita_relazioni'] as $table) {
if (! Schema::hasTable($table)) {
$this->error("Tabella {$table} non disponibile.");
return 1;
}
}
$query = DB::table('unita_immobiliare_nominativi as uin')
->join('unita_immobiliari as ui', 'ui.id', '=', 'uin.unita_immobiliare_id')
->join('stabili as s', 's.id', '=', 'ui.stabile_id')
->select([
'uin.id',
'uin.unita_immobiliare_id',
'uin.legacy_cond_id',
'uin.ruolo',
'uin.nominativo',
'uin.data_inizio',
'uin.data_fine',
'uin.percentuale',
'uin.fonte',
'uin.legacy_payload',
'ui.stabile_id',
'ui.codice_unita',
'ui.scala',
'ui.interno',
'ui.piano',
's.codice_stabile',
's.amministratore_id',
])
->orderBy('s.codice_stabile')
->orderBy('ui.id')
->orderBy('uin.id');
if ($stabile !== '') {
$trimmed = ltrim($stabile, '0');
$query->where(function ($builder) use ($stabile, $trimmed): void {
$builder->where('s.codice_stabile', $stabile);
if ($trimmed !== '' && $trimmed !== $stabile) {
$builder->orWhere('s.codice_stabile', $trimmed);
}
});
}
$rows = $query->limit($limit)->get();
if ($rows->isEmpty()) {
$this->info('Nessun nominativo unita da materializzare.');
return 0;
}
$stats = [
'processed' => 0,
'skipped' => 0,
'persona_created' => 0,
'persona_updated' => 0,
'rel_created' => 0,
'rel_updated' => 0,
];
foreach ($rows as $row) {
$stats['processed']++;
$nominativo = trim((string) ($row->nominativo ?? ''));
if ($nominativo === '') {
$stats['skipped']++;
continue;
}
$tipoRelazione = $this->resolveTipoRelazione($row);
$ruoloRate = $this->deriveRuoloRate($tipoRelazione);
$rubrica = $this->findMatchingRubrica($row, $tipoRelazione);
$payload = $this->buildPersonaPayload($row, $rubrica);
if (! $this->hasPersonaIdentity($payload)) {
$stats['skipped']++;
continue;
}
$persona = $this->resolvePersonaMatch($payload);
if ($apply) {
if ($persona) {
$updated = $this->fillPersonaMissingFields($persona, $payload);
if ($updated) {
$stats['persona_updated']++;
}
} else {
$persona = $this->createPersonaFromPayload($payload);
if (! $persona) {
$stats['skipped']++;
continue;
}
$stats['persona_created']++;
}
$relationPayload = [
'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null),
'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01',
'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null),
'attivo' => $this->isRelationActive($row->data_fine ?? null),
'riceve_comunicazioni' => true,
'riceve_convocazioni' => $ruoloRate === 'C',
'vota_assemblea' => $ruoloRate === 'C',
'note_relazione' => $this->buildRelationNote($row, $tipoRelazione),
];
if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) {
$relationPayload['ruolo_rate'] = $ruoloRate;
}
$existingRelationId = $this->findExistingRelationId(
(int) $persona->id,
(int) $row->unita_immobiliare_id,
$tipoRelazione,
$relationPayload['data_inizio'],
$relationPayload['data_fine'],
);
if ($existingRelationId) {
DB::table('persone_unita_relazioni')
->where('id', $existingRelationId)
->update($relationPayload + ['updated_at' => now()]);
$stats['rel_updated']++;
} else {
DB::table('persone_unita_relazioni')->insert([
'persona_id' => (int) $persona->id,
'unita_id' => (int) $row->unita_immobiliare_id,
'tipo_relazione' => $tipoRelazione,
'quota_relazione' => $relationPayload['quota_relazione'],
'data_inizio' => $relationPayload['data_inizio'],
'data_fine' => $relationPayload['data_fine'],
'attivo' => $relationPayload['attivo'],
'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'],
'riceve_convocazioni' => $relationPayload['riceve_convocazioni'],
'vota_assemblea' => $relationPayload['vota_assemblea'],
'note_relazione' => $relationPayload['note_relazione'],
'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null,
'created_at' => now(),
'updated_at' => now(),
]);
$stats['rel_created']++;
}
}
}
$mode = $apply ? 'Materializzazione eseguita' : 'Dry-run materializzazione';
$this->info($mode
. " - processati: {$stats['processed']}"
. ", persone create: {$stats['persona_created']}"
. ", persone aggiornate: {$stats['persona_updated']}"
. ", relazioni create: {$stats['rel_created']}"
. ", relazioni aggiornate: {$stats['rel_updated']}"
. ", saltati: {$stats['skipped']}");
return 0;
}
private function findMatchingRubrica(object $row, string $tipoRelazione): ?RubricaUniversale
{
if (! Schema::hasTable('rubrica_universale')) {
return null;
}
$role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino';
$codCond = trim((string) ($row->legacy_cond_id ?? ''));
$codStabile = trim((string) ($row->codice_stabile ?? ''));
$adminId = (int) ($row->amministratore_id ?? 0);
if ($codCond !== '' && $codStabile !== '') {
$stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')])));
foreach ($stabileVariants as $stabileVariant) {
$rubrica = RubricaUniversale::query()
->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId))
->where('categoria', 'condomino')
->whereNotNull('note')
->where('note', 'like', '%cod_stabile=' . $stabileVariant . '%')
->where('note', 'like', '%cod_cond=' . $codCond . '%')
->where('note', 'like', '%ruolo=' . $role . '%')
->orderBy('id')
->first();
if ($rubrica) {
return $rubrica;
}
}
}
$identityKey = $this->normalizeIdentityKey($row->nominativo ?? null);
if (! $identityKey) {
return null;
}
return RubricaUniversale::query()
->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId))
->where(function ($query) use ($identityKey): void {
$query->whereRaw("UPPER(REGEXP_REPLACE(COALESCE(ragione_sociale, ''), '[^A-Z0-9]', '')) = ?", [$identityKey])
->orWhereRaw("UPPER(REGEXP_REPLACE(CONCAT(COALESCE(cognome, ''), COALESCE(nome, '')), '[^A-Z0-9]', '')) = ?", [$identityKey]);
})
->orderBy('id')
->first();
}
private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica): array
{
if ($rubrica) {
$ragione = $this->sanitizeString($rubrica->ragione_sociale, 255);
$nome = $this->sanitizeString($rubrica->nome, 100);
$cognome = $this->sanitizeString($rubrica->cognome, 100);
} else {
[$cognome, $nome, $ragione] = $this->splitNomeCognomeOrRagione((string) ($row->nominativo ?? ''));
}
if ($ragione && ! $cognome) {
$cognome = $ragione;
}
if (! $nome) {
$nome = $ragione ? 'Impresa' : ($cognome ?: 'N/D');
}
if (! $cognome) {
$cognome = $nome ?: 'N/D';
}
$rawFiscal = $rubrica?->codice_fiscale ?: $rubrica?->partita_iva;
$fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null);
return [
'tipologia' => $ragione ? 'giuridica' : 'fisica',
'nome' => $nome,
'cognome' => $cognome,
'ragione_sociale' => $ragione,
'codice_fiscale' => $fiscal['cf'],
'partita_iva' => $fiscal['piva'],
'email_principale' => $this->sanitizeEmail($rubrica?->email),
'email_pec' => $this->sanitizeEmail($rubrica?->pec),
'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio),
'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio),
'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255),
'note' => $this->buildPersonaNote($row, $rubrica),
'attivo' => true,
'name_key' => $this->normalizeNameKey($nome, $cognome),
];
}
private function resolvePersonaMatch(array $payload): ?object
{
$cf = $payload['codice_fiscale'] ?? null;
if ($cf) {
$found = DB::table('persone')->where('codice_fiscale', $cf)->orderBy('id')->first();
if ($found) {
return $found;
}
}
$piva = $payload['partita_iva'] ?? null;
if ($piva) {
$found = DB::table('persone')
->where(function ($query) use ($piva): void {
$query->where('partita_iva', $piva)
->orWhere('partita_iva_societa', $piva);
})
->orderBy('id')
->first();
if ($found) {
return $found;
}
}
$telefono = $payload['telefono_principale'] ?? null;
if ($telefono) {
$found = DB::table('persone')->where('telefono_principale', $telefono)->orderBy('id')->first();
if ($found) {
return $found;
}
}
$email = $payload['email_principale'] ?? null;
if ($email) {
$found = DB::table('persone')->where('email_principale', $email)->orderBy('id')->first();
if ($found) {
return $found;
}
}
$nameKey = $payload['name_key'] ?? null;
if ($nameKey) {
return DB::table('persone')
->whereRaw("UPPER(CONCAT(TRIM(cognome), ' ', TRIM(nome))) = ?", [$nameKey])
->orderBy('id')
->first();
}
return null;
}
private function createPersonaFromPayload(array $payload): ?Persona
{
$data = [
'tipologia' => $payload['tipologia'] ?? 'fisica',
'cognome' => $payload['cognome'],
'nome' => $payload['nome'],
'ragione_sociale' => $payload['ragione_sociale'],
'codice_fiscale' => $payload['codice_fiscale'],
'partita_iva' => $payload['partita_iva'],
'residenza_via' => $payload['residenza_via'],
'telefono_principale' => $payload['telefono_principale'],
'telefono_secondario' => $payload['telefono_secondario'],
'email_principale' => $payload['email_principale'],
'email_pec' => $payload['email_pec'],
'note' => $payload['note'],
'attivo' => $payload['attivo'] ?? true,
];
if ($data['telefono_principale'] && DB::table('persone')->where('telefono_principale', $data['telefono_principale'])->exists()) {
$data['telefono_principale'] = null;
}
try {
return Persona::query()->create($data);
} catch (\Throwable $e) {
$this->warn('Creazione persona fallita per nominativo ' . ($payload['ragione_sociale'] ?: trim($payload['cognome'] . ' ' . $payload['nome'])) . ': ' . $e->getMessage());
return null;
}
}
private function fillPersonaMissingFields(object $persona, array $payload): bool
{
$updates = [];
foreach ([
'tipologia',
'cognome',
'nome',
'ragione_sociale',
'codice_fiscale',
'partita_iva',
'residenza_via',
'telefono_principale',
'telefono_secondario',
'email_principale',
'email_pec',
] as $field) {
$value = $payload[$field] ?? null;
if (! $value) {
continue;
}
$current = $persona->{$field} ?? null;
if (empty($current)) {
if ($field === 'telefono_principale' && DB::table('persone')->where('telefono_principale', $value)->where('id', '!=', $persona->id)->exists()) {
continue;
}
$updates[$field] = $value;
}
}
if (! empty($updates)) {
$updates['updated_at'] = now();
DB::table('persone')->where('id', $persona->id)->update($updates);
return true;
}
return false;
}
private function findExistingRelationId(int $personaId, int $unitaId, string $tipoRelazione, string $dataInizio, ?string $dataFine): ?int
{
$query = DB::table('persone_unita_relazioni')
->where('persona_id', $personaId)
->where('unita_id', $unitaId)
->where('tipo_relazione', $tipoRelazione)
->where('data_inizio', $dataInizio);
if ($dataFine === null) {
$query->whereNull('data_fine');
} else {
$query->where('data_fine', $dataFine);
}
$id = $query->orderBy('id')->value('id');
return $id ? (int) $id : null;
}
private function resolveTipoRelazione(object $row): string
{
$ruolo = strtoupper(trim((string) ($row->ruolo ?? 'C')));
if ($ruolo === 'I') {
return 'inquilino';
}
$payload = $this->decodeLegacyPayload($row->legacy_payload ?? null);
$diritto = $this->normalizeRoleCustom((string) ($payload['diritto_reale'] ?? $payload['diritto_label'] ?? ''));
if ($diritto !== null && in_array($diritto, ['comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare'], true)) {
return $diritto;
}
$percentuale = $this->normalizePercentValue($row->percentuale ?? null);
if (($row->fonte ?? null) === 'legacy_comproprietari' && $percentuale !== null && $percentuale < 100) {
return 'comproprietario';
}
return 'proprietario';
}
private function deriveRuoloRate(string $tipoRelazione): string
{
return in_array($tipoRelazione, ['inquilino', 'locatario', 'conduttore'], true) ? 'I' : 'C';
}
private function buildRelationNote(object $row, string $tipoRelazione): string
{
$parts = [
'source=unita_immobiliare_nominativi',
'uin_id=' . (int) ($row->id ?? 0),
'fonte=' . trim((string) ($row->fonte ?? 'manuale')),
'cod_stabile=' . trim((string) ($row->codice_stabile ?? '')),
'cod_cond=' . trim((string) ($row->legacy_cond_id ?? '')),
'tipo=' . $tipoRelazione,
];
return implode(' | ', array_filter($parts));
}
private function buildPersonaNote(object $row, ?RubricaUniversale $rubrica): string
{
$parts = [
'Materializzato da unita_immobiliare_nominativi',
'uin_id=' . (int) ($row->id ?? 0),
'fonte=' . trim((string) ($row->fonte ?? 'manuale')),
'cod_stabile=' . trim((string) ($row->codice_stabile ?? '')),
'cod_cond=' . trim((string) ($row->legacy_cond_id ?? '')),
];
if ($rubrica) {
$parts[] = 'rubrica_id=' . (int) $rubrica->id;
}
return implode(' | ', array_filter($parts));
}
private function hasPersonaIdentity(array $payload): bool
{
foreach (['ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email_principale', 'telefono_principale'] as $field) {
if (! empty($payload[$field])) {
return true;
}
}
return false;
}
private function sanitizeString(?string $value, int $maxLength = 255): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : mb_substr($value, 0, $maxLength);
}
private function sanitizeEmail(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = strtolower(trim($value));
$value = preg_replace('/\s+/', '', $value);
return $value === '' ? null : $value;
}
private function normalizePhone(?string $value): ?string
{
if ($value === null) {
return null;
}
$normalized = preg_replace('/[^\d+]/', '', (string) $value);
return $normalized === '' ? null : $normalized;
}
private function normalizeFiscalIds(?string $raw): array
{
$raw = strtoupper(trim((string) ($raw ?? '')));
$raw = preg_replace('/[^A-Z0-9]/', '', $raw);
if ($raw === '') {
return ['cf' => null, 'piva' => null];
}
if (preg_match('/^\d+$/', $raw)) {
$raw = strlen($raw) < 11 ? str_pad($raw, 11, '0', STR_PAD_LEFT) : $raw;
if (strlen($raw) === 11) {
return ['cf' => $raw, 'piva' => $raw];
}
}
return ['cf' => $raw, 'piva' => null];
}
private function normalizeIdentityKey(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = Str::upper(trim((string) $value));
$value = preg_replace('/[^A-Z0-9]+/u', '', $value);
return $value !== '' ? $value : null;
}
private function normalizeNameKey(?string $nome, ?string $cognome): ?string
{
$full = trim(strtoupper(trim((string) ($cognome ?? '')) . ' ' . trim((string) ($nome ?? ''))));
$full = preg_replace('/\s+/', ' ', $full);
$full = preg_replace('/[^A-Z0-9 ]/', '', $full);
return $full !== '' ? $full : null;
}
private function splitNomeCognomeOrRagione(?string $raw): array
{
$value = trim((string) ($raw ?? ''));
if ($value === '') {
return [null, null, null];
}
if (preg_match('/\b(SRL|SPA|S\.R\.L\.|S\.P\.A\.|SAS|SNC|ASSOCIAZIONE|CONDOMINIO)\b/i', $value)) {
return [null, null, mb_substr($value, 0, 255)];
}
$parts = array_values(array_filter(preg_split('/\s+/', $value) ?: []));
if (count($parts) >= 2) {
$nome = array_pop($parts);
$cognome = implode(' ', $parts);
return [mb_substr($cognome, 0, 100), mb_substr($nome, 0, 100), null];
}
return [null, null, mb_substr($value, 0, 255)];
}
private function normalizeDateToYmd(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
if ($value === '') {
return null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value)) {
return $value;
}
$date = \DateTime::createFromFormat('Y-m-d H:i:s', $value)
?: \DateTime::createFromFormat('d/m/Y', $value)
?: \DateTime::createFromFormat('d/m/y', $value)
?: \DateTime::createFromFormat('m/d/Y', $value)
?: \DateTime::createFromFormat('m/d/y', $value)
?: \DateTime::createFromFormat('d/m/Y H:i:s', $value)
?: \DateTime::createFromFormat('m/d/Y H:i:s', $value);
if ($date instanceof \DateTimeInterface) {
return $date->format('Y-m-d');
}
$timestamp = @strtotime($value);
return $timestamp ? date('Y-m-d', $timestamp) : null;
}
private function normalizePercentValue(mixed $value): ?float
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$value = str_replace(['%', ' '], '', trim($value));
$value = str_replace(',', '.', $value);
}
return is_numeric($value) ? (float) $value : null;
}
private function isRelationActive(mixed $dataFine): bool
{
$normalized = $this->normalizeDateToYmd($dataFine);
return $normalized === null || $normalized >= now()->toDateString();
}
private function normalizeRoleCustom(string $value): ?string
{
$value = strtolower(trim($value));
if ($value === '') {
return null;
}
$value = str_replace(['à', 'è', 'é', 'ì', 'ò', 'ù'], ['a', 'e', 'e', 'i', 'o', 'u'], $value);
$value = preg_replace('/[^a-z0-9_\s-]+/', '', $value) ?? $value;
$value = str_replace([' ', '-'], '_', $value);
$value = preg_replace('/_+/', '_', $value) ?? $value;
$value = trim($value, '_');
return match ($value) {
'comproprietario', 'co_proprietario' => 'comproprietario',
'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario',
'usufruttuario', 'usufrutto' => 'usufruttuario',
'titolare' => 'titolare',
default => null,
};
}
private function decodeLegacyPayload(mixed $payload): array
{
if (is_array($payload)) {
return $payload;
}
if (is_string($payload) && trim($payload) !== '') {
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}

View File

@ -5,6 +5,7 @@
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\RubricaRuolo;
@ -105,24 +106,58 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
return null;
}
$cf = $persona->codice_fiscale;
$query = $this->rubricaScopedQuery($amministratoreId);
$cf = trim((string) ($persona->codice_fiscale ?? ''));
if ($cf) {
$found = RubricaUniversale::where('codice_fiscale', $cf)->first();
$found = (clone $query)->where('codice_fiscale', $cf)->first();
if ($found) return $found;
}
$email = $persona->email_principale;
if ($email) {
$found = RubricaUniversale::where('email', $email)->first();
$piva = trim((string) ($persona->partita_iva ?? ''));
if ($piva) {
$found = (clone $query)->where('partita_iva', $piva)->first();
if ($found) return $found;
}
$phone = $persona->telefono_principale;
if ($phone) {
$found = RubricaUniversale::where(function ($q) use ($phone) {
$q->where('telefono_ufficio', $phone)->orWhere('telefono_cellulare', $phone);
foreach ($this->collectPersonaEmails($persona) as $email) {
$found = (clone $query)->whereRaw('LOWER(email) = ?', [$email])->first();
if ($found) {
return $found;
}
}
foreach ($this->collectPersonaPhones($persona) as $phone) {
$found = (clone $query)->where(function ($q) use ($phone) {
$q->where('telefono_ufficio', $phone)
->orWhere('telefono_cellulare', $phone)
->orWhere('telefono_casa', $phone);
})->first();
if ($found) return $found;
if ($found) {
return $found;
}
}
$ragioneSociale = trim((string) ($persona->ragione_sociale ?? ''));
if ($ragioneSociale !== '') {
$found = (clone $query)
->whereRaw('LOWER(ragione_sociale) = ?', [mb_strtolower($ragioneSociale)])
->first();
if ($found) {
return $found;
}
}
$nome = trim((string) ($persona->nome ?? ''));
$cognome = trim((string) ($persona->cognome ?? ''));
if ($nome !== '' && $cognome !== '') {
$found = (clone $query)
->whereRaw('LOWER(nome) = ?', [mb_strtolower($nome)])
->whereRaw('LOWER(cognome) = ?', [mb_strtolower($cognome)])
->first();
if ($found) {
return $found;
}
}
if (!$createIfMissing) {
@ -152,6 +187,59 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
]);
}
private function rubricaScopedQuery($amministratoreId = null): Builder
{
$query = RubricaUniversale::query();
if (! Schema::hasColumn('rubrica_universale', 'amministratore_id') || $amministratoreId === null) {
return $query;
}
return $query
->where(function ($inner) use ($amministratoreId) {
$inner->where('amministratore_id', (int) $amministratoreId)
->orWhereNull('amministratore_id');
})
->orderByRaw('CASE WHEN amministratore_id = ? THEN 0 WHEN amministratore_id IS NULL THEN 1 ELSE 2 END', [(int) $amministratoreId]);
}
/** @return array<int, string> */
private function collectPersonaEmails(Persona $persona): array
{
$emails = [];
$primary = trim((string) ($persona->email_principale ?? ''));
if ($primary !== '') {
$emails[] = mb_strtolower($primary);
}
if (Schema::hasTable('persone_email_multiple')) {
foreach ($persona->emailMultiple()->pluck('email')->all() as $email) {
$candidate = mb_strtolower(trim((string) $email));
if ($candidate !== '') {
$emails[] = $candidate;
}
}
}
return array_values(array_unique($emails));
}
/** @return array<int, string> */
private function collectPersonaPhones(Persona $persona): array
{
$phones = [];
foreach ([$persona->telefono_principale, $persona->telefono_secondario] as $value) {
$candidate = trim((string) $value);
if ($candidate !== '') {
$phones[] = $candidate;
}
}
return array_values(array_unique($phones));
}
private function personaHasRubricaIdentity(Persona $persona): bool
{
$vals = [

View File

@ -24,7 +24,7 @@ class ImportGesconFullPipeline extends Command
{--dry-run : Simula senza scrivere sulle tabelle dominio}
{--limit= : Limita numero record per debug}
{--reset-temp : Svuota staging prima di importare}
{--solo= : Step singolo: unita|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto}
{--solo= : Step singolo: unita|relazioni|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto}
{--no-views : Non ricrea le viste QA}
{--force-mapping : Applica mapping anche sovrascrivendo valori esistenti}
{--amministratore-id= : Forza l\'assegnazione dello stabile a questo amministratore}
@ -35,6 +35,7 @@ class ImportGesconFullPipeline extends Command
private array $stepsOrder = [
'stabili',
'unita',
'relazioni',
'soggetti',
'diritti',
'millesimi',
@ -104,6 +105,38 @@ private function scopeCondominQuery($query)
// Legacy row cache per stabili (usata da mapField migliorata)
private ?array $stabileLegacyRow = null;
private function stepRelazioni(?int $limit = null): int
{
$params = [
'--stabile' => (string) $this->option('stabile'),
'--limit' => $limit ?? 10000,
];
if (! $this->option('dry-run')) {
$params['--apply'] = true;
}
$exit = $this->call('gescon:materialize-persone-relazioni', $params);
if ($exit !== 0) {
throw new \RuntimeException('Materializzazione persone/relazioni non riuscita.');
}
return (int) DB::table('unita_immobiliare_nominativi as uin')
->join('unita_immobiliari as ui', 'ui.id', '=', 'uin.unita_immobiliare_id')
->join('stabili as s', 's.id', '=', 'ui.stabile_id')
->when($this->option('stabile'), function ($query): void {
$stabile = trim((string) $this->option('stabile'));
$trimmed = ltrim($stabile, '0');
$query->where(function ($builder) use ($stabile, $trimmed): void {
$builder->where('s.codice_stabile', $stabile);
if ($trimmed !== '' && $trimmed !== $stabile) {
$builder->orWhere('s.codice_stabile', $trimmed);
}
});
})
->count();
}
public function handle(): int
{
if (! $this->option('stabile')) {
@ -4966,25 +4999,166 @@ private function buildRoleHistoryPayloads(array $historyRows, string $role, ?str
return $payloads;
}
private function loadLegacyComproprietariHistoryRows(object $row, ?string $legacyCondId): array
{
if (! Schema::connection('gescon_import')->hasTable('comproprietari')) {
return [];
}
$codStabile = trim((string) ($row->cod_stabile ?? ''));
$idCondRaw = trim((string) ($legacyCondId ?? $row->cod_cond ?? ''));
if ($codStabile === '' || $idCondRaw === '') {
return [];
}
$query = DB::connection('gescon_import')
->table('comproprietari')
->where('cod_stabile', $codStabile)
->orderByDesc('legacy_year')
->orderByDesc('id');
if (is_numeric($idCondRaw)) {
$query->where('id_cond', (int) $idCondRaw);
} else {
$query->where('id_cond', $idCondRaw);
}
$historyRows = $query->get()->all();
if ($historyRows === []) {
return [];
}
$byYear = [];
foreach ($historyRows as $historyRow) {
$snapshotYear = $this->resolveLegacySnapshotYear(isset($historyRow->legacy_year) ? (string) $historyRow->legacy_year : null, $historyRow);
$bucket = $snapshotYear ? (string) $snapshotYear : ('legacy:' . (string) ($historyRow->legacy_year ?? '0'));
$nameKey = $this->normalizeLegacyPartyName($historyRow->nom_cond ?? null);
$rightKey = $this->normalizeLegacyPartyName($historyRow->diritto_reale ?? $historyRow->descriz ?? null);
$groupKey = $bucket . '|' . $nameKey . '|' . $rightKey;
if (! isset($byYear[$groupKey])) {
$byYear[$groupKey] = $historyRow;
}
}
$historyRows = array_values($byYear);
usort($historyRows, function (object $a, object $b): int {
$yearA = $this->resolveLegacySnapshotYear(isset($a->legacy_year) ? (string) $a->legacy_year : null, $a) ?? 0;
$yearB = $this->resolveLegacySnapshotYear(isset($b->legacy_year) ? (string) $b->legacy_year : null, $b) ?? 0;
if ($yearA !== $yearB) {
return $yearB <=> $yearA;
}
return ((int) ($b->id ?? 0)) <=> ((int) ($a->id ?? 0));
});
return $historyRows;
}
private function buildComproprietariHistoryPayloads(array $historyRows, ?string $defaultLegacyCondId = null): array
{
if ($historyRows === []) {
return [];
}
$snapshots = [];
foreach ($historyRows as $historyRow) {
$legacyYear = isset($historyRow->legacy_year) ? trim((string) $historyRow->legacy_year) : null;
$snapshotYear = $this->resolveLegacySnapshotYear($legacyYear, $historyRow);
$name = trim((string) ($historyRow->nom_cond ?? ''));
$rightCode = trim((string) ($historyRow->diritto_reale ?? ''));
$rightLabel = trim((string) ($historyRow->descriz ?? '')) ?: ($rightCode !== '' ? $rightCode : 'Comproprietario');
$percentuale = isset($historyRow->perc_diritto_reale) && is_numeric($historyRow->perc_diritto_reale)
? (float) $historyRow->perc_diritto_reale
: null;
if ($name === '') {
continue;
}
$snapshots[] = [
'ruolo' => 'C',
'legacy_year' => $legacyYear,
'snapshot_year' => $snapshotYear,
'legacy_cond_id' => trim((string) ($historyRow->id_cond ?? $defaultLegacyCondId ?? '')) ?: $defaultLegacyCondId,
'nominativo' => $name,
'data_inizio' => $this->startOfYear($snapshotYear),
'data_fine' => null,
'percentuale' => $percentuale,
'diritto_reale' => $rightCode !== '' ? $rightCode : null,
'diritto_label' => $rightLabel,
'id_compr' => isset($historyRow->id_compr) ? (int) $historyRow->id_compr : null,
];
}
if ($snapshots === []) {
return [];
}
$payloads = [];
$current = null;
foreach ($snapshots as $snapshot) {
$snapshotNameKey = $this->normalizeLegacyPartyName($snapshot['nominativo'] ?? null);
$snapshotRightKey = $this->normalizeLegacyPartyName($snapshot['diritto_reale'] ?? $snapshot['diritto_label'] ?? null);
$snapshotStart = $snapshot['data_inizio'] ?? $this->startOfYear($snapshot['snapshot_year']);
if ($current === null) {
$current = $snapshot + [
'legacy_years' => array_values(array_filter([$snapshot['legacy_year']])),
];
continue;
}
$currentNameKey = $this->normalizeLegacyPartyName($current['nominativo'] ?? null);
$currentRightKey = $this->normalizeLegacyPartyName($current['diritto_reale'] ?? $current['diritto_label'] ?? null);
$sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey;
$sameRight = $snapshotRightKey === $currentRightKey;
$samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null
|| abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005;
if ($sameName && $sameRight && $samePercent) {
$current['data_inizio'] = $snapshotStart ?? $current['data_inizio'];
if (! empty($snapshot['legacy_year'])) {
$current['legacy_years'][] = $snapshot['legacy_year'];
}
continue;
}
$payloads[] = $current;
$current = $snapshot + [
'data_fine' => $this->previousDay($current['data_inizio'] ?? null),
'legacy_years' => array_values(array_filter([$snapshot['legacy_year']])),
];
}
if ($current !== null) {
$payloads[] = $current;
}
return $payloads;
}
private function upsertUnitaNominativiFromLegacy(int $unitaId, int $stabileId, ?string $legacyCondId, object $row): void
{
if (! Schema::hasTable('unita_immobiliare_nominativi')) {
return;
}
$historyRows = $this->loadLegacyCondominHistoryRows($row, $legacyCondId);
$payloads = array_merge(
$this->buildRoleHistoryPayloads($historyRows, 'C', $legacyCondId),
$this->buildRoleHistoryPayloads($historyRows, 'I', $legacyCondId)
$historyRowsCondomin = $this->loadLegacyCondominHistoryRows($row, $legacyCondId);
$historyRowsComproprietari = $this->loadLegacyComproprietariHistoryRows($row, $legacyCondId);
$payloadsCondomin = array_merge(
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'C', $legacyCondId),
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'I', $legacyCondId)
);
$payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId);
DB::table('unita_immobiliare_nominativi')
->where('unita_immobiliare_id', $unitaId)
->where('stabile_id', $stabileId)
->where('fonte', 'legacy_condomin')
->whereIn('fonte', ['legacy_condomin', 'legacy_comproprietari'])
->delete();
foreach ($payloads as $payload) {
foreach ($payloadsCondomin as $payload) {
DB::table('unita_immobiliare_nominativi')->insert([
'unita_immobiliare_id' => $unitaId,
'stabile_id' => $stabileId,
@ -5004,6 +5178,30 @@ private function upsertUnitaNominativiFromLegacy(int $unitaId, int $stabileId, ?
'updated_at' => now(),
]);
}
foreach ($payloadsComproprietari as $payload) {
DB::table('unita_immobiliare_nominativi')->insert([
'unita_immobiliare_id' => $unitaId,
'stabile_id' => $stabileId,
'legacy_cond_id' => $payload['legacy_cond_id'] ?? $legacyCondId,
'ruolo' => 'C',
'nominativo' => $payload['nominativo'],
'data_inizio' => $payload['data_inizio'],
'data_fine' => $payload['data_fine'],
'percentuale' => $payload['percentuale'],
'fonte' => 'legacy_comproprietari',
'legacy_payload' => json_encode([
'cod_cond' => $payload['legacy_cond_id'] ?? $legacyCondId,
'id_compr' => $payload['id_compr'] ?? null,
'diritto_reale' => $payload['diritto_reale'] ?? null,
'diritto_label' => $payload['diritto_label'] ?? null,
'legacy_years' => array_values(array_unique($payload['legacy_years'] ?? [])),
'history_mode' => 'comproprietari-series',
]),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
private function normalizeLegacyCalcolo(?string $raw): ?string

View File

@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use Illuminate\Console\Command;
class NetgesconApplyPbxPresetCommand extends Command
{
protected $signature = 'netgescon:pbx-apply-day0-preset
{admin : ID o codice amministratore}
{--apply : Salva il preset sull\'amministratore}
{--day-group=601 : Gruppo risposta giorno}
{--night-group=603 : Gruppo risposta notte}
{--day-fallback=201 : Interno fallback giorno}
{--night-fallback=206 : Interno fallback notte}
{--admin-line=0003 : Linea/DID amministratore}
{--admin-target= : Interno target amministratore, se diverso dall\'attuale}
{--watch=201,206,601,603,0003 : Interni/linee monitorati CSV}';
protected $description = 'Applica o mostra il preset PBX Day0 con gruppi 601/603, fallback 201/206 e linea amministratore 0003.';
public function handle(): int
{
$admin = $this->resolveAdmin((string) $this->argument('admin'));
if (! $admin) {
$this->error('Amministratore non trovato.');
return self::FAILURE;
}
$impostazioni = is_array($admin->impostazioni) ? $admin->impostazioni : [];
$centralino = (array) ($impostazioni['centralino'] ?? []);
$pbx = (array) ($impostazioni['pbx'] ?? []);
$dayGroup = $this->digits((string) $this->option('day-group'));
$nightGroup = $this->digits((string) $this->option('night-group'));
$dayFallback = $this->digits((string) $this->option('day-fallback'));
$nightFallback = $this->digits((string) $this->option('night-fallback'));
$adminLine = $this->digits((string) $this->option('admin-line'));
$adminTarget = $this->digits((string) $this->option('admin-target'));
if ($adminTarget === '') {
$adminTarget = $this->digits((string) ($centralino['interno_amministratore'] ?? ''));
}
if ($adminTarget !== '') {
$centralino['interno_amministratore'] = $adminTarget;
}
$centralino['interno_gruppo_giorno'] = $dayGroup;
$centralino['interno_gruppo_notte'] = $nightGroup;
$pbx['provider'] = (string) ($pbx['provider'] ?? 'panasonic_ns1000');
$pbx['channel'] = (string) ($pbx['channel'] ?? 'hybrid');
$pbx['bridge_mode'] = (string) ($pbx['bridge_mode'] ?? 'group-first');
$watch = preg_split('/\s*,\s*/', (string) $this->option('watch'), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$watch = array_values(array_unique(array_filter(array_map(
fn (string $value): string => $this->digits($value),
array_merge($watch, [$dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget])
))));
$pbx['watch_extensions'] = implode(',', $watch);
$pbx['response_groups'] = array_values(array_filter([
[
'extension' => $dayGroup,
'label' => 'Gruppo giorno',
'mode' => 'giorno',
'target_extension' => $dayFallback,
],
[
'extension' => $nightGroup,
'label' => 'Gruppo notte',
'mode' => 'notte',
'target_extension' => $nightFallback,
],
], fn (array $row): bool => ($row['extension'] ?? '') !== ''));
$incomingLines = [];
if ($adminLine !== '') {
$incomingLines[] = [
'number' => $adminLine,
'label' => 'Linea amministratore',
'route_group' => '',
'target_extension' => $adminTarget,
'notes' => 'Smistamento dedicato amministratore su linea 0003.',
];
}
$pbx['incoming_lines'] = $incomingLines;
$impostazioni['centralino'] = $centralino;
$impostazioni['pbx'] = $pbx;
$this->line('Preset PBX Day0:');
$this->line(json_encode([
'amministratore_id' => (int) $admin->id,
'codice_amministratore' => (string) ($admin->codice_amministratore ?? ''),
'centralino' => $centralino,
'pbx' => $pbx,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
if (! $this->option('apply')) {
$this->warn('Dry-run completato. Riesegui con --apply per salvare il preset.');
return self::SUCCESS;
}
$admin->impostazioni = $impostazioni;
$admin->save();
$this->info('Preset PBX salvato correttamente.');
return self::SUCCESS;
}
private function resolveAdmin(string $value): ?Amministratore
{
$value = trim($value);
if ($value === '') {
return null;
}
return Amministratore::query()
->when(is_numeric($value), fn ($query) => $query->orWhere('id', (int) $value))
->orWhere('codice_amministratore', $value)
->orWhere('codice_univoco', $value)
->first();
}
private function digits(string $value): string
{
return preg_replace('/\D+/', '', $value) ?: '';
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Services\TenantArchiveRegistryService;
use Illuminate\Console\Command;
class NetgesconArchiveRegistrySyncCommand extends Command
{
protected $signature = 'netgescon:archive-registry-sync
{--amministratore= : Codice amministratore canonico o univoco}
{--stabile= : Codice stabile da riallineare}
{--dry-run : Mostra i record senza salvarli}';
protected $description = 'Allinea il registry centrale degli archivi tenant-aware per amministratori e stabili';
public function __construct(private TenantArchiveRegistryService $registryService)
{
parent::__construct();
}
public function handle(): int
{
$adminFilter = trim((string) $this->option('amministratore'));
$stabileFilter = trim((string) $this->option('stabile'));
$dryRun = (bool) $this->option('dry-run');
$amministratori = Amministratore::query()
->when($adminFilter !== '', function ($query) use ($adminFilter) {
$query->where('codice_amministratore', $adminFilter)
->orWhere('codice_univoco', $adminFilter);
})
->orderBy('id')
->get();
if ($amministratori->isEmpty()) {
$this->error('Nessun amministratore trovato per i criteri richiesti.');
return self::FAILURE;
}
$stabili = Stabile::query()
->with(['amministratore', 'latestAmministratoreTransfer'])
->whereIn('amministratore_id', $amministratori->pluck('id'))
->when($stabileFilter !== '', fn($query) => $query->where('codice_stabile', $stabileFilter))
->orderBy('id')
->get();
$records = [];
foreach ($amministratori as $amministratore) {
$records[] = $dryRun
? $this->registryService->buildAmministratorePayload($amministratore)
: $this->registryService->syncAmministratore($amministratore)->toArray();
}
foreach ($stabili as $stabile) {
$records[] = $dryRun
? $this->registryService->buildStabilePayload($stabile)
: $this->registryService->syncStabile($stabile)->toArray();
}
$this->info(sprintf(
'%s registry completato: %d amministratori, %d stabili.',
$dryRun ? 'Dry-run' : 'Sync',
$amministratori->count(),
$stabili->count()
));
$this->table(
['Tipo', 'Codice', 'Owner', 'Database', 'Storage', 'Stato'],
collect($records)->map(function (array $record) {
return [
'tipo' => $record['archive_type'] ?? '-',
'codice' => $record['archive_code'] ?? '-',
'owner' => $record['owner_amministratore_code'] ?? '-',
'database' => $record['database_name'] ?? ($record['database_strategy'] ?? '-'),
'storage' => $record['storage_relative_path'] ?? '-',
'stato' => $record['status'] ?? '-',
];
})->all()
);
return self::SUCCESS;
}
}

View File

@ -1,5 +1,4 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
@ -44,7 +43,7 @@ public function handle(): int
$recentPages = $this->recentPages();
$commitNotes = $this->recentCommitNotes();
$snapshot = [];
$snapshot = [];
$snapshot[] = '# Snapshot continuita sviluppo';
$snapshot[] = '';
$snapshot[] = 'Generata automaticamente il ' . now()->format('d/m/Y H:i:s') . '.';
@ -114,9 +113,9 @@ public function handle(): int
File::put($outputPath, implode(PHP_EOL, $snapshot) . PHP_EOL);
File::put($jsonPath, json_encode([
'generated_at' => now()->toIso8601String(),
'branch' => $branch,
'commit' => $commit,
'output_path' => $outputPath,
'branch' => $branch,
'commit' => $commit,
'output_path' => $outputPath,
'source_files' => [
'docs/support/MANUALE-SVILUPPO-OPERATIVO.md',
'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md',
@ -165,8 +164,8 @@ private function recentCommits(): array
}
$rows[] = [
'hash' => (string) $parts[0],
'date' => (string) $parts[1],
'hash' => (string) $parts[0],
'date' => (string) $parts[1],
'message' => (string) $parts[2],
];
}
@ -177,9 +176,9 @@ private function recentCommits(): array
/** @return array<int,array{page:string,path:string,commit:string}> */
private function recentPages(): array
{
$rows = [];
$seen = [];
$result = Process::path(base_path())->timeout(120)->run([
$rows = [];
$seen = [];
$result = Process::path(base_path())->timeout(120)->run([
'git', 'log', '-n', '12', '--name-only', '--pretty=format:@@@%h', '--', 'app/Filament/Pages', 'resources/views/filament/pages',
]);
@ -209,9 +208,9 @@ private function recentPages(): array
}
$seen[$line] = true;
$rows[] = [
'page' => basename($line),
'path' => $line,
$rows[] = [
'page' => basename($line),
'path' => $line,
'commit' => $currentCommit,
];
@ -244,9 +243,9 @@ private function recentCommitNotes(): array
}
$rows[] = [
'hash' => $commit['hash'],
'hash' => $commit['hash'],
'message' => $commit['message'],
'note' => trim($decoded[$fullHash]),
'note' => trim($decoded[$fullHash]),
];
}

View File

@ -9,6 +9,7 @@
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Actions\Action;
@ -69,6 +70,31 @@ public function mount(): void
$this->mountInteractsWithTable();
}
private function resolveActiveAnnoGestione(): int
{
$user = Auth::user();
return AnnoGestioneContext::resolveActiveAnno($user instanceof User ? $user : null);
}
private function applyActiveYearFilter(Builder $query): Builder
{
$year = $this->resolveActiveAnnoGestione();
return $query->where(function (Builder $inner) use ($year): void {
$inner->whereYear('periodo_al', $year)
->orWhere(function (Builder $fallback) use ($year): void {
$fallback->whereNull('periodo_al')
->whereYear('periodo_dal', $year);
})
->orWhere(function (Builder $fallback) use ($year): void {
$fallback->whereNull('periodo_al')
->whereNull('periodo_dal')
->whereYear('created_at', $year);
});
});
}
protected function getTableQuery(): Builder
{
$user = Auth::user();
@ -97,6 +123,7 @@ protected function getTableQuery(): Builder
fn(Builder $q) => $q->whereIn('stabile_id', $stabiliIds->all()),
fn(Builder $q) => $q->where('stabile_id', (int) $activeStabileId)
)
->tap(fn(Builder $query) => $this->applyActiveYearFilter($query))
->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter))
->with([
'stabile:id,codice_stabile,denominazione',

View File

@ -8,6 +8,7 @@
use App\Models\StabileServizioTariffa;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Actions\Action;
@ -470,6 +471,39 @@ private function resolveActiveStabileId(): ?int
return $stabileId ? (int) $stabileId : null;
}
private function resolveActiveAnnoGestione(): int
{
$user = Auth::user();
return AnnoGestioneContext::resolveActiveAnno($user instanceof User ? $user : null);
}
/** @return array<int, string> */
private function resolveLegacyCodiciStabile(): array
{
$user = Auth::user();
return StabileContext::legacyCodeCandidates(StabileContext::getActiveStabile($user instanceof User ? $user : null));
}
private function applyActiveYearFilterToReadingQuery(Builder $query): Builder
{
$year = $this->resolveActiveAnnoGestione();
return $query->where(function (Builder $inner) use ($year): void {
$inner->whereYear('periodo_al', $year)
->orWhere(function (Builder $fallback) use ($year): void {
$fallback->whereNull('periodo_al')
->whereYear('periodo_dal', $year);
})
->orWhere(function (Builder $fallback) use ($year): void {
$fallback->whereNull('periodo_al')
->whereNull('periodo_dal')
->whereYear('created_at', $year);
});
});
}
/** @return array{totale_fatture: float, totale_operazioni_ac12_legacy: float, delta_operazioni_vs_fatture: float, totale_letture: int, totale_letture_con_riferimento: int, totale_consumi_mc: float, totale_tariffe: int} */
public function getAcquaDashboardStatsProperty(): array
{
@ -559,10 +593,23 @@ public function getAcquaLegacyOperazioniSummaryProperty(): array
return ['totale' => 0.0, 'voci' => [], 'righe' => 0];
}
$rows = DB::connection('gescon_import')
$legacyCodes = $this->resolveLegacyCodiciStabile();
$year = $this->resolveActiveAnnoGestione();
$query = DB::connection('gescon_import')
->table('operazioni')
->where('gestione', 'O')
->whereIn('cod_spe', ['AC1', 'AC2'])
->whereIn('cod_spe', ['AC1', 'AC2']);
if ($legacyCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) {
$query->whereIn('cod_stabile', $legacyCodes);
}
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) {
$query->whereYear('dt_spe', $year);
}
$rows = $query
->select('cod_spe')
->selectRaw('COUNT(*) as righe')
->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale')
@ -603,10 +650,23 @@ public function getAcquaLegacyOperazioniRowsProperty(): array
return [];
}
$legacyRows = DB::connection('gescon_import')
$legacyCodes = $this->resolveLegacyCodiciStabile();
$year = $this->resolveActiveAnnoGestione();
$query = DB::connection('gescon_import')
->table('operazioni')
->where('gestione', 'O')
->whereIn('cod_spe', ['AC1', 'AC2'])
->whereIn('cod_spe', ['AC1', 'AC2']);
if ($legacyCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) {
$query->whereIn('cod_stabile', $legacyCodes);
}
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) {
$query->whereYear('dt_spe', $year);
}
$legacyRows = $query
->orderByDesc('dt_spe')
->orderByDesc('id_operaz')
->limit(120)
@ -841,38 +901,43 @@ public function getAcquaRipartoNominativiLegacyProperty(): array
return [];
}
$user = Auth::user();
$legacyCode = trim((string) (StabileContext::getActiveStabile($user)?->codice_stabile ?? ''));
if ($legacyCode === '') {
$legacyCodes = $this->resolveLegacyCodiciStabile();
if ($legacyCodes === []) {
return [];
}
$legacyYear = (string) (DB::connection('gescon_import')
->table('dett_tab')
->where('cod_stabile', $legacyCode)
->max('legacy_year') ?: '');
$legacyYear = (string) $this->resolveActiveAnnoGestione();
$condRows = DB::connection('gescon_import')
$condQuery = DB::connection('gescon_import')
->table('condomin')
->where('cod_stabile', $legacyCode)
->when($legacyYear !== '', fn($q) => $q->where('legacy_year', $legacyYear))
->get([
'id_cond',
'scala',
'interno',
'cognome',
'nome',
'proprietario_denominazione',
'inquilino_denominazione',
'inquil_nome',
])
->whereIn('cod_stabile', $legacyCodes);
if ($legacyYear !== '' && Schema::connection('gescon_import')->hasColumn('condomin', 'legacy_year')) {
$condQuery->where('legacy_year', $legacyYear);
}
$condRows = $condQuery->get([
'id_cond',
'scala',
'interno',
'cognome',
'nome',
'proprietario_denominazione',
'inquilino_denominazione',
'inquil_nome',
])
->keyBy('id_cond');
$rows = DB::connection('gescon_import')
$rowsQuery = DB::connection('gescon_import')
->table('dett_tab')
->where('cod_tab', 'ACQUA')
->where('cod_stabile', $legacyCode)
->when($legacyYear !== '', fn($q) => $q->where('legacy_year', $legacyYear))
->whereIn('cod_stabile', $legacyCodes);
if ($legacyYear !== '' && Schema::connection('gescon_import')->hasColumn('dett_tab', 'legacy_year')) {
$rowsQuery->where('legacy_year', $legacyYear);
}
$rows = $rowsQuery
->orderBy('id_cond')
->orderBy('cond_inquil')
->get(['id_cond', 'cond_inquil', 'cons_euro']);
@ -984,6 +1049,8 @@ public function getAcquaFatturePerGestioneProperty(): array
return [];
}
$year = $this->resolveActiveAnnoGestione();
if (Schema::hasTable('contabilita_fatture_fornitori')) {
$fornitoreIds = StabileServizio::query()
->where('stabile_id', $stabileId)
@ -1001,6 +1068,8 @@ public function getAcquaFatturePerGestioneProperty(): array
->leftJoin('gestioni_contabili as g', 'g.id', '=', 'f.gestione_id')
->where('f.stabile_id', $stabileId)
->whereIn('f.fornitore_id', $fornitoreIds)
->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($q) => $q->where('g.anno_gestione', $year))
->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($q) => $q->whereYear('f.data_documento', $year))
->get(['f.id', 'f.totale', 'f.fattura_elettronica_id', 'g.tipo_gestione']);
if ($contabili->isNotEmpty()) {
@ -1047,6 +1116,7 @@ public function getAcquaFatturePerGestioneProperty(): array
->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'acqua'))
->whereNotNull('fattura_elettronica_id')
->with(['voceSpesa:id,tipo_gestione'])
->tap(fn(Builder $query) => $this->applyActiveYearFilterToReadingQuery($query))
->get(['fattura_elettronica_id', 'voce_spesa_id', 'importo_totale']);
$byFattura = [];
@ -1110,6 +1180,7 @@ public function getAcquaLettureCondominiProperty(): array
'unitaImmobiliare:id,codice_unita,interno,scala',
'servizio:id,nome,contatore_matricola',
])
->tap(fn(Builder $query) => $this->applyActiveYearFilterToReadingQuery($query))
->orderByDesc('created_at')
->limit(60)
->get()
@ -1140,6 +1211,7 @@ public function getAcquaTariffeRowsProperty(): array
return StabileServizioTariffa::query()
->where('stabile_id', $stabileId)
->whereYear('data_fattura', $this->resolveActiveAnnoGestione())
->orderByDesc('data_fattura')
->orderByDesc('id')
->limit(80)
@ -1193,12 +1265,25 @@ public function getAcquaAltreVociLegacyProperty(): array
return [];
}
$rows = DB::connection('gescon_import')
$legacyCodes = $this->resolveLegacyCodiciStabile();
$year = $this->resolveActiveAnnoGestione();
$query = DB::connection('gescon_import')
->table('operazioni')
->where('gestione', 'O')
->where('cod_spe', 'like', 'AC%')
->where('cod_spe', '!=', 'AC1')
->where('cod_spe', '!=', 'AC2')
->where('cod_spe', '!=', 'AC2');
if ($legacyCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) {
$query->whereIn('cod_stabile', $legacyCodes);
}
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) {
$query->whereYear('dt_spe', $year);
}
$rows = $query
->select('cod_spe')
->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale')
->groupBy('cod_spe')

View File

@ -3,6 +3,7 @@
use App\Models\User;
use App\Models\UserSetting;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
@ -67,6 +68,11 @@ public static function canAccess(): bool
public function mount(): void
{
$user = Auth::user();
if ($user instanceof User && $this->filterAnno === null) {
$this->filterAnno = AnnoGestioneContext::resolveActiveAnno($user);
}
$tab = request()->query('tab');
if (in_array($tab, ['operazioni', 'consuntivo', 'straordinarie', 'acqua', 'incassi', 'fatture'], true)) {
$this->viewTab = $tab;
@ -152,25 +158,8 @@ private function resolveLegacyCodiceStabile(): ?string
{
$user = Auth::user();
$stabile = StabileContext::getActiveStabile($user);
if (! $stabile) {
return null;
}
// Preferisci il codice legacy "tecnico" (cod_stabile/old_id), poi codice_stabile.
// Questo evita mismatch quando in archivio import il campo cod_stabile e` numerico.
$candidates = [
trim((string) ($stabile->cod_stabile ?? '')),
trim((string) ($stabile->old_id ?? '')),
trim((string) ($stabile->codice_stabile ?? '')),
];
foreach ($candidates as $candidate) {
if ($candidate !== '') {
return $candidate;
}
}
return null;
return StabileContext::preferredLegacyCode($stabile);
}
private function resolveLegacyYearForRiparto(?string $codStabile): ?string
@ -881,16 +870,10 @@ public function getConsuntivoProperty(): array
WHEN (COALESCE(importo_spese, 0) + COALESCE(importo_entrate, 0) + COALESCE(importo_debiti, 0) + COALESCE(importo_crediti, 0)) = 0 THEN
CASE
WHEN cod_spe = 'RB1' THEN -1 * COALESCE(importo_euro, importo, 0)
$rowsQuery = DB::connection('gescon_import')
WHEN natura2 IN ('E', 'ENTRATA', 'ENTRATE', 'CREDITO') THEN -1 * COALESCE(importo_euro, importo, 0)
ELSE COALESCE(importo_euro, importo, 0)
END
->whereIn('cod_spe', ['AC1', 'AC2']);
if ($legacyCode && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) {
$rowsQuery->where('cod_stabile', $legacyCode);
}
$rows = $rowsQuery
ELSE (COALESCE(importo_spese, 0) + COALESCE(importo_debiti, 0) - COALESCE(importo_entrate, 0) - COALESCE(importo_crediti, 0))
END
) as totale")
->groupBy('tabella', 'cod_spe')

View File

@ -3,6 +3,7 @@
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\PbxClickToCallRequest;
use App\Models\Persona;
use App\Models\PersonaEmailMultipla;
use App\Models\RubricaContattoCanale;
@ -279,24 +280,28 @@ public function mount(int | string $record): void
->all();
// Contatti principali (per mostrarli velocemente nella scheda): prende canali principali + fallback ai campi telefono/email.
$principali = RubricaContattoCanale::query()
->where('rubrica_id', (int) $this->rubrica->id)
->orderByDesc('is_principale')
->orderByDesc('data_inizio')
->orderByDesc('id')
->limit(20)
->get();
if (Schema::hasTable('rubrica_contatto_canali')) {
$principali = RubricaContattoCanale::query()
->where('rubrica_id', (int) $this->rubrica->id)
->orderByDesc('is_principale')
->orderByDesc('data_inizio')
->orderByDesc('id')
->limit(20)
->get();
$this->contattiPrincipali = $principali
->map(fn(RubricaContattoCanale $c) => [
'tipo' => (string) ($c->tipo ?? 'altro'),
'etichetta' => (string) ($c->etichetta ?? ''),
'valore' => (string) ($c->valore ?? ''),
'principale' => (bool) ($c->is_principale ?? false),
])
->all();
$this->contattiPrincipali = $principali
->map(fn(RubricaContattoCanale $c) => [
'tipo' => (string) ($c->tipo ?? 'altro'),
'etichetta' => (string) ($c->etichetta ?? ''),
'valore' => (string) ($c->valore ?? ''),
'principale' => (bool) ($c->is_principale ?? false),
])
->all();
} else {
$this->contattiPrincipali = [];
}
$this->hydrateEmailMultiple();
$this->loadEmailMultiple();
$this->fillInlineForm();
$this->hydrateFornitoriWorkspace();
$this->hydrateStudioCollaboratoreWorkspace();
@ -684,6 +689,7 @@ protected function getHeaderActions(): array
Action::make('contatti_avanzati')
->label('Contatti avanzati')
->icon('heroicon-o-rectangle-stack')
->visible(fn(): bool => Schema::hasTable('rubrica_contatto_canali'))
->modalWidth('7xl')
->form([
Repeater::make('canali')
@ -1282,7 +1288,7 @@ private function resolveTitoloId(?string $raw): ?int
return $title?->id ? (int) $title->id : null;
}
private function hydrateEmailMultiple(): void
private function loadEmailMultiple(): void
{
$persona = $this->resolvePersonaForRubrica(false);
if (! $persona instanceof Persona) {
@ -1364,7 +1370,80 @@ private function saveAdditionalEmails(array $rows): void
$deleteQuery->delete();
});
$this->hydrateEmailMultiple();
$this->loadEmailMultiple();
}
private function getStudioCollaboratoreEmail(): ?string
{
$primary = mb_strtolower(trim((string) ($this->rubrica->email ?? '')));
if ($primary !== '') {
return $primary;
}
foreach ($this->emailMultiple as $row) {
$email = mb_strtolower(trim((string) ($row['email'] ?? '')));
if ($email !== '' && (bool) ($row['attiva'] ?? true)) {
return $email;
}
}
return null;
}
public function canRequestClickToCall(): bool
{
$user = Auth::user();
return $user instanceof User
&& (bool) ($user->pbx_click_to_call_enabled ?? false)
&& trim((string) ($user->pbx_extension ?? '')) !== '';
}
public function richiediClickToCallRubrica(string $field): void
{
if (! in_array($field, ['telefono_ufficio', 'telefono_cellulare', 'telefono_casa'], true)) {
Notification::make()->title('Campo telefono non supportato')->warning()->send();
return;
}
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$extension = trim((string) ($user->pbx_extension ?? ''));
if (! (bool) ($user->pbx_click_to_call_enabled ?? false) || $extension === '') {
Notification::make()->title('Click-to-call non abilitato per il tuo utente')->warning()->send();
return;
}
$phone = $this->normalizePhoneDigits(data_get($this->rubrica, $field));
if ($phone === '') {
Notification::make()->title('Numero non valido')->warning()->send();
return;
}
PbxClickToCallRequest::query()->create([
'requested_by_user_id' => (int) $user->id,
'assigned_user_id' => (int) $user->id,
'stabile_id' => $this->resolveClickToCallStabileId($user),
'source_extension' => $extension,
'target_number' => $phone,
'status' => 'pending',
'note' => 'Richiesta da scheda rubrica',
'requested_at' => now(),
'metadata' => [
'requested_from' => 'rubrica_universale',
'rubrica_id' => (int) $this->rubrica->id,
'field' => $field,
],
]);
Notification::make()
->title('Richiesta click-to-call registrata')
->body('Interno ' . $extension . ' -> ' . $phone)
->success()
->send();
}
private function resolvePersonaForRubrica(bool $createIfMissing): ?Persona
@ -1797,12 +1876,12 @@ public function salvaInternoDipendente(int $dipendenteId): void
public function abilitaAccessoCollaboratoreStudio(): void
{
$email = mb_strtolower(trim((string) ($this->rubrica->email ?? '')));
if ($email === '') {
$email = $this->getStudioCollaboratoreEmail();
if ($email === null) {
Notification::make()
->title('Email rubrica mancante')
->warning()
->body('Per creare o collegare il collaboratore di studio serve un indirizzo email nella rubrica.')
->body('Per creare o collegare il collaboratore di studio serve un indirizzo email principale o aggiuntivo attivo nella rubrica.')
->send();
return;
}
@ -1877,7 +1956,7 @@ public function salvaCollaboratoreStudio(): void
private function hydrateStudioCollaboratoreWorkspace(): void
{
$this->studioCollaboratoreUserId = null;
$this->studioCollaboratoreUserEmail = trim((string) ($this->rubrica->email ?? '')) ?: null;
$this->studioCollaboratoreUserEmail = $this->getStudioCollaboratoreEmail();
$this->studioCollaboratoreUserName = trim((string) ($this->rubrica->nome_completo ?: $this->rubrica->ragione_sociale ?: '')) ?: null;
$this->studioCollaboratorePbxExtension = '';
$this->studioCollaboratoreRoles = [];
@ -1945,4 +2024,28 @@ private function syncStudioCollaboratoreStabili(User $user): void
$user->stabiliAssegnati()->sync(array_values(array_unique(array_merge($outsideAdminIds, $requested))));
}
private function normalizePhoneDigits(mixed $value): string
{
$digits = preg_replace('/\D+/', '', (string) ($value ?? ''));
return is_string($digits) ? $digits : '';
}
private function resolveClickToCallStabileId(User $user): ?int
{
$activeStabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0);
if ($activeStabileId > 0) {
return $activeStabileId;
}
foreach ($this->stabili as $stabile) {
$candidate = (int) ($stabile['id'] ?? 0);
if ($candidate > 0) {
return $candidate;
}
}
return null;
}
}

View File

@ -24,10 +24,11 @@
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Schemas\Schema as FilamentSchema;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
use UnitEnum;
@ -134,7 +135,7 @@ public function mount(): void
]);
}
public function form(Schema $schema): Schema
public function form(FilamentSchema $schema): FilamentSchema
{
return $schema
->statePath('data')
@ -761,7 +762,7 @@ public function form(Schema $schema): Schema
TextInput::make('impostazioni.pbx.watch_extensions')
->label('Interni monitorati')
->maxLength(255)
->placeholder('201,205,206,601,603')
->placeholder('201,206,601,603,0003')
->helperText('Lista CSV degli interni/gruppi che il watcher Windows deve osservare.'),
Toggle::make('impostazioni.pbx.popup_enabled')
->label('Popup CRM attivi')
@ -796,7 +797,7 @@ public function form(Schema $schema): Schema
TextInput::make('target_extension')
->label('Interno di fallback')
->maxLength(20)
->placeholder('205'),
->placeholder('201 oppure 206'),
])
->columns(4)
->columnSpanFull(),
@ -811,19 +812,19 @@ public function form(Schema $schema): Schema
TextInput::make('number')
->label('Numero / DID')
->maxLength(40)
->placeholder('0811234567'),
->placeholder('0003'),
TextInput::make('label')
->label('Descrizione linea')
->maxLength(120)
->placeholder('Linea assistenza'),
->placeholder('Linea amministratore'),
TextInput::make('route_group')
->label('Gruppo target')
->maxLength(20)
->placeholder('601'),
->placeholder('601 o 603'),
TextInput::make('target_extension')
->label('Interno target')
->maxLength(20)
->placeholder('205'),
->placeholder('interno amministratore'),
Textarea::make('notes')
->label('Note')
->rows(2)
@ -852,7 +853,7 @@ public function form(Schema $schema): Schema
]),
]),
]);
}
}
public function runProductionUpgrade(): void
{
@ -1344,11 +1345,12 @@ public function getPbxSummaryProperty(): array
];
}
$query = $this->pbxMessagesQuery();
$latest = (clone $query)->orderByDesc('received_at')->orderByDesc('id')->first();
$total = (clone $query)->count();
$last24h = (clone $query)->where('received_at', '>=', now()->subDay())->count();
$missedCalls = (clone $query)->where(function ($q): void {
$query = $this->pbxMessagesQuery();
$rawQuery = $this->pbxRawMessagesQuery();
$latest = (clone $query)->orderByDesc('received_at')->orderByDesc('id')->first();
$total = (clone $query)->count();
$last24h = (clone $query)->where('received_at', '>=', now()->subDay())->count();
$missedCalls = (clone $rawQuery)->where(function ($q): void {
$q->where('message_text', 'like', '%persa%')
->orWhere('metadata->outcome', 'like', '%miss%')
->orWhere('metadata->outcome', 'like', '%no_answer%');
@ -1369,20 +1371,20 @@ public function getPbxSummaryProperty(): array
: 'La configurazione e predisposta per usare gli eventi PBX direttamente nel CRM. Verifica i log del watcher Windows per confermare il flusso sul canale selezionato.';
return [
'status' => $status,
'label' => $label,
'provider' => $provider,
'channel' => $channel,
'bridge_mode' => $bridgeMode,
'watched_extensions'=> $watchedExtensions,
'response_groups' => $responseGroups,
'incoming_lines' => $incomingLines,
'last_event_at' => $latest?->received_at?->format('d/m/Y H:i:s'),
'last_event_type' => $latest ? (string) data_get($latest->metadata, 'event_type', $latest->status) : null,
'total_messages' => $total,
'last_24h' => $last24h,
'missed_calls' => $missedCalls,
'assessment' => $assessment,
'status' => $status,
'label' => $label,
'provider' => $provider,
'channel' => $channel,
'bridge_mode' => $bridgeMode,
'watched_extensions' => $watchedExtensions,
'response_groups' => $responseGroups,
'incoming_lines' => $incomingLines,
'last_event_at' => $latest?->received_at?->format('d/m/Y H:i:s'),
'last_event_type' => $latest ? (string) data_get($latest->metadata, 'event_type', $latest->status) : null,
'total_messages' => $total,
'last_24h' => $last24h,
'missed_calls' => $missedCalls,
'assessment' => $assessment,
];
}
@ -1401,7 +1403,7 @@ public function getPbxRecentMessagesProperty(): array
->orderByDesc('id')
->limit(12)
->get(['id', 'phone_number', 'target_extension', 'assigned_user_id', 'direction', 'status', 'received_at', 'metadata'])
->map(fn(CommunicationMessage $message): array => [
->map(fn(CommunicationMessage $message): array=> [
'id' => (int) $message->id,
'received_at' => $message->received_at?->format('d/m/Y H:i:s') ?? '-',
'phone_number' => (string) ($message->phone_number ?? '-'),
@ -1428,39 +1430,39 @@ public function getPbxRoutingRowsProperty(): array
$incoming = array_values(array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row)));
$routingRows = [
[
'type' => 'Studio',
'label' => 'Interno centralino',
'extension' => (string) Arr::get($centralino, 'interno_centralino', '-'),
'target' => 'Smistamento studio',
'note' => (string) Arr::get($centralino, 'numero_principale', '-'),
'type' => 'Studio',
'label' => 'Interno centralino',
'extension' => (string) Arr::get($centralino, 'interno_centralino', '-'),
'target' => 'Smistamento studio',
'note' => (string) Arr::get($centralino, 'numero_principale', '-'),
],
[
'type' => 'Studio',
'label' => 'Operatore',
'extension' => (string) Arr::get($centralino, 'interno_operatore', '-'),
'target' => 'Utente operatore',
'note' => 'Instradamento diretto',
'type' => 'Studio',
'label' => 'Operatore',
'extension' => (string) Arr::get($centralino, 'interno_operatore', '-'),
'target' => 'Utente operatore',
'note' => 'Instradamento diretto',
],
[
'type' => 'Studio',
'label' => 'Amministratore',
'extension' => (string) Arr::get($centralino, 'interno_amministratore', '-'),
'target' => 'Utente amministratore',
'note' => 'Instradamento diretto',
'type' => 'Studio',
'label' => 'Amministratore',
'extension' => (string) Arr::get($centralino, 'interno_amministratore', '-'),
'target' => 'Utente amministratore',
'note' => 'Instradamento diretto',
],
[
'type' => 'Gruppo',
'label' => 'Gruppo giorno',
'extension' => (string) Arr::get($centralino, 'interno_gruppo_giorno', '-'),
'target' => 'Modalita giorno',
'note' => 'Routing shared',
'type' => 'Gruppo',
'label' => 'Gruppo giorno',
'extension' => (string) Arr::get($centralino, 'interno_gruppo_giorno', '-'),
'target' => 'Modalita giorno',
'note' => 'Routing shared',
],
[
'type' => 'Gruppo',
'label' => 'Gruppo notte',
'extension' => (string) Arr::get($centralino, 'interno_gruppo_notte', '-'),
'target' => 'Modalita notte',
'note' => 'Routing shared',
'type' => 'Gruppo',
'label' => 'Gruppo notte',
'extension' => (string) Arr::get($centralino, 'interno_gruppo_notte', '-'),
'target' => 'Modalita notte',
'note' => 'Routing shared',
],
];
@ -1487,12 +1489,15 @@ public function getPbxRoutingRowsProperty(): array
return $routingRows;
}
private function pbxMessagesQuery()
private function pbxRawMessagesQuery()
{
$settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []);
$channels = $this->resolvePbxChannels($settings);
$extensions = $this->resolveWatchedPbxExtensions((array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []));
return CommunicationMessage::query()
->where('channel', 'panasonic_csta')
->whereIn('channel', $channels)
->whereIn('direction', ['inbound', 'outbound'])
->where(function ($q) use ($extensions): void {
$q->where('metadata->amministratore_id', (int) $this->amministratore->id);
@ -1502,6 +1507,37 @@ private function pbxMessagesQuery()
});
}
private function pbxMessagesQuery()
{
return $this->pbxRawMessagesQuery()
->where(function ($q): void {
$q->whereNull('metadata->outcome')
->orWhere(function ($inner): void {
$inner->where('metadata->outcome', 'not like', '%miss%')
->where('metadata->outcome', 'not like', '%no_answer%');
});
})
->where(function ($q): void {
$q->whereNull('message_text')
->orWhere('message_text', 'not like', '%persa%');
});
}
/**
* @param array<string,mixed> $settings
* @return array<int,string>
*/
private function resolvePbxChannels(array $settings): array
{
$channel = mb_strtolower(trim((string) ($settings['channel'] ?? 'hybrid')));
return match ($channel) {
'panasonic', 'panasonic_csta', 'csta' => ['panasonic_csta'],
'smdr' => ['smdr'],
default => ['panasonic_csta', 'smdr'],
};
}
/**
* @return array<int,string>
*/
@ -1515,9 +1551,9 @@ private function resolveWatchedPbxExtensions(array $settings): array
(string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_gruppo_notte', ''),
];
$csvExtensions = preg_split('/\s*,\s*/', (string) ($settings['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$csvExtensions = preg_split('/\s*,\s*/', (string) ($settings['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$groupExtensions = array_map(fn($row): string => (string) ($row['extension'] ?? ''), array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row)));
$lineTargets = array_map(fn($row): string => (string) (($row['target_extension'] ?? '') !== '' ? $row['target_extension'] : ($row['route_group'] ?? '')), array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row)));
$lineTargets = array_map(fn($row): string => (string) (($row['target_extension'] ?? '') !== '' ? $row['target_extension'] : ($row['route_group'] ?? '')), array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row)));
return array_values(array_unique(array_filter(array_map(
fn($value): string => preg_replace('/\D+/', '', (string) $value) ?: '',

View File

@ -1,5 +1,4 @@
<?php
namespace App\Filament\Pages\SuperAdmin;
use App\Http\Controllers\Admin\CodeQualityController;
@ -34,6 +33,10 @@ class ControlloQualita extends Page
public string $menu = 'all';
public string $stabile = '';
public string $anno = '';
public bool $includeDataChecks = true;
/** @var array<int, array<string, mixed>> */
@ -42,14 +45,14 @@ class ControlloQualita extends Page
/** @var array<string, mixed> */
public array $summary = [
'total_scanned_files' => 0,
'total_issues' => 0,
'by_severity' => [
'total_issues' => 0,
'by_severity' => [
'critical' => 0,
'error' => 0,
'warning' => 0,
'info' => 0,
'error' => 0,
'warning' => 0,
'info' => 0,
],
'by_rule' => [],
'by_rule' => [],
];
public static function canAccess(): bool
@ -61,6 +64,8 @@ public static function canAccess(): bool
public function mount(): void
{
$this->stabile = (string) userSetting('code_quality.stabile', '');
$this->anno = (string) userSetting('code_quality.anno', '');
$this->runScan();
}
@ -69,15 +74,17 @@ public function runScan(): void
$controller = app(CodeQualityController::class);
$request = Request::create('/admin/code-quality/scan', 'POST', [
'path' => $this->path,
'severity' => $this->severity,
'module' => $this->module,
'menu' => $this->menu,
'path' => $this->path,
'severity' => $this->severity,
'module' => $this->module,
'menu' => $this->menu,
'stabile' => $this->stabile,
'anno' => $this->anno,
'include_data_checks' => $this->includeDataChecks ? 1 : 0,
]);
$response = $controller->scan($request);
$payload = $response->getData(true);
$payload = $response->getData(true);
$this->results = is_array($payload['results'] ?? null) ? $payload['results'] : [];
$this->summary = is_array($payload['summary'] ?? null) ? $payload['summary'] : $this->summary;

View File

@ -590,7 +590,7 @@ private function startUpdateJob(bool $fallback): void
$this->updateProgressMessage = $this->updateSkipBackup
? 'Preparazione aggiornamento senza backup (modalita test)...'
: 'Preparazione backup pre-update...';
$this->updateProgressStatus = 'running';
$this->updateProgressStatus = 'running';
@file_put_contents($progressPath, json_encode([
'timestamp' => now()->toIso8601String(),
@ -725,8 +725,8 @@ private function startUpdateJob(bool $fallback): void
Notification::make()
->title('Aggiornamento avviato')
->body($this->updateSkipBackup
? 'Aggiornamento avviato senza backup pre-update (modalita test). Avanzamento visibile in tempo reale.'
: 'Backup pre-update completato. Avanzamento visibile in tempo reale.')
? 'Aggiornamento avviato senza backup pre-update (modalita test). Avanzamento visibile in tempo reale.'
: 'Backup pre-update completato. Avanzamento visibile in tempo reale.')
->success()
->send();
}
@ -912,8 +912,8 @@ private function loadGitWorkspaceStatus(): void
if (is_file($summaryPath)) {
$summary = json_decode((string) @file_get_contents($summaryPath), true);
if (is_array($summary)) {
$this->lastGitDocsSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitDocsSyncAt;
$this->lastGitSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitSyncAt;
$this->lastGitDocsSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitDocsSyncAt;
$this->lastGitSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitSyncAt;
$this->lastGitSyncExitCode = isset($summary['exit_code']) && is_numeric($summary['exit_code'])
? (int) $summary['exit_code']
: $this->lastGitSyncExitCode;
@ -935,8 +935,8 @@ private function loadDevelopmentSnapshot(): void
? (string) @file_get_contents($path)
: '# Snapshot sviluppo non disponibile' . PHP_EOL . PHP_EOL . '- Rigenera la snapshot dalla pagina Supporto > Modifiche.';
$this->developmentSnapshotPath = $path;
$this->developmentSnapshotHtml = $this->renderSupportMarkdown($raw);
$this->developmentSnapshotPath = $path;
$this->developmentSnapshotHtml = $this->renderSupportMarkdown($raw);
$this->developmentSnapshotGeneratedAt = null;
if (is_file($jsonPath)) {
@ -1015,9 +1015,9 @@ private function startGitSyncJob(): void
@file_put_contents($progressPath, json_encode([
'timestamp' => now()->toIso8601String(),
'percent' => 1,
'message' => 'Preparazione sync Git da Gitea...',
'status' => 'running',
'percent' => 1,
'message' => 'Preparazione sync Git da Gitea...',
'status' => 'running',
'exit_code' => null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
@ -1067,28 +1067,34 @@ private function loadManualSections(): void
$documents = [
[
'key' => 'manuale-operativo',
'title' => 'Manuale operativo sviluppo',
'key' => 'manuale-operativo',
'title' => 'Manuale operativo sviluppo',
'description' => 'Quadro principale: come stiamo sviluppando, cosa e stato implementato e come continuare senza ripartire da zero.',
'path' => 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md',
'path' => 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md',
],
[
'key' => 'pbx-panasonic',
'title' => 'PBX Panasonic stato operativo',
'key' => 'aggiornamenti-staging-git',
'title' => 'Aggiornamenti staging via Git',
'description' => 'Flusso ufficiale Day0 -> Gitea -> staging, comando predisposto usato dalla UI e riepilogo dei fix strutturali che stiamo distribuendo.',
'path' => 'docs/support/AGGIORNAMENTI-STAGING-GIT.md',
],
[
'key' => 'pbx-panasonic',
'title' => 'PBX Panasonic stato operativo',
'description' => 'Riepilogo strutturato del lavoro fatto su CTI, CSTA, click-to-call, stato attuale e prossimi test sul centralino Panasonic.',
'path' => 'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md',
'path' => 'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md',
],
[
'key' => 'bug-risolti',
'title' => 'Bug risolti e validazioni',
'key' => 'bug-risolti',
'title' => 'Bug risolti e validazioni',
'description' => 'Registro sintetico dei problemi corretti, dello stato di verifica e dei punti ancora da testare.',
'path' => 'docs/support/BUG-RISOLTI-E-VALIDAZIONI.md',
'path' => 'docs/support/BUG-RISOLTI-E-VALIDAZIONI.md',
],
[
'key' => 'continuita-agent',
'title' => 'Continuita sviluppo e agent',
'key' => 'continuita-agent',
'title' => 'Continuita sviluppo e agent',
'description' => 'Regole pratiche per aggiornare documentazione, note commit e materiali utili al riaggancio delle prossime sessioni.',
'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md',
'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md',
],
];
@ -1101,12 +1107,12 @@ private function loadManualSections(): void
: '# Documento non trovato' . PHP_EOL . PHP_EOL . '- Path atteso: `' . $relativePath . '`';
$this->manualSections[] = [
'key' => (string) $document['key'],
'title' => (string) $document['title'],
'key' => (string) $document['key'],
'title' => (string) $document['title'],
'description' => (string) $document['description'],
'path' => $relativePath,
'exists' => $exists,
'html' => $this->renderSupportMarkdown($raw),
'path' => $relativePath,
'exists' => $exists,
'html' => $this->renderSupportMarkdown($raw),
];
}
}
@ -1115,9 +1121,9 @@ public function renderSupportMarkdown(string $markdown): string
{
$replacements = [
'[E!]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-danger-500"></span><span class="font-mono">E!</span></span>',
'[U]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-success-500"></span><span class="font-mono">U</span></span>',
'[P]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-primary-500"></span><span class="font-mono">P</span></span>',
'[E]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-warning-500"></span><span class="font-mono">E</span></span>',
'[U]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-success-500"></span><span class="font-mono">U</span></span>',
'[P]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-primary-500"></span><span class="font-mono">P</span></span>',
'[E]' => '<span class="inline-flex items-center gap-2"><span class="inline-block h-3 w-3 rounded-sm bg-warning-500"></span><span class="font-mono">E</span></span>',
];
return str_replace(

View File

@ -325,8 +325,8 @@ public function getSelectedTicketProperty(): ?Ticket
return null;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds(true);
if ($stabileIds === []) {
return null;
}
@ -341,7 +341,7 @@ public function getSelectedTicketProperty(): ?Ticket
'interventi.fornitore:id,ragione_sociale,nome,cognome',
'interventi.creatoDaUser:id,name',
])
->where('stabile_id', $stabileId)
->whereIn('stabile_id', $stabileIds)
->find($this->selectedTicketId);
}
@ -705,8 +705,8 @@ private function loadTickets(): void
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all');
if ($stabileIds === []) {
$this->tickets = collect();
return;
}
@ -714,7 +714,7 @@ private function loadTickets(): void
$query = Ticket::query()
->with(['categoriaTicket', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome', 'assegnatoAUser:id,name'])
->withCount('attachments')
->where('stabile_id', $stabileId);
->whereIn('stabile_id', $stabileIds);
if ($this->status === 'open') {
$query->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']);
@ -976,6 +976,36 @@ private function resolveFornitoriBaseQuery()
return $query;
}
/**
* @return array<int, int>
*/
private function resolveTicketScopeStabileIds(bool $includeAllAccessible = false): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$accessibleIds = StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
if ($accessibleIds === []) {
return [];
}
if ($includeAllAccessible) {
return $accessibleIds;
}
$activeId = StabileContext::resolveActiveStabileId($user);
return $activeId ? [$activeId] : [(int) $accessibleIds[0]];
}
/**
* @return array<string,mixed>
*/
@ -1349,13 +1379,13 @@ private function loadCounters(): void
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all');
if ($stabileIds === []) {
$this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0];
return;
}
$base = Ticket::query()->where('stabile_id', $stabileId);
$base = Ticket::query()->whereIn('stabile_id', $stabileIds);
$this->ticketCounters = [
'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(),
@ -1372,14 +1402,14 @@ private function aggiornaStatoTicket(int $ticketId, string $nuovoStato, bool $as
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds(true);
if ($stabileIds === []) {
return;
}
$ticket = Ticket::query()
->where('id', $ticketId)
->where('stabile_id', $stabileId)
->whereIn('stabile_id', $stabileIds)
->first();
if (! $ticket) {

View File

@ -442,7 +442,7 @@ private function searchCaller(): void
$needleText = '%' . mb_strtolower($raw) . '%';
$needle = '%' . $digits . '%';
$this->callerMatches = RubricaUniversale::query()
$matches = RubricaUniversale::query()
->where(function ($q) use ($needle, $needleText, $digits): void {
$q->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText])
@ -458,9 +458,18 @@ private function searchCaller(): void
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedCallerId ?? 0)])
->orderBy('nome')
->orderBy('cognome')
->limit(12)
->limit(50)
->get();
$selectedCallerId = (int) ($this->selectedCallerId ?? 0);
$this->callerMatches = $matches
->groupBy(fn(RubricaUniversale $match): string => $this->buildCallerIdentityKey($match))
->map(function (Collection $group) use ($selectedCallerId): RubricaUniversale {
return $this->selectCallerRepresentative($group, $selectedCallerId);
})
->take(12)
->values();
if ($this->selectedCallerId && ! $this->callerMatches->contains('id', $this->selectedCallerId)) {
$this->selectedCallerId = null;
}
@ -944,6 +953,68 @@ private function normalizePhoneDigits(string $value): string
return preg_replace('/\D+/', '', $value) ?: '';
}
private function buildCallerIdentityKey(RubricaUniversale $match): string
{
$fiscalCode = strtoupper(trim((string) ($match->codice_fiscale ?? '')));
if ($fiscalCode !== '') {
return 'cf:' . $fiscalCode;
}
$vatNumber = strtoupper(trim((string) ($match->partita_iva ?? '')));
if ($vatNumber !== '') {
return 'piva:' . $vatNumber;
}
$email = mb_strtolower(trim((string) ($match->email ?? '')));
if ($email !== '') {
return 'email:' . $email;
}
$phone = $this->normalizePhoneDigits((string) ($match->telefono_cellulare ?: $match->telefono_ufficio ?: $match->telefono_casa ?: ''));
$name = mb_strtolower(trim(preg_replace('/\s+/', ' ', (string) ($match->nome_completo ?: $match->ragione_sociale ?: ''))));
if ($phone !== '') {
return 'phone:' . $phone . '|name:' . $name;
}
if ($name !== '') {
return 'name:' . $name;
}
return 'id:' . (string) $match->id;
}
private function selectCallerRepresentative(Collection $group, int $selectedCallerId): RubricaUniversale
{
$selected = $selectedCallerId > 0
? $group->firstWhere('id', $selectedCallerId)
: null;
$representative = $selected instanceof RubricaUniversale
? $selected
: $group
->sortByDesc(fn(RubricaUniversale $match): array => [
(int) (! empty($match->amministratore_id)),
(int) (! empty($match->riferimento_stabile)),
(int) (! empty($match->riferimento_unita)),
(int) (($match->categoria ?? '') === 'condomino'),
-1 * (int) $match->id,
])
->first();
$duplicateCategories = $group
->pluck('categoria')
->filter(fn($value) => is_string($value) && trim($value) !== '')
->unique()
->values()
->all();
$representative->setAttribute('duplicate_count', $group->count());
$representative->setAttribute('duplicate_categories', $duplicateCategories);
return $representative;
}
private function applyLiveCallQueryPrefill(): void
{
$phone = $this->normalizePhoneDigits((string) request()->query('live_phone', ''));

View File

@ -4,13 +4,16 @@
use App\Models\DettaglioRipartizioneSpese;
use App\Models\GestioneContabile;
use App\Models\Incasso;
use App\Models\Persona;
use App\Models\Proprieta;
use App\Models\RataEmessaNg;
use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale;
use App\Models\UnitaImmobiliare;
use App\Models\UnitaRecapitoServizio;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Services\Comunicazioni\RecapitiServizioResolver;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use BackedEnum;
@ -20,6 +23,7 @@
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
@ -81,6 +85,8 @@ public static function canAccess(): bool
public array $nominativiStorici = [];
public array $recapitiServizio = [];
protected ?object $legacyCondominRow = null;
public array $ripartizioniPerTabella = [];
@ -179,6 +185,7 @@ public function dehydrate(): void
$this->dirittiProprieta = [];
$this->relazioniPerTipo = [];
$this->nominativiStorici = [];
$this->recapitiServizio = [];
$this->ripartizioniPerTabella = [];
$this->preventiviPerTabella = [];
$this->totaliPerGestione = [];
@ -295,6 +302,67 @@ protected function getHeaderActions(): array
])
->columns(9),
]),
Action::make('recapiti_email')
->label('Recapiti email')
->icon('heroicon-o-envelope')
->modalWidth('7xl')
->visible(fn(): bool => (bool) $this->unitaId)
->form([
Repeater::make('recapiti')
->label('Recapiti email per servizio')
->defaultItems(0)
->reorderable(true)
->schema([
Hidden::make('id'),
Select::make('tipo_servizio')
->label('Servizio')
->options(UnitaRecapitoServizio::serviceLabels())
->native(false)
->required()
->columnSpan(3),
Select::make('persona_id')
->label('Persona collegata')
->options(fn(): array => $this->getPersonaRecapitoOptions())
->searchable()
->preload()
->native(false)
->columnSpan(4),
TextInput::make('email')
->label('Email da usare')
->email()
->required()
->placeholder('nome@example.it')
->helperText('Scegli la persona collegata e usa una delle email mostrate nel relativo riepilogo.')
->columnSpan(5),
TextInput::make('etichetta')
->label('Etichetta')
->maxLength(120)
->columnSpan(4),
TextInput::make('note')
->label('Note')
->maxLength(255)
->columnSpan(4),
Toggle::make('is_default')
->label('Predefinita')
->default(true)
->columnSpan(2),
Toggle::make('attivo')
->label('Attiva')
->default(true)
->columnSpan(2),
])
->columns(12),
])
->fillForm(fn(): array => ['recapiti' => $this->getRecapitiServizioFormRows()])
->action(function (array $data): void {
$this->saveRecapitiServizioOverrides($data['recapiti'] ?? []);
$this->loadUnita();
\Filament\Notifications\Notification::make()
->title('Recapiti email aggiornati')
->success()
->send();
}),
];
}
@ -309,6 +377,91 @@ protected function getRuoliLabels(): array
];
}
private function getPersonaRecapitoOptions(): array
{
if (! $this->unita) {
return [];
}
return $this->unita->relazioniPersoneAttive
->map(fn($relazione) => $relazione->persona)
->filter(fn($persona) => $persona instanceof Persona)
->unique(fn(Persona $persona) => (int) $persona->id)
->mapWithKeys(function (Persona $persona): array {
$emails = $this->extractPersonaEmails($persona);
$label = $persona->nome_completo;
if ($emails !== []) {
$label .= ' · ' . implode(', ', $emails);
}
return [(int) $persona->id => $label];
})
->all();
}
private function getRecapitiServizioFormRows(): array
{
if (! $this->unita) {
return [];
}
return $this->unita->unitaRecapitiServizio
->sortBy(['tipo_servizio', 'ordine', 'id'])
->values()
->map(function (UnitaRecapitoServizio $row): array {
return [
'id' => (int) $row->id,
'tipo_servizio' => (string) $row->tipo_servizio,
'persona_id' => $row->persona_id ? (int) $row->persona_id : null,
'email' => (string) $row->email,
'etichetta' => (string) ($row->etichetta ?? ''),
'note' => (string) ($row->note ?? ''),
'is_default' => (bool) $row->is_default,
'attivo' => (bool) $row->attivo,
];
})
->all();
}
private function saveRecapitiServizioOverrides(array $rows): void
{
if (! $this->unita) {
return;
}
$labels = UnitaRecapitoServizio::serviceLabels();
DB::transaction(function () use ($rows, $labels): void {
UnitaRecapitoServizio::query()
->where('unita_id', (int) $this->unita->id)
->delete();
$ordine = 1;
foreach ($rows as $row) {
$tipoServizio = trim((string) ($row['tipo_servizio'] ?? ''));
$email = mb_strtolower(trim((string) ($row['email'] ?? '')));
if ($tipoServizio === '' || $email === '' || ! array_key_exists($tipoServizio, $labels)) {
continue;
}
UnitaRecapitoServizio::create([
'unita_id' => (int) $this->unita->id,
'persona_id' => isset($row['persona_id']) && is_numeric($row['persona_id']) ? (int) $row['persona_id'] : null,
'tipo_servizio' => $tipoServizio,
'email' => $email,
'etichetta' => $this->normalizeOptionalString($row['etichetta'] ?? null) ?: ($labels[$tipoServizio] ?? null),
'note' => $this->normalizeOptionalString($row['note'] ?? null),
'ordine' => $ordine++,
'is_default' => (bool) ($row['is_default'] ?? true),
'attivo' => (bool) ($row['attivo'] ?? true),
'sorgente' => 'manuale',
'last_confirmed_at' => now(),
]);
}
});
}
protected function makeSelfUrl(?int $unitaId = null): string
{
$base = static::getUrl(panel: 'admin-filament');
@ -566,6 +719,7 @@ protected function loadUnita(): void
$this->dirittiProprieta = [];
$this->relazioniPerTipo = [];
$this->nominativiStorici = [];
$this->recapitiServizio = [];
$this->ripartizioniPerTabella = [];
$this->preventiviPerTabella = [];
$this->totaliPerGestione = [];
@ -581,6 +735,8 @@ protected function loadUnita(): void
'soggetti',
'stabile',
'dettagliMillesimi.tabellaMillesimale',
'relazioniPersoneAttive.persona.emailMultiple',
'unitaRecapitiServizio.persona',
])
->where('stabile_id', $this->stabileId)
->whereKey($this->unitaId)
@ -591,6 +747,7 @@ protected function loadUnita(): void
$this->hydrateDiritti();
$this->hydrateRelazioni();
$this->hydrateNominativiStorici();
$this->hydrateRecapitiServizio();
$this->hydrateRateEmesse();
$this->hydrateEstrattoCompatto();
$this->hydrateConguagliIniziali();
@ -626,6 +783,7 @@ public function hydrateNominativiStorici(): void
$this->nominativiStorici = $rows->map(function ($row): array {
$rawInizio = method_exists($row, 'getRawOriginal') ? $row->getRawOriginal('data_inizio') : null;
$rawFine = method_exists($row, 'getRawOriginal') ? $row->getRawOriginal('data_fine') : null;
$payload = is_array($row->legacy_payload) ? $row->legacy_payload : [];
$inizioVal = $this->normalizeLegacyDateValue($rawInizio ?? $row->data_inizio ?? null);
$fineVal = $this->normalizeLegacyDateValue($rawFine ?? $row->data_fine ?? null);
@ -648,16 +806,107 @@ public function hydrateNominativiStorici(): void
$percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%';
}
$details = [];
$dirittoLabel = trim((string) ($payload['diritto_label'] ?? ''));
if ($dirittoLabel !== '') {
$details[] = 'Diritto: ' . $dirittoLabel;
}
$legacyYears = $payload['legacy_years'] ?? [];
if (is_array($legacyYears) && $legacyYears !== []) {
$details[] = 'Annualita: ' . implode(', ', array_map('strval', $legacyYears));
}
$sourceLabel = match ((string) ($row->fonte ?? '')) {
'legacy_comproprietari' => 'Legacy comproprietari',
'legacy_condomin' => 'Legacy condomin',
default => $row->fonte ?: null,
};
return [
'ruolo' => $row->ruolo ?: '—',
'ruolo' => $this->formatNominativoStoricoRuolo((string) ($row->ruolo ?? ''), (string) ($row->fonte ?? ''), $payload),
'nominativo' => $row->nominativo ?: '—',
'periodo' => $periodo,
'percentuale' => $percentuale,
'fonte' => $row->fonte ?: null,
'fonte' => $sourceLabel,
'detail' => $details === [] ? null : implode(' · ', $details),
];
})->all();
}
private function hydrateRecapitiServizio(): void
{
$this->recapitiServizio = [];
if (! $this->unita) {
return;
}
$resolver = app(RecapitiServizioResolver::class);
foreach (UnitaRecapitoServizio::serviceLabels() as $serviceType => $label) {
$rows = $resolver->resolveForUnita($this->unita, $serviceType);
$this->recapitiServizio[$serviceType] = [
'label' => $label,
'rows' => array_map(function (array $row): array {
$source = (string) ($row['source'] ?? 'persona');
return [
'email' => (string) ($row['email'] ?? ''),
'persona' => (string) ($row['persona'] ?? ''),
'label' => (string) ($row['label'] ?? ''),
'source' => $source,
'sourceLabel' => $source === 'override_unita' ? 'Override unita' : 'Anagrafica persona',
];
}, $rows),
];
}
}
private function formatNominativoStoricoRuolo(string $ruolo, string $fonte, array $payload): string
{
if ($fonte === 'legacy_comproprietari') {
$dirittoLabel = trim((string) ($payload['diritto_label'] ?? ''));
return $dirittoLabel !== '' ? $dirittoLabel : 'Comproprietario';
}
return match (strtoupper($ruolo)) {
'I' => 'Inquilino',
'C' => 'Condomino',
default => $ruolo !== '' ? $ruolo : '—',
};
}
private function extractPersonaEmails(Persona $persona): array
{
$emails = [];
$primary = mb_strtolower(trim((string) ($persona->email_principale ?? '')));
if ($primary !== '') {
$emails[] = $primary;
}
foreach ($persona->emailMultiple as $row) {
if (! $row->attiva) {
continue;
}
$email = mb_strtolower(trim((string) ($row->email ?? '')));
if ($email !== '') {
$emails[] = $email;
}
}
return array_values(array_unique($emails));
}
private function normalizeOptionalString(mixed $value): ?string
{
$string = trim((string) ($value ?? ''));
return $string !== '' ? $string : null;
}
protected function hydrateRateEmesse(): void
{
$this->rateEmessePerCategoria = [

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\Amministratore;
use App\Services\DistributionService;
use App\Services\TenantArchivePathService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -207,7 +208,7 @@ public function importAdministrator(Request $request): JsonResponse
$archive->move(dirname($tempPath), basename($tempPath));
// Estrae archivio
$extractPath = storage_path("app/amministratori/{$codiceAmministratore}");
$extractPath = app(TenantArchivePathService::class)->amministratoreAbsolutePath($codiceAmministratore);
$zip = new \ZipArchive();
if ($zip->open($tempPath) !== true) {
@ -256,7 +257,7 @@ public function activateAdministrator(Request $request): JsonResponse
Log::info("Attivazione amministratore {$codiceAmministratore}");
// Verifica che l'archivio sia stato importato
$archivePath = storage_path("app/amministratori/{$codiceAmministratore}");
$archivePath = app(TenantArchivePathService::class)->amministratoreAbsolutePath($codiceAmministratore);
if (! is_dir($archivePath)) {
throw new \Exception('Archivio amministratore non trovato. Importare prima l\'archivio.');
}
@ -271,13 +272,13 @@ public function activateAdministrator(Request $request): JsonResponse
// Crea record amministratore nel database
$amministratore = Amministratore::create([
'nome' => $metadata['amministratore']['nome'],
'cognome' => $metadata['amministratore']['cognome'],
'denominazione_studio' => $metadata['amministratore']['denominazione_studio'],
'codice_amministratore' => $codiceAmministratore,
'user_id' => 1, // TODO: gestire user associato
'database_attivo' => $metadata['amministratore']['database_name'],
'cartella_dati' => "amministratori/{$codiceAmministratore}",
'nome' => $metadata['amministratore']['nome'],
'cognome' => $metadata['amministratore']['cognome'],
'denominazione_studio' => $metadata['amministratore']['denominazione_studio'],
'codice_amministratore' => $codiceAmministratore,
'user_id' => 1, // TODO: gestire user associato
'database_attivo' => $metadata['amministratore']['database_name'],
'cartella_dati' => app(TenantArchivePathService::class)->amministratoreRelativePath($codiceAmministratore),
'stato_sincronizzazione' => 'attivo',
'attivo' => true,
]);

View File

@ -1,5 +1,4 @@
<?php
namespace App\Http\Middleware;
use Closure;
@ -15,25 +14,25 @@ public function handle(Request $request, Closure $next)
{
// Inizializza sempre le variabili per evitare errori undefined
$userPermissions = ['dashboard' => false];
$userRole = 'guest';
$userRoles = [];
$activeRole = 'guest';
$userRole = 'guest';
$userRoles = [];
$activeRole = 'guest';
if (auth()->check()) {
$user = auth()->user();
// Gestione ruoli multipli
$userRoles = $this->getUserRoles($user);
$userRoles = $this->getUserRoles($user);
$activeRole = $request->session()->get('active_role', $user->role ?? 'user');
// Se il ruolo attivo non è tra quelli disponibili, usa il primo disponibile
if (!in_array($activeRole, $userRoles)) {
if (! in_array($activeRole, $userRoles)) {
$activeRole = $userRoles[0] ?? 'user';
$request->session()->put('active_role', $activeRole);
}
$userPermissions = $this->getUserPermissions($user, $activeRole);
$userRole = $activeRole;
$userRole = $activeRole;
}
// Condividi sempre le variabili con tutte le viste
@ -53,30 +52,30 @@ private function getUserPermissions($user, $activeRole = null)
$activeRole = $activeRole ?? $user->role ?? 'user';
$permissions = [
'dashboard' => false,
'stabili' => false,
'unita' => false,
'soggetti' => false,
'contabilita' => false,
'fatture_acquisto' => false,
'fatture_emesse' => false,
'rate' => false,
'assemblee' => false,
'rubrica' => false,
'calendario' => false,
'manutentori' => false,
'amministrazione' => false,
'super_admin' => false,
'gestione_permessi' => false,
'xml_fatture' => false,
'dashboard' => false,
'stabili' => false,
'unita' => false,
'soggetti' => false,
'contabilita' => false,
'fatture_acquisto' => false,
'fatture_emesse' => false,
'rate' => false,
'assemblee' => false,
'rubrica' => false,
'calendario' => false,
'manutentori' => false,
'amministrazione' => false,
'super_admin' => false,
'gestione_permessi' => false,
'xml_fatture' => false,
// Nuovi permessi per condomini
'area_personale' => false,
'estratto_conto' => false,
'documenti_personali' => false,
'ticket_support' => false,
'area_personale' => false,
'estratto_conto' => false,
'documenti_personali' => false,
'ticket_support' => false,
'assemblee_partecipazione' => false,
'comunicazioni' => false,
'deleghe' => false,
'comunicazioni' => false,
'deleghe' => false,
];
// Determina i permessi in base al ruolo attivo
@ -86,93 +85,93 @@ private function getUserPermissions($user, $activeRole = null)
case 'amministratore':
$permissions = array_merge($permissions, [
'dashboard' => true,
'stabili' => true,
'unita' => true,
'soggetti' => true,
'contabilita' => true,
'fatture_acquisto' => true,
'fatture_emesse' => true,
'rate' => true,
'assemblee' => true,
'rubrica' => true,
'calendario' => true,
'amministrazione' => true,
'dashboard' => true,
'stabili' => true,
'unita' => true,
'soggetti' => true,
'contabilita' => true,
'fatture_acquisto' => true,
'fatture_emesse' => true,
'rate' => true,
'assemblee' => true,
'rubrica' => true,
'calendario' => true,
'amministrazione' => true,
'gestione_permessi' => true,
'ticket_support' => true,
'comunicazioni' => true,
'ticket_support' => true,
'comunicazioni' => true,
]);
break;
case 'contabile':
$permissions = array_merge($permissions, [
'dashboard' => true,
'contabilita' => true,
'dashboard' => true,
'contabilita' => true,
'fatture_acquisto' => true,
'soggetti' => true, // Solo per consultazione
'soggetti' => true, // Solo per consultazione
]);
break;
case 'fatture_acquisto':
$permissions = array_merge($permissions, [
'dashboard' => true,
'dashboard' => true,
'fatture_acquisto' => true,
'soggetti' => true, // Solo fornitori
'soggetti' => true, // Solo fornitori
]);
break;
case 'fatture_emesse':
$permissions = array_merge($permissions, [
'dashboard' => true,
'dashboard' => true,
'fatture_emesse' => true,
'soggetti' => true, // Solo clienti
'xml_fatture' => true,
'soggetti' => true, // Solo clienti
'xml_fatture' => true,
]);
break;
case 'rate_manager':
$permissions = array_merge($permissions, [
'dashboard' => true,
'rate' => true,
'soggetti' => true, // Solo condomini
'unita' => true, // Solo consultazione
'rate' => true,
'soggetti' => true, // Solo condomini
'unita' => true, // Solo consultazione
]);
break;
case 'assemblee_manager':
$permissions = array_merge($permissions, [
'dashboard' => true,
'assemblee' => true,
'rubrica' => true,
'calendario' => true,
'soggetti' => true, // Solo condomini
'dashboard' => true,
'assemblee' => true,
'rubrica' => true,
'calendario' => true,
'soggetti' => true, // Solo condomini
'comunicazioni' => true,
]);
break;
case 'manutentore':
$permissions = array_merge($permissions, [
'dashboard' => true,
'manutentori' => true,
'xml_fatture' => true,
'dashboard' => true,
'manutentori' => true,
'xml_fatture' => true,
'ticket_support' => true,
]);
// Aggiungi permessi specifici per condomini assegnati
if (isset($user->assigned_stabili)) {
$permissions['stabili'] = 'limited'; // Solo stabili assegnati
$permissions['unita'] = 'limited'; // Solo unità degli stabili assegnati
$permissions['unita'] = 'limited'; // Solo unità degli stabili assegnati
}
break;
case 'collaboratore':
$permissions['dashboard'] = true;
$permissions['dashboard'] = true;
$permissions['ticket_support'] = true;
// I permessi specifici vengono gestiti tramite la tabella user_permissions
if ($user->custom_permissions) {
$customPermissions = json_decode($user->custom_permissions, true);
$permissions = array_merge($permissions, $customPermissions);
$permissions = array_merge($permissions, $customPermissions);
}
break;
@ -181,29 +180,29 @@ private function getUserPermissions($user, $activeRole = null)
case 'proprietario':
case 'inquilino':
$permissions = array_merge($permissions, [
'dashboard' => true,
'area_personale' => true,
'estratto_conto' => true,
'documenti_personali' => true,
'ticket_support' => true,
'dashboard' => true,
'area_personale' => true,
'estratto_conto' => true,
'documenti_personali' => true,
'ticket_support' => true,
'assemblee_partecipazione' => true,
'comunicazioni' => 'read_only',
'comunicazioni' => 'read_only',
]);
// Permessi aggiuntivi per proprietari
if ($activeRole === 'proprietario') {
$permissions['deleghe'] = true;
$permissions['unita'] = 'own_only'; // Solo le proprie unità
$permissions['unita'] = 'own_only'; // Solo le proprie unità
}
break;
case 'delegato':
$permissions = array_merge($permissions, [
'dashboard' => true,
'area_personale' => true,
'estratto_conto' => 'delegated', // Solo per gli account delegati
'dashboard' => true,
'area_personale' => true,
'estratto_conto' => 'delegated', // Solo per gli account delegati
'assemblee_partecipazione' => true,
'ticket_support' => true,
'ticket_support' => true,
]);
break;
@ -258,8 +257,8 @@ private function isCondomino($user)
{
// Verifica se l'utente ha unità immobiliari associate
return $user->unita_immobiliari()->exists()
|| $user->contratti_proprietario()->exists()
|| $user->contratti_inquilino()->exists();
|| $user->contratti_proprietario()->exists()
|| $user->contratti_inquilino()->exists();
}
/**
@ -283,7 +282,7 @@ private function isInquilino($user)
*/
private function hasDeleghe($user)
{
// Implementare logica per verificare deleghe
// Implementare logica per verificare deleghe
return false; // TODO: implementare quando avremo la tabella deleghe
}
}

View File

@ -1,6 +1,7 @@
<?php
namespace App\Models;
use App\Services\TenantArchivePathService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -176,7 +177,7 @@ public function provisionArchiveIfMissing(): void
*/
public function getFolderPath(): string
{
return "amministratori/{$this->codice_amministratore}";
return app(TenantArchivePathService::class)->amministratoreRelativePath($this);
}
/**
@ -212,7 +213,7 @@ public function getDatabaseName(): string
*/
public function getArchivePath(): string
{
return storage_path("app/amministratori/{$this->codice_amministratore}");
return app(TenantArchivePathService::class)->amministratoreAbsolutePath($this);
}
/**

View File

@ -16,6 +16,7 @@ class PersonaUnitaRelazione extends Model
'persona_id',
'unita_id',
'tipo_relazione',
'ruolo_rate',
'quota_relazione',
'data_inizio',
'data_fine',
@ -30,6 +31,7 @@ class PersonaUnitaRelazione extends Model
'data_inizio' => 'date',
'data_fine' => 'date',
'quota_relazione' => 'decimal:2',
'ruolo_rate' => 'string',
'attivo' => 'boolean',
'riceve_comunicazioni' => 'boolean',
'riceve_convocazioni' => 'boolean',
@ -126,4 +128,14 @@ public function isAttiva()
return $this->attivo &&
($this->data_fine === null || $this->data_fine >= now()->toDateString());
}
public static function deriveRuoloRate(?string $tipoRelazione): ?string
{
$tipoRelazione = strtolower(trim((string) ($tipoRelazione ?? '')));
if ($tipoRelazione === '') {
return null;
}
return in_array($tipoRelazione, ['inquilino', 'locatario', 'conduttore'], true) ? 'I' : 'C';
}
}

View File

@ -12,6 +12,8 @@ class RubricaUniversale extends Model
protected $table = 'rubrica_universale';
protected $fillable = [
'amministratore_id',
'codice_univoco',
'titolo_id',
'nome',
'cognome',

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantArchiveRegistry extends Model
{
use HasFactory;
protected $table = 'tenant_archive_registries';
protected $fillable = [
'archive_type',
'archive_code',
'amministratore_id',
'stabile_id',
'owner_amministratore_id',
'owner_amministratore_code',
'database_name',
'database_strategy',
'storage_disk',
'storage_relative_path',
'status',
'last_aligned_at',
'meta',
];
protected $casts = [
'last_aligned_at' => 'datetime',
'meta' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function amministratore(): BelongsTo
{
return $this->belongsTo(Amministratore::class, 'amministratore_id', 'id');
}
public function stabile(): BelongsTo
{
return $this->belongsTo(Stabile::class, 'stabile_id', 'id');
}
public function ownerAmministratore(): BelongsTo
{
return $this->belongsTo(Amministratore::class, 'owner_amministratore_id', 'id');
}
}

View File

@ -117,5 +117,6 @@ public function panel(Panel $panel): Panel
->authMiddleware([
Authenticate::class,
]);
}
}
}

View File

@ -1,5 +1,4 @@
<?php
namespace App\Services\Cti;
use App\Filament\Pages\Supporto\TicketMobile;
@ -24,6 +23,17 @@ public function getForUser(User $user): ?array
->where('direction', 'inbound')
->whereNotNull('received_at')
->where('received_at', '>=', now()->subMinutes(8))
->where(function ($query): void {
$query->whereNull('metadata->outcome')
->orWhere(function ($inner): void {
$inner->where('metadata->outcome', 'not like', '%miss%')
->where('metadata->outcome', 'not like', '%no_answer%');
});
})
->where(function ($query): void {
$query->whereNull('message_text')
->orWhere('message_text', 'not like', '%persa%');
})
->when($popupRestricted, function ($query) use ($watchedExtensions): void {
$query->whereIn('target_extension', $watchedExtensions);
})
@ -47,27 +57,27 @@ public function getForUser(User $user): ?array
$soggetto = $this->matchSoggetto($rubrica, $phone);
return [
'message_id' => (int) $latest->id,
'phone' => $phone,
'received_at' => optional($latest->received_at)->format('d/m/Y H:i:s'),
'line' => (string) ($latest->message_text ?? ''),
'target_extension' => (string) ($latest->target_extension ?? ''),
'rubrica_id' => (int) ($rubrica?->id ?? 0),
'rubrica_nome' => (string) ($rubrica?->nome_completo ?: $rubrica?->ragione_sociale ?: ''),
'soggetto_id' => (int) ($soggetto?->id ?? 0),
'can_click_to_call' => (bool) ($user->pbx_click_to_call_enabled ?? false) && $userExtension !== '',
'ticket_mobile_url' => TicketMobile::getUrl([
'message_id' => (int) $latest->id,
'phone' => $phone,
'received_at' => optional($latest->received_at)->format('d/m/Y H:i:s'),
'line' => (string) ($latest->message_text ?? ''),
'target_extension' => (string) ($latest->target_extension ?? ''),
'rubrica_id' => (int) ($rubrica?->id ?? 0),
'rubrica_nome' => (string) ($rubrica?->nome_completo ?: $rubrica?->ragione_sociale ?: ''),
'soggetto_id' => (int) ($soggetto?->id ?? 0),
'can_click_to_call' => (bool) ($user->pbx_click_to_call_enabled ?? false) && $userExtension !== '',
'ticket_mobile_url' => TicketMobile::getUrl([
'live_phone' => $phone,
'live_message_id' => (int) $latest->id,
'live_note' => (string) ($latest->message_text ?? ''),
], panel: 'admin-filament'),
'rubrica_url' => (int) ($rubrica?->id ?? 0) > 0
? \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => (int) $rubrica->id], panel: 'admin-filament')
'rubrica_url' => (int) ($rubrica?->id ?? 0) > 0
? \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => (int) $rubrica->id], panel : 'admin-filament')
: null,
'estratto_conto_url' => (int) ($soggetto?->id ?? 0) > 0
? \App\Filament\Pages\Contabilita\EstrattoContoSoggetto::getUrl(['record' => (int) $soggetto->id], panel: 'admin-filament')
'estratto_conto_url' => (int) ($soggetto?->id ?? 0) > 0
? \App\Filament\Pages\Contabilita\EstrattoContoSoggetto::getUrl(['record' => (int) $soggetto->id], panel : 'admin-filament')
: null,
'post_it_redirect_url' => TicketMobile::getUrl([
'post_it_redirect_url' => TicketMobile::getUrl([
'live_phone' => $phone,
'live_message_id' => (int) $latest->id,
'live_note' => (string) ($latest->message_text ?? ''),

View File

@ -1,23 +1,25 @@
<?php
namespace App\Services;
use App\Models\Amministratore;
use App\Models\Stabile;
use Exception;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Exception;
class DatabaseManagerService
{
public function __construct(private TenantArchivePathService $tenantArchivePath)
{
}
/**
* Crea database dedicato per amministratore
*/
public function createAmministratoreDatabase(Amministratore $amministratore): array
{
$code = $amministratore->codice_amministratore ?? $amministratore->codice_univoco;
$code = $amministratore->codice_amministratore ?? $amministratore->codice_univoco;
$dbName = "netgescon_admin_{$code}";
try {
@ -37,14 +39,14 @@ public function createAmministratoreDatabase(Amministratore $amministratore): ar
$this->createDatabaseConfig($amministratore, $dbName);
return [
'success' => true,
'success' => true,
'database_name' => $dbName,
'message' => "Database amministratore {$code} creato"
'message' => "Database amministratore {$code} creato",
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -54,7 +56,7 @@ public function createAmministratoreDatabase(Amministratore $amministratore): ar
*/
public function createStabileDatabase(Stabile $stabile): array
{
$dbName = "netgescon_stabile_{$stabile->codice_stabile}";
$dbName = "netgescon_stabile_{$stabile->codice_stabile}";
$adminCode = $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco;
try {
@ -71,14 +73,14 @@ public function createStabileDatabase(Stabile $stabile): array
$this->createStabileFolderStructure($stabile);
return [
'success' => true,
'success' => true,
'database_name' => $dbName,
'message' => "Database stabile {$stabile->codice_stabile} creato"
'message' => "Database stabile {$stabile->codice_stabile} creato",
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -100,17 +102,17 @@ private function createDatabaseUser(string $dbName, string $adminCode): void
DB::statement("FLUSH PRIVILEGES");
// Salva credenziali in file sicuro
$credentialsDir = storage_path("app/amministratori/{$adminCode}/database");
if (!is_dir($credentialsDir)) {
$credentialsDir = $this->tenantArchivePath->amministratoreAbsolutePath($adminCode, 'database');
if (! is_dir($credentialsDir)) {
mkdir($credentialsDir, 0755, true);
}
$credentialsPath = $credentialsDir . "/credentials.json";
$credentials = [
'database' => $dbName,
'username' => $username,
'password' => $password,
'host' => 'localhost',
'created_at' => date('Y-m-d H:i:s')
$credentials = [
'database' => $dbName,
'username' => $username,
'password' => $password,
'host' => 'localhost',
'created_at' => date('Y-m-d H:i:s'),
];
file_put_contents($credentialsPath, json_encode($credentials, JSON_PRETTY_PRINT));
@ -199,7 +201,7 @@ private function populateAdminDatabase(string $dbName): void
KEY log_operazioni_user_idx (user_id),
KEY log_operazioni_data_idx (created_at),
KEY log_operazioni_operazione_idx (operazione)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($tables as $sql) {
@ -361,7 +363,7 @@ private function populateStabileDatabase(string $dbName, Stabile $stabile): void
PRIMARY KEY (id),
KEY ripartizioni_movimento_idx (movimento_id),
KEY ripartizioni_unita_idx (unita_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($tables as $sql) {
@ -370,11 +372,11 @@ private function populateStabileDatabase(string $dbName, Stabile $stabile): void
// Inserisci dato stabile di riferimento
DB::table('stabili_ref')->insert([
'codice_stabile' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'codice_stabile' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'amministratore_codice' => $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco,
'created_at' => now(),
'updated_at' => now()
'created_at' => now(),
'updated_at' => now(),
]);
});
}
@ -384,9 +386,8 @@ private function populateStabileDatabase(string $dbName, Stabile $stabile): void
*/
private function createStabileFolderStructure(Stabile $stabile): void
{
$adminCode = $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco;
$stabileCode = $stabile->codice_stabile;
$basePath = "amministratori/{$adminCode}/stabili/{$stabileCode}";
$basePath = $this->tenantArchivePath->stabileRelativePath($stabile);
$stabileFolders = [
// Documenti stabile
@ -434,26 +435,26 @@ private function createStabileFolderStructure(Stabile $stabile): void
// File configurazione stabile
$config = [
'stabile' => [
'codice' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'amministratore' => $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco,
'stabile' => [
'codice' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'amministratore' => $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco,
'database_dedicato' => $stabile->database_dedicato,
'created_at' => $stabile->created_at->toISOString(),
'created_at' => $stabile->created_at->toISOString(),
],
'settings' => [
'backup_automatico' => true,
'backup_frequency' => 'daily',
'retention_days' => 90,
'backup_frequency' => 'daily',
'retention_days' => 90,
'report_automatici' => true,
],
'paths' => [
'documenti' => 'documenti',
'contabilita' => 'contabilita',
'paths' => [
'documenti' => 'documenti',
'contabilita' => 'contabilita',
'database_backups' => 'database/backups',
'reports' => 'reports',
'archivio' => 'archivio',
]
'reports' => 'reports',
'archivio' => 'archivio',
],
];
Storage::disk('local')->put(
@ -467,9 +468,9 @@ private function createStabileFolderStructure(Stabile $stabile): void
*/
private function createDatabaseConfig(Amministratore $amministratore, string $dbName): void
{
$code = $amministratore->codice_amministratore ?? $amministratore->codice_univoco;
$configDir = storage_path("app/amministratori/{$code}/database");
if (!is_dir($configDir)) {
$code = $amministratore->codice_amministratore ?? $amministratore->codice_univoco;
$configDir = $this->tenantArchivePath->amministratoreAbsolutePath($amministratore, 'database');
if (! is_dir($configDir)) {
mkdir($configDir, 0755, true);
}
$configPath = $configDir . "/config.php";
@ -536,21 +537,21 @@ public function getAmministratoreConnection(Amministratore $amministratore): \Il
$dbName = $amministratore->getDatabaseName();
// Crea configurazione dinamica se non esiste
if (!config("database.connections.admin_{$amministratore->codice_univoco}")) {
if (! config("database.connections.admin_{$amministratore->codice_univoco}")) {
config([
"database.connections.admin_{$amministratore->codice_univoco}" => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $dbName,
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $dbName,
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]
'prefix' => '',
'strict' => true,
'engine' => null,
],
]);
}
@ -564,26 +565,26 @@ public function getStabileConnection(Stabile $stabile): \Illuminate\Database\Con
{
$dbName = $stabile->database_dedicato;
if (!$dbName) {
if (! $dbName) {
throw new Exception("Stabile {$stabile->codice_stabile} non ha database dedicato");
}
// Crea configurazione dinamica se non esiste
if (!config("database.connections.stabile_{$stabile->codice_stabile}")) {
if (! config("database.connections.stabile_{$stabile->codice_stabile}")) {
config([
"database.connections.stabile_{$stabile->codice_stabile}" => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $dbName,
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => $dbName,
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]
'prefix' => '',
'strict' => true,
'engine' => null,
],
]);
}
@ -603,16 +604,15 @@ public function backupAmministratoreDatabase(Amministratore $amministratore): ar
*/
public function backupStabileDatabase(Stabile $stabile): array
{
if (!$stabile->database_dedicato) {
if (! $stabile->database_dedicato) {
return ['success' => false, 'error' => 'Stabile non ha database dedicato'];
}
try {
// Allinea ai nuovi percorsi per-amministratore basati su codice_amministratore
$adminCode = $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco;
$backupPath = storage_path("app/amministratori/{$adminCode}/stabili/{$stabile->codice_stabile}/database/backups");
$backupPath = $this->tenantArchivePath->stabileAbsolutePath($stabile, 'database/backups');
if (!is_dir($backupPath)) {
if (! is_dir($backupPath)) {
mkdir($backupPath, 0755, true);
}
@ -635,16 +635,16 @@ public function backupStabileDatabase(Stabile $stabile): array
}
return [
'success' => true,
'filename' => $filename,
'path' => $fullPath,
'size' => filesize($fullPath),
'created_at' => date('Y-m-d H:i:s')
'success' => true,
'filename' => $filename,
'path' => $fullPath,
'size' => filesize($fullPath),
'created_at' => date('Y-m-d H:i:s'),
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}

View File

@ -1,11 +1,10 @@
<?php
namespace App\Services\Import;
use App\Models\GestioneContabile;
use App\Models\IncassoEstrattoConto;
use App\Models\OperazioneContabile;
use App\Models\RegistroRitenuteAcconto;
use App\Models\IncassoEstrattoConto;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -23,7 +22,7 @@ class MultiYearGesconImportService
{
private string $tenantId;
private array $mdbConnections = [];
private array $gestioni = [];
private array $gestioni = [];
public function __construct(string $tenantId)
{
@ -53,10 +52,10 @@ public function importFromGesconDirectory(string $baseDir): array
$this->createBilanciApertura();
return [
'success' => true,
'success' => true,
'gestioni_create' => count($this->gestioni),
'years_imported' => array_keys($results),
'details' => $results
'years_imported' => array_keys($results),
'details' => $results,
];
}
@ -74,8 +73,8 @@ private function scanGesconYears(string $baseDir): array
$year = basename(rtrim($dir, '/'));
if (is_numeric($year) && strlen($year) == 4) {
$paths = [
'singolo_anno' => $dir . 'singolo_anno.mdb',
'generale_stabile' => $dir . 'generale_stabile.mdb'
'singolo_anno' => $dir . 'singolo_anno.mdb',
'generale_stabile' => $dir . 'generale_stabile.mdb',
];
// Verifica esistenza file
@ -102,7 +101,7 @@ private function scanGestioniFromMdb(string $mdbPath, int $year): array
'denominazione' => "Gestione Ordinaria {$year}",
'data_inizio' => "{$year}-01-01",
'data_fine' => "{$year}-12-31",
'protocollo_prefix' => "O{$year}"
'protocollo_prefix' => "O{$year}",
];
// Gestione Riscaldamento (se presente)
@ -111,14 +110,14 @@ private function scanGestioniFromMdb(string $mdbPath, int $year): array
WHERE gestione = 'R' OR gestione = 'r'
");
if (!empty($opRiscaldamento) && ($opRiscaldamento[0]['cnt'] ?? 0) > 0) {
if (! empty($opRiscaldamento) && ($opRiscaldamento[0]['cnt'] ?? 0) > 0) {
$gestioni[] = [
'anno_gestione' => $year,
'tipo_gestione' => 'riscaldamento',
'denominazione' => "Gestione Riscaldamento {$year}",
'data_inizio' => "{$year}-01-01",
'data_fine' => "{$year}-12-31",
'protocollo_prefix' => "R{$year}"
'protocollo_prefix' => "R{$year}",
];
}
@ -133,15 +132,15 @@ private function scanGestioniFromMdb(string $mdbPath, int $year): array
");
foreach ($straordinarie as $stra) {
$numero = (int)$stra['numero'];
$numero = (int) $stra['numero'];
$gestioni[] = [
'anno_gestione' => $year,
'tipo_gestione' => 'straordinaria',
'anno_gestione' => $year,
'tipo_gestione' => 'straordinaria',
'numero_straordinaria' => $numero,
'denominazione' => "Gestione Straordinaria {$year} - {$numero}",
'denominazione' => "Gestione Straordinaria {$year} - {$numero}",
'data_inizio' => "{$year}-01-01",
'data_fine' => "{$year}-12-31",
'protocollo_prefix' => "S{$year}-{$numero}"
'protocollo_prefix' => "S{$year}-{$numero}",
];
}
@ -154,16 +153,16 @@ private function scanGestioniFromMdb(string $mdbPath, int $year): array
private function createOrUpdateGestione(array $gestioneData): GestioneContabile
{
$gestione = GestioneContabile::updateOrCreate([
'tenant_id' => $this->tenantId,
'anno_gestione' => $gestioneData['anno_gestione'],
'tipo_gestione' => $gestioneData['tipo_gestione'],
'numero_straordinaria' => $gestioneData['numero_straordinaria'] ?? null
'tenant_id' => $this->tenantId,
'anno_gestione' => $gestioneData['anno_gestione'],
'tipo_gestione' => $gestioneData['tipo_gestione'],
'numero_straordinaria' => $gestioneData['numero_straordinaria'] ?? null,
], [
'denominazione' => $gestioneData['denominazione'],
'data_inizio' => $gestioneData['data_inizio'],
'data_fine' => $gestioneData['data_fine'],
'stato' => 'aperta',
'protocollo_prefix' => $gestioneData['protocollo_prefix']
'denominazione' => $gestioneData['denominazione'],
'data_inizio' => $gestioneData['data_inizio'],
'data_fine' => $gestioneData['data_fine'],
'stato' => 'aperta',
'protocollo_prefix' => $gestioneData['protocollo_prefix'],
]);
// Cache per lookup veloce
@ -202,25 +201,25 @@ private function importOperazioniForGestione(GestioneContabile $gestione, string
$imported = 0;
foreach ($operazioni as $op) {
try {
$protocollo = $this->generateProtocollo($gestione->id, $op);
$protocollo = $this->generateProtocollo($gestione->id, $op);
$voceSnapshot = $this->getVoceSpesaSnapshot($mdbPath, $op['cod_voce'] ?? '');
OperazioneContabile::create([
'tenant_id' => $this->tenantId,
'gestione_id' => $gestione->id,
'numero_operazione' => $op['n_operazione'],
'data_operazione' => $this->parseGesconDate($op['data_oper']),
'importo' => (float)($op['euro'] ?? 0),
'descrizione' => $op['descrizione'] ?? '',
'cod_voce_gescon' => $op['cod_voce'] ?? '',
'tenant_id' => $this->tenantId,
'gestione_id' => $gestione->id,
'numero_operazione' => $op['n_operazione'],
'data_operazione' => $this->parseGesconDate($op['data_oper']),
'importo' => (float) ($op['euro'] ?? 0),
'descrizione' => $op['descrizione'] ?? '',
'cod_voce_gescon' => $op['cod_voce'] ?? '',
'cod_beneficiario_gescon' => $op['cod_ben'] ?? '',
'compet' => $op['compet_clean'],
'natura2' => $op['natura2_clean'],
'n_stra' => (int)$op['n_stra_clean'],
'protocollo_numero' => $protocollo['numero'],
'protocollo_completo' => $protocollo['completo'],
'voce_spesa_snapshot' => json_encode($voceSnapshot),
'metadati_gescon' => json_encode($op)
'compet' => $op['compet_clean'],
'natura2' => $op['natura2_clean'],
'n_stra' => (int) $op['n_stra_clean'],
'protocollo_numero' => $protocollo['numero'],
'protocollo_completo' => $protocollo['completo'],
'voce_spesa_snapshot' => json_encode($voceSnapshot),
'metadati_gescon' => json_encode($op),
]);
$imported++;
@ -228,8 +227,8 @@ private function importOperazioniForGestione(GestioneContabile $gestione, string
} catch (\Exception $e) {
Log::error("Error importing operazione for gestione", [
'gestione_id' => $gestione->id,
'operazione' => $op,
'error' => $e->getMessage()
'operazione' => $op,
'error' => $e->getMessage(),
]);
}
}
@ -258,8 +257,8 @@ private function importIncassiForGestione(GestioneContabile $gestione, string $m
} catch (\Exception $e) {
Log::error("Error importing incasso", [
'gestione_id' => $gestione->id,
'incasso' => $inc,
'error' => $e->getMessage()
'incasso' => $inc,
'error' => $e->getMessage(),
]);
}
}
@ -285,16 +284,16 @@ private function importRitenuteFOrGestione(GestioneContabile $gestione, string $
foreach ($ritenute as $ra) {
try {
RegistroRitenuteAcconto::create([
'tenant_id' => $this->tenantId,
'gestione_id' => $gestione->id,
'tenant_id' => $this->tenantId,
'gestione_id' => $gestione->id,
'numero_progressivo' => $ra['Rif_RDA'],
'data_competenza' => $this->parseGesconDate($ra['data_oper']),
'imponibile' => (float)($ra['imponibile_operazione'] ?? 0),
'aliquota_ritenuta' => (float)($ra['aliquota'] ?? 20),
'importo_ritenuta' => (float)($ra['ritenuta'] ?? 0),
'rif_rda' => $ra['Rif_RDA'],
'stato_versamento' => 'da_versare',
'metadati_gescon' => json_encode($ra)
'data_competenza' => $this->parseGesconDate($ra['data_oper']),
'imponibile' => (float) ($ra['imponibile_operazione'] ?? 0),
'aliquota_ritenuta' => (float) ($ra['aliquota'] ?? 20),
'importo_ritenuta' => (float) ($ra['ritenuta'] ?? 0),
'rif_rda' => $ra['Rif_RDA'],
'stato_versamento' => 'da_versare',
'metadati_gescon' => json_encode($ra),
]);
$imported++;
@ -302,8 +301,8 @@ private function importRitenuteFOrGestione(GestioneContabile $gestione, string $
} catch (\Exception $e) {
Log::error("Error importing ritenuta", [
'gestione_id' => $gestione->id,
'ritenuta' => $ra,
'error' => $e->getMessage()
'ritenuta' => $ra,
'error' => $e->getMessage(),
]);
}
}
@ -325,15 +324,15 @@ private function importIncassiEstrattoContoForGestione(GestioneContabile $gestio
foreach ($incassiEc as $inc) {
try {
IncassoEstrattoConto::create([
'tenant_id' => $this->tenantId,
'gestione_id' => $gestione->id,
'id_gescon' => $inc['id'] ?? null,
'tenant_id' => $this->tenantId,
'gestione_id' => $gestione->id,
'id_gescon' => $inc['id'] ?? null,
'data_operazione' => $this->parseGesconDate($inc['data_oper']),
'data_valuta' => $this->parseGesconDate($inc['data_valuta']),
'importo' => (float)($inc['importo'] ?? 0),
'descrizione' => $inc['descrizione'] ?? '',
'stato' => 'da_riconciliare',
'metadati_gescon' => json_encode($inc)
'data_valuta' => $this->parseGesconDate($inc['data_valuta']),
'importo' => (float) ($inc['importo'] ?? 0),
'descrizione' => $inc['descrizione'] ?? '',
'stato' => 'da_riconciliare',
'metadati_gescon' => json_encode($inc),
]);
$imported++;
@ -341,8 +340,8 @@ private function importIncassiEstrattoContoForGestione(GestioneContabile $gestio
} catch (\Exception $e) {
Log::error("Error importing incasso EC", [
'gestione_id' => $gestione->id,
'incasso' => $inc,
'error' => $e->getMessage()
'incasso' => $inc,
'error' => $e->getMessage(),
]);
}
}
@ -400,11 +399,11 @@ private function buildGestioneJoinClause(GestioneContabile $gestione, string $al
private function importGestioneYear(string $year, array $paths): array
{
$results = [
'operazioni' => 0,
'incassi' => 0,
'ritenute' => 0,
'operazioni' => 0,
'incassi' => 0,
'ritenute' => 0,
'rate_emesse' => 0,
'incassi_ec' => 0
'incassi_ec' => 0,
];
// 1. Import operazioni contabili
@ -456,30 +455,30 @@ private function importOperazioni(string $year, string $mdbPath): int
$voceSnapshot = $this->getVoceSpesaSnapshot($mdbPath, $op['cod_voce'] ?? '');
OperazioneContabile::create([
'tenant_id' => $this->tenantId,
'gestione_id' => $gestioneId,
'numero_operazione' => $op['n_operazione'],
'data_operazione' => $this->parseGesconDate($op['data_oper']),
'importo' => (float)($op['euro'] ?? 0),
'descrizione' => $op['descrizione'] ?? '',
'cod_voce_gescon' => $op['cod_voce'] ?? '',
'tenant_id' => $this->tenantId,
'gestione_id' => $gestioneId,
'numero_operazione' => $op['n_operazione'],
'data_operazione' => $this->parseGesconDate($op['data_oper']),
'importo' => (float) ($op['euro'] ?? 0),
'descrizione' => $op['descrizione'] ?? '',
'cod_voce_gescon' => $op['cod_voce'] ?? '',
'cod_beneficiario_gescon' => $op['cod_ben'] ?? '',
'compet' => $op['compet_clean'],
'natura2' => $op['natura2_clean'],
'n_stra' => (int)$op['n_stra_clean'],
'protocollo_numero' => $protocollo['numero'],
'protocollo_completo' => $protocollo['completo'],
'voce_spesa_snapshot' => json_encode($voceSnapshot),
'metadati_gescon' => json_encode($op)
'compet' => $op['compet_clean'],
'natura2' => $op['natura2_clean'],
'n_stra' => (int) $op['n_stra_clean'],
'protocollo_numero' => $protocollo['numero'],
'protocollo_completo' => $protocollo['completo'],
'voce_spesa_snapshot' => json_encode($voceSnapshot),
'metadati_gescon' => json_encode($op),
]);
$imported++;
} catch (\Exception $e) {
Log::error("Error importing operazione", [
'year' => $year,
'year' => $year,
'operazione' => $op,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
}
}
@ -508,25 +507,25 @@ private function importRitenuteAcconto(string $year, string $mdbPath): int
$gestioneId = $this->gestioni[$year]['ordinaria']->id;
RegistroRitenuteAcconto::create([
'tenant_id' => $this->tenantId,
'gestione_id' => $gestioneId,
'tenant_id' => $this->tenantId,
'gestione_id' => $gestioneId,
'numero_progressivo' => $ra['Rif_RDA'],
'data_competenza' => $this->parseGesconDate($ra['data_oper']),
'imponibile' => (float)($ra['imponibile_operazione'] ?? 0),
'aliquota_ritenuta' => (float)($ra['aliquota'] ?? 20),
'importo_ritenuta' => (float)($ra['ritenuta'] ?? 0),
'rif_rda' => $ra['Rif_RDA'],
'stato_versamento' => 'da_versare', // Default
'metadati_gescon' => json_encode($ra)
'data_competenza' => $this->parseGesconDate($ra['data_oper']),
'imponibile' => (float) ($ra['imponibile_operazione'] ?? 0),
'aliquota_ritenuta' => (float) ($ra['aliquota'] ?? 20),
'importo_ritenuta' => (float) ($ra['ritenuta'] ?? 0),
'rif_rda' => $ra['Rif_RDA'],
'stato_versamento' => 'da_versare', // Default
'metadati_gescon' => json_encode($ra),
]);
$imported++;
} catch (\Exception $e) {
Log::error("Error importing ritenuta", [
'year' => $year,
'year' => $year,
'ritenuta' => $ra,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
}
}
@ -551,24 +550,24 @@ private function importIncassiEstrattoConto(string $year, string $mdbPath): int
$gestioneId = $this->gestioni[$year]['ordinaria']->id;
IncassoEstrattoConto::create([
'tenant_id' => $this->tenantId,
'gestione_id' => $gestioneId,
'id_gescon' => $inc['id'] ?? null,
'tenant_id' => $this->tenantId,
'gestione_id' => $gestioneId,
'id_gescon' => $inc['id'] ?? null,
'data_operazione' => $this->parseGesconDate($inc['data_oper']),
'data_valuta' => $this->parseGesconDate($inc['data_valuta']),
'importo' => (float)($inc['importo'] ?? 0),
'descrizione' => $inc['descrizione'] ?? '',
'stato' => 'da_riconciliare',
'metadati_gescon' => json_encode($inc)
'data_valuta' => $this->parseGesconDate($inc['data_valuta']),
'importo' => (float) ($inc['importo'] ?? 0),
'descrizione' => $inc['descrizione'] ?? '',
'stato' => 'da_riconciliare',
'metadati_gescon' => json_encode($inc),
]);
$imported++;
} catch (\Exception $e) {
Log::error("Error importing incasso EC", [
'year' => $year,
'year' => $year,
'incasso' => $inc,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
}
}
@ -608,7 +607,7 @@ private function importRateEmesse(string $year, string $mdbPath): int
private function determineGestioneId(string $year, array $operazione): int
{
$gestione = $operazione['gestione'] ?? 'O';
$nStra = (int)($operazione['N_STRA'] ?? 0);
$nStra = (int) ($operazione['N_STRA'] ?? 0);
switch (strtoupper($gestione)) {
case 'R':
@ -630,17 +629,17 @@ private function getOrCreateStraordinaria(string $year, int $nStra): int
{
$key = "straordinaria_{$nStra}";
if (!isset($this->gestioni[$year][$key])) {
if (! isset($this->gestioni[$year][$key])) {
$this->gestioni[$year][$key] = GestioneContabile::create([
'tenant_id' => $this->tenantId,
'anno_gestione' => (int)$year,
'tipo_gestione' => 'straordinaria',
'tenant_id' => $this->tenantId,
'anno_gestione' => (int) $year,
'tipo_gestione' => 'straordinaria',
'numero_straordinaria' => $nStra,
'denominazione' => "Gestione Straordinaria {$year} - {$nStra}",
'denominazione' => "Gestione Straordinaria {$year} - {$nStra}",
'data_inizio' => "{$year}-01-01",
'data_fine' => "{$year}-12-31",
'stato' => 'aperta',
'protocollo_prefix' => "S{$year}-{$nStra}"
'protocollo_prefix' => "S{$year}-{$nStra}",
]);
}
@ -671,15 +670,20 @@ private function queryMdb(string $mdbPath, string $query): array
private function parseMdbOutput(string $output): array
{
$lines = explode("\n", trim($output));
if (empty($lines)) return [];
if (empty($lines)) {
return [];
}
$headers = array_map('trim', explode("\t", array_shift($lines)));
$data = [];
$data = [];
foreach ($lines as $line) {
if (empty(trim($line))) continue;
if (empty(trim($line))) {
continue;
}
$values = explode("\t", $line);
$row = array_combine($headers, $values);
$row = array_combine($headers, $values);
$data[] = $row;
}
@ -692,11 +696,11 @@ private function parseMdbOutput(string $output): array
private function generateProtocollo(int $gestioneId, array $operazione): array
{
$gestione = GestioneContabile::find($gestioneId);
$numero = $gestione->getNextProtocolNumber();
$numero = $gestione->getNextProtocolNumber();
return [
'numero' => $numero,
'completo' => $gestione->protocollo_prefix . '-' . str_pad($numero, 4, '0', STR_PAD_LEFT)
'numero' => $numero,
'completo' => $gestione->protocollo_prefix . '-' . str_pad($numero, 4, '0', STR_PAD_LEFT),
];
}
@ -705,7 +709,9 @@ private function generateProtocollo(int $gestioneId, array $operazione): array
*/
private function getVoceSpesaSnapshot(string $mdbPath, string $codVoce): array
{
if (empty($codVoce)) return [];
if (empty($codVoce)) {
return [];
}
$voci = $this->queryMdb($mdbPath, "
SELECT * FROM voci_spesa
@ -720,7 +726,9 @@ private function getVoceSpesaSnapshot(string $mdbPath, string $codVoce): array
*/
private function parseGesconDate($dateValue): ?string
{
if (empty($dateValue)) return null;
if (empty($dateValue)) {
return null;
}
// Prova vari formati date Gescon/Access
$formats = ['Y-m-d', 'd/m/Y', 'm/d/Y', 'Y-m-d H:i:s'];

View File

@ -1,22 +1,22 @@
<?php
namespace App\Services;
use App\Models\Amministratore;
use App\Models\Stabile;
use Illuminate\Support\Facades\Storage;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use ZipArchive;
use Exception;
class MigrationService
{
protected DatabaseManagerService $dbManager;
protected TenantArchivePathService $tenantArchivePath;
public function __construct(DatabaseManagerService $dbManager)
public function __construct(DatabaseManagerService $dbManager, TenantArchivePathService $tenantArchivePath)
{
$this->dbManager = $dbManager;
$this->dbManager = $dbManager;
$this->tenantArchivePath = $tenantArchivePath;
}
/**
@ -25,23 +25,23 @@ public function __construct(DatabaseManagerService $dbManager)
public function prepareAmministratoreMigration(Amministratore $amministratore): array
{
try {
$migrationId = "migration_admin_{$amministratore->codice_univoco}_" . date('Y-m-d_H-i-s');
$migrationId = "migration_admin_{$amministratore->codice_univoco}_" . date('Y-m-d_H-i-s');
$migrationPath = storage_path("app/migrations/{$migrationId}");
if (!is_dir($migrationPath)) {
if (! is_dir($migrationPath)) {
mkdir($migrationPath, 0755, true);
}
$results = [
'migration_id' => $migrationId,
'migration_id' => $migrationId,
'amministratore' => $amministratore->codice_univoco,
'created_at' => date('Y-m-d H:i:s'),
'components' => []
'created_at' => date('Y-m-d H:i:s'),
'components' => [],
];
// 1. Backup database amministratore
$adminDbBackup = $this->dbManager->backupAmministratoreDatabase($amministratore);
if (!$adminDbBackup['success']) {
if (! $adminDbBackup['success']) {
throw new Exception('Errore backup database amministratore: ' . $adminDbBackup['error']);
}
$results['components']['admin_database'] = $adminDbBackup;
@ -59,33 +59,33 @@ public function prepareAmministratoreMigration(Amministratore $amministratore):
$results['components']['stabili_databases'] = $stabiliBackups;
// 3. Archivio file sistema
$archiveResult = $this->createFileSystemArchive($amministratore, $migrationPath);
$archiveResult = $this->createFileSystemArchive($amministratore, $migrationPath);
$results['components']['filesystem_archive'] = $archiveResult;
// 4. Metadata e configurazioni
$metadataResult = $this->createMigrationMetadata($amministratore, $migrationPath);
$metadataResult = $this->createMigrationMetadata($amministratore, $migrationPath);
$results['components']['metadata'] = $metadataResult;
// 5. Script di ricostruzione
$scriptsResult = $this->createMigrationScripts($amministratore, $migrationPath);
$scriptsResult = $this->createMigrationScripts($amministratore, $migrationPath);
$results['components']['scripts'] = $scriptsResult;
// 6. Verifica integrità
$verificationResult = $this->verifyMigrationIntegrity($migrationPath);
$verificationResult = $this->verifyMigrationIntegrity($migrationPath);
$results['components']['verification'] = $verificationResult;
// 7. Crea package finale
$packageResult = $this->createMigrationPackage($migrationId, $migrationPath);
$packageResult = $this->createMigrationPackage($migrationId, $migrationPath);
$results['package'] = $packageResult;
return [
'success' => true,
'migration' => $results
'success' => true,
'migration' => $results,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -96,50 +96,50 @@ public function prepareAmministratoreMigration(Amministratore $amministratore):
public function prepareStabileMigration(Stabile $stabile): array
{
try {
$migrationId = "migration_stabile_{$stabile->codice_stabile}_" . date('Y-m-d_H-i-s');
$migrationId = "migration_stabile_{$stabile->codice_stabile}_" . date('Y-m-d_H-i-s');
$migrationPath = storage_path("app/migrations/{$migrationId}");
if (!is_dir($migrationPath)) {
if (! is_dir($migrationPath)) {
mkdir($migrationPath, 0755, true);
}
$results = [
'migration_id' => $migrationId,
'stabile' => $stabile->codice_stabile,
'migration_id' => $migrationId,
'stabile' => $stabile->codice_stabile,
'amministratore' => $stabile->amministratore->codice_univoco,
'created_at' => date('Y-m-d H:i:s'),
'components' => []
'created_at' => date('Y-m-d H:i:s'),
'components' => [],
];
// 1. Backup database stabile
if ($stabile->database_dedicato) {
$stabileDbBackup = $this->dbManager->backupStabileDatabase($stabile);
if (!$stabileDbBackup['success']) {
if (! $stabileDbBackup['success']) {
throw new Exception('Errore backup database stabile: ' . $stabileDbBackup['error']);
}
$results['components']['stabile_database'] = $stabileDbBackup;
}
// 2. Archivio file stabile
$archiveResult = $this->createStabileFileArchive($stabile, $migrationPath);
$archiveResult = $this->createStabileFileArchive($stabile, $migrationPath);
$results['components']['filesystem_archive'] = $archiveResult;
// 3. Metadata stabile
$metadataResult = $this->createStabileMetadata($stabile, $migrationPath);
$metadataResult = $this->createStabileMetadata($stabile, $migrationPath);
$results['components']['metadata'] = $metadataResult;
// 4. Package finale
$packageResult = $this->createMigrationPackage($migrationId, $migrationPath);
$packageResult = $this->createMigrationPackage($migrationId, $migrationPath);
$results['package'] = $packageResult;
return [
'success' => true,
'migration' => $results
'success' => true,
'migration' => $results,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -150,16 +150,16 @@ public function prepareStabileMigration(Stabile $stabile): array
private function createFileSystemArchive(Amministratore $amministratore, string $migrationPath): array
{
try {
$sourcePath = storage_path("app/amministratori/{$amministratore->codice_univoco}");
$sourcePath = $this->tenantArchivePath->amministratoreAbsolutePath($amministratore);
$archiveName = "filesystem_admin_{$amministratore->codice_univoco}.zip";
$archivePath = $migrationPath . '/' . $archiveName;
if (!is_dir($sourcePath)) {
if (! is_dir($sourcePath)) {
throw new Exception("Cartella amministratore non trovata: {$sourcePath}");
}
$zip = new ZipArchive();
if ($zip->open($archivePath, ZipArchive::CREATE) !== TRUE) {
if ($zip->open($archivePath, ZipArchive::CREATE) !== true) {
throw new Exception('Impossibile creare archivio filesystem');
}
@ -177,16 +177,16 @@ private function createFileSystemArchive(Amministratore $amministratore, string
$zip->close();
return [
'success' => true,
'success' => true,
'archive_name' => $archiveName,
'archive_path' => $archivePath,
'archive_size' => filesize($archivePath),
'files_count' => $zip->numFiles ?? 0
'files_count' => $zip->numFiles ?? 0,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -197,18 +197,17 @@ private function createFileSystemArchive(Amministratore $amministratore, string
private function createStabileFileArchive(Stabile $stabile, string $migrationPath): array
{
try {
$adminCode = $stabile->amministratore->codice_univoco;
$sourcePath = storage_path("app/amministratori/{$adminCode}/stabili/{$stabile->codice_stabile}");
$sourcePath = $this->tenantArchivePath->stabileAbsolutePath($stabile);
$archiveName = "filesystem_stabile_{$stabile->codice_stabile}.zip";
$archivePath = $migrationPath . '/' . $archiveName;
if (!is_dir($sourcePath)) {
if (! is_dir($sourcePath)) {
// Crea struttura base se non esiste
$this->createStabileFolderStructure($stabile);
}
$zip = new ZipArchive();
if ($zip->open($archivePath, ZipArchive::CREATE) !== TRUE) {
if ($zip->open($archivePath, ZipArchive::CREATE) !== true) {
throw new Exception('Impossibile creare archivio filesystem stabile');
}
@ -226,16 +225,16 @@ private function createStabileFileArchive(Stabile $stabile, string $migrationPat
$zip->close();
return [
'success' => true,
'success' => true,
'archive_name' => $archiveName,
'archive_path' => $archivePath,
'archive_size' => filesize($archivePath),
'files_count' => $zip->numFiles ?? 0
'files_count' => $zip->numFiles ?? 0,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -247,58 +246,58 @@ private function createMigrationMetadata(Amministratore $amministratore, string
{
try {
$metadata = [
'migration' => [
'type' => 'amministratore_completo',
'version' => '1.0.0',
'created_at' => date('Y-m-d H:i:s'),
'source_server' => env('APP_URL'),
'migration' => [
'type' => 'amministratore_completo',
'version' => '1.0.0',
'created_at' => date('Y-m-d H:i:s'),
'source_server' => env('APP_URL'),
'netgescon_version' => '2.1.0',
],
'amministratore' => [
'id' => $amministratore->id,
'codice_univoco' => $amministratore->codice_univoco,
'nome' => $amministratore->nome,
'cognome' => $amministratore->cognome,
'denominazione_studio' => $amministratore->denominazione_studio,
'partita_iva' => $amministratore->partita_iva,
'id' => $amministratore->id,
'codice_univoco' => $amministratore->codice_univoco,
'nome' => $amministratore->nome,
'cognome' => $amministratore->cognome,
'denominazione_studio' => $amministratore->denominazione_studio,
'partita_iva' => $amministratore->partita_iva,
'codice_fiscale_studio' => $amministratore->codice_fiscale_studio,
'email_studio' => $amministratore->email_studio,
'database_principale' => $amministratore->getDatabaseName(),
'created_at' => $amministratore->created_at->toISOString(),
'email_studio' => $amministratore->email_studio,
'database_principale' => $amministratore->getDatabaseName(),
'created_at' => $amministratore->created_at->toISOString(),
],
'stabili' => [],
'databases' => [
'stabili' => [],
'databases' => [
'principale' => $amministratore->getDatabaseName(),
'stabili' => []
'stabili' => [],
],
'requirements' => [
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'mysql_version' => DB::select('SELECT VERSION() as version')[0]->version,
'requirements' => [
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'mysql_version' => DB::select('SELECT VERSION() as version')[0]->version,
'required_extensions' => [
'zip',
'pdo_mysql',
'openssl',
'fileinfo',
'mbstring'
'mbstring',
],
'min_disk_space_mb' => $this->calculateRequiredSpace($amministratore),
'min_disk_space_mb' => $this->calculateRequiredSpace($amministratore),
],
'structure' => [
'folders' => $this->analyzeFolderStructure($amministratore),
'structure' => [
'folders' => $this->analyzeFolderStructure($amministratore),
'databases' => $this->analyzeDatabaseStructure($amministratore),
]
],
];
// Aggiungi info per ogni stabile
foreach ($amministratore->stabili as $stabile) {
$stabileInfo = [
'id' => $stabile->id,
'codice_stabile' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'indirizzo' => $stabile->indirizzo . ', ' . $stabile->citta,
'id' => $stabile->id,
'codice_stabile' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'indirizzo' => $stabile->indirizzo . ', ' . $stabile->citta,
'database_dedicato' => $stabile->database_dedicato,
'created_at' => $stabile->created_at->toISOString(),
'created_at' => $stabile->created_at->toISOString(),
];
$metadata['stabili'][] = $stabileInfo;
@ -312,15 +311,15 @@ private function createMigrationMetadata(Amministratore $amministratore, string
file_put_contents($metadataPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return [
'success' => true,
'metadata_file' => $metadataPath,
'stabili_count' => count($metadata['stabili']),
'databases_count' => count($metadata['databases']['stabili']) + 1
'success' => true,
'metadata_file' => $metadataPath,
'stabili_count' => count($metadata['stabili']),
'databases_count' => count($metadata['databases']['stabili']) + 1,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -332,46 +331,46 @@ private function createStabileMetadata(Stabile $stabile, string $migrationPath):
{
try {
$metadata = [
'migration' => [
'type' => 'stabile_singolo',
'version' => '1.0.0',
'created_at' => date('Y-m-d H:i:s'),
'migration' => [
'type' => 'stabile_singolo',
'version' => '1.0.0',
'created_at' => date('Y-m-d H:i:s'),
'source_server' => env('APP_URL'),
],
'stabile' => [
'id' => $stabile->id,
'codice_stabile' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'indirizzo' => $stabile->indirizzo,
'cap' => $stabile->cap,
'citta' => $stabile->citta,
'provincia' => $stabile->provincia,
'stabile' => [
'id' => $stabile->id,
'codice_stabile' => $stabile->codice_stabile,
'denominazione' => $stabile->denominazione,
'indirizzo' => $stabile->indirizzo,
'cap' => $stabile->cap,
'citta' => $stabile->citta,
'provincia' => $stabile->provincia,
'database_dedicato' => $stabile->database_dedicato,
'created_at' => $stabile->created_at->toISOString(),
'created_at' => $stabile->created_at->toISOString(),
],
'amministratore_origine' => [
'codice_univoco' => $stabile->amministratore->codice_univoco,
'nome_completo' => $stabile->amministratore->nome_completo,
'codice_univoco' => $stabile->amministratore->codice_univoco,
'nome_completo' => $stabile->amministratore->nome_completo,
'denominazione_studio' => $stabile->amministratore->denominazione_studio,
],
'requirements' => [
'requirements' => [
'target_admin_code' => 'required',
'php_version' => PHP_VERSION,
'mysql_version' => DB::select('SELECT VERSION() as version')[0]->version,
]
'php_version' => PHP_VERSION,
'mysql_version' => DB::select('SELECT VERSION() as version')[0]->version,
],
];
$metadataPath = $migrationPath . '/stabile_metadata.json';
file_put_contents($metadataPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return [
'success' => true,
'metadata_file' => $metadataPath
'success' => true,
'metadata_file' => $metadataPath,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -383,7 +382,7 @@ private function createMigrationScripts(Amministratore $amministratore, string $
{
try {
$scriptsPath = $migrationPath . '/scripts';
if (!is_dir($scriptsPath)) {
if (! is_dir($scriptsPath)) {
mkdir($scriptsPath, 0755, true);
}
@ -413,14 +412,14 @@ private function createMigrationScripts(Amministratore $amministratore, string $
chmod($scriptsPath . '/verify_migration.sh', 0755);
return [
'success' => true,
'scripts_path' => $scriptsPath,
'scripts_created' => 5
'success' => true,
'scripts_path' => $scriptsPath,
'scripts_created' => 5,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -430,7 +429,7 @@ private function createMigrationScripts(Amministratore $amministratore, string $
*/
private function createDatabaseRestoreScript(Amministratore $amministratore): string
{
$script = "#!/bin/bash\n\n";
$script = "#!/bin/bash\n\n";
$script .= "# Script restore database per amministratore {$amministratore->codice_univoco}\n";
$script .= "# Generato automaticamente da NetGescon\n\n";
@ -488,7 +487,7 @@ private function createDatabaseRestoreScript(Amministratore $amministratore): st
*/
private function createFilesystemRestoreScript(Amministratore $amministratore): string
{
$script = "#!/bin/bash\n\n";
$script = "#!/bin/bash\n\n";
$script .= "# Script restore filesystem per amministratore {$amministratore->codice_univoco}\n\n";
$script .= "set -e\n\n";
@ -528,7 +527,7 @@ private function createFilesystemRestoreScript(Amministratore $amministratore):
*/
private function createVerificationScript(Amministratore $amministratore): string
{
$script = "#!/bin/bash\n\n";
$script = "#!/bin/bash\n\n";
$script .= "# Script verifica migrazione per amministratore {$amministratore->codice_univoco}\n\n";
$script .= "ADMIN_CODE=\"{$amministratore->codice_univoco}\"\n";
@ -760,32 +759,32 @@ private function verifyMigrationIntegrity(string $migrationPath): array
{
try {
$checks = [
'metadata_exists' => file_exists($migrationPath . '/metadata.json'),
'scripts_exists' => is_dir($migrationPath . '/scripts'),
'database_backups' => 0,
'metadata_exists' => file_exists($migrationPath . '/metadata.json'),
'scripts_exists' => is_dir($migrationPath . '/scripts'),
'database_backups' => 0,
'filesystem_archives' => 0,
];
// Conta backup database
$files = glob($migrationPath . '/backup_*.sql');
$files = glob($migrationPath . '/backup_*.sql');
$checks['database_backups'] = count($files);
// Conta archivi filesystem
$archives = glob($migrationPath . '/filesystem_*.zip');
$archives = glob($migrationPath . '/filesystem_*.zip');
$checks['filesystem_archives'] = count($archives);
$checks['integrity_ok'] = $checks['metadata_exists'] &&
$checks['scripts_exists'] &&
$checks['database_backups'] > 0;
$checks['scripts_exists'] &&
$checks['database_backups'] > 0;
return [
'success' => true,
'checks' => $checks
'checks' => $checks,
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -799,7 +798,7 @@ private function createMigrationPackage(string $migrationId, string $migrationPa
$packagePath = storage_path("app/migrations/{$migrationId}.zip");
$zip = new ZipArchive();
if ($zip->open($packagePath, ZipArchive::CREATE) !== TRUE) {
if ($zip->open($packagePath, ZipArchive::CREATE) !== true) {
throw new Exception('Impossibile creare package migrazione');
}
@ -820,15 +819,15 @@ private function createMigrationPackage(string $migrationId, string $migrationPa
$this->deleteDirectory($migrationPath);
return [
'success' => true,
'success' => true,
'package_path' => $packagePath,
'package_size' => filesize($packagePath),
'download_url' => url('admin/migrations/download/' . basename($packagePath))
'download_url' => url('admin/migrations/download/' . basename($packagePath)),
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
@ -841,12 +840,12 @@ private function calculateRequiredSpace(Amministratore $amministratore): int
$totalSize = 0;
// Dimensione archivio amministratore
$adminPath = storage_path("app/amministratori/{$amministratore->codice_univoco}");
$adminPath = $this->tenantArchivePath->amministratoreAbsolutePath($amministratore);
if (is_dir($adminPath)) {
$totalSize += $this->getDirectorySize($adminPath);
}
// Aggiungi overhead per backup database (stimato)
// Aggiungi overhead per backup database (stimato)
$totalSize += 50 * 1024 * 1024; // 50MB stimati per backup
return intval($totalSize / (1024 * 1024)); // Converti in MB
@ -857,18 +856,18 @@ private function calculateRequiredSpace(Amministratore $amministratore): int
*/
private function analyzeFolderStructure(Amministratore $amministratore): array
{
$adminPath = storage_path("app/amministratori/{$amministratore->codice_univoco}");
$adminPath = $this->tenantArchivePath->amministratoreAbsolutePath($amministratore);
if (!is_dir($adminPath)) {
if (! is_dir($adminPath)) {
return ['exists' => false];
}
return [
'exists' => true,
'path' => $adminPath,
'size' => $this->getDirectorySize($adminPath),
'exists' => true,
'path' => $adminPath,
'size' => $this->getDirectorySize($adminPath),
'files_count' => $this->countFiles($adminPath),
'directories' => $this->listDirectories($adminPath)
'directories' => $this->listDirectories($adminPath),
];
}
@ -880,15 +879,15 @@ private function analyzeDatabaseStructure(Amministratore $amministratore): array
$structure = [
'amministratore' => [
'database' => $amministratore->getDatabaseName(),
'exists' => false,
'tables' => []
'exists' => false,
'tables' => [],
],
'stabili' => []
'stabili' => [],
];
// Verifica database amministratore
try {
$tables = DB::select("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?", [$amministratore->getDatabaseName()]);
$tables = DB::select("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?", [$amministratore->getDatabaseName()]);
$structure['amministratore']['exists'] = count($tables) > 0;
$structure['amministratore']['tables'] = array_column($tables, 'TABLE_NAME');
} catch (Exception $e) {
@ -899,14 +898,14 @@ private function analyzeDatabaseStructure(Amministratore $amministratore): array
foreach ($amministratore->stabili as $stabile) {
if ($stabile->database_dedicato) {
$stabileInfo = [
'codice' => $stabile->codice_stabile,
'codice' => $stabile->codice_stabile,
'database' => $stabile->database_dedicato,
'exists' => false,
'tables' => []
'exists' => false,
'tables' => [],
];
try {
$tables = DB::select("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?", [$stabile->database_dedicato]);
$tables = DB::select("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?", [$stabile->database_dedicato]);
$stabileInfo['exists'] = count($tables) > 0;
$stabileInfo['tables'] = array_column($tables, 'TABLE_NAME');
} catch (Exception $e) {
@ -925,7 +924,7 @@ private function analyzeDatabaseStructure(Amministratore $amministratore): array
*/
private function getDirectorySize(string $path): int
{
$size = 0;
$size = 0;
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
foreach ($iterator as $file) {
@ -939,7 +938,7 @@ private function getDirectorySize(string $path): int
private function countFiles(string $path): int
{
$count = 0;
$count = 0;
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
foreach ($iterator as $file) {
@ -954,14 +953,14 @@ private function countFiles(string $path): int
private function listDirectories(string $path): array
{
$directories = [];
$iterator = new \RecursiveIteratorIterator(
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir() && !in_array($file->getFilename(), ['.', '..'])) {
$relativePath = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getPathname());
if ($file->isDir() && ! in_array($file->getFilename(), ['.', '..'])) {
$relativePath = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getPathname());
$directories[] = $relativePath;
}
}
@ -971,7 +970,7 @@ private function listDirectories(string $path): array
private function deleteDirectory(string $path): bool
{
if (!is_dir($path)) {
if (! is_dir($path)) {
return false;
}

View File

@ -0,0 +1,394 @@
<?php
namespace App\Services\Quality;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class OrdinarieQualityAuditService
{
public function run(?string $stabileInput = null, ?int $anno = null): array
{
$results = [];
$registryIssues = $this->checkRegistryConsistency($stabileInput);
if ($registryIssues !== []) {
$results[] = [
'file' => 'quality://archives/tenant-registry-ordinarie',
'module' => 'stabili',
'menu' => 'condomini',
'issues' => $registryIssues,
];
}
$ordinarieIssues = $this->checkOrdinarieConsistency($stabileInput, $anno);
if ($ordinarieIssues !== []) {
$results[] = [
'file' => 'quality://archives/ordinarie-audit',
'module' => 'contabilita-gescon',
'menu' => 'contabilita',
'issues' => $ordinarieIssues,
];
}
return $results;
}
private function checkRegistryConsistency(?string $stabileInput): array
{
if (! Schema::hasTable('tenant_archive_registries')) {
return [
$this->issue(
'tenant_registry_missing',
'Registry archivi non disponibile',
'La tabella tenant_archive_registries non esiste: impossibile allineare in modo affidabile archivio, owner e storage per stabile.',
'warning',
'Esegui la migrazione del registry e il comando netgescon:archive-registry-sync.'
),
];
}
$issues = [];
$registryRows = DB::table('tenant_archive_registries')
->where('archive_type', 'stabile')
->orderBy('archive_code')
->get(['archive_code', 'owner_amministratore_code', 'storage_relative_path', 'status']);
$invalidCodes = $registryRows
->filter(fn($row) => ! preg_match('/^[A-Za-z0-9_-]+$/', (string) ($row->archive_code ?? '')))
->values();
if ($invalidCodes->isNotEmpty()) {
$sample = $invalidCodes->take(5)->map(fn($row) => (string) $row->archive_code)->implode(', ');
$issues[] = $this->issue(
'tenant_registry_invalid_codes',
'Codici archivio stabile non validi nel registry',
'Rilevati ' . $invalidCodes->count() . ' codici archivio non canonici nel registry stabili. Esempi: ' . $sample . '.',
'error',
'Normalizza i codici stabile e rigenera il registry prima di usare i controlli operativi.'
);
}
$normalizedDuplicates = $registryRows
->groupBy(fn($row) => $this->normalizeArchiveCode((string) ($row->archive_code ?? '')))
->filter(fn($group, $key) => $key !== '' && $group->count() > 1);
if ($normalizedDuplicates->isNotEmpty()) {
$sample = $normalizedDuplicates->take(5)->map(function ($group, $key) {
$codes = collect($group)->pluck('archive_code')->implode('/');
return $key . ' => ' . $codes;
})->implode('; ');
$issues[] = $this->issue(
'tenant_registry_normalized_duplicates',
'Codici stabile duplicati dopo normalizzazione',
'Il registry contiene ' . $normalizedDuplicates->count() . ' gruppi di codici che collassano sullo stesso identificativo logico. Esempi: ' . $sample . '.',
'warning',
'Scegli un solo codice canonico per stabile e riallinea archivio, import e mapping legacy.'
);
}
if ($stabileInput !== null && trim($stabileInput) !== '') {
$resolved = $this->resolveStabile(trim($stabileInput));
if ($resolved === null) {
$issues[] = $this->issue(
'tenant_registry_stabile_not_found',
'Stabile richiesto non trovato',
'Nessuno stabile locale trovato per il filtro `' . trim($stabileInput) . '`.',
'warning',
'Usa codice_stabile, cod_stabile o ID locale valido prima di lanciare la QA specifica.'
);
return $issues;
}
$candidateCodes = collect($this->buildLegacyCodeCandidates($resolved))
->map(fn($code) => $this->normalizeArchiveCode($code))
->filter()
->unique()
->values();
$matchingRegistry = $registryRows->filter(function ($row) use ($candidateCodes) {
return $candidateCodes->contains($this->normalizeArchiveCode((string) ($row->archive_code ?? '')));
})->values();
if ($matchingRegistry->isEmpty()) {
$issues[] = $this->issue(
'tenant_registry_missing_stabile_mapping',
'Stabile senza mapping registry coerente',
'Lo stabile `' . ($resolved->codice_stabile ?? trim($stabileInput)) . '` non ha un record coerente nel registry centrale.',
'error',
'Esegui netgescon:archive-registry-sync e verifica codice stabile e owner attuale.'
);
} elseif ($matchingRegistry->count() > 1) {
$sample = $matchingRegistry->map(fn($row) => (string) $row->archive_code . ' -> ' . (string) $row->owner_amministratore_code)->implode('; ');
$issues[] = $this->issue(
'tenant_registry_multiple_mappings_stabile',
'Stabile con mapping registry multipli',
'Per lo stabile `' . ($resolved->codice_stabile ?? trim($stabileInput)) . '` risultano più mapping coerenti nel registry: ' . $sample . '.',
'warning',
'Riduci il registry a un solo record canonico per stabile dopo la pulizia dati test.'
);
}
}
if ($issues === []) {
$issues[] = $this->issue(
'tenant_registry_consistent',
'Registry archivi coerente',
'Nessuna anomalia bloccante rilevata nel registry centrale degli archivi stabili.',
'info',
'Continua a usare il registry come fonte di verità per codice, owner e storage.'
);
}
return $issues;
}
private function checkOrdinarieConsistency(?string $stabileInput, ?int $anno): array
{
if (! Schema::connection('gescon_import')->hasTable('operazioni')) {
return [
$this->issue(
'ordinarie_operazioni_missing',
'Archivio operazioni legacy non disponibile',
'La tabella gescon_import.operazioni non è disponibile: impossibile eseguire la QA di Ordinarie.',
'critical',
'Ripristina la connessione gescon_import e la tabella operazioni.'
),
];
}
$issues = [];
$resolved = null;
$candidateCodes = [];
$label = 'globale';
if ($stabileInput !== null && trim($stabileInput) !== '') {
$resolved = $this->resolveStabile(trim($stabileInput));
if ($resolved === null) {
return [
$this->issue(
'ordinarie_stabile_not_found',
'Stabile QA non trovato',
'Nessuno stabile locale trovato per il filtro `' . trim($stabileInput) . '`.',
'warning',
'Usa un codice stabile valido prima di eseguire la QA mirata di Ordinarie.'
),
];
}
$candidateCodes = $this->buildLegacyCodeCandidates($resolved);
$label = (string) ($resolved->codice_stabile ?: $resolved->denominazione ?: trim($stabileInput));
}
$baseQuery = DB::connection('gescon_import')->table('operazioni');
if ($candidateCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) {
$baseQuery->whereIn('cod_stabile', $candidateCodes);
}
if ($anno !== null && Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) {
$baseQuery->whereYear('dt_spe', $anno);
}
$totalRows = (clone $baseQuery)->count();
if ($totalRows === 0) {
return [
$this->issue(
'ordinarie_no_rows',
'Nessuna riga Ordinarie trovata',
'La QA non ha trovato righe in gescon_import.operazioni per il contesto `' . $label . '`' . ($anno ? ' e anno ' . $anno : '') . '.',
'info',
'Se atteso, controlla codice legacy stabile, anno selezionato e import operazioni.'
),
];
}
$variantSummary = [];
if ($candidateCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) {
$variantSummary = (clone $baseQuery)
->select('cod_stabile')
->selectRaw('COUNT(*) as righe')
->groupBy('cod_stabile')
->orderByDesc('righe')
->get()
->map(fn($row) => (string) $row->cod_stabile . ': ' . (int) $row->righe)
->all();
if (count($variantSummary) > 1) {
$issues[] = $this->issue(
'ordinarie_multiple_legacy_codes',
'Ordinarie distribuite su più codici legacy stabile',
'Per lo stabile `' . $label . '` le operazioni sono presenti su più codici legacy: ' . implode('; ', array_slice($variantSummary, 0, 8)) . '.',
'warning',
'Allinea il mapping legacy del stabile e scegli un solo codice operativo per filtri e viste.'
);
}
}
$gestioneCounts = Schema::connection('gescon_import')->hasColumn('operazioni', 'gestione')
? (clone $baseQuery)
->selectRaw("COALESCE(NULLIF(TRIM(gestione), ''), '[vuoto]') as gestione_label")
->selectRaw('COUNT(*) as righe')
->groupBy('gestione_label')
->orderByDesc('righe')
->get()
: collect();
$yearCounts = Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')
? (clone $baseQuery)
->selectRaw('YEAR(dt_spe) as anno_label')
->selectRaw('COUNT(*) as righe')
->groupBy('anno_label')
->orderByDesc('anno_label')
->get()
: collect();
$topCodes = (clone $baseQuery)
->selectRaw("COALESCE(NULLIF(TRIM(cod_spe), ''), '[vuoto]') as cod_spe_label")
->selectRaw('COUNT(*) as righe')
->groupBy('cod_spe_label')
->orderByDesc('righe')
->limit(5)
->get()
->map(fn($row) => (string) $row->cod_spe_label . ': ' . (int) $row->righe)
->all();
$issues[] = $this->issue(
'ordinarie_volume_overview',
'Panoramica volume Ordinarie',
'Contesto `' . $label . '`' . ($anno ? ' anno ' . $anno : '') . ': ' . $totalRows . ' righe operative. Gestione: ' . $this->summarizeRows($gestioneCounts, 'gestione_label') . '. Anni: ' . $this->summarizeRows($yearCounts, 'anno_label') . '. Top voci: ' . implode('; ', $topCodes) . '.',
$totalRows > 10000 ? 'warning' : 'info',
$totalRows > 10000
? 'Usa questo audit come base per restringere stabile/anno prima di verifiche manuali riga per riga in Filament.'
: 'La distribuzione delle righe è entro un volume gestibile per analisi mirate.'
);
$missingGestione = Schema::connection('gescon_import')->hasColumn('operazioni', 'gestione')
? (clone $baseQuery)->where(function ($query) {
$query->whereNull('gestione')->orWhere('gestione', '');
})->count()
: 0;
if ($missingGestione > 0) {
$issues[] = $this->issue(
'ordinarie_missing_gestione',
'Righe Ordinarie senza gestione valorizzata',
'Rilevate ' . $missingGestione . ' righe in operazioni con gestione vuota o nulla per il contesto `' . $label . '`' . ($anno ? ' anno ' . $anno : '') . '.',
'error',
'Normalizza il campo gestione nei dati legacy o aggiungi una regola univoca di fallback prima della visualizzazione Filament.'
);
}
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'tabella')) {
$missingTabella = (clone $baseQuery)->where(function ($query) {
$query->whereNull('tabella')->orWhere('tabella', '');
})->count();
if ($missingTabella > 0) {
$issues[] = $this->issue(
'ordinarie_missing_tabella',
'Righe Ordinarie senza tabella millesimale',
'Rilevate ' . $missingTabella . ' righe con tabella vuota o nulla nel contesto `' . $label . '`' . ($anno ? ' anno ' . $anno : '') . '.',
'warning',
'Bonifica tabella/cod_tab prima di usare consuntivi e raggruppamenti per tabella in Filament.'
);
}
}
if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) {
$missingDate = (clone $baseQuery)->whereNull('dt_spe')->count();
if ($missingDate > 0) {
$issues[] = $this->issue(
'ordinarie_missing_date',
'Righe Ordinarie senza data operazione',
'Rilevate ' . $missingDate . ' righe senza dt_spe nel contesto `' . $label . '`' . ($anno ? ' anno ' . $anno : '') . '.',
'warning',
'Compila dt_spe o definisci una regola di derivazione, altrimenti i filtri anno resteranno inaffidabili.'
);
}
}
return $issues;
}
private function resolveStabile(string $input): ?object
{
$stabile = DB::table('stabili')->where('codice_stabile', $input)->first();
if (! $stabile && Schema::hasColumn('stabili', 'cod_stabile')) {
$stabile = DB::table('stabili')->where('cod_stabile', $input)->first();
}
if (! $stabile && ctype_digit($input) && ! str_starts_with($input, '0')) {
$stabile = DB::table('stabili')->where('id', (int) $input)->first();
}
return $stabile;
}
private function buildLegacyCodeCandidates(object $stabile): array
{
$candidates = [];
foreach ([(string) ($stabile->codice_stabile ?? ''), (string) ($stabile->cod_stabile ?? ''), (string) ($stabile->old_id ?? '')] as $value) {
$trimmed = trim($value);
if ($trimmed === '') {
continue;
}
$candidates[] = $trimmed;
if (ctype_digit($trimmed)) {
$candidates[] = ltrim($trimmed, '0');
$candidates[] = str_pad((string) ((int) $trimmed), 4, '0', STR_PAD_LEFT);
$candidates[] = str_pad((string) ((int) $trimmed), 8, '0', STR_PAD_LEFT);
}
}
return array_values(array_unique(array_filter($candidates, fn($value) => $value !== '')));
}
private function summarizeRows($rows, string $labelKey): string
{
if ($rows instanceof \Illuminate\Support\Collection) {
$rows = $rows->all();
}
if (! is_array($rows) || $rows === []) {
return 'n/d';
}
return collect($rows)
->take(8)
->map(function ($row) use ($labelKey) {
$label = (string) ($row->{$labelKey} ?? '[n/d]');
$count = (int) ($row->righe ?? 0);
return $label . ': ' . $count;
})
->implode('; ');
}
private function normalizeArchiveCode(string $code): string
{
$normalized = strtoupper(trim($code));
if ($normalized === '') {
return '';
}
if (ctype_digit($normalized)) {
$normalized = ltrim($normalized, '0');
return $normalized === '' ? '0' : $normalized;
}
return $normalized;
}
private function issue(string $rule, string $name, string $description, string $severity, string $suggestion): array
{
return [
'rule' => $rule,
'name' => $name,
'description' => $description,
'severity' => $severity,
'line' => 1,
'match' => '—',
'suggestion' => $suggestion,
'context' => [],
];
}
}

View File

@ -1,10 +1,10 @@
<?php
namespace App\Services\Support;
use App\Models\TicketAttachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class TicketAttachmentUploadService
@ -13,13 +13,29 @@ class TicketAttachmentUploadService
* @param UploadedFile|TemporaryUploadedFile $file
* @return array{path:string,mime:string,size:int,original_name:string}
*/
public function store(object $file, string $directory): array
public function store(object $file, string $directory, array $options = []): array
{
$path = $file->store($directory, 'public');
$originalName = method_exists($file, 'getClientOriginalName')
? (string) $file->getClientOriginalName()
: basename($path);
: 'file';
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
$storedBasename = trim((string) ($options['stored_basename'] ?? ''));
$displayName = trim((string) ($options['display_name'] ?? ''));
if ($storedBasename !== '') {
$sanitizedBase = trim((string) Str::of(pathinfo($storedBasename, PATHINFO_FILENAME))->slug('-'), '-');
$sanitizedBase = $sanitizedBase !== '' ? $sanitizedBase : 'file';
$storedName = $sanitizedBase . ($extension !== '' ? '.' . $extension : '');
$path = $file->storeAs($directory, $storedName, 'public');
} else {
$path = $file->store($directory, 'public');
$storedName = basename($path);
}
if ($displayName === '') {
$displayName = $storedName !== '' ? $storedName : $originalName;
}
$mime = TicketAttachment::normalizeMimeType(
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
@ -27,8 +43,11 @@ public function store(object $file, string $directory): array
$path,
);
$metadata = [];
if (str_starts_with($mime, 'image/')) {
$this->optimizeImage($path, $mime);
$metadata = $this->extractImageMetadata($path);
$this->optimizeImage($path, $mime, $metadata);
$mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path);
}
@ -40,27 +59,34 @@ public function store(object $file, string $directory): array
}
return [
'path' => $path,
'mime' => $mime,
'size' => $size,
'original_name' => $originalName,
'path' => $path,
'mime' => $mime,
'size' => $size,
'original_name' => $displayName,
'source_original_name' => $originalName,
'stored_name' => $storedName,
'metadata' => $metadata,
];
}
private function optimizeImage(string $path, string $mime): void
private function optimizeImage(string $path, string $mime, array $metadata = []): void
{
$absolutePath = Storage::disk('public')->path($path);
if (! is_file($absolutePath) || ! function_exists('getimagesize')) {
return;
}
$size = @getimagesize($absolutePath);
if (! is_array($size)) {
$source = $this->createImageResource($absolutePath, $mime);
if (! $source) {
return;
}
[$width, $height] = $size;
$source = $this->applyOrientation($source, (int) ($metadata['orientation'] ?? 1));
$width = imagesx($source);
$height = imagesy($source);
if ($width <= 0 || $height <= 0) {
imagedestroy($source);
return;
}
@ -70,11 +96,6 @@ private function optimizeImage(string $path, string $mime): void
$targetW = (int) max(1, round($width * $ratio));
$targetH = (int) max(1, round($height * $ratio));
$source = $this->createImageResource($absolutePath, $mime);
if (! $source) {
return;
}
$target = imagecreatetruecolor($targetW, $targetH);
if ($target === false) {
imagedestroy($source);
@ -116,4 +137,116 @@ private function createImageResource(string $absolutePath, string $mime)
default => function_exists('imagecreatefromjpeg') ? @imagecreatefromjpeg($absolutePath) : false,
};
}
private function applyOrientation($source, int $orientation)
{
$angle = match ($orientation) {
3 => 180,
6 => 270,
8 => 90,
default => 0,
};
if ($angle === 0 || ! function_exists('imagerotate')) {
return $source;
}
$rotated = @imagerotate($source, $angle, 0);
if ($rotated === false) {
return $source;
}
imagedestroy($source);
return $rotated;
}
private function extractImageMetadata(string $path): array
{
$absolutePath = Storage::disk('public')->path($path);
$metadata = [];
$imageSize = @getimagesize($absolutePath);
if (is_array($imageSize)) {
$metadata['width'] = $imageSize[0] ?? null;
$metadata['height'] = $imageSize[1] ?? null;
$metadata['mime'] = $imageSize['mime'] ?? null;
}
if (! function_exists('exif_read_data')) {
return $metadata;
}
try {
$exif = @exif_read_data($absolutePath);
if (! is_array($exif)) {
return $metadata;
}
$metadata['exif_datetime'] = $exif['DateTimeOriginal'] ?? ($exif['DateTime'] ?? null);
$metadata['device'] = trim(implode(' ', array_filter([
$exif['Make'] ?? null,
$exif['Model'] ?? null,
])));
$metadata['orientation'] = (int) ($exif['Orientation'] ?? 1);
$lat = $this->gpsCoordinateToDecimal($exif['GPSLatitude'] ?? null, $exif['GPSLatitudeRef'] ?? null);
$lng = $this->gpsCoordinateToDecimal($exif['GPSLongitude'] ?? null, $exif['GPSLongitudeRef'] ?? null);
if ($lat !== null && $lng !== null) {
$metadata['gps_decimal'] = ['lat' => $lat, 'lng' => $lng];
$metadata['google_maps_url'] = 'https://maps.google.com/?q=' . $lat . ',' . $lng;
}
} catch (\Throwable) {
}
return $metadata;
}
private function gpsCoordinateToDecimal(mixed $coordinate, mixed $reference): ?float
{
if (! is_array($coordinate) || count($coordinate) < 3) {
return null;
}
$degrees = $this->gpsValueToFloat($coordinate[0] ?? null);
$minutes = $this->gpsValueToFloat($coordinate[1] ?? null);
$seconds = $this->gpsValueToFloat($coordinate[2] ?? null);
if ($degrees === null || $minutes === null || $seconds === null) {
return null;
}
$decimal = $degrees + ($minutes / 60) + ($seconds / 3600);
$ref = strtoupper(trim((string) $reference));
if (in_array($ref, ['S', 'W'], true)) {
$decimal *= -1;
}
return round($decimal, 6);
}
private function gpsValueToFloat(mixed $value): ?float
{
if (is_numeric($value)) {
return (float) $value;
}
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
if (! str_contains($raw, '/')) {
return is_numeric($raw) ? (float) $raw : null;
}
[$numerator, $denominator] = array_pad(explode('/', $raw, 2), 2, null);
if (! is_numeric($numerator) || ! is_numeric($denominator) || (float) $denominator === 0.0) {
return null;
}
return (float) $numerator / (float) $denominator;
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services;
use App\Models\Amministratore;
use App\Models\Stabile;
use InvalidArgumentException;
class TenantArchivePathService
{
public function amministratoreCode(Amministratore | string $amministratore): string
{
if (is_string($amministratore)) {
$code = trim($amministratore);
if ($code === '') {
throw new InvalidArgumentException('Codice amministratore non valido.');
}
return $code;
}
$code = trim((string) ($amministratore->codice_amministratore ?: $amministratore->codice_univoco ?: ''));
if ($code === '') {
throw new InvalidArgumentException('Amministratore senza codice archivio canonico.');
}
return $code;
}
public function stabileCode(Stabile | string $stabile): string
{
if (is_string($stabile)) {
$code = trim($stabile);
if ($code === '') {
throw new InvalidArgumentException('Codice stabile non valido.');
}
return $code;
}
$code = trim((string) ($stabile->codice_stabile ?? ''));
if ($code === '') {
throw new InvalidArgumentException('Stabile senza codice archivio canonico.');
}
return $code;
}
public function amministratoreRelativePath(Amministratore | string $amministratore, ?string $suffix = null): string
{
$basePath = 'amministratori/' . $this->amministratoreCode($amministratore);
return $this->appendSuffix($basePath, $suffix);
}
public function amministratoreAbsolutePath(Amministratore | string $amministratore, ?string $suffix = null): string
{
return storage_path('app/' . $this->amministratoreRelativePath($amministratore, $suffix));
}
public function stabileRelativePath(Stabile $stabile, ?string $suffix = null): string
{
$basePath = $this->amministratoreRelativePath($stabile->amministratore) . '/stabili/' . $this->stabileCode($stabile);
return $this->appendSuffix($basePath, $suffix);
}
public function stabileAbsolutePath(Stabile $stabile, ?string $suffix = null): string
{
return storage_path('app/' . $this->stabileRelativePath($stabile, $suffix));
}
private function appendSuffix(string $basePath, ?string $suffix): string
{
$normalizedSuffix = trim((string) $suffix, '/');
if ($normalizedSuffix === '') {
return $basePath;
}
return $basePath . '/' . $normalizedSuffix;
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Models\TenantArchiveRegistry;
use Illuminate\Support\Collection;
class TenantArchiveRegistryService
{
public function __construct(private TenantArchivePathService $tenantArchivePath)
{
}
public function buildAmministratorePayload(Amministratore $amministratore): array
{
$adminCode = $this->tenantArchivePath->amministratoreCode($amministratore);
return [
'archive_type' => 'amministratore',
'archive_code' => $adminCode,
'amministratore_id' => $amministratore->id,
'stabile_id' => null,
'owner_amministratore_id' => $amministratore->id,
'owner_amministratore_code' => $adminCode,
'database_name' => $amministratore->database_attivo ?: null,
'database_strategy' => $amministratore->database_attivo ? 'dedicated' : 'shared',
'storage_disk' => 'local',
'storage_relative_path' => $this->tenantArchivePath->amministratoreRelativePath($amministratore),
'status' => (bool) ($amministratore->attivo ?? true) ? 'active' : 'inactive',
'last_aligned_at' => now(),
'meta' => [
'codice_univoco' => $amministratore->codice_univoco,
'codice_amministratore' => $amministratore->codice_amministratore,
'cartella_dati' => $amministratore->cartella_dati,
],
];
}
public function buildStabilePayload(Stabile $stabile): array
{
$ownerCode = $this->tenantArchivePath->amministratoreCode($stabile->amministratore);
$transfer = $stabile->latestAmministratoreTransfer;
return [
'archive_type' => 'stabile',
'archive_code' => $this->tenantArchivePath->stabileCode($stabile),
'amministratore_id' => $stabile->amministratore_id,
'stabile_id' => $stabile->id,
'owner_amministratore_id' => $stabile->amministratore_id,
'owner_amministratore_code' => $ownerCode,
'database_name' => $stabile->database_dedicato ?: null,
'database_strategy' => $stabile->database_dedicato ? 'dedicated' : 'shared',
'storage_disk' => 'local',
'storage_relative_path' => $this->tenantArchivePath->stabileRelativePath($stabile),
'status' => (bool) ($stabile->attivo ?? true) ? 'active' : 'inactive',
'last_aligned_at' => now(),
'meta' => [
'denominazione' => $stabile->denominazione,
'codice_stabile' => $stabile->codice_stabile,
'documenti_path' => $stabile->documenti_path,
'latest_transfer_at' => optional($transfer?->created_at)->toIso8601String(),
'latest_transfer_source' => $transfer?->source,
'latest_transfer_reason' => $transfer?->reason,
'latest_transfer_meta' => $transfer?->meta ?? [],
],
];
}
public function syncAmministratore(Amministratore $amministratore): TenantArchiveRegistry
{
return TenantArchiveRegistry::updateOrCreate(
[
'archive_type' => 'amministratore',
'archive_code' => $this->tenantArchivePath->amministratoreCode($amministratore),
],
$this->buildAmministratorePayload($amministratore)
);
}
public function syncStabile(Stabile $stabile): TenantArchiveRegistry
{
return TenantArchiveRegistry::updateOrCreate(
[
'archive_type' => 'stabile',
'archive_code' => $this->tenantArchivePath->stabileCode($stabile),
],
$this->buildStabilePayload($stabile)
);
}
public function syncAmministratori(iterable $amministratori): Collection
{
return collect($amministratori)->map(fn(Amministratore $amministratore) => $this->syncAmministratore($amministratore));
}
public function syncStabili(iterable $stabili): Collection
{
return collect($stabili)->map(fn(Stabile $stabile) => $this->syncStabile($stabile));
}
}

View File

@ -1,5 +1,4 @@
<?php
namespace App\Support;
use App\Models\Stabile;
@ -13,6 +12,45 @@ class StabileContext
{
public const SESSION_KEY = 'netgescon.stabile_attivo_id';
/**
* @return array<int, string>
*/
public static function legacyCodeCandidates(?Stabile $stabile): array
{
if (! $stabile instanceof Stabile) {
return [];
}
$rawCandidates = [
trim((string) ($stabile->codice_stabile ?? '')),
trim((string) ($stabile->old_id ?? '')),
trim((string) ($stabile->cod_stabile ?? '')),
];
$candidates = [];
foreach ($rawCandidates as $candidate) {
if ($candidate === '') {
continue;
}
$candidates[] = $candidate;
$noZero = ltrim($candidate, '0');
if ($noZero !== '' && $noZero !== $candidate) {
$candidates[] = $noZero;
}
}
return array_values(array_unique($candidates));
}
public static function preferredLegacyCode(?Stabile $stabile): ?string
{
$candidates = self::legacyCodeCandidates($stabile);
return $candidates[0] ?? null;
}
public static function setFromStabile(Stabile $stabile, ?User $user = null): bool
{
$user = $user ?? Auth::user();

View File

@ -8,6 +8,7 @@
use App\Console\Commands\GesconImportBanche;
use App\Console\Commands\GesconImportFattureAdeCsv;
use App\Console\Commands\GesconImportFornitoriLegacyMdb;
use App\Console\Commands\GesconMaterializePersoneRelazioniCommand;
use App\Console\Commands\GesconQaContatori;
use App\Console\Commands\GesconQaEmissione;
use App\Console\Commands\GesconResyncStabili;
@ -24,6 +25,7 @@
use App\Console\Commands\NetgesconDevelopmentSnapshotCommand;
use App\Console\Commands\NetgesconDistributionPullCommand;
use App\Console\Commands\NetgesconGitSyncCommand;
use App\Console\Commands\NetgesconApplyPbxPresetCommand;
use App\Console\Commands\NetgesconPreupdateBackupCommand;
use App\Console\Commands\NetgesconQaUnitaNominativiCommand;
use App\Console\Commands\NetgesconRestoreRecordCommand;
@ -45,6 +47,7 @@
GesconImportBanche::class,
GesconImportFattureAdeCsv::class,
GesconImportFornitoriLegacyMdb::class,
GesconMaterializePersoneRelazioniCommand::class,
GesconResyncStabili::class,
GesconCollegaFornitoreStabili::class,
SyncSoggettiToPersone::class,
@ -60,6 +63,7 @@
ImportGesconF24LegacyCommand::class,
NetgesconQaUnitaNominativiCommand::class,
NetgesconArchiveRegistrySyncCommand::class,
NetgesconApplyPbxPresetCommand::class,
NetgesconDevelopmentSnapshotCommand::class,
NetgesconDistributionPullCommand::class,
NetgesconGitSyncCommand::class,

View File

@ -1,14 +1,14 @@
<?php
return [
'shared_token' => env('NETGESCON_CTI_SHARED_TOKEN', ''),
'legacy_tokens' => [
'shared_token' => env('NETGESCON_CTI_SHARED_TOKEN', ''),
'legacy_tokens' => [
env('CTI_SHARED_SECRET', ''),
env('CTI_SHARED_TOKEN', ''),
env('CTI_TOKEN', ''),
env('CTI_PANASONIC_TOKEN', ''),
],
'mirror_enabled' => (bool) env('NETGESCON_CTI_MIRROR_ENABLED', false),
'mirror_base_url' => env('NETGESCON_CTI_MIRROR_BASE_URL', ''),
'mirror_enabled' => (bool) env('NETGESCON_CTI_MIRROR_ENABLED', false),
'mirror_base_url' => env('NETGESCON_CTI_MIRROR_BASE_URL', ''),
'mirror_timeout_seconds' => (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2),
];

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('tenant_archive_registries')) {
return;
}
Schema::create('tenant_archive_registries', function (Blueprint $table) {
$table->id();
$table->string('archive_type', 30);
$table->string('archive_code', 80);
$table->foreignId('amministratore_id')->nullable()->constrained('amministratori')->nullOnDelete();
$table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete();
$table->foreignId('owner_amministratore_id')->nullable()->constrained('amministratori')->nullOnDelete();
$table->string('owner_amministratore_code', 80)->nullable();
$table->string('database_name', 120)->nullable();
$table->string('database_strategy', 30)->default('shared');
$table->string('storage_disk', 40)->default('local');
$table->string('storage_relative_path', 255);
$table->string('status', 30)->default('active');
$table->timestamp('last_aligned_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->unique(['archive_type', 'archive_code'], 'tenant_archive_type_code_unique');
$table->index(['owner_amministratore_id', 'archive_type'], 'tenant_archive_owner_type_idx');
$table->index(['stabile_id', 'status'], 'tenant_archive_stabile_status_idx');
});
}
public function down(): void
{
Schema::dropIfExists('tenant_archive_registries');
}
};

View File

@ -0,0 +1,131 @@
# Guida Import GESCON da MDB
## Obiettivo
Usare `Stabili.mdb` come sorgente di controllo iniziale per decidere quali stabili importare, con una finestra dati prudente e verificabile prima della distribuzione operativa.
## Sorgenti da considerare
### 1. Metadati stabili
- `dbc/Stabili.mdb`
- campi chiave attesi:
- `cod_stabile`
- `nome_directory`
- campo CF stabile/condominio valorizzato
### 2. Dati annuali per stabile
- `<cod_stabile>/<anno>/singolo_anno.mdb`
- tabelle minime da validare:
- `condomin`
- `operazioni`
- `rate`
- `incassi`
- `dett_tab`
- `voc_spe`
- `straordinarie`
### 3. Dati trasversali stabile
- `<cod_stabile>/generale_stabile.mdb`
- utile per verifiche aggiuntive su rate emesse, casse e dati generali
## Regole operative di preselezione
Importare nello smoke test solo gli stabili che rispettano tutte queste condizioni:
1. lo stabile esiste in `Stabili.mdb`
2. il campo CF stabile/condominio e valorizzato
3. `cod_stabile` e presente e usabile come codice mnemonico principale
4. `nome_directory` e presente e punta a una cartella archivio esistente
## Regole di naming e archivio
- codice stabile operativo: usare `stabile.cod_stabile`
- cartella archivio stabile: usare `stabile.nome_directory`
- cartella dominio NetGescon: usare sempre il codice univoco/canonico dello stabile
- sincronizzazione documentale: mantenere mapping stabile tra codice canonico NetGescon e cartella legacy, cosi da poter allineare anche Google Drive
## Finestra temporale smoke test
Per il primo passaggio operativo non importare tutto lo storico.
### Import base
- anno corrente legacy piu recente disponibile
- anno precedente
### Import esteso solo per straordinarie
- includere fino a 5 anni indietro
- fermarsi prima se non esistono MDB annuali o non esistono righe utili nelle tabelle straordinarie
### Storico oltre la finestra base
- da importare solo su richiesta o quando serve per quadrature, estratti conto storici o audit mirati
## Copertura minima richiesta per rendere operativo il sistema
Lo smoke test deve confermare che per ogni stabile selezionato siano importabili almeno:
1. nominativi completi da `condomin`
2. unita immobiliari e riferimenti scala/interno/piano
3. collegamenti proprietario/inquilino/comproprietari
4. voci spesa e tabelle millesimali essenziali
5. rate e incassi degli ultimi 2 anni
6. straordinarie degli ultimi 5 anni, se presenti
Questo e il minimo per attivare senza dati monchi:
- ticket
- letture acqua
- CRM/rubrica unica
- estratti conto da cellulare
- pagamento online rate condominiali
## Smoke Test pratico per stabile
Per ogni stabile filtrato da `Stabili.mdb` eseguire questa checklist.
### A. Controllo metadati
- `cod_stabile` valorizzato
- `nome_directory` valorizzato
- CF stabile valorizzato
- cartella legacy esistente
### B. Controllo annualita
- individuato ultimo anno disponibile
- individuato anno precedente disponibile
- individuate eventuali annualita straordinarie fino a -5 anni
### C. Controllo file
- esiste `singolo_anno.mdb` per ogni anno scelto
- se presente, esiste `generale_stabile.mdb`
- `Stabili.mdb` leggibile senza errori
### D. Controllo tabelle minime
- `condomin` leggibile
- `operazioni` leggibile
- `rate` leggibile
- `incassi` leggibile
- `dett_tab` leggibile
- `voc_spe` leggibile
- `straordinarie` leggibile o assente in modo coerente
### E. QA dominio post-import
- stabile creato/aggiornato correttamente
- unita importate
- rubrica/nominativi deduplicati
- rate/incassi coerenti per gli anni caricati
- nessun crash nelle pagine operative principali
## Raccomandazione di rollout
Ordine consigliato:
1. caricare elenco candidati da `Stabili.mdb`
2. filtrare solo stabili con CF valorizzato
3. per ciascuno verificare `nome_directory`
4. importare ultimi 2 anni
5. importare straordinarie fino a 5 anni
6. eseguire QA per stabile/anno
7. abilitare progressivamente ticket, letture, CRM e consultazione mobile
## Nota importante
La tabella staging `gescon_import.stabili` non va usata come unica base per questa preselezione se i CF risultano non materializzati. In quel caso la fonte corretta per lo smoke test resta il `Stabili.mdb` reale.

View File

@ -456,11 +456,11 @@ ### 11.4 Dove vedere le nuove chiamate su staging
Quando l'invio live va a buon fine, le nuove chiamate Panasonic compaiono in due punti applicativi:
1. `Strumenti -> Post-it Gestione`
- legge i record `chiamate_post_it`
- e il punto principale per vedere le chiamate create dal bridge
- legge i record `chiamate_post_it`
- e il punto principale per vedere le chiamate create dal bridge
2. flussi tecnici basati su `communication_messages`
- canale `panasonic_csta`
- utili per verifica tecnica e correlazione con routing/intercettazione operatore
- canale `panasonic_csta`
- utili per verifica tecnica e correlazione con routing/intercettazione operatore
In aggiunta, il banner operatore mobile usa anche `panasonic_csta`, quindi una chiamata instradata a un interno monitorato puo comparire anche lato operatore senza passare solo da SMDR.

View File

@ -6,6 +6,19 @@ # Modifiche NetGescon
| Data | Versione | Area | Descrizione | Tipo |
| --- | --- | --- | --- | --- |
| 01/04/2026 | 0.8.x-dev | Gescon / Import / Anagrafica unica | Introdotto `gescon:materialize-persone-relazioni` per materializzare `persone` e `persone_unita_relazioni` da `unita_immobiliare_nominativi`, con innesto dello step `relazioni` nel `gescon:import-full` | [P] |
| 01/04/2026 | 0.8.x-dev | Supporto / Modifiche / Git staging | Spostato il flusso `Aggiornamento staging da Gitea` nella tab `Aggiornamenti + Bug`, con riepilogo esplicito di cosa aggiorna la UI e del comando predisposto `netgescon:git-sync` | [U] |
| 01/04/2026 | 0.8.x-dev | Rubrica / Stabili / Relazioni | Riallineato il caso reale `GERMANICO 96` materializzando 191 relazioni persona-unita e rilanciando la sync ruoli rubrica dal dato legacy gia importato | [U] |
| 28/03/2026 | 0.8.x-dev | SuperAdmin / Filament / QA | Esteso `SuperAdmin > Controllo qualità` con audit riusabile per `Ordinarie`, filtro stabile/anno e integrazione nel flusso QA esistente Filament/controller/export | [P] |
| 28/03/2026 | 0.8.x-dev | Gescon / Ordinarie / Consuntivo | Ripristinata la TAB `consuntivo` in `gescon/ordinarie` correggendo una porzione di query corrotta che causava il crash Livewire con `Undefined variable $rowsQuery` | [U] |
| 28/03/2026 | 0.8.x-dev | Storage / Tenancy / Registry | Introdotta infrastruttura base del registry centrale archivio (`tenant_archive_registries`, servizio di sync e comando `netgescon:archive-registry-sync`) per riallineare archivi amministratore/stabile su codici canonici | [P] |
| 28/03/2026 | 0.8.x-dev | Storage / Tenancy / Anagrafica unica | Introdotto resolver unico dei path archivio tenant-aware basato sui codici canonici di amministratore e stabile, per eliminare divergenze tra `codice_amministratore`, `codice_univoco` e cartelle fisiche | [P] |
| 28/03/2026 | 0.8.x-dev | Storage / Tenancy / Sicurezza | Documentata la distinzione tra backup tecnico del nodo e archivi tenant, con roadmap consigliata: separazione file per amministratore/stabile prima di valutare stack Docker dedicati | [P] |
| 28/03/2026 | 0.8.x-dev | Supporto / Continuita sviluppo | Aggiunta snapshot automatica locale di continuita sviluppo, rigenerabile dalla UI e integrata nella sync Git, con sezione dedicata allo stato PBX Panasonic | [P] |
| 28/03/2026 | 0.8.x-dev | Supporto / Modifiche / Sync Git | Aggiunta sync staging da Gitea via UI con job asincrono, stato repository, progress bar e rigenerazione changelog Git automatico in area non tracciata del nodo | [P] |
| 28/03/2026 | 0.8.x-dev | Supporto / Modifiche / Manuale | Introdotta sezione Manuale in `admin-filament/supporto/modifiche` con documenti Markdown strutturali versionati nel repo per sviluppo, bug risolti e continuita operativa | [P] |
| 28/03/2026 | 0.8.x-dev | CTI / CSTA / Post-It | Separati i tab tecnici SMDR e CTI-CSTA in `strumenti/post-it-gestione`, con richieste click-to-call e vista provider-oriented per tenere il layer PBX aperto anche a FreePBX | [U] |
| 28/03/2026 | 0.8.x-dev | Gescon / Ordinarie / Servizi Utenze | Allineati filtri stabile e anno al contesto topbar in `ordinarie`, `servizi-utenze` e archivio letture per ridurre mismatch sui dati e rendere operative le estrazioni acqua per gestione | [U] |
| 08/03/2026 | 0.8.x-dev | Filament / Google / Ticket | Widget `admin-filament` esteso con export Rubrica NetGescon -> Google Contacts e preview ticket aperti per test operativi | [U] |
| 08/03/2026 | 0.8.x-dev | Filament / Navigazione | Resi visibili link UI locale ticket/fornitore/dipendenti in `admin-filament` anche con setup permessi incompleto | [U] |
| 08/03/2026 | 0.8.x-dev | Import legacy / Rubrica | Fix mapping `condomin.cell_cond` e fallback email/cellulare proprietario-inquilino nella sync anagrafiche | [U] |

View File

@ -84,6 +84,11 @@ ## 🧭 Documentazione & Indici
- `00-COPILOT-HANDOFF-MASTER.md` handoff immediato per riprendere il lavoro dopo crash o cambio turno.
- `00-INDICE-DOCS-UNIFICATA.md` indice generale aggiornato (sezione “Automation” per log auto-generati).
- `support/MANUALE-SVILUPPO-OPERATIVO.md` manuale operativo strutturale per sviluppo, update e continuita delle sessioni.
- `support/PBX-PANASONIC-STATO-OPERATIVO.md` stato tecnico e operativo dell integrazione Panasonic CTI/CSTA e dei prossimi test PBX.
- `support/ARCHITETTURA-STORAGE-TENANCY.md` decisione architetturale su isolamento file per amministratore/stabile e valutazione pragmatica del modello Docker per stabile.
- `support/BUG-RISOLTI-E-VALIDAZIONI.md` registro sintetico bug corretti, fix applicati e verifiche ancora da completare.
- `support/CONTINUITA-SVILUPPO-AGENT.md` procedura minima per mantenere allineati manuale, note commit e sviluppo con agent.
- `docs/automation/` commit log mensile, summary giornalieri e protocollo handoff automatici.
- `07-IMPORT-UNITA-IMMOBILIARI.md` blueprint operativo per importare unità e collegare proprietari/inquilini.
- `08-ANAGRAFICA-UNICA-UI.md` specifica UX della scheda anagrafica unica e relativi widget condominiali.

View File

@ -0,0 +1,105 @@
# Aggiornamenti staging via Git e Gitea
## Regola operativa
Per l ambiente `staging.netgescon.it` il flusso corretto di aggiornamento deve passare sempre da Git.
Ordine corretto:
1. sviluppo e test nel workspace Day0
2. aggiornamento documentazione o note operative nel repository
3. commit Git leggibile
4. push su Gitea `origin/main`
5. riallineamento di staging dalla pagina `Supporto > Modifiche` con il pulsante `Aggiorna staging da Gitea`
Non va considerato flusso ordinario:
- copiare file a mano su staging
- correggere direttamente file remoti senza commit
- usare rsync o patch locali non tracciate come percorso principale
## UI e comando predisposto
La UI di `admin-filament/supporto/modifiche` usa il comando predisposto:
```bash
php artisan netgescon:git-sync --remote=origin --branch=main
```
Il comando:
1. fa fetch dal repository Gitea
2. allinea il nodo al branch richiesto
3. rigenera il changelog Git automatico per la pagina supporto
4. salva esito e diagnostica in `storage/app/support/generated/git-sync-last.json`
Quindi la UI non e un percorso separato: e il front-end controllato del flusso Git ufficiale.
## Cosa stiamo aggiornando in questo momento
### 1. Supporto > Modifiche come pannello operativo reale
- il blocco `Aggiornamento staging da Gitea` e stato spostato nella tab `Aggiornamenti + Bug`
- la pagina ora deve spiegare con chiarezza che staging si aggiorna da Git/Gitea
- l operatore deve vedere dalla stessa UI sia il pulsante di sync sia il contenuto funzionale del rilascio
Effetto pratico:
- [U] chi aggiorna staging non deve ricordare procedure esterne
- [P] il flusso Day0 -> Gitea -> staging resta coerente e auditabile
### 2. Import Gescon e collegamenti persone/unita
- e stato individuato un buco reale nel flusso import: `unita_immobiliare_nominativi` era popolata, ma `persone_unita_relazioni` restava vuota
- e stato introdotto il comando `gescon:materialize-persone-relazioni`
- il comando crea/aggiorna `persone` e materializza le relazioni persona-unita partendo dai nominativi gia importati
- il `gescon:import-full` ora include anche lo step `relazioni`
Effetto pratico:
- [U] i nominativi in rubrica e nelle schede unita tornano collegabili allo stabile e alle unita corrette
- [P] il full import non si ferma piu a meta tra staging legacy e dominio applicativo
### 3. Riallineamento ruoli rubrica dopo la materializzazione
- dopo la creazione di `persone_unita_relazioni` viene rilanciata la sync `gescon:sync-rubrica-ruoli`
- questo permette di ricreare o aggiornare i ruoli `condomino` e `inquilino` nelle unita dello stabile
Effetto pratico:
- [U] la rubrica universale mostra di nuovo il collegamento operativo con unita e stabile
- [P] la verifica su `GERMANICO 96` e diventata ripetibile e non piu una correzione manuale
## Caso reale gia riallineato
### GERMANICO 96
- stabile dominio individuato con `codice_stabile = 0016`
- nominativi presenti in `unita_immobiliare_nominativi`: `191`
- materializzazione eseguita:
- `110` persone create
- `191` relazioni create
- sync ruoli successiva:
- `179` ruoli inseriti
- `12` ruoli aggiornati
Questo e il tipo di aggiornamento che deve comparire e restare leggibile nella UI, perche impatta direttamente l operativita su nominativi, unita, procedure e schede rubrica.
## Cosa deve leggere l operatore in questa pagina
Quando apre `Supporto > Modifiche`, l operatore deve trovare subito:
1. come si aggiorna staging
2. quale comando usa la UI
3. quali commit e quali migliorie stanno per arrivare sul nodo
4. quali fix strutturali sono stati appena distribuiti
5. se l aggiornamento e andato bene oppure no
## Regola finale
Per NetGescon il deploy di staging deve restare Git-first:
- sorgente canonica: repository Gitea
- punto di esecuzione assistita: UI `Supporto > Modifiche`
- comando tecnico sottostante: `netgescon:git-sync`
- documentazione di cio che si sta distribuendo: file Markdown versionati nel repository

View File

@ -0,0 +1,248 @@
# Architettura storage e tenancy
## Punto di partenza reale
- il nodo staging attuale gira su questa macchina in ` /var/www/netgescon/ `
- il backup visto in `storage/app/update-backups/...` e un backup tecnico del nodo applicativo, non un archivio documentale per amministratore o per stabile
- oggi il progetto e impostato come multi-tenant logico, soprattutto tramite `amministratore_id` e `stabile_id`
## Distinzione importante da non perdere
Ci sono due problemi diversi:
### 1. Backup tecnico del nodo
Serve per:
- rollback di un aggiornamento
- snapshot pre-update
- ripristino tecnico applicazione
Questo backup puo restare a livello nodo, perche riguarda il sito installato in quella macchina.
### 2. Archivi documentali dei condomini/stabili
Servono per:
- documenti utenti
- bilanci
- consuntivi
- allegati ticket
- pratiche e storico per stabile
Questi invece vanno isolati in modo molto piu rigoroso per amministratore e per stabile.
## Cosa si puo replicare davvero dal modello archivi MySQL visto negli screenshot
Negli screenshot il concetto utile non e "forzare MySQL a salvare la singola tabella dove vogliamo", ma questo:
- ogni archivio ha un proprio contenitore fisico riconoscibile
- dentro quel contenitore ci sono le tabelle operative di quell archivio
- il codice gestionale punta all archivio giusto tramite un codice stabile e non tramite il nome casuale del file
Su MySQL moderno la traduzione corretta e questa:
- database centrale registry condiviso per autenticazione, configurazioni globali, mapping e storico trasferimenti
- database archivio dedicato per amministratore o per stabile, nominato con il codice canonico
- storage documentale separato con lo stesso codice canonico
Esempio coerente con NetGescon:
```text
registry condiviso:
netgescon_core
archivi amministratore:
ng_arch_admin_ADM0001
ng_arch_admin_ADM0002
archivi stabile:
ng_arch_stb_148
ng_arch_stb_205
storage file:
storage/app/amministratori/ADM0001/stabili/148/
storage/app/amministratori/ADM0001/stabili/205/
```
Questo da un risultato molto vicino al software che hai mostrato, ma in una forma compatibile con Laravel, backup, restore e trasferimento stabile tra amministratori.
## Valutazione dell idea: un Docker per ogni stabile
### Fattibilita tecnica
Si, e fattibile.
### Valutazione pratica
Come idea di massima e valida per l isolamento forte, ma non e il primo passo giusto per NetGescon oggi.
Motivi:
- aumenterebbe molto la complessita operativa
- obbligherebbe a gestire migrazioni, immagini, log, backup, code e monitoraggio per molti container
- renderebbe piu pesante il deploy di funzionalita comuni
- il codice attuale e ancora pensato come applicazione condivisa con separazione logica dei dati, non come fleet di micro-istallazioni per stabile
## Strategia consigliata senza rivoluzionare tutto
### Fase 1. Isolamento storage per amministratore e stabile
Questo e il passo giusto da fare adesso.
Struttura consigliata:
```text
/var/www/netgescon/storage/app/tenants/
amministratore-12/
stabile-146/
documenti/
bilanci/
ticket/
acqua/
consuntivi/
amministratore-18/
stabile-205/
documenti/
bilanci/
ticket/
```
Vantaggi:
- isolamento fisico piu chiaro dei file
- minor rischio di accessi incrociati accidentali
- nessuna rivoluzione immediata dell architettura Laravel
- facilita backup/export/trasporto per singolo stabile
- usa da subito i codici canonici gia presenti in anagrafica unica, evitando dipendenze da ID locali
### Fase 1-bis. Resolver unico dei percorsi archivio
Questo e il primo pezzo tecnico concreto da imporre nel codice:
- un solo servizio decide il path canonico per amministratore e stabile
- il path deve usare i codici canonici, non gli ID locali
- ogni comando, import, export, restore e upload deve passare da li
Questo evita il problema attuale piu pericoloso: alcuni punti usano `codice_amministratore`, altri `codice_univoco`, e il filesystem rischia di divergere.
### Fase 2. Disk privati dedicati e regole applicative
Dopo la struttura cartelle, il passo corretto e:
- usare solo storage privato per documenti sensibili
- servire i file via controller autorizzati
- centralizzare il path resolver per amministratore/stabile
- impedire che un path venga calcolato senza contesto stabile valido
### Fase 3. Backup/export per stabile
Una volta separati i file, si puo aggiungere:
- zip esportabile per singolo stabile
- dump dati mirato per stabile
- pacchetto di trasporto o consegna per cambio gestione
### Fase 3-bis. Separazione MySQL realmente utile
Se vogliamo ottenere un effetto simile agli archivi del software legacy, la scelta migliore non e una tabella fisica per ogni ditta nello stesso schema, ma una di queste due:
1. database dedicato per amministratore e tabelle interne per i suoi stabili
2. database dedicato per stabile quando serve portabilita massima
Per NetGescon oggi la scelta piu equilibrata e:
- registry centrale condiviso
- archivio MySQL dedicato almeno per amministratore
- opzionale archivio dedicato per stabile nei casi che richiedono trasporto o isolamento forte
Questa direzione e gia piu vicina al codice esistente, perche nel progetto esistono gia servizi e comandi che prevedono `database_attivo` e `database_dedicato`.
### Fase 4. Nodo dedicato per amministratore o cliente grande
Qui entra in gioco Docker, ma in modo piu sensato:
- un container o stack per amministratore grande
- oppure un container dedicato per cliente enterprise
- non uno per ogni stabile come default
Questo da un vero isolamento senza moltiplicare troppo la complessita.
## Raccomandazione architetturale
### Default consigliato
- codice applicativo condiviso
- database condiviso con domini ben separati
- storage fisico separato per amministratore e stabile
- regole di autorizzazione strette lato applicazione
### Eccezione premium o ad alta sicurezza
- stack Docker dedicato per amministratore
- database dedicato
- volume dedicato
- dominio dedicato
Questa e la soluzione piu equilibrata.
## Come integrarla senza stravolgere il codice
### Passo 1
Introdurre un resolver unico dei path documentali, ad esempio un servizio applicativo tipo:
- `TenantStoragePathService`
Compito:
- riceve `amministratore_id`, `stabile_id`, tipo archivio
- restituisce il path corretto e validato
### Passo 2
Migrare progressivamente le aree piu sensibili:
- allegati ticket
- documenti stabile
- bilanci e consuntivi
- documenti acqua e ripartizioni
### Passo 3
Lasciare i backup update in `storage/app/update-backups/` come backup tecnici del nodo, separati dai documenti tenant.
## Decisione pratica attuale
Per NetGescon oggi la scelta piu corretta e:
1. non usare un Docker per ogni stabile come configurazione standard
2. separare subito i file per amministratore e stabile
3. mantenere il nodo staging in ` /var/www/netgescon/ ` come installazione condivisa di test
4. prevedere in futuro stack Docker dedicati per amministratori o clienti che richiedono isolamento forte
## Prossimo passo tecnico consigliato
Il prossimo sviluppo coerente e introdurre il layer di storage tenant-aware senza toccare ancora il modello runtime a container per stabile.
## Registry centrale introdotto nel progetto
Per mettere a terra il punto 1 e stato introdotto il concetto di registry centrale archivio:
- tabella `tenant_archive_registries`
- modello `TenantArchiveRegistry`
- servizio `TenantArchiveRegistryService`
- comando `php artisan netgescon:archive-registry-sync`
Scopo:
- avere una mappa unica e rigenerabile degli archivi amministratore e stabile
- legare codice archivio, owner corrente, path storage e strategia database
- rendere piu semplice riallineare ambienti di test con dati sporchi o movimenti tra amministratori
Questo registry non sostituisce ancora la logica applicativa dei moduli, ma crea il punto centrale da cui partire per:
- pulizia dati
- export/import per stabile
- trasferimento stabile tra amministratori
- futura separazione MySQL per archivio

View File

@ -0,0 +1,79 @@
# Assistenza TecnoRepair MDB
## Stato iniziale
- Il file MDB nel workspace e leggibile da Linux con `mdb-tools`.
- Archivio verificato: `Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb`.
- Obiettivo Day0: usare l'MDB come sorgente legacy leggibile e portare gestione clienti, fornitori e schede riparazione dentro NetGescon senza scrivere direttamente nel file Access.
## Tabelle lette dal file reale
### Anagrafiche principali
- `TClienti`
Campi chiave: `ID`, `NomeCognome`, `NumeroTelefono`, `TelFisso`, `Email`, `CodFis`, `PIVA`, `Indirizzo`, `Citta`, `Cap`, `Prov`, `Annotazioni`.
- `TFornitori`
Campi chiave: `ID`, `RagioneSociale`, `PIVA`, `Annotazioni`.
- `TCommittenti`
Campi chiave: `ID`, `Codice`, `Committente`, `Telefono`, `Email`, `PIVA`, `Utente`, `Pwd`.
- `TCentriAss`
Campi chiave: `ID`, `RagioneSociale`, `Telefono`, `Email`, `PIVA`, indirizzo completo.
### Schede assistenza e allegati
- `TApparecchi`
Campi chiave: `ID`, `NumeroScheda`, `DataIngresso`, `ID_Cliente`, `ID_CentroAss`, `Cod_Committente`, `Modello`, `SerialNumber`, `SerialNumber2`, `CodiceProdotto`, `DifettoSegnalato`, `DescrizioneRiparazione`, `StatoRiparazione`, `ID_StatoRip`, `NomeOperatore`, `NomeTecnicoRiparatore`, `DataOrdine`, `NumOrdine`, `CodiceRMA`, `CodicePIN`, `CodiceSblocco`, `Comunicazioni`, `FileAllegato1`.
- `TAllegati`
Campi chiave: `ID`, `ID_Scheda`, `NumAllegato`, `FileAllegato`.
### Tabelle di supporto gia viste
- `TTelefono`
- `TMarche`
- `TTipiApparecchi`
- `TTipoIntervento`
- `TTipoGuasto`
- `TStatoRip`
- `TLavorazioni`
- `TRicambi*`
## Mappa proposta verso NetGescon
### Primo livello, non distruttivo
- `TClienti` -> rubrica tecnica / contatti assistenza
- `TFornitori`, `TCentriAss`, `TCommittenti` -> fornitori e partner assistenza
- `TApparecchi` -> archivio schede manutenzione/riparazione legacy da collegare a fornitore, cliente e ticket
- `TAllegati` -> allegati scheda legacy
### Regola tecnica consigliata
- leggere dall'MDB in sola lettura
- normalizzare dentro tabelle/appositi modelli NetGescon
- non scrivere direttamente nel file `.mdb` da Linux come archivio operativo principale
Motivo:
- la lettura e gia affidabile
- la scrittura diretta su Access/MDB fuori da Windows/Access resta fragile
- NetGescon deve diventare il layer operativo e storico principale
## Collegamenti operativi gia individuati
- `TApparecchi.ID_Cliente` -> `TClienti.ID`
- `TApparecchi.ID_CentroAss` -> `TCentriAss.ID`
- `TApparecchi.Cod_Committente` -> `TCommittenti.ID` o `Codice` da verificare in import
- `TAllegati.ID_Scheda` -> `TApparecchi.ID`
## Prossimo step pragmatico
1. creare staging NetGescon per `clienti_assistenza_legacy`, `fornitori_assistenza_legacy`, `schede_assistenza_legacy`, `schede_assistenza_allegati_legacy`
2. importare il file TecnoRepair in sola lettura
3. collegare `TClienti` e `TFornitori` alla rubrica/fornitori NetGescon con matching su telefono, email, CF/PIVA
4. esporre in UI la scheda riparazione collegata a cliente, fornitore e ticket/intervento
## Decisione corrente
- Si: NetGescon puo leggere l'MDB subito.
- Si: possiamo usare l'MDB come sorgente legacy di partenza.
- No: non conviene usare la scrittura diretta verso MDB come modello operativo principale.

View File

@ -0,0 +1,63 @@
# Bug risolti e validazioni
## Obiettivo
Questa sezione tiene separato il materiale di fix dal manuale generale. Qui annotiamo:
- bug corretti
- fix applicato
- stato della verifica
- eventuali controlli ancora da fare su staging
## Registro sintetico
### Ripristino bootstrap pannello admin
- Problema: `AdminFilamentPanelProvider.php` era stato corrotto da una modifica incompleta e impediva il bootstrap corretto.
- Fix applicato: ripristino struttura PHP e riallineamento del provider.
- Stato: [U] risolto localmente, bootstrap Laravel nuovamente valido.
### Token CTI Panasonic e lettura configurazione piu robusta
- Problema: il flusso staging poteva fallire per risoluzione non coerente del token CTI.
- Fix applicato: `PanasonicCstaController` riallineato a `config/cti.php` con risoluzione piu robusta.
- Stato: [P] implementato e verificato in locale; su staging resta da completare la validazione end-to-end del canale reale.
### Mismatch stabile/anno in Ordinarie
- Problema: la pagina `gescon/ordinarie` poteva mostrare dati non coerenti con stabile e anno scelti nella topbar.
- Fix applicato: default anno dal contesto condiviso e centralizzazione risoluzione codice legacy stabile.
- Stato: [P] implementato e validato a livello codice; da verificare sul caso reale staging con dati utente specifici.
### Filtri acqua e servizi non coerenti con la gestione attiva
- Problema: in `condomini/servizi-utenze` e archivio letture la selezione anno/stabile non era sempre rispettata in tutte le query.
- Fix applicato: applicazione del filtro anno attivo e del contesto stabile ai blocchi acqua principali.
- Stato: [P] implementato e testato sintatticamente; serve verifica funzionale con dati annuali reali su staging.
### Tracciato tecnico SMDR e CSTA mescolato
- Problema: una sola vista tecnica rendeva meno chiaro distinguere fallback SMDR da eventi CTI/CSTA ricchi.
- Fix applicato: nuovi tab distinti in `strumenti/post-it-gestione` e vista click-to-call dedicata.
- Stato: [U] implementato; da usare come base per test operativi PBX e future integrazioni provider-agnostic.
## Validazioni eseguite nell ultimo ciclo
- `php artisan list` eseguito con esito positivo dopo gli ultimi fix applicativi
- controlli sintattici PHP sulle classi modificate senza errori
- diagnostica editor senza errori sui file toccati
## Validazioni ancora aperte
- verificare su staging la nuova tab `CTI / CSTA e Click-to-Call`
- verificare il caso reale di mismatch stabile/anno con dati staging
- usare il filtro annuale acqua per l estrazione effettiva delle fatture e confermare il perimetro dati
## Regola di aggiornamento
Ogni bug corretto va aggiornato qui in quattro righe:
1. problema osservato
2. fix applicato
3. stato verifica locale
4. stato verifica staging o nodi remoti

View File

@ -0,0 +1,67 @@
# Continuita sviluppo e agent
## Scopo
Questo file serve a dare continuita alle prossime sessioni di sviluppo senza dover rileggere ogni volta tutti gli script o ricostruire il contesto dai commit.
## Regola semplice
Quando facciamo una modifica significativa, aggiorniamo tre livelli diversi solo se servono:
1. `docs/support/MANUALE-SVILUPPO-OPERATIVO.md` per la logica generale e l effetto pratico
2. `docs/support/BUG-RISOLTI-E-VALIDAZIONI.md` per fix, regressioni e stato test
3. tab `Commit + Note` in `Supporto > Modifiche` per contesto breve ma utile al riaggancio
## Cosa deve poter leggere un agent
Un agent deve trovare rapidamente:
- qual e il workspace autorevole
- qual e il flusso Git reale
- quali aree sono state appena toccate
- quali bug sono stati corretti e cosa resta da verificare
- qual e lo stato operativo del PBX Panasonic e quali test sono ancora aperti
- qual e la decisione corrente su storage, isolamento dati e tenancy
- quali documenti sono la fonte principale invece di note sparse
## Flusso operativo consigliato
### Dopo una modifica piccola
- aggiorna il changelog funzionale se impatta l utente o il flusso operativo
- aggiungi una nota commit se il messaggio Git non basta
### Dopo una modifica strutturale
- aggiorna il manuale operativo
- aggiorna il registro bug/fix se il lavoro nasce da un problema concreto
- verifica che la pagina `Supporto > Modifiche` mostri il nuovo materiale
### Dopo il push
- usa il tab manuale di `Supporto > Modifiche` per lanciare la sync da Gitea sul nodo interessato
- annota eventuale esito tecnico se c e stato un problema o un comportamento speciale
## Convenzione minima per scrivere
- frasi corte
- una sezione = un obiettivo
- distinguere chiaramente `implementato`, `validato`, `da verificare`
- evitare duplicati: se un dettaglio e manuale generale non riscriverlo come nota commit lunga
## Collegamenti utili
- `docs/README.md`
- `docs/NETGESCON-MODIFICHE.md`
- `docs/support/MANUALE-SVILUPPO-OPERATIVO.md`
- `docs/support/PBX-PANASONIC-STATO-OPERATIVO.md`
- `docs/support/ARCHITETTURA-STORAGE-TENANCY.md`
- `docs/support/BUG-RISOLTI-E-VALIDAZIONI.md`
## Direzione architetturale attuale
- Day0 resta il workspace e repository autorevole
- Git -> Gitea -> staging resta il flusso operativo reale
- staging deve potersi riallineare da interfaccia web senza richiedere terminale all operatore
- CTI/PBX va tenuto aperto a provider diversi, senza chiudersi su Panasonic
- le pagine gestionali devono seguire il contesto condiviso topbar per stabile e anno

View File

@ -0,0 +1,98 @@
# Manuale sviluppo operativo NetGescon
## Scopo della sezione
Questa documentazione serve a evitare dispersione di informazioni tra chat, script, note locali e memoria personale.
La regola pratica e semplice:
1. quello che diventa operativo o architetturale va scritto qui
2. quello che corregge un problema va riportato anche nel registro bug/fix
3. quello che richiede contesto extra puo essere annotato nel tab `Commit + Note`
4. il deploy o update applicativo parte poi dalla pagina `Supporto > Modifiche`
Per il dettaglio del flusso Git-first di staging fare riferimento anche a:
- `docs/support/AGGIORNAMENTI-STAGING-GIT.md`
## Flusso standard di lavoro
1. sviluppo nel workspace Day0
2. test locale mirato sulla funzione toccata
3. aggiornamento del file manuale o bug corrispondente
4. commit Git con messaggio leggibile
5. push su Gitea
6. aggiornamento del nodo dalla pagina `admin-filament/supporto/modifiche` con sync Git da Gitea
7. verifica finale su staging
## Effetto pratico
- [P] il progetto inizia ad avere una memoria strutturale interna, non solo conversazionale
- [P] l agent puo leggere direttamente dal repository cosa e stato fatto, perche e stato fatto e quali file sono coinvolti
- [U] chi opera dalla UI trova nello stesso punto manuale, aggiornamenti, bug e note commit
- [P] dopo ogni Git staging puo essere riallineato da Gitea direttamente dalla UI, senza terminale
- [P] il changelog Git automatico puo essere rigenerato sul nodo senza sporcare il repository locale
## Implementazioni recenti da tenere come riferimento
### 1. CTI / CSTA / click-to-call piu aperti lato PBX
- Separato il tab tecnico SMDR dal tab `CTI / CSTA e Click-to-Call` in `strumenti/post-it-gestione`.
- Esposta in UI una vista piu orientata al provider PBX, non solo a Panasonic.
- Aggiunta la creazione di richieste click-to-call direttamente dai messaggi tecnici CSTA.
- Mantenuta la base attuale Panasonic, ma con direzione compatibile con future integrazioni FreePBX.
Effetto pratico:
- [U] test piu precisi tra tracciato passivo SMDR e canale CTI/CSTA
- [U] possibilita di verificare il flusso di chiamata originata dal gestionale verso il telefono/interno associato
- [P] base piu riusabile per l adapter PBX successivo
### 2. Allineamento stabile/anno nelle pagine gestionali
- `gescon/ordinarie` ora eredita l anno attivo dal contesto topbar.
- La risoluzione dei codici legacy stabile e stata centralizzata in `StabileContext`.
- `condomini/servizi-utenze` e l archivio letture applicano l anno attivo e il contesto stabile in modo piu coerente.
Effetto pratico:
- [U] riduzione dei casi in cui l header mostra uno stabile/anno ma i dati arrivano da un altro contesto
- [U] base piu affidabile per estrazioni acqua e verifiche contabili per gestione
- [P] meno logica sparsa con chiavi session legacy non coerenti tra le pagine
### 3. Supporto > Modifiche come pannello unico
- La pagina ha gia tab per aggiornamenti, bug, commit e migliorie.
- Ora diventa anche punto di lettura del manuale strutturale dal repository.
- La sezione manuale espone anche la sync Git/Gitea per staging con stato del repository e avanzamento job.
- La tab `Aggiornamenti + Bug` espone in chiaro sia il comando predisposto usato dalla UI sia gli aggiornamenti inclusi nel riallineamento Git.
Effetto pratico:
- [P] una sola sezione per spiegare, tracciare, aggiornare e riprendere il lavoro
- [U] piu semplice per chi usa il gestionale capire cosa e stato aggiunto o corretto
## File guida da considerare autorevoli
- `docs/README.md`
- `docs/NETGESCON-MODIFICHE.md`
- `docs/support/MANUALE-SVILUPPO-OPERATIVO.md`
- `docs/support/AGGIORNAMENTI-STAGING-GIT.md`
- `docs/support/PBX-PANASONIC-STATO-OPERATIVO.md`
- `docs/support/ARCHITETTURA-STORAGE-TENANCY.md`
- `docs/support/BUG-RISOLTI-E-VALIDAZIONI.md`
- `docs/support/CONTINUITA-SVILUPPO-AGENT.md`
## Regola editoriale minima
Quando completiamo una modifica, aggiorniamo almeno uno di questi livelli:
- `manuale operativo` se cambia il modo di lavorare o una funzione importante
- `bug risolti` se correggiamo un errore o un comportamento incoerente
- `note commit` se il commit ha dettagli utili ma troppo specifici per stare nel manuale
## Prossimi passi consigliati
1. usare questa sezione come fonte primaria per le prossime implementazioni CTI e contabilita
2. automatizzare anche la scrittura di snapshot di sviluppo basati sui commit e sulle note operative
3. aggiungere progressivamente how-to piu mirati, senza spargere file duplicati

View File

@ -0,0 +1,82 @@
# PBX Panasonic stato operativo
## Scopo
Questo file raccoglie in modo strutturato tutto il lavoro gia fatto sul centralino Panasonic, cosi da poter riprendere lo sviluppo o il collaudo senza dover ricostruire il ragionamento da zero.
## Stato attuale
- La base CTI lato NetGescon e gia predisposta con endpoint incoming, call-ended, lookup e click-to-call.
- Il canale `panasonic_csta` e gia integrato nel flusso Post-it e nella messaggistica tecnica.
- In `strumenti/post-it-gestione` esistono ora tab distinti per SMDR e `CTI / CSTA e Click-to-Call`.
- Il click-to-call e gia previsto tramite richieste in `pbx_click_to_call_requests`.
- L architettura lato UI e servizi e stata tenuta piu aperta possibile per non bloccare il progetto su Panasonic e poter accogliere FreePBX come prossimo adapter.
## Cosa e stato implementato
### Eventi CTI / CSTA
- endpoint per arrivo chiamata
- endpoint per chiusura chiamata
- lookup rubrica per popup e correlazione
- salvataggio eventi tecnici su `communication_messages.channel=panasonic_csta`
### Click-to-call
- registrazione richiesta outbound dal gestionale
- recupero richieste pendenti lato adapter PBX
- presa in carico richiesta
- aggiornamento esito chiamata
### UI di test e controllo
- separazione tra traccia passiva SMDR e flusso ricco CSTA
- esposizione provider e metadati piu adatti a scenari PBX multipli
- action diretta di click-to-call dalla vista tecnica
## Endpoint gia disponibili
- `POST /api/v1/cti/panasonic/incoming`
- `POST /api/v1/cti/panasonic/call-ended`
- `GET /api/v1/cti/panasonic/lookup?phone=...`
- `GET /api/v1/cti/panasonic/click-to-call/pending?extension=...`
- `POST /api/v1/cti/panasonic/click-to-call/{id}/claim`
- `POST /api/v1/cti/panasonic/click-to-call/{id}/status`
## Stato verifiche
- [U] locale: bootstrap e codice applicativo validi
- [U] locale: tab tecnici separati presenti
- [P] staging: da verificare con traffico CTI/CSTA reale e con interni reali monitorati
- [P] staging: da validare click-to-call end-to-end sul centralino Panasonic
## Dati e interni di riferimento attuali
- `201` centralino con segreteria
- `205` operatore studio
- `206` amministratore
- `601` gruppo/modalita giorno
- `603` gruppo/modalita notte
Questi riferimenti vanno mantenuti allineati nella scheda amministratore e nelle configurazioni usate per routing e test.
## Ragionamento architetturale da non perdere
- Panasonic e il primo centralino integrato, non il vincolo definitivo del progetto.
- Il livello da tenere stabile e il contratto applicativo NetGescon: eventi chiamata, lookup, richieste click-to-call, mapping interno utente.
- Il livello da cambiare in futuro deve essere l adapter PBX, non la logica gestionale o la UI principale.
- FreePBX deve poter entrare riusando il piu possibile la stessa semantica applicativa.
## Prossimi test da fare su staging
1. verificare la tab `CTI / CSTA e Click-to-Call` con eventi reali
2. verificare la creazione e chiusura corretta dei Post-it da eventi Panasonic
3. verificare il click-to-call dal gestionale verso interno/telefono associato
4. verificare il mapping tra interno PBX, utente NetGescon e stabile attivo
## Collegamenti utili
- `docs/CTI-NS1000-CHECKLIST.md`
- `docs/CTI-WINDOWS-TAPI-PROBE.md`
- `docs/support/MANUALE-SVILUPPO-OPERATIVO.md`
- `docs/support/BUG-RISOLTI-E-VALIDAZIONI.md`

View File

@ -1,6 +1,7 @@
<x-filament-panels::page>
@php
$stats = $this->acquaDashboardStats;
$annoGestioneAttivo = \App\Support\AnnoGestioneContext::resolveActiveAnno(auth()->user());
$fatturePerGestione = $this->acquaFatturePerGestione;
$lettureCondomini = $this->acquaLettureCondomini;
$tariffeRows = $this->acquaTariffeRows;
@ -16,8 +17,11 @@
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Servizi / Utenze - ACQUA</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Cruscotto operativo acqua con controllo fatture, versamenti/letture condomini e tariffe storicizzate.
Cruscotto operativo acqua con controllo fatture, versamenti/letture condomini e tariffe storicizzate. I dati mostrati seguono lo stabile attivo e l'anno gestione del topbar.
</p>
<div class="mt-2 inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200">
Anno gestione attivo: {{ $annoGestioneAttivo }}
</div>
</div>
<div class="border-b border-gray-200 dark:border-gray-800">
@ -285,7 +289,7 @@ class="pb-3 px-1 border-b-2 font-medium text-sm {{ $acquaTab === 'servizi' ? 'bo
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Versamenti/Letture condomini (con data ricezione e riferimento)</div>
<a href="{{ \App\Filament\Pages\Condomini\LettureServiziArchivio::getUrl() }}"
class="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200">
Apri archivio letture (inserimento/modifica)
Apri archivio letture (filtrato per anno {{ $annoGestioneAttivo }})
</a>
</div>
<div class="overflow-x-auto">

View File

@ -295,12 +295,22 @@
<div>
<div class="text-sm text-gray-500">Telefono</div>
<div>{{ $rubrica->telefono_ufficio ?? '—' }}</div>
<div class="flex items-center gap-2">
<span>{{ $rubrica->telefono_ufficio ?? '—' }}</span>
@if(filled($rubrica->telefono_ufficio) && $this->canRequestClickToCall())
<x-filament::button size="xs" color="info" type="button" wire:click="richiediClickToCallRubrica('telefono_ufficio')">Chiama</x-filament::button>
@endif
</div>
</div>
<div>
<div class="text-sm text-gray-500">Cellulare</div>
<div>{{ $rubrica->telefono_cellulare ?? '—' }}</div>
<div class="flex items-center gap-2">
<span>{{ $rubrica->telefono_cellulare ?? '—' }}</span>
@if(filled($rubrica->telefono_cellulare) && $this->canRequestClickToCall())
<x-filament::button size="xs" color="info" type="button" wire:click="richiediClickToCallRubrica('telefono_cellulare')">Chiama</x-filament::button>
@endif
</div>
</div>
<div>
@ -320,7 +330,12 @@
<div>
<div class="text-sm text-gray-500">Telefono casa</div>
<div>{{ $rubrica->telefono_casa ?? '—' }}</div>
<div class="flex items-center gap-2">
<span>{{ $rubrica->telefono_casa ?? '—' }}</span>
@if(filled($rubrica->telefono_casa) && $this->canRequestClickToCall())
<x-filament::button size="xs" color="info" type="button" wire:click="richiediClickToCallRubrica('telefono_casa')">Chiama</x-filament::button>
@endif
</div>
</div>
<div class="md:col-span-2">
@ -443,7 +458,7 @@
</div>
<div class="rounded-md border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700">
Questo collegamento usa l'email della rubrica come chiave utente. Se l'utente esiste già, aggiunge solo il ruolo <strong>collaboratore</strong> e sincronizza gli stabili dell'amministratore corrente senza rimuovere eventuali altri ruoli o profili già presenti.
Questo collegamento usa come chiave l'email principale della rubrica o, se manca, la prima email aggiuntiva attiva. Se l'utente esiste già, aggiunge solo il ruolo <strong>collaboratore</strong> e sincronizza gli stabili dell'amministratore corrente senza rimuovere eventuali altri ruoli o profili già presenti.
</div>
</div>
</x-filament::section>

View File

@ -3,7 +3,7 @@
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Stato integrazione Panasonic / TAPI</div>
<div class="mb-3 text-xs text-gray-500">Questa sezione tiene insieme configurazione PBX, gruppi di risposta, linee entranti e ultimi eventi CRM letti dal canale Panasonic.</div>
<div class="mb-3 text-xs text-gray-500">Questa sezione tiene insieme configurazione PBX, gruppi di risposta, linee entranti e ultimi eventi CRM letti dai canali telefonici attivi per questo ambiente.</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-4">
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
@ -96,7 +96,7 @@
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Ultimi eventi letti dal CRM</div>
<div class="mb-3 text-xs text-gray-500">Se qui compaiono record, il bridge Panasonic sta gia portando nel CRM almeno una parte del traffico telefonico.</div>
<div class="mb-3 text-xs text-gray-500">Se qui compaiono record, almeno uno dei canali telefonici attivi sta gia portando traffico nel CRM. In modalita hybrid puoi vedere sia eventi Panasonic sia righe SMDR.</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
@ -126,7 +126,7 @@
</tr>
@empty
<tr>
<td colspan="8" class="border px-2 py-3 text-center text-gray-500">Nessun evento Panasonic presente nel CRM per questo amministratore.</td>
<td colspan="8" class="border px-2 py-3 text-center text-gray-500">Nessun evento telefonico presente nel CRM per questo amministratore.</td>
</tr>
@endforelse
</tbody>

View File

@ -43,6 +43,16 @@
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
Stabile QA
<input wire:model.defer="stabile" type="text" placeholder="es. GES00148, 148, 0021" class="mt-1 w-full rounded-md border-gray-300 text-sm dark:border-white/10 dark:bg-gray-800" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
Anno QA
<input wire:model.defer="anno" type="text" placeholder="es. 2025" class="mt-1 w-full rounded-md border-gray-300 text-sm dark:border-white/10 dark:bg-gray-800" />
</label>
<label class="flex items-center gap-2 self-end text-xs text-gray-700 dark:text-gray-300">
<input wire:model.defer="includeDataChecks" type="checkbox" class="rounded border-gray-300" />
Controlli integrità archivi/procedure
@ -81,6 +91,9 @@
<div class="rounded-xl border bg-white p-4 dark:border-white/10 dark:bg-gray-900">
<div class="mb-3 text-sm font-semibold">Risultati</div>
<div class="mb-3 text-xs text-gray-500 dark:text-gray-400">
Filtro QA attivo: stabile <span class="font-semibold">{{ $stabile !== '' ? $stabile : 'tutti' }}</span>, anno <span class="font-semibold">{{ $anno !== '' ? $anno : 'tutti' }}</span>.
</div>
<div class="space-y-3">
@forelse($results as $fileResult)
<div class="rounded-lg border p-3 dark:border-white/10">

View File

@ -122,13 +122,91 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
</div>
</x-filament::section>
@foreach($this->manualSections as $section)
<x-filament::section class="!p-4">
<div class="mb-2 flex flex-wrap items-start justify-between gap-2">
<div>
<div class="text-xs font-semibold text-gray-700">{{ $section['title'] }}</div>
<div class="mt-1 text-[11px] text-gray-500">{{ $section['description'] }}</div>
</div>
<div class="rounded bg-slate-100 px-2 py-1 font-mono text-[10px] text-slate-600">{{ $section['path'] }}</div>
</div>
@if(! $section['exists'])
<div class="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-900">
Documento non ancora presente nel repository. La sezione e predisposta per essere popolata progressivamente.
</div>
@endif
<div class="prose prose-sm max-w-none text-[11px] leading-relaxed">
{!! $section['html'] !!}
</div>
</x-filament::section>
@endforeach
@endif
@if($this->activeTab === 'errori')
<x-filament::section class="!p-4">
<div class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-[11px] text-amber-900">
<div class="font-semibold">Uso corretto di questa sezione</div>
<div class="mt-1">
Per il nodo staging, il percorso operativo principale e la sync Git da Gitea disponibile in questa tab. I pulsanti piu sotto servono invece al canale <span class="font-semibold">distribution</span>, che al momento non e ancora il flusso update attivo principale e puo restituire errore manifest 404.
</div>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-xs font-semibold text-slate-800">Cosa aggiorna il flusso Git di staging</div>
<div class="mt-2 text-[11px] leading-relaxed text-slate-700">
Il pulsante <span class="font-semibold">Aggiorna staging da Gitea</span> lancia il comando applicativo <span class="font-mono">netgescon:git-sync</span>. Il nodo si riallinea dal repository Git pubblicato su Gitea, rigenera il changelog automatico e aggiorna questa pagina senza usare deploy manuali fuori Git.
</div>
<ol class="mt-3 list-decimal space-y-1 pl-4 text-[11px] text-slate-700">
<li>fetch e allineamento del branch richiesto da Gitea</li>
<li>aggiornamento del codice sul nodo staging</li>
<li>rigenerazione changelog Git per Supporto &gt; Modifiche</li>
<li>salvataggio esito, log e diagnostica del job</li>
</ol>
</div>
<div class="rounded-lg border bg-white p-3">
<div class="text-xs font-semibold text-gray-700">Aggiornamenti inclusi nel riallineamento Git</div>
<div class="mt-2 grid gap-3 lg:grid-cols-2">
<div>
<div class="mb-1 text-[11px] font-semibold text-slate-700">Ultimi commit</div>
<ul class="space-y-1 text-[11px] text-gray-700">
@forelse(array_slice($this->latestCommits, 0, 5) as $commit)
<li class="rounded border bg-slate-50 px-2 py-1">
<span class="font-mono text-[10px] text-slate-500">{{ $commit['short_hash'] }}</span>
<span class="ml-1">{{ $commit['message'] }}</span>
</li>
@empty
<li class="text-gray-500">Nessun commit disponibile.</li>
@endforelse
</ul>
</div>
<div>
<div class="mb-1 text-[11px] font-semibold text-slate-700">Migliorie/fix rilevati</div>
<ul class="list-disc space-y-1 pl-4 text-[11px] text-gray-700">
@forelse(array_slice($this->functionalHighlights, 0, 6) as $item)
<li>{{ $item }}</li>
@empty
<li>Nessuna miglioria estratta automaticamente.</li>
@endforelse
</ul>
</div>
</div>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-[1.15fr_0.85fr]">
<div class="rounded-lg border bg-gray-50 p-3 lg:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Aggiornamento staging da Gitea</div>
<div class="mb-3 text-[11px] text-gray-600">
Questo e il flusso principale da usare sui nodi interni: pull da Gitea, rigenerazione changelog Git per la pagina supporto e pulizia cache applicativa. Non richiede terminale.
</div>
<div class="mb-3 rounded border border-slate-200 bg-white px-3 py-2 text-[11px] text-slate-700">
Comando predisposto usato dalla UI: <span class="font-mono">php artisan netgescon:git-sync --remote={{ $this->gitRemote }} --branch={{ $this->gitBranch }}</span>
</div>
<div class="grid gap-2 sm:grid-cols-4">
<label class="block">
@ -199,6 +277,7 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
<div class="rounded-lg border bg-white p-3">
<div class="text-xs font-semibold text-gray-700">Esito ultima sync Git</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600">
<div>Comando: <span class="font-mono font-medium">netgescon:git-sync</span></div>
<div>
Exit code:
@if($this->lastGitSyncExitCode === null)
@ -224,50 +303,12 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
<pre class="max-h-64 overflow-auto whitespace-pre-wrap text-[11px] leading-relaxed text-slate-100">{{ $this->lastGitSyncOutput }}</pre>
</div>
@endif
</x-filament::section>
@foreach($this->manualSections as $section)
<x-filament::section class="!p-4">
<div class="mb-2 flex flex-wrap items-start justify-between gap-2">
<div>
<div class="text-xs font-semibold text-gray-700">{{ $section['title'] }}</div>
<div class="mt-1 text-[11px] text-gray-500">{{ $section['description'] }}</div>
</div>
<div class="rounded bg-slate-100 px-2 py-1 font-mono text-[10px] text-slate-600">{{ $section['path'] }}</div>
</div>
@if(! $section['exists'])
<div class="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-900">
Documento non ancora presente nel repository. La sezione e predisposta per essere popolata progressivamente.
</div>
@endif
<div class="prose prose-sm max-w-none text-[11px] leading-relaxed">
{!! $section['html'] !!}
</div>
</x-filament::section>
@endforeach
@endif
@if($this->activeTab === 'errori')
<x-filament::section class="!p-4">
<div class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-[11px] text-amber-900">
<div class="font-semibold">Uso corretto di questa sezione</div>
<div class="mt-1">
Per il nodo staging, il percorso operativo principale e la sync Git da Gitea disponibile nel tab <span class="font-semibold">Manuale</span>. I pulsanti sotto servono al canale <span class="font-semibold">distribution</span>, che al momento non e ancora il flusso update attivo principale e puo restituire errore manifest 404.
</div>
<div class="mt-2">
<x-filament::button size="sm" color="warning" wire:click="setActiveTab('manuale')">
Vai a Manuale e sync Gitea
</x-filament::button>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-3">
<div class="rounded-lg border bg-gray-50 p-3 lg:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Aggiornamento da server distribution</div>
<div class="mb-3 text-[11px] text-gray-500">
Usare questi pulsanti solo quando il server <span class="font-mono">updates.netgescon.it</span> espone correttamente il manifest update. Per staging standard usare il flusso Git/Gitea nel tab Manuale.
Usare questi pulsanti solo quando il server <span class="font-mono">updates.netgescon.it</span> espone correttamente il manifest update. Per staging standard usare il flusso Git/Gitea mostrato sopra in questa stessa tab.
</div>
<div class="grid gap-2 sm:grid-cols-3">
<label class="block">

View File

@ -86,6 +86,14 @@
<button type="button" wire:click="selezionaCaller({{ (int) $match->id }})" class="w-full rounded-lg border p-3 text-left {{ (int) $selectedCallerId === (int) $match->id ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'bg-white hover:bg-gray-50' }}">
<div class="text-sm font-medium">{{ $match->nome_completo ?: ($match->ragione_sociale ?: 'Contatto') }}</div>
<div class="text-xs text-gray-600">{{ $match->categoria ?: 'altro' }} @if($match->email) · {{ $match->email }} @endif</div>
@if(($match->duplicate_count ?? 1) > 1)
<div class="mt-1 text-xs text-amber-700">
Raggruppati {{ $match->duplicate_count }} record equivalenti
@if(!empty($match->duplicate_categories))
· categorie: {{ implode(', ', $match->duplicate_categories) }}
@endif
</div>
@endif
@if($match->tipo_utenza_call)
<div class="text-xs text-indigo-700">Profilo: {{ $match->tipo_utenza_call }}</div>
@endif
@ -111,7 +119,7 @@
<div class="mt-3 text-xs text-gray-500">Nessun contatto trovato con questa ricerca.</div>
@endif
<div class="mt-4 rounded-lg border border-dashed p-3" x-data="ticketMobilePreview()">
<div class="mt-4 rounded-lg border border-dashed p-3">
<div class="text-sm font-semibold">Nuovo ticket rapido</div>
<div class="mt-1 text-xs text-gray-500">Dopo aver selezionato il chiamante, compila e crea il ticket.</div>
@if($errors->any())
@ -179,46 +187,23 @@
<div class="flex flex-wrap gap-2">
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta foto
<input type="file" wire:model="newTicketCameraShots" x-on:change="ingest($event, 'camera')" multiple accept="image/*" capture="environment" class="hidden" />
<input type="file" wire:model="pendingTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" />
</label>
<label class="inline-flex cursor-pointer items-center rounded-md bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">
Aggiungi allegati
<input type="file" wire:model="newTicketAttachments" x-on:change="ingest($event, 'attachment')" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" />
<input type="file" wire:model="pendingTicketAttachments" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" />
</label>
</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare fino a 10 file totali (foto + documenti). Ogni file ha descrizione separata.</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare fino a 10 file totali. Ogni nuovo scatto viene accodato senza sostituire i precedenti.</div>
<div class="mt-1 text-xs text-gray-500">Bozza protocollo corrente: <span class="font-semibold">{{ $newTicketDraftProtocol }}</span></div>
<div class="mt-1 text-xs text-gray-500">iPhone: su "Aggiungi allegati" scegli "Sfoglia" per aprire l'app File (iCloud Drive/On My iPhone).</div>
<div wire:loading wire:target="newTicketCameraShots,newTicketAttachments" class="mt-2 text-xs font-medium text-amber-700">Caricamento in corso: l'anteprima locale e gia disponibile, sto sincronizzando i file al server.</div>
<div wire:loading wire:target="pendingTicketCameraShots,pendingTicketAttachments" class="mt-2 text-xs font-medium text-amber-700">Caricamento in corso: sto accodando foto e allegati alla bozza ticket.</div>
</label>
<div class="md:col-span-2" x-show="previews.length > 0">
<div class="mb-2 text-xs font-semibold text-emerald-700">Anteprima immediata sul telefono</div>
<div class="grid gap-3 sm:grid-cols-2">
<template x-for="item in previews" :key="item.key">
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-white p-2">
<template x-if="item.isImage && item.previewUrl">
<img :src="item.previewUrl" :alt="item.name" class="h-full w-full rounded object-cover" />
</template>
<template x-if="!item.isImage || !item.previewUrl">
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold" x-text="item.ext"></div>
<div class="mt-1 text-[11px]" x-text="item.name"></div>
</div>
</div>
</template>
</div>
<div class="mt-2 font-medium" x-text="item.name"></div>
</div>
</template>
</div>
</div>
@if(count($this->selectedUploads) > 0)
<div class="md:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Anteprima allegati sincronizzati</div>
<div class="mb-2 text-xs font-semibold text-gray-700">Coda allegati pronta per il ticket</div>
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
@ -236,6 +221,10 @@
</div>
<div class="mt-2 font-medium">{{ $upload['name'] }}</div>
<div class="mt-1 text-[11px] text-emerald-700">Protocollo bozza: {{ $upload['protocol'] }}</div>
@if($upload['original_name'] !== $upload['name'])
<div class="mt-1 text-[11px] text-gray-500">File origine: {{ $upload['original_name'] }}</div>
@endif
<label class="mt-2 block">
<span class="mb-1 block text-[11px] font-medium">Descrizione foto/allegato</span>
<input type="text" wire:model.defer="newTicketAttachmentDescriptions.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. perdita in cucina lato sifone" />
@ -249,7 +238,7 @@
@endif
</div>
<div class="mt-3">
<x-filament::button wire:click="creaTicketRapido" wire:loading.attr="disabled" wire:target="creaTicketRapido,newTicketCameraShots,newTicketAttachments">
<x-filament::button wire:click="creaTicketRapido" wire:loading.attr="disabled" wire:target="creaTicketRapido,pendingTicketCameraShots,pendingTicketAttachments">
<span wire:loading.remove wire:target="creaTicketRapido">Crea ticket</span>
<span wire:loading wire:target="creaTicketRapido">Invio ticket...</span>
</x-filament::button>
@ -271,27 +260,4 @@
</div>
</div>
<script>
function ticketMobilePreview() {
return {
previews: [],
ingest(event, source) {
const files = Array.from(event.target.files || []);
files.forEach((file, index) => {
const isImage = (file.type || '').startsWith('image/');
const previewUrl = isImage ? URL.createObjectURL(file) : null;
const extension = (file.name.split('.').pop() || 'FILE').toUpperCase();
this.previews.unshift({
key: `${source}-${file.name}-${file.size}-${index}`,
name: file.name,
isImage,
previewUrl,
ext: extension,
});
});
this.previews = this.previews.slice(0, 10);
},
};
}
</script>
</x-filament-panels::page>

View File

@ -276,6 +276,33 @@ class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-s
@endforeach
</div>
<div class="mt-6">
<div class="text-sm font-semibold text-gray-900 mb-2">Recapiti email per servizio</div>
@if(empty($recapitiServizio))
<div class="text-sm text-gray-500">Nessun recapito email disponibile.</div>
@else
<div class="grid gap-3 lg:grid-cols-2">
@foreach($recapitiServizio as $service)
<div class="rounded-xl border bg-gray-50 p-3">
<div class="text-sm font-semibold text-gray-900">{{ $service['label'] ?? 'Servizio' }}</div>
@if(empty($service['rows']))
<div class="mt-2 text-sm text-gray-500">Nessun recapito risolto.</div>
@else
<div class="mt-2 space-y-2">
@foreach($service['rows'] as $recipient)
<div class="rounded-lg border bg-white p-3">
<div class="text-sm font-semibold text-gray-900">{{ $recipient['email'] ?? '—' }}</div>
<div class="text-xs text-gray-500">{{ $recipient['persona'] ?: 'Recapito unità' }} · {{ $recipient['sourceLabel'] ?? 'Anagrafica persona' }}</div>
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
<div class="mt-6">
<div class="text-sm font-semibold text-gray-900 mb-2">Nominativi storici (legacy)</div>
@if(empty($nominativiStorici))
@ -300,6 +327,9 @@ class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-s
@if(!empty($nominativo['fonte']))
<div class="text-xs text-gray-500">Fonte: {{ $nominativo['fonte'] }}</div>
@endif
@if(!empty($nominativo['detail']))
<div class="text-xs text-gray-500">{{ $nominativo['detail'] }}</div>
@endif
</td>
<td class="py-2 pr-4 text-gray-700">{{ $nominativo['periodo'] ?? '—' }}</td>
<td class="py-2 pr-4 text-right text-gray-900">{{ $nominativo['percentuale'] ?? '—' }}</td>
@ -425,6 +455,34 @@ class="inline-flex items-center rounded-lg border px-4 py-2 text-sm font-semibol
</div>
@endforeach
</div>
<div class="mt-6">
<div class="mb-2 text-sm font-semibold text-gray-900">Recapiti email per servizio</div>
@if(empty($recapitiServizio))
<div class="text-sm text-gray-500">Nessun recapito email disponibile.</div>
@else
<div class="grid gap-3 lg:grid-cols-2">
@foreach($recapitiServizio as $service)
<div class="rounded-2xl border bg-gray-50 p-3">
<div class="text-sm font-semibold text-gray-900">{{ $service['label'] ?? 'Servizio' }}</div>
@if(empty($service['rows']))
<div class="mt-2 text-sm text-gray-500">Nessun recapito risolto.</div>
@else
<div class="mt-2 space-y-2">
@foreach($service['rows'] as $recipient)
<div class="rounded-xl border bg-white p-3 shadow-sm">
<div class="text-sm font-semibold text-gray-900">{{ $recipient['email'] ?? '—' }}</div>
<div class="text-xs text-gray-500 mt-1">{{ $recipient['persona'] ?: 'Recapito unità' }}</div>
<div class="text-xs text-gray-500 mt-1">Origine: {{ $recipient['sourceLabel'] ?? 'Anagrafica persona' }}</div>
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>
@endif

View File

@ -1,4 +1,4 @@
<div wire:poll.8s="refreshLiveIncomingCall" class="hidden xl:flex xl:min-w-[28rem] xl:flex-1 xl:justify-center">
<div wire:poll.8s="refreshLiveIncomingCall" class="hidden lg:flex lg:min-w-[24rem] lg:flex-1 lg:justify-center">
@if($liveIncomingCall)
<div class="mx-3 flex w-full max-w-2xl items-center justify-between gap-3 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 shadow-sm">
<div class="min-w-0">