diff --git a/.gitignore b/.gitignore index a2d5002..d5979ae 100755 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,7 @@ Miki-Bug-workspace/** # Local mirrored historical material (not versioned) _legacy-vault/ docs/ai/restart/DAY0-AUDIT-*.txt + +# Windows TAPI probe outputs (generated locally) +inspect-*.json +netgescon-tapi-info.json diff --git a/app/Console/Commands/PanasonicCstaBridgeCommand.php b/app/Console/Commands/PanasonicCstaBridgeCommand.php new file mode 100644 index 0000000..7e45e26 --- /dev/null +++ b/app/Console/Commands/PanasonicCstaBridgeCommand.php @@ -0,0 +1,195 @@ +option('host'); + $port = max(1, (int) $this->option('port')); + $timeout = max(1, (int) $this->option('timeout')); + $readBytes = max(64, min(65535, (int) $this->option('read-bytes'))); + $reconnect = (bool) $this->option('reconnect'); + $reconnectDelay = max(1, (int) $this->option('reconnect-delay')); + $maxIdleTimeouts = max(0, (int) $this->option('max-idle-timeouts')); + $startupSend = (string) ($this->option('startup-send') ?? ''); + $startupSendHex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', (string) ($this->option('startup-send-hex') ?? '')) ?? ''); + $logFile = (string) $this->option('log-file'); + $printHex = (bool) $this->option('print-hex'); + $printText = (bool) $this->option('print-text'); + + if ($startupSend !== '' && $startupSendHex !== '') { + $this->error('Usa solo uno tra --startup-send e --startup-send-hex.'); + return self::FAILURE; + } + + if ($startupSendHex !== '' && strlen($startupSendHex) % 2 !== 0) { + $this->error('Il payload HEX deve avere un numero pari di caratteri.'); + return self::FAILURE; + } + + $startupPayload = $startupSendHex !== '' ? (hex2bin($startupSendHex) ?: ''): $startupSend; + $absoluteLogFile = $this->normalizeLogFilePath($logFile); + + $this->info("Bridge Panasonic CSTA verso {$host}:{$port}"); + $this->line('Log raw frames: ' . $absoluteLogFile); + + do { + $socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, $timeout); + if (! is_resource($socket)) { + $this->error("Connessione fallita ({$errno}): {$errstr}"); + if (! $reconnect) { + return self::FAILURE; + } + + sleep($reconnectDelay); + continue; + } + + stream_set_timeout($socket, $timeout); + stream_set_blocking($socket, true); + + $this->info('Connessione CSTA stabilita.'); + $this->appendFrameLog($absoluteLogFile, [ + 'type' => 'connection.opened', + 'host' => $host, + 'port' => $port, + 'ts' => now()->toIso8601String(), + ]); + + if ($startupPayload !== '') { + @fwrite($socket, $startupPayload); + $this->appendFrameLog($absoluteLogFile, [ + 'type' => 'connection.startup_payload', + 'host' => $host, + 'port' => $port, + 'ts' => now()->toIso8601String(), + 'payload_hex' => strtoupper(bin2hex($startupPayload)), + 'payload_text' => $this->renderPrintable($startupPayload), + ]); + } + + $idleTimeouts = 0; + while (! feof($socket)) { + $chunk = fread($socket, $readBytes); + $chunk = is_string($chunk) ? $chunk : ''; + + if ($chunk === '') { + $meta = stream_get_meta_data($socket); + if (($meta['timed_out'] ?? false) === true) { + $idleTimeouts++; + $this->warn("Timeout lettura CSTA ({$idleTimeouts})"); + + if ($maxIdleTimeouts > 0 && $idleTimeouts >= $maxIdleTimeouts) { + $this->warn('Soglia timeout raggiunta, riconnessione in corso.'); + break; + } + + continue; + } + + break; + } + + $idleTimeouts = 0; + $hex = strtoupper(bin2hex($chunk)); + $text = $this->renderPrintable($chunk); + + $record = [ + 'type' => 'frame.received', + 'host' => $host, + 'port' => $port, + 'ts' => now()->toIso8601String(), + 'size' => strlen($chunk), + 'payload_hex' => $hex, + 'payload_text' => $text, + ]; + + $this->appendFrameLog($absoluteLogFile, $record); + + if ($printHex) { + $this->line('HEX ' . implode(' ', str_split($hex, 2))); + } + + if ($printText) { + $this->line('TEXT ' . ($text !== '' ? $text : '[binary/non-printable]')); + } + } + + fclose($socket); + $this->appendFrameLog($absoluteLogFile, [ + 'type' => 'connection.closed', + 'host' => $host, + 'port' => $port, + 'ts' => now()->toIso8601String(), + ]); + $this->warn('Socket CSTA chiusa.'); + + if (! $reconnect) { + return self::SUCCESS; + } + + sleep($reconnectDelay); + } while (true); + } + + private function normalizeLogFilePath(string $path): string + { + if (str_starts_with($path, '/')) { + return $path; + } + + return base_path($path); + } + + /** + * @param array $payload + */ + private function appendFrameLog(string $logFile, array $payload): void + { + $dir = dirname($logFile); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (! is_string($json) || $json === '') { + return; + } + + @file_put_contents($logFile, $json . PHP_EOL, FILE_APPEND); + + Log::info('Panasonic CSTA bridge', [ + 'event' => $payload['type'] ?? 'unknown', + 'host' => $payload['host'] ?? null, + 'port' => $payload['port'] ?? null, + 'size' => $payload['size'] ?? null, + ]); + } + + private function renderPrintable(string $payload): string + { + $printable = preg_replace('/[^\P{C}\n\r\t]/u', '.', $payload); + + return is_string($printable) ? trim($printable) : ''; + } +} diff --git a/app/Console/Commands/PanasonicCtiProbeCommand.php b/app/Console/Commands/PanasonicCtiProbeCommand.php new file mode 100644 index 0000000..37dd062 --- /dev/null +++ b/app/Console/Commands/PanasonicCtiProbeCommand.php @@ -0,0 +1,71 @@ +option('host'); + $port = max(1, (int) $this->option('port')); + $timeout = max(1, (int) $this->option('timeout')); + $send = (string) ($this->option('send') ?? ''); + $readBytes = max(1, min(8192, (int) $this->option('read-bytes'))); + $hex = (bool) $this->option('hex'); + + $this->line("Connessione a {$host}:{$port} con timeout {$timeout}s..."); + + $socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, $timeout); + if (! is_resource($socket)) { + $this->error("Connessione fallita ({$errno}): {$errstr}"); + return self::FAILURE; + } + + stream_set_timeout($socket, $timeout); + stream_set_blocking($socket, true); + $this->info('Connessione stabilita.'); + + if ($send !== '') { + fwrite($socket, $send); + $this->line($hex ? ('Payload inviato (hex): ' . strtoupper(bin2hex($send))) : ('Payload inviato: ' . $send)); + } + + $response = fread($socket, $readBytes); + $response = is_string($response) ? $response : ''; + $meta = stream_get_meta_data($socket); + fclose($socket); + + if ($response === '') { + $this->warn('Nessun banner o risposta immediata letta dalla socket.'); + if (($meta['timed_out'] ?? false) === true) { + $this->line('La lettura รจ andata in timeout senza dati.'); + } + + $this->line('Nota: su Linux non possiamo implementare un TAPI/TSP nativo Panasonic senza specifica protocollo o middleware proprietario.'); + return self::SUCCESS; + } + + if ($hex) { + $this->line('Risposta (hex):'); + $this->line(strtoupper(implode(' ', str_split(bin2hex($response), 2)))); + } + + $printable = preg_replace('/[^\P{C}\n\r\t]/u', '.', $response); + $this->line('Risposta (testo best-effort):'); + $this->line($printable === '' ? '[vuota]' : $printable); + $this->line('Nota: su Linux non possiamo implementare un TAPI/TSP nativo Panasonic senza specifica protocollo o middleware proprietario.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SmdrListenCommand.php b/app/Console/Commands/SmdrListenCommand.php index 95271b3..6ab0194 100644 --- a/app/Console/Commands/SmdrListenCommand.php +++ b/app/Console/Commands/SmdrListenCommand.php @@ -3,7 +3,7 @@ use App\Models\ChiamataPostIt; use App\Models\CommunicationMessage; -use App\Models\User; +use App\Services\Cti\PbxRoutingService; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -33,6 +33,7 @@ class SmdrListenCommand extends Command {--user=SMDR : Login username} {--pass=PCCSMDR : Login password} {--timeout=0 : Read timeout in seconds (0 = no timeout)} + {--max-idle-timeouts=30 : Reconnect after this many consecutive read timeouts (0 = never)} {--reconnect=1 : Auto-reconnect when socket closes} {--reconnect-delay=3 : Seconds before reconnect attempt} {--write-postit=1 : Create Post-it records from incoming SMDR lines} @@ -49,6 +50,7 @@ public function handle(): int $user = (string) $this->option('user'); $pass = (string) $this->option('pass'); $timeout = (int) $this->option('timeout'); + $maxIdleTimeouts = max(0, (int) $this->option('max-idle-timeouts')); $reconnect = (bool) ((int) $this->option('reconnect')); $reconnectDelay = max(1, (int) $this->option('reconnect-delay')); $maxLines = (int) $this->option('max-lines'); @@ -108,6 +110,7 @@ public function handle(): int } $this->info('Listening for SMDR lines...'); + $idleTimeouts = 0; while (! feof($socket)) { $line = fgets($socket); @@ -115,7 +118,15 @@ public function handle(): int if ($line === false) { $meta = stream_get_meta_data($socket); if (($meta['timed_out'] ?? false) === true) { + $idleTimeouts++; $this->warn('Read timeout reached, waiting for next data...'); + + if ($maxIdleTimeouts > 0 && $idleTimeouts >= $maxIdleTimeouts) { + $this->warn("Idle timeout threshold reached ({$idleTimeouts}), reconnecting..."); + fclose($socket); + break; + } + continue; } @@ -123,7 +134,8 @@ public function handle(): int continue; } - $line = trim($line); + $idleTimeouts = 0; + $line = trim($line); if ($line === '') { continue; } @@ -472,24 +484,14 @@ private function mapPostItDirection(string $direction, ?int $durationSeconds): s private function resolveRouting(string $extension): array { - $ext = trim($extension); - if ($ext === '' || ! Schema::hasColumn('users', 'pbx_extension')) { + $routing = app(PbxRoutingService::class)->resolveByExtension($extension); + if (! $routing['user']) { return [null, null]; } - $user = User::query()->where('pbx_extension', $ext)->first(); - if (! $user) { - return [null, null]; - } - - $stabileId = null; - if (method_exists($user, 'stabiliAssegnati')) { - $stabileId = $user->stabiliAssegnati()->value('stabili.id'); - } - return [ - (int) $user->id, - $stabileId ? (int) $stabileId : null, + $routing['user_id'], + $routing['stabile_id'], ]; } } diff --git a/app/Filament/Pages/Condomini/LettureServiziArchivio.php b/app/Filament/Pages/Condomini/LettureServiziArchivio.php index af348ad..74197c6 100644 --- a/app/Filament/Pages/Condomini/LettureServiziArchivio.php +++ b/app/Filament/Pages/Condomini/LettureServiziArchivio.php @@ -33,7 +33,7 @@ class LettureServiziArchivio extends Page implements HasTable { use InteractsWithTable; - public ?int $servizioFilter = null; + public ?int $servizioFilter = null; public string $archivioScope = 'active'; protected static ?string $navigationLabel = 'Letture servizi'; @@ -190,7 +190,7 @@ public function table(Table $table): Table } $codice = trim((string) ($stabile->codice_stabile ?? '')); - $nome = trim((string) ($stabile->denominazione ?? '')); + $nome = trim((string) ($stabile->denominazione ?? '')); return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id); }) @@ -277,7 +277,7 @@ public function table(Table $table): Table TextColumn::make('protocollo_numero') ->label('Protocollo') ->formatStateUsing(function ($state, StabileServizioLettura $record): string { - $numero = trim((string) ($state ?? '')); + $numero = trim((string) ($state ?? '')); $categoria = trim((string) ($record->protocollo_categoria ?? '')); if ($numero === '' && $categoria === '') { @@ -418,12 +418,12 @@ public function table(Table $table): Table Select::make('workflow_stato') ->label('Workflow') ->options([ - 'acquisita' => 'Acquisita', - 'da_richiedere' => 'Da richiedere', + 'acquisita' => 'Acquisita', + 'da_richiedere' => 'Da richiedere', 'richiesta_inviata' => 'Richiesta inviata', - 'ricevuta' => 'Ricevuta', - 'protocollata' => 'Protocollata', - 'archiviata' => 'Archiviata', + 'ricevuta' => 'Ricevuta', + 'protocollata' => 'Protocollata', + 'archiviata' => 'Archiviata', ]) ->default('acquisita'), TextInput::make('protocollo_categoria')->label('Categoria protocollo')->maxLength(40), @@ -435,9 +435,9 @@ public function table(Table $table): Table Select::make('rilevatore_tipo') ->label('Rilevatore') ->options([ - 'condomino' => 'Condomino', - 'inquilino' => 'Inquilino', - 'letturista' => 'Letturista', + 'condomino' => 'Condomino', + 'inquilino' => 'Inquilino', + 'letturista' => 'Letturista', 'amministrazione' => 'Amministrazione', ]), TextInput::make('rilevatore_nome')->label('Nome rilevatore')->maxLength(191), @@ -468,32 +468,32 @@ public function table(Table $table): Table } $record = StabileServizioLettura::query()->create([ - 'stabile_id' => (int) $stabileId, - 'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0), - 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, - 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, - 'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null, - 'periodo_dal' => $data['periodo_dal'] ?? null, - 'periodo_al' => $data['periodo_al'] ?? null, - 'tipologia_lettura' => $data['tipologia_lettura'] ?? null, - 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale', - 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita', - 'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null, - 'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null, - 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, + 'stabile_id' => (int) $stabileId, + 'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0), + 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, + 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, + 'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null, + 'periodo_dal' => $data['periodo_dal'] ?? null, + 'periodo_al' => $data['periodo_al'] ?? null, + 'tipologia_lettura' => $data['tipologia_lettura'] ?? null, + 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale', + 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita', + 'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null, + 'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null, + 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, 'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null, 'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null, - 'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null, - 'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null, - 'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, - 'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null, - 'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null, - 'lettura_inizio' => $data['lettura_inizio'] ?? null, - 'lettura_fine' => $data['lettura_fine'] ?? null, - 'consumo_valore' => $data['consumo_valore'] ?? null, - 'consumo_unita' => (string) ($data['consumo_unita'] ?? ''), - 'importo_totale' => $data['importo_totale'] ?? null, - 'created_by' => (int) $user->id, + 'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null, + 'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null, + 'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, + 'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null, + 'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null, + 'lettura_inizio' => $data['lettura_inizio'] ?? null, + 'lettura_fine' => $data['lettura_fine'] ?? null, + 'consumo_valore' => $data['consumo_valore'] ?? null, + 'consumo_unita' => (string) ($data['consumo_unita'] ?? ''), + 'importo_totale' => $data['importo_totale'] ?? null, + 'created_by' => (int) $user->id, ]); $this->hydrateReadingWithPreviousData($record); @@ -542,9 +542,9 @@ public function table(Table $table): Table Select::make('rilevatore_tipo') ->label('Rilevatore') ->options([ - 'condomino' => 'Condomino', - 'inquilino' => 'Inquilino', - 'letturista' => 'Letturista', + 'condomino' => 'Condomino', + 'inquilino' => 'Inquilino', + 'letturista' => 'Letturista', 'amministrazione' => 'Amministrazione', ]), TextInput::make('rilevatore_nome')->label('Nome rilevatore')->maxLength(191), @@ -563,57 +563,57 @@ public function table(Table $table): Table TextInput::make('importo_totale')->label('Importo totale')->numeric(), ]) ->fillForm(fn(StabileServizioLettura $record): array=> [ - 'stabile_servizio_id' => $record->stabile_servizio_id, - 'voce_spesa_id' => $record->voce_spesa_id, - 'fornitore_id' => $record->fornitore_id, - 'unita_immobiliare_id' => $record->unita_immobiliare_id, - 'periodo_dal' => $record->periodo_dal, - 'periodo_al' => $record->periodo_al, - 'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''), - 'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''), - 'workflow_stato' => (string) ($record->workflow_stato ?? ''), - 'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''), - 'protocollo_numero' => (string) ($record->protocollo_numero ?? ''), - 'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''), + 'stabile_servizio_id' => $record->stabile_servizio_id, + 'voce_spesa_id' => $record->voce_spesa_id, + 'fornitore_id' => $record->fornitore_id, + 'unita_immobiliare_id' => $record->unita_immobiliare_id, + 'periodo_dal' => $record->periodo_dal, + 'periodo_al' => $record->periodo_al, + 'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''), + 'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''), + 'workflow_stato' => (string) ($record->workflow_stato ?? ''), + 'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''), + 'protocollo_numero' => (string) ($record->protocollo_numero ?? ''), + 'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''), 'richiesta_lettura_inviata_at' => $record->richiesta_lettura_inviata_at, 'prossima_lettura_scadenza_at' => $record->prossima_lettura_scadenza_at, - 'deadline_lettura_at' => $record->deadline_lettura_at, - 'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? ''), - 'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''), - 'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''), - 'lettura_precedente_valore' => $record->lettura_precedente_valore, - 'lettura_inizio' => $record->lettura_inizio, - 'lettura_fine' => $record->lettura_fine, - 'consumo_valore' => $record->consumo_valore, - 'consumo_unita' => (string) ($record->consumo_unita ?? ''), - 'importo_totale' => $record->importo_totale, + 'deadline_lettura_at' => $record->deadline_lettura_at, + 'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? ''), + 'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''), + 'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''), + 'lettura_precedente_valore' => $record->lettura_precedente_valore, + 'lettura_inizio' => $record->lettura_inizio, + 'lettura_fine' => $record->lettura_fine, + 'consumo_valore' => $record->consumo_valore, + 'consumo_unita' => (string) ($record->consumo_unita ?? ''), + 'importo_totale' => $record->importo_totale, ]) ->action(function (StabileServizioLettura $record, array $data): void { $record->fill([ - 'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0), - 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, - 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, - 'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null, - 'periodo_dal' => $data['periodo_dal'] ?? null, - 'periodo_al' => $data['periodo_al'] ?? null, - 'tipologia_lettura' => $data['tipologia_lettura'] ?? null, - 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null, - 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null, - 'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null, - 'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null, - 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, + 'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0), + 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, + 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, + 'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null, + 'periodo_dal' => $data['periodo_dal'] ?? null, + 'periodo_al' => $data['periodo_al'] ?? null, + 'tipologia_lettura' => $data['tipologia_lettura'] ?? null, + 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null, + 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null, + 'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null, + 'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null, + 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, 'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null, 'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null, - 'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null, - 'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null, - 'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, - 'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null, - 'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null, - 'lettura_inizio' => $data['lettura_inizio'] ?? null, - 'lettura_fine' => $data['lettura_fine'] ?? null, - 'consumo_valore' => $data['consumo_valore'] ?? null, - 'consumo_unita' => (string) ($data['consumo_unita'] ?? ''), - 'importo_totale' => $data['importo_totale'] ?? null, + 'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null, + 'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null, + 'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, + 'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null, + 'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null, + 'lettura_inizio' => $data['lettura_inizio'] ?? null, + 'lettura_fine' => $data['lettura_fine'] ?? null, + 'consumo_valore' => $data['consumo_valore'] ?? null, + 'consumo_unita' => (string) ($data['consumo_unita'] ?? ''), + 'importo_totale' => $data['importo_totale'] ?? null, ]); $record->save(); $this->hydrateReadingWithPreviousData($record); @@ -677,8 +677,8 @@ private function getStabiliOptions(): array return StabileContext::accessibleStabili($user) ->mapWithKeys(function (Stabile $stabile): array { $codice = trim((string) ($stabile->codice_stabile ?? '')); - $nome = trim((string) ($stabile->denominazione ?? '')); - $label = trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id); + $nome = trim((string) ($stabile->denominazione ?? '')); + $label = trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id); return [(int) $stabile->id => $label]; }) @@ -802,16 +802,16 @@ private function getServiziOptionsByTipo(string $tipo): array private function scheduleWaterCampaign(array $data): int { - $user = Auth::user(); - $stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0; + $user = Auth::user(); + $stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0; $servizioId = (int) ($data['stabile_servizio_id'] ?? 0); if ($stabileId <= 0 || $servizioId <= 0) { return 0; } $deadline = $data['deadline_lettura_at'] ?? null; - $message = trim((string) ($data['messaggio_template'] ?? '')); - $count = 0; + $message = trim((string) ($data['messaggio_template'] ?? '')); + $count = 0; $unitaIds = UnitaImmobiliare::query() ->where('stabile_id', $stabileId) @@ -828,27 +828,27 @@ private function scheduleWaterCampaign(array $data): int if (! $row instanceof StabileServizioLettura) { $row = StabileServizioLettura::query()->create([ - 'stabile_id' => $stabileId, - 'stabile_servizio_id' => $servizioId, - 'unita_immobiliare_id' => (int) $unitaId, - 'workflow_stato' => 'richiesta_inviata', - 'canale_acquisizione' => 'manuale', + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => $servizioId, + 'unita_immobiliare_id' => (int) $unitaId, + 'workflow_stato' => 'richiesta_inviata', + 'canale_acquisizione' => 'manuale', 'richiesta_lettura_inviata_at' => now(), - 'deadline_lettura_at' => $deadline, - 'raw' => ['campagna_acqua' => ['messaggio' => $message, 'creata_da' => Auth::id()]], - 'created_by' => Auth::id(), + 'deadline_lettura_at' => $deadline, + 'raw' => ['campagna_acqua' => ['messaggio' => $message, 'creata_da' => Auth::id()]], + 'created_by' => Auth::id(), ]); } else { - $raw = is_array($row->raw ?? null) ? $row->raw : []; + $raw = is_array($row->raw ?? null) ? $row->raw : []; $raw['campagna_acqua'] = [ - 'messaggio' => $message, + 'messaggio' => $message, 'aggiornata_da' => Auth::id(), - 'updated_at' => now()->toIso8601String(), + 'updated_at' => now()->toIso8601String(), ]; - $row->workflow_stato = 'richiesta_inviata'; + $row->workflow_stato = 'richiesta_inviata'; $row->richiesta_lettura_inviata_at = $row->richiesta_lettura_inviata_at ?? now(); - $row->deadline_lettura_at = $deadline; - $row->raw = $raw; + $row->deadline_lettura_at = $deadline; + $row->raw = $raw; $row->save(); } @@ -860,7 +860,7 @@ private function scheduleWaterCampaign(array $data): int private function markOverdueWaterReminders(): int { - $user = Auth::user(); + $user = Auth::user(); $stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0; if ($stabileId <= 0) { return 0; @@ -877,15 +877,15 @@ private function markOverdueWaterReminders(): int ->get(); foreach ($rows as $row) { - $raw = is_array($row->raw ?? null) ? $row->raw : []; - $campagna = is_array($raw['campagna_acqua'] ?? null) ? $raw['campagna_acqua'] : []; - $campagna['solleciti'] = (int) ($campagna['solleciti'] ?? 0) + 1; + $raw = is_array($row->raw ?? null) ? $row->raw : []; + $campagna = is_array($raw['campagna_acqua'] ?? null) ? $raw['campagna_acqua'] : []; + $campagna['solleciti'] = (int) ($campagna['solleciti'] ?? 0) + 1; $campagna['ultimo_sollecito_at'] = now()->toIso8601String(); - $raw['campagna_acqua'] = $campagna; + $raw['campagna_acqua'] = $campagna; $row->sollecito_inviato_at = now(); - $row->workflow_stato = 'richiesta_sollecitata'; - $row->raw = $raw; + $row->workflow_stato = 'richiesta_sollecitata'; + $row->raw = $raw; $row->save(); } @@ -913,12 +913,12 @@ private function hydrateReadingWithPreviousData(StabileServizioLettura $record): $changed = false; if ($record->lettura_precedente_valore === null && $previous->lettura_fine !== null) { $record->lettura_precedente_valore = $previous->lettura_fine; - $changed = true; + $changed = true; } if (! $record->lettura_precedente_foto_path && $previous->lettura_foto_path) { $record->lettura_precedente_foto_path = $previous->lettura_foto_path; - $changed = true; + $changed = true; } if ($changed) { @@ -931,7 +931,7 @@ private function storeReadingUploads(StabileServizioLettura $record, array $data $updated = false; foreach ([ - 'lettura_foto_upload' => ['field' => 'lettura_foto_path', 'prefix' => 'lettura'], + 'lettura_foto_upload' => ['field' => 'lettura_foto_path', 'prefix' => 'lettura'], 'lettura_precedente_foto_upload' => ['field' => 'lettura_precedente_foto_path', 'prefix' => 'precedente'], ] as $input => $meta) { $upload = $data[$input] ?? null; @@ -942,14 +942,14 @@ private function storeReadingUploads(StabileServizioLettura $record, array $data $record->{$meta['field']} = $stored['path']; - $raw = is_array($record->raw ?? null) ? $record->raw : []; + $raw = is_array($record->raw ?? null) ? $record->raw : []; $raw[$input . '_metadata'] = $stored['metadata']; if ($input === 'lettura_foto_upload') { $record->lettura_foto_original_name = (string) ($stored['original_name'] ?? ''); - $record->lettura_foto_metadata = $stored['metadata']; + $record->lettura_foto_metadata = $stored['metadata']; } $record->raw = $raw; - $updated = true; + $updated = true; } if ($updated) { @@ -963,8 +963,8 @@ private function storeReadingPhoto($upload, StabileServizioLettura $record, stri return null; } - $ext = strtolower((string) ($upload->getClientOriginalExtension() ?: 'jpg')); - $unit = (int) ($record->unita_immobiliare_id ?? 0); + $ext = strtolower((string) ($upload->getClientOriginalExtension() ?: 'jpg')); + $unit = (int) ($record->unita_immobiliare_id ?? 0); $filename = implode('-', array_filter([ 'stabile' . (int) $record->stabile_id, 'servizio' . (int) $record->stabile_servizio_id, @@ -975,25 +975,25 @@ private function storeReadingPhoto($upload, StabileServizioLettura $record, stri $path = $upload->storeAs('letture-servizi/foto', $filename, 'public'); return [ - 'path' => $path, + 'path' => $path, 'original_name' => (string) $upload->getClientOriginalName(), - 'metadata' => $this->extractImageMetadata($path), + 'metadata' => $this->extractImageMetadata($path), ]; } private function extractImageMetadata(string $path): array { $absolutePath = \Illuminate\Support\Facades\Storage::disk('public')->path($path); - $metadata = [ + $metadata = [ 'path' => $path, 'size' => is_file($absolutePath) ? filesize($absolutePath) : null, ]; $imageSize = @getimagesize($absolutePath); if (is_array($imageSize)) { - $metadata['width'] = $imageSize[0] ?? null; + $metadata['width'] = $imageSize[0] ?? null; $metadata['height'] = $imageSize[1] ?? null; - $metadata['mime'] = $imageSize['mime'] ?? null; + $metadata['mime'] = $imageSize['mime'] ?? null; } if (function_exists('exif_read_data')) { @@ -1001,10 +1001,10 @@ private function extractImageMetadata(string $path): array $exif = @exif_read_data($absolutePath); if (is_array($exif)) { $metadata['exif_datetime'] = $exif['DateTimeOriginal'] ?? ($exif['DateTime'] ?? null); - $metadata['gps'] = [ - 'lat' => $exif['GPSLatitude'] ?? null, + $metadata['gps'] = [ + 'lat' => $exif['GPSLatitude'] ?? null, 'lat_ref' => $exif['GPSLatitudeRef'] ?? null, - 'lng' => $exif['GPSLongitude'] ?? null, + 'lng' => $exif['GPSLongitude'] ?? null, 'lng_ref' => $exif['GPSLongitudeRef'] ?? null, ]; $metadata['device'] = trim(implode(' ', array_filter([ diff --git a/app/Filament/Pages/Condomini/StabilePage.php b/app/Filament/Pages/Condomini/StabilePage.php index 0343935..f27bbd4 100644 --- a/app/Filament/Pages/Condomini/StabilePage.php +++ b/app/Filament/Pages/Condomini/StabilePage.php @@ -1,24 +1,30 @@ > */ + public array $insuranceBrokerMatches = []; + + /** @var array> */ + public array $insuranceCompanyMatches = []; + + /** @var array> */ + public array $insurancePaymentRows = [ + ['label' => '', 'due_date' => null, 'paid_at' => null, 'amount' => null, 'notes' => ''], + ['label' => '', 'due_date' => null, 'paid_at' => null, 'amount' => null, 'notes' => ''], + ]; + + /** @var array|null */ + public ?array $insuranceAttachmentPreview = null; + public array $insurancePolicyForm = [ 'broker_rubrica_id' => null, 'company_rubrica_id' => null, @@ -409,15 +434,39 @@ public function getInsuranceRubricaOptionsProperty(): array ->all(); } - public function selectInsurancePolicy(int $policyId): void + public function updatedInsuranceBrokerSearch($value = null): void { + $this->insuranceBrokerSearch = $this->normalizeInsuranceSearchValue($value ?? $this->insuranceBrokerSearch); + $this->searchInsuranceRubrica('broker'); + } + + public function updatedInsuranceCompanySearch($value = null): void + { + $this->insuranceCompanySearch = $this->normalizeInsuranceSearchValue($value ?? $this->insuranceCompanySearch); + $this->searchInsuranceRubrica('company'); + } + + public function updatedInsurancePolicyForm($value, string $key): void + { + if (in_array($key, ['broker_rubrica_id', 'company_rubrica_id'], true)) { + $this->insurancePolicyForm[$key] = $this->normalizeInsuranceIdValue($value); + } + } + + public function selectInsurancePolicy($policyId): void + { + $policyId = $this->normalizeInsuranceIdValue($policyId); + if ($policyId === null) { + return; + } + $policy = $this->insurancePolicies->firstWhere('id', $policyId); if (! $policy instanceof InsurancePolicy) { return; } $this->selectedInsurancePolicyId = (int) $policy->id; - $this->insurancePolicyForm = [ + $this->insurancePolicyForm = [ 'broker_rubrica_id' => $policy->broker_rubrica_id, 'company_rubrica_id' => $policy->company_rubrica_id, 'policy_name' => (string) ($policy->policy_name ?? ''), @@ -435,12 +484,18 @@ public function selectInsurancePolicy(int $policyId): void 'claim_form_template' => (string) ($policy->claim_form_template ?? ''), 'notes' => (string) ($policy->notes ?? ''), ]; + $this->insuranceBrokerSearch = $this->formatRubricaLabel($policy->brokerRubrica); + $this->insuranceCompanySearch = $this->formatRubricaLabel($policy->companyRubrica); + $this->insuranceBrokerMatches = $policy->brokerRubrica ? [$this->mapInsuranceRubricaRow($policy->brokerRubrica)] : []; + $this->insuranceCompanyMatches = $policy->companyRubrica ? [$this->mapInsuranceRubricaRow($policy->companyRubrica)] : []; + $this->insurancePaymentRows = $this->buildInsurancePaymentRowsFromPolicy($policy); + $this->insuranceAttachmentPreview = null; } public function resetInsurancePolicyForm(): void { $this->selectedInsurancePolicyId = null; - $this->insurancePolicyForm = [ + $this->insurancePolicyForm = [ 'broker_rubrica_id' => null, 'company_rubrica_id' => null, 'policy_name' => '', @@ -459,7 +514,61 @@ public function resetInsurancePolicyForm(): void 'notes' => '', ]; $this->insurancePolicyDocumentUpload = null; - $this->insuranceSignatureUpload = null; + $this->insuranceSignatureUpload = null; + $this->insuranceBrokerSearch = ''; + $this->insuranceCompanySearch = ''; + $this->insuranceBrokerMatches = []; + $this->insuranceCompanyMatches = []; + $this->insurancePaymentRows = $this->defaultInsurancePaymentRows(); + $this->insuranceAttachmentPreview = null; + } + + public function selectInsuranceBroker($rubricaId): void + { + $rubricaId = $this->normalizeInsuranceIdValue($rubricaId); + if ($rubricaId === null) { + return; + } + + $rubrica = RubricaUniversale::query()->find($rubricaId); + if (! $rubrica instanceof RubricaUniversale) { + return; + } + + $this->insurancePolicyForm['broker_rubrica_id'] = (int) $rubrica->id; + $this->insuranceBrokerSearch = $this->formatRubricaLabel($rubrica); + $this->insuranceBrokerMatches = [$this->mapInsuranceRubricaRow($rubrica)]; + } + + public function selectInsuranceCompany($rubricaId): void + { + $rubricaId = $this->normalizeInsuranceIdValue($rubricaId); + if ($rubricaId === null) { + return; + } + + $rubrica = RubricaUniversale::query()->find($rubricaId); + if (! $rubrica instanceof RubricaUniversale) { + return; + } + + $this->insurancePolicyForm['company_rubrica_id'] = (int) $rubrica->id; + $this->insuranceCompanySearch = $this->formatRubricaLabel($rubrica); + $this->insuranceCompanyMatches = [$this->mapInsuranceRubricaRow($rubrica)]; + } + + public function resetInsuranceBrokerSelection(): void + { + $this->insurancePolicyForm['broker_rubrica_id'] = null; + $this->insuranceBrokerSearch = ''; + $this->insuranceBrokerMatches = []; + } + + public function resetInsuranceCompanySelection(): void + { + $this->insurancePolicyForm['company_rubrica_id'] = null; + $this->insuranceCompanySearch = ''; + $this->insuranceCompanyMatches = []; } public function saveInsurancePolicy(): void @@ -468,6 +577,11 @@ public function saveInsurancePolicy(): void return; } + $this->insurancePolicyForm['broker_rubrica_id'] = $this->normalizeInsuranceIdValue($this->insurancePolicyForm['broker_rubrica_id'] ?? null); + $this->insurancePolicyForm['company_rubrica_id'] = $this->normalizeInsuranceIdValue($this->insurancePolicyForm['company_rubrica_id'] ?? null); + $this->insuranceBrokerSearch = $this->normalizeInsuranceSearchValue($this->insuranceBrokerSearch); + $this->insuranceCompanySearch = $this->normalizeInsuranceSearchValue($this->insuranceCompanySearch); + $data = validator($this->insurancePolicyForm, [ 'broker_rubrica_id' => ['nullable', 'integer', 'exists:rubrica_universale,id'], 'company_rubrica_id' => ['nullable', 'integer', 'exists:rubrica_universale,id'], @@ -487,8 +601,12 @@ public function saveInsurancePolicy(): void 'notes' => ['nullable', 'string', 'max:10000'], ])->validate(); - $policy = $this->selectedInsurancePolicyId - ? InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($this->selectedInsurancePolicyId) + $paymentRows = $this->normalizeInsurancePaymentRows($this->insurancePaymentRows); + + $selectedPolicyId = $this->normalizeInsuranceIdValue($this->selectedInsurancePolicyId); + + $policy = $selectedPolicyId + ? InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($selectedPolicyId) : new InsurancePolicy(); if (! $policy instanceof InsurancePolicy) { @@ -496,24 +614,25 @@ public function saveInsurancePolicy(): void } $policy->fill([ - 'stabile_id' => (int) $this->stabile->id, - 'broker_rubrica_id' => (int) ($data['broker_rubrica_id'] ?? 0) ?: null, - 'company_rubrica_id' => (int) ($data['company_rubrica_id'] ?? 0) ?: null, - 'policy_name' => $this->cleanInsuranceNullable($data['policy_name'] ?? null), - 'policy_number' => $this->cleanInsuranceNullable($data['policy_number'] ?? null), - 'policy_reference' => $this->cleanInsuranceNullable($data['policy_reference'] ?? null), - 'status' => $this->cleanInsuranceNullable($data['status'] ?? 'attiva') ?: 'attiva', - 'started_at' => $data['started_at'] ?? null, - 'expires_at' => $data['expires_at'] ?? null, - 'renewal_at' => $data['renewal_at'] ?? null, - 'annual_amount' => $data['annual_amount'] ?? null, - 'payment_split' => $this->cleanInsuranceNullable($data['payment_split'] ?? null), - 'payment_schedule_notes' => $this->cleanInsuranceNullable($data['payment_schedule_notes'] ?? null), - 'coverage_summary' => $this->cleanInsuranceNullable($data['coverage_summary'] ?? null), - 'special_conditions' => $this->cleanInsuranceNullable($data['special_conditions'] ?? null), - 'claim_form_template' => $this->cleanInsuranceNullable($data['claim_form_template'] ?? null), - 'notes' => $this->cleanInsuranceNullable($data['notes'] ?? null), - 'created_by' => Auth::id(), + 'stabile_id' => (int) $this->stabile->id, + 'broker_rubrica_id' => (int) ($data['broker_rubrica_id'] ?? 0) ?: null, + 'company_rubrica_id' => (int) ($data['company_rubrica_id'] ?? 0) ?: null, + 'policy_name' => $this->cleanInsuranceNullable($data['policy_name'] ?? null), + 'policy_number' => $this->cleanInsuranceNullable($data['policy_number'] ?? null), + 'policy_reference' => $this->cleanInsuranceNullable($data['policy_reference'] ?? null), + 'status' => $this->cleanInsuranceNullable($data['status'] ?? 'attiva') ?: 'attiva', + 'started_at' => $data['started_at'] ?? null, + 'expires_at' => $data['expires_at'] ?? null, + 'renewal_at' => $data['renewal_at'] ?? null, + 'annual_amount' => $data['annual_amount'] ?? null, + 'payment_split' => $this->cleanInsuranceNullable($data['payment_split'] ?? null), + 'payment_schedule_notes' => $this->cleanInsuranceNullable($this->serializeInsurancePaymentRows($paymentRows)), + 'coverage_summary' => $this->cleanInsuranceNullable($data['coverage_summary'] ?? null), + 'special_conditions' => $this->cleanInsuranceNullable($data['special_conditions'] ?? null), + 'claim_form_template' => $this->cleanInsuranceNullable($data['claim_form_template'] ?? null), + 'notes' => $this->cleanInsuranceNullable($data['notes'] ?? null), + 'metadata' => array_merge((array) ($policy->metadata ?? []), ['payment_rows' => $paymentRows]), + 'created_by' => Auth::id(), ]); $policy->save(); @@ -523,12 +642,17 @@ public function saveInsurancePolicy(): void Notification::make()->title('Polizza assicurativa salvata')->success()->send(); } - public function deleteInsurancePolicy(int $policyId): void + public function deleteInsurancePolicy($policyId): void { if (! $this->stabile instanceof StabileModel) { return; } + $policyId = $this->normalizeInsuranceIdValue($policyId); + if ($policyId === null) { + return; + } + $policy = InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($policyId); if (! $policy instanceof InsurancePolicy) { return; @@ -543,19 +667,37 @@ public function deleteInsurancePolicy(int $policyId): void Notification::make()->title('Polizza assicurativa eliminata')->success()->send(); } - public function getInsuranceDocumentUrl(InsurancePolicy $policy): ?string + public function getInsuranceDocumentUrl($policy): ?string { - $path = trim((string) ($policy->policy_document_path ?? '')); - return $path !== '' ? Storage::disk('public')->url($path) : null; + if (! $policy instanceof InsurancePolicy) { + return null; + } + + $path = $this->normalizeInsuranceSearchValue($policy->policy_document_path ?? ''); + $routeName = $this->resolveInsuranceAssetRouteName(); + + return $path !== '' + && $routeName !== null + ? route($routeName, ['policy' => (int) $policy->id, 'asset' => 'document']) + : null; } - public function getInsuranceSignatureUrl(InsurancePolicy $policy): ?string + public function getInsuranceSignatureUrl($policy): ?string { - $path = trim((string) ($policy->signature_image_path ?? '')); - return $path !== '' ? Storage::disk('public')->url($path) : null; + if (! $policy instanceof InsurancePolicy) { + return null; + } + + $path = $this->normalizeInsuranceSearchValue($policy->signature_image_path ?? ''); + $routeName = $this->resolveInsuranceAssetRouteName(); + + return $path !== '' + && $routeName !== null + ? route($routeName, ['policy' => (int) $policy->id, 'asset' => 'signature']) + : null; } - public function getInsuranceTicketUrl(?Ticket $ticket): ?string + public function getInsuranceTicketUrl($ticket): ?string { if (! $ticket instanceof Ticket) { return null; @@ -564,26 +706,266 @@ public function getInsuranceTicketUrl(?Ticket $ticket): ?string return \App\Filament\Pages\Supporto\TicketGestione::getUrl(['ticket' => (int) $ticket->id, 'tab' => 'scheda'], panel: 'admin-filament'); } + public function openInsuranceAttachmentPreview($policyId, string $asset): void + { + if (! in_array($asset, ['document', 'signature'], true)) { + return; + } + + $policyId = $this->normalizeInsuranceIdValue($policyId); + if ($policyId === null) { + return; + } + + $policy = InsurancePolicy::query() + ->with(['brokerRubrica', 'companyRubrica']) + ->where('stabile_id', (int) ($this->stabile?->id ?? 0)) + ->find($policyId); + + if (! $policy instanceof InsurancePolicy) { + return; + } + + $path = $asset === 'document' + ? trim((string) ($policy->policy_document_path ?? '')) + : trim((string) ($policy->signature_image_path ?? '')); + + if ($path === '') { + return; + } + + $mime = $asset === 'document' + ? (string) ($policy->policy_document_mime ?: Storage::disk('public')->mimeType($path)) + : (string) (Storage::disk('public')->mimeType($path) ?: 'image/png'); + + $resolvedMime = TicketAttachment::normalizeMimeType($mime, basename($path), $path); + $url = $asset === 'document' ? $this->getInsuranceDocumentUrl($policy) : $this->getInsuranceSignatureUrl($policy); + + if (! is_string($url) || $url === '') { + return; + } + + $this->insuranceAttachmentPreview = [ + 'name' => $asset === 'document' + ? (string) ($policy->policy_document_name ?: ($policy->display_name . '.pdf')) + : ('Firma ' . $policy->display_name), + 'description' => $asset === 'document' + ? 'Documento polizza archiviato con accesso controllato' + : 'Firma referente polizza', + 'url' => $url, + 'is_image' => str_starts_with($resolvedMime, 'image/'), + 'is_pdf' => $resolvedMime === 'application/pdf', + ]; + } + + public function closeInsuranceAttachmentPreview(): void + { + $this->insuranceAttachmentPreview = null; + } + private function storeInsuranceUploads(InsurancePolicy $policy): void { if (is_object($this->insurancePolicyDocumentUpload) && method_exists($this->insurancePolicyDocumentUpload, 'storeAs')) { - $ext = strtolower((string) ($this->insurancePolicyDocumentUpload->getClientOriginalExtension() ?: 'pdf')); - $fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-documento.' . $ext; - $path = $this->insurancePolicyDocumentUpload->storeAs('assicurazioni/polizze', $fileName, 'public'); + $ext = strtolower((string) ($this->insurancePolicyDocumentUpload->getClientOriginalExtension() ?: 'pdf')); + $fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-documento.' . $ext; + $path = $this->insurancePolicyDocumentUpload->storeAs('assicurazioni/polizze', $fileName, 'public'); $policy->policy_document_path = $path; $policy->policy_document_name = (string) $this->insurancePolicyDocumentUpload->getClientOriginalName(); $policy->policy_document_mime = (string) $this->insurancePolicyDocumentUpload->getClientMimeType(); } if (is_object($this->insuranceSignatureUpload) && method_exists($this->insuranceSignatureUpload, 'storeAs')) { - $ext = strtolower((string) ($this->insuranceSignatureUpload->getClientOriginalExtension() ?: 'png')); - $fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-firma.' . $ext; + $ext = strtolower((string) ($this->insuranceSignatureUpload->getClientOriginalExtension() ?: 'png')); + $fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-firma.' . $ext; $policy->signature_image_path = $this->insuranceSignatureUpload->storeAs('assicurazioni/firme', $fileName, 'public'); } $policy->save(); + $this->syncInsuranceDocumentArchive($policy); $this->insurancePolicyDocumentUpload = null; - $this->insuranceSignatureUpload = null; + $this->insuranceSignatureUpload = null; + } + + private function searchInsuranceRubrica(string $target): void + { + $field = $target === 'broker' ? 'insuranceBrokerSearch' : 'insuranceCompanySearch'; + $matchField = $target === 'broker' ? 'insuranceBrokerMatches' : 'insuranceCompanyMatches'; + $selectedField = $target === 'broker' ? 'broker_rubrica_id' : 'company_rubrica_id'; + $raw = $this->normalizeInsuranceSearchValue($this->{$field} ?? ''); + $this->{$field} = $raw; + + $this->{$matchField} = $this->searchRubricaLookupMatches( + $raw, + $this->normalizeInsuranceIdValue($this->insurancePolicyForm[$selectedField] ?? null), + ['assicurazione', 'fornitore', 'altro'], + 12 + ); + } + + /** + * @return array + */ + private function mapInsuranceRubricaRow(RubricaUniversale $rubrica): array + { + return $this->mapRubricaLookupRow($rubrica); + } + + private function formatRubricaLabel(?RubricaUniversale $rubrica): string + { + return $this->formatRubricaLookupLabel($rubrica); + } + + private function normalizeInsuranceSearchValue($value): string + { + return $this->normalizeRubricaLookupText($value); + } + + private function normalizeInsuranceIdValue($value): ?int + { + return $this->normalizeRubricaLookupId($value); + } + + /** + * @return array> + */ + private function defaultInsurancePaymentRows(): array + { + return [ + ['label' => '', 'due_date' => null, 'paid_at' => null, 'amount' => null, 'notes' => ''], + ['label' => '', 'due_date' => null, 'paid_at' => null, 'amount' => null, 'notes' => ''], + ]; + } + + /** + * @return array> + */ + private function buildInsurancePaymentRowsFromPolicy(InsurancePolicy $policy): array + { + $rows = (array) data_get($policy->metadata, 'payment_rows', []); + if ($rows === []) { + $rows = $this->defaultInsurancePaymentRows(); + $rows[0]['notes'] = (string) ($policy->payment_schedule_notes ?? ''); + } + + return $this->normalizeInsurancePaymentRows($rows); + } + + /** + * @param array> $rows + * @return array> + */ + private function normalizeInsurancePaymentRows(array $rows): array + { + $normalized = []; + + foreach (range(0, 1) as $index) { + $row = (array) ($rows[$index] ?? []); + $normalized[] = [ + 'label' => trim((string) ($row['label'] ?? '')), + 'due_date' => filled($row['due_date'] ?? null) ? (string) $row['due_date'] : null, + 'paid_at' => filled($row['paid_at'] ?? null) ? (string) $row['paid_at'] : null, + 'amount' => filled($row['amount'] ?? null) ? (float) $row['amount'] : null, + 'notes' => trim((string) ($row['notes'] ?? '')), + ]; + } + + return $normalized; + } + + /** + * @param array> $rows + */ + private function serializeInsurancePaymentRows(array $rows): string + { + $lines = []; + + foreach ($rows as $index => $row) { + if (! filled($row['label'] ?? null) && ! filled($row['amount'] ?? null) && ! filled($row['due_date'] ?? null) && ! filled($row['notes'] ?? null)) { + continue; + } + + $parts = ['Riga ' . ($index + 1)]; + if (filled($row['label'] ?? null)) { + $parts[] = trim((string) $row['label']); + } + if (filled($row['amount'] ?? null)) { + $parts[] = number_format((float) $row['amount'], 2, ',', '.') . ' EUR'; + } + if (filled($row['due_date'] ?? null)) { + $parts[] = 'Scadenza ' . \Carbon\Carbon::parse((string) $row['due_date'])->format('d/m/Y'); + } + if (filled($row['paid_at'] ?? null)) { + $parts[] = 'Pagata ' . \Carbon\Carbon::parse((string) $row['paid_at'])->format('d/m/Y'); + } + if (filled($row['notes'] ?? null)) { + $parts[] = trim((string) $row['notes']); + } + + $lines[] = implode(' ยท ', $parts); + } + + return implode("\n", $lines); + } + + private function syncInsuranceDocumentArchive(InsurancePolicy $policy): void + { + $path = trim((string) ($policy->policy_document_path ?? '')); + if ($path === '') { + return; + } + + $protocol = 'ASS-POL-' . (int) $policy->stabile_id . '-' . (int) $policy->id; + $record = DocumentoCollegato::withTrashed()->firstOrNew([ + 'numero_protocollo' => $protocol, + ]); + + if ($record->trashed()) { + $record->restore(); + } + + $record->fill([ + 'stabile_id' => (int) $policy->stabile_id, + 'contatto_id' => (int) ($policy->company_rubrica_id ?: $policy->broker_rubrica_id ?: 0) ?: null, + 'tipo_documento' => 'assicurazione', + 'titolo' => $policy->display_name, + 'descrizione' => $this->cleanInsuranceNullable($policy->coverage_summary) ?: 'Documento polizza assicurativa', + 'data_sottoscrizione' => $policy->started_at, + 'data_scadenza' => $policy->expires_at, + 'tipo_contratto' => $this->mapInsuranceContractType($policy->payment_split), + 'importo_contratto' => $policy->annual_amount, + 'stato_documento' => $policy->status === 'scaduta' ? 'scaduto' : 'attivo', + 'path_file_originale' => $path, + 'note' => $policy->notes, + 'creato_da' => $record->exists ? $record->creato_da : Auth::id(), + 'modificato_da' => Auth::id(), + ]); + $record->save(); + } + + private function mapInsuranceContractType(?string $paymentSplit): ?string + { + $value = mb_strtolower(trim((string) $paymentSplit)); + + return match (true) { + str_contains($value, 'mens') => 'mensile', + str_contains($value, 'bimes') => 'bimestrale', + str_contains($value, 'trimes') => 'trimestrale', + str_contains($value, 'semes') => 'semestrale', + str_contains($value, 'ann') => 'annuale', + $value !== '' => 'altro', + default => null, + }; + } + + private function resolveInsuranceAssetRouteName(): ?string + { + foreach (['admin.insurance.policies.assets.view', 'superadmin.insurance.policies.assets.view'] as $routeName) { + if (Route::has($routeName)) { + return $routeName; + } + } + + return null; } private function cleanInsuranceNullable($value): ?string diff --git a/app/Filament/Pages/Fornitore/Collaboratori.php b/app/Filament/Pages/Fornitore/Collaboratori.php index a83d704..aa8b92b 100644 --- a/app/Filament/Pages/Fornitore/Collaboratori.php +++ b/app/Filament/Pages/Fornitore/Collaboratori.php @@ -169,12 +169,12 @@ public function createCollaboratore(): void 'fornitoreEsternoId' => ['nullable', 'integer'], ]); - $tipo = (string) $validated['collaboratoreTipo']; + $tipo = (string) $validated['collaboratoreTipo']; $tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica'); if ($tipo === FornitoreDipendente::TIPO_INTERNO) { $hasPersonalName = trim((string) ($validated['nome'] ?? '')) !== ''; - $hasCompanyName = trim((string) ($validated['ragioneSociale'] ?? '')) !== ''; + $hasCompanyName = trim((string) ($validated['ragioneSociale'] ?? '')) !== ''; if ($tipoContatto === 'persona_giuridica' && ! $hasCompanyName) { Notification::make()->title('Inserisci la ragione sociale del collaboratore')->warning()->send(); @@ -545,7 +545,7 @@ protected function resetForm(): void protected function upsertRubricaInterna(array $validated, FornitoreDipendente $dipendente): int { - $email = trim((string) ($validated['email'] ?? '')); + $email = trim((string) ($validated['email'] ?? '')); $rubrica = null; if ($email !== '') { @@ -557,41 +557,41 @@ protected function upsertRubricaInterna(array $validated, FornitoreDipendente $d } if (! $rubrica instanceof RubricaUniversale) { - $rubrica = new RubricaUniversale(); + $rubrica = new RubricaUniversale(); $rubrica->data_inserimento = now()->toDateString(); - $rubrica->creato_da = Auth::id(); + $rubrica->creato_da = Auth::id(); } - $tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica'); - $telefono = trim((string) ($validated['telefono'] ?? '')); + $tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica'); + $telefono = trim((string) ($validated['telefono'] ?? '')); $ragioneSociale = trim((string) ($validated['ragioneSociale'] ?? '')); - $rubrica->titolo_id = null; - $rubrica->nome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['nome'] ?? '')) ?: null); - $rubrica->cognome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['cognome'] ?? '')) ?: null); - $rubrica->ragione_sociale = $tipoContatto === 'persona_giuridica' ? ($ragioneSociale ?: null) : null; - $rubrica->tipo_contatto = $tipoContatto; - $rubrica->codice_fiscale = trim((string) ($validated['codiceFiscale'] ?? '')) ?: null; - $rubrica->partita_iva = trim((string) ($validated['partitaIva'] ?? '')) ?: null; - $rubrica->sesso = trim((string) ($validated['sesso'] ?? '')) ?: null; - $rubrica->data_nascita = trim((string) ($validated['dataNascita'] ?? '')) ?: null; - $rubrica->luogo_nascita = trim((string) ($validated['luogoNascita'] ?? '')) ?: null; - $rubrica->provincia_nascita = trim((string) ($validated['provinciaNascita'] ?? '')) ?: null; - $rubrica->indirizzo = trim((string) ($validated['indirizzo'] ?? '')) ?: null; - $rubrica->civico = trim((string) ($validated['civico'] ?? '')) ?: null; - $rubrica->cap = trim((string) ($validated['cap'] ?? '')) ?: null; - $rubrica->citta = trim((string) ($validated['citta'] ?? '')) ?: null; - $rubrica->provincia = trim((string) ($validated['provincia'] ?? '')) ?: null; - $rubrica->nazione = trim((string) ($validated['nazione'] ?? '')) ?: 'Italia'; - $rubrica->telefono_ufficio = $tipoContatto === 'persona_giuridica' ? ($telefono ?: null) : null; - $rubrica->telefono_cellulare = $telefono ?: null; - $rubrica->email = $email !== '' ? $email : null; - $rubrica->pec = trim((string) ($validated['pec'] ?? '')) ?: null; - $rubrica->note = trim((string) ($validated['note'] ?? '')) ?: null; - $rubrica->categoria = 'fornitore'; - $rubrica->stato = 'attivo'; + $rubrica->titolo_id = null; + $rubrica->nome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['nome'] ?? '')) ?: null); + $rubrica->cognome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['cognome'] ?? '')) ?: null); + $rubrica->ragione_sociale = $tipoContatto === 'persona_giuridica' ? ($ragioneSociale ?: null): null; + $rubrica->tipo_contatto = $tipoContatto; + $rubrica->codice_fiscale = trim((string) ($validated['codiceFiscale'] ?? '')) ?: null; + $rubrica->partita_iva = trim((string) ($validated['partitaIva'] ?? '')) ?: null; + $rubrica->sesso = trim((string) ($validated['sesso'] ?? '')) ?: null; + $rubrica->data_nascita = trim((string) ($validated['dataNascita'] ?? '')) ?: null; + $rubrica->luogo_nascita = trim((string) ($validated['luogoNascita'] ?? '')) ?: null; + $rubrica->provincia_nascita = trim((string) ($validated['provinciaNascita'] ?? '')) ?: null; + $rubrica->indirizzo = trim((string) ($validated['indirizzo'] ?? '')) ?: null; + $rubrica->civico = trim((string) ($validated['civico'] ?? '')) ?: null; + $rubrica->cap = trim((string) ($validated['cap'] ?? '')) ?: null; + $rubrica->citta = trim((string) ($validated['citta'] ?? '')) ?: null; + $rubrica->provincia = trim((string) ($validated['provincia'] ?? '')) ?: null; + $rubrica->nazione = trim((string) ($validated['nazione'] ?? '')) ?: 'Italia'; + $rubrica->telefono_ufficio = $tipoContatto === 'persona_giuridica' ? ($telefono ?: null): null; + $rubrica->telefono_cellulare = $telefono ?: null; + $rubrica->email = $email !== '' ? $email : null; + $rubrica->pec = trim((string) ($validated['pec'] ?? '')) ?: null; + $rubrica->note = trim((string) ($validated['note'] ?? '')) ?: null; + $rubrica->categoria = 'fornitore'; + $rubrica->stato = 'attivo'; $rubrica->data_ultima_modifica = now()->toDateString(); - $rubrica->modificato_da = Auth::id(); + $rubrica->modificato_da = Auth::id(); $rubrica->save(); $dipendente->rubrica_id = (int) $rubrica->id; diff --git a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php index 21d1cd3..b7e37c4 100644 --- a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php +++ b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php @@ -41,11 +41,11 @@ class TicketInterventoScheda extends Page /** @var array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string} */ public array $caller = [ - 'contatto' => '-', - 'telefono' => '', - 'email' => '', - 'problema' => '', - 'sorgente' => '', + 'contatto' => '-', + 'telefono' => '', + 'email' => '', + 'problema' => '', + 'sorgente' => '', 'riferimento' => '', ]; @@ -418,11 +418,11 @@ protected function resolveCallerData(?Ticket $ticket): array $parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? '')); $caller = [ - 'contatto' => $parsed['contatto'], - 'telefono' => $parsed['telefono'], - 'email' => '', - 'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''), - 'sorgente' => 'Descrizione ticket', + 'contatto' => $parsed['contatto'], + 'telefono' => $parsed['telefono'], + 'email' => '', + 'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''), + 'sorgente' => 'Descrizione ticket', 'riferimento' => $this->buildCallerReference($ticket), ]; @@ -434,7 +434,7 @@ protected function resolveCallerData(?Ticket $ticket): array } $caller['telefono'] = trim((string) ($soggetto->telefono ?? '')) ?: $caller['telefono']; - $caller['email'] = trim((string) ($soggetto->email ?? '')); + $caller['email'] = trim((string) ($soggetto->email ?? '')); $caller['sorgente'] = 'Richiedente collegato al ticket'; return $caller; @@ -443,7 +443,7 @@ protected function resolveCallerData(?Ticket $ticket): array $openedBy = $ticket?->apertoDaUser; if ($openedBy instanceof User) { $caller['contatto'] = trim((string) ($openedBy->name ?? '')) ?: $caller['contatto']; - $caller['email'] = trim((string) ($openedBy->email ?? '')); + $caller['email'] = trim((string) ($openedBy->email ?? '')); $caller['sorgente'] = 'Utente che ha aperto il ticket'; } @@ -461,7 +461,7 @@ protected function buildCallerReference(?Ticket $ticket): string $stabile = $ticket->stabile; if ($stabile) { $denominazione = trim((string) ($stabile->denominazione ?? '')); - $indirizzo = trim(implode(' ', array_filter([ + $indirizzo = trim(implode(' ', array_filter([ $stabile->indirizzo ?? null, $stabile->cap ?? null, $stabile->citta ?? null, diff --git a/app/Filament/Pages/Gescon/FornitoriArchivio.php b/app/Filament/Pages/Gescon/FornitoriArchivio.php index 0bc6e69..5558d59 100644 --- a/app/Filament/Pages/Gescon/FornitoriArchivio.php +++ b/app/Filament/Pages/Gescon/FornitoriArchivio.php @@ -102,7 +102,7 @@ public function mount(): void } $this->fornitoreMatches = new Collection(); - $this->searchInput = $this->fornitoriSearch; + $this->searchInput = $this->fornitoriSearch; $this->searchFornitoreMatches(); } @@ -318,7 +318,7 @@ public function applicaRicerca(): void { $this->fornitoriSearch = trim($this->searchInput); $this->searchFornitoreMatches(); - $this->activeTab = 'elenco'; + $this->activeTab = 'elenco'; } public function pulisciRicerca(): void @@ -326,7 +326,7 @@ public function pulisciRicerca(): void $this->fornitoriSearch = ''; $this->searchInput = ''; $this->searchFornitoreMatches(); - $this->activeTab = 'elenco'; + $this->activeTab = 'elenco'; } public function importaTagLegacy(bool $soloSelezionato = false, bool $dryRun = false): void @@ -382,7 +382,7 @@ public function removeSearchFilter(string $token): void $this->fornitoriSearch = implode(' e ', $tokens); $this->searchInput = $this->fornitoriSearch; $this->searchFornitoreMatches(); - $this->activeTab = 'elenco'; + $this->activeTab = 'elenco'; } public function apriScheda(int $fornitoreId): void @@ -458,7 +458,7 @@ public function createFornitoreRapido(): void $this->fornitoriCount = (int) $this->getTableQuery()->count(); $this->apriScheda((int) $fornitore->id); - $this->searchInput = $this->getFornitoreLabel($fornitore); + $this->searchInput = $this->getFornitoreLabel($fornitore); $this->fornitoriSearch = $this->searchInput; $this->searchFornitoreMatches(); @@ -698,12 +698,12 @@ public function saveDipendenteInline(int $dipendenteId): void } $state = $this->dipendenteInline[$dipendenteId] ?? []; - $data = Validator::make($state, [ - 'nome' => ['required', 'string', 'max:255'], - 'cognome' => ['nullable', 'string', 'max:255'], - 'email' => ['nullable', 'email', 'max:255'], - 'telefono' => ['nullable', 'string', 'max:64'], - 'attivo' => ['nullable', 'boolean'], + $data = Validator::make($state, [ + 'nome' => ['required', 'string', 'max:255'], + 'cognome' => ['nullable', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'telefono' => ['nullable', 'string', 'max:64'], + 'attivo' => ['nullable', 'boolean'], ])->validate(); $dipendente->nome = trim((string) ($data['nome'] ?? '')); @@ -842,7 +842,7 @@ private function applyFornitoreSearch(Builder $query, string $raw): Builder if ($tokens === []) { return $query; } - $hasTags = Schema::hasColumn('fornitori', 'tags'); + $hasTags = Schema::hasColumn('fornitori', 'tags'); foreach ($tokens as $token) { $term = trim($token); diff --git a/app/Filament/Pages/Gescon/StabiliArchivio.php b/app/Filament/Pages/Gescon/StabiliArchivio.php index 10963d0..ac0935c 100644 --- a/app/Filament/Pages/Gescon/StabiliArchivio.php +++ b/app/Filament/Pages/Gescon/StabiliArchivio.php @@ -1,39 +1,37 @@ hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void @@ -143,21 +141,21 @@ public function table(Table $table): Table } $options = [ - 'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'), - 'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null, - 'with_unita' => (bool) ($data['with_unita'] ?? false), - 'with_soggetti' => (bool) ($data['with_soggetti'] ?? false), - 'with_diritti' => (bool) ($data['with_diritti'] ?? false), + 'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'), + 'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null, + 'with_unita' => (bool) ($data['with_unita'] ?? false), + 'with_soggetti' => (bool) ($data['with_soggetti'] ?? false), + 'with_diritti' => (bool) ($data['with_diritti'] ?? false), 'with_millesimi' => (bool) ($data['with_millesimi'] ?? false), - 'with_voci' => (bool) ($data['with_voci'] ?? false), - 'with_gestioni' => (bool) ($data['with_gestioni'] ?? false), - 'with_banche' => (bool) ($data['with_banche'] ?? false), - 'dry' => (bool) ($data['dry'] ?? false), + 'with_voci' => (bool) ($data['with_voci'] ?? false), + 'with_gestioni' => (bool) ($data['with_gestioni'] ?? false), + 'with_banche' => (bool) ($data['with_banche'] ?? false), + 'dry' => (bool) ($data['dry'] ?? false), ]; - $query = $this->getTableQuery(); + $query = $this->getTableQuery(); $stabili = $query->get(); - $svc = new EssentialImportService(); + $svc = new EssentialImportService(); foreach ($stabili as $record) { $legacyCode = trim((string) ($record->codice_stabile ?? '')); if ($legacyCode === '') { @@ -181,7 +179,7 @@ public function table(Table $table): Table ->visible(function (): bool { $user = Auth::user(); return $user instanceof User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); }) ->form([ TextInput::make('codice_stabile') @@ -246,14 +244,14 @@ public function table(Table $table): Table $stabile = Stabile::query()->create([ 'amministratore_id' => $amm->id, - 'codice_stabile' => $codice, - 'denominazione' => (string) ($data['denominazione'] ?? $codice), - 'indirizzo' => (string) ($data['indirizzo'] ?? ''), - 'cap' => (string) ($data['cap'] ?? ''), - 'citta' => (string) ($data['citta'] ?? ''), - 'provincia' => (string) ($data['provincia'] ?? ''), - 'stato' => 'attivo', - 'attivo' => true, + 'codice_stabile' => $codice, + 'denominazione' => (string) ($data['denominazione'] ?? $codice), + 'indirizzo' => (string) ($data['indirizzo'] ?? ''), + 'cap' => (string) ($data['cap'] ?? ''), + 'citta' => (string) ($data['citta'] ?? ''), + 'provincia' => (string) ($data['provincia'] ?? ''), + 'stato' => 'attivo', + 'attivo' => true, ]); Notification::make() @@ -313,63 +311,63 @@ public function table(Table $table): Table $stabile = Stabile::query()->create([ 'amministratore_id' => $amm->id, - 'codice_stabile' => $codice, - 'denominazione' => (string) ($data['denominazione'] ?? 'Condominio Demo'), - 'indirizzo' => 'Via Demo 1', - 'cap' => '00000', - 'citta' => 'Demo', - 'provincia' => 'RM', - 'stato' => 'attivo', - 'attivo' => true, + 'codice_stabile' => $codice, + 'denominazione' => (string) ($data['denominazione'] ?? 'Condominio Demo'), + 'indirizzo' => 'Via Demo 1', + 'cap' => '00000', + 'citta' => 'Demo', + 'provincia' => 'RM', + 'stato' => 'attivo', + 'attivo' => true, ]); $palazzina = Palazzina::query()->create([ - 'stabile_id' => $stabile->id, - 'codice_palazzina' => 'A', - 'denominazione' => 'Palazzina A', - 'numero_scale' => 1, + 'stabile_id' => $stabile->id, + 'codice_palazzina' => 'A', + 'denominazione' => 'Palazzina A', + 'numero_scale' => 1, 'numero_piani_fuori_terra' => 3, - 'numero_piani_interrati' => 0, - 'ha_piano_terra' => true, - 'appartamenti_per_piano' => 2, - 'attiva' => true, - 'created_by' => $user->id, + 'numero_piani_interrati' => 0, + 'ha_piano_terra' => true, + 'appartamenti_per_piano' => 2, + 'attiva' => true, + 'created_by' => $user->id, ]); $unita = UnitaImmobiliare::query()->create([ - 'stabile_id' => $stabile->id, - 'palazzina_id' => $palazzina->id, - 'codice_unita' => 'A-1-0-01', - 'palazzina' => 'A', - 'scala' => '1', - 'piano' => 0, - 'interno' => '01', + 'stabile_id' => $stabile->id, + 'palazzina_id' => $palazzina->id, + 'codice_unita' => 'A-1-0-01', + 'palazzina' => 'A', + 'scala' => '1', + 'piano' => 0, + 'interno' => '01', 'denominazione' => 'Unitร  Demo', - 'tipo_unita' => 'appartamento', - 'attiva' => true, - 'unita_demo' => true, - 'created_by' => $user->id, + 'tipo_unita' => 'appartamento', + 'attiva' => true, + 'unita_demo' => true, + 'created_by' => $user->id, ]); $persona = Persona::query()->create([ - 'tipologia' => 'fisica', - 'cognome' => 'Demo', - 'nome' => 'Condomino', - 'codice_fiscale' => null, + 'tipologia' => 'fisica', + 'cognome' => 'Demo', + 'nome' => 'Condomino', + 'codice_fiscale' => null, 'telefono_principale' => null, - 'email_principale' => null, - 'attivo' => true, + 'email_principale' => null, + 'attivo' => true, ]); PersonaUnitaRelazione::query()->create([ - 'persona_id' => $persona->id, - 'unita_id' => $unita->id, - 'tipo_relazione' => 'proprietario', - 'quota_relazione' => 100, - 'attivo' => true, + 'persona_id' => $persona->id, + 'unita_id' => $unita->id, + 'tipo_relazione' => 'proprietario', + 'quota_relazione' => 100, + 'attivo' => true, 'riceve_comunicazioni' => true, - 'riceve_convocazioni' => true, - 'vota_assemblea' => true, + 'riceve_convocazioni' => true, + 'vota_assemblea' => true, ]); Notification::make() @@ -487,23 +485,23 @@ public function table(Table $table): Table Select::make('stato') ->label('Stato') ->options([ - 'attivo' => 'Attivo', + 'attivo' => 'Attivo', 'inattivo' => 'Inattivo', ]) ->default('attivo') ->required(), Checkbox::make('attivo')->label('Attivo')->default(true), ]) - ->fillForm(fn(Stabile $record): array => [ + ->fillForm(fn(Stabile $record): array=> [ 'codice_stabile' => (string) ($record->codice_stabile ?? ''), - 'cod_stabile' => (string) ($record->cod_stabile ?? ''), - 'denominazione' => (string) ($record->denominazione ?? ''), - 'indirizzo' => (string) ($record->indirizzo ?? ''), - 'cap' => (string) ($record->cap ?? ''), - 'citta' => (string) ($record->citta ?? ''), - 'provincia' => (string) ($record->provincia ?? ''), - 'stato' => (string) ($record->stato ?? 'attivo'), - 'attivo' => (bool) ($record->attivo ?? true), + 'cod_stabile' => (string) ($record->cod_stabile ?? ''), + 'denominazione' => (string) ($record->denominazione ?? ''), + 'indirizzo' => (string) ($record->indirizzo ?? ''), + 'cap' => (string) ($record->cap ?? ''), + 'citta' => (string) ($record->citta ?? ''), + 'provincia' => (string) ($record->provincia ?? ''), + 'stato' => (string) ($record->stato ?? 'attivo'), + 'attivo' => (bool) ($record->attivo ?? true), ]) ->action(function (Stabile $record, array $data): void { $codice = trim((string) ($data['codice_stabile'] ?? '')); @@ -526,14 +524,14 @@ public function table(Table $table): Table $record->fill([ 'codice_stabile' => $codice, - 'cod_stabile' => $codOperatore, - 'denominazione' => (string) ($data['denominazione'] ?? $codice), - 'indirizzo' => (string) ($data['indirizzo'] ?? ''), - 'cap' => (string) ($data['cap'] ?? ''), - 'citta' => (string) ($data['citta'] ?? ''), - 'provincia' => (string) ($data['provincia'] ?? ''), - 'stato' => (string) ($data['stato'] ?? 'attivo'), - 'attivo' => (bool) ($data['attivo'] ?? true), + 'cod_stabile' => $codOperatore, + 'denominazione' => (string) ($data['denominazione'] ?? $codice), + 'indirizzo' => (string) ($data['indirizzo'] ?? ''), + 'cap' => (string) ($data['cap'] ?? ''), + 'citta' => (string) ($data['citta'] ?? ''), + 'provincia' => (string) ($data['provincia'] ?? ''), + 'stato' => (string) ($data['stato'] ?? 'attivo'), + 'attivo' => (bool) ($data['attivo'] ?? true), ]); $record->save(); @@ -599,7 +597,7 @@ public function table(Table $table): Table if ($label === '') { $label = 'Amministratore'; } - $code = (string) ($a->codice_univoco ?: $a->codice_amministratore ?: ''); + $code = (string) ($a->codice_univoco ?: $a->codice_amministratore ?: ''); $suffix = $code !== '' ? (' [' . $code . ']') : ''; return [$a->id => $label . $suffix . ' (ID ' . $a->id . ')']; }) @@ -645,7 +643,7 @@ public function table(Table $table): Table source: 'portal-superadmin', ipAddress: request()->ip(), meta: [ - 'panel' => 'admin-filament', + 'panel' => 'admin-filament', 'action' => 'stabili-archivio-trasferisci', ], ); @@ -676,7 +674,7 @@ public function table(Table $table): Table ->get(); return view('filament.pages.gescon.partials.stabile-transfer-history', [ - 'stabile' => $record, + 'stabile' => $record, 'transfers' => $transfers, ]); }), @@ -754,16 +752,16 @@ public function table(Table $table): Table $svc = new EssentialImportService(); $res = $svc->syncStabile($amm, $legacyCode, [ - 'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'), - 'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null, - 'with_unita' => (bool) ($data['with_unita'] ?? false), - 'with_soggetti' => (bool) ($data['with_soggetti'] ?? false), - 'with_diritti' => (bool) ($data['with_diritti'] ?? false), + 'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'), + 'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null, + 'with_unita' => (bool) ($data['with_unita'] ?? false), + 'with_soggetti' => (bool) ($data['with_soggetti'] ?? false), + 'with_diritti' => (bool) ($data['with_diritti'] ?? false), 'with_millesimi' => (bool) ($data['with_millesimi'] ?? false), - 'with_voci' => (bool) ($data['with_voci'] ?? false), - 'with_gestioni' => (bool) ($data['with_gestioni'] ?? false), - 'with_banche' => (bool) ($data['with_banche'] ?? false), - 'dry' => (bool) ($data['dry'] ?? false), + 'with_voci' => (bool) ($data['with_voci'] ?? false), + 'with_gestioni' => (bool) ($data['with_gestioni'] ?? false), + 'with_banche' => (bool) ($data['with_banche'] ?? false), + 'dry' => (bool) ($data['dry'] ?? false), ]); $title = ($res['changed'] ?? false) @@ -798,15 +796,15 @@ public function table(Table $table): Table ->requiresConfirmation() ->action(function (Stabile $record): void { $checks = [ - 'Palazzine' => fn() => $record->palazzine()->exists(), - 'Unitร  immobiliari' => fn() => $record->unitaImmobiliari()->exists(), - 'Tabelle millesimali' => fn() => $record->tabelleMillesimali()->exists(), - 'Voci spesa' => fn() => $record->vociSpesa()->exists(), - 'Gestioni contabili' => fn() => $record->gestioniContabili()->exists(), + 'Palazzine' => fn() => $record->palazzine()->exists(), + 'Unitร  immobiliari' => fn() => $record->unitaImmobiliari()->exists(), + 'Tabelle millesimali' => fn() => $record->tabelleMillesimali()->exists(), + 'Voci spesa' => fn() => $record->vociSpesa()->exists(), + 'Gestioni contabili' => fn() => $record->gestioniContabili()->exists(), 'Gestioni condominiali' => fn() => $record->gestioniCondominiali()->exists(), - 'Documenti collegati' => fn() => $record->documentiCollegati()->exists(), - 'Dati bancari' => fn() => $record->datiBancari()->exists(), - 'Tickets' => fn() => $record->tickets()->exists(), + 'Documenti collegati' => fn() => $record->documentiCollegati()->exists(), + 'Dati bancari' => fn() => $record->datiBancari()->exists(), + 'Tickets' => fn() => $record->tickets()->exists(), ]; $blocked = []; diff --git a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php index c0870e6..e11e222 100644 --- a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php +++ b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php @@ -195,7 +195,7 @@ public function form(Schema $schema): Schema ]), Section::make('Centralino studio') - ->columns(2) + ->columns(3) ->schema([ TextInput::make('impostazioni.centralino.numero_principale') ->label('Numero principale centralino') @@ -206,9 +206,30 @@ public function form(Schema $schema): Schema TextInput::make('impostazioni.centralino.numero_emergenza') ->label('Numero emergenza') ->maxLength(30), + TextInput::make('impostazioni.centralino.interno_centralino') + ->label('Interno centralino') + ->maxLength(20) + ->placeholder('Es. 201'), + TextInput::make('impostazioni.centralino.interno_operatore') + ->label('Interno operatore studio') + ->maxLength(20) + ->placeholder('Es. 205'), + TextInput::make('impostazioni.centralino.interno_amministratore') + ->label('Interno amministratore') + ->maxLength(20) + ->placeholder('Es. 206'), + TextInput::make('impostazioni.centralino.interno_gruppo_giorno') + ->label('Interno gruppo giorno') + ->maxLength(20) + ->placeholder('Es. 601'), + TextInput::make('impostazioni.centralino.interno_gruppo_notte') + ->label('Interno gruppo notte') + ->maxLength(20) + ->placeholder('Es. 603'), TextInput::make('impostazioni.centralino.note_instradamento') ->label('Note instradamento') - ->maxLength(255), + ->maxLength(255) + ->columnSpanFull(), ]), ]), diff --git a/app/Filament/Pages/Strumenti/PostItGestione.php b/app/Filament/Pages/Strumenti/PostItGestione.php index 4aef800..b713076 100644 --- a/app/Filament/Pages/Strumenti/PostItGestione.php +++ b/app/Filament/Pages/Strumenti/PostItGestione.php @@ -214,7 +214,7 @@ public function getChiamateTecnicheProperty() try { $query = CommunicationMessage::query() ->with(['assignedUser:id,name', 'stabile:id,denominazione']) - ->where('channel', 'smdr') + ->whereIn('channel', ['smdr', 'panasonic_csta']) ->latest('id'); if ($this->tecnicoCallView === 'interne') { @@ -268,6 +268,8 @@ public function creaPostItDaMessaggio(int $messageId): void return; } + $sourceKey = (string) ($message->channel ?: 'cti'); + $sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR'; $smdr = (array) data_get($message->metadata, 'smdr', []); $line = trim((string) ($message->message_text ?? '')); @@ -275,13 +277,13 @@ public function creaPostItDaMessaggio(int $messageId): void 'stabile_id' => $message->stabile_id, 'creato_da_user_id' => Auth::id(), 'telefono' => $message->phone_number, - 'nome_chiamante' => 'SMDR ' . strtoupper((string) $message->direction), + 'nome_chiamante' => $sourceLabel . ' ' . strtoupper((string) $message->direction), 'direzione' => $this->normalizePostItDirection((string) $message->direction), - 'origine' => 'smdr_table', + 'origine' => $sourceKey . '_table', 'origine_id' => (string) $message->id, 'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null, - 'oggetto' => 'Chiamata da pannello tecnico SMDR', - 'nota' => $line !== '' ? $line : 'Evento SMDR senza testo riga', + 'oggetto' => 'Chiamata da pannello tecnico ' . $sourceLabel, + 'nota' => $line !== '' ? $line : ('Evento ' . $sourceLabel . ' senza testo riga'), 'priorita' => 'Media', 'stato' => 'post_it', 'chiamata_il' => $message->received_at ?? now(), diff --git a/app/Filament/Pages/Supporto/Modifiche.php b/app/Filament/Pages/Supporto/Modifiche.php index 464102a..4d63cb9 100644 --- a/app/Filament/Pages/Supporto/Modifiche.php +++ b/app/Filament/Pages/Supporto/Modifiche.php @@ -450,7 +450,7 @@ private function startUpdateJob(bool $fallback): void 'exit_code' => null, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); - $adminId = $this->resolveAmministratoreId(); + $adminId = $this->resolveAmministratoreId(); $requireDrive = $this->shouldRequireDrivePreupdateBackup(); $driveEnabled = $this->shouldAttemptDrivePreupdateBackup(); @@ -478,7 +478,7 @@ private function startUpdateJob(bool $fallback): void ]; if ($driveEnabled && $adminId > 0) { - $backupParams['--drive'] = true; + $backupParams['--drive'] = true; $backupParams['--admin-id'] = (string) $adminId; if ($requireDrive) { diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index 0711ef4..6b2c688 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages\Supporto; use App\Models\CategoriaTicket; +use App\Models\Documento; use App\Models\Fornitore; use App\Models\FornitoreDipendente; use App\Models\InsuranceClaim; @@ -10,6 +11,8 @@ use App\Models\TicketAttachment; use App\Models\TicketIntervento; use App\Models\User; +use App\Services\Documenti\ImageTextExtractionService; +use App\Services\Documenti\PdfTextExtractionService; use App\Support\StabileContext; use BackedEnum; use Filament\Notifications\Notification; @@ -17,6 +20,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Livewire\WithFileUploads; @@ -26,6 +30,9 @@ class TicketGestione extends Page { use WithFileUploads; + /** @var array|null */ + private ?array $documentiColumnsCache = null; + protected static ?string $navigationLabel = 'Ticket Gestione'; protected static ?string $title = 'Gestione Ticket'; @@ -60,6 +67,22 @@ class TicketGestione extends Page public ?string $insuranceClaimNumber = null; + public ?string $insuranceStatus = null; + + public ?string $insuranceTakenInChargeAt = null; + + public ?string $insuranceEmailSentAt = null; + + public ?string $insuranceInsurerContactedAt = null; + + public ?string $insuranceDocumentsSentAt = null; + + public ?string $insuranceAppointmentAt = null; + + public ?string $insuranceClosedAt = null; + + public ?string $insuranceNextAction = null; + public ?string $insuranceNotes = null; /** @var array */ @@ -430,28 +453,47 @@ public function salvaSinistroAssicurativo(): void } $this->validate([ - 'insurancePolicyId' => ['nullable', 'integer', 'exists:insurance_policies,id'], - 'insurancePolicyReference' => ['nullable', 'string', 'max:255'], - 'insuranceClaimNumber' => ['nullable', 'string', 'max:255'], - 'insuranceNotes' => ['nullable', 'string', 'max:4000'], + 'insurancePolicyId' => ['nullable', 'integer', 'exists:insurance_policies,id'], + 'insurancePolicyReference' => ['nullable', 'string', 'max:255'], + 'insuranceClaimNumber' => ['nullable', 'string', 'max:255'], + 'insuranceStatus' => ['nullable', 'string', 'max:50'], + 'insuranceTakenInChargeAt' => ['nullable', 'date'], + 'insuranceEmailSentAt' => ['nullable', 'date'], + 'insuranceInsurerContactedAt' => ['nullable', 'date'], + 'insuranceDocumentsSentAt' => ['nullable', 'date'], + 'insuranceAppointmentAt' => ['nullable', 'date'], + 'insuranceClosedAt' => ['nullable', 'date'], + 'insuranceNextAction' => ['nullable', 'string', 'max:1000'], + 'insuranceNotes' => ['nullable', 'string', 'max:4000'], + ]); + + $claimMetadata = array_merge((array) ($ticket->insuranceClaim?->metadata ?? []), [ + 'taken_in_charge_at' => $this->insuranceTakenInChargeAt ?: null, + 'email_sent_at' => $this->insuranceEmailSentAt ?: null, + 'insurer_contacted_at' => $this->insuranceInsurerContactedAt ?: null, + 'documents_sent_at' => $this->insuranceDocumentsSentAt ?: null, + 'appointment_at' => $this->insuranceAppointmentAt ?: null, + 'closed_at' => $this->insuranceClosedAt ?: null, + 'next_action' => filled($this->insuranceNextAction) ? trim((string) $this->insuranceNextAction) : null, ]); $claim = InsuranceClaim::query()->updateOrCreate( ['ticket_id' => (int) $ticket->id], [ 'insurance_policy_id' => (int) ($this->insurancePolicyId ?? 0) ?: null, - 'stabile_id' => (int) $ticket->stabile_id, - 'policy_reference' => filled($this->insurancePolicyReference) ? trim((string) $this->insurancePolicyReference) : null, - 'claim_number' => filled($this->insuranceClaimNumber) ? trim((string) $this->insuranceClaimNumber) : null, - 'status' => 'aperta', - 'opened_at' => $ticket->insuranceClaim?->opened_at ?? now(), - 'notes' => filled($this->insuranceNotes) ? trim((string) $this->insuranceNotes) : null, + 'stabile_id' => (int) $ticket->stabile_id, + 'policy_reference' => filled($this->insurancePolicyReference) ? trim((string) $this->insurancePolicyReference) : null, + 'claim_number' => filled($this->insuranceClaimNumber) ? trim((string) $this->insuranceClaimNumber) : null, + 'status' => filled($this->insuranceStatus) ? trim((string) $this->insuranceStatus) : 'aperta', + 'opened_at' => $ticket->insuranceClaim?->opened_at ?? now(), + 'notes' => filled($this->insuranceNotes) ? trim((string) $this->insuranceNotes) : null, + 'metadata' => $claimMetadata, ] ); $ticket->messages()->create([ 'user_id' => Auth::id(), - 'messaggio' => 'Apertura sinistro assicurativo collegata al ticket. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire'), + 'messaggio' => 'Sinistro assicurativo aggiornato. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire') . ' ยท Stato: ' . ($claim->status ?: 'aperta'), 'canale' => 'assicurazione', 'direzione' => 'outbound', 'inviato_il' => now(), @@ -505,7 +547,7 @@ public function caricaAllegati(): void $path ); - TicketAttachment::query()->create([ + $attachment = TicketAttachment::query()->create([ 'ticket_id' => $ticket->id, 'ticket_update_id' => null, 'user_id' => Auth::id(), @@ -516,6 +558,10 @@ public function caricaAllegati(): void 'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket', ]); + if ($attachment instanceof TicketAttachment) { + $this->archiveTicketAttachmentDocument($ticket, $attachment); + } + $saved++; } @@ -1099,12 +1145,20 @@ private function syncSelectedTicketState(): void $ticket = $this->selectedTicket; if (! $ticket) { - $this->fornitoreSearch = ''; - $this->fornitoreMatches = []; - $this->insurancePolicyId = null; - $this->insurancePolicyReference = null; - $this->insuranceClaimNumber = null; - $this->insuranceNotes = null; + $this->fornitoreSearch = ''; + $this->fornitoreMatches = []; + $this->insurancePolicyId = null; + $this->insurancePolicyReference = null; + $this->insuranceClaimNumber = null; + $this->insuranceStatus = null; + $this->insuranceTakenInChargeAt = null; + $this->insuranceEmailSentAt = null; + $this->insuranceInsurerContactedAt = null; + $this->insuranceDocumentsSentAt = null; + $this->insuranceAppointmentAt = null; + $this->insuranceClosedAt = null; + $this->insuranceNextAction = null; + $this->insuranceNotes = null; return; } @@ -1120,10 +1174,20 @@ private function syncSelectedTicketState(): void $this->fornitoreMatches = []; } - $this->insurancePolicyId = (int) ($ticket->insuranceClaim?->insurance_policy_id ?? 0) ?: null; - $this->insurancePolicyReference = (string) ($ticket->insuranceClaim?->policy_reference ?? ''); - $this->insuranceClaimNumber = (string) ($ticket->insuranceClaim?->claim_number ?? ''); - $this->insuranceNotes = (string) ($ticket->insuranceClaim?->notes ?? ''); + $this->insurancePolicyId = (int) ($ticket->insuranceClaim?->insurance_policy_id ?? 0) ?: null; + $this->insurancePolicyReference = (string) ($ticket->insuranceClaim?->policy_reference ?? ''); + $this->insuranceClaimNumber = (string) ($ticket->insuranceClaim?->claim_number ?? ''); + $this->insuranceStatus = (string) ($ticket->insuranceClaim?->status ?? 'aperta'); + $this->insuranceTakenInChargeAt = filled(data_get($ticket->insuranceClaim?->metadata, 'taken_in_charge_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'taken_in_charge_at') : null; + $this->insuranceEmailSentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'email_sent_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'email_sent_at') : null; + $this->insuranceInsurerContactedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'insurer_contacted_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'insurer_contacted_at') : null; + $this->insuranceDocumentsSentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'documents_sent_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'documents_sent_at') : null; + $this->insuranceAppointmentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'appointment_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'appointment_at') : null; + $this->insuranceClosedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'closed_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'closed_at') : null; + $this->insuranceNextAction = (string) (data_get($ticket->insuranceClaim?->metadata, 'next_action') ?? ''); + $this->insuranceNotes = (string) ($ticket->insuranceClaim?->notes ?? ''); + + $this->syncTicketAttachmentsArchive($ticket); } /** @@ -1154,6 +1218,133 @@ public function getInsurancePolicyOptionsProperty(): array ->all(); } + private function syncTicketAttachmentsArchive(Ticket $ticket): void + { + foreach ($ticket->attachments as $attachment) { + if ($attachment instanceof TicketAttachment) { + $this->archiveTicketAttachmentDocument($ticket, $attachment); + } + } + } + + private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void + { + if (! Schema::hasTable('documenti')) { + return; + } + + $publicPath = trim((string) ($attachment->file_path ?? '')); + if ($publicPath === '' || ! Storage::disk('public')->exists($publicPath)) { + return; + } + + $extension = strtolower((string) pathinfo((string) ($attachment->original_file_name ?: $publicPath), PATHINFO_EXTENSION)); + $safeName = Str::slug(pathinfo((string) ($attachment->original_file_name ?: 'allegato'), PATHINFO_FILENAME)); + $safeName = $safeName !== '' ? $safeName : ('attachment-' . (int) $attachment->id); + $archiveReference = $this->buildTicketArchiveReference($ticket, $attachment); + $localPath = $this->buildTicketArchivePath($ticket, $attachment, $safeName, $extension, $archiveReference); + + if (! Storage::disk('local')->exists($localPath)) { + Storage::disk('local')->put($localPath, Storage::disk('public')->get($publicPath)); + } + + $contents = Storage::disk('local')->get($localPath); + $payload = [ + 'stabile_id' => (int) $ticket->stabile_id, + 'utente_id' => Auth::id(), + 'nome' => (string) ($attachment->original_file_name ?: ('Allegato ticket #' . (int) $attachment->id)), + 'nome_file' => (string) ($attachment->original_file_name ?: basename($localPath)), + 'percorso_file' => $localPath, + 'tipo_documento' => 'ticket_allegato', + 'tipologia' => $attachment->isImage() ? 'foto' : ($attachment->isPdf() ? 'comunicazione' : 'altro'), + 'mime_type' => $attachment->resolvedMimeType(), + 'descrizione' => (string) ($attachment->description ?: 'Allegato ticket #' . (int) $ticket->id), + 'note' => 'Ticket #' . (int) $ticket->id . ' ยท ' . (string) ($ticket->titolo ?: 'senza titolo'), + 'dimensione_file' => (int) ($attachment->size ?? strlen($contents)), + 'estensione' => $extension !== '' ? $extension : null, + 'hash_file' => hash('sha256', $contents), + 'xml_data' => [ + 'ticket_id' => (int) $ticket->id, + 'ticket_attachment_id' => (int) $attachment->id, + 'source_public_path' => $publicPath, + 'archive_reference' => $archiveReference, + 'archive_year' => $this->resolveTicketArchiveYear($ticket, $attachment), + 'archive_scope' => 'ticket', + 'visibility_scope' => 'interno', + 'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id], + ], + 'metadati_ocr' => $attachment->isImage() + ? ['status' => 'pending_image_ocr', 'source' => 'ticket_attachment'] + : ['source' => 'ticket_attachment'], + 'data_upload' => now(), + 'approvato' => true, + 'archiviato' => true, + 'urgente' => false, + 'is_demo' => false, + 'archive_reference' => $archiveReference, + 'visibility_scope' => 'interno', + 'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id], + 'visibility_roles' => ['super-admin', 'admin', 'amministratore', 'collaboratore'], + ]; + + $documento = Documento::query()->updateOrCreate( + [ + 'documentable_type' => Ticket::class, + 'documentable_id' => (int) $ticket->id, + 'path_file' => $localPath, + ], + $this->filterDocumentoPayload($payload) + ); + + if ($attachment->isPdf()) { + app(PdfTextExtractionService::class)->extractAndStore($documento, false); + } elseif ($attachment->isImage()) { + app(ImageTextExtractionService::class)->extractAndStore($documento, false); + } + } + + /** + * @return array + */ + private function documentiColumns(): array + { + if ($this->documentiColumnsCache === null) { + $this->documentiColumnsCache = Schema::hasTable('documenti') + ? Schema::getColumnListing('documenti') + : []; + } + + return $this->documentiColumnsCache; + } + + private function filterDocumentoPayload(array $payload): array + { + $allowed = array_flip($this->documentiColumns()); + return array_intersect_key($payload, $allowed); + } + + private function buildTicketArchiveReference(Ticket $ticket, TicketAttachment $attachment): string + { + return sprintf('TKT-%06d-ATT-%06d', (int) $ticket->id, (int) $attachment->id); + } + + private function buildTicketArchivePath(Ticket $ticket, TicketAttachment $attachment, string $safeName, string $extension, string $archiveReference): string + { + $year = $this->resolveTicketArchiveYear($ticket, $attachment); + $suffix = $extension !== '' ? ('.' . $extension) : ''; + + return 'documenti/' . $year + . '/stabili/stabile-' . (int) $ticket->stabile_id + . '/ticket/ticket-' . str_pad((string) (int) $ticket->id, 6, '0', STR_PAD_LEFT) + . '/' . Str::lower($archiveReference) . '-' . $safeName . $suffix; + } + + private function resolveTicketArchiveYear(Ticket $ticket, TicketAttachment $attachment): string + { + $date = $attachment->created_at ?? $ticket->created_at ?? now(); + return method_exists($date, 'format') ? $date->format('Y') : now()->format('Y'); + } + private function loadCounters(): void { $user = Auth::user(); diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 26fec62..b092877 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -13,6 +13,7 @@ use App\Models\Ticket; use App\Models\TicketAttachment; use App\Models\User; +use App\Services\Cti\PbxRoutingService; use App\Support\StabileContext; use BackedEnum; use Filament\Notifications\Notification; @@ -159,16 +160,17 @@ public function refreshLiveCallBanner(): void return; } - $userExtension = trim((string) ($authUser->pbx_extension ?? '')); - $popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && $userExtension !== ''; + $userExtension = trim((string) ($authUser->pbx_extension ?? '')); + $watchedExtensions = app(PbxRoutingService::class)->getWatchedExtensionsForUser($authUser); + $popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && count($watchedExtensions) > 0; $latest = CommunicationMessage::query() - ->where('channel', 'smdr') + ->whereIn('channel', ['smdr', 'panasonic_csta']) ->where('direction', 'inbound') ->whereNotNull('received_at') ->where('received_at', '>=', now()->subMinutes(8)) - ->when($popupRestricted, function ($q) use ($userExtension): void { - $q->where('target_extension', $userExtension); + ->when($popupRestricted, function ($q) use ($watchedExtensions): void { + $q->whereIn('target_extension', $watchedExtensions); }) ->latest('id') ->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']); diff --git a/app/Http/Controllers/Admin/InsurancePolicyAssetViewController.php b/app/Http/Controllers/Admin/InsurancePolicyAssetViewController.php new file mode 100644 index 0000000..898e3ae --- /dev/null +++ b/app/Http/Controllers/Admin/InsurancePolicyAssetViewController.php @@ -0,0 +1,57 @@ +hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) { + abort(403); + } + + $accessible = StabileContext::accessibleStabili($user) + ->contains(fn($stabile) => (int) ($stabile->id ?? 0) === (int) $policy->stabile_id); + + abort_unless($accessible, 403); + + [$path, $mime, $downloadName] = match ($asset) { + 'document' => [ + trim((string) ($policy->policy_document_path ?? '')), + trim((string) ($policy->policy_document_mime ?? '')), + (string) ($policy->policy_document_name ?: ($policy->display_name . '.pdf')), + ], + 'signature' => [ + trim((string) ($policy->signature_image_path ?? '')), + '', + 'firma-' . (int) $policy->id . '.' . pathinfo((string) ($policy->signature_image_path ?? 'firma.png'), PATHINFO_EXTENSION), + ], + default => abort(404), + }; + + if ($path === '' || ! Storage::disk('public')->exists($path)) { + abort(404); + } + + $absolutePath = Storage::disk('public')->path($path); + $resolvedMime = $mime !== '' ? $mime : ((string) (Storage::disk('public')->mimeType($path) ?: 'application/octet-stream')); + + return response()->file($absolutePath, [ + 'Content-Type' => $resolvedMime, + 'Cache-Control' => 'private, max-age=300', + 'Content-Disposition' => 'inline; filename="' . addslashes($downloadName) . '"', + ]); + } +} diff --git a/app/Http/Controllers/Admin/TicketAttachmentViewController.php b/app/Http/Controllers/Admin/TicketAttachmentViewController.php index 878f9c6..303e99e 100644 --- a/app/Http/Controllers/Admin/TicketAttachmentViewController.php +++ b/app/Http/Controllers/Admin/TicketAttachmentViewController.php @@ -56,7 +56,7 @@ public function show(TicketAttachment $attachment): BinaryFileResponse protected function authorizeSupplierAttachment(User $user, TicketAttachment $attachment): void { - $fornitoreIds = []; + $fornitoreIds = []; $currentSupplier = $this->resolveCurrentUserSupplier($user); if ($currentSupplier instanceof Fornitore) { diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index a18a6d3..5670806 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -3,7 +3,10 @@ use App\Http\Controllers\Controller; use App\Models\ChiamataPostIt; +use App\Models\CommunicationMessage; +use App\Models\PbxClickToCallRequest; use App\Models\RubricaUniversale; +use App\Services\Cti\PbxRoutingService; use App\Support\PhoneNumber; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -24,15 +27,17 @@ public function incoming(Request $request): JsonResponse $traceId = (string) Str::uuid(); $payload = $request->validate([ - 'phone' => ['required', 'string', 'max:64'], - 'name' => ['nullable', 'string', 'max:255'], - 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], - 'outcome' => ['nullable', 'string', 'max:255'], - 'event_id' => ['nullable', 'string', 'max:100'], - 'called_extension' => ['nullable', 'string', 'max:30'], - 'note' => ['nullable', 'string'], - 'called_at' => ['nullable', 'date'], - 'is_test' => ['nullable', 'boolean'], + 'phone' => ['required', 'string', 'max:64'], + 'name' => ['nullable', 'string', 'max:255'], + 'direction' => ['nullable', 'string', 'max:32'], + 'outcome' => ['nullable', 'string', 'max:255'], + 'event_id' => ['nullable', 'string', 'max:100'], + 'called_extension' => ['nullable', 'string', 'max:30'], + 'calling_extension' => ['nullable', 'string', 'max:30'], + 'event_type' => ['nullable', 'string', 'max:60'], + 'note' => ['nullable', 'string'], + 'called_at' => ['nullable', 'date'], + 'is_test' => ['nullable', 'boolean'], ]); [$isTest, $classification] = $this->classifyCall($payload); @@ -44,21 +49,23 @@ public function incoming(Request $request): JsonResponse $normalized = PhoneNumber::normalizeForMatch((string) $payload['phone']); $rubrica = $this->findRubricaByPhone($normalized); + $direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); + $routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? '')); $postIt = null; if (Schema::hasTable('chiamate_post_it')) { $postIt = ChiamataPostIt::query()->create([ - 'stabile_id' => null, + 'stabile_id' => $routing['stabile_id'], 'rubrica_id' => $rubrica?->id, 'ticket_id' => null, - 'creato_da_user_id' => null, + 'creato_da_user_id' => $routing['user_id'], 'telefono' => $normalized !== '' ? $normalized : (string) $payload['phone'], 'nome_chiamante' => $rubrica?->nome_completo ?: ($payload['name'] ?? null), - 'direzione' => $payload['direction'] ?? 'in_arrivo', + 'direzione' => $direction['post_it'], 'esito' => $payload['outcome'] ?? null, 'origine' => 'panasonic_ns1000', 'origine_id' => $payload['event_id'] ?? null, - 'oggetto' => 'Chiamata ' . (($payload['direction'] ?? 'in_arrivo') === 'in_arrivo' ? 'in arrivo' : 'telefonica'), + 'oggetto' => 'Chiamata ' . ($direction['post_it'] === 'in_uscita' ? 'telefonica' : 'in arrivo'), 'nota' => $this->buildNote($payload), 'priorita' => 'Media', 'stato' => 'post_it', @@ -66,29 +73,44 @@ public function incoming(Request $request): JsonResponse ]); } + $message = $this->storeIncomingCommunicationMessage( + $payload, + $normalized, + $routing, + $rubrica, + $direction['communication'], + $postIt?->id + ); + $this->mirrorToPeerIfEnabled($request, 'incoming', $payload); $this->logTrace('incoming.stored', $request, $payload, [ 'trace_id' => $traceId, 'post_it_id' => $postIt?->id, + 'message_id' => $message?->id, 'rubrica_id' => $rubrica?->id, 'classification' => $classification, 'is_test' => $isTest, ]); return response()->json([ - 'ok' => true, - 'source' => 'panasonic_ns1000', - 'trace_id' => $traceId, - 'phone_normalized' => $normalized, - 'matched' => (bool) $rubrica, - 'rubrica' => $rubrica ? [ + 'ok' => true, + 'source' => 'panasonic_ns1000', + 'trace_id' => $traceId, + 'phone_normalized' => $normalized, + 'matched' => (bool) $rubrica, + 'rubrica' => $rubrica ? [ 'id' => $rubrica->id, 'nome_completo' => $rubrica->nome_completo, 'categoria' => $rubrica->categoria, ] : null, - 'post_it_id' => $postIt?->id, - 'called_extension' => $payload['called_extension'] ?? null, + 'post_it_id' => $postIt?->id, + 'communication_message_id' => $message?->id, + 'assigned_user_id' => $routing['user_id'], + 'amministratore_id' => $routing['amministratore_id'], + 'studio_role' => $routing['studio_role'], + 'studio_mode' => $routing['studio_mode'], + 'called_extension' => $payload['called_extension'] ?? null, ]); } @@ -131,15 +153,17 @@ public function callEnded(Request $request): JsonResponse $traceId = (string) Str::uuid(); $payload = $request->validate([ - 'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'], - 'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'], - 'outcome' => ['nullable', 'string', 'max:255'], - 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], - 'ended_at' => ['nullable', 'date'], - 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], - 'called_extension' => ['nullable', 'string', 'max:30'], - 'note' => ['nullable', 'string'], - 'is_test' => ['nullable', 'boolean'], + 'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'], + 'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'], + 'outcome' => ['nullable', 'string', 'max:255'], + 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'ended_at' => ['nullable', 'date'], + 'direction' => ['nullable', 'string', 'max:32'], + 'called_extension' => ['nullable', 'string', 'max:30'], + 'calling_extension' => ['nullable', 'string', 'max:30'], + 'event_type' => ['nullable', 'string', 'max:60'], + 'note' => ['nullable', 'string'], + 'is_test' => ['nullable', 'boolean'], ]); [$isTest, $classification] = $this->classifyCall($payload); @@ -150,6 +174,7 @@ public function callEnded(Request $request): JsonResponse ]); $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); + $direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); $postIt = $this->findOpenPostIt( (string) ($payload['event_id'] ?? ''), $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') @@ -167,7 +192,7 @@ public function callEnded(Request $request): JsonResponse 'creato_da_user_id' => null, 'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''), 'nome_chiamante' => $rubrica?->nome_completo, - 'direzione' => $payload['direction'] ?? 'in_arrivo', + 'direzione' => $direction['post_it'], 'esito' => $payload['outcome'] ?? null, 'origine' => 'panasonic_ns1000', 'origine_id' => $payload['event_id'] ?? null, @@ -212,26 +237,145 @@ public function callEnded(Request $request): JsonResponse $postIt->save(); + $message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id); + $this->mirrorToPeerIfEnabled($request, 'call-ended', $payload); $this->logTrace('call-ended.closed', $request, $payload, [ 'trace_id' => $traceId, 'post_it_id' => $postIt->id, + 'message_id' => $message?->id, 'created_new' => $created, 'classification' => $classification, 'is_test' => $isTest, ]); return response()->json([ - 'ok' => true, - 'source' => 'panasonic_ns1000', - 'trace_id' => $traceId, - 'closed' => true, - 'created_new' => $created, - 'post_it_id' => $postIt->id, - 'stato' => $postIt->stato, - 'esito' => $postIt->esito, - 'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, + 'ok' => true, + 'source' => 'panasonic_ns1000', + 'trace_id' => $traceId, + 'closed' => true, + 'created_new' => $created, + 'post_it_id' => $postIt->id, + 'communication_message_id' => $message?->id, + 'stato' => $postIt->stato, + 'esito' => $postIt->esito, + 'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, + ]); + } + + public function pendingClickToCall(Request $request): JsonResponse + { + if (! $this->isAuthorized($request)) { + $this->logUnauthorizedAttempt($request, 'click-to-call/pending'); + return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); + } + + $data = $request->validate([ + 'extension' => ['nullable', 'string', 'max:30'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $extension = app(PbxRoutingService::class)->normalizeExtension((string) ($data['extension'] ?? '')); + $limit = (int) ($data['limit'] ?? 20); + + $requests = PbxClickToCallRequest::query() + ->where('status', 'pending') + ->when($extension !== '', function ($query) use ($extension): void { + $query->where('source_extension', $extension); + }) + ->orderByRaw('COALESCE(requested_at, created_at) asc') + ->limit($limit) + ->get(); + + return response()->json([ + 'ok' => true, + 'count' => $requests->count(), + 'requests' => $requests->map(fn(PbxClickToCallRequest $clickToCallRequest): array=> $this->serializeClickToCallRequest($clickToCallRequest))->values(), + ]); + } + + public function claimClickToCall(Request $request, PbxClickToCallRequest $clickToCallRequest): JsonResponse + { + if (! $this->isAuthorized($request)) { + $this->logUnauthorizedAttempt($request, 'click-to-call/claim'); + return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); + } + + $data = $request->validate([ + 'adapter' => ['nullable', 'string', 'max:120'], + 'note' => ['nullable', 'string'], + ]); + + $clickToCallRequest->refresh(); + if ($clickToCallRequest->status !== 'pending') { + return response()->json([ + 'ok' => false, + 'message' => 'Request not pending anymore', + 'status' => $clickToCallRequest->status, + ], 409); + } + + $clickToCallRequest->status = 'processing'; + $clickToCallRequest->metadata = $this->mergeMetadata($clickToCallRequest->metadata, [ + 'adapter' => (string) ($data['adapter'] ?? ''), + 'claimed_at' => now()->toIso8601String(), + 'claim_note' => $data['note'] ?? null, + ]); + $clickToCallRequest->save(); + + return response()->json([ + 'ok' => true, + 'request' => $this->serializeClickToCallRequest($clickToCallRequest), + ]); + } + + public function updateClickToCallStatus(Request $request, PbxClickToCallRequest $clickToCallRequest): JsonResponse + { + if (! $this->isAuthorized($request)) { + $this->logUnauthorizedAttempt($request, 'click-to-call/status'); + return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); + } + + $data = $request->validate([ + 'status' => ['required', 'in:processing,accepted,completed,failed,cancelled'], + 'note' => ['nullable', 'string'], + 'event_id' => ['nullable', 'string', 'max:100'], + 'provider_call_id' => ['nullable', 'string', 'max:120'], + 'called_extension' => ['nullable', 'string', 'max:30'], + 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'outcome' => ['nullable', 'string', 'max:255'], + 'error_code' => ['nullable', 'string', 'max:80'], + 'error_message' => ['nullable', 'string', 'max:255'], + ]); + + $clickToCallRequest->status = (string) $data['status']; + if (in_array($clickToCallRequest->status, ['completed', 'failed', 'cancelled'], true)) { + $clickToCallRequest->processed_at = now(); + } + + if (! empty($data['note'])) { + $clickToCallRequest->note = trim(implode("\n", array_filter([ + (string) ($clickToCallRequest->note ?? ''), + (string) $data['note'], + ]))); + } + + $clickToCallRequest->metadata = $this->mergeMetadata($clickToCallRequest->metadata, [ + 'event_id' => $data['event_id'] ?? null, + 'provider_call_id' => $data['provider_call_id'] ?? null, + 'called_extension' => app(PbxRoutingService::class)->normalizeExtension((string) ($data['called_extension'] ?? '')), + 'duration_seconds' => $data['duration_seconds'] ?? null, + 'outcome' => $data['outcome'] ?? null, + 'error_code' => $data['error_code'] ?? null, + 'error_message' => $data['error_message'] ?? null, + 'status_updated_at' => now()->toIso8601String(), + ]); + $clickToCallRequest->save(); + + return response()->json([ + 'ok' => true, + 'request' => $this->serializeClickToCallRequest($clickToCallRequest), ]); } @@ -250,6 +394,18 @@ private function isAuthorized(Request $request): bool private function resolveConfiguredToken(): string { + $configured = trim((string) config('cti.shared_token', '')); + if ($configured !== '') { + return $configured; + } + + foreach ((array) config('cti.legacy_tokens', []) as $legacyToken) { + $candidate = trim((string) $legacyToken); + if ($candidate !== '') { + return $candidate; + } + } + $configured = (string) env('NETGESCON_CTI_SHARED_TOKEN', ''); if ($configured === '') { $configured = (string) env('CTI_SHARED_SECRET', ''); @@ -272,7 +428,7 @@ private function resolveConfiguredToken(): string private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array $payload): void { - $enabled = filter_var((string) env('NETGESCON_CTI_MIRROR_ENABLED', 'false'), FILTER_VALIDATE_BOOL); + $enabled = (bool) config('cti.mirror_enabled', false); if (! $enabled) { return; } @@ -281,7 +437,7 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array return; } - $baseUrl = rtrim((string) env('NETGESCON_CTI_MIRROR_BASE_URL', ''), '/'); + $baseUrl = rtrim((string) config('cti.mirror_base_url', ''), '/'); if ($baseUrl === '') { return; } @@ -291,7 +447,7 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array return; } - $timeout = (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2); + $timeout = (int) config('cti.mirror_timeout_seconds', 2); $url = $baseUrl . '/api/v1/cti/panasonic/' . ltrim($endpoint, '/'); try { @@ -373,10 +529,12 @@ private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt private function buildNote(array $payload): string { [$isTest, $classification] = $this->classifyCall($payload); + $direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); $lines = [ 'Origine: Panasonic NS1000 (CSTA)', 'Classificazione: ' . $classification, + 'Direzione normalizzata: ' . $direction['communication'], ]; if ($isTest) { @@ -387,6 +545,14 @@ private function buildNote(array $payload): string $lines[] = 'Interno chiamato: ' . $payload['called_extension']; } + if (! empty($payload['calling_extension'])) { + $lines[] = 'Interno chiamante: ' . $payload['calling_extension']; + } + + if (! empty($payload['event_type'])) { + $lines[] = 'Evento CSTA: ' . $payload['event_type']; + } + if (! empty($payload['event_id'])) { $lines[] = 'Event ID: ' . $payload['event_id']; } @@ -439,14 +605,220 @@ private function logTrace(string $event, Request $request, array $payload, array $phoneDigits = PhoneNumber::normalizeDigits($phoneRaw); Log::info('CTI Panasonic trace', array_merge([ - 'event' => $event, - 'ip' => $request->ip(), - 'user_agent' => (string) $request->userAgent(), - 'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1', - 'event_id' => (string) ($payload['event_id'] ?? ''), - 'called_extension' => (string) ($payload['called_extension'] ?? ''), - 'direction' => (string) ($payload['direction'] ?? ''), - 'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '', + 'event' => $event, + 'ip' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), + 'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1', + 'event_type' => (string) ($payload['event_type'] ?? ''), + 'event_id' => (string) ($payload['event_id'] ?? ''), + 'calling_extension' => (string) ($payload['calling_extension'] ?? ''), + 'called_extension' => (string) ($payload['called_extension'] ?? ''), + 'direction' => (string) ($payload['direction'] ?? ''), + 'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '', ], $extra)); } + + private function storeIncomingCommunicationMessage( + array $payload, + string $normalizedPhone, + array $routing, + ?RubricaUniversale $rubrica, + string $direction, + ?int $postItId + ): ?CommunicationMessage { + if (! Schema::hasTable('communication_messages')) { + return null; + } + + $message = new CommunicationMessage([ + 'channel' => 'panasonic_csta', + 'direction' => $direction, + 'external_chat_id' => (string) ($payload['called_extension'] ?? ''), + 'external_message_id' => (string) ($payload['event_id'] ?? ''), + 'phone_number' => $normalizedPhone !== '' ? $normalizedPhone : (string) $payload['phone'], + 'sender_name' => $rubrica?->nome_completo ?: ($payload['name'] ?? null), + 'message_text' => $this->buildNote($payload), + 'attachments' => null, + 'status' => 'received', + 'received_at' => $payload['called_at'] ?? now(), + 'metadata' => [ + 'provider' => 'panasonic_ns1000', + 'amministratore_id' => $routing['amministratore_id'], + 'studio_role' => $routing['studio_role'], + 'studio_mode' => $routing['studio_mode'], + 'called_extension' => $routing['extension'], + 'calling_extension' => app(PbxRoutingService::class)->normalizeExtension((string) ($payload['calling_extension'] ?? '')), + 'event_id' => (string) ($payload['event_id'] ?? ''), + 'event_type' => (string) ($payload['event_type'] ?? 'Delivered'), + 'rubrica_id' => $rubrica?->id, + 'post_it_id' => $postItId, + 'raw_direction' => (string) ($payload['direction'] ?? ''), + 'outcome' => $payload['outcome'] ?? null, + 'note' => $payload['note'] ?? null, + ], + ]); + + if (Schema::hasColumn('communication_messages', 'target_extension')) { + $message->target_extension = $routing['extension'] !== '' ? $routing['extension'] : null; + } + + if (Schema::hasColumn('communication_messages', 'assigned_user_id')) { + $message->assigned_user_id = $routing['user_id']; + } + + if (Schema::hasColumn('communication_messages', 'stabile_id')) { + $message->stabile_id = $routing['stabile_id']; + } + + if (Schema::hasColumn('communication_messages', 'post_it_id')) { + $message->post_it_id = $postItId; + } + + $message->save(); + + return $message; + } + + private function closeCommunicationMessage(array $payload, string $normalizedPhone, string $direction, int $postItId): ?CommunicationMessage + { + if (! Schema::hasTable('communication_messages')) { + return null; + } + + $message = $this->findCommunicationMessage((string) ($payload['event_id'] ?? ''), $normalizedPhone !== '' ? $normalizedPhone : (string) ($payload['phone'] ?? '')); + + if (! $message) { + $routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? '')); + + $message = $this->storeIncomingCommunicationMessage( + $payload, + $normalizedPhone, + $routing, + $this->findRubricaByPhone($normalizedPhone), + $direction, + $postItId + ); + + if ($message) { + $metadata = is_array($message->metadata) ? $message->metadata : []; + $metadata['ended_at'] = ($payload['ended_at'] ?? now())->toDateTimeString(); + $metadata['duration_seconds'] = $payload['duration_seconds'] ?? null; + $metadata['outcome'] = $payload['outcome'] ?? null; + $metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared'); + $message->status = 'completed'; + $message->metadata = $metadata; + $message->save(); + } + + return $message; + } + + $metadata = is_array($message->metadata) ? $message->metadata : []; + $metadata['ended_at'] = ($payload['ended_at'] ?? now())->toDateTimeString(); + $metadata['duration_seconds'] = $payload['duration_seconds'] ?? null; + $metadata['outcome'] = $payload['outcome'] ?? null; + $metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared'); + $metadata['note'] = $payload['note'] ?? ($metadata['note'] ?? null); + $metadata['post_it_id'] = $postItId; + + $message->status = 'completed'; + $message->message_text = $this->buildNote($payload); + $message->metadata = $metadata; + + if (Schema::hasColumn('communication_messages', 'post_it_id')) { + $message->post_it_id = $postItId; + } + + $message->save(); + + return $message; + } + + private function findCommunicationMessage(string $eventId, string $phone): ?CommunicationMessage + { + if ($eventId !== '') { + $byEvent = CommunicationMessage::query() + ->where('channel', 'panasonic_csta') + ->where('external_message_id', $eventId) + ->orderByDesc('id') + ->first(); + + if ($byEvent) { + return $byEvent; + } + } + + $digits = PhoneNumber::normalizeDigits($phone); + if ($digits === '') { + return null; + } + + $tail = substr($digits, -7); + if ($tail === false || $tail === '') { + return null; + } + + return CommunicationMessage::query() + ->where('channel', 'panasonic_csta') + ->where('phone_number', 'like', '%' . $tail . '%') + ->orderByDesc('id') + ->first(); + } + + /** + * @return array{post_it:string,communication:string} + */ + private function normalizeDirection(string $direction): array + { + $value = mb_strtolower(trim($direction)); + + return match ($value) { + 'in_uscita', 'outbound', 'outgoing' => ['post_it' => 'in_uscita', 'communication' => 'outbound'], + 'persa', 'missed', 'no_answer' => ['post_it' => 'persa', 'communication' => 'inbound'], + default => ['post_it' => 'in_arrivo', 'communication' => 'inbound'], + }; + } + + /** + * @param array|null $existing + * @param array $newData + * @return array + */ + private function mergeMetadata(?array $existing, array $newData): array + { + $base = is_array($existing) ? $existing : []; + + foreach ($newData as $key => $value) { + if ($value === null || $value === '') { + continue; + } + + $base[$key] = $value; + } + + return $base; + } + + /** + * @return array + */ + private function serializeClickToCallRequest(PbxClickToCallRequest $clickToCallRequest): array + { + return [ + 'id' => $clickToCallRequest->id, + 'requested_by_user_id' => $clickToCallRequest->requested_by_user_id, + 'assigned_user_id' => $clickToCallRequest->assigned_user_id, + 'stabile_id' => $clickToCallRequest->stabile_id, + 'communication_message_id' => $clickToCallRequest->communication_message_id, + 'source_extension' => $clickToCallRequest->source_extension, + 'target_number' => $clickToCallRequest->target_number, + 'status' => $clickToCallRequest->status, + 'note' => $clickToCallRequest->note, + 'requested_at' => optional($clickToCallRequest->requested_at)->toIso8601String(), + 'processed_at' => optional($clickToCallRequest->processed_at)->toIso8601String(), + 'metadata' => $clickToCallRequest->metadata, + 'created_at' => optional($clickToCallRequest->created_at)->toIso8601String(), + 'updated_at' => optional($clickToCallRequest->updated_at)->toIso8601String(), + ]; + } } diff --git a/app/Http/Middleware/MenuPermissionMiddleware.php b/app/Http/Middleware/MenuPermissionMiddleware.php index 48e97dd..cd58d31 100755 --- a/app/Http/Middleware/MenuPermissionMiddleware.php +++ b/app/Http/Middleware/MenuPermissionMiddleware.php @@ -215,125 +215,6 @@ private function getUserPermissions($user, $activeRole = null) return $permissions; } - { - $permissions = [ - 'dashboard' => false, - 'stabili' => false, - 'unita' => false, - 'soggetti' => false, - 'contabilita' => false, - 'fatture_acquisto' => false, - 'fatture_emesse' => false, - 'rate' => false, - 'assemblee' => false, - 'rubrica' => false, - 'calendario' => false, - 'manutentori' => false, - 'amministrazione' => false, - 'super_admin' => false, - 'gestione_permessi' => false, - 'xml_fatture' => false, - ]; - - // Determina i permessi in base al ruolo - switch ($user->role) { - case 'super_admin': - return array_fill_keys(array_keys($permissions), true); - - case 'amministratore': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'stabili' => true, - 'unita' => true, - 'soggetti' => true, - 'contabilita' => true, - 'fatture_acquisto' => true, - 'fatture_emesse' => true, - 'rate' => true, - 'assemblee' => true, - 'rubrica' => true, - 'calendario' => true, - 'amministrazione' => true, - 'gestione_permessi' => true, - ]); - break; - - case 'contabile': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'contabilita' => true, - 'fatture_acquisto' => true, - 'soggetti' => true, // Solo per consultazione - ]); - break; - - case 'fatture_acquisto': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'fatture_acquisto' => true, - 'soggetti' => true, // Solo fornitori - ]); - break; - - case 'fatture_emesse': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'fatture_emesse' => true, - 'soggetti' => true, // Solo clienti - 'xml_fatture' => true, - ]); - break; - - case 'rate_manager': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'rate' => true, - 'soggetti' => true, // Solo condomini - 'unita' => true, // Solo consultazione - ]); - break; - - case 'assemblee_manager': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'assemblee' => true, - 'rubrica' => true, - 'calendario' => true, - 'soggetti' => true, // Solo condomini - ]); - break; - - case 'manutentore': - $permissions = array_merge($permissions, [ - 'dashboard' => true, - 'manutentori' => true, - 'xml_fatture' => true, - ]); - - // Aggiungi permessi specifici per condomini assegnati - if (isset($user->assigned_stabili)) { - $permissions['stabili'] = 'limited'; // Solo stabili assegnati - $permissions['unita'] = 'limited'; // Solo unitร  degli stabili assegnati - } - break; - - case 'collaboratore': - $permissions['dashboard'] = true; - // I permessi specifici vengono gestiti tramite la tabella user_permissions - if ($user->custom_permissions) { - $customPermissions = json_decode($user->custom_permissions, true); - $permissions = array_merge($permissions, $customPermissions); - } - break; - - default: - // Utente normale - solo dashboard - $permissions['dashboard'] = true; - break; - } - - return $permissions; - } /** * Ottiene tutti i ruoli dell'utente (sistema ruoli multipli) diff --git a/app/Models/Documento.php b/app/Models/Documento.php index 0c11467..c0e71a8 100755 --- a/app/Models/Documento.php +++ b/app/Models/Documento.php @@ -1,12 +1,11 @@ 'array', - 'metadati_ocr' => 'array', - 'dimensione_file' => 'integer', - 'data_documento' => 'date', - 'data_scadenza' => 'date', - 'data_upload' => 'datetime', - 'data_approvazione' => 'datetime', + 'xml_data' => 'array', + 'metadati_ocr' => 'array', + 'visibility_groups' => 'array', + 'visibility_roles' => 'array', + 'dimensione_file' => 'integer', + 'data_documento' => 'date', + 'data_scadenza' => 'date', + 'data_upload' => 'datetime', + 'data_approvazione' => 'datetime', 'data_archiviazione' => 'datetime', - 'importo_collegato' => 'decimal:2', - 'approvato' => 'boolean', - 'archiviato' => 'boolean', - 'urgente' => 'boolean', - 'is_demo' => 'boolean', - 'collegato_budget' => 'boolean', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'importo_collegato' => 'decimal:2', + 'approvato' => 'boolean', + 'archiviato' => 'boolean', + 'urgente' => 'boolean', + 'is_demo' => 'boolean', + 'collegato_budget' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; /** @@ -175,9 +180,11 @@ public function setTagsArrayAttribute($value) public function getDimensioneFormattataAttribute() { - if (!$this->dimensione_file) return null; + if (! $this->dimensione_file) { + return null; + } - $size = $this->dimensione_file; + $size = $this->dimensione_file; $units = ['B', 'KB', 'MB', 'GB']; for ($i = 0; $size > 1024 && $i < count($units) - 1; $i++) { @@ -189,7 +196,9 @@ public function getDimensioneFormattataAttribute() public function getGiorniAllaScadenzaAttribute() { - if (!$this->data_scadenza) return null; + if (! $this->data_scadenza) { + return null; + } return Carbon::now()->diffInDays($this->data_scadenza, false); } @@ -198,10 +207,21 @@ public function getStatoScadenzaAttribute() { $giorni = $this->giorni_alla_scadenza; - if ($giorni === null) return 'nessuna_scadenza'; - if ($giorni < 0) return 'scaduto'; - if ($giorni <= 7) return 'scadenza_imminente'; - if ($giorni <= 30) return 'in_scadenza'; + if ($giorni === null) { + return 'nessuna_scadenza'; + } + + if ($giorni < 0) { + return 'scaduto'; + } + + if ($giorni <= 7) { + return 'scadenza_imminente'; + } + + if ($giorni <= 30) { + return 'in_scadenza'; + } return 'valido'; } @@ -212,7 +232,7 @@ public function getStatoScadenzaAttribute() public function generaNumeroProtocollo() { $prefisso = strtoupper(substr($this->tipologia ?? 'DOC', 0, 4)); - $anno = Carbon::now()->year; + $anno = Carbon::now()->year; // Trova il prossimo numero progressivo per l'anno $ultimoNumero = static::where('numero_protocollo', 'like', "{$prefisso}-{$anno}-%") @@ -236,8 +256,8 @@ public function hasTag($tag) public function addTag($tag) { $tags = $this->tags_array; - if (!in_array($tag, $tags)) { - $tags[] = $tag; + if (! in_array($tag, $tags)) { + $tags[] = $tag; $this->tags_array = $tags; } return $this; @@ -245,7 +265,7 @@ public function addTag($tag) public function removeTag($tag) { - $tags = $this->tags_array; + $tags = $this->tags_array; $index = array_search($tag, $tags); if ($index !== false) { unset($tags[$index]); @@ -256,7 +276,7 @@ public function removeTag($tag) public function approva($userId = null) { - $this->approvato = true; + $this->approvato = true; $this->data_approvazione = Carbon::now(); if ($userId) { $this->approvato_da = $userId; @@ -266,7 +286,7 @@ public function approva($userId = null) public function archivia() { - $this->archiviato = true; + $this->archiviato = true; $this->data_archiviazione = Carbon::now(); return $this->save(); } diff --git a/app/Models/InsurancePolicy.php b/app/Models/InsurancePolicy.php index e4eac52..9f577b3 100644 --- a/app/Models/InsurancePolicy.php +++ b/app/Models/InsurancePolicy.php @@ -35,11 +35,11 @@ class InsurancePolicy extends Model ]; protected $casts = [ - 'started_at' => 'date', - 'expires_at' => 'date', - 'renewal_at' => 'date', + 'started_at' => 'date', + 'expires_at' => 'date', + 'renewal_at' => 'date', 'annual_amount' => 'decimal:2', - 'metadata' => 'array', + 'metadata' => 'array', ]; public function stabile() @@ -76,4 +76,4 @@ public function getDisplayNameAttribute(): string return 'Polizza #' . (int) $this->id; } -} \ No newline at end of file +} diff --git a/app/Models/Stabile.php b/app/Models/Stabile.php index 60e457a..2d0849f 100755 --- a/app/Models/Stabile.php +++ b/app/Models/Stabile.php @@ -1,10 +1,9 @@ 'datetime', - 'updated_at' => 'datetime', - 'anno_costruzione' => 'integer', - 'numero_piani' => 'integer', - 'numero_unita' => 'integer', - 'superficie_totale' => 'decimal:2', - 'numero_ascensori' => 'integer', - 'numero_garage' => 'integer', - 'numero_rate_ordinarie' => 'integer', - 'numero_rate_straordinarie' => 'integer', - 'presenza_ascensore' => 'boolean', - 'presenza_giardino' => 'boolean', - 'presenza_piscina' => 'boolean', - 'presenza_garage' => 'boolean', - 'registro_anagrafe' => 'boolean', - 'attivo' => 'boolean', - 'mesi_rate_ordinarie' => 'array', - 'mesi_rate_straordinarie' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'anno_costruzione' => 'integer', + 'numero_piani' => 'integer', + 'numero_unita' => 'integer', + 'superficie_totale' => 'decimal:2', + 'numero_ascensori' => 'integer', + 'numero_garage' => 'integer', + 'numero_rate_ordinarie' => 'integer', + 'numero_rate_straordinarie' => 'integer', + 'presenza_ascensore' => 'boolean', + 'presenza_giardino' => 'boolean', + 'presenza_piscina' => 'boolean', + 'presenza_garage' => 'boolean', + 'registro_anagrafe' => 'boolean', + 'attivo' => 'boolean', + 'mesi_rate_ordinarie' => 'array', + 'mesi_rate_straordinarie' => 'array', 'configurazione_default_unita' => 'array', - 'struttura_fisica_json' => 'array', - 'palazzine_data' => 'array', - 'locali_servizio' => 'array', - 'configurazione_avanzata' => 'array', - 'rate_ordinarie_mesi' => 'array', - 'rate_riscaldamento_mesi' => 'array', - 'rendita_catastale' => 'decimal:2', - 'superficie_catastale' => 'decimal:2', - 'fondo_riserva_minimo' => 'decimal:2', - 'importo_rata_standard' => 'decimal:2', - 'inizio_esercizio' => 'date', - 'fine_esercizio' => 'date', - 'data_nomina' => 'date', - 'scadenza_mandato' => 'date', - 'ultima_generazione_unita' => 'datetime' + 'struttura_fisica_json' => 'array', + 'palazzine_data' => 'array', + 'locali_servizio' => 'array', + 'configurazione_avanzata' => 'array', + 'rate_ordinarie_mesi' => 'array', + 'rate_riscaldamento_mesi' => 'array', + 'rendita_catastale' => 'decimal:2', + 'superficie_catastale' => 'decimal:2', + 'fondo_riserva_minimo' => 'decimal:2', + 'importo_rata_standard' => 'decimal:2', + 'inizio_esercizio' => 'date', + 'fine_esercizio' => 'date', + 'data_nomina' => 'date', + 'scadenza_mandato' => 'date', + 'ultima_generazione_unita' => 'datetime', ]; /** @@ -280,30 +279,25 @@ public function getIndirizzoCompletoAttribute() */ public function getDatiCatastaliAttribute() { - $codiceComune = $this->codice_comune_catasto - ?? $this->codice_catastale_comune; + $codiceComune = $this->codice_comune_catasto ?? $this->codice_catastale_comune; - $foglio = $this->foglio - ?? $this->foglio_catasto; + $foglio = $this->foglio ?? $this->foglio_catasto; - $particella = $this->particella - ?? $this->particella_catasto - ?? $this->mappale; + $particella = $this->particella ?? $this->particella_catasto ?? $this->mappale; - $categoria = $this->categoria_catastale - ?? $this->categoria; + $categoria = $this->categoria_catastale ?? $this->categoria; return [ 'codice_comune' => $codiceComune, - 'foglio' => $foglio, - 'particella' => $particella, - 'subalterno' => $this->subalterno, - 'sezione' => $this->sezione, - 'categoria' => $categoria, - 'classe' => $this->classe, - 'consistenza' => $this->consistenza, - 'rendita' => $this->rendita_catastale, - 'superficie' => $this->superficie_catastale, + 'foglio' => $foglio, + 'particella' => $particella, + 'subalterno' => $this->subalterno, + 'sezione' => $this->sezione, + 'categoria' => $categoria, + 'classe' => $this->classe, + 'consistenza' => $this->consistenza, + 'rendita' => $this->rendita_catastale, + 'superficie' => $this->superficie_catastale, ]; } @@ -314,8 +308,8 @@ public function getDatiSdiAttribute() { return [ 'codice_destinatario' => $this->codice_destinatario_sdi, - 'pec_amministratore' => $this->pec_amministratore, - 'pec_condominio' => $this->pec_condominio + 'pec_amministratore' => $this->pec_amministratore, + 'pec_condominio' => $this->pec_condominio, ]; } @@ -326,7 +320,7 @@ public function getConfigurazioneRateOrdinarieAttribute() { return [ 'numero_rate' => $this->numero_rate_ordinarie, - 'mesi' => $this->mesi_rate_ordinarie + 'mesi' => $this->mesi_rate_ordinarie, ]; } @@ -337,7 +331,7 @@ public function getConfigurazioneRateStraordinarieAttribute() { return [ 'numero_rate' => $this->numero_rate_straordinarie, - 'mesi' => $this->mesi_rate_straordinarie + 'mesi' => $this->mesi_rate_straordinarie, ]; } @@ -347,18 +341,18 @@ public function getConfigurazioneRateStraordinarieAttribute() public function getCaratteristicheAttribute() { return [ - 'anno_costruzione' => $this->anno_costruzione, - 'numero_piani' => $this->numero_piani, - 'numero_unita' => $this->numero_unita, - 'superficie_totale' => $this->superficie_totale, + 'anno_costruzione' => $this->anno_costruzione, + 'numero_piani' => $this->numero_piani, + 'numero_unita' => $this->numero_unita, + 'superficie_totale' => $this->superficie_totale, 'tipo_riscaldamento' => $this->tipo_riscaldamento, - 'tipo_acqua' => $this->tipo_acqua, + 'tipo_acqua' => $this->tipo_acqua, 'presenza_ascensore' => $this->presenza_ascensore, - 'numero_ascensori' => $this->numero_ascensori, - 'presenza_giardino' => $this->presenza_giardino, - 'presenza_piscina' => $this->presenza_piscina, - 'presenza_garage' => $this->presenza_garage, - 'numero_garage' => $this->numero_garage + 'numero_ascensori' => $this->numero_ascensori, + 'presenza_giardino' => $this->presenza_giardino, + 'presenza_piscina' => $this->presenza_piscina, + 'presenza_garage' => $this->presenza_garage, + 'numero_garage' => $this->numero_garage, ]; } @@ -432,8 +426,8 @@ public function getStatistiche() $unita = $this->unitaImmobiliari()->where('attiva', true); return [ - 'totale_unita' => $unita->count(), - 'unita_in_locazione' => $unita->whereHas('contrattiLocazione', function ($query) { + 'totale_unita' => $unita->count(), + 'unita_in_locazione' => $unita->whereHas('contrattiLocazione', function ($query) { $query->where('stato', 'attivo') ->whereDate('data_inizio', '<=', now()) ->where(function ($q) { @@ -441,10 +435,10 @@ public function getStatistiche() ->orWhereDate('data_fine', '>=', now()); }); })->count(), - 'superficie_totale' => $unita->sum('superficie_commerciale'), + 'superficie_totale' => $unita->sum('superficie_commerciale'), 'totale_millesimi_proprieta' => $this->getTotaleMillesimi('proprieta'), - 'totale_proprietari' => $this->getProprietari()->count(), - 'totale_inquilini' => $this->getInquilini()->count() + 'totale_proprietari' => $this->getProprietari()->count(), + 'totale_inquilini' => $this->getInquilini()->count(), ]; } diff --git a/app/Models/StabileAmministratoreTransfer.php b/app/Models/StabileAmministratoreTransfer.php index 517ce33..75f3e46 100644 --- a/app/Models/StabileAmministratoreTransfer.php +++ b/app/Models/StabileAmministratoreTransfer.php @@ -1,5 +1,4 @@ 'boolean', - 'meta' => 'array', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'meta' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; public function stabile(): BelongsTo @@ -51,4 +50,4 @@ public function changedByUser(): BelongsTo { return $this->belongsTo(User::class, 'changed_by_user_id', 'id'); } -} \ No newline at end of file +} diff --git a/app/Models/StabileServizioLettura.php b/app/Models/StabileServizioLettura.php index b6693b2..d606b17 100644 --- a/app/Models/StabileServizioLettura.php +++ b/app/Models/StabileServizioLettura.php @@ -48,20 +48,20 @@ class StabileServizioLettura extends Model ]; protected $casts = [ - 'periodo_dal' => 'date', - 'periodo_al' => 'date', + 'periodo_dal' => 'date', + 'periodo_al' => 'date', 'richiesta_lettura_inviata_at' => 'datetime', 'prossima_lettura_scadenza_at' => 'datetime', - 'deadline_lettura_at' => 'datetime', - 'sollecito_inviato_at' => 'datetime', - 'lettura_precedente_valore' => 'decimal:3', - 'lettura_inizio' => 'decimal:3', - 'lettura_fine' => 'decimal:3', - 'lettura_foto_metadata' => 'array', - 'lettura_ocr_confidenza' => 'decimal:2', - 'consumo_valore' => 'decimal:3', - 'importo_totale' => 'decimal:2', - 'raw' => 'array', + 'deadline_lettura_at' => 'datetime', + 'sollecito_inviato_at' => 'datetime', + 'lettura_precedente_valore' => 'decimal:3', + 'lettura_inizio' => 'decimal:3', + 'lettura_fine' => 'decimal:3', + 'lettura_foto_metadata' => 'array', + 'lettura_ocr_confidenza' => 'decimal:2', + 'consumo_valore' => 'decimal:3', + 'importo_totale' => 'decimal:2', + 'raw' => 'array', ]; public function stabile(): BelongsTo diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index cade183..a320c44 100644 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -107,5 +107,6 @@ public function panel(Panel $panel): Panel ->authMiddleware([ Authenticate::class, ]); + } } diff --git a/app/Services/Cti/PbxRoutingService.php b/app/Services/Cti/PbxRoutingService.php new file mode 100644 index 0000000..45509ac --- /dev/null +++ b/app/Services/Cti/PbxRoutingService.php @@ -0,0 +1,175 @@ +normalizeExtension($extension); + if ($normalized === '' || ! Schema::hasColumn('users', 'pbx_extension')) { + return [ + 'extension' => $normalized, + 'user_id' => null, + 'stabile_id' => null, + 'user' => null, + 'amministratore_id' => null, + 'studio_role' => null, + 'studio_mode' => null, + ]; + } + + $studioContext = $this->findStudioContextByExtension($normalized); + + $user = User::query()->where('pbx_extension', $normalized)->first(); + if (! $user) { + return [ + 'extension' => $normalized, + 'user_id' => null, + 'stabile_id' => null, + 'user' => null, + 'amministratore_id' => $studioContext['amministratore_id'], + 'studio_role' => $studioContext['studio_role'], + 'studio_mode' => $studioContext['studio_mode'], + ]; + } + + $stabileId = null; + if (method_exists($user, 'stabiliAssegnati')) { + $stabileId = $user->stabiliAssegnati()->value('stabili.id'); + } + + return [ + 'extension' => $normalized, + 'user_id' => (int) $user->id, + 'stabile_id' => $stabileId ? (int) $stabileId : null, + 'user' => $user, + 'amministratore_id' => $studioContext['amministratore_id'] ?? $this->resolveAmministratoreIdForUser($user), + 'studio_role' => $studioContext['studio_role'], + 'studio_mode' => $studioContext['studio_mode'], + ]; + } + + /** + * @return array + */ + public function getWatchedExtensionsForUser(User $user): array + { + $extensions = []; + + $userExtension = $this->normalizeExtension((string) ($user->pbx_extension ?? '')); + if ($userExtension !== '') { + $extensions[] = $userExtension; + } + + $amministratore = $this->resolveAmministratoreForUser($user); + if ($amministratore) { + $extensions = array_merge($extensions, $this->getStudioExtensionsForAmministratore($amministratore)); + } + + return array_values(array_unique(array_filter($extensions, fn($extension): bool => is_string($extension) && $extension !== ''))); + } + + /** + * @return array + */ + public function getStudioExtensionsForAmministratore(Amministratore $amministratore): array + { + $centralino = (array) (($amministratore->impostazioni ?? [])['centralino'] ?? []); + + $extensions = [ + $this->normalizeExtension((string) Arr::get($centralino, 'interno_centralino', '')), + $this->normalizeExtension((string) Arr::get($centralino, 'interno_operatore', '')), + $this->normalizeExtension((string) Arr::get($centralino, 'interno_amministratore', '')), + $this->normalizeExtension((string) Arr::get($centralino, 'interno_gruppo_giorno', '')), + $this->normalizeExtension((string) Arr::get($centralino, 'interno_gruppo_notte', '')), + ]; + + return array_values(array_unique(array_filter($extensions, fn($extension): bool => $extension !== ''))); + } + + /** + * @return array{amministratore_id:int|null,studio_role:?string,studio_mode:?string} + */ + public function findStudioContextByExtension(?string $extension): array + { + $normalized = $this->normalizeExtension($extension); + if ($normalized === '') { + return [ + 'amministratore_id' => null, + 'studio_role' => null, + 'studio_mode' => null, + ]; + } + + foreach (Amministratore::query()->get(['id', 'impostazioni']) as $amministratore) { + $centralino = (array) (($amministratore->impostazioni ?? [])['centralino'] ?? []); + $map = [ + 'interno_centralino' => ['studio_role' => 'centralino', 'studio_mode' => null], + 'interno_operatore' => ['studio_role' => 'operatore', 'studio_mode' => null], + 'interno_amministratore' => ['studio_role' => 'amministratore', 'studio_mode' => null], + 'interno_gruppo_giorno' => ['studio_role' => 'gruppo', 'studio_mode' => 'giorno'], + 'interno_gruppo_notte' => ['studio_role' => 'gruppo', 'studio_mode' => 'notte'], + ]; + + foreach ($map as $key => $context) { + if ($this->normalizeExtension((string) Arr::get($centralino, $key, '')) !== $normalized) { + continue; + } + + return [ + 'amministratore_id' => (int) $amministratore->id, + 'studio_role' => $context['studio_role'], + 'studio_mode' => $context['studio_mode'], + ]; + } + } + + return [ + 'amministratore_id' => null, + 'studio_role' => null, + 'studio_mode' => null, + ]; + } + + private function resolveAmministratoreForUser(User $user): ?Amministratore + { + if ($user->amministratore instanceof Amministratore) { + return $user->amministratore; + } + + $activeStabile = StabileContext::getActiveStabile($user); + if ($activeStabile?->amministratore instanceof Amministratore) { + return $activeStabile->amministratore; + } + + $amministratoreId = null; + if (method_exists($user, 'stabiliAssegnati')) { + $amministratoreId = $user->stabiliAssegnati()->value('stabili.amministratore_id'); + } + + return $amministratoreId ? Amministratore::query()->find((int) $amministratoreId) : null; + } + + private function resolveAmministratoreIdForUser(User $user): ?int + { + $amministratore = $this->resolveAmministratoreForUser($user); + + return $amministratore ? (int) $amministratore->id : null; + } +} diff --git a/app/Services/Documenti/ImageTextExtractionService.php b/app/Services/Documenti/ImageTextExtractionService.php new file mode 100644 index 0000000..47ba738 --- /dev/null +++ b/app/Services/Documenti/ImageTextExtractionService.php @@ -0,0 +1,138 @@ +resolveImagePath($documento); + if ($relativePath === null) { + return ['status' => 'skipped', 'message' => 'Immagine non disponibile']; + } + + if (! Storage::disk('local')->exists($relativePath)) { + return ['status' => 'error', 'message' => 'File immagine non trovato su storage']; + } + + if (! $force) { + $existing = null; + if (Schema::hasColumn('documenti', 'contenuto_ocr')) { + $existing = $documento->contenuto_ocr; + } elseif (Schema::hasColumn('documenti', 'testo_estratto_ocr')) { + $existing = $documento->testo_estratto_ocr; + } + + if (is_string($existing) && trim($existing) !== '') { + return ['status' => 'skipped', 'message' => 'Testo giร  presente']; + } + } + + $tesseract = $this->findBinary('tesseract'); + if ($tesseract === null) { + $this->updateOcrMetadata($documento, ['status' => 'pending_image_ocr', 'engine' => null, 'message' => 'tesseract non disponibile']); + return ['status' => 'skipped', 'message' => 'Tesseract non disponibile']; + } + + $absolutePath = Storage::disk('local')->path($relativePath); + $language = (string) config('services.tesseract.lang', 'ita+eng'); + $command = escapeshellcmd($tesseract) + . ' ' . escapeshellarg($absolutePath) + . ' stdout -l ' . escapeshellarg($language) + . ' 2>/dev/null'; + + $output = shell_exec($command); + $text = $this->normalizeText(is_string($output) ? $output : ''); + + if ($text === '') { + $this->updateOcrMetadata($documento, ['status' => 'empty', 'engine' => 'tesseract', 'language' => $language]); + return ['status' => 'error', 'message' => 'OCR immagine vuota']; + } + + $update = []; + if (Schema::hasColumn('documenti', 'contenuto_ocr')) { + $update['contenuto_ocr'] = $text; + } + if (Schema::hasColumn('documenti', 'testo_estratto_ocr')) { + $update['testo_estratto_ocr'] = mb_substr($text, 0, 20000); + } + if (Schema::hasColumn('documenti', 'metadati_ocr')) { + $update['metadati_ocr'] = array_merge((array) ($documento->metadati_ocr ?? []), [ + 'status' => 'ok', + 'engine' => 'tesseract', + 'language' => $language, + 'extracted_at' => now()->toIso8601String(), + ]); + } + + if ($update === []) { + return ['status' => 'error', 'message' => 'Schema documenti senza colonne OCR/testo']; + } + + $documento->forceFill($update)->save(); + + return ['status' => 'ok', 'chars' => mb_strlen($text), 'engine' => 'tesseract']; + } + + private function resolveImagePath(Documento $documento): ?string + { + $candidate = null; + + if (isset($documento->percorso_file) && is_string($documento->percorso_file) && $documento->percorso_file !== '') { + $candidate = $documento->percorso_file; + } elseif (isset($documento->path_file) && is_string($documento->path_file) && $documento->path_file !== '') { + $candidate = $documento->path_file; + } + + if (! is_string($candidate) || trim($candidate) === '') { + return null; + } + + $candidate = trim($candidate); + $lower = strtolower($candidate); + foreach (['.png', '.jpg', '.jpeg', '.webp', '.tif', '.tiff', '.bmp'] as $extension) { + if (str_ends_with($lower, $extension)) { + return $candidate; + } + } + + return null; + } + + private function findBinary(string $name): ?string + { + $path = shell_exec('command -v ' . escapeshellarg($name) . ' 2>/dev/null'); + $path = is_string($path) ? trim($path) : ''; + return $path !== '' ? $path : null; + } + + private function normalizeText(string $text): string + { + $text = str_replace("\r\n", "\n", $text); + $text = str_replace("\r", "\n", $text); + $text = str_replace("\0", '', $text); + + if (! mb_check_encoding($text, 'UTF-8')) { + $text = mb_convert_encoding($text, 'UTF-8'); + } + + return trim($text); + } + + private function updateOcrMetadata(Documento $documento, array $metadata): void + { + if (! Schema::hasColumn('documenti', 'metadati_ocr')) { + return; + } + + $documento->forceFill([ + 'metadati_ocr' => array_merge((array) ($documento->metadati_ocr ?? []), $metadata), + ])->save(); + } +} diff --git a/app/Services/Import/MultiYearGesconImportService.php b/app/Services/Import/MultiYearGesconImportService.php index 659486a..4cc3216 100644 --- a/app/Services/Import/MultiYearGesconImportService.php +++ b/app/Services/Import/MultiYearGesconImportService.php @@ -1,148 +1,4 @@ -# MIGRAZIONE ARCHITETTURA CONTABILE MULTI-ANNO - -## ๐ŸŽฏ PROBLEMI IDENTIFICATI - -### 1. **Collisione Dati Multi-Anno** -``` -PROBLEMA: Import 2024 + 2025 โ†’ stesso database senza separazione -SOLUZIONE: Campo `gestione_id` + `anno_gestione` in tutte le tabelle -``` - -### 2. **Mancanza Continuitร  Contabile** -``` -PROBLEMA: Dati non passano alla gestione successiva -SOLUZIONE: Bilancio apertura + scritture partita doppia + saldi portati -``` - -### 3. **Gestioni Separate Non Collegate** -``` -PROBLEMA: O/R/S gestite separatamente โ†’ perdita visione d'insieme -SOLUZIONE: Gestione unificata con campo tipo_gestione -``` - -### 4. **Mancanza Tracciabilitร  Fiscale** -``` -PROBLEMA: Ritenute d'acconto disperse in codici -SOLUZIONE: Registro RA dedicato + collegamento operazioni -``` - -## ๐Ÿ—๏ธ NUOVA ARCHITETTURA PROPOSTA - -### **1. Gestioni Contabili (Master)** -```sql -CREATE TABLE gestioni_contabili ( - id BIGINT PRIMARY KEY, - tenant_id VARCHAR(50), - anno_gestione INTEGER, - tipo_gestione ENUM('ordinaria', 'riscaldamento', 'straordinaria'), - numero_straordinaria INTEGER NULL, -- per N_STRA - denominazione VARCHAR(200), - data_inizio DATE, - data_fine DATE, - stato ENUM('aperta', 'chiusa', 'consolidata'), - bilancio_apertura JSON, -- saldi iniziali - protocollo_prefix VARCHAR(10), -- O2024, R2024, S2024-001 - INDEX(tenant_id, anno_gestione, tipo_gestione) -); -``` - -### **2. Operazioni Contabili (Unificata)** -```sql -ALTER TABLE operazioni_contabili ADD COLUMN ( - gestione_id BIGINT REFERENCES gestioni_contabili(id), - compet ENUM('C', 'P') DEFAULT 'C', - natura2 VARCHAR(10), -- per partita doppia - n_stra INTEGER NULL, -- collegamento straordinarie - protocollo_numero INTEGER, -- auto-increment per gestione - protocollo_completo VARCHAR(50), -- O2024-001, S2024-003-015 - - -- Tracciabilitร  modifiche - voce_spesa_snapshot JSON, -- snapshot voce al momento registrazione - modificato_da_user_id BIGINT NULL, - modificato_il TIMESTAMP NULL, - motivo_modifica TEXT NULL, - - -- Collegamento fiscale - ritenuta_acconto_id BIGINT NULL, - fattura_elettronica_id BIGINT NULL, - - INDEX(gestione_id, protocollo_numero), - INDEX(tenant_id, compet), - INDEX(natura2) -); -``` - -### **3. Registro Ritenute d'Acconto** -```sql -CREATE TABLE registro_ritenute_acconto ( - id BIGINT PRIMARY KEY, - tenant_id VARCHAR(50), - gestione_id BIGINT REFERENCES gestioni_contabili(id), - - -- Dati ritenuta - numero_progressivo INTEGER, - data_competenza DATE, - fornitore_id BIGINT, - operazione_id BIGINT, -- collegamento operazione che ha generato RA - - -- Importi - imponibile DECIMAL(15,4), - aliquota_ritenuta DECIMAL(5,2), - importo_ritenuta DECIMAL(15,4), - - -- Codici fiscali - codice_tributo VARCHAR(10), - rif_rda VARCHAR(50), -- riferimento Gescon - - -- Versamento - stato_versamento ENUM('da_versare', 'versata', 'compensata'), - data_versamento DATE NULL, - f24_riferimento VARCHAR(100) NULL, - - -- Comunicazioni - comunicazione_fornitore_inviata BOOLEAN DEFAULT FALSE, - data_comunicazione_fornitore DATE NULL, - - INDEX(gestione_id, numero_progressivo), - INDEX(fornitore_id, data_competenza), - INDEX(stato_versamento) -); -``` - -### **4. Incassi da Estratto Conto** -```sql -CREATE TABLE incassi_estratto_conto ( - id BIGINT PRIMARY KEY, - tenant_id VARCHAR(50), - gestione_id BIGINT REFERENCES gestioni_contabili(id), - - -- Dati Gescon Inc_da_ec - id_gescon INTEGER, - data_operazione DATE, - data_valuta DATE, - importo DECIMAL(15,4), - descrizione TEXT, - - -- Collegamento - incasso_id BIGINT NULL REFERENCES incassi(id), - movimento_bancario_id BIGINT NULL, - - -- Stato riconciliazione - stato ENUM('da_riconciliare', 'riconciliato', 'anomalo'), - note_riconciliazione TEXT, - - INDEX(gestione_id, data_operazione), - INDEX(stato) -); -``` - -## ๐Ÿ“Š ESTRATTI E IMPORT MULTIPLI DATABASE - -Invece di CSV, creiamo connessioni dirette ai file MDB: - - - -queryMdb($mdbPath, "SELECT * FROM {$table} ORDER BY data_em"); - - foreach ($rate as $rata) { - try { - // TODO: Import rate in tabella dedicata - $imported++; - } catch (\Exception $e) { - Log::error("Error importing rata", [ - 'table' => $table, - 'rata' => $rata, - 'error' => $e->getMessage() - ]); - } - } - } - - return $imported; - } - /** * Costruisce WHERE clause per filtrare per gestione */ @@ -566,23 +393,6 @@ private function buildGestioneJoinClause(GestioneContabile $gestione, string $al return "1=1"; } } - ]); - - // Gestione Riscaldamento - $this->gestioni[$year]['riscaldamento'] = GestioneContabile::create([ - 'tenant_id' => $this->tenantId, - 'anno_gestione' => (int)$year, - 'tipo_gestione' => 'riscaldamento', - 'denominazione' => "Gestione Riscaldamento {$year}", - 'data_inizio' => "{$year}-01-01", - 'data_fine' => "{$year}-12-31", - 'stato' => 'aperta', - 'protocollo_prefix' => "R{$year}" - ]); - - // Le straordinarie verranno create dinamicamente - } - } /** * Import dati per una specifica gestione/anno @@ -925,6 +735,3 @@ private function parseGesconDate($dateValue): ?string return null; } } - - // Altri metodi helper... -} diff --git a/app/Services/Stabili/StabileTransferService.php b/app/Services/Stabili/StabileTransferService.php index 25eece2..a13696b 100644 --- a/app/Services/Stabili/StabileTransferService.php +++ b/app/Services/Stabili/StabileTransferService.php @@ -1,5 +1,4 @@ amministratore_id ?? 0); - $toId = (int) $destination->id; + $toId = (int) $destination->id; if ($fromId === $toId) { throw new \InvalidArgumentException('Lo stabile e` gia` assegnato a questo amministratore.'); @@ -45,17 +44,17 @@ public function transfer( } return StabileAmministratoreTransfer::query()->create([ - 'stabile_id' => (int) $stabile->id, + 'stabile_id' => (int) $stabile->id, 'from_amministratore_id' => $fromId > 0 ? $fromId : null, - 'to_amministratore_id' => $toId, - 'changed_by_user_id' => $actor?->id, - 'changed_by_name' => $actor?->name ?: 'Sistema', - 'source' => $source, - 'reason' => $reason !== null ? trim($reason) : null, - 'also_rubrica' => $alsoRubrica, - 'ip_address' => $ipAddress, - 'meta' => $meta, + 'to_amministratore_id' => $toId, + 'changed_by_user_id' => $actor?->id, + 'changed_by_name' => $actor?->name ?: 'Sistema', + 'source' => $source, + 'reason' => $reason !== null ? trim($reason) : null, + 'also_rubrica' => $alsoRubrica, + 'ip_address' => $ipAddress, + 'meta' => $meta, ]); }); } -} \ No newline at end of file +} diff --git a/app/Support/Livewire/SupportsRubricaLookup.php b/app/Support/Livewire/SupportsRubricaLookup.php new file mode 100644 index 0000000..2612edf --- /dev/null +++ b/app/Support/Livewire/SupportsRubricaLookup.php @@ -0,0 +1,134 @@ +value; + } + + if (! is_scalar($value) && $value !== null) { + return ''; + } + + return trim((string) ($value ?? '')); + } + + protected function normalizeRubricaLookupId($value): ?int + { + $normalized = $this->normalizeRubricaLookupText($value); + if ($normalized === '' || ! is_numeric($normalized)) { + return null; + } + + $id = (int) $normalized; + return $id > 0 ? $id : null; + } + + protected function formatRubricaLookupLabel(?RubricaUniversale $rubrica): string + { + if (! $rubrica instanceof RubricaUniversale) { + return ''; + } + + $label = trim((string) ($rubrica->ragione_sociale ?: $rubrica->nome_completo)); + return $label !== '' ? $label : ('Rubrica #' . (int) $rubrica->id); + } + + /** + * Mappa una riga rubrica in un payload leggero e riusabile nelle dropdown live. + * + * @return array + */ + protected function mapRubricaLookupRow(RubricaUniversale $rubrica): array + { + $phone = trim((string) ($rubrica->telefono_ufficio ?: $rubrica->telefono_cellulare ?: $rubrica->telefono_casa)); + + return [ + 'id' => (int) $rubrica->id, + 'label' => $this->formatRubricaLookupLabel($rubrica), + 'email' => trim((string) ($rubrica->email ?? '')), + 'phone' => $phone, + 'category' => (string) ($rubrica->categoria ?? ''), + 'note' => Str::limit(trim((string) ($rubrica->note ?? '')), 100), + ]; + } + + /** + * Ricerca libera su rubrica con deduplica label e fallback sulla selezione corrente. + * + * @param array $categories + * @return array> + */ + protected function searchRubricaLookupMatches(string $raw, ?int $selectedId = null, array $categories = ['assicurazione', 'fornitore', 'altro'], int $limit = 12): array + { + $raw = $this->normalizeRubricaLookupText($raw); + + if ($raw === '') { + if (($selectedId ?? 0) > 0) { + $selected = RubricaUniversale::query()->find($selectedId); + return $selected instanceof RubricaUniversale ? [$this->mapRubricaLookupRow($selected)] : []; + } + + return []; + } + + $needle = '%' . mb_strtolower($raw) . '%'; + $digits = preg_replace('/\D+/', '', $raw) ?: ''; + $seen = []; + $rows = []; + + $matches = RubricaUniversale::query() + ->whereNull('deleted_at') + ->whereIn('categoria', $categories) + ->where(function ($query) use ($needle, $digits): void { + $query->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needle]) + ->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needle]) + ->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needle]) + ->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needle]) + ->orWhereRaw("LOWER(COALESCE(note, '')) LIKE ?", [$needle]); + + if ($digits !== '') { + $query->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", ['%' . $digits . '%']) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", ['%' . $digits . '%']); + } + }) + ->orderBy('ragione_sociale') + ->orderBy('cognome') + ->orderBy('nome') + ->limit(80) + ->get(); + + foreach ($matches as $rubrica) { + $row = $this->mapRubricaLookupRow($rubrica); + $key = mb_strtolower(trim((string) $row['label'])); + + if ($key === '' || isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $rows[] = $row; + + if (count($rows) >= $limit) { + break; + } + } + + return $rows; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index e68fd08..8e2182e 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,7 @@ use App\Console\Commands\NetgesconPreupdateBackupCommand; use App\Console\Commands\NetgesconQaUnitaNominativiCommand; use App\Console\Commands\NetgesconRestoreRecordCommand; +use App\Console\Commands\PanasonicCstaBridgeCommand; use App\Console\Commands\StabiliTransferCommand; use App\Console\Commands\SyncSoggettiToPersone; use Illuminate\Foundation\Application; @@ -58,6 +59,7 @@ NetgesconDistributionPullCommand::class, NetgesconPreupdateBackupCommand::class, NetgesconRestoreRecordCommand::class, + PanasonicCstaBridgeCommand::class, GoogleSyncRubricaContactsCommand::class, GooglePushRubricaContactsCommand::class, GoogleImportKeepNotesCommand::class, diff --git a/config/cti.php b/config/cti.php new file mode 100644 index 0000000..6a4c513 --- /dev/null +++ b/config/cti.php @@ -0,0 +1,14 @@ + env('NETGESCON_CTI_SHARED_TOKEN', ''), + 'legacy_tokens' => [ + env('CTI_SHARED_SECRET', ''), + env('CTI_SHARED_TOKEN', ''), + env('CTI_TOKEN', ''), + env('CTI_PANASONIC_TOKEN', ''), + ], + 'mirror_enabled' => (bool) env('NETGESCON_CTI_MIRROR_ENABLED', false), + 'mirror_base_url' => env('NETGESCON_CTI_MIRROR_BASE_URL', ''), + 'mirror_timeout_seconds' => (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2), +]; \ No newline at end of file diff --git a/config/distribution.php b/config/distribution.php index bcaebec..8dac594 100644 --- a/config/distribution.php +++ b/config/distribution.php @@ -2,27 +2,27 @@ return [ // Base URL del server che distribuisce i pacchetti di aggiornamento. - 'update_url' => env('NETGESCON_UPDATE_URL', ''), + 'update_url' => env('NETGESCON_UPDATE_URL', ''), // Canale di default per il nodo (free o licensed). - 'default_channel' => env('NETGESCON_UPDATE_CHANNEL', 'free'), + 'default_channel' => env('NETGESCON_UPDATE_CHANNEL', 'free'), // Identificativo nodo opzionale, utile per audit lato server update. - 'node_code' => env('NETGESCON_NODE_CODE', ''), + 'node_code' => env('NETGESCON_NODE_CODE', ''), // Credenziali lato client per canale licensed. - 'licensed_code' => env('NETGESCON_LICENSED_CODE', ''), - 'licensed_auth' => env('NETGESCON_LICENSED_AUTH', ''), + 'licensed_code' => env('NETGESCON_LICENSED_CODE', ''), + 'licensed_auth' => env('NETGESCON_LICENSED_AUTH', ''), // Credenziali lato server: lista "code:auth" separata da virgola. // Esempio: NODEA:abc123,NODEB:def456 - 'licensed_pairs' => env('NETGESCON_LICENSED_PAIRS', ''), + 'licensed_pairs' => env('NETGESCON_LICENSED_PAIRS', ''), // Percorso locale dove il server update espone manifest e pacchetti. - 'storage_root' => storage_path('app/distribution'), + 'storage_root' => storage_path('app/distribution'), // Timeout client per check/download. - 'http_timeout_seconds' => (int) env('NETGESCON_UPDATE_HTTP_TIMEOUT', 60), + 'http_timeout_seconds' => (int) env('NETGESCON_UPDATE_HTTP_TIMEOUT', 60), // Backup pre-update: abilita upload su Google Drive solo se richiesto dal nodo. 'preupdate_drive_enabled' => (bool) env('NETGESCON_PREUPDATE_DRIVE_ENABLED', false), @@ -32,5 +32,5 @@ // Override opzionale DNS per richieste update (formato CSV host:port:ip). // Esempio: updates.netgescon.it:443:192.168.0.53 - 'http_resolve' => env('NETGESCON_UPDATE_RESOLVE', ''), + 'http_resolve' => env('NETGESCON_UPDATE_RESOLVE', ''), ]; diff --git a/database/migrations/2026_03_20_160000_create_stabile_amministratore_transfers_table.php b/database/migrations/2026_03_20_160000_create_stabile_amministratore_transfers_table.php index b796cd4..fd84b09 100644 --- a/database/migrations/2026_03_20_160000_create_stabile_amministratore_transfers_table.php +++ b/database/migrations/2026_03_20_160000_create_stabile_amministratore_transfers_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { if (Schema::hasTable('stabile_amministratore_transfers')) { @@ -34,4 +35,4 @@ public function down(): void { Schema::dropIfExists('stabile_amministratore_transfers'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_03_21_101000_create_insurance_policies_table.php b/database/migrations/2026_03_21_101000_create_insurance_policies_table.php index 2cc0ea6..20357b6 100644 --- a/database/migrations/2026_03_21_101000_create_insurance_policies_table.php +++ b/database/migrations/2026_03_21_101000_create_insurance_policies_table.php @@ -63,4 +63,4 @@ public function down(): void Schema::dropIfExists('insurance_policies'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_03_21_102000_extend_stabile_servizio_letture_for_water_module.php b/database/migrations/2026_03_21_102000_extend_stabile_servizio_letture_for_water_module.php index 07ba0f6..039e68d 100644 --- a/database/migrations/2026_03_21_102000_extend_stabile_servizio_letture_for_water_module.php +++ b/database/migrations/2026_03_21_102000_extend_stabile_servizio_letture_for_water_module.php @@ -85,4 +85,4 @@ public function down(): void } }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_03_23_160000_add_archive_reference_and_visibility_to_documenti_table.php b/database/migrations/2026_03_23_160000_add_archive_reference_and_visibility_to_documenti_table.php new file mode 100644 index 0000000..0ef9122 --- /dev/null +++ b/database/migrations/2026_03_23_160000_add_archive_reference_and_visibility_to_documenti_table.php @@ -0,0 +1,74 @@ +unsignedBigInteger('dimensione_file')->nullable()->after('numero_pagine'); + } + + if (! Schema::hasColumn('documenti', 'hash_file')) { + $table->string('hash_file', 64)->nullable()->after('dimensione_file'); + } + + if (! Schema::hasColumn('documenti', 'xml_data')) { + $table->json('xml_data')->nullable()->after('hash_file'); + } + + if (! Schema::hasColumn('documenti', 'archive_reference')) { + $table->string('archive_reference', 100)->nullable()->after('numero_protocollo'); + } + + if (! Schema::hasColumn('documenti', 'visibility_scope')) { + $table->string('visibility_scope', 50)->default('interno')->after('metadati_ocr'); + } + + if (! Schema::hasColumn('documenti', 'visibility_groups')) { + $table->json('visibility_groups')->nullable()->after('visibility_scope'); + } + + if (! Schema::hasColumn('documenti', 'visibility_roles')) { + $table->json('visibility_roles')->nullable()->after('visibility_groups'); + } + }); + + Schema::table('documenti', function (Blueprint $table): void { + $indexNames = collect((array) \DB::select('SHOW INDEX FROM documenti'))->pluck('Key_name'); + + if (! $indexNames->contains('documenti_archive_reference_unique')) { + $table->unique('archive_reference', 'documenti_archive_reference_unique'); + } + + if (! $indexNames->contains('documenti_visibility_scope_index')) { + $table->index('visibility_scope', 'documenti_visibility_scope_index'); + } + }); + } + + public function down(): void + { + Schema::table('documenti', function (Blueprint $table): void { + if (Schema::hasColumn('documenti', 'archive_reference')) { + $table->dropUnique('documenti_archive_reference_unique'); + } + + if (Schema::hasColumn('documenti', 'visibility_scope')) { + $table->dropIndex('documenti_visibility_scope_index'); + } + }); + + Schema::table('documenti', function (Blueprint $table): void { + foreach (['visibility_roles', 'visibility_groups', 'visibility_scope', 'archive_reference', 'xml_data', 'hash_file', 'dimensione_file'] as $column) { + if (Schema::hasColumn('documenti', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/docs/00-STRATEGIA-GIT-DISTRIBUZIONE.md b/docs/00-STRATEGIA-GIT-DISTRIBUZIONE.md index a6b8dda..277ea43 100755 --- a/docs/00-STRATEGIA-GIT-DISTRIBUZIONE.md +++ b/docs/00-STRATEGIA-GIT-DISTRIBUZIONE.md @@ -17,6 +17,21 @@ ## Nota Day0 In caso di conflitto prevalgono sempre le regole Day0 e la policy [docs/DOCS-AND-WORKSPACE-POLICY-DAY0.md](/home/michele/netgescon/netgescon-day0/docs/DOCS-AND-WORKSPACE-POLICY-DAY0.md). +## Stato operativo reale 2026 + +Per l'operativita corrente e confermato questo assetto: + +- repository attivo unico: `netgescon-day0` +- remoto Git autorevole: `origin -> ssh://git@192.168.0.53:2222/michele/netgescon-day0.git` +- branch corrente di lavoro/distribuzione: `main` +- flusso reale: sviluppo locale -> commit Git -> push su Gitea -> allineamento staging e altri siti + +Quindi: + +- Gitea interno e gia il punto centrale del flusso codice +- non e corretto descrivere come standard il deploy diretto macchina locale -> staging +- la parte multi-canale `free/licensed` resta predisposizione tecnica, non il modello operativo gia attivo + --- ## ๐Ÿ—๏ธ **ARCHITETTURA GIT DISTRIBUITA** @@ -29,9 +44,11 @@ ### ๐ŸŽฏ **FILOSOFIA SISTEMA** - **Plugin system** per estensibilitร  - **Licenza A-GPL** per protezione IP +Nota: i blocchi successivi che descrivono ecosistemi multi-repository, GitHub pubblico, APT o marketplace sono da leggere come direzione architetturale possibile, non come fotografia dell'operativita corrente. + ### ๐ŸŒ **STRUTTURA GIT MULTI-LIVELLO** -``` +```text ๐Ÿข NETGESCON GIT ECOSYSTEM โ”œโ”€โ”€ ๐Ÿ”’ Git Server Interno (MASTER) โ”‚ โ”œโ”€โ”€ netgescon-core.git # Core applicazione @@ -54,20 +71,18 @@ ### ๐ŸŒ **STRUTTURA GIT MULTI-LIVELLO** ## โœ… **STATUS IMPLEMENTAZIONE** -### ๐ŸŽฏ **COMPLETATO** +### ๐ŸŽฏ **COMPLETATO / VERIFICATO OGGI** -- โœ… Repository Git inizializzato con commit iniziale -- โœ… Struttura branches creata: master, development, release, hotfix -- โœ… Scripts di automazione Git workflow -- โœ… Sistema di packaging e distribuzione -- โœ… Licenza A-GPL definita -- โœ… Plugin system progettato +- โœ… Repository attivo individuato: `netgescon-day0` +- โœ… Remote Gitea verificato su `origin` +- โœ… Branch attivo verificato: `main` +- โœ… Flusso reale confermato: commit e push su Gitea prima di aggiornare staging ### ๐Ÿ”„ **IN CORSO** -- ๐Ÿšง Setup Git server (Gitea) su macchina master -- ๐Ÿšง Configurazione domini: git.netgescon.it -- ๐Ÿšง Sistema distribuzione pacchetti +- ๐Ÿšง Normalizzazione della documentazione al flusso Git/Gitea reale +- ๐Ÿšง Formalizzazione del meccanismo di allineamento staging dal repository Gitea +- ๐Ÿšง Scelta definitiva del modello di canali distribuzione/licensing --- @@ -114,7 +129,7 @@ ### ๐Ÿ—‚๏ธ **Repository Structure** #### ๐Ÿ“ฆ **netgescon-core.git** -``` +```text netgescon-core/ โ”œโ”€โ”€ app/ # Laravel application โ”œโ”€โ”€ database/ # Migrations & seeds @@ -129,7 +144,7 @@ #### ๐Ÿ“ฆ **netgescon-core.git** #### ๐Ÿ”Œ **netgescon-plugins.git** -``` +```text netgescon-plugins/ โ”œโ”€โ”€ core-plugins/ # Plugin essenziali โ”‚ โ”œโ”€โ”€ fatturazione-enhanced/ @@ -143,7 +158,7 @@ #### ๐Ÿ”Œ **netgescon-plugins.git** #### ๐ŸŽจ **netgescon-themes.git** -``` +```text netgescon-themes/ โ”œโ”€โ”€ default/ # Tema di default โ”œโ”€โ”€ modern-ui/ # Tema moderno @@ -159,6 +174,18 @@ ## ๐Ÿ“‹ **WORKFLOW DI SVILUPPO** ### ๐Ÿ”„ **Processo di Sviluppo Interno** +Flusso effettivo attuale: + +```bash +cd /home/michele/netgescon/netgescon-day0 +git status +git add ... +git commit -m "messaggio coerente" +git push origin main +``` + +Poi il nodo staging deve allinearsi dal repository Gitea pubblicato. + #### 1๏ธโƒฃ **Sviluppo Locale (Michele + AI)** ```bash @@ -205,7 +232,7 @@ # Trigger deploy automatico ### ๐Ÿš€ **Branching Strategy** -``` +```text main # Produzione stabile โ”œโ”€โ”€ develop # Sviluppo attivo โ”œโ”€โ”€ feature/* # Nuove funzionalitร  @@ -213,6 +240,8 @@ ### ๐Ÿš€ **Branching Strategy** โ””โ”€โ”€ release/* # Preparazione release ``` +Nota pratica: oggi il ramo realmente usato e verificato e `main`. Gli altri rami restano una strategia disponibile ma non vanno documentati come obbligatori se non sono parte del flusso in uso. + --- ## ๐Ÿ“ฆ **SISTEMA DISTRIBUZIONE AUTOMATICA** @@ -410,7 +439,7 @@ #### ๐Ÿ“‹ **Implementazione Licenza** #### ๐Ÿ’ผ **Modello Business A-GPL** -``` +```text ๐Ÿ†“ COMMUNITY EDITION (A-GPL) โ”œโ”€โ”€ โœ… Core completo โ”œโ”€โ”€ โœ… Plugin base diff --git a/docs/000-FILAMENT/07-DISTRIBUZIONE-UPDATE.md b/docs/000-FILAMENT/07-DISTRIBUZIONE-UPDATE.md index 6a6ad88..5e3c2e4 100644 --- a/docs/000-FILAMENT/07-DISTRIBUZIONE-UPDATE.md +++ b/docs/000-FILAMENT/07-DISTRIBUZIONE-UPDATE.md @@ -1,104 +1,87 @@ -# Distribuzione e aggiornamenti (Open Source) +# Distribuzione e aggiornamenti ## Obiettivo -Linee guida per pubblicare NetGescon e distribuire aggiornamenti, con focus su Docker. -## Specifiche demo online -- Dominio: `demo.netgescon.it` (o `.org`) -- TLS: certificato Letโ€™s Encrypt -- Server consigliato: 2 vCPU / 4 GB RAM / 40+ GB SSD -- Porta HTTP: 80 โ†’ Nginx -- Porta HTTPS: 443 โ†’ Nginx -- Stack: Docker Engine + Docker Compose +Allineare NetGescon a un flusso di distribuzione reale, verificabile e ripetibile. -## Setup online (end-to-end) -- Guida completa: `docs/000-FILAMENT/10-ONLINE-SETUP.md` -- Compose online (Traefik + app): `docker/docker-compose.online.yml` -- Env online: `.env.online.example` +Stato operativo attuale: -## Opzione consigliata: Docker + Versioning -### Struttura immagini -- `netgescon/app` โ†’ PHP + Laravel + Filament -- `netgescon/nginx` โ†’ Nginx -- `netgescon/mysql` โ†’ DB (opzionale per demo) +- sviluppo nel repository `netgescon-day0` +- versionamento e condivisione su Gitea interno +- staging e nodi aggiornati partendo da Gitea +- Docker, APT, immagini pubbliche e canali commerciali restano scenari possibili ma non il percorso principale di oggi -### Versionamento -- Tag immagine: `vX.Y.Z` -- `latest` solo per preview -- Release notes su GitHub +## Flusso reale da usare oggi -### Aggiornamento -- Pull nuova immagine -- `php artisan migrate --force` -- `php artisan optimize:clear` +1. sviluppo e test nel workspace locale `netgescon-day0` +2. commit Git locali +3. push verso `origin` su Gitea interno +4. aggiornamento di staging allineando il repository pubblicato su Gitea +5. aggiornamento degli altri siti con lo stesso meccanismo -## Docker (distribuzione pronta) -- Dockerfile: `docker/Dockerfile.prod` -- Compose: `docker/docker-compose.prod.yml` -- Compose demo: `docker/docker-compose.demo.yml` -- Nginx: `docker/nginx/default.conf` -- Init DB staging: `docker/mysql/init.sql` +Regola chiave: -### Installazione base -1. Configura file `.env` per DB e APP_URL. -2. Avvia: - - `docker compose -f docker/docker-compose.prod.yml up -d` +- evitare come flusso ordinario il deploy manuale diretto di file o patch dalla macchina di sviluppo verso staging +- la macchina di staging deve essere riallineata dal repository Gitea -### DNS (esempio) -- `A` record โ†’ IP del server -- `CNAME` (opzionale) โ†’ `demo` โ†’ root +## Stato canali distribuzione -### TLS (Nginx esterno) -- Termina TLS su reverse proxy (Nginx/Traefik) davanti al container -- Forward verso `http://127.0.0.1:8080` +- infrastruttura `free` / `licensed` gia prevista nel codice +- uso reale attuale: una sola linea di distribuzione +- il nome del canale tecnico presente nelle config non equivale ancora a un modello commerciale consolidato -### Demo online -- Avvio demo: `scripts/deploy-demo.sh` -- Compose demo: `docker/docker-compose.demo.yml` +## Gitea interno -### Aggiornamento autonomo -- Script: `scripts/update-docker.sh` -- Azioni: pull immagine โ†’ restart โ†’ migrate โ†’ clear cache +- remote principale attuale: `ssh://git@192.168.0.53:2222/michele/netgescon-day0.git` +- branch operativo corrente: `main` +- Gitea e il punto di passaggio obbligato per distribuire il codice sviluppato -## Database e dati -- Volume persistente per DB -- Volume persistente per storage -- Backup obbligatorio prima di update +## Procedura minima di aggiornamento -## Strategie aggiornamento -1) **Rolling update** (prod) -2) **Blue/Green** (consigliata per installazioni grandi) +1. verificare modifiche e contesto con `git status` +2. creare commit coerenti +3. fare push su `origin` +4. documentare il cambiamento se tocca flussi operativi o distribuzione +5. aggiornare staging tramite pull o meccanismo equivalente dal repository Gitea +6. eseguire solo sul nodo aggiornato le normali operazioni Laravel: cache clear, eventuali migrate, check salute -## Distribuzione archivi legacy -- Non includere in immagini -- Montare `/mnt/gescon-archives/gescon` come volume read-only +## Cosa non considerare flusso principale -## Checklist update -- backup DB -- backup storage -- pull immagine -- migrate -- clear cache -- health check +- pubblicazione GitHub pubblica +- distribuzione Docker come percorso standard di tutti i siti +- repository multipli separati per core/plugin/theme come assetto gia in esercizio +- differenziazione commerciale gia attiva tra `free` e `licensed` -## Backup pre-update applicativo -- Il backup locale pre-update resta sempre obbligatorio. -- Upload Google Drive opzionale per nodo tramite `NETGESCON_PREUPDATE_DRIVE_ENABLED=true`. -- Blocco hard dell update se Drive fallisce tramite `NETGESCON_PREUPDATE_REQUIRE_DRIVE=true`. -- Per staging e ambienti di test remoti si puo lasciare entrambi i flag a `false` e usare solo il backup locale. +## Scenari futuri, non prioritari -## Note d'aggiornamento -- File: `CHANGELOG.md` -- Versione: `VERSION` +- server update dedicato +- pacchettizzazione release +- canali distinti di licensing +- distribuzione containerizzata piu automatica -## Materiale Open Source minimo -- `README.md` con setup e run -- `CONTRIBUTING.md` con workflow PR e standard -- `CHANGELOG.md` -- `LICENSE` -- Documentazione in `docs/` aggiornata +Questi scenari possono restare documentati, ma devono essere marcati come successivi rispetto al flusso Git/Gitea attuale. -## Note sicurezza -- Non pubblicare credenziali -- Configurare `.env` esterno -- Permessi minimi sui volumi +## Checklist pratica attuale + +- repository corretto: `netgescon-day0` +- remote corretto: `origin` su Gitea interno +- branch corretto: `main`, salvo eccezioni deliberate +- documentazione aggiornata insieme alle modifiche di processo +- staging allineato dal repository, non da copie locali non tracciate + +## Note operative + +- se un ambiente usa ancora script remoti o rsync, va considerato transitorio e da riallineare al flusso Gitea +- prima di formalizzare canali commerciali distinti serve una decisione operativa esplicita +- la documentazione deve sempre distinguere tra stato reale attivo e roadmap futura + +## Appendice tecnica futura + +I seguenti temi restano validi come approfondimenti architetturali futuri, ma non sono il flusso primario corrente: + +- container Docker dedicati +- rolling update o blue/green +- repository pacchetti e update server dedicati +- separazione commerciale avanzata dei canali + +Quando verranno riattivati come percorso principale, andranno documentati in un documento separato o in una revisione successiva di questo file. diff --git a/docs/01-git-machine-blueprint-2026.md b/docs/01-git-machine-blueprint-2026.md index 06e1c70..604efba 100644 --- a/docs/01-git-machine-blueprint-2026.md +++ b/docs/01-git-machine-blueprint-2026.md @@ -1,105 +1,102 @@ # NetGescon - Macchina Git e Distribuzione (Blueprint 2026) -Obiettivo: una piattaforma unica per piu progetti con: +Questo file e un blueprint evolutivo. Non descrive il processo operativo minimo gia in uso oggi, ma la direzione target verso cui convergere senza confondere lo stato attuale del progetto. -- repository Git self-hosted, -- distribuzione aggiornamenti, -- accesso selettivo con scadenza, -- moduli premium con pagamento. +## 1. Stato reale attuale -## Scelta consigliata +Oggi la situazione concreta e questa: -Stack consigliato: +- esiste un workspace autorevole Day0; +- il repository sorgente canonico e Gitea; +- il branch operativo e `main`; +- staging deve ricevere gli aggiornamenti partendo da cio che e stato prima pubblicato su Gitea; +- il motore update Laravel esiste gia ed e utilizzabile anche da browser; +- la distinzione `free` e `licensed` e tecnica e preparatoria, non un prodotto commerciale formalizzato. -- Forgejo (o Gitea) per Git, issue, release e accessi team. -- API Update/Licensing separata (Laravel) per manifest, package, code/auth e policy licenza. -- MinIO per storage pacchetti release e asset aggiornamenti. -- Reverse proxy (Caddy o Nginx) con TLS e domini separati. -- Keycloak opzionale se vuoi SSO enterprise multi-progetto. +Per il flusso operativo quotidiano fare riferimento prima alla documentazione Day0/Gitea e ai runbook di distribuzione, non a questo blueprint. -## Perche Forgejo/Gitea +## 2. Obiettivo del blueprint -1. Leggero rispetto a GitLab e facile da mantenere. -2. Multi-progetto nativo con org/team/permessi granulari. -3. Runner CI disponibili per pipeline build package. -4. Facile integrazione con API esterne per licenze e billing. +L obiettivo 2026 resta costruire una piattaforma unica per piu progetti con: -## Architettura consigliata +- repository Git self-hosted; +- distribuzione aggiornamenti centralizzata; +- controllo accessi e abilitazioni per nodo; +- moduli opzionali e, se necessario in futuro, gestione commerciale separata. -- `git.netgescon.it`: Forgejo UI/API/SSH. -- `updates.netgescon.it`: API update/licensing (manifest/package/license). -- `storage.netgescon.it`: MinIO S3 (privato, firmato). -- `registry.netgescon.it` opzionale: container registry. +## 3. Stack consigliato -## Modello accessi aggiornamenti +Stack consigliato per l evoluzione: -Canali: +- Gitea o Forgejo per Git, issue, release e accessi team; +- API update/licensing separata in Laravel per manifest, package, autorizzazioni e audit; +- storage oggetti dedicato, ad esempio MinIO, per pacchetti release e asset; +- reverse proxy Nginx o Caddy con TLS e domini separati; +- Keycloak solo se emergera una reale esigenza SSO multi-progetto. -- `free`: accesso manifest/package pubblico o con token base. -- `licensed`: accesso con `code+auth` e controlli avanzati. +## 4. Architettura target -Controlli raccomandati: +Separazione logica consigliata: -- scadenza licenza (`expires_at`), -- limite nodi (`max_nodes`), -- modulo abilitato (`modules[]`), -- versione minima consentita (`min_supported`), -- revoca immediata (`revoked_at`). +- `git.netgescon.it`: repository, review, release, chiavi SSH; +- `updates.netgescon.it`: manifest, package, audit applicazione update; +- `storage.netgescon.it`: bucket privati per pacchetti e snapshot pubblicabili; +- `registry.netgescon.it`: opzionale, solo se entrano in gioco immagini container. -## Modello commerciale moduli premium +Questa architettura va letta come destinazione, non come prerequisito immediato per il flusso corrente Day0 -> Gitea -> staging. -Dati minimi backend licenze: +## 5. Modello canali update -- `customers` -- `licenses` -- `license_modules` -- `payments` -- `entitlements_audit` +I canali oggi esistono nel codice per compatibilita tecnica: -Flusso: +- `free`: percorso standard attuale; +- `licensed`: percorso riservato per futuri controlli piu stretti. -1. Cliente acquista piano base o modulo. -2. Payment provider invia webhook. -3. Sistema aggiorna entitlement e scadenza. -4. Nodo client chiama endpoint license-check. -5. Feature gate lato app abilita/disabilita moduli. +Se e quando il modello verra attivato davvero, i controlli consigliati sono: -Provider pagamento consigliati: +- scadenza licenza `expires_at`; +- limite nodi `max_nodes`; +- moduli abilitati `modules[]`; +- versione minima supportata `min_supported`; +- revoca immediata `revoked_at`. -- Stripe (piu rapido per subscription e webhook). -- Paddle se vuoi gestione VAT UE piu assistita. +Fino a quel momento il canale `free` non deve essere comunicato come offerta commerciale finale. -## Sicurezza minima obbligatoria +## 6. Governance minima da mantenere gia ora -1. 2FA obbligatoria per admin Forgejo. -2. Backup giornaliero DB+repository+bucket MinIO. -3. Firma manifest release (Ed25519) e verifica lato client. -4. Logging audit su download package e apply update. -5. Secrets solo da vault/env sicuri, mai nel repo. +Anche prima della piattaforma completa, conviene mantenere da subito queste regole: -## Setup rapido locale +1. Gitea come unica sorgente canonica dei rilasci approvati. +2. Backup giornaliero di repository e snapshot applicativi. +3. Backup pre-update obbligatorio sui nodi sensibili. +4. Seconda copia su Google Drive dove il nodo ha gia integrazione Google disponibile. +5. Separazione netta fra ambiente Day0, staging e futuri siti cliente. -La base Docker e in: +## 7. Evoluzioni consigliate -- `scripts/ops/git-stack/docker-compose.yml` -- `scripts/ops/git-stack/.env.example` +Passi coerenti con il codice gia presente: -Avvio: +1. pipeline Gitea per creare zip release e manifest in modo ripetibile; +2. firma manifest e validazione lato nodo; +3. audit centralizzato di check, download e apply; +4. retention formalizzata dei backup pre-update locali e Drive; +5. eventuale catalogo moduli/licenze solo dopo stabilizzazione del flusso tecnico. -```bash -cd scripts/ops/git-stack -cp .env.example .env -# modifica password, domini e secret +## 8. Tema commerciale -docker compose --env-file .env up -d -``` +Il progetto e predisposto per scenari con moduli o accessi differenziati, ma al momento non bisogna confondere predisposizione tecnica e modello commerciale attivo. -## Roadmap operativa consigliata +Se in futuro servira davvero una gestione commerciale, le entita minime lato backend saranno probabilmente: -1. Porta online Forgejo + TLS + backup. -2. Crea org/progetti NetGescon e branch policy. -3. Attiva API update/licensing su `updates.netgescon.it`. -4. Pubblica primo pacchetto baseline e manifest firmato. -5. Aggiorna staging con `php artisan netgescon:update --apply`. -6. Integra billing moduli premium (Stripe webhook). -7. Estendi stessa piattaforma agli altri progetti. +- `customers`; +- `licenses`; +- `license_modules`; +- `payments`; +- `entitlements_audit`. + +Fino ad allora la priorita resta consolidare il flusso tecnico unico: + +1. sviluppo in Day0; +2. push su Gitea; +3. aggiornamento controllato di staging; +4. solo dopo, eventuale estensione verso altri nodi o clienti. diff --git a/docs/CTI-NS1000-CHECKLIST.md b/docs/CTI-NS1000-CHECKLIST.md index 0c39561..ca9f13b 100644 --- a/docs/CTI-NS1000-CHECKLIST.md +++ b/docs/CTI-NS1000-CHECKLIST.md @@ -5,6 +5,9 @@ ## 1) Endpoint NetGescon da usare - `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=101` +- `POST /api/v1/cti/panasonic/click-to-call/{id}/claim` +- `POST /api/v1/cti/panasonic/click-to-call/{id}/status` Esempio base: @@ -25,6 +28,13 @@ ## 3) Porta 33333 / servizi monitoraggio - Se usata, configurare IP sorgente autorizzati e ACL precise (mai aperta pubblicamente). - Preferire webhook HTTPS verso NetGescon come canale primario e 33333/syslog come canale diagnostico. +## 3-bis) Serve hardware dedicato? + +- No, non e necessario un hardware aggiuntivo dedicato se il PBX espone gia CSTA o un flusso eventi raggiungibile in rete. +- Il bridge/adapter puo girare internamente su Linux come servizio applicativo o daemon dedicato. +- L'hardware aggiuntivo avrebbe senso solo se il centralino imponesse un gateway proprietario esterno o una licenza/modulo fisico non gia presente. +- Per NetGescon la strada consigliata resta: `PBX Panasonic -> servizio Linux adapter CSTA -> API HTTPS NetGescon`. + ## 4) Syslog NS1000 (consigliato) - Abilitare invio syslog remoto dal PBX verso un collector interno. @@ -49,6 +59,44 @@ ## 6) Test rapido post-configurazione 3. Chiudi la chiamata e verifica aggiornamento su endpoint `call-ended`. 4. Controlla log applicativi e, se attivo, log syslog del PBX. +## 7) Mapping operativo CSTA -> NetGescon + +- `Delivered` o evento ring inbound verso interno/studio: + - adapter -> `POST /api/v1/cti/panasonic/incoming` + - campi minimi: `phone`, `event_id`, `called_extension` + - campi consigliati: `event_type=Delivered`, `direction=inbound`, `name`, `note`, `called_at` + - effetto: crea/aggiorna Post-it e scrive anche `communication_messages.channel=panasonic_csta` +- `ConnectionCleared` o fine chiamata: + - adapter -> `POST /api/v1/cti/panasonic/call-ended` + - campi minimi: `event_id` oppure `phone` + - campi consigliati: `event_type=ConnectionCleared`, `duration_seconds`, `outcome`, `ended_at` + - effetto: chiude Post-it e chiude il messaggio CTI corrispondente +- lookup rubrica prima del popup PBX/desktop: + - adapter -> `GET /api/v1/cti/panasonic/lookup?phone=...` +- click-to-call outbound da un interno: + - NetGescon UI registra richiesta in `pbx_click_to_call_requests` + - adapter legge `GET /api/v1/cti/panasonic/click-to-call/pending` + - adapter prende possesso con `POST /claim` + - adapter esegue `MakeCall` lato PBX/CSTA Phase III + - adapter aggiorna esito con `POST /status` + +## 8) Note su routing interno e intercettazione + +- Il routing operatore avviene tramite `users.pbx_extension`. +- Gli eventi inbound Panasonic vengono normalizzati con `target_extension`, `assigned_user_id` e `stabile_id` quando l'interno e associato a un utente NetGescon. +- Il banner operatore mobile legge sia eventi `smdr` sia eventi `panasonic_csta`, quindi l'intercettazione di una chiamata verso un numero/interno del centralino passa dallo stesso flusso applicativo. +- Se SMDR e CSTA sono entrambi attivi, CSTA resta il canale piu ricco per popup e correlate; SMDR resta utile come traccia passiva e fallback. + +Esempio studio corrente: + +- `201` = centralino con segreteria +- `205` = operatore studio +- `206` = amministratore +- `601` = gruppo/modalita giorno +- `603` = gruppo/modalita notte + +Questi valori vanno salvati in `Scheda amministratore -> Centralino studio e interni collaboratori` cosi il layer CTI puo riconoscere sia gli interni personali sia gli alias condivisi del centralino. + ## 7) Opzione interno VOIP monitor - Si puo configurare un interno tecnico di monitoraggio per ascolto/diagnostica, rispettando policy privacy. diff --git a/docs/CTI-WINDOWS-TAPI-PROBE.md b/docs/CTI-WINDOWS-TAPI-PROBE.md new file mode 100644 index 0000000..e54f18a --- /dev/null +++ b/docs/CTI-WINDOWS-TAPI-PROBE.md @@ -0,0 +1,541 @@ +# CTI Windows TAPI - Guida Semplice + +Questa guida serve a fare il primo passo sulla macchina Windows dove hai installato il driver Panasonic TAPI/TSP. + +L'obiettivo non e ancora fare il bridge completo. + +L'obiettivo adesso e capire tre cose, con calma: + +1. il driver TAPI e davvero visibile a Windows +2. quali linee o indirizzi espone +3. la macchina Windows riesce a parlare con NetGescon con il token corretto + +## File da usare + +- `scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1` +- `scripts/ops/windows/test-netgescon-panasonic-api.ps1` + +## Passo 1: apri PowerShell come amministratore + +Sulla macchina Windows: + +1. apri il menu Start +2. cerca `PowerShell` +3. fai click destro +4. scegli `Esegui come amministratore` + +## Passo 2: spostati nella cartella dove hai copiato il repo + +Esempio: + +```powershell +cd C:\netgescon\netgescon-day0 +``` + +## Passo 3: permetti l'esecuzione dello script nella sessione corrente + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +``` + +Questo non cambia in modo permanente la politica della macchina. Vale solo per quella finestra. + +## Passo 4: estrai le informazioni dal driver TAPI + +Prima prova senza filtro: + +```powershell +.\scripts\ops\windows\get-netgescon-panasonic-tapi-info.ps1 -IncludeMembers +``` + +Se il risultato e troppo lungo, prova filtrando gli interni che ci interessano: + +```powershell +.\scripts\ops\windows\get-netgescon-panasonic-tapi-info.ps1 -Filter 205 +.\scripts\ops\windows\get-netgescon-panasonic-tapi-info.ps1 -Filter 206 +.\scripts\ops\windows\get-netgescon-panasonic-tapi-info.ps1 -Filter 201 +``` + +Lo script crea un file JSON, di default: + +```text +netgescon-tapi-info.json +``` + +### Cosa mi devi mandare dopo questo passo + +Mandami una di queste cose: + +1. le righe del riepilogo rapido finali +2. il contenuto del file JSON +3. uno screenshot del riepilogo se ti viene piu comodo + +## Passo 5: verifica che Windows riesca a parlare con NetGescon + +Sostituisci: + +- `https://staging.netgescon.it` con il tuo URL reale, se diverso +- `IL_TUO_TOKEN` con il token CTI reale + +E lancia: + +```powershell +.\scripts\ops\windows\test-netgescon-panasonic-api.ps1 -BaseUrl "https://staging.netgescon.it" -Token "IL_TUO_TOKEN" -Extension "205" +``` + +### Cosa deve succedere nel watcher + +Devi vedere tre blocchi: + +1. `Lookup rubrica` +2. `Simulazione incoming` +3. `Simulazione call-ended` + +Se dentro i JSON compare `ok: true`, allora: + +- il token e giusto +- la macchina Windows raggiunge NetGescon +- gli endpoint sono pronti + +## Se qualcosa fallisce + +### Caso A: errore creando `TAPI.TAPI` + +Significa che il provider COM TAPI non e registrato bene o il driver Panasonic non e installato correttamente. + +### Caso B: trovi TAPI ma zero indirizzi + +Significa che il driver c'e, ma non sta esponendo ancora linee utilizzabili. + +In quel caso di solito bisogna controllare: + +1. configurazione del TSP Panasonic +2. `Check Location` +3. IP PBX e porta `33333` +4. licenza CTI lato centralino + +### Caso C: gli script API falliscono con `401 Unauthorized` + +Significa che il token non coincide con quello configurato in NetGescon. + +Importante: il watcher Windows non e bloccato. Continua a ricevere eventi TAPI, ma gli invii `incoming` e `call-ended` verso staging vengono rifiutati, quindi su NetGescon non compare nulla. + +### Caso D: gli script API falliscono con timeout o errore TLS + +Significa che la macchina Windows non riesce a raggiungere NetGescon su HTTPS. + +## Cosa faremo subito dopo + +Quando mi mandi l'output del primo script, io ti guidero al passo successivo: + +1. capire quali linee TAPI sono quelle giuste +2. decidere se monitorare `201`, `205`, `206` +3. scrivere il probe successivo che ascolta davvero gli eventi chiamata + +Il primo obiettivo adesso non e fare tutto. + +Il primo obiettivo e vedere nero su bianco che il driver Panasonic espone qualcosa che possiamo usare. + +## Risultato atteso corretto + +Se nel JSON o nel riepilogo trovi linee simili a queste, significa che il provider Panasonic sta esponendo davvero gli interni giusti: + +- `EXT00201` +- `EXT00205` +- `EXT00206` +- `GRP00601` +- `GRP00603` + +Se li vedi, il passo successivo e ascoltare gli eventi di chiamata reali. + +## Passo 6: watcher TAPI via polling + +Lo script VBScript di event sink puo fallire con questo errore: + +```text +WScript.CreateObject: Impossibile connettere l'oggetto. +``` + +Questo non significa che Panasonic TAPI non funziona. + +Significa solo che quell'oggetto COM non si lascia agganciare bene da VBScript come sorgente eventi. + +Per questo motivo il passo consigliato diventa il polling PowerShell, che e piu robusto. + +Usa questi script Windows: + +- `scripts/ops/windows/inspect-netgescon-panasonic-address.ps1` +- `scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1` + +### 6.1 Ispezione veloce dell'interno 205 + +```powershell +.\scripts\ops\windows\inspect-netgescon-panasonic-address.ps1 -Extension 205 -OutFile .\inspect-205.json +``` + +Se vuoi fare anche 201 e 206: + +```powershell +.\scripts\ops\windows\inspect-netgescon-panasonic-address.ps1 -Extension 201 -OutFile .\inspect-201.json +.\scripts\ops\windows\inspect-netgescon-panasonic-address.ps1 -Extension 206 -OutFile .\inspect-206.json +``` + +### 6.2 Polling chiamate attive + +Lancialo cosi da PowerShell: + +```powershell +.\scripts\ops\windows\poll-netgescon-panasonic-active-calls.ps1 -Extensions "201,205,206" -LogFile .\netgescon-tapi-poll.log +``` + +Se vuoi includere anche i gruppi studio: + +```powershell +.\scripts\ops\windows\poll-netgescon-panasonic-active-calls.ps1 -Extensions "201,205,206,601,603" -LogFile .\netgescon-tapi-poll.log +``` + +### Cosa deve succedere nel polling + +All'avvio lo script deve stampare righe tipo: + +```text +ADDRESS READY name=EXT00205 dial=205 provider=CTSP0000.tsp +``` + +Poi resta in ascolto e interroga periodicamente il provider. + +Adesso fai una chiamata reale verso uno degli interni monitorati. + +Se tutto va bene compariranno righe tipo: + +```text +CALL address=EXT00205 dial=205 idx=1 state=... caller=... called=... connected=... +``` + +e lo stesso contenuto verra scritto nel file: + +```text +.\netgescon-tapi-poll.log +``` + +### Cosa mi devi mandare dopo il polling + +Mi serve una di queste due cose: + +1. l'output console del polling +2. il contenuto del file `netgescon-tapi-poll.log` + +## Nota importante sui valori `State=0` e `TerminalCount=0` + +Nel dump iniziale non e un problema grave. + +Quel dump fotografava solo l'elenco indirizzi esposto dal provider. + +Il test decisivo adesso non e il numero di terminali, ma se il polling vede comparire una chiamata attiva durante una chiamata vera. + +## Passo 7: ascolto eventi COM reali + +Se il polling resta fermo su `IDLE` anche dopo le chiamate, ma `RegisterCallNotifications` e tutto verde, allora il passo corretto e ascoltare gli eventi COM veri del provider. + +Usa questo script: + +- `scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1` + +Lancialo cosi: + +```powershell +.\scripts\ops\windows\watch-netgescon-panasonic-tapi-com-events.ps1 -Extensions "201,205,206" -LogFile .\netgescon-tapi-com-events.log +``` + +### Cosa deve succedere nell'ispezione interop + +All'avvio vedrai le righe di setup e `REGISTER OK`. + +Poi, quando fai una chiamata reale verso `201`, `205` o `206`, devono comparire righe tipo: + +```text +COM EVENT type=... +``` + +Se compaiono, abbiamo finalmente il canale giusto da usare per il bridge verso NetGescon. + +## Passo 8: ispezione interop .NET di TAPI 3 + +L'output che hai raccolto conferma che l'interop espone davvero i due canali evento utili: + +- `ITTAPIEventNotification` +- `ITTAPIDispatchEventNotification` + +In pratica non dobbiamo piu indovinare il nome dell'evento COM. Dobbiamo agganciarci ai wrapper .NET generati dall'interop. + +## Passo 9: watcher .NET basato sui wrapper evento interop + +Usa questo script: + +- `scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1` + +Lancialo cosi: + +```powershell +.\scripts\ops\windows\watch-netgescon-panasonic-tapi-dotnet-events.ps1 -Extensions "201-208,601,603,0001,0003" -LogFile .\netgescon-tapi-dotnet-events.log +``` + +Il watcher ora accetta anche range di interni, quindi `201-208` equivale a `201,202,203,204,205,206,207,208`. + +Se vuoi una prova limitata di 90 secondi: + +```powershell +.\scripts\ops\windows\watch-netgescon-panasonic-tapi-dotnet-events.ps1 -Extensions "201-208,601,603,0001,0003" -LogFile .\netgescon-tapi-dotnet-events.log -Seconds 90 +``` + +### Prova bridge dry-run + +Quando il watcher eventi e stabile, puoi chiedergli di costruire anche i payload candidati per gli endpoint Laravel senza ancora inviarli. + +Comando: + +```powershell +.\scripts\ops\windows\watch-netgescon-panasonic-tapi-dotnet-events.ps1 -Extensions "201-208,601,603,0001,0003" -LogFile .\netgescon-tapi-dotnet-events.log -Seconds 90 -BridgeMode dry-run +``` + +In questa modalita vedrai righe aggiuntive tipo: + +```text +BRIDGE DRYRUN endpoint=incoming payload=... +BRIDGE MARK answered event_id=... +BRIDGE DRYRUN endpoint=call-ended payload=... +``` + +Se il provider non espone ancora il numero esterno della chiamata, vedrai invece: + +```text +BRIDGE SKIP endpoint=incoming reason=phone-missing ... +``` + +Questo non e un errore del watcher. Significa solo che per quella chiamata Panasonic non ha ancora reso disponibile il numero nel punto del flusso che stiamo leggendo. + +Attenzione: `-BridgeMode dry-run` non invia nulla a NetGescon. Se su staging i Post-it restano fermi, la prima cosa da controllare e che il watcher Windows non sia partito in dry-run ma in `live`. + +### Cosa deve succedere nel watcher .NET + +All'avvio vedrai prima: + +- `REGISTER OK` sugli interni monitorati +- `DOTNET CHECK` per i wrapper provati +- `DOTNET SUBSCRIBE OK` oppure `DOTNET SUBSCRIBE KO` + +Se almeno uno dei wrapper va in `DOTNET SUBSCRIBE OK`, fai subito una chiamata reale su `201`, `205` o `206`. + +Quando l'aggancio funziona, nel log devono comparire righe tipo: + +```text +DOTNET EVENT source=... eventType=... payloadType=... state=... cause=... address=... dial=... +``` + +### Cosa mi devi mandare dal watcher .NET + +Mandami il contenuto completo di: + +- console PowerShell +- oppure file `netgescon-tapi-dotnet-events.log` + +Con quel log possiamo decidere subito una delle due strade: + +1. il watcher PowerShell/.NET basta e lo estendiamo per chiamare gli endpoint NetGescon +2. i wrapper non si agganciano e allora scriviamo il sink COM compilato con la firma esatta appena confermata + +Se il dry-run produce payload corretti, il passo successivo e passare a: + +```powershell +-BridgeMode live -BaseUrl "https://staging.netgescon.it" -Token "IL_TUO_TOKEN" +``` + +## Passo 10: tenere il bridge sempre acceso su Windows + +Per evitare che tutto dipenda da una finestra PowerShell aperta, usa questi script: + +- `scripts/ops/windows/start-netgescon-panasonic-live.ps1` +- `scripts/ops/windows/install-netgescon-panasonic-live-task.ps1` + +### 10.1 Avvio live manuale + +Prima imposta il token come variabile ambiente persistente Windows. + +In PowerShell amministratore: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass -Force +[Environment]::SetEnvironmentVariable("NETGESCON_CTI_SHARED_TOKEN", "IL_TUO_TOKEN_REALE", "Machine") +``` + +Poi avvia il bridge live: + +```powershell +cd C:\netgescon\netgescon-day0 +.\scripts\ops\windows\start-netgescon-panasonic-live.ps1 -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" +``` + +### 10.2 Installazione come task automatico all'avvio macchina con account SYSTEM + +In PowerShell amministratore: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass -Force +[Environment]::SetEnvironmentVariable("NETGESCON_CTI_SHARED_TOKEN", "IL_TUO_TOKEN_REALE", "Machine") +cd C:\netgescon\netgescon-day0 +.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode system-startup -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" +``` + +Nota importante: quando il task gira come `SYSTEM`, i drive mappati come `S:` in genere non esistono. Usa quindi percorsi locali tipo `C:\ProgramData\...` oppure percorsi UNC. + +### 10.3 Installazione come task al logon utente corrente + +Se non hai privilegi amministrativi, usa la modalita legata all'utente corrente. + +```powershell +Set-ExecutionPolicy -Scope Process Bypass -Force +[Environment]::SetEnvironmentVariable("NETGESCON_CTI_SHARED_TOKEN", "IL_TUO_TOKEN_REALE", "User") +cd C:\netgescon\netgescon-day0 +.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop" +``` + +### 10.4 Sequenza rapida in 6 comandi per il tuo caso `S:\` + +Se il repo e davvero montato in `S:\` e non hai privilegi amministrativi, usa questi 6 comandi esatti nell'ordine indicato. + +```powershell +Set-ExecutionPolicy -Scope Process Bypass -Force +cd S:\ +[Environment]::SetEnvironmentVariable("NETGESCON_CTI_SHARED_TOKEN", "IL_TUO_TOKEN_REALE", "User") +.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop" +Start-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" +Get-ScheduledTaskInfo -TaskName "NetGescon Panasonic Live Bridge" +``` + +Se il quarto comando fallisce, non lanciare il quinto: significa che il task non e stato creato. + +Il file di log non esiste finche il task non parte davvero almeno una volta. Dopo che il sesto comando mostra che il task esiste, puoi seguire il log con: + +```powershell +Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait +``` + +## Passo 11: messa in esercizio su staging + +Quando il watcher e avviato e i log mostrano `DOTNET SUBSCRIBE OK`, la checklist minima per entrare davvero in live e questa. + +### 11.1 Verifica token prima di fare chiamate reali + +Sul PC Windows che gira il bridge: + +```powershell +[Environment]::GetEnvironmentVariable("NETGESCON_CTI_SHARED_TOKEN", "User") +``` + +Se leggi il placeholder o una stringa vecchia, riscrivilo con quello reale e poi reinstalla/rilancia il task. + +### 11.2 Smoke test HTTP verso staging + +```powershell +.\scripts\ops\windows\test-netgescon-panasonic-api.ps1 -BaseUrl "https://staging.netgescon.it" -Token "IL_TUO_TOKEN_REALE" -Extension "205" +``` + +Se questo test non restituisce `ok: true`, non fare ancora chiamate reali: il problema e a livello token o connettivita HTTP. + +### 11.3 Come leggere il log del bridge live + +Nel log live, questi casi significano cose diverse: + +- `BRIDGE LIVE OK endpoint=incoming`: la chiamata e stata accettata da staging +- `BRIDGE LIVE OK endpoint=call-ended`: la chiusura chiamata e stata accettata da staging +- `BRIDGE LIVE KO endpoint=incoming error=(401) Non autorizzato`: token errato sul bridge Windows oppure token diverso su staging +- `BRIDGE IGNORE endpoint=... category=EXT mode=group-first`: evento visto ma non scelto come sorgente autorevole per il bridge + +### 11.4 Dove vedere le nuove chiamate su staging + +Quando l'invio live va a buon fine, le nuove chiamate Panasonic compaiono in due punti applicativi: + +1. `Strumenti -> Post-it Gestione` + - legge i record `chiamate_post_it` + - e il punto principale per vedere le chiamate create dal bridge +2. flussi tecnici basati su `communication_messages` + - canale `panasonic_csta` + - utili per verifica tecnica e correlazione con routing/intercettazione operatore + +In aggiunta, il banner operatore mobile usa anche `panasonic_csta`, quindi una chiamata instradata a un interno monitorato puo comparire anche lato operatore senza passare solo da SMDR. + +### 11.5 Procedura rapida di ripartenza se staging smette di ricevere chiamate + +Sul PC Windows: + +```powershell +cd S:\ +Stop-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" -ErrorAction SilentlyContinue +.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop" +Start-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" +Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait +``` + +Se dopo una chiamata reale appare `401`, fermati subito: il bridge sta funzionando ma staging sta rifiutando il token. + +Avvio immediato del task: + +```powershell +Start-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" +``` + +Stato task: + +```powershell +Get-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" +Get-ScheduledTaskInfo -TaskName "NetGescon Panasonic Live Bridge" +``` + +Stop task: + +```powershell +Stop-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" +``` + +Rimozione task: + +```powershell +Unregister-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" -Confirm:$false +``` + +ma solo dopo aver validato bene il contenuto dei payload dry-run. + +Se il provider Panasonic accetta `RegisterCallNotifications`, ma: + +- il polling resta sempre `IDLE` +- `CallHubs` resta `none` +- `Register-ObjectEvent` dice che non esiste l'evento `Event` + +allora PowerShell in late binding non basta piu. + +In quel caso il passo corretto e generare l'interop .NET della type library `tapi3.dll` e vedere i nomi reali di tipi, delegate ed eventi da usare in un watcher compilato. + +Usa questo script: + +- `scripts/ops/windows/inspect-netgescon-tapi-interop.ps1` + +Lancialo cosi: + +```powershell +.\scripts\ops\windows\inspect-netgescon-tapi-interop.ps1 +``` + +### Cosa deve succedere + +Lo script: + +1. usa le classi .NET gia presenti in Windows +2. genera un assembly .NET da `C:\Windows\System32\tapi3.dll` +3. elenca i tipi che contengono `Event` +4. elenca i tipi legati a `ITTAPI` + +### Cosa mi devi mandare + +Mandami l'output completo dello script. + +Con quello preparo il watcher compilato con i nomi esatti esposti dall'interop reale della tua macchina Windows. diff --git a/docs/README.md b/docs/README.md index 592f52e..04a1d6f 100755 --- a/docs/README.md +++ b/docs/README.md @@ -9,10 +9,37 @@ ## Nota Operativa Day0 - Workspace attivo: `/home/michele/netgescon/netgescon-day0` - Documentazione autorevole: `/home/michele/netgescon/netgescon-day0/docs` - Repository Git da usare: `netgescon-day0` +- Remote Git principale: `origin -> ssh://git@192.168.0.53:2222/michele/netgescon-day0.git` +- Branch operativo corrente: `main` - La cartella storica `netgescon-laravel` va considerata archivio tecnico e non base di lavoro corrente. Se un documento riporta path o workflow precedenti, prevalgono sempre le regole Day0. +## Flusso operativo reale Git -> Gitea -> Staging + +Il flusso di aggiornamento attivo di NetGescon non prevede deploy manuali ad hoc da terminale verso staging come canale principale. + +Pipeline da seguire: + +1. sviluppo e test nel workspace `netgescon-day0` +2. commit locale Git sul repository `netgescon-day0` +3. push su Gitea interno (`origin`) +4. allineamento di staging dal repository Gitea +5. stesso meccanismo per gli altri siti/nodi + +Regole operative: + +- la fonte di verita del codice e Gitea +- staging va aggiornato allineando il codice pubblicato su Gitea +- ogni documento che suggerisce un deploy diretto macchina -> staging va considerato superato, salvo emergenze operative +- la distribuzione commerciale multi-canale non e ancora il flusso attivo principale + +## Stato distribuzione attuale + +- modello attivo: distribuzione unica via Git/Gitea +- canali commerciali `free` e `licensed`: infrastruttura prevista ma non ancora flusso operativo principale +- il valore `free` presente in alcune config va interpretato oggi come placeholder tecnico, non come conferma del modello commerciale definitivo + ## ๐Ÿ“Ž Descrizione NetGescon รจ una piattaforma web sviluppata in **Laravel** per la gestione completa di condomini e amministrazioni condominiali. Il sistema offre funzionalitร  avanzate per: @@ -116,27 +143,29 @@ # Debug in tempo reale tail -f storage/logs/laravel.log ``` -## ๐Ÿค **Contributi** +## Git e collaborazione -Per contribuire al progetto: +Flusso minimo consigliato: -1. Fork del repository -2. Creazione branch feature (`git checkout -b feature/nome-feature`) -3. Commit modifiche (`git commit -am 'Aggiunta nuova feature'`) -4. Push del branch (`git push origin feature/nome-feature`) -5. Creazione Pull Request +1. aggiornare il workspace attivo `netgescon-day0` +2. verificare stato con `git status` +3. creare commit coerenti e leggibili +4. fare push su `origin` +5. allineare staging dal repository Gitea -## ๐Ÿ“„ **Licenza** +Quando serviranno branch dedicati, release o tag, vanno gestiti sempre dentro Gitea e non con copie manuali fuori flusso. -Questo progetto รจ rilasciato sotto licenza MIT. Vedi il file `LICENSE` per i dettagli. +## Licenza e distribuzione -## ๐Ÿ“ž **Supporto** +- esiste una infrastruttura di distribuzione gia predisposta nel codice +- il modello commerciale definitivo non va dato per concluso solo leggendo le config correnti +- fino a nuova decisione operativa, la documentazione deve descrivere prima il flusso Git/Gitea reale e solo dopo gli scenari futuri -Per supporto tecnico o domande: +## Supporto e riferimenti -- **Issues:** Usa il sistema issues di GitHub -- **Email:** -- **Documentazione:** Disponibile nel repository +- documentazione operativa: `docs/` +- repository autorevole: Gitea interno +- issue tracking e flusso di aggiornamento: da riallineare al sistema Gitea interno --- diff --git a/docs/RUNBOOK-DISTRIBUZIONE-REMOTA-NETGESCON.md b/docs/RUNBOOK-DISTRIBUZIONE-REMOTA-NETGESCON.md index 0f334a0..64cccbd 100644 --- a/docs/RUNBOOK-DISTRIBUZIONE-REMOTA-NETGESCON.md +++ b/docs/RUNBOOK-DISTRIBUZIONE-REMOTA-NETGESCON.md @@ -8,6 +8,18 @@ # NetGescon - Runbook Distribuzione Remota Questo runbook e pensato per essere eseguito sulla macchina remota dove stai preparando l'ambiente. +## Stato operativo reale da usare oggi + +Prima di ogni altra sezione, vale questa regola pratica: + +1. il codice si aggiorna nel repository locale `netgescon-day0` +2. si esegue commit Git e push su Gitea interno +3. staging e gli altri nodi si allineano dal repository Gitea + +Quindi il runbook remoto non sostituisce il flusso Git, ma lo completa. + +Il deploy manuale diretto verso staging non e il percorso standard da descrivere come primario. + ## Stato Aggiornato (10-03-2026) Modifiche gia implementate nel progetto per distribuzione/update: @@ -53,6 +65,11 @@ ## 1. Obiettivo 3. Aggiornamento automatico nodi (staging e poi altri nodi). 4. Base pronta per licensing con scadenza e moduli premium. +Nota attuale: + +- il punto 1 oggi e gia sostanzialmente rappresentato dal repository Gitea interno `origin` +- i punti 2, 3 e 4 restano parzialmente predisposti ma non sono il flusso principale gia in esercizio + ## 2. Architettura consigliata Domini consigliati: @@ -171,6 +188,13 @@ # Esempio ## 7. Setup update API su remoto +Questa sezione va considerata secondaria rispetto al flusso Git/Gitea corrente. + +Prima di usarla, assicurarsi che: + +- il commit corretto sia gia stato pubblicato su Gitea +- staging abbia gia riallineato quel commit dal repository + Nel progetto Laravel (istanza update server) devi avere: - endpoint manifest/package @@ -184,6 +208,8 @@ ## 7. Setup update API su remoto NETGESCON_LICENSED_PAIRS="CLIENTA:tokenA,CLIENTB:tokenB" ``` +Il valore `free` qui va letto come valore tecnico corrente di configurazione. Non dimostra da solo che il modello commerciale attivo sia gia definitivamente separato in canali. + Poi: ```bash @@ -233,7 +259,26 @@ ### 8.2 Pubblica manifest --base-url https://updates.netgescon.it ``` -## 9. Aggiornare staging da remoto +## 9. Aggiornare staging + +Flusso corretto oggi: + +1. sviluppare e testare in `netgescon-day0` +2. fare commit e push su Gitea +3. su staging, eseguire allineamento del repository dal remoto Gitea +4. solo dopo l'allineamento, eseguire eventuali comandi Laravel necessari + +Questa resta la procedura preferita anche quando esistono script o comandi di update applicativo. + +## 10. Nota sui canali distribuzione + +Nel codice esiste gia il supporto ai canali `free` e `licensed`, ma operativamente oggi il progetto va trattato come distribuzione unica fino a conferma di una reale attivazione commerciale differenziata. + +La documentazione deve quindi: + +- descrivere prima il flusso Git/Gitea reale +- marcare i canali distinti come infrastruttura pronta o parzialmente pronta +- evitare di presentare come attiva una segmentazione commerciale non ancora formalizzata Sul nodo staging configura `.env`: diff --git a/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md b/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md index 6c7d638..43b312c 100644 --- a/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md +++ b/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md @@ -2,6 +2,12 @@ # CTI Panasonic NS1000 - Setup operativo Obiettivo: intercettare chiamate dal centralino Panasonic NS1000 (CSTA porta 33333) e creare/chiudere automaticamente Post-it in NetGescon con filtro rubrica. +Nota architetturale: + +- non serve un hardware dedicato aggiuntivo se il PBX espone gia CSTA in rete; +- il bridge/adapter puo essere eseguito internamente su Linux come servizio applicativo; +- il flusso consigliato resta `Panasonic NS1000 -> adapter CSTA Linux -> API HTTPS NetGescon`. + ## Prerequisiti verificati - Connessione TCP PBX CSTA: `192.168.0.101:33333` raggiungibile. @@ -13,6 +19,13 @@ ## 1) Configurazione applicazione NetGescon ```env NETGESCON_CTI_SHARED_TOKEN= +NETGESCON_CTI_PBX_HOST=192.168.0.101 +NETGESCON_CTI_PBX_PORT=33333 +NETGESCON_CTI_BRIDGE_TIMEOUT=10 +NETGESCON_CTI_BRIDGE_READ_BYTES=2048 +NETGESCON_CTI_BRIDGE_RECONNECT_DELAY=5 +NETGESCON_CTI_BRIDGE_MAX_IDLE_TIMEOUTS=30 +NETGESCON_CTI_BRIDGE_LOG_FILE=storage/logs/cti-panasonic-raw.ndjson ``` Poi: @@ -36,11 +49,19 @@ ## 2) Endpoint disponibili - `POST /api/v1/cti/panasonic/incoming` - crea Post-it chiamata in `chiamate_post_it` - fa match automatico numero -> `rubrica_universale` + - scrive anche `communication_messages` con `channel=panasonic_csta`, `target_extension`, `assigned_user_id` - `POST /api/v1/cti/panasonic/call-ended` - chiude automaticamente il Post-it aperto (match su `event_id`, fallback su numero) - salva `esito`, `durata_secondi`, `chiusa_il` + - chiude anche il record `communication_messages` CSTA corrispondente - `GET /api/v1/cti/panasonic/lookup?phone=...` - solo lookup rubrica (match yes/no) +- `GET /api/v1/cti/panasonic/click-to-call/pending?extension=101` + - coda outbound per adapter/middleware CSTA +- `POST /api/v1/cti/panasonic/click-to-call/{id}/claim` + - lock logico della richiesta da parte dell'adapter +- `POST /api/v1/cti/panasonic/click-to-call/{id}/status` + - esito esecuzione (`accepted`, `completed`, `failed`, `cancelled`) Autenticazione: header `X-CTI-Token: `. @@ -93,24 +114,46 @@ ## 5) Comando minimo per sniffing CSTA grezzo (porta 33333) sudo tcpdump -i any -nn -s0 -A port 33333 ``` -4. Verifica quale sorgente arriva davvero sulla porta: +1. Verifica quale sorgente arriva davvero sulla porta: ```bash sudo tcpdump -i any -nn "tcp port 33333" ``` -5. Verifica che non sia traffico su altra porta (es. CTI middleware): +1. Verifica che non sia traffico su altra porta (es. CTI middleware): ```bash sudo tcpdump -i any -nn -s0 -A host 192.168.0.101 and \(port 33333 or port 33334 or port 33335\) ``` -6. Verifica che sul server non ci sia un servizio locale che assorbe la sessione senza inoltro HTTP: +1. Verifica che sul server non ci sia un servizio locale che assorbe la sessione senza inoltro HTTP: ```bash sudo ss -lntp | grep -E ':33333|:8099|:80' ``` +## 5-bis) Daemon Linux minimale di cattura CSTA + +Avvio manuale: + +```bash +cd /home/michele/netgescon/netgescon-day0 +bash scripts/ops/netgescon-panasonic-csta-bridge.sh +``` + +Il daemon: + +- apre la socket TCP verso il PBX +- resta in ascolto con riconnessione automatica +- salva i frame grezzi in `storage/logs/cti-panasonic-raw.ndjson` +- stampa dump hex e una resa testuale best-effort + +Per installarlo come servizio usare il template: + +```text +scripts/systemd/netgescon-panasonic-csta-bridge.service.template +``` + ## 6) Listener terminale con script (log su file) Metti questo listener in ascolto e punta il PBX a `http://:8099` per vedere esattamente i dati in arrivo. @@ -140,13 +183,13 @@ ## 8) Comandi singoli per simulare chiamate da terminale Evento incoming (una riga): ```bash -curl -sS -X POST "http://127.0.0.1:8000/api/v1/cti/panasonic/incoming" -H "Content-Type: application/json" -H "X-CTI-Token: " -d '{"phone":"+393331112233","direction":"in_arrivo","called_extension":"101","event_id":"evt-001","note":"test incoming"}' +curl -sS -X POST "http://127.0.0.1:8000/api/v1/cti/panasonic/incoming" -H "Content-Type: application/json" -H "X-CTI-Token: " -d '{"phone":"+393331112233","direction":"inbound","event_type":"Delivered","called_extension":"101","event_id":"evt-001","note":"test incoming"}' ``` Evento call-ended (una riga): ```bash -curl -sS -X POST "http://127.0.0.1:8000/api/v1/cti/panasonic/call-ended" -H "Content-Type: application/json" -H "X-CTI-Token: " -d '{"phone":"+393331112233","event_id":"evt-001","outcome":"risposta","duration_seconds":83,"ended_at":"2026-03-11T10:20:00+01:00","note":"test call-ended"}' +curl -sS -X POST "http://127.0.0.1:8000/api/v1/cti/panasonic/call-ended" -H "Content-Type: application/json" -H "X-CTI-Token: " -d '{"phone":"+393331112233","direction":"inbound","event_type":"ConnectionCleared","event_id":"evt-001","outcome":"risposta","duration_seconds":83,"ended_at":"2026-03-11T10:20:00+01:00","note":"test call-ended"}' ``` Lookup rubrica (una riga): @@ -188,6 +231,8 @@ ## 9) Esempio comandi da usare lato interfaccia centralino -H "X-CTI-Token: " \ -d '{ "phone": "", + "direction": "inbound", + "event_type": "ConnectionCleared", "event_id": "", "outcome": "risposta", "duration_seconds": 83, @@ -196,6 +241,32 @@ ## 9) Esempio comandi da usare lato interfaccia centralino }' ``` +## 13) Bridge/adapter CSTA Phase III: prova pratica + +Contratto minimo consigliato per un adapter esterno: + +1. Sottoscrive gli eventi PBX/CSTA su uno o piu interni. +2. Converte i nomi evento PBX in payload HTTP NetGescon: + - `Delivered` -> `incoming` + - `ConnectionCleared` -> `call-ended` +3. Per l'outbound interroga periodicamente `click-to-call/pending`. +4. Esegue claim della richiesta. +5. Invia `MakeCall` o equivalente al PBX verso `source_extension` -> `target_number`. +6. Aggiorna lo stato con `accepted/completed/failed`. + +Payload di stato outbound consigliato: + +```json +{ + "status": "completed", + "event_id": "csta-out-001", + "provider_call_id": "ns1000-call-7788", + "called_extension": "101", + "duration_seconds": 64, + "outcome": "answered" +} +``` + ## 10) Cosa fa il match numeri - normalizza il numero (es. `+39`, `0039`, spazi, trattini) @@ -231,3 +302,34 @@ ## 12) Step successivo consigliato 1. Agganciare evento reale CSTA incoming. 2. Agganciare evento call-ended per aggiornare `esito` e durata. 3. Mostrare popup operatore in Filament con scheda rubrica trovata. + +## 12-bis) Mappatura studio corrente + +- `201`: centralino con segreteria +- `205`: operatore studio +- `206`: amministratore +- `601`: gruppo/modalita giorno +- `603`: gruppo/modalita notte + +Questi interni devono essere censiti nella scheda amministratore per consentire al routing applicativo di riconoscere gli alias condivisi del PBX oltre agli interni utente personali. + +## 12-ter) Impostazioni PBX da recuperare o configurare + +- conferma che la porta CTI/CSTA sia realmente `33333` +- eventuale utente/password o token richiesti per la sessione CSTA +- eventuale payload iniziale di handshake/login richiesto dal PBX +- conferma della licenza o modulo necessario per `MonitorStart` e `MakeCall` +- elenco device/interni da monitorare: + - `201` + - `205` + - `206` + - `601` + - `603` +- eventi da ricevere, se configurabili: + - `Delivered` + - `Established` + - `ConnectionCleared` + - `Failed` o equivalente +- abilitazione del controllo outbound per chiamate originate da `205` + +Se il PBX espone solo un feed CTI generico e non una configurazione eventi dettagliata, va comunque bene: il daemon serve proprio a catturare il traffico reale e ricostruire il protocollo effettivo. diff --git a/docs/distribuzione-update-netgescon.md b/docs/distribuzione-update-netgescon.md index 87de78b..c512b70 100644 --- a/docs/distribuzione-update-netgescon.md +++ b/docs/distribuzione-update-netgescon.md @@ -1,14 +1,39 @@ -# Distribuzione e Aggiornamenti NetGescon (senza dipendenza Git locale) +# Distribuzione e Aggiornamenti NetGescon -Questo documento definisce un flusso pratico per: +Questo documento descrive il flusso operativo reale oggi in uso e separa chiaramente cio che e gia attivo da cio che resta roadmap. -- congelare una baseline stabile, -- pubblicare aggiornamenti da una macchina di distribuzione, -- applicare update automatici sui nodi (`free` e `licensed`). +## 1. Flusso attivo oggi -## 1. Punto zero (baseline stabile) +La catena corretta e una sola: -Usare lo script baseline sulla macchina attuale funzionante: +1. sviluppo e verifica in Day0; +2. commit e push su Gitea `main`; +3. staging allineato dal contenuto pubblicato su Gitea; +4. controllo e applicazione aggiornamento dal pannello browser oppure via comando `php artisan`; +5. backup pre-update locale, con seconda copia opzionale o obbligatoria su Google Drive secondo configurazione del nodo. + +Questo significa che Day0 e il workspace autorevole, mentre staging va trattato come nodo remoto anche quando gira sulla stessa macchina fisica. + +## 2. Cosa e gia implementato + +Sono gia presenti e funzionanti i seguenti componenti: + +- comando `php artisan netgescon:update` per controllare e applicare un pacchetto pubblicato; +- comando `php artisan netgescon:preupdate-backup` per creare snapshot ripristinabile prima di ogni update; +- pagina browser Supporto > Modifiche, che esegue prima il backup e poi l update in background con file di progresso persistente; +- supporto Google Drive per la seconda copia del backup pre-update; +- esclusione di `.env`, `storage/` e `.git/` dalla sovrascrittura del pacchetto; +- verifica hash SHA256 e pulizia cache a fine applicazione. + +## 3. Significato del canale `free` + +Nel sistema attuale il canale `free` e un segnaposto tecnico di compatibilita, non una decisione commerciale definitiva. + +In pratica oggi esiste una sola distribuzione operativa. La distinzione `free` e `licensed` rimane nel codice per non rompere il motore update e per preparare scenari futuri, ma non va interpretata come modello commerciale gia definito. + +## 4. Preparazione baseline e backup manuale + +Se serve congelare una baseline completa del nodo attuale, usare: ```bash bash scripts/ops/netgescon-baseline-freeze.sh \ @@ -16,100 +41,120 @@ ## 1. Punto zero (baseline stabile) --version 0.8.0-baseline ``` -Risultato: +Produce: -- snapshot codice (`code.tar.gz`), -- backup `.env`, -- dump DB best-effort, -- file metadati baseline. +- archivio codice; +- copia `.env`; +- dump database best-effort; +- metadati baseline. -## 2. Macchina di distribuzione update - -La macchina update ospita: - -- API Laravel giร  presenti: `/api/v1/distribution/updates/manifest`, `/api/v1/distribution/updates/package` -- storage pacchetti: `storage/app/distribution/{free|licensed}/` - -### Variabili principali - -Aggiungere in `.env` (macchina update server): - -```env -NETGESCON_LICENSED_PAIRS="CLIENTA:tokenA,CLIENTB:tokenB" -``` - -Note: - -- canale `free`: accesso pubblico, -- canale `licensed`: richiede `code`+`auth` validi. - -## 3. Pubblicazione release - -Preparare zip release e pubblicare manifest: +Per il backup pre-update applicativo usare invece: ```bash -bash scripts/ops/netgescon-publish-manifest.sh \ - --channel free \ - --version 0.8.1 \ - --package /var/www/netgescon/storage/app/distribution/free/netgescon-0.8.1.zip \ - --base-url https://updates.netgescon.it +php artisan netgescon:preupdate-backup --differential --tag=manuale ``` -Per licensed: +Con copia anche su Google Drive: ```bash -bash scripts/ops/netgescon-publish-manifest.sh \ - --channel licensed \ - --version 0.8.1 \ - --package /var/www/netgescon/storage/app/distribution/licensed/netgescon-0.8.1.zip \ - --base-url https://updates.netgescon.it +php artisan netgescon:preupdate-backup \ + --differential \ + --tag=manuale \ + --drive \ + --admin-id=ID_AMMINISTRATORE ``` -Il manifest contiene: `version`, `sha256`, `migrate_required`, `package_url`. +Se il nodo richiede obbligatoriamente la seconda copia Drive: -## 4. Nodo client: check/apply update +```bash +php artisan netgescon:preupdate-backup \ + --differential \ + --tag=manuale \ + --drive \ + --require-drive \ + --admin-id=ID_AMMINISTRATORE +``` -Configurare sul nodo da aggiornare: +## 5. Pubblicazione da Day0 verso Gitea + +Prima di aggiornare staging, il contenuto deve essere allineato a Gitea. + +Sequenza minima: + +```bash +git status +git add . +git commit -m "Messaggio coerente con il lotto Day0" +git push origin main +``` + +Solo dopo il push il nodo staging deve essere considerato aggiornabile tramite il suo flusso di pull/apply. + +## 6. Configurazione del nodo staging + +Le variabili principali sono: ```env NETGESCON_UPDATE_URL=https://updates.netgescon.it NETGESCON_UPDATE_CHANNEL=free NETGESCON_NODE_CODE=staging-01 -NETGESCON_LICENSED_CODE= -NETGESCON_LICENSED_AUTH= +NETGESCON_PREUPDATE_DRIVE_ENABLED=true +NETGESCON_PREUPDATE_REQUIRE_DRIVE=false +NETGESCON_UPDATE_RESOLVE= ``` -Per nodi licensed valorizzare code/auth. +Note: -### Verifica update +- `NETGESCON_UPDATE_CHANNEL=free` oggi indica il percorso operativo standard, non la licenza del cliente; +- `NETGESCON_PREUPDATE_DRIVE_ENABLED=true` abilita il tentativo di seconda copia su Drive quando l amministratore del nodo ha credenziali Google valide; +- `NETGESCON_PREUPDATE_REQUIRE_DRIVE=true` blocca l update se la seconda copia non viene completata; +- `NETGESCON_UPDATE_RESOLVE` serve solo in casi DNS particolari, ad esempio `updates.netgescon.it:443:192.168.0.53`. + +## 7. Aggiornamento da browser + +Il percorso operativo preferito per staging e il pannello browser in Supporto > Modifiche. + +La pagina esegue questa sequenza: + +1. verifica permessi utente; +2. crea backup pre-update locale; +3. se configurato, carica anche la copia Google Drive; +4. avvia `netgescon:update --apply` in background; +5. salva avanzamento e log in `storage/app/support/update-jobs/`; +6. aggiorna il riepilogo ultimo backup e l esito dell operazione. + +Questo e il percorso da preferire per l accettazione operativa lato staging, perche lascia traccia applicativa e stato avanzamento leggibile da interfaccia. + +## 8. Aggiornamento da comando + +Se serve eseguire la stessa procedura senza browser: ```bash php artisan netgescon:update --channel=free -``` - -### Applicazione update - -```bash php artisan netgescon:update --channel=free --apply ``` -### Apply con licensed +La modalita `licensed` rimane disponibile solo come compatibilita tecnica o scenario futuro: ```bash php artisan netgescon:update --channel=licensed --apply --code=CLIENTA --auth=tokenA ``` -## 5. Sicurezza minima inclusa +## 9. Sicurezza minima gia coperta -- verifica hash SHA256 del pacchetto, -- backup `.env` prima dell'applicazione, -- esclusione `storage/` e `.env` dalla sovrascrittura, -- `php artisan optimize:clear` post update, -- migrate automatico se richiesto da manifest. +- hash SHA256 del pacchetto verificato prima dell apply; +- backup `.env` locale durante l update; +- snapshot pre-update con manifest file e indice record; +- esclusione dei percorsi sensibili dalla sovrascrittura; +- `composer install --no-dev --optimize-autoloader` quando il pacchetto contiene file Composer; +- `php artisan optimize:clear` sempre a fine apply; +- `php artisan migrate --force` se il manifest dichiara `migrate_required=true`. -## 6. Roadmap consigliata (step successivi) +## 10. Roadmap reale -1. Firma manifest con chiave privata (Ed25519) e verifica firma lato client. -2. Endpoint audit update (chi ha scaricato/applicato e quando). -3. UI admin Filament per vedere canale/versione e lanciare update. -4. Rollback automatico completo (codice + DB) con policy di retention. +I passaggi sotto restano obiettivi successivi, non prerequisiti del flusso attuale: + +1. firma manifest con chiave privata e verifica lato nodo; +2. audit centralizzato di download e apply; +3. packaging/release automatizzati da pipeline Gitea; +4. rollback completo codice piu database con retention formalizzata. diff --git a/resources/views/filament/pages/condomini/tabs/assicurazioni.blade.php b/resources/views/filament/pages/condomini/tabs/assicurazioni.blade.php index 18b4f64..f2e92f6 100644 --- a/resources/views/filament/pages/condomini/tabs/assicurazioni.blade.php +++ b/resources/views/filament/pages/condomini/tabs/assicurazioni.blade.php @@ -44,21 +44,67 @@
-
\ No newline at end of file diff --git a/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php b/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php index 75df23e..9a27877 100644 --- a/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php +++ b/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php @@ -1,13 +1,18 @@
Centralino studio e interni collaboratori
-
I numeri studio sono salvati nella scheda amministratore. Gli interni PBX dei collaboratori sono usati per instradamento SMDR/post-it.
+
I numeri studio e gli interni PBX/CSTA sono salvati qui. Gli interni condivisi del centralino permettono di instradare popup e chiamate anche quando il PBX espone gruppi giorno/notte invece dell'interno personale.
-
+
- + + + + + +
diff --git a/resources/views/filament/pages/supporto/ticket-gestione.blade.php b/resources/views/filament/pages/supporto/ticket-gestione.blade.php index 6157e65..0ae145b 100644 --- a/resources/views/filament/pages/supporto/ticket-gestione.blade.php +++ b/resources/views/filament/pages/supporto/ticket-gestione.blade.php @@ -384,6 +384,45 @@ Numero sinistro + + + + + + + +
@endif @@ -431,7 +491,7 @@
@if($attachmentPreview) -
+
@@ -458,11 +518,13 @@
@if($attachmentPreview['is_image'] ?? false) -
- Anteprima allegato +
+
+ Anteprima allegato +
@elseif($attachmentPreview['is_pdf'] ?? false) - + @else
Anteprima non disponibile per questo formato. Scarica o apri il file: diff --git a/routes/api.php b/routes/api.php index e138010..ef64c07 100755 --- a/routes/api.php +++ b/routes/api.php @@ -87,6 +87,15 @@ Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup']) ->name('api.cti.panasonic.lookup'); + + Route::get('/click-to-call/pending', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'pendingClickToCall']) + ->name('api.cti.panasonic.click_to_call.pending'); + + Route::post('/click-to-call/{clickToCallRequest}/claim', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'claimClickToCall']) + ->name('api.cti.panasonic.click_to_call.claim'); + + Route::post('/click-to-call/{clickToCallRequest}/status', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'updateClickToCallStatus']) + ->name('api.cti.panasonic.click_to_call.status'); }); // --- API Comunicazioni Omnicanale (Telegram / WhatsApp test) --- diff --git a/routes/web.php b/routes/web.php index c135d8a..50ce616 100755 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\FornitoreController; use App\Http\Controllers\Admin\GestioneAttivaController; use App\Http\Controllers\Admin\ImpostazioniController; +use App\Http\Controllers\Admin\InsurancePolicyAssetViewController; use App\Http\Controllers\Admin\PianoRateizzazioneController; use App\Http\Controllers\Admin\PreventivoController; use App\Http\Controllers\Admin\RataController; @@ -143,6 +144,7 @@ Route::post('tickets/{ticket}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); + Route::get('insurance-policies/{policy}/assets/{asset}', [InsurancePolicyAssetViewController::class, 'show'])->name('insurance.policies.assets.view'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); Route::post('tickets/{ticket}/interventi/{intervento}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify'); Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice'); @@ -314,6 +316,7 @@ Route::post('tickets/{ticket}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); + Route::get('insurance-policies/{policy}/assets/{asset}', [InsurancePolicyAssetViewController::class, 'show'])->name('insurance.policies.assets.view'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); Route::post('tickets/{ticket}/interventi/{intervento}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify'); Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice'); diff --git a/scripts/ops/netgescon-panasonic-csta-bridge.sh b/scripts/ops/netgescon-panasonic-csta-bridge.sh new file mode 100755 index 0000000..338d6af --- /dev/null +++ b/scripts/ops/netgescon-panasonic-csta-bridge.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$BASE_DIR" + +PHP_BIN="${PHP_BIN:-php}" +PBX_HOST="${NETGESCON_CTI_PBX_HOST:-192.168.0.101}" +PBX_PORT="${NETGESCON_CTI_PBX_PORT:-33333}" +TIMEOUT="${NETGESCON_CTI_BRIDGE_TIMEOUT:-10}" +READ_BYTES="${NETGESCON_CTI_BRIDGE_READ_BYTES:-2048}" +RECONNECT_DELAY="${NETGESCON_CTI_BRIDGE_RECONNECT_DELAY:-5}" +MAX_IDLE_TIMEOUTS="${NETGESCON_CTI_BRIDGE_MAX_IDLE_TIMEOUTS:-30}" +LOG_FILE="${NETGESCON_CTI_BRIDGE_LOG_FILE:-storage/logs/cti-panasonic-raw.ndjson}" +PRINT_HEX="${NETGESCON_CTI_BRIDGE_PRINT_HEX:-1}" +PRINT_TEXT="${NETGESCON_CTI_BRIDGE_PRINT_TEXT:-1}" +STARTUP_SEND="${NETGESCON_CTI_BRIDGE_STARTUP_SEND:-}" +STARTUP_SEND_HEX="${NETGESCON_CTI_BRIDGE_STARTUP_SEND_HEX:-}" + +args=( + artisan cti:bridge-panasonic + --host="$PBX_HOST" + --port="$PBX_PORT" + --timeout="$TIMEOUT" + --read-bytes="$READ_BYTES" + --reconnect=1 + --reconnect-delay="$RECONNECT_DELAY" + --max-idle-timeouts="$MAX_IDLE_TIMEOUTS" + --log-file="$LOG_FILE" + --print-hex="$PRINT_HEX" + --print-text="$PRINT_TEXT" +) + +if [[ -n "$STARTUP_SEND" ]]; then + args+=(--startup-send="$STARTUP_SEND") +fi + +if [[ -n "$STARTUP_SEND_HEX" ]]; then + args+=(--startup-send-hex="$STARTUP_SEND_HEX") +fi + +exec "$PHP_BIN" "${args[@]}" \ No newline at end of file diff --git a/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 b/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 new file mode 100644 index 0000000..dff4fd2 --- /dev/null +++ b/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 @@ -0,0 +1,139 @@ +param( + [string]$Filter = "", + [string]$OutFile = ".\netgescon-tapi-info.json", + [switch]$IncludeMembers, + [switch]$ShowRawMembers +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-SafeComValue { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$Name + ) + + try { + return $Object.$Name + } + catch { + return $null + } +} + +function Convert-ComCollectionToArray { + param([Parameter(Mandatory = $true)] $Collection) + + $items = @() + try { + foreach ($item in $Collection) { + $items += $item + } + } + catch { + } + + return $items +} + +function Get-AddressSnapshot { + param( + [Parameter(Mandatory = $true)] $Address, + [string]$FilterValue = "" + ) + + $addressName = [string](Get-SafeComValue -Object $Address -Name "AddressName") + $serviceProviderName = [string](Get-SafeComValue -Object $Address -Name "ServiceProviderName") + $dialableAddress = [string](Get-SafeComValue -Object $Address -Name "DialableAddress") + $state = Get-SafeComValue -Object $Address -Name "State" + $mediaTypes = Get-SafeComValue -Object $Address -Name "MediaTypes" + $bearerMode = Get-SafeComValue -Object $Address -Name "BearerMode" + + $terminals = @() + $terminalCollection = Get-SafeComValue -Object $Address -Name "Terminals" + if ($null -ne $terminalCollection) { + foreach ($terminal in (Convert-ComCollectionToArray -Collection $terminalCollection)) { + $terminals += [pscustomobject]@{ + Name = [string](Get-SafeComValue -Object $terminal -Name "Name") + Direction = Get-SafeComValue -Object $terminal -Name "Direction" + TerminalCls = [string]$terminal.GetType().FullName + } + } + } + + $match = $true + if ($FilterValue -ne "") { + $haystack = @($addressName, $serviceProviderName, $dialableAddress) -join " | " + $match = $haystack -match [regex]::Escape($FilterValue) + } + + return [pscustomobject]@{ + AddressName = $addressName + ServiceProviderName = $serviceProviderName + DialableAddress = $dialableAddress + State = $state + MediaTypes = $mediaTypes + BearerMode = $bearerMode + TerminalCount = $terminals.Count + Terminals = $terminals + MatchesFilter = $match + } +} + +Write-Host "[1/5] Creo l'oggetto COM TAPI..." +$tapi = New-Object -ComObject TAPI.TAPI + +Write-Host "[2/5] Inizializzo TAPI..." +$tapi.Initialize() + +Write-Host "[3/5] Leggo le informazioni generali..." +$addressesRaw = Convert-ComCollectionToArray -Collection $tapi.Addresses +$addressSnapshots = @() +foreach ($address in $addressesRaw) { + $addressSnapshots += Get-AddressSnapshot -Address $address -FilterValue $Filter +} + +if ($Filter -ne "") { + $addressSnapshots = @($addressSnapshots | Where-Object { $_.MatchesFilter }) +} + +$memberDump = @() +if ($IncludeMembers) { + $memberDump = @( + $tapi | Get-Member | Sort-Object MemberType, Name | Select-Object Name, MemberType, Definition + ) +} + +$rawAddressMembers = @() +if ($ShowRawMembers -and $addressesRaw.Count -gt 0) { + $rawAddressMembers = @( + $addressesRaw[0] | Get-Member | Sort-Object MemberType, Name | Select-Object Name, MemberType, Definition + ) +} + +$report = [pscustomobject]@{ + GeneratedAt = (Get-Date).ToString("o") + ComputerName = $env:COMPUTERNAME + UserName = $env:USERNAME + Filter = $Filter + AddressCount = $addressSnapshots.Count + Addresses = $addressSnapshots + TapiMembers = $memberDump + FirstAddressDump = $rawAddressMembers +} + +Write-Host "[4/5] Scrivo il report JSON in $OutFile" +$report | ConvertTo-Json -Depth 8 | Set-Content -Path $OutFile -Encoding UTF8 + +Write-Host "[5/5] Riepilogo rapido" +Write-Host "Macchina: $($report.ComputerName)" +Write-Host "Utente: $($report.UserName)" +Write-Host "Indirizzi TAPI trovati: $($report.AddressCount)" +foreach ($address in $addressSnapshots) { + Write-Host (" - {0} | Provider: {1} | Dialable: {2} | State: {3}" -f $address.AddressName, $address.ServiceProviderName, $address.DialableAddress, $address.State) +} + +Write-Host "" +Write-Host "File creato: $OutFile" +Write-Host "Mandami il contenuto del JSON o almeno le righe del riepilogo rapido." diff --git a/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 b/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 new file mode 100644 index 0000000..6530ccf --- /dev/null +++ b/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 @@ -0,0 +1,106 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Extension, + + [string]$OutFile = ".\netgescon-tapi-address-inspect.json" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-SafeProperty { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$Name + ) + + try { + return $Object.$Name + } + catch { + return $null + } +} + +function Get-SafeMethodResult { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$MethodName, + [object[]]$Arguments = @() + ) + + try { + return $Object.GetType().InvokeMember($MethodName, [System.Reflection.BindingFlags]::InvokeMethod, $null, $Object, $Arguments) + } + catch { + return $null + } +} + +function Convert-ComCollectionToArray { + param($Collection) + + $items = @() + if ($null -eq $Collection) { + return $items + } + + try { + foreach ($item in $Collection) { + $items += $item + } + } + catch { + } + + return $items +} + +$tapi = New-Object -ComObject TAPI.TAPI +$tapi.Initialize() + +$addresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$target = $addresses | Where-Object { + ($_.DialableAddress -as [string]) -eq $Extension -or + ($_.AddressName -as [string]) -match [regex]::Escape($Extension) +} | Select-Object -First 1 + +if (-not $target) { + throw "Nessun indirizzo TAPI trovato per extension=$Extension" +} + +$callsProperty = Get-SafeProperty -Object $target -Name 'Calls' +$calls = @(Convert-ComCollectionToArray -Collection $callsProperty) +$enumCalls = Get-SafeMethodResult -Object $target -MethodName 'EnumerateCalls' + +$report = [pscustomobject]@{ + GeneratedAt = (Get-Date).ToString('o') + Extension = $Extension + Address = [pscustomobject]@{ + AddressName = [string](Get-SafeProperty -Object $target -Name 'AddressName') + DialableAddress = [string](Get-SafeProperty -Object $target -Name 'DialableAddress') + ServiceProviderName = [string](Get-SafeProperty -Object $target -Name 'ServiceProviderName') + State = Get-SafeProperty -Object $target -Name 'State' + MediaTypes = Get-SafeProperty -Object $target -Name 'MediaTypes' + BearerMode = Get-SafeProperty -Object $target -Name 'BearerMode' + } + AddressMembers = @($target | Get-Member | Sort-Object MemberType, Name | Select-Object Name, MemberType, Definition) + CallsPropertyCount = $calls.Count + CallsPropertySnapshot = @($calls | ForEach-Object { + [pscustomobject]@{ + TypeName = $_.GetType().FullName + State = Get-SafeProperty -Object $_ -Name 'State' + CallState = Get-SafeProperty -Object $_ -Name 'CallState' + } + }) + EnumerateCallsType = if ($null -eq $enumCalls) { $null } else { $enumCalls.GetType().FullName } +} + +$report | ConvertTo-Json -Depth 8 | Set-Content -Path $OutFile -Encoding UTF8 + +Write-Host "AddressName: $($report.Address.AddressName)" +Write-Host "DialableAddress: $($report.Address.DialableAddress)" +Write-Host "ServiceProvider: $($report.Address.ServiceProviderName)" +Write-Host "State: $($report.Address.State)" +Write-Host "CallsPropertyCount: $($report.CallsPropertyCount)" +Write-Host "File creato: $OutFile" diff --git a/scripts/ops/windows/inspect-netgescon-tapi-interop.ps1 b/scripts/ops/windows/inspect-netgescon-tapi-interop.ps1 new file mode 100644 index 0000000..2b44106 --- /dev/null +++ b/scripts/ops/windows/inspect-netgescon-tapi-interop.ps1 @@ -0,0 +1,160 @@ +param( + [string]$OutputDir = ".\tapi-interop" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-Section { + param([string]$Text) + Write-Host "" + Write-Host "==== $Text ====" +} + +Add-Type -TypeDefinition @" +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +public static class NetGesconTapiNativeMethods +{ + [DllImport("oleaut32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] + public static extern void LoadTypeLib(string file, out ITypeLib typeLib); +} + +public sealed class NetGesconTapiImporterSink : ITypeLibImporterNotifySink +{ + public void ReportEvent(ImporterEventKind eventKind, int eventCode, string eventMsg) + { + Console.WriteLine("[" + eventKind.ToString() + "] " + eventMsg); + } + + public Assembly ResolveRef(object typeLib) + { + return null; + } +} +"@ + +$tapiDll = Join-Path $env:WINDIR 'System32\tapi3.dll' +if (-not (Test-Path $tapiDll)) { + throw "File non trovato: $tapiDll" +} + +$outputDirectoryInfo = New-Item -ItemType Directory -Force -Path $OutputDir +$outputDirectory = $outputDirectoryInfo.FullName +$interopFileName = 'Tapi3.Interop.dll' +$interopDll = Join-Path $outputDirectory $interopFileName + +Write-Section 'Generazione interop' +Write-Host "sorgente: $tapiDll" +Write-Host "destinaz.: $interopDll" + +$typeLib = $null +[NetGesconTapiNativeMethods]::LoadTypeLib($tapiDll, [ref]$typeLib) + +$converter = New-Object System.Runtime.InteropServices.TypeLibConverter +$sink = New-Object NetGesconTapiImporterSink +Push-Location $outputDirectory +try { + $assemblyBuilder = $converter.ConvertTypeLibToAssembly( + $typeLib, + $interopFileName, + 0, + $sink, + $null, + $null, + $null, + $null + ) + try { + $assemblyBuilder.Save($interopFileName) + } + catch { + Write-Host "[WARN] Save assembly non riuscito: $($_.Exception.Message)" + } +} +finally { + Pop-Location +} + +Write-Section 'Caricamento assembly' +$assembly = $null +if (Test-Path $interopDll) { + $assembly = [System.Reflection.Assembly]::LoadFrom($interopDll) + Write-Host "Assembly caricato da file: $($assembly.FullName)" +} +else { + $assembly = [System.Reflection.Assembly]$assemblyBuilder + Write-Host "Assembly disponibile solo in memoria: $($assembly.FullName)" +} + +$types = $assembly.GetTypes() + +$eventTypes = @($types | Where-Object { + $_.Name -match 'Event' -or $_.FullName -match 'Event' +} | Sort-Object FullName) + +$tapiTypes = @($types | Where-Object { + $_.Name -match 'TAPI|Call|Address|Notification' +} | Sort-Object FullName) + +Write-Section 'Tipi con Event nel nome' +foreach ($type in $eventTypes) { + Write-Host $type.FullName +} + +Write-Section 'Tipi TAPI rilevanti' +foreach ($type in $tapiTypes) { + Write-Host $type.FullName +} + +Write-Section 'Dettaglio tipi evento ITTAPI' +$ittapiRelated = @($types | Where-Object { + $_.FullName -match 'ITTAPI' +} | Sort-Object FullName) + +foreach ($type in $ittapiRelated) { + Write-Host "" + Write-Host $type.FullName + foreach ($member in ($type.GetMembers() | Sort-Object MemberType, Name)) { + Write-Host (" - {0} {1}" -f $member.MemberType, $member.Name) + } +} + +Write-Section 'Dettaglio interfacce evento chiave' +$keyTypes = @( + 'ITTAPIEventNotification', + 'ITTAPIDispatchEventNotification', + 'ITCallStateEvent', + 'ITCallNotificationEvent', + 'ITCallInfoChangeEvent', + 'ITCallMediaEvent', + 'ITAddressEvent', + 'ITTAPIObjectEvent' +) + +foreach ($typeName in $keyTypes) { + $type = $types | Where-Object { $_.Name -eq $typeName } | Select-Object -First 1 + if ($null -eq $type) { + Write-Host "" + Write-Host "$typeName (non trovato)" + continue + } + + Write-Host "" + Write-Host $type.FullName + foreach ($member in ($type.GetMembers() | Sort-Object MemberType, Name)) { + Write-Host (" - {0} {1}" -f $member.MemberType, $member.Name) + } +} + +Write-Section 'Fatto' +if (Test-Path $interopDll) { + Write-Host "Interop creato in: $interopDll" +} +else { + Write-Host "Interop disponibile in memoria; file non salvato su disco." +} +Write-Host "Mandami l'output completo di questo script." diff --git a/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 b/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 new file mode 100644 index 0000000..d89625f --- /dev/null +++ b/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 @@ -0,0 +1,73 @@ +param( + [string]$TaskName = 'NetGescon Panasonic Live Bridge', + [string]$BaseUrl = 'https://staging.netgescon.it', + [string]$Extensions = '201-208,601,603,0001,0003', + [string]$LogFile = 'C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log', + [string]$InteropOutputDir = '', + [int]$RestartDelaySeconds = 5, + [ValidateSet('system-startup', 'current-user-logon')] + [string]$Mode = 'system-startup', + [switch]$IncludeDiagnosticEvents +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +$runnerPath = Join-Path $PSScriptRoot 'start-netgescon-panasonic-live.ps1' +if (-not (Test-Path $runnerPath)) { + throw "Runner non trovato: $runnerPath" +} + +if ([string]::IsNullOrWhiteSpace($InteropOutputDir)) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $InteropOutputDir = Join-Path $env:LOCALAPPDATA 'NetGescon\tapi-interop' + } + else { + $InteropOutputDir = 'C:\ProgramData\NetGescon\tapi-interop' + } +} + +$argumentList = @( + '-NoProfile' + '-ExecutionPolicy', 'Bypass' + '-File', ('"{0}"' -f $runnerPath) + '-BaseUrl', ('"{0}"' -f $BaseUrl) + '-Extensions', ('"{0}"' -f $Extensions) + '-LogFile', ('"{0}"' -f $LogFile) + '-InteropOutputDir', ('"{0}"' -f $InteropOutputDir) + '-RestartDelaySeconds', $RestartDelaySeconds +) + +if ($IncludeDiagnosticEvents.IsPresent) { + $argumentList += '-IncludeDiagnosticEvents' +} + +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ($argumentList -join ' ') +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + +if ($Mode -eq 'system-startup') { + if (-not (Test-IsAdministrator)) { + throw 'La modalita system-startup richiede PowerShell avviata come amministratore.' + } + + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest -LogonType ServiceAccount +} +else { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $currentUser + $principal = New-ScheduledTaskPrincipal -UserId $currentUser -RunLevel Limited -LogonType Interactive +} + +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force -ErrorAction Stop | Out-Null +Write-Host "Task installato: $TaskName" +Write-Host ("Modalita: {0}" -f $Mode) +Write-Host ("Log file: {0}" -f $LogFile) +Write-Host ("Interop dir: {0}" -f $InteropOutputDir) +Write-Host ('Per avviarlo subito: Start-ScheduledTask -TaskName "{0}"' -f $TaskName) \ No newline at end of file diff --git a/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 b/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 new file mode 100644 index 0000000..7a1f2af --- /dev/null +++ b/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 @@ -0,0 +1,265 @@ +param( + [string]$Extensions = "201,205,206", + [string]$LogFile = ".\netgescon-tapi-poll.log", + [int]$IntervalSeconds = 2 +) + +$TapiMediaTypeAudio = 8 +$TapiEventFilterCallNotification = 4 +$TapiEventFilterCallState = 8 +$TapiEventFilterCallMedia = 16 +$TapiEventFilterCallHub = 32 +$TapiEventFilterCallInfoChange = 64 +$TapiEventFilterPrivate = 128 + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-SafeProperty { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$Name + ) + + try { + return $Object.$Name + } + catch { + return $null + } +} + +function Get-SafeCallInfoString { + param( + [Parameter(Mandatory = $true)] $Call, + [Parameter(Mandatory = $true)] [int]$Id + ) + + try { + return [string]$Call.CallInfoString($Id) + } + catch { + return "" + } +} + +function Get-SafeMethodResult { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$MethodName, + [object[]]$Arguments = @() + ) + + try { + return $Object.GetType().InvokeMember($MethodName, [System.Reflection.BindingFlags]::InvokeMethod, $null, $Object, $Arguments) + } + catch { + return $null + } +} + +function Try-RegisterCallNotifications { + param( + [Parameter(Mandatory = $true)] $Tapi, + [Parameter(Mandatory = $true)] $Address, + [Parameter(Mandatory = $true)] [bool]$Monitor, + [Parameter(Mandatory = $true)] [bool]$Owner + ) + + try { + $cookie = $Tapi.RegisterCallNotifications($Address, $Monitor, $Owner, $TapiMediaTypeAudio, 0) + return [pscustomobject]@{ + Ok = $true + Cookie = $cookie + Error = '' + } + } + catch { + return [pscustomobject]@{ + Ok = $false + Cookie = $null + Error = $_.Exception.Message + } + } +} + +function Convert-ComCollectionToArray { + param($Collection) + + $items = @() + if ($null -eq $Collection) { + return $items + } + + try { + foreach ($item in $Collection) { + $items += $item + } + } + catch { + } + + return $items +} + +function Normalize-ExtensionToken { + param([string]$Value) + + if ($null -eq $Value) { + $trimmed = '' + } + else { + $trimmed = [string]$Value + } + $trimmed = $trimmed.Trim() + if ($trimmed -eq '') { + return '' + } + + if ($trimmed -match '(\d+)$') { + return $matches[1].TrimStart('0') + } + + return $trimmed +} + +function Should-WatchAddress { + param( + [Parameter(Mandatory = $true)] $Address, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + $dial = [string](Get-SafeProperty -Object $Address -Name 'DialableAddress') + $name = [string](Get-SafeProperty -Object $Address -Name 'AddressName') + + $dialToken = Normalize-ExtensionToken -Value $dial + $nameToken = Normalize-ExtensionToken -Value $name + + foreach ($watch in $WatchList) { + $watchToken = Normalize-ExtensionToken -Value $watch + if ($watchToken -eq '') { + continue + } + + if ($dialToken -eq $watchToken -or $nameToken -eq $watchToken) { + return $true + } + } + + return $false +} + +function Write-LogLine { + param([string]$Text) + + $line = "{0} | {1}" -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Text + Add-Content -Path $script:LogFilePath -Value $line -Encoding UTF8 + Write-Host $line +} + +function Get-CallHubSnapshot { + param([Parameter(Mandatory = $true)] $Tapi) + + $snapshots = @() + $hubs = Convert-ComCollectionToArray -Collection (Get-SafeProperty -Object $Tapi -Name 'CallHubs') + foreach ($hub in @($hubs)) { + $calls = Convert-ComCollectionToArray -Collection (Get-SafeProperty -Object $hub -Name 'Calls') + $snapshots += [pscustomobject]@{ + HubType = $hub.GetType().FullName + CallCount = @($calls).Count + } + } + + return @($snapshots) +} + +$watchList = @($Extensions.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) +$script:LogFilePath = $LogFile + +$tapi = New-Object -ComObject TAPI.TAPI +$tapi.Initialize() +$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate + +$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$addresses = @($allAddresses | Where-Object { + Should-WatchAddress -Address $_ -WatchList $watchList +}) + +if ($addresses.Count -eq 0) { + throw "Nessun indirizzo TAPI trovato per le estensioni: $Extensions" +} + +Write-LogLine "=== AVVIO POLL TAPI ===" +Write-LogLine "Watch list: $Extensions" +Write-LogLine "Provider addresses totali: $($allAddresses.Count)" +Write-LogLine "Provider addresses filtrati: $($addresses.Count)" +$registrationCookies = @() +foreach ($address in $addresses) { + $addressName = [string](Get-SafeProperty -Object $address -Name 'AddressName') + $dial = [string](Get-SafeProperty -Object $address -Name 'DialableAddress') + $provider = [string](Get-SafeProperty -Object $address -Name 'ServiceProviderName') + Write-LogLine ("ADDRESS READY name={0} dial={1} provider={2}" -f $addressName, $dial, $provider) + + $registration = Try-RegisterCallNotifications -Tapi $tapi -Address $address -Monitor $true -Owner $true + if ($registration.Ok) { + $registrationCookies += $registration.Cookie + Write-LogLine ("REGISTER OK mode=monitor+owner cookie={0} address={1} dial={2}" -f $registration.Cookie, $addressName, $dial) + } + else { + Write-LogLine ("REGISTER KO address={0} dial={1} error={2}" -f $addressName, $dial, $registration.Error) + } +} + +$lastSnapshot = @{} +$lastHubSignature = "__UNSET__" + +while ($true) { + $hubSnapshot = Get-CallHubSnapshot -Tapi $tapi + $hubSignature = (($hubSnapshot | ForEach-Object { "{0}:{1}" -f $_.HubType, $_.CallCount }) -join '|') + if ($hubSignature -ne $lastHubSignature) { + $hubText = $hubSignature + if ($hubText -eq '') { + $hubText = 'none' + } + Write-LogLine ("CALLHUBS {0}" -f $hubText) + $lastHubSignature = $hubSignature + } + + foreach ($address in $addresses) { + $addressName = [string](Get-SafeProperty -Object $address -Name 'AddressName') + $dial = [string](Get-SafeProperty -Object $address -Name 'DialableAddress') + $calls = @(Convert-ComCollectionToArray -Collection (Get-SafeProperty -Object $address -Name 'Calls')) + + if ($calls.Count -eq 0) { + $key = "$addressName|$dial" + if (-not $lastSnapshot.ContainsKey($key) -or $lastSnapshot[$key] -ne 'IDLE') { + Write-LogLine ("IDLE address={0} dial={1}" -f $addressName, $dial) + $lastSnapshot[$key] = 'IDLE' + } + continue + } + + $index = 0 + foreach ($call in $calls) { + $index++ + $state = Get-SafeProperty -Object $call -Name 'State' + if ($null -eq $state) { + $state = Get-SafeProperty -Object $call -Name 'CallState' + } + + $caller = Get-SafeCallInfoString -Call $call -Id 14 + $called = Get-SafeCallInfoString -Call $call -Id 15 + $connected = Get-SafeCallInfoString -Call $call -Id 16 + + $signature = "state=$state;caller=$caller;called=$called;connected=$connected" + $key = "$addressName|$dial|$index" + + if (-not $lastSnapshot.ContainsKey($key) -or $lastSnapshot[$key] -ne $signature) { + Write-LogLine ("CALL address={0} dial={1} idx={2} state={3} caller={4} called={5} connected={6}" -f $addressName, $dial, $index, $state, $caller, $called, $connected) + $lastSnapshot[$key] = $signature + } + } + } + + Start-Sleep -Seconds $IntervalSeconds +} diff --git a/scripts/ops/windows/start-netgescon-panasonic-live.ps1 b/scripts/ops/windows/start-netgescon-panasonic-live.ps1 new file mode 100644 index 0000000..fb24041 --- /dev/null +++ b/scripts/ops/windows/start-netgescon-panasonic-live.ps1 @@ -0,0 +1,69 @@ +param( + [string]$BaseUrl = 'https://staging.netgescon.it', + [string]$Extensions = '201-208,601,603,0001,0003', + [string]$LogFile = '.\netgescon-tapi-dotnet-events-live.log', + [string]$InteropOutputDir = '', + [string]$Token = '', + [int]$RestartDelaySeconds = 5, + [switch]$IncludeDiagnosticEvents +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'watch-netgescon-panasonic-tapi-dotnet-events.ps1' +if (-not (Test-Path $scriptPath)) { + throw "Watcher non trovato: $scriptPath" +} + +if ([string]::IsNullOrWhiteSpace($Token)) { + $Token = [string]([Environment]::GetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', 'Machine')) +} + +if ([string]::IsNullOrWhiteSpace($Token)) { + $Token = [string]([Environment]::GetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', 'User')) +} + +if ([string]::IsNullOrWhiteSpace($Token)) { + throw 'Token mancante. Imposta NETGESCON_CTI_SHARED_TOKEN a livello User o Machine, oppure passa -Token.' +} + +if ([string]::IsNullOrWhiteSpace($InteropOutputDir)) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $InteropOutputDir = Join-Path $env:LOCALAPPDATA 'NetGescon\tapi-interop' + } + else { + $InteropOutputDir = Join-Path $PSScriptRoot 'tapi-interop' + } +} +elseif (-not [System.IO.Path]::IsPathRooted($InteropOutputDir)) { + $InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir +} + +while ($true) { + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + Write-Host "$timestamp | AVVIO bridge live Panasonic -> NetGescon" + + try { + $params = @{ + Extensions = $Extensions + LogFile = $LogFile + InteropOutputDir = $InteropOutputDir + BridgeMode = 'live' + BaseUrl = $BaseUrl + Token = $Token + } + + if ($IncludeDiagnosticEvents.IsPresent) { + $params.IncludeDiagnosticEvents = $true + } + + & $scriptPath @params + } + catch { + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + Write-Host "$timestamp | ERRORE bridge live: $($_.Exception.Message)" + } + + Start-Sleep -Seconds ([Math]::Max(1, $RestartDelaySeconds)) +} \ No newline at end of file diff --git a/scripts/ops/windows/test-netgescon-panasonic-api.ps1 b/scripts/ops/windows/test-netgescon-panasonic-api.ps1 new file mode 100644 index 0000000..893f7c4 --- /dev/null +++ b/scripts/ops/windows/test-netgescon-panasonic-api.ps1 @@ -0,0 +1,70 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaseUrl, + + [Parameter(Mandatory = $true)] + [string]$Token, + + [string]$Phone = "+393331112233", + [string]$Extension = "205", + [string]$EventId = "evt-win-test-001" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$headers = @{ "X-CTI-Token" = $Token } + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)] [string]$Label, + [Parameter(Mandatory = $true)] [scriptblock]$Action + ) + + Write-Host "" + Write-Host "==== $Label ====" + & $Action +} + +Invoke-Step -Label "1. Lookup rubrica" -Action { + $uri = "{0}/api/v1/cti/panasonic/lookup?phone={1}" -f $BaseUrl.TrimEnd('/'), [uri]::EscapeDataString($Phone) + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get + $response | ConvertTo-Json -Depth 8 +} + +Invoke-Step -Label "2. Simulazione incoming" -Action { + $body = @{ + phone = $Phone + direction = "inbound" + event_type = "Delivered" + called_extension = $Extension + event_id = $EventId + called_at = (Get-Date).ToString("o") + note = "test incoming da PowerShell Windows" + } | ConvertTo-Json + + $response = Invoke-RestMethod -Uri ("{0}/api/v1/cti/panasonic/incoming" -f $BaseUrl.TrimEnd('/')) -Headers $headers -Method Post -ContentType "application/json" -Body $body + $response | ConvertTo-Json -Depth 8 +} + +Start-Sleep -Seconds 2 + +Invoke-Step -Label "3. Simulazione call-ended" -Action { + $body = @{ + phone = $Phone + direction = "inbound" + event_type = "ConnectionCleared" + called_extension = $Extension + event_id = $EventId + duration_seconds = 12 + outcome = "risposta" + ended_at = (Get-Date).ToString("o") + note = "test call-ended da PowerShell Windows" + } | ConvertTo-Json + + $response = Invoke-RestMethod -Uri ("{0}/api/v1/cti/panasonic/call-ended" -f $BaseUrl.TrimEnd('/')) -Headers $headers -Method Post -ContentType "application/json" -Body $body + $response | ConvertTo-Json -Depth 8 +} + +Write-Host "" +Write-Host "Test completato. Se tutti i blocchi rispondono ok=true, la parte HTTP verso NetGescon e pronta." diff --git a/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 b/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 new file mode 100644 index 0000000..4e08520 --- /dev/null +++ b/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 @@ -0,0 +1,143 @@ +param( + [string]$Extensions = "201,205,206" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$TapiMediaTypeAudio = 8 + +function Get-SafeProperty { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$Name + ) + + try { + return $Object.$Name + } + catch { + return $null + } +} + +function Convert-ComCollectionToArray { + param($Collection) + + $items = @() + if ($null -eq $Collection) { + return $items + } + + try { + foreach ($item in $Collection) { + $items += $item + } + } + catch { + } + + return $items +} + +function Normalize-ExtensionToken { + param([string]$Value) + + if ($null -eq $Value) { + return '' + } + + $trimmed = ([string]$Value).Trim() + if ($trimmed -eq '') { + return '' + } + + if ($trimmed -match '(\d+)$') { + return $matches[1].TrimStart('0') + } + + return $trimmed +} + +function Should-WatchAddress { + param( + [Parameter(Mandatory = $true)] $Address, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + $dial = Normalize-ExtensionToken -Value ([string](Get-SafeProperty -Object $Address -Name 'DialableAddress')) + $name = Normalize-ExtensionToken -Value ([string](Get-SafeProperty -Object $Address -Name 'AddressName')) + + foreach ($watch in $WatchList) { + $token = Normalize-ExtensionToken -Value $watch + if ($token -ne '' -and ($dial -eq $token -or $name -eq $token)) { + return $true + } + } + + return $false +} + +function Try-RegisterCallNotifications { + param( + [Parameter(Mandatory = $true)] $Tapi, + [Parameter(Mandatory = $true)] $Address, + [Parameter(Mandatory = $true)] [bool]$Monitor, + [Parameter(Mandatory = $true)] [bool]$Owner + ) + + try { + $cookie = $Tapi.RegisterCallNotifications($Address, $Monitor, $Owner, $TapiMediaTypeAudio, 0) + return [pscustomobject]@{ + Ok = $true + Cookie = $cookie + HResult = '' + Message = '' + } + } + catch { + $exception = $_.Exception + return [pscustomobject]@{ + Ok = $false + Cookie = $null + HResult = ('0x{0:X8}' -f ($exception.HResult -band 0xffffffff)) + Message = $exception.Message + } + } +} + +$watchList = @($Extensions.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + +$tapi = New-Object -ComObject TAPI.TAPI +$tapi.Initialize() + +$addresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses | Where-Object { + Should-WatchAddress -Address $_ -WatchList $watchList +}) + +Write-Host "Indirizzi filtrati: $($addresses.Count)" + +foreach ($address in $addresses) { + $addressName = [string](Get-SafeProperty -Object $address -Name 'AddressName') + $dial = [string](Get-SafeProperty -Object $address -Name 'DialableAddress') + $provider = [string](Get-SafeProperty -Object $address -Name 'ServiceProviderName') + + Write-Host "" + Write-Host ("=== {0} / {1} / {2} ===" -f $addressName, $dial, $provider) + + $tests = @( + @{ Monitor = $true; Owner = $false; Label = 'monitor=true owner=false' }, + @{ Monitor = $false; Owner = $true; Label = 'monitor=false owner=true' }, + @{ Monitor = $true; Owner = $true; Label = 'monitor=true owner=true' } + ) + + foreach ($test in $tests) { + $result = Try-RegisterCallNotifications -Tapi $tapi -Address $address -Monitor $test.Monitor -Owner $test.Owner + if ($result.Ok) { + Write-Host ("OK {0} cookie={1}" -f $test.Label, $result.Cookie) + } + else { + Write-Host ("FAIL {0} hresult={1} message={2}" -f $test.Label, $result.HResult, $result.Message) + } + } +} diff --git a/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 b/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 new file mode 100644 index 0000000..ef341d1 --- /dev/null +++ b/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 @@ -0,0 +1,181 @@ +param( + [string]$Extensions = "201,205,206", + [string]$LogFile = ".\netgescon-tapi-com-events.log", + [int]$Seconds = 0 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$TapiMediaTypeAudio = 8 +$TapiEventFilterCallNotification = 4 +$TapiEventFilterCallState = 8 +$TapiEventFilterCallMedia = 16 +$TapiEventFilterCallHub = 32 +$TapiEventFilterCallInfoChange = 64 +$TapiEventFilterPrivate = 128 + +function Get-SafeProperty { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$Name + ) + + try { + return $Object.$Name + } + catch { + return $null + } +} + +function Convert-ComCollectionToArray { + param($Collection) + + $items = @() + if ($null -eq $Collection) { + return $items + } + + try { + foreach ($item in $Collection) { + $items += $item + } + } + catch { + } + + return $items +} + +function Normalize-ExtensionToken { + param([string]$Value) + + if ($null -eq $Value) { + return '' + } + + $trimmed = ([string]$Value).Trim() + if ($trimmed -eq '') { + return '' + } + + if ($trimmed -match '(\d+)$') { + return $matches[1].TrimStart('0') + } + + return $trimmed +} + +function Should-WatchAddress { + param( + [Parameter(Mandatory = $true)] $Address, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + $dial = Normalize-ExtensionToken -Value ([string](Get-SafeProperty -Object $Address -Name 'DialableAddress')) + $name = Normalize-ExtensionToken -Value ([string](Get-SafeProperty -Object $Address -Name 'AddressName')) + + foreach ($watch in $WatchList) { + $token = Normalize-ExtensionToken -Value $watch + if ($token -ne '' -and ($dial -eq $token -or $name -eq $token)) { + return $true + } + } + + return $false +} + +function Write-LogLine { + param([string]$Text) + + $line = "{0} | {1}" -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Text + Add-Content -Path $script:LogFilePath -Value $line -Encoding UTF8 + Write-Host $line +} + +function Try-RegisterCallNotifications { + param( + [Parameter(Mandatory = $true)] $Tapi, + [Parameter(Mandatory = $true)] $Address + ) + + try { + $cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0) + return [pscustomobject]@{ + Ok = $true + Cookie = $cookie + Error = '' + } + } + catch { + return [pscustomobject]@{ + Ok = $false + Cookie = $null + Error = $_.Exception.Message + } + } +} + +$script:LogFilePath = $LogFile +$watchList = @($Extensions.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + +$tapi = New-Object -ComObject TAPI.TAPI +$tapi.Initialize() +$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate + +$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$addresses = @($allAddresses | Where-Object { + Should-WatchAddress -Address $_ -WatchList $watchList +}) + +Write-LogLine "=== AVVIO WATCH COM TAPI ===" +Write-LogLine "Watch list: $Extensions" +Write-LogLine "Provider addresses totali: $($allAddresses.Count)" +Write-LogLine "Provider addresses filtrati: $($addresses.Count)" + +foreach ($address in $addresses) { + $addressName = [string](Get-SafeProperty -Object $address -Name 'AddressName') + $dial = [string](Get-SafeProperty -Object $address -Name 'DialableAddress') + $provider = [string](Get-SafeProperty -Object $address -Name 'ServiceProviderName') + Write-LogLine ("ADDRESS READY name={0} dial={1} provider={2}" -f $addressName, $dial, $provider) + + $registration = Try-RegisterCallNotifications -Tapi $tapi -Address $address + if ($registration.Ok) { + Write-LogLine ("REGISTER OK mode=monitor+owner cookie={0} address={1} dial={2}" -f $registration.Cookie, $addressName, $dial) + } + else { + Write-LogLine ("REGISTER KO address={0} dial={1} error={2}" -f $addressName, $dial, $registration.Error) + } +} + +$sourceId = 'NetGescon.Tapi.Event' +try { + Unregister-Event -SourceIdentifier $sourceId -ErrorAction SilentlyContinue +} catch {} + +$eventRegistration = Register-ObjectEvent -InputObject $tapi -EventName Event -SourceIdentifier $sourceId -Action { + $eventObject = $Event.SourceEventArgs + $messageData = $Event.MessageData + $logPath = $messageData.LogPath + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $line = "{0} | COM EVENT type={1} argsType={2}" -f $timestamp, $eventObject.Event, $eventObject.GetType().FullName + Add-Content -Path $logPath -Value $line -Encoding UTF8 + Write-Host $line +} + -MessageData @{ LogPath = $LogFile } + +Write-LogLine "Sottoscrizione COM attiva. Fai ora chiamate reali su 201, 205 o 206." + +if ($Seconds -gt 0) { + $end = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $end) { + Wait-Event -SourceIdentifier $sourceId -Timeout 1 | Out-Null + } +} +else { + while ($true) { + Wait-Event -SourceIdentifier $sourceId -Timeout 1 | Out-Null + } +} diff --git a/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 b/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 new file mode 100644 index 0000000..ca162cf --- /dev/null +++ b/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 @@ -0,0 +1,1417 @@ +param( + [string]$Extensions = "201,205,206", + [string]$LogFile = ".\netgescon-tapi-dotnet-events.log", + [int]$Seconds = 0, + [string]$InteropOutputDir = '', + [ValidateSet('none', 'dry-run', 'live')] + [string]$BridgeMode = 'none', + [string]$BaseUrl = '', + [string]$Token = '', + [switch]$IncludeDiagnosticEvents +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($InteropOutputDir)) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $InteropOutputDir = Join-Path $env:LOCALAPPDATA 'NetGescon\tapi-interop' + } + else { + $InteropOutputDir = Join-Path $PSScriptRoot 'tapi-interop' + } +} +elseif (-not [System.IO.Path]::IsPathRooted($InteropOutputDir)) { + $InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir +} + +Add-Type -AssemblyName System.Runtime.InteropServices + +$TapiMediaTypeAudio = 8 +$TapiEventFilterCallNotification = 4 +$TapiEventFilterCallState = 8 +$TapiEventFilterCallMedia = 16 +$TapiEventFilterCallHub = 32 +$TapiEventFilterCallInfoChange = 64 +$TapiEventFilterPrivate = 128 +$CallStateIdle = 0 +$CallStateInProgress = 1 +$CallStateConnected = 2 +$CallStateDisconnected = 3 +$CallStateOffering = 4 +$CallStateHold = 5 +$CallStateQueued = 6 + +function Get-SafeProperty { + param( + [Parameter(Mandatory = $true)] $Object, + [Parameter(Mandatory = $true)] [string]$Name + ) + + if ($null -eq $Object) { + return $null + } + + try { + return $Object.$Name + } + catch { + return $null + } +} + +function Get-SafeTypeName { + param($Object) + + if ($null -eq $Object) { + return '' + } + + try { + $type = $Object.GetType() + if ($null -eq $type) { + return '' + } + + return [string]$type.FullName + } + catch { + return '' + } +} + +function Convert-ToSafeString { + param($Value) + + if ($null -eq $Value) { + return '' + } + + try { + return [string]$Value + } + catch { + return '' + } +} + +function Get-FirstNonEmptyProperty { + param( + $Object, + [Parameter(Mandatory = $true)] [string[]]$Names + ) + + if ($null -eq $Object) { + return '' + } + + foreach ($name in $Names) { + $value = Get-SafeProperty -Object $Object -Name $name + $valueString = Convert-ToSafeString -Value $value + if ($valueString -ne '') { + return $valueString + } + } + + return '' +} + +function Normalize-PhoneLikeValue { + param([string]$Value) + + if ($null -eq $Value) { + return '' + } + + $trimmed = $Value.Trim() + if ($trimmed -eq '') { + return '' + } + + return ($trimmed -replace '[^\d\+]', '') +} + +function Expand-ExtensionToken { + param([string]$Value) + + $text = Convert-ToSafeString -Value $Value + if ($text -eq '') { + return @() + } + + if ($text -match '^(\d+)-(\d+)$') { + $start = [int]$matches[1] + $end = [int]$matches[2] + if ($end -lt $start) { + $swap = $start + $start = $end + $end = $swap + } + + $expanded = @() + for ($index = $start; $index -le $end; $index++) { + $expanded += [string]$index + } + + return $expanded + } + + return @($text) +} + +function Parse-WatchList { + param([string]$ExtensionsValue) + + $watchItems = New-Object System.Collections.Generic.List[string] + foreach ($rawToken in @($ExtensionsValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })) { + foreach ($expandedToken in @(Expand-ExtensionToken -Value $rawToken)) { + $token = Convert-ToSafeString -Value $expandedToken + if ($token -ne '' -and -not $watchItems.Contains($token)) { + $watchItems.Add($token) + } + } + } + + return @($watchItems) +} + +function Is-WatchedExtensionValue { + param( + [string]$Value, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + $token = Normalize-ExtensionToken -Value $Value + if ($token -eq '') { + return $false + } + + return $WatchList -contains $token +} + +function Select-ExternalPhoneCandidate { + param( + [string[]]$Candidates, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + if ($null -eq $Candidates) { + return '' + } + + foreach ($candidate in $Candidates) { + $normalized = Normalize-PhoneLikeValue -Value $candidate + if ($normalized -eq '') { + continue + } + + if (-not (Is-WatchedExtensionValue -Value $normalized -WatchList $WatchList)) { + return $normalized + } + } + + return '' +} + +function Convert-ComCollectionToArray { + param($Collection) + + $items = @() + if ($null -eq $Collection) { + return $items + } + + try { + foreach ($item in $Collection) { + $items += $item + } + } + catch { + } + + return $items +} + +function Normalize-ExtensionToken { + param([string]$Value) + + if ($null -eq $Value) { + return '' + } + + $trimmed = ([string]$Value).Trim() + if ($trimmed -eq '') { + return '' + } + + if ($trimmed -match '(\d+)$') { + return $matches[1].TrimStart('0') + } + + return $trimmed +} + +function Get-AddressCategory { + param([string]$AddressName) + + if ($null -eq $AddressName) { + return '' + } + + $trimmed = $AddressName.Trim().ToUpperInvariant() + if ($trimmed -match '^([A-Z]+)') { + return $matches[1] + } + + return '' +} + +function Convert-ToNullableInt { + param([string]$Value) + + if ($null -eq $Value) { + return $null + } + + $digits = ($Value -replace '\D+', '') + if ($digits -eq '') { + return $null + } + + try { + return [int]$digits + } + catch { + return $null + } +} + +function Should-WatchAddress { + param( + [Parameter(Mandatory = $true)] $Address, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + $rawDial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress') + $rawName = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName') + $dial = Normalize-ExtensionToken -Value $rawDial + $name = Normalize-ExtensionToken -Value $rawName + $category = Get-AddressCategory -AddressName $rawName + + foreach ($watch in $WatchList) { + $watchRaw = Convert-ToSafeString -Value $watch + $token = Normalize-ExtensionToken -Value $watchRaw + if ($token -eq '') { + continue + } + + if ($watchRaw -match '^0\d+$') { + if ($category -ne 'CO') { + continue + } + + $watchInt = Convert-ToNullableInt -Value $watchRaw + $nameInt = Convert-ToNullableInt -Value $rawName + $dialInt = Convert-ToNullableInt -Value $rawDial + if (($null -ne $watchInt -and $null -ne $nameInt -and $watchInt -eq $nameInt) -or ($null -ne $watchInt -and $null -ne $dialInt -and $watchInt -eq $dialInt)) { + return $true + } + + continue + } + + if ($dial -eq $token -or $name -eq $token) { + return $true + } + } + + return $false +} + +function Get-BridgeWatchMode { + param([Parameter(Mandatory = $true)] [string[]]$WatchList) + + foreach ($watch in $WatchList) { + $value = Convert-ToSafeString -Value $watch + if ($value -match '^6\d\d$') { + return 'group-first' + } + } + + foreach ($watch in $WatchList) { + $value = Convert-ToSafeString -Value $watch + if ($value -match '^0\d+$') { + return 'co-first' + } + } + + return 'extension-only' +} + +function Should-EmitBridgeForSnapshot { + param([Parameter(Mandatory = $true)] $Snapshot) + + if ($Snapshot.Direction -ne 'inbound') { + switch ($script:BridgeWatchMode) { + 'co-first' { + return $Snapshot.AddressCategory -eq 'CO' + } + 'group-first' { + return $Snapshot.AddressCategory -eq 'EXT' -or $Snapshot.AddressCategory -eq 'CO' + } + default { + return $Snapshot.AddressCategory -eq 'EXT' + } + } + } + + switch ($script:BridgeWatchMode) { + 'group-first' { + return $Snapshot.AddressCategory -eq 'GRP' + } + 'co-first' { + return $Snapshot.AddressCategory -eq 'CO' + } + default { + return $Snapshot.AddressCategory -eq 'EXT' + } + } +} + +function Should-LogDotNetEvent { + param([string]$EventType) + + if ($script:IncludeDiagnosticEvents) { + return $true + } + + return $EventType -eq 'TE_CALLNOTIFICATION' -or $EventType -eq 'TE_CALLSTATE' +} + +function Resolve-BridgeEventId { + param([string]$EventId) + + if ($EventId -eq '') { + return '' + } + + if ($script:BridgeEventAliases.ContainsKey($EventId)) { + return [string]$script:BridgeEventAliases[$EventId] + } + + return $EventId +} + +function Test-BridgeCallRecentlyCompleted { + param([string]$EventId) + + if ($EventId -eq '' -or -not $script:CompletedBridgeCalls.ContainsKey($EventId)) { + return $false + } + + $completedAt = $script:CompletedBridgeCalls[$EventId] + if ($null -eq $completedAt) { + return $false + } + + $ageSeconds = [Math]::Abs((New-TimeSpan -Start $completedAt -End (Get-Date)).TotalSeconds) + if ($ageSeconds -gt 30) { + [void]$script:CompletedBridgeCalls.Remove($EventId) + return $false + } + + return $true +} + +function Mark-BridgeCallCompleted { + param([string]$EventId) + + if ($EventId -ne '') { + $script:CompletedBridgeCalls[$EventId] = Get-Date + } +} + +function Find-BridgeCallMatchBySignature { + param([Parameter(Mandatory = $true)] $Snapshot) + + if ($Snapshot.Direction -ne 'outbound') { + return $null + } + + $snapshotPhone = Convert-ToSafeString -Value $Snapshot.ExternalPhone + $snapshotCallingExtension = Convert-ToSafeString -Value $Snapshot.CallingExtension + $now = Get-Date + + foreach ($eventId in @($script:BridgeCalls.Keys)) { + $callRecord = $script:BridgeCalls[$eventId] + if ($null -eq $callRecord) { + continue + } + + if ((Convert-ToSafeString -Value $callRecord.Direction) -ne 'outbound') { + continue + } + + $ageSeconds = [Math]::Abs((New-TimeSpan -Start $callRecord.StartedAt -End $now).TotalSeconds) + if ($ageSeconds -gt 120) { + continue + } + + $recordPhone = Convert-ToSafeString -Value $callRecord.ExternalPhone + if ($snapshotPhone -ne '' -and $recordPhone -ne '' -and $snapshotPhone -ne $recordPhone) { + continue + } + + $recordCallingExtension = Convert-ToSafeString -Value $callRecord.CallingExtension + if ($snapshotCallingExtension -ne '' -and $recordCallingExtension -ne '' -and $snapshotCallingExtension -ne $recordCallingExtension) { + continue + } + + return [pscustomobject]@{ + EventId = [string]$eventId + CallRecord = $callRecord + } + } + + return $null +} + +function Write-LogLine { + param([string]$Text) + + $line = "{0} | {1}" -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Text + Write-Host $line + + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $directory = Split-Path -Parent $script:LogFilePath + if ($directory -and -not (Test-Path $directory)) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null + } + + $fileMode = [System.IO.FileMode]::OpenOrCreate + $fileAccess = [System.IO.FileAccess]::Write + $fileShare = [System.IO.FileShare]::ReadWrite + $stream = New-Object System.IO.FileStream($script:LogFilePath, $fileMode, $fileAccess, $fileShare) + try { + $stream.Seek(0, [System.IO.SeekOrigin]::End) | Out-Null + $writer = New-Object System.IO.StreamWriter($stream, [System.Text.UTF8Encoding]::new($false)) + try { + $writer.WriteLine($line) + $writer.Flush() + return + } + finally { + $writer.Dispose() + } + } + finally { + $stream.Dispose() + } + } + catch { + if ($attempt -eq 3) { + Write-Host ("[WARN] Log file non scrivibile: {0}" -f $_.Exception.Message) + return + } + + Start-Sleep -Milliseconds 150 + } + } +} + +function Try-RegisterCallNotifications { + param( + [Parameter(Mandatory = $true)] $Tapi, + [Parameter(Mandatory = $true)] $Address + ) + + try { + $cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0) + return [pscustomobject]@{ + Ok = $true + Cookie = $cookie + Error = '' + } + } + catch { + return [pscustomobject]@{ + Ok = $false + Cookie = $null + Error = $_.Exception.Message + } + } +} + +function Get-SafeCallInfoString { + param( + $Call, + [Parameter(Mandatory = $true)] [int]$Id + ) + + if ($null -eq $Call) { + return '' + } + + try { + return Convert-ToSafeString -Value ($Call.CallInfoString($Id)) + } + catch { + return '' + } +} + +function Get-PreferredCallInfoString { + param( + $Call, + [Parameter(Mandatory = $true)] [int[]]$Ids + ) + + foreach ($id in $Ids) { + $value = Get-SafeCallInfoString -Call $Call -Id $id + if ($value -ne '') { + return $value + } + } + + return '' +} + +function Get-TypeByName { + param( + [Parameter(Mandatory = $true)] [System.Reflection.Assembly]$Assembly, + [Parameter(Mandatory = $true)] [string]$Name + ) + + return $Assembly.GetTypes() | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 +} + +function Get-InteropAssembly { + param([Parameter(Mandatory = $true)] [string]$OutputDir) + + Add-Type -TypeDefinition @" +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +public static class NetGesconTapiWatcherNativeMethods +{ + [DllImport("oleaut32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] + public static extern void LoadTypeLib(string file, out ITypeLib typeLib); +} + +public sealed class NetGesconTapiWatcherImporterSink : ITypeLibImporterNotifySink +{ + public void ReportEvent(ImporterEventKind eventKind, int eventCode, string eventMsg) + { + } + + public Assembly ResolveRef(object typeLib) + { + return null; + } +} +"@ + + $tapiDll = Join-Path $env:WINDIR 'System32\tapi3.dll' + if (-not (Test-Path $tapiDll)) { + throw "File non trovato: $tapiDll" + } + + $outputDirectoryInfo = New-Item -ItemType Directory -Force -Path $OutputDir + $outputDirectory = $outputDirectoryInfo.FullName + $interopFileName = 'Tapi3.Interop.dll' + $interopDll = Join-Path $outputDirectory $interopFileName + + $typeLib = $null + [NetGesconTapiWatcherNativeMethods]::LoadTypeLib($tapiDll, [ref]$typeLib) + + $converter = New-Object System.Runtime.InteropServices.TypeLibConverter + $sink = New-Object NetGesconTapiWatcherImporterSink + + Push-Location $outputDirectory + try { + $assemblyBuilder = $converter.ConvertTypeLibToAssembly( + $typeLib, + $interopFileName, + 0, + $sink, + $null, + $null, + $null, + $null + ) + + try { + $assemblyBuilder.Save($interopFileName) + } + catch { + } + } + finally { + Pop-Location + } + + if (Test-Path $interopDll) { + return [System.Reflection.Assembly]::LoadFrom($interopDll) + } + + return [System.Reflection.Assembly]$assemblyBuilder +} + +function Describe-Address { + param($Address) + + if ($null -eq $Address) { + return '' + } + + $name = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName') + $dial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress') + $provider = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'ServiceProviderName') + return "address=$name dial=$dial provider=$provider" +} + +function Describe-Call { + param($Call) + + if ($null -eq $Call) { + return '' + } + + $parts = @() + foreach ($name in @('CallState', 'Privilege', 'MediaType', 'CallerIDAddress', 'CalledIDAddress', 'ConnectedIDAddress', 'CallerIDName', 'CalledIDName', 'ConnectedIDName')) { + $value = Get-SafeProperty -Object $Call -Name $name + $valueString = Convert-ToSafeString -Value $value + if ($null -ne $value -and $valueString -ne '') { + $parts += ("{0}={1}" -f $name, $valueString) + } + } + + $callAddress = Get-SafeProperty -Object $Call -Name 'Address' + if ($null -ne $callAddress) { + $parts += (Describe-Address -Address $callAddress) + } + + return ($parts -join ' ') +} + +function Get-PhoneDebugSummary { + param($Call) + + if ($null -eq $Call) { + return 'call=' + } + + $candidates = @( + 'CallerIDAddress', + 'CalledIDAddress', + 'ConnectedIDAddress', + 'CallerIDName', + 'CalledIDName', + 'ConnectedIDName', + 'CalledPartyID', + 'CallingPartyID', + 'OriginatingAddress', + 'DestinationAddress', + 'DisplayableAddress' + ) + + $parts = @() + foreach ($name in $candidates) { + $valueString = Convert-ToSafeString -Value (Get-SafeProperty -Object $Call -Name $name) + if ($valueString -ne '') { + $parts += ("{0}={1}" -f $name, $valueString) + } + } + + $callInfo01 = Get-SafeCallInfoString -Call $Call -Id 1 + $callInfo03 = Get-SafeCallInfoString -Call $Call -Id 3 + $callInfo05 = Get-SafeCallInfoString -Call $Call -Id 5 + $callInfo12 = Get-SafeCallInfoString -Call $Call -Id 12 + $callInfo13 = Get-SafeCallInfoString -Call $Call -Id 13 + $callInfo14 = Get-SafeCallInfoString -Call $Call -Id 14 + $callInfo15 = Get-SafeCallInfoString -Call $Call -Id 15 + $callInfo16 = Get-SafeCallInfoString -Call $Call -Id 16 + if ($callInfo01 -ne '') { + $parts += ("CallInfoString1_CallerIdNumber={0}" -f $callInfo01) + } + if ($callInfo03 -ne '') { + $parts += ("CallInfoString3_CalledIdNumber={0}" -f $callInfo03) + } + if ($callInfo05 -ne '') { + $parts += ("CallInfoString5_ConnectedIdNumber={0}" -f $callInfo05) + } + if ($callInfo12 -ne '') { + $parts += ("CallInfoString12_DisplayableAddress={0}" -f $callInfo12) + } + if ($callInfo13 -ne '') { + $parts += ("CallInfoString13_CallingPartyId={0}" -f $callInfo13) + } + if ($callInfo14 -ne '') { + $parts += ("CallInfoString14={0}" -f $callInfo14) + } + if ($callInfo15 -ne '') { + $parts += ("CallInfoString15={0}" -f $callInfo15) + } + if ($callInfo16 -ne '') { + $parts += ("CallInfoString16={0}" -f $callInfo16) + } + + if ($parts.Count -eq 0) { + return 'callProperties=' + } + + return ($parts -join ' ') +} + +function Get-CallIdentity { + param($Call) + + if ($null -eq $Call) { + return '' + } + + try { + $pointer = [System.Runtime.InteropServices.Marshal]::GetIUnknownForObject($Call) + try { + if ($pointer -eq [IntPtr]::Zero) { + return '' + } + + return ('0x{0}' -f $pointer.ToInt64().ToString('X')) + } + finally { + if ($pointer -ne [IntPtr]::Zero) { + [void][System.Runtime.InteropServices.Marshal]::Release($pointer) + } + } + } + catch { + return '' + } +} + +function Get-CallSnapshot { + param( + [Parameter(Mandatory = $true)] [string]$EventType, + $Payload, + [Parameter(Mandatory = $true)] [string[]]$WatchList + ) + + $call = Get-SafeProperty -Object $Payload -Name 'Call' + $address = Get-SafeProperty -Object $Payload -Name 'Address' + if ($null -eq $address -and $null -ne $call) { + $address = Get-SafeProperty -Object $call -Name 'Address' + } + + $addressName = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'AddressName') + $dialableAddress = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'DialableAddress') + $addressCategory = Get-AddressCategory -AddressName $addressName + $watchedExtension = Normalize-ExtensionToken -Value $dialableAddress + if ($watchedExtension -eq '') { + $watchedExtension = Normalize-ExtensionToken -Value $addressName + } + + $callState = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'State') + $cause = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'Cause') + + $callerIdAddress = Normalize-PhoneLikeValue -Value (Get-FirstNonEmptyProperty -Object $call -Names @('CallerIDAddress', 'CallingPartyID', 'CallerAddress', 'OriginatingAddress')) + $calledIdAddress = Normalize-PhoneLikeValue -Value (Get-FirstNonEmptyProperty -Object $call -Names @('CalledIDAddress', 'CalledPartyID', 'DestinationAddress')) + $connectedIdAddress = Normalize-PhoneLikeValue -Value (Get-FirstNonEmptyProperty -Object $call -Names @('ConnectedIDAddress', 'ConnectedAddress')) + + if ($callerIdAddress -eq '') { + $callerIdAddress = Normalize-PhoneLikeValue -Value (Get-PreferredCallInfoString -Call $call -Ids @(1, 13, 12, 14)) + } + if ($calledIdAddress -eq '') { + $calledIdAddress = Normalize-PhoneLikeValue -Value (Get-PreferredCallInfoString -Call $call -Ids @(3, 12, 15)) + } + if ($connectedIdAddress -eq '') { + $connectedIdAddress = Normalize-PhoneLikeValue -Value (Get-PreferredCallInfoString -Call $call -Ids @(5, 12, 16)) + } + + $callerName = Get-FirstNonEmptyProperty -Object $call -Names @('CallerIDName', 'CallingPartyName') + if ($callerName -eq '') { + $callerName = Get-SafeCallInfoString -Call $call -Id 0 + } + + $rawCallerIdAddress = $callerIdAddress + $rawCalledIdAddress = $calledIdAddress + $rawConnectedIdAddress = $connectedIdAddress + + $direction = 'inbound' + $externalPhone = Select-ExternalPhoneCandidate -Candidates @($callerIdAddress, $connectedIdAddress, $calledIdAddress) -WatchList $WatchList + + $callerToken = Normalize-ExtensionToken -Value $callerIdAddress + $calledToken = Normalize-ExtensionToken -Value $calledIdAddress + $connectedToken = Normalize-ExtensionToken -Value $connectedIdAddress + + $callingExtension = '' + $calledExtension = $watchedExtension + + if ($callerToken -ne '' -and ($WatchList -contains $callerToken) -and $calledIdAddress -ne '' -and -not ($WatchList -contains $calledToken)) { + $direction = 'outbound' + $externalPhone = Select-ExternalPhoneCandidate -Candidates @($calledIdAddress, $connectedIdAddress, $callerIdAddress) -WatchList $WatchList + $callingExtension = $callerToken + $calledExtension = $watchedExtension + } + elseif ($connectedToken -ne '' -and ($WatchList -contains $connectedToken) -and $callerToken -eq '') { + $calledExtension = $connectedToken + } + elseif ($watchedExtension -eq '' -and $connectedToken -ne '' -and ($WatchList -contains $connectedToken)) { + $calledExtension = $connectedToken + } + + $eventId = Get-CallIdentity -Call $call + if ($eventId -eq '') { + $eventId = "{0}:{1}:{2}" -f $addressName, $watchedExtension, $EventType + } + + return [pscustomobject]@{ + EventType = $EventType + EventId = $eventId + CallState = $callState + Cause = $cause + AddressName = $addressName + AddressCategory = $addressCategory + WatchedExtension = $watchedExtension + CalledExtension = $calledExtension + CallingExtension = $callingExtension + Direction = $direction + ExternalPhone = $externalPhone + RawCallerIdAddress = $rawCallerIdAddress + RawCalledIdAddress = $rawCalledIdAddress + RawConnectedIdAddress = $rawConnectedIdAddress + CallerName = $callerName + CallObject = $call + } +} + +function Build-IncomingPayload { + param([Parameter(Mandatory = $true)] $CallRecord) + + return [ordered]@{ + phone = $CallRecord.ExternalPhone + name = $CallRecord.CallerName + direction = $CallRecord.Direction + outcome = $null + event_id = $CallRecord.EventId + called_extension = $CallRecord.CalledExtension + calling_extension = $CallRecord.CallingExtension + event_type = $CallRecord.LastEventType + note = 'bridge dry-run da watcher TAPI .NET' + called_at = $CallRecord.StartedAt.ToString('o') + is_test = $true + } +} + +function Build-CallEndedPayload { + param([Parameter(Mandatory = $true)] $CallRecord) + + $durationSeconds = [Math]::Max(0, [int][Math]::Round((New-TimeSpan -Start $CallRecord.StartedAt -End $CallRecord.EndedAt).TotalSeconds)) + $outcome = 'terminata' + if ($CallRecord.Answered) { + $outcome = 'risposta' + } + + return [ordered]@{ + phone = $CallRecord.ExternalPhone + event_id = $CallRecord.EventId + outcome = $outcome + duration_seconds = $durationSeconds + ended_at = $CallRecord.EndedAt.ToString('o') + direction = $CallRecord.Direction + called_extension = $CallRecord.CalledExtension + calling_extension = $CallRecord.CallingExtension + event_type = $CallRecord.LastEventType + note = 'bridge dry-run da watcher TAPI .NET' + is_test = $true + } +} + +function Invoke-BridgeRequest { + param( + [Parameter(Mandatory = $true)] [string]$Endpoint, + [Parameter(Mandatory = $true)] [System.Collections.Specialized.OrderedDictionary]$Payload + ) + + $json = $Payload | ConvertTo-Json -Depth 8 -Compress + if ($script:BridgeMode -eq 'dry-run') { + Write-LogLine ("BRIDGE DRYRUN endpoint={0} payload={1}" -f $Endpoint, $json) + return + } + + if ($script:BridgeMode -ne 'live') { + return + } + + if ($script:BaseUrl -eq '' -or $script:Token -eq '') { + Write-LogLine ("BRIDGE SKIP endpoint={0} reason=missing-baseurl-or-token payload={1}" -f $Endpoint, $json) + return + } + + try { + $headers = @{ 'X-CTI-Token' = $script:Token } + $uri = ('{0}/api/v1/cti/panasonic/{1}' -f $script:BaseUrl.TrimEnd('/'), $Endpoint) + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Post -ContentType 'application/json' -Body $json + Write-LogLine ("BRIDGE LIVE OK endpoint={0} response={1}" -f $Endpoint, (($response | ConvertTo-Json -Depth 8 -Compress))) + } + catch { + Write-LogLine ("BRIDGE LIVE KO endpoint={0} error={1} payload={2}" -f $Endpoint, $_.Exception.Message, $json) + } +} + +function Register-BridgeIncoming { + param([Parameter(Mandatory = $true)] $Snapshot) + + if (-not (Should-EmitBridgeForSnapshot -Snapshot $Snapshot)) { + Write-LogLine ("BRIDGE IGNORE endpoint=incoming address={0} category={1} mode={2}" -f $Snapshot.AddressName, $Snapshot.AddressCategory, $script:BridgeWatchMode) + return + } + + $resolvedEventId = Resolve-BridgeEventId -EventId $Snapshot.EventId + if ($resolvedEventId -eq '' -and $Snapshot.EventId -ne '') { + $resolvedEventId = $Snapshot.EventId + } + + if ($resolvedEventId -eq '') { + $resolvedEventId = "{0}:{1}:{2}" -f $Snapshot.AddressName, $Snapshot.CalledExtension, $Snapshot.EventType + } + + if (Test-BridgeCallRecentlyCompleted -EventId $resolvedEventId) { + Write-LogLine ("BRIDGE IGNORE endpoint=incoming reason=already-completed event_id={0} address={1}" -f $resolvedEventId, $Snapshot.AddressName) + return + } + + if (-not $script:BridgeCalls.ContainsKey($resolvedEventId)) { + $matchedCall = Find-BridgeCallMatchBySignature -Snapshot $Snapshot + if ($null -ne $matchedCall) { + $resolvedEventId = $matchedCall.EventId + if ($Snapshot.EventId -ne '') { + $script:BridgeEventAliases[$Snapshot.EventId] = $resolvedEventId + } + } + } + + if ($script:BridgeCalls.ContainsKey($resolvedEventId)) { + return + } + + $callRecord = [ordered]@{ + EventId = $resolvedEventId + StartedAt = Get-Date + EndedAt = $null + LastEventType = $Snapshot.EventType + CalledExtension = $Snapshot.CalledExtension + CallingExtension = $Snapshot.CallingExtension + Direction = $Snapshot.Direction + ExternalPhone = $Snapshot.ExternalPhone + CallerName = $Snapshot.CallerName + Answered = $false + IncomingSent = $false + } + + if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) { + $script:BridgeEventAliases[$Snapshot.EventId] = $resolvedEventId + } + + $script:BridgeCalls[$resolvedEventId] = $callRecord + + if ($callRecord.ExternalPhone -eq '') { + Write-LogLine ("BRIDGE SKIP endpoint=incoming reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension) + Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress) + Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject)) + return + } + + Invoke-BridgeRequest -Endpoint 'incoming' -Payload (Build-IncomingPayload -CallRecord $callRecord) + $callRecord.IncomingSent = $true +} + +function Register-BridgeStateTransition { + param([Parameter(Mandatory = $true)] $Snapshot) + + if (-not (Should-EmitBridgeForSnapshot -Snapshot $Snapshot)) { + Write-LogLine ("BRIDGE IGNORE endpoint=state address={0} category={1} mode={2}" -f $Snapshot.AddressName, $Snapshot.AddressCategory, $script:BridgeWatchMode) + return + } + + if ($Snapshot.Direction -eq 'outbound' -and $Snapshot.AddressCategory -eq 'CO' -and $Snapshot.CallingExtension -eq '') { + Write-LogLine ("BRIDGE WARN unresolved-outbound-source address={0} phone={1} rawCaller={2} rawCalled={3} rawConnected={4}" -f $Snapshot.AddressName, $Snapshot.ExternalPhone, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress) + } + + $resolvedEventId = Resolve-BridgeEventId -EventId $Snapshot.EventId + if ($resolvedEventId -eq '' -and $Snapshot.EventId -ne '') { + $resolvedEventId = $Snapshot.EventId + } + + if ($resolvedEventId -eq '') { + $resolvedEventId = "{0}:{1}:{2}" -f $Snapshot.AddressName, $Snapshot.CalledExtension, $Snapshot.EventType + } + + if (Test-BridgeCallRecentlyCompleted -EventId $resolvedEventId) { + Write-LogLine ("BRIDGE IGNORE endpoint=state reason=already-completed event_id={0} address={1}" -f $resolvedEventId, $Snapshot.AddressName) + return + } + + if (-not $script:BridgeCalls.ContainsKey($resolvedEventId)) { + $matchedCall = Find-BridgeCallMatchBySignature -Snapshot $Snapshot + if ($null -ne $matchedCall) { + $resolvedEventId = $matchedCall.EventId + if ($Snapshot.EventId -ne '') { + $script:BridgeEventAliases[$Snapshot.EventId] = $resolvedEventId + } + } + } + + $callRecord = $null + if ($script:BridgeCalls.ContainsKey($resolvedEventId)) { + $callRecord = $script:BridgeCalls[$resolvedEventId] + } + else { + $callRecord = [ordered]@{ + EventId = $resolvedEventId + StartedAt = Get-Date + EndedAt = $null + LastEventType = $Snapshot.EventType + CalledExtension = $Snapshot.CalledExtension + CallingExtension = $Snapshot.CallingExtension + Direction = $Snapshot.Direction + ExternalPhone = $Snapshot.ExternalPhone + CallerName = $Snapshot.CallerName + Answered = $false + IncomingSent = $false + } + + if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) { + $script:BridgeEventAliases[$Snapshot.EventId] = $resolvedEventId + } + + $script:BridgeCalls[$resolvedEventId] = $callRecord + } + + if ($callRecord.ExternalPhone -eq '' -and $Snapshot.ExternalPhone -ne '') { + $callRecord.ExternalPhone = $Snapshot.ExternalPhone + } + + if ($callRecord.CallerName -eq '' -and $Snapshot.CallerName -ne '') { + $callRecord.CallerName = $Snapshot.CallerName + } + + if ($callRecord.CalledExtension -eq '' -and $Snapshot.CalledExtension -ne '') { + $callRecord.CalledExtension = $Snapshot.CalledExtension + } + + if ($callRecord.CallingExtension -eq '' -and $Snapshot.CallingExtension -ne '') { + $callRecord.CallingExtension = $Snapshot.CallingExtension + } + + if ($callRecord.Direction -eq '' -and $Snapshot.Direction -ne '') { + $callRecord.Direction = $Snapshot.Direction + } + + if ($Snapshot.EventType -ne '') { + $callRecord.LastEventType = $Snapshot.EventType + } + + if (-not $callRecord.IncomingSent -and $callRecord.ExternalPhone -ne '') { + Invoke-BridgeRequest -Endpoint 'incoming' -Payload (Build-IncomingPayload -CallRecord $callRecord) + $callRecord.IncomingSent = $true + } + + switch ([int]$Snapshot.CallState) { + $CallStateConnected { + $callRecord.Answered = $true + Write-LogLine ("BRIDGE MARK answered event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension) + break + } + $CallStateDisconnected { + $callRecord.EndedAt = Get-Date + if ($callRecord.ExternalPhone -eq '') { + Write-LogLine ("BRIDGE SKIP endpoint=call-ended reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension) + Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress) + Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject)) + } + else { + Invoke-BridgeRequest -Endpoint 'call-ended' -Payload (Build-CallEndedPayload -CallRecord $callRecord) + } + + Mark-BridgeCallCompleted -EventId $resolvedEventId + [void]$script:BridgeCalls.Remove($resolvedEventId) + if ($Snapshot.EventId -ne '' -and $script:BridgeEventAliases.ContainsKey($Snapshot.EventId)) { + [void]$script:BridgeEventAliases.Remove($Snapshot.EventId) + } + break + } + } +} + +function Describe-EventPayload { + param($Payload) + + if ($null -eq $Payload) { + return 'payload=' + } + + $parts = @() + + $payloadTypeName = Get-SafeTypeName -Object $Payload + $parts += ("payloadType={0}" -f $payloadTypeName) + + + if ($payloadTypeName -eq '') { + try { + if ([System.Runtime.InteropServices.Marshal]::IsComObject($Payload)) { + $parts += 'payloadComObject=true' + } + } + catch { + } + } + + $eventValue = Get-SafeProperty -Object $Payload -Name 'Event' + if ($null -ne $eventValue) { + $parts += ("event={0}" -f (Convert-ToSafeString -Value $eventValue)) + + } + + $stateValue = Get-SafeProperty -Object $Payload -Name 'State' + if ($null -ne $stateValue) { + $parts += ("state={0}" -f (Convert-ToSafeString -Value $stateValue)) + } + + + $causeValue = Get-SafeProperty -Object $Payload -Name 'Cause' + if ($null -ne $causeValue) { + $parts += ("cause={0}" -f (Convert-ToSafeString -Value $causeValue)) + + } + + $errorValue = Get-SafeProperty -Object $Payload -Name 'Error' + if ($null -ne $errorValue) { + $parts += ("error={0}" -f (Convert-ToSafeString -Value $errorValue)) + } + + $address = Get-SafeProperty -Object $Payload -Name 'Address' + if ($null -ne $address) { + $parts += (Describe-Address -Address $address) + } + + $call = Get-SafeProperty -Object $Payload -Name 'Call' + if ($null -ne $call) { + $parts += (Describe-Call -Call $call) + } + + if ($parts.Count -le 1) { + $parts += 'payloadDetails=non-accessible' + } + + return ($parts -join ' ') +} + +function Try-SubscribeDotNetEvent { + param( + [Parameter(Mandatory = $true)] $InputObject, + [Parameter(Mandatory = $true)] [string]$SourceIdentifier, + [Parameter(Mandatory = $true)] [string]$Label + ) + + try { + $events = @($InputObject.GetType().GetEvents() | Select-Object -ExpandProperty Name) + Write-LogLine ("DOTNET CHECK label={0} type={1} events={2}" -f $Label, $InputObject.GetType().FullName, ($events -join ',')) + if (-not ($events -contains 'Event')) { + return [pscustomobject]@{ + Ok = $false + Error = 'evento .NET Event non esposto' + } + } + + Register-ObjectEvent -InputObject $InputObject -EventName Event -SourceIdentifier $SourceIdentifier | Out-Null + return [pscustomobject]@{ + Ok = $true + Error = '' + } + } + catch { + return [pscustomobject]@{ + Ok = $false + Error = $_.Exception.Message + } + } +} + +function Process-ReceivedEvent { + param([Parameter(Mandatory = $true)] $EventRecord) + + try { + $sourceIdentifier = Convert-ToSafeString -Value $EventRecord.SourceIdentifier + $sourceArgs = @($EventRecord.SourceArgs) + $argTypes = @() + foreach ($arg in $sourceArgs) { + $argTypes += (Get-SafeTypeName -Object $arg) + } + + $eventType = '' + if ($sourceArgs.Count -ge 1 -and $null -ne $sourceArgs[0]) { + $eventType = Convert-ToSafeString -Value $sourceArgs[0] + } + + if (Should-LogDotNetEvent -EventType $eventType) { + $payloadDescription = 'payload=' + if ($sourceArgs.Count -ge 2) { + $payloadDescription = Describe-EventPayload -Payload $sourceArgs[1] + } + + Write-LogLine ("DOTNET EVENT source={0} eventType={1} argTypes={2} {3}" -f $sourceIdentifier, $eventType, ($argTypes -join ','), $payloadDescription) + } + + if ($script:BridgeMode -ne 'none' -and $sourceArgs.Count -ge 2 -and ($eventType -eq 'TE_CALLNOTIFICATION' -or $eventType -eq 'TE_CALLSTATE')) { + $snapshot = Get-CallSnapshot -EventType $eventType -Payload $sourceArgs[1] -WatchList $watchList + if ($eventType -eq 'TE_CALLNOTIFICATION') { + Register-BridgeIncoming -Snapshot $snapshot + } + elseif ($eventType -eq 'TE_CALLSTATE' -and $snapshot.CallState -ne '') { + Register-BridgeStateTransition -Snapshot $snapshot + } + } + } + catch { + Write-LogLine ("DOTNET EVENT ERROR source={0} error={1}" -f (Convert-ToSafeString -Value $EventRecord.SourceIdentifier), $_.Exception.Message) + } +} + +$script:LogFilePath = $LogFile +$script:BridgeMode = $BridgeMode +$script:BaseUrl = $BaseUrl +$script:Token = $Token +$script:IncludeDiagnosticEvents = $IncludeDiagnosticEvents.IsPresent +$script:BridgeCalls = @{} +$script:BridgeEventAliases = @{} +$script:CompletedBridgeCalls = @{} +$watchList = @(Parse-WatchList -ExtensionsValue $Extensions) +$script:BridgeWatchMode = Get-BridgeWatchMode -WatchList $watchList +$subscriptions = @() +$wrappers = @() + +$assembly = Get-InteropAssembly -OutputDir $InteropOutputDir +$tapiClassType = Get-TypeByName -Assembly $assembly -Name 'TAPIClass' +$typedEventInterfaceType = Get-TypeByName -Assembly $assembly -Name 'ITTAPIEventNotification_Event' +$dispatchEventInterfaceType = Get-TypeByName -Assembly $assembly -Name 'ITTAPIDispatchEventNotification_Event' + +$tapi = New-Object -ComObject TAPI.TAPI +$tapi.Initialize() +$eventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterPrivate +if ($script:IncludeDiagnosticEvents) { + $eventFilter += $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange +} +$tapi.EventFilter = $eventFilter + +$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$addresses = @($allAddresses | Where-Object { + Should-WatchAddress -Address $_ -WatchList $watchList +}) + +Write-LogLine "=== AVVIO WATCH DOTNET TAPI ===" +Write-LogLine ("Assembly interop: {0}" -f $assembly.FullName) +Write-LogLine "Watch list: $Extensions" +Write-LogLine ("Watch list expanded: {0}" -f ($watchList -join ',')) +Write-LogLine "Provider addresses totali: $($allAddresses.Count)" +Write-LogLine "Provider addresses filtrati: $($addresses.Count)" +Write-LogLine "Bridge mode: $BridgeMode" +Write-LogLine "Bridge watch mode: $($script:BridgeWatchMode)" +Write-LogLine ("Diagnostic events: {0}" -f ($(if ($script:IncludeDiagnosticEvents) { 'enabled' } else { 'disabled' }))) + +foreach ($address in $addresses) { + Write-LogLine ("ADDRESS READY {0}" -f (Describe-Address -Address $address)) + $registration = Try-RegisterCallNotifications -Tapi $tapi -Address $address + if ($registration.Ok) { + Write-LogLine ("REGISTER OK mode=monitor+owner cookie={0} {1}" -f $registration.Cookie, (Describe-Address -Address $address)) + } + else { + Write-LogLine ("REGISTER KO {0} error={1}" -f (Describe-Address -Address $address), $registration.Error) + } +} + +if ($null -ne $tapiClassType) { + try { + $typedTapi = [System.Runtime.InteropServices.Marshal]::CreateWrapperOfType($tapi, $tapiClassType) + $wrappers += $typedTapi + $subscriptions += [pscustomobject]@{ Label = 'tapi-class'; Result = (Try-SubscribeDotNetEvent -InputObject $typedTapi -SourceIdentifier 'NetGescon.Tapi.DotNet.TapiClass' -Label 'tapi-class') } + } + catch { + Write-LogLine ("DOTNET WRAP KO label=tapi-class error={0}" -f $_.Exception.Message) + } +} + +if ($null -ne $typedEventInterfaceType) { + try { + $typedEventSource = [System.Runtime.InteropServices.Marshal]::CreateWrapperOfType($tapi, $typedEventInterfaceType) + $wrappers += $typedEventSource + $subscriptions += [pscustomobject]@{ Label = 'typed-event-interface'; Result = (Try-SubscribeDotNetEvent -InputObject $typedEventSource -SourceIdentifier 'NetGescon.Tapi.DotNet.TypedEvent' -Label 'typed-event-interface') } + } + catch { + Write-LogLine ("DOTNET WRAP KO label=typed-event-interface error={0}" -f $_.Exception.Message) + } +} + +if ($null -ne $dispatchEventInterfaceType) { + try { + $dispatchEventSource = [System.Runtime.InteropServices.Marshal]::CreateWrapperOfType($tapi, $dispatchEventInterfaceType) + $wrappers += $dispatchEventSource + $subscriptions += [pscustomobject]@{ Label = 'dispatch-event-interface'; Result = (Try-SubscribeDotNetEvent -InputObject $dispatchEventSource -SourceIdentifier 'NetGescon.Tapi.DotNet.DispatchEvent' -Label 'dispatch-event-interface') } + } + catch { + Write-LogLine ("DOTNET WRAP KO label=dispatch-event-interface error={0}" -f $_.Exception.Message) + } +} + +foreach ($subscription in $subscriptions) { + if ($subscription.Result.Ok) { + Write-LogLine ("DOTNET SUBSCRIBE OK label={0}" -f $subscription.Label) + } + else { + Write-LogLine ("DOTNET SUBSCRIBE KO label={0} error={1}" -f $subscription.Label, $subscription.Result.Error) + } +} + +Write-LogLine 'Fai ora chiamate reali su 201, 205 o 206.' + +$end = $null +if ($Seconds -gt 0) { + $end = (Get-Date).AddSeconds($Seconds) +} + +try { + while ($true) { + if ($null -ne $end -and (Get-Date) -ge $end) { + break + } + + $eventRecord = Wait-Event -Timeout 1 + if ($null -eq $eventRecord) { + continue + } + + $sourceIdentifier = Convert-ToSafeString -Value $eventRecord.SourceIdentifier + if ($sourceIdentifier -notlike 'NetGescon.Tapi.DotNet.*') { + Remove-Event -EventIdentifier $eventRecord.EventIdentifier -ErrorAction SilentlyContinue + continue + } + + Process-ReceivedEvent -EventRecord $eventRecord + Remove-Event -EventIdentifier $eventRecord.EventIdentifier -ErrorAction SilentlyContinue + } +} +finally { + foreach ($sourceIdentifier in @('NetGescon.Tapi.DotNet.TapiClass', 'NetGescon.Tapi.DotNet.TypedEvent', 'NetGescon.Tapi.DotNet.DispatchEvent')) { + Unregister-Event -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue + Get-Event -SourceIdentifier $sourceIdentifier -ErrorAction SilentlyContinue | Remove-Event -ErrorAction SilentlyContinue + } + + try { + $tapi.Shutdown() + } + catch { + } +} \ No newline at end of file diff --git a/scripts/ops/windows/watch-netgescon-panasonic-tapi-events.vbs b/scripts/ops/windows/watch-netgescon-panasonic-tapi-events.vbs new file mode 100644 index 0000000..571a2a8 --- /dev/null +++ b/scripts/ops/windows/watch-netgescon-panasonic-tapi-events.vbs @@ -0,0 +1,281 @@ +Option Explicit + +Const TE_TAPIOBJECT = 1 +Const TE_ADDRESS = 2 +Const TE_CALLNOTIFICATION = 4 +Const TE_CALLSTATE = 8 +Const TE_CALLMEDIA = 16 +Const TE_CALLHUB = 32 +Const TE_CALLINFOCHANGE = 64 +Const TE_PRIVATE = 128 +Const TE_REQUEST = 256 +Const TE_AGENT = 512 +Const TE_AGENTSESSION = 1024 +Const TE_QOSEVENT = 2048 +Const TE_AGENTHANDLER = 4096 +Const TE_ACDGROUP = 8192 +Const TE_QUEUE = 16384 +Const TE_DIGITEVENT = 32768 +Const TE_GENERATEEVENT = 65536 + +Const TAPIMEDIATYPE_AUDIO = 8 + +Dim g_tapi +Dim g_logFile +Dim g_watchList +Dim g_cookieMap + +Sub Main() + Dim args + Set args = WScript.Arguments + + If args.Count < 1 Then + WScript.Echo "Uso: cscript //nologo watch-netgescon-panasonic-tapi-events.vbs 201,205,206 [logfile]" + WScript.Quit 1 + End If + + g_watchList = "," & Trim(args.Item(0)) & "," + If args.Count >= 2 Then + g_logFile = args.Item(1) + Else + g_logFile = CreateObject("Scripting.FileSystemObject").GetAbsolutePathName(".") & "\\netgescon-tapi-events.log" + End If + + Set g_cookieMap = CreateObject("Scripting.Dictionary") + + LogLine "=== AVVIO WATCHER TAPI ===" + LogLine "Watch list: " & g_watchList + LogLine "Log file: " & g_logFile + + Set g_tapi = WScript.CreateObject("TAPI.TAPI", "Tapi_") + g_tapi.Initialize + g_tapi.EventFilter = TE_CALLNOTIFICATION + TE_CALLSTATE + TE_CALLINFOCHANGE + TE_CALLMEDIA + TE_PRIVATE + + RegisterAddresses + + LogLine "Watcher pronto. Ora fai una chiamata reale su 201, 205 o 206." + LogLine "Premi Ctrl+C per fermare lo script." + + Do + WScript.Sleep 1000 + Loop +End Sub + +Sub RegisterAddresses() + On Error Resume Next + + Dim address + Dim name + Dim dial + Dim cookie + + For Each address In g_tapi.Addresses + name = SafeProp(address, "AddressName") + dial = SafeProp(address, "DialableAddress") + + If ShouldWatch(name, dial) Then + Err.Clear + cookie = g_tapi.RegisterCallNotifications(address, True, False, TAPIMEDIATYPE_AUDIO, 0) + If Err.Number = 0 Then + g_cookieMap(CStr(cookie)) = name & "|" & dial + LogLine "REGISTER OK cookie=" & cookie & " address=" & name & " dial=" & dial + Else + LogLine "REGISTER KO address=" & name & " dial=" & dial & " err=" & Err.Number & " desc=" & Err.Description + Err.Clear + End If + End If + Next +End Sub + +Function ShouldWatch(name, dial) + Dim token + token = "" + + If Trim(CStr(dial)) <> "" Then + token = Trim(CStr(dial)) + Else + token = Trim(CStr(name)) + End If + + ShouldWatch = (InStr(1, g_watchList, "," & token & ",", vbTextCompare) > 0) + + If Not ShouldWatch Then + If Left(UCase(CStr(name)), 3) = "EXT" Then + token = Right(CStr(name), 3) + ShouldWatch = (InStr(1, g_watchList, "," & token & ",", vbTextCompare) > 0) + End If + End If + + If Not ShouldWatch Then + If Left(UCase(CStr(name)), 3) = "GRP" Then + token = Right(CStr(name), 3) + ShouldWatch = (InStr(1, g_watchList, "," & token & ",", vbTextCompare) > 0) + End If + End If +End Function + +Sub Tapi_Event(ByVal eventType, ByVal pEvent) + On Error Resume Next + + Dim objectType + Dim addressName + Dim dialableAddress + Dim stateValue + Dim causeValue + Dim privilegeValue + Dim infoText + + objectType = TypeName(pEvent) + addressName = ResolveAddressName(pEvent) + dialableAddress = ResolveDialableAddress(pEvent) + stateValue = SafeProp(pEvent, "State") + causeValue = SafeProp(pEvent, "Cause") + privilegeValue = SafeProp(pEvent, "Privilege") + infoText = "" + + If SafeProp(pEvent, "Call") <> "" Then + infoText = ResolveCallInfo(pEvent) + End If + + LogLine "EVENT type=" & eventType & " object=" & objectType & " address=" & addressName & " dial=" & dialableAddress & " state=" & stateValue & " cause=" & causeValue & " privilege=" & privilegeValue & " info=" & infoText +End Sub + +Function ResolveAddressName(pEvent) + On Error Resume Next + Dim value + value = "" + + value = SafeNestedProp(pEvent, "Call", "Address", "AddressName") + If value <> "" Then + ResolveAddressName = value + Exit Function + End If + + value = SafeNestedProp(pEvent, "Address", "AddressName", "") + ResolveAddressName = value +End Function + +Function ResolveDialableAddress(pEvent) + On Error Resume Next + Dim value + value = "" + + value = SafeNestedProp(pEvent, "Call", "Address", "DialableAddress") + If value <> "" Then + ResolveDialableAddress = value + Exit Function + End If + + value = SafeNestedProp(pEvent, "Address", "DialableAddress", "") + ResolveDialableAddress = value +End Function + +Function ResolveCallInfo(pEvent) + On Error Resume Next + Dim callObj + Dim buffer + + buffer = "" + Set callObj = Nothing + Err.Clear + Set callObj = pEvent.Call + If Err.Number <> 0 Then + Err.Clear + ResolveCallInfo = "" + Exit Function + End If + + buffer = buffer & "caller=" & SafeCallInfoString(callObj, 14) & ";" + buffer = buffer & "called=" & SafeCallInfoString(callObj, 15) & ";" + buffer = buffer & "connected=" & SafeCallInfoString(callObj, 16) & ";" + + ResolveCallInfo = buffer +End Function + +Function SafeCallInfoString(callObj, infoId) + On Error Resume Next + Dim value + value = "" + value = callObj.CallInfoString(infoId) + If Err.Number <> 0 Then + Err.Clear + SafeCallInfoString = "" + Else + SafeCallInfoString = Trim(CStr(value)) + End If +End Function + +Function SafeProp(obj, propName) + On Error Resume Next + Dim value + value = "" + value = CallByName(obj, propName, VbGet) + If Err.Number <> 0 Then + Err.Clear + SafeProp = "" + Else + SafeProp = Trim(CStr(value)) + End If +End Function + +Function SafeNestedProp(obj, prop1, prop2, prop3) + On Error Resume Next + Dim level1 + Dim level2 + Dim value + + SafeNestedProp = "" + Set level1 = Nothing + Set level2 = Nothing + + Err.Clear + Set level1 = CallByName(obj, prop1, VbGet) + If Err.Number <> 0 Then + Err.Clear + Exit Function + End If + + If prop3 = "" Then + value = CallByName(level1, prop2, VbGet) + If Err.Number = 0 Then + SafeNestedProp = Trim(CStr(value)) + Else + Err.Clear + End If + Exit Function + End If + + Set level2 = CallByName(level1, prop2, VbGet) + If Err.Number <> 0 Then + Err.Clear + Exit Function + End If + + value = CallByName(level2, prop3, VbGet) + If Err.Number = 0 Then + SafeNestedProp = Trim(CStr(value)) + Else + Err.Clear + End If +End Function + +Sub LogLine(text) + Dim fso + Dim stream + Dim line + + Set fso = CreateObject("Scripting.FileSystemObject") + Set stream = fso.OpenTextFile(g_logFile, 8, True) + line = NowIso() & " | " & text + stream.WriteLine line + stream.Close + WScript.Echo line +End Sub + +Function NowIso() + Dim dt + dt = Now + NowIso = Year(dt) & "-" & Right("0" & Month(dt), 2) & "-" & Right("0" & Day(dt), 2) & " " & Right("0" & Hour(dt), 2) & ":" & Right("0" & Minute(dt), 2) & ":" & Right("0" & Second(dt), 2) +End Function + +Main diff --git a/scripts/systemd/netgescon-panasonic-csta-bridge.service.template b/scripts/systemd/netgescon-panasonic-csta-bridge.service.template new file mode 100644 index 0000000..f8af05b --- /dev/null +++ b/scripts/systemd/netgescon-panasonic-csta-bridge.service.template @@ -0,0 +1,20 @@ +[Unit] +Description=NetGescon Panasonic CSTA bridge +After=network-online.target mysql.service +Wants=network-online.target + +[Service] +Type=simple +User=@RUN_AS@ +Group=@RUN_GROUP@ +WorkingDirectory=@BASE_DIR@ +ExecStart=/usr/bin/env bash -lc '@BASE_DIR@/scripts/ops/netgescon-panasonic-csta-bridge.sh' +Restart=always +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=30 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target \ No newline at end of file