Allinea sviluppo Day0: Gescon, PBX hybrid e supporto operativo
This commit is contained in:
parent
cd5d004561
commit
89dabf9d72
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
|
|
@ -25,6 +26,8 @@ public function handle()
|
|||
$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');
|
||||
|
|
@ -38,6 +41,8 @@ public function handle()
|
|||
'severity' => $severity,
|
||||
'module' => $module,
|
||||
'menu' => $menu,
|
||||
'stabile' => $stabile,
|
||||
'anno' => $anno,
|
||||
'include_data_checks' => $includeDataChecks ? 1 : 0,
|
||||
'format' => $format,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<?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
|
||||
|
|
@ -13,11 +13,13 @@ class CreateAmministratoreStructure extends Command
|
|||
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->tenantArchivePath = $tenantArchivePath;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
|
|
@ -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,7 +126,7 @@ 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 = [
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<?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
|
||||
|
|
@ -14,11 +14,13 @@ class CreateStabileStructure extends Command
|
|||
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->tenantArchivePath = $tenantArchivePath;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
|
|
@ -30,7 +32,9 @@ public function handle(): int
|
|||
$this->info("🏗️ Creazione struttura stabile: {$stabileCode} (Admin: {$adminCode})");
|
||||
|
||||
// Trova amministratore
|
||||
$amministratore = Amministratore::where('codice_univoco', $adminCode)->first();
|
||||
$amministratore = Amministratore::where('codice_amministratore', $adminCode)
|
||||
->orWhere('codice_univoco', $adminCode)
|
||||
->first();
|
||||
if (! $amministratore) {
|
||||
$this->error("❌ Amministratore {$adminCode} non trovato");
|
||||
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
|
||||
|
|
@ -193,9 +195,7 @@ 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 = [
|
||||
|
|
@ -257,7 +257,7 @@ private function createStabileConfigurations(Stabile $stabile): void
|
|||
'frequenza_mensili' => true,
|
||||
'email_notifiche' => true,
|
||||
'formato_default' => 'pdf',
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
Storage::disk('local')->put(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
app/Console/Commands/NetgesconApplyPbxPresetCommand.php
Normal file
135
app/Console/Commands/NetgesconApplyPbxPresetCommand.php
Normal 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) ?: '';
|
||||
}
|
||||
}
|
||||
87
app/Console/Commands/NetgesconArchiveRegistrySyncCommand.php
Normal file
87
app/Console/Commands/NetgesconArchiveRegistrySyncCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,22 +901,22 @@ 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([
|
||||
->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',
|
||||
|
|
@ -868,11 +928,16 @@ public function getAcquaRipartoNominativiLegacyProperty(): array
|
|||
])
|
||||
->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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,6 +280,7 @@ public function mount(int | string $record): void
|
|||
->all();
|
||||
|
||||
// Contatti principali (per mostrarli velocemente nella scheda): prende canali principali + fallback ai campi telefono/email.
|
||||
if (Schema::hasTable('rubrica_contatto_canali')) {
|
||||
$principali = RubricaContattoCanale::query()
|
||||
->where('rubrica_id', (int) $this->rubrica->id)
|
||||
->orderByDesc('is_principale')
|
||||
|
|
@ -295,8 +297,11 @@ public function mount(int | string $record): void
|
|||
'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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1345,10 +1346,11 @@ public function getPbxSummaryProperty(): array
|
|||
}
|
||||
|
||||
$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 $query)->where(function ($q): void {
|
||||
$missedCalls = (clone $rawQuery)->where(function ($q): void {
|
||||
$q->where('message_text', 'like', '%persa%')
|
||||
->orWhere('metadata->outcome', 'like', '%miss%')
|
||||
->orWhere('metadata->outcome', 'like', '%no_answer%');
|
||||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>> */
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +78,8 @@ public function runScan(): void
|
|||
'severity' => $this->severity,
|
||||
'module' => $this->module,
|
||||
'menu' => $this->menu,
|
||||
'stabile' => $this->stabile,
|
||||
'anno' => $this->anno,
|
||||
'include_data_checks' => $this->includeDataChecks ? 1 : 0,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,12 @@ private function loadManualSections(): void
|
|||
'description' => 'Quadro principale: come stiamo sviluppando, cosa e stato implementato e come continuare senza ripartire da zero.',
|
||||
'path' => 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md',
|
||||
],
|
||||
[
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', ''));
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Quality\OrdinarieQualityAuditService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// Use FQN in code to avoid aliasing issues with static analyzers
|
||||
|
||||
class CodeQualityController extends Controller
|
||||
{
|
||||
public function __construct(private OrdinarieQualityAuditService $ordinarieQualityAudit)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Regole di qualità del codice NetGescon
|
||||
*/
|
||||
|
|
@ -34,8 +39,8 @@ class CodeQualityController extends Controller
|
|||
'resources/views/admin/gescon-import/tabs',
|
||||
'resources/views/components/menu',
|
||||
'resources/views/components/',
|
||||
'resources/views/admin/documenti/print-list.blade.php'
|
||||
]
|
||||
'resources/views/admin/documenti/print-list.blade.php',
|
||||
],
|
||||
],
|
||||
// Vietato l'uso del vecchio layout
|
||||
'layout_forbidden_app' => [
|
||||
|
|
@ -45,7 +50,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => "Sostituisci con @extends('admin.layouts.netgescon')",
|
||||
'severity' => 'error',
|
||||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'bootstrap_classes' => [
|
||||
'name' => 'Classi Bootstrap/Tailwind Obsolete',
|
||||
|
|
@ -66,8 +71,8 @@ class CodeQualityController extends Controller
|
|||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['blade'],
|
||||
'excludeContains' => [
|
||||
'resources/views/components/menu'
|
||||
]
|
||||
'resources/views/components/menu',
|
||||
],
|
||||
],
|
||||
'netgescon_components' => [
|
||||
'name' => 'Componenti NetGescon',
|
||||
|
|
@ -76,7 +81,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Componenti NetGescon rilevati',
|
||||
'severity' => 'info',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'controller_namespace' => [
|
||||
'name' => 'Namespace Controller',
|
||||
|
|
@ -86,7 +91,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'error',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['php'],
|
||||
'pathContains' => ['app/Http/Controllers']
|
||||
'pathContains' => ['app/Http/Controllers'],
|
||||
],
|
||||
'model_relationships' => [
|
||||
'name' => 'Relazioni Eloquent',
|
||||
|
|
@ -95,7 +100,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Relazioni Eloquent definite correttamente',
|
||||
'severity' => 'info',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['php']
|
||||
'fileTypes' => ['php'],
|
||||
],
|
||||
'deprecated_helpers' => [
|
||||
'name' => 'Helper Deprecati',
|
||||
|
|
@ -104,7 +109,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Usare data_get(), Arr::set(), Str::is(), Str::startsWith(), Str::endsWith() o funzioni PHP 8',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['php']
|
||||
'fileTypes' => ['php'],
|
||||
],
|
||||
'security_issues' => [
|
||||
'name' => 'Problemi di Sicurezza',
|
||||
|
|
@ -113,7 +118,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Usare Request validation e Query Builder/parametri',
|
||||
'severity' => 'critical',
|
||||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['php']
|
||||
'fileTypes' => ['php'],
|
||||
],
|
||||
'migration_standards' => [
|
||||
'name' => 'Standard Migration',
|
||||
|
|
@ -123,7 +128,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'info',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['php'],
|
||||
'pathContains' => ['database/migrations']
|
||||
'pathContains' => ['database/migrations'],
|
||||
],
|
||||
// Debug Blade: bilanciamento @section/@endsection
|
||||
'blade_sections_balance' => [
|
||||
|
|
@ -133,7 +138,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Aggiungi o rimuovi le direttive per bilanciare le sezioni',
|
||||
'severity' => 'error',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
// Debug Blade: div bilanciati
|
||||
'unbalanced_divs' => [
|
||||
|
|
@ -144,7 +149,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la struttura dei contenitori e chiudi tutti i div',
|
||||
'severity' => 'error',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'unbalanced_navs' => [
|
||||
'name' => 'Nav non bilanciati',
|
||||
|
|
@ -154,7 +159,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica che <nav> sia chiuso correttamente',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'unbalanced_uls' => [
|
||||
'name' => 'Liste non bilanciate',
|
||||
|
|
@ -164,7 +169,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la chiusura delle liste <ul>',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
// Altri contenitori comuni: section, header, footer, main, article
|
||||
'unbalanced_sections' => [
|
||||
|
|
@ -175,7 +180,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la chiusura dei tag <section>',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'unbalanced_headers' => [
|
||||
'name' => 'Header non bilanciati',
|
||||
|
|
@ -185,7 +190,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la chiusura dei tag <header>',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'unbalanced_footers' => [
|
||||
'name' => 'Footer non bilanciati',
|
||||
|
|
@ -195,7 +200,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la chiusura dei tag <footer>',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'unbalanced_main' => [
|
||||
'name' => 'Main non bilanciati',
|
||||
|
|
@ -205,7 +210,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la chiusura dei tag <main>',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
'unbalanced_articles' => [
|
||||
'name' => 'Article non bilanciati',
|
||||
|
|
@ -215,7 +220,7 @@ class CodeQualityController extends Controller
|
|||
'suggestion' => 'Verifica la chiusura dei tag <article>',
|
||||
'severity' => 'warning',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade']
|
||||
'fileTypes' => ['blade'],
|
||||
],
|
||||
// Debug specifico Gescon Import
|
||||
// (Confluita in gescon_import_cards_required più in basso)
|
||||
|
|
@ -230,7 +235,7 @@ class CodeQualityController extends Controller
|
|||
'pathContains' => ['resources/views/admin/gescon-import/index.blade.php'],
|
||||
'excludeContains' => [
|
||||
'resources/views/admin/gescon-import/partials/menu.blade.php',
|
||||
'resources/views/admin/gescon-import/partials'
|
||||
'resources/views/admin/gescon-import/partials',
|
||||
],
|
||||
],
|
||||
'gescon_import_columns_detect' => [
|
||||
|
|
@ -241,7 +246,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'info',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/gescon-import']
|
||||
'pathContains' => ['resources/views/admin/gescon-import'],
|
||||
],
|
||||
'gescon_import_cards_required' => [
|
||||
'name' => 'Card NetGescon coerenti',
|
||||
|
|
@ -254,8 +259,8 @@ class CodeQualityController extends Controller
|
|||
'pathContains' => ['resources/views/admin/gescon-import'],
|
||||
'excludeContains' => [
|
||||
'resources/views/admin/gescon-import/partials',
|
||||
'resources/views/admin/gescon-import/tabs'
|
||||
]
|
||||
'resources/views/admin/gescon-import/tabs',
|
||||
],
|
||||
],
|
||||
// Controllo integrità sidebar NetGescon
|
||||
'sidebar_toggle_integrity' => [
|
||||
|
|
@ -266,7 +271,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'error',
|
||||
'mode' => 'detect',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php'],
|
||||
],
|
||||
// Sidebar: vieta onclick su <a> per il toggle; usare la freccia .ng-chevron-toggle
|
||||
'sidebar_anchor_toggle_forbidden' => [
|
||||
|
|
@ -277,7 +282,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'error',
|
||||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php'],
|
||||
],
|
||||
// Sidebar: richiede presenza della classe ng-chevron-toggle (freccia cliccabile)
|
||||
'sidebar_chevron_toggle_required' => [
|
||||
|
|
@ -288,7 +293,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'warning',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php'],
|
||||
],
|
||||
// Sidebar: la dashboard GESCON Import deve essere link di primo livello, non nel sottomenu
|
||||
'sidebar_gescon_dashboard_top' => [
|
||||
|
|
@ -299,7 +304,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'warning',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php'],
|
||||
],
|
||||
'sidebar_gescon_dashboard_not_submenu' => [
|
||||
'name' => 'Dashboard Gescon Import non nel sottomenu',
|
||||
|
|
@ -309,7 +314,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'info',
|
||||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/components/menu/sidebar-netgescon.blade.php'],
|
||||
],
|
||||
// GESCON Import: garantire uso card NetGescon nella dashboard
|
||||
'gescon_import_dashboard_cards' => [
|
||||
|
|
@ -320,7 +325,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'warning',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/gescon-import/tabs/dashboard.blade.php']
|
||||
'pathContains' => ['resources/views/admin/gescon-import/tabs/dashboard.blade.php'],
|
||||
],
|
||||
// Rileva badge Bootstrap nelle viste Gescon Import
|
||||
'bootstrap_badges' => [
|
||||
|
|
@ -331,7 +336,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'warning',
|
||||
'mode' => 'prohibit',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/gescon-import']
|
||||
'pathContains' => ['resources/views/admin/gescon-import'],
|
||||
],
|
||||
// Verifica inclusion Material Design Icons nel layout admin
|
||||
'mdi_stylesheet_required' => [
|
||||
|
|
@ -342,7 +347,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'error',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php'],
|
||||
],
|
||||
// Verifica presenza Font Awesome nel layout admin
|
||||
'fontawesome_stylesheet_required' => [
|
||||
|
|
@ -353,7 +358,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'warning',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php'],
|
||||
],
|
||||
// Verifica presenza CSS/JS NetGescon nel layout
|
||||
'netgescon_css_required' => [
|
||||
|
|
@ -364,7 +369,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'error',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php'],
|
||||
],
|
||||
'netgescon_js_required' => [
|
||||
'name' => 'JS NetGescon mancante',
|
||||
|
|
@ -374,7 +379,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'error',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php'],
|
||||
],
|
||||
// Verifica presenza Vite assets nel layout
|
||||
'vite_assets_required' => [
|
||||
|
|
@ -385,7 +390,7 @@ class CodeQualityController extends Controller
|
|||
'severity' => 'warning',
|
||||
'mode' => 'require',
|
||||
'fileTypes' => ['blade'],
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php']
|
||||
'pathContains' => ['resources/views/admin/layouts/netgescon.blade.php'],
|
||||
],
|
||||
// Uso mdi-* senza layout o link diretto
|
||||
'mdi_usage_requires_layout_or_link' => [
|
||||
|
|
@ -399,8 +404,8 @@ class CodeQualityController extends Controller
|
|||
'excludeContains' => [
|
||||
'resources/views/components/',
|
||||
'partials/',
|
||||
'resources/views/admin/gescon-import/tabs'
|
||||
]
|
||||
'resources/views/admin/gescon-import/tabs',
|
||||
],
|
||||
],
|
||||
// Uso fa-/fas senza layout o link diretto
|
||||
'fa_usage_requires_layout_or_link' => [
|
||||
|
|
@ -414,8 +419,8 @@ class CodeQualityController extends Controller
|
|||
'excludeContains' => [
|
||||
'resources/views/components/',
|
||||
'partials/',
|
||||
'resources/views/admin/gescon-import/tabs'
|
||||
]
|
||||
'resources/views/admin/gescon-import/tabs',
|
||||
],
|
||||
],
|
||||
// Uso classi NetGescon senza CSS
|
||||
'netgescon_btn_usage_requires_css' => [
|
||||
|
|
@ -429,8 +434,8 @@ class CodeQualityController extends Controller
|
|||
'excludeContains' => [
|
||||
'resources/views/components/',
|
||||
'partials/',
|
||||
'resources/views/admin/gescon-import/tabs'
|
||||
]
|
||||
'resources/views/admin/gescon-import/tabs',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
|
|
@ -485,9 +490,12 @@ public function index(\Illuminate\Http\Request $request)
|
|||
}
|
||||
$moduleFilter = $request->get('module', userSetting('code_quality.module', 'all'));
|
||||
$menuFilter = $request->get('menu', userSetting('code_quality.menu', 'all'));
|
||||
$stabileFilter = trim((string) $request->get('stabile', userSetting('code_quality.stabile', '')));
|
||||
$annoFilterRaw = trim((string) $request->get('anno', userSetting('code_quality.anno', '')));
|
||||
$annoFilter = is_numeric($annoFilterRaw) ? (int) $annoFilterRaw : null;
|
||||
$includeDataChecks = filter_var($request->get('include_data_checks', userSetting('code_quality.include_data_checks', true)), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$results = $this->scanCodeQuality($scanPath, $severityFilter, $moduleFilter, $menuFilter, $includeDataChecks);
|
||||
$results = $this->scanCodeQuality($scanPath, $severityFilter, $moduleFilter, $menuFilter, $includeDataChecks, $stabileFilter, $annoFilter);
|
||||
$totalScanned = $this->countFilesInPaths($scanPath, $moduleFilter, $menuFilter);
|
||||
|
||||
return View::make('admin.code-quality.index', [
|
||||
|
|
@ -497,6 +505,8 @@ public function index(\Illuminate\Http\Request $request)
|
|||
'totalScanned' => $totalScanned,
|
||||
'moduleFilter' => $moduleFilter,
|
||||
'menuFilter' => $menuFilter,
|
||||
'stabileFilter' => $stabileFilter,
|
||||
'annoFilter' => $annoFilterRaw,
|
||||
'includeDataChecks' => $includeDataChecks,
|
||||
]);
|
||||
}
|
||||
|
|
@ -514,6 +524,9 @@ public function scan(\Illuminate\Http\Request $request)
|
|||
}
|
||||
$moduleFilter = $request->get('module', userSetting('code_quality.module', 'all'));
|
||||
$menuFilter = $request->get('menu', userSetting('code_quality.menu', 'all'));
|
||||
$stabileFilter = trim((string) $request->get('stabile', userSetting('code_quality.stabile', '')));
|
||||
$annoFilterRaw = trim((string) $request->get('anno', userSetting('code_quality.anno', '')));
|
||||
$annoFilter = is_numeric($annoFilterRaw) ? (int) $annoFilterRaw : null;
|
||||
$includeDataChecks = filter_var($request->get('include_data_checks', userSetting('code_quality.include_data_checks', true)), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
// Salva preferenze per utente
|
||||
|
|
@ -521,15 +534,17 @@ public function scan(\Illuminate\Http\Request $request)
|
|||
setUserSetting('code_quality.severity', $severityFilter);
|
||||
setUserSetting('code_quality.module', $moduleFilter);
|
||||
setUserSetting('code_quality.menu', $menuFilter);
|
||||
setUserSetting('code_quality.stabile', $stabileFilter);
|
||||
setUserSetting('code_quality.anno', $annoFilterRaw);
|
||||
setUserSetting('code_quality.include_data_checks', $includeDataChecks);
|
||||
|
||||
$results = $this->scanCodeQuality($scanPath, $severityFilter, $moduleFilter, $menuFilter, $includeDataChecks);
|
||||
$results = $this->scanCodeQuality($scanPath, $severityFilter, $moduleFilter, $menuFilter, $includeDataChecks, $stabileFilter, $annoFilter);
|
||||
$totalScanned = $this->countFilesInPaths($scanPath, $moduleFilter, $menuFilter);
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'results' => $results,
|
||||
'summary' => $this->generateSummary($results, $totalScanned)
|
||||
'summary' => $this->generateSummary($results, $totalScanned),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -542,16 +557,19 @@ public function export(\Illuminate\Http\Request $request)
|
|||
$severityFilter = $request->get('severity', userSetting('code_quality.severity', 'all'));
|
||||
$moduleFilter = $request->get('module', userSetting('code_quality.module', 'all'));
|
||||
$menuFilter = $request->get('menu', userSetting('code_quality.menu', 'all'));
|
||||
$stabileFilter = trim((string) $request->get('stabile', userSetting('code_quality.stabile', '')));
|
||||
$annoFilterRaw = trim((string) $request->get('anno', userSetting('code_quality.anno', '')));
|
||||
$annoFilter = is_numeric($annoFilterRaw) ? (int) $annoFilterRaw : null;
|
||||
$includeDataChecks = filter_var($request->get('include_data_checks', userSetting('code_quality.include_data_checks', true)), FILTER_VALIDATE_BOOLEAN);
|
||||
$format = strtolower($request->get('format', 'csv'));
|
||||
|
||||
$results = $this->scanCodeQuality($scanPath, $severityFilter, $moduleFilter, $menuFilter, $includeDataChecks);
|
||||
$results = $this->scanCodeQuality($scanPath, $severityFilter, $moduleFilter, $menuFilter, $includeDataChecks, $stabileFilter, $annoFilter);
|
||||
$totalScanned = $this->countFilesInPaths($scanPath, $moduleFilter, $menuFilter);
|
||||
$summary = $this->generateSummary($results, $totalScanned);
|
||||
|
||||
if ($format === 'json') {
|
||||
$payload = [
|
||||
'params' => compact('scanPath', 'severityFilter', 'moduleFilter', 'menuFilter'),
|
||||
'params' => compact('scanPath', 'severityFilter', 'moduleFilter', 'menuFilter', 'stabileFilter', 'annoFilter'),
|
||||
'summary' => $summary,
|
||||
'results' => $results,
|
||||
];
|
||||
|
|
@ -564,7 +582,7 @@ public function export(\Illuminate\Http\Request $request)
|
|||
|
||||
// CSV flat: file, module, menu, rule, severity, line, suggestion, match
|
||||
$rows = [
|
||||
['file', 'module', 'menu', 'rule', 'name', 'severity', 'line', 'description', 'suggestion', 'match']
|
||||
['file', 'module', 'menu', 'rule', 'name', 'severity', 'line', 'description', 'suggestion', 'match'],
|
||||
];
|
||||
foreach ($results as $fileResult) {
|
||||
foreach ($fileResult['issues'] as $issue) {
|
||||
|
|
@ -601,7 +619,7 @@ public function export(\Illuminate\Http\Request $request)
|
|||
/**
|
||||
* Esegue la scansione effettiva
|
||||
*/
|
||||
private function scanCodeQuality(string $scanPaths, string $severityFilter, string $moduleFilter = 'all', string $menuFilter = 'all', bool $includeDataChecks = true): array
|
||||
private function scanCodeQuality(string $scanPaths, string $severityFilter, string $moduleFilter = 'all', string $menuFilter = 'all', bool $includeDataChecks = true, string $stabileFilter = '', ?int $annoFilter = null): array
|
||||
{
|
||||
$results = [];
|
||||
$paths = explode(',', $scanPaths);
|
||||
|
|
@ -634,8 +652,8 @@ private function scanCodeQuality(string $scanPaths, string $severityFilter, stri
|
|||
'line' => 1,
|
||||
'match' => '—',
|
||||
'suggestion' => 'Verifica i permessi o escludi il percorso dalla scansione',
|
||||
'context' => []
|
||||
]]
|
||||
'context' => [],
|
||||
]],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
|
@ -658,7 +676,7 @@ private function scanCodeQuality(string $scanPaths, string $severityFilter, stri
|
|||
'line' => 1,
|
||||
'match' => '—',
|
||||
'suggestion' => 'Riduci il file o escludilo dalla scansione',
|
||||
'context' => []
|
||||
'context' => [],
|
||||
]];
|
||||
}
|
||||
|
||||
|
|
@ -667,14 +685,14 @@ private function scanCodeQuality(string $scanPaths, string $severityFilter, stri
|
|||
'file' => $relativePath,
|
||||
'module' => $this->classifyModule($relativePath),
|
||||
'menu' => $this->classifyMenu($relativePath),
|
||||
'issues' => $fileResults
|
||||
'issues' => $fileResults,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($includeDataChecks) {
|
||||
$results = array_merge($results, $this->runArchiveConsistencyChecks());
|
||||
$results = array_merge($results, $this->runArchiveConsistencyChecks($stabileFilter, $annoFilter));
|
||||
}
|
||||
|
||||
// Filtro per severity
|
||||
|
|
@ -685,7 +703,7 @@ private function scanCodeQuality(string $scanPaths, string $severityFilter, stri
|
|||
return $results;
|
||||
}
|
||||
|
||||
private function runArchiveConsistencyChecks(): array
|
||||
private function runArchiveConsistencyChecks(string $stabileFilter = '', ?int $annoFilter = null): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
|
|
@ -739,6 +757,11 @@ private function runArchiveConsistencyChecks(): array
|
|||
];
|
||||
}
|
||||
|
||||
$ordinarieIssues = $this->ordinarieQualityAudit->run($stabileFilter !== '' ? $stabileFilter : null, $annoFilter);
|
||||
if (! empty($ordinarieIssues)) {
|
||||
$results = array_merge($results, $ordinarieIssues);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
|
|
@ -1444,14 +1467,32 @@ private function getFilesToScan(string $path): array
|
|||
if (in_array($extension, ['php', 'blade.php', 'js', 'vue', 'ts'])) {
|
||||
// Escludi alcune cartelle rumorose
|
||||
$pathname = $file->getPathname();
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)) continue;
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'node_modules' . DIRECTORY_SEPARATOR)) continue;
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR)) continue;
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'SAFE_BACKUPS' . DIRECTORY_SEPARATOR)) continue;
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'phpmyadmin' . DIRECTORY_SEPARATOR)) continue;
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'node_modules' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'SAFE_BACKUPS' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($pathname, DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'phpmyadmin' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Salta file troppo grandi (> 1 MB) per evitare consumo eccessivo di memoria/tempo
|
||||
try {
|
||||
if (@filesize($pathname) !== false && @filesize($pathname) > 1024 * 1024) continue;
|
||||
if (@filesize($pathname) !== false && @filesize($pathname) > 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$files[] = $file->getPathname();
|
||||
|
|
@ -1494,7 +1535,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => 1,
|
||||
'match' => '—',
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, 1)
|
||||
'context' => $this->getLineContext($content, 1),
|
||||
];
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1512,7 +1553,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => 1,
|
||||
'match' => "File con $lineCount righe",
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, 1)
|
||||
'context' => $this->getLineContext($content, 1),
|
||||
];
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1542,7 +1583,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => $lineNumber,
|
||||
'match' => is_array($match[0]) ? ($match[0][0] ?? "toggleCategory('$cat')") : (string) $match[0],
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, max(1, $lineNumber))
|
||||
'context' => $this->getLineContext($content, max(1, $lineNumber)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1563,7 +1604,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => 1,
|
||||
'match' => '—',
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, 1)
|
||||
'context' => $this->getLineContext($content, 1),
|
||||
];
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1604,7 +1645,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => $lineNumber,
|
||||
'match' => trim($match[0]),
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, $lineNumber)
|
||||
'context' => $this->getLineContext($content, $lineNumber),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1632,12 +1673,15 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'netgescon-card',
|
||||
'netgescon-title',
|
||||
'netgescon-text',
|
||||
'netgescon-input'
|
||||
'netgescon-input',
|
||||
];
|
||||
$parts = [];
|
||||
foreach ($tokens as $t) {
|
||||
$c = preg_match_all('/' . preg_quote($t, '/') . '/m', $content);
|
||||
if ($c > 0) $parts[] = $t . ':' . $c;
|
||||
if ($c > 0) {
|
||||
$parts[] = $t . ':' . $c;
|
||||
}
|
||||
|
||||
}
|
||||
$summaryMatch = 'componenti: ' . ($parts ? implode(', ', $parts) : ('totali:' . $count));
|
||||
} else {
|
||||
|
|
@ -1646,12 +1690,15 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'grid-cols-' => 'grid-cols',
|
||||
'md:grid-cols-' => 'md:grid-cols',
|
||||
'col-span-' => 'col-span',
|
||||
'\\bflex\\b' => 'flex'
|
||||
'\\bflex\\b' => 'flex',
|
||||
];
|
||||
$parts = [];
|
||||
foreach ($tokens as $pattern => $label) {
|
||||
$c = preg_match_all('/' . $pattern . '/m', $content);
|
||||
if ($c > 0) $parts[] = $label . ':' . $c;
|
||||
if ($c > 0) {
|
||||
$parts[] = $label . ':' . $c;
|
||||
}
|
||||
|
||||
}
|
||||
$summaryMatch = 'layout: ' . ($parts ? implode(', ', $parts) : ('totali:' . $count));
|
||||
}
|
||||
|
|
@ -1663,7 +1710,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => $lineNumber,
|
||||
'match' => $summaryMatch,
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, $lineNumber)
|
||||
'context' => $this->getLineContext($content, $lineNumber),
|
||||
];
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1680,7 +1727,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => 1,
|
||||
'match' => '—',
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, 1)
|
||||
'context' => $this->getLineContext($content, 1),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1696,7 +1743,7 @@ private function analyzeFile(string $filePath, string $content, string $relative
|
|||
'line' => $lineNumber,
|
||||
'match' => trim($match[0]),
|
||||
'suggestion' => $rule['suggestion'] ?? '',
|
||||
'context' => $this->getLineContext($content, $lineNumber)
|
||||
'context' => $this->getLineContext($content, $lineNumber),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1732,7 +1779,10 @@ private function shouldApplyRule(string $ruleKey, array $rule, string $extension
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (!$ok) return false;
|
||||
if (! $ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Esclusioni per percorso
|
||||
|
|
@ -1761,7 +1811,7 @@ private function getLineContext(string $content, int $lineNumber): array
|
|||
$context[] = [
|
||||
'number' => $i + 1,
|
||||
'content' => $lines[$i] ?? '',
|
||||
'highlight' => ($i + 1) === $lineNumber
|
||||
'highlight' => ($i + 1) === $lineNumber,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -1798,11 +1848,11 @@ private function generateSummary(array $results, ?int $totalScannedFiles = null)
|
|||
'critical' => 0,
|
||||
'error' => 0,
|
||||
'warning' => 0,
|
||||
'info' => 0
|
||||
'info' => 0,
|
||||
],
|
||||
'by_rule' => [],
|
||||
'by_module' => [],
|
||||
'by_menu' => []
|
||||
'by_menu' => [],
|
||||
];
|
||||
|
||||
foreach ($results as $fileResult) {
|
||||
|
|
@ -1814,15 +1864,21 @@ private function generateSummary(array $results, ?int $totalScannedFiles = null)
|
|||
if (! isset($summary['by_rule'][$ruleKey])) {
|
||||
$summary['by_rule'][$ruleKey] = [
|
||||
'name' => $issue['name'],
|
||||
'count' => 0
|
||||
'count' => 0,
|
||||
];
|
||||
}
|
||||
$summary['by_rule'][$ruleKey]['count']++;
|
||||
|
||||
$module = $fileResult['module'] ?? 'unknown';
|
||||
$menu = $fileResult['menu'] ?? 'unknown';
|
||||
if (!isset($summary['by_module'][$module])) $summary['by_module'][$module] = 0;
|
||||
if (!isset($summary['by_menu'][$menu])) $summary['by_menu'][$menu] = 0;
|
||||
if (! isset($summary['by_module'][$module])) {
|
||||
$summary['by_module'][$module] = 0;
|
||||
}
|
||||
|
||||
if (! isset($summary['by_menu'][$menu])) {
|
||||
$summary['by_menu'][$menu] = 0;
|
||||
}
|
||||
|
||||
$summary['by_module'][$module]++;
|
||||
$summary['by_menu'][$menu]++;
|
||||
}
|
||||
|
|
@ -1865,7 +1921,7 @@ public function autofix(Request $request)
|
|||
'permissionHints' => ! is_writable($fullPath) ? [
|
||||
'Rendi scrivibile il file per l\'utente del web server (www-data) o per il tuo utente.',
|
||||
'Valuta di cambiare proprietario/gruppo del progetto o applicare ACL (setfacl) per la modifica.',
|
||||
'In alternativa, copia il contenuto proposto manualmente nel file e salva.'
|
||||
'In alternativa, copia il contenuto proposto manualmente nel file e salva.',
|
||||
] : [],
|
||||
]);
|
||||
}
|
||||
|
|
@ -1898,7 +1954,10 @@ private function applyAutoFix(string $content, string $ruleKey): string
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (!$inserted) array_unshift($lines, rtrim($insertion));
|
||||
if (! $inserted) {
|
||||
array_unshift($lines, rtrim($insertion));
|
||||
}
|
||||
|
||||
$content = implode("\n", $lines);
|
||||
}
|
||||
break;
|
||||
|
|
@ -1984,7 +2043,10 @@ private function countFilesInPaths(string $scanPaths, string $moduleFilter = 'al
|
|||
foreach ($paths as $path) {
|
||||
$path = trim($path);
|
||||
$fullPath = \Illuminate\Support\Facades\App::basePath($path);
|
||||
if (!File::exists($fullPath)) continue;
|
||||
if (! File::exists($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = $this->getFilesToScan($fullPath);
|
||||
if ($moduleFilter !== 'all' || $menuFilter !== 'all') {
|
||||
$files = array_filter($files, function ($f) use ($moduleFilter, $menuFilter) {
|
||||
|
|
@ -2026,7 +2088,10 @@ private function classifyModule(string $relativePath): string
|
|||
{
|
||||
foreach ($this->modulePaths as $key => $paths) {
|
||||
foreach ($paths as $p) {
|
||||
if (Str::contains($relativePath, $p)) return $key;
|
||||
if (Str::contains($relativePath, $p)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return 'core';
|
||||
|
|
@ -2036,7 +2101,10 @@ private function classifyMenu(string $relativePath): string
|
|||
{
|
||||
foreach ($this->menuPaths as $key => $paths) {
|
||||
foreach ($paths as $p) {
|
||||
if (Str::contains($relativePath, $p)) return $key;
|
||||
if (Str::contains($relativePath, $p)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return 'generic';
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
@ -277,7 +278,7 @@ public function activateAdministrator(Request $request): JsonResponse
|
|||
'codice_amministratore' => $codiceAmministratore,
|
||||
'user_id' => 1, // TODO: gestire user associato
|
||||
'database_attivo' => $metadata['amministratore']['database_name'],
|
||||
'cartella_dati' => "amministratori/{$codiceAmministratore}",
|
||||
'cartella_dati' => app(TenantArchivePathService::class)->amministratoreRelativePath($codiceAmministratore),
|
||||
'stato_sincronizzazione' => 'attivo',
|
||||
'attivo' => true,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class RubricaUniversale extends Model
|
|||
protected $table = 'rubrica_universale';
|
||||
|
||||
protected $fillable = [
|
||||
'amministratore_id',
|
||||
'codice_univoco',
|
||||
'titolo_id',
|
||||
'nome',
|
||||
'cognome',
|
||||
|
|
|
|||
51
app/Models/TenantArchiveRegistry.php
Normal file
51
app/Models/TenantArchiveRegistry.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -118,4 +118,5 @@ public function panel(Panel $panel): Panel
|
|||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
<?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
|
||||
*/
|
||||
|
|
@ -39,12 +41,12 @@ public function createAmministratoreDatabase(Amministratore $amministratore): ar
|
|||
return [
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -73,12 +75,12 @@ public function createStabileDatabase(Stabile $stabile): array
|
|||
return [
|
||||
'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,7 +102,7 @@ private function createDatabaseUser(string $dbName, string $adminCode): void
|
|||
DB::statement("FLUSH PRIVILEGES");
|
||||
|
||||
// Salva credenziali in file sicuro
|
||||
$credentialsDir = storage_path("app/amministratori/{$adminCode}/database");
|
||||
$credentialsDir = $this->tenantArchivePath->amministratoreAbsolutePath($adminCode, 'database');
|
||||
if (! is_dir($credentialsDir)) {
|
||||
mkdir($credentialsDir, 0755, true);
|
||||
}
|
||||
|
|
@ -110,7 +112,7 @@ private function createDatabaseUser(string $dbName, string $adminCode): void
|
|||
'username' => $username,
|
||||
'password' => $password,
|
||||
'host' => 'localhost',
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
'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) {
|
||||
|
|
@ -374,7 +376,7 @@ private function populateStabileDatabase(string $dbName, Stabile $stabile): void
|
|||
'denominazione' => $stabile->denominazione,
|
||||
'amministratore_codice' => $stabile->amministratore->codice_amministratore ?? $stabile->amministratore->codice_univoco,
|
||||
'created_at' => now(),
|
||||
'updated_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
|
||||
|
|
@ -453,7 +454,7 @@ private function createStabileFolderStructure(Stabile $stabile): void
|
|||
'database_backups' => 'database/backups',
|
||||
'reports' => 'reports',
|
||||
'archivio' => 'archivio',
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
Storage::disk('local')->put(
|
||||
|
|
@ -468,7 +469,7 @@ 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");
|
||||
$configDir = $this->tenantArchivePath->amministratoreAbsolutePath($amministratore, 'database');
|
||||
if (! is_dir($configDir)) {
|
||||
mkdir($configDir, 0755, true);
|
||||
}
|
||||
|
|
@ -550,7 +551,7 @@ public function getAmministratoreConnection(Amministratore $amministratore): \Il
|
|||
'prefix' => '',
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -583,7 +584,7 @@ public function getStabileConnection(Stabile $stabile): \Illuminate\Database\Con
|
|||
'prefix' => '',
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -609,8 +610,7 @@ public function backupStabileDatabase(Stabile $stabile): array
|
|||
|
||||
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)) {
|
||||
mkdir($backupPath, 0755, true);
|
||||
|
|
@ -639,12 +639,12 @@ public function backupStabileDatabase(Stabile $stabile): array
|
|||
'filename' => $filename,
|
||||
'path' => $fullPath,
|
||||
'size' => filesize($fullPath),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -56,7 +55,7 @@ public function importFromGesconDirectory(string $baseDir): array
|
|||
'success' => true,
|
||||
'gestioni_create' => count($this->gestioni),
|
||||
'years_imported' => array_keys($results),
|
||||
'details' => $results
|
||||
'details' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +74,7 @@ private function scanGesconYears(string $baseDir): array
|
|||
if (is_numeric($year) && strlen($year) == 4) {
|
||||
$paths = [
|
||||
'singolo_anno' => $dir . 'singolo_anno.mdb',
|
||||
'generale_stabile' => $dir . 'generale_stabile.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)
|
||||
|
|
@ -118,7 +117,7 @@ private function scanGestioniFromMdb(string $mdbPath, int $year): array
|
|||
'denominazione' => "Gestione Riscaldamento {$year}",
|
||||
'data_inizio' => "{$year}-01-01",
|
||||
'data_fine' => "{$year}-12-31",
|
||||
'protocollo_prefix' => "R{$year}"
|
||||
'protocollo_prefix' => "R{$year}",
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +140,7 @@ private function scanGestioniFromMdb(string $mdbPath, int $year): array
|
|||
'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}",
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -157,13 +156,13 @@ private function createOrUpdateGestione(array $gestioneData): GestioneContabile
|
|||
'tenant_id' => $this->tenantId,
|
||||
'anno_gestione' => $gestioneData['anno_gestione'],
|
||||
'tipo_gestione' => $gestioneData['tipo_gestione'],
|
||||
'numero_straordinaria' => $gestioneData['numero_straordinaria'] ?? null
|
||||
'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']
|
||||
'protocollo_prefix' => $gestioneData['protocollo_prefix'],
|
||||
]);
|
||||
|
||||
// Cache per lookup veloce
|
||||
|
|
@ -220,7 +219,7 @@ private function importOperazioniForGestione(GestioneContabile $gestione, string
|
|||
'protocollo_numero' => $protocollo['numero'],
|
||||
'protocollo_completo' => $protocollo['completo'],
|
||||
'voce_spesa_snapshot' => json_encode($voceSnapshot),
|
||||
'metadati_gescon' => json_encode($op)
|
||||
'metadati_gescon' => json_encode($op),
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
|
|
@ -229,7 +228,7 @@ private function importOperazioniForGestione(GestioneContabile $gestione, string
|
|||
Log::error("Error importing operazione for gestione", [
|
||||
'gestione_id' => $gestione->id,
|
||||
'operazione' => $op,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -259,7 +258,7 @@ private function importIncassiForGestione(GestioneContabile $gestione, string $m
|
|||
Log::error("Error importing incasso", [
|
||||
'gestione_id' => $gestione->id,
|
||||
'incasso' => $inc,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -294,7 +293,7 @@ private function importRitenuteFOrGestione(GestioneContabile $gestione, string $
|
|||
'importo_ritenuta' => (float) ($ra['ritenuta'] ?? 0),
|
||||
'rif_rda' => $ra['Rif_RDA'],
|
||||
'stato_versamento' => 'da_versare',
|
||||
'metadati_gescon' => json_encode($ra)
|
||||
'metadati_gescon' => json_encode($ra),
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
|
|
@ -303,7 +302,7 @@ private function importRitenuteFOrGestione(GestioneContabile $gestione, string $
|
|||
Log::error("Error importing ritenuta", [
|
||||
'gestione_id' => $gestione->id,
|
||||
'ritenuta' => $ra,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -333,7 +332,7 @@ private function importIncassiEstrattoContoForGestione(GestioneContabile $gestio
|
|||
'importo' => (float) ($inc['importo'] ?? 0),
|
||||
'descrizione' => $inc['descrizione'] ?? '',
|
||||
'stato' => 'da_riconciliare',
|
||||
'metadati_gescon' => json_encode($inc)
|
||||
'metadati_gescon' => json_encode($inc),
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
|
|
@ -342,7 +341,7 @@ private function importIncassiEstrattoContoForGestione(GestioneContabile $gestio
|
|||
Log::error("Error importing incasso EC", [
|
||||
'gestione_id' => $gestione->id,
|
||||
'incasso' => $inc,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -404,7 +403,7 @@ private function importGestioneYear(string $year, array $paths): array
|
|||
'incassi' => 0,
|
||||
'ritenute' => 0,
|
||||
'rate_emesse' => 0,
|
||||
'incassi_ec' => 0
|
||||
'incassi_ec' => 0,
|
||||
];
|
||||
|
||||
// 1. Import operazioni contabili
|
||||
|
|
@ -470,7 +469,7 @@ private function importOperazioni(string $year, string $mdbPath): int
|
|||
'protocollo_numero' => $protocollo['numero'],
|
||||
'protocollo_completo' => $protocollo['completo'],
|
||||
'voce_spesa_snapshot' => json_encode($voceSnapshot),
|
||||
'metadati_gescon' => json_encode($op)
|
||||
'metadati_gescon' => json_encode($op),
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
|
|
@ -479,7 +478,7 @@ private function importOperazioni(string $year, string $mdbPath): int
|
|||
Log::error("Error importing operazione", [
|
||||
'year' => $year,
|
||||
'operazione' => $op,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -517,7 +516,7 @@ private function importRitenuteAcconto(string $year, string $mdbPath): int
|
|||
'importo_ritenuta' => (float) ($ra['ritenuta'] ?? 0),
|
||||
'rif_rda' => $ra['Rif_RDA'],
|
||||
'stato_versamento' => 'da_versare', // Default
|
||||
'metadati_gescon' => json_encode($ra)
|
||||
'metadati_gescon' => json_encode($ra),
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
|
|
@ -526,7 +525,7 @@ private function importRitenuteAcconto(string $year, string $mdbPath): int
|
|||
Log::error("Error importing ritenuta", [
|
||||
'year' => $year,
|
||||
'ritenuta' => $ra,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -559,7 +558,7 @@ private function importIncassiEstrattoConto(string $year, string $mdbPath): int
|
|||
'importo' => (float) ($inc['importo'] ?? 0),
|
||||
'descrizione' => $inc['descrizione'] ?? '',
|
||||
'stato' => 'da_riconciliare',
|
||||
'metadati_gescon' => json_encode($inc)
|
||||
'metadati_gescon' => json_encode($inc),
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
|
|
@ -568,7 +567,7 @@ private function importIncassiEstrattoConto(string $year, string $mdbPath): int
|
|||
Log::error("Error importing incasso EC", [
|
||||
'year' => $year,
|
||||
'incasso' => $inc,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -640,7 +639,7 @@ private function getOrCreateStraordinaria(string $year, int $nStra): int
|
|||
'data_inizio' => "{$year}-01-01",
|
||||
'data_fine' => "{$year}-12-31",
|
||||
'stato' => 'aperta',
|
||||
'protocollo_prefix' => "S{$year}-{$nStra}"
|
||||
'protocollo_prefix' => "S{$year}-{$nStra}",
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -671,13 +670,18 @@ 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 = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty(trim($line))) continue;
|
||||
if (empty(trim($line))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values = explode("\t", $line);
|
||||
$row = array_combine($headers, $values);
|
||||
$data[] = $row;
|
||||
|
|
@ -696,7 +700,7 @@ private function generateProtocollo(int $gestioneId, array $operazione): array
|
|||
|
||||
return [
|
||||
'numero' => $numero,
|
||||
'completo' => $gestione->protocollo_prefix . '-' . str_pad($numero, 4, '0', STR_PAD_LEFT)
|
||||
'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'];
|
||||
|
|
|
|||
|
|
@ -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->tenantArchivePath = $tenantArchivePath;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,7 +36,7 @@ public function prepareAmministratoreMigration(Amministratore $amministratore):
|
|||
'migration_id' => $migrationId,
|
||||
'amministratore' => $amministratore->codice_univoco,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'components' => []
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
// 1. Backup database amministratore
|
||||
|
|
@ -80,12 +80,12 @@ public function prepareAmministratoreMigration(Amministratore $amministratore):
|
|||
|
||||
return [
|
||||
'success' => true,
|
||||
'migration' => $results
|
||||
'migration' => $results,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ public function prepareStabileMigration(Stabile $stabile): array
|
|||
'stabile' => $stabile->codice_stabile,
|
||||
'amministratore' => $stabile->amministratore->codice_univoco,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'components' => []
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
// 1. Backup database stabile
|
||||
|
|
@ -134,12 +134,12 @@ public function prepareStabileMigration(Stabile $stabile): array
|
|||
|
||||
return [
|
||||
'success' => true,
|
||||
'migration' => $results
|
||||
'migration' => $results,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -150,7 +150,7 @@ 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;
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ private function createFileSystemArchive(Amministratore $amministratore, string
|
|||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($archivePath, ZipArchive::CREATE) !== TRUE) {
|
||||
if ($zip->open($archivePath, ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Impossibile creare archivio filesystem');
|
||||
}
|
||||
|
||||
|
|
@ -181,12 +181,12 @@ private function createFileSystemArchive(Amministratore $amministratore, string
|
|||
'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,8 +197,7 @@ 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;
|
||||
|
||||
|
|
@ -208,7 +207,7 @@ private function createStabileFileArchive(Stabile $stabile, string $migrationPat
|
|||
}
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
|
|
@ -230,12 +229,12 @@ private function createStabileFileArchive(Stabile $stabile, string $migrationPat
|
|||
'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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -269,7 +268,7 @@ private function createMigrationMetadata(Amministratore $amministratore, string
|
|||
'stabili' => [],
|
||||
'databases' => [
|
||||
'principale' => $amministratore->getDatabaseName(),
|
||||
'stabili' => []
|
||||
'stabili' => [],
|
||||
],
|
||||
'requirements' => [
|
||||
'php_version' => PHP_VERSION,
|
||||
|
|
@ -280,14 +279,14 @@ private function createMigrationMetadata(Amministratore $amministratore, string
|
|||
'pdo_mysql',
|
||||
'openssl',
|
||||
'fileinfo',
|
||||
'mbstring'
|
||||
'mbstring',
|
||||
],
|
||||
'min_disk_space_mb' => $this->calculateRequiredSpace($amministratore),
|
||||
],
|
||||
'structure' => [
|
||||
'folders' => $this->analyzeFolderStructure($amministratore),
|
||||
'databases' => $this->analyzeDatabaseStructure($amministratore),
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
// Aggiungi info per ogni stabile
|
||||
|
|
@ -315,12 +314,12 @@ private function createMigrationMetadata(Amministratore $amministratore, string
|
|||
'success' => true,
|
||||
'metadata_file' => $metadataPath,
|
||||
'stabili_count' => count($metadata['stabili']),
|
||||
'databases_count' => count($metadata['databases']['stabili']) + 1
|
||||
'databases_count' => count($metadata['databases']['stabili']) + 1,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -358,7 +357,7 @@ private function createStabileMetadata(Stabile $stabile, string $migrationPath):
|
|||
'target_admin_code' => 'required',
|
||||
'php_version' => PHP_VERSION,
|
||||
'mysql_version' => DB::select('SELECT VERSION() as version')[0]->version,
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
$metadataPath = $migrationPath . '/stabile_metadata.json';
|
||||
|
|
@ -366,12 +365,12 @@ private function createStabileMetadata(Stabile $stabile, string $migrationPath):
|
|||
|
||||
return [
|
||||
'success' => true,
|
||||
'metadata_file' => $metadataPath
|
||||
'metadata_file' => $metadataPath,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -415,12 +414,12 @@ private function createMigrationScripts(Amministratore $amministratore, string $
|
|||
return [
|
||||
'success' => true,
|
||||
'scripts_path' => $scriptsPath,
|
||||
'scripts_created' => 5
|
||||
'scripts_created' => 5,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -780,12 +779,12 @@ private function verifyMigrationIntegrity(string $migrationPath): array
|
|||
|
||||
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');
|
||||
}
|
||||
|
||||
|
|
@ -823,12 +822,12 @@ private function createMigrationPackage(string $migrationId, string $migrationPa
|
|||
'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,7 +840,7 @@ 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);
|
||||
}
|
||||
|
|
@ -857,7 +856,7 @@ 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)) {
|
||||
return ['exists' => false];
|
||||
|
|
@ -868,7 +867,7 @@ private function analyzeFolderStructure(Amministratore $amministratore): array
|
|||
'path' => $adminPath,
|
||||
'size' => $this->getDirectorySize($adminPath),
|
||||
'files_count' => $this->countFiles($adminPath),
|
||||
'directories' => $this->listDirectories($adminPath)
|
||||
'directories' => $this->listDirectories($adminPath),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -881,9 +880,9 @@ private function analyzeDatabaseStructure(Amministratore $amministratore): array
|
|||
'amministratore' => [
|
||||
'database' => $amministratore->getDatabaseName(),
|
||||
'exists' => false,
|
||||
'tables' => []
|
||||
'tables' => [],
|
||||
],
|
||||
'stabili' => []
|
||||
'stabili' => [],
|
||||
];
|
||||
|
||||
// Verifica database amministratore
|
||||
|
|
@ -902,7 +901,7 @@ private function analyzeDatabaseStructure(Amministratore $amministratore): array
|
|||
'codice' => $stabile->codice_stabile,
|
||||
'database' => $stabile->database_dedicato,
|
||||
'exists' => false,
|
||||
'tables' => []
|
||||
'tables' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
|
|
|
|||
394
app/Services/Quality/OrdinarieQualityAuditService.php
Normal file
394
app/Services/Quality/OrdinarieQualityAuditService.php
Normal 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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -43,24 +62,31 @@ public function store(object $file, string $directory): array
|
|||
'path' => $path,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'original_name' => $originalName,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
86
app/Services/TenantArchivePathService.php
Normal file
86
app/Services/TenantArchivePathService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
101
app/Services/TenantArchiveRegistryService.php
Normal file
101
app/Services/TenantArchiveRegistryService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
131
docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md
Normal file
131
docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md
Normal 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.
|
||||
|
|
@ -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] |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
105
docs/support/AGGIORNAMENTI-STAGING-GIT.md
Normal file
105
docs/support/AGGIORNAMENTI-STAGING-GIT.md
Normal 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
|
||||
248
docs/support/ARCHITETTURA-STORAGE-TENANCY.md
Normal file
248
docs/support/ARCHITETTURA-STORAGE-TENANCY.md
Normal 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
|
||||
79
docs/support/ASSISTENZA-TECNOREPAIR-MDB.md
Normal file
79
docs/support/ASSISTENZA-TECNOREPAIR-MDB.md
Normal 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.
|
||||
63
docs/support/BUG-RISOLTI-E-VALIDAZIONI.md
Normal file
63
docs/support/BUG-RISOLTI-E-VALIDAZIONI.md
Normal 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
|
||||
67
docs/support/CONTINUITA-SVILUPPO-AGENT.md
Normal file
67
docs/support/CONTINUITA-SVILUPPO-AGENT.md
Normal 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
|
||||
98
docs/support/MANUALE-SVILUPPO-OPERATIVO.md
Normal file
98
docs/support/MANUALE-SVILUPPO-OPERATIVO.md
Normal 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
|
||||
82
docs/support/PBX-PANASONIC-STATO-OPERATIVO.md
Normal file
82
docs/support/PBX-PANASONIC-STATO-OPERATIVO.md
Normal 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`
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 > 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user