Allinea lotto Day0: CTI Panasonic, workflow update e documentazione
This commit is contained in:
parent
1a468fdd78
commit
cc1c8f69aa
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -118,3 +118,7 @@ Miki-Bug-workspace/**
|
||||||
# Local mirrored historical material (not versioned)
|
# Local mirrored historical material (not versioned)
|
||||||
_legacy-vault/
|
_legacy-vault/
|
||||||
docs/ai/restart/DAY0-AUDIT-*.txt
|
docs/ai/restart/DAY0-AUDIT-*.txt
|
||||||
|
|
||||||
|
# Windows TAPI probe outputs (generated locally)
|
||||||
|
inspect-*.json
|
||||||
|
netgescon-tapi-info.json
|
||||||
|
|
|
||||||
195
app/Console/Commands/PanasonicCstaBridgeCommand.php
Normal file
195
app/Console/Commands/PanasonicCstaBridgeCommand.php
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PanasonicCstaBridgeCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cti:bridge-panasonic
|
||||||
|
{--host=192.168.0.101 : Panasonic host/IP}
|
||||||
|
{--port=33333 : Panasonic CSTA TCP port}
|
||||||
|
{--timeout=10 : Connect and read timeout in seconds}
|
||||||
|
{--read-bytes=2048 : Bytes to read per frame/chunk}
|
||||||
|
{--reconnect=1 : Auto reconnect when socket closes}
|
||||||
|
{--reconnect-delay=5 : Seconds before reconnect}
|
||||||
|
{--max-idle-timeouts=30 : Reconnect after N consecutive read timeouts (0 = never)}
|
||||||
|
{--startup-send= : Optional ASCII payload sent right after connect}
|
||||||
|
{--startup-send-hex= : Optional HEX payload sent right after connect}
|
||||||
|
{--log-file=storage/logs/cti-panasonic-raw.ndjson : NDJSON file where raw frames are appended}
|
||||||
|
{--print-hex=1 : Print each frame as hex in console output}
|
||||||
|
{--print-text=1 : Print best-effort text rendering in console output}';
|
||||||
|
|
||||||
|
protected $description = 'Minimal Panasonic CSTA bridge daemon: connects to the PBX, captures raw frames and keeps the socket alive for protocol analysis.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$host = (string) $this->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<string,mixed> $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) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Console/Commands/PanasonicCtiProbeCommand.php
Normal file
71
app/Console/Commands/PanasonicCtiProbeCommand.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PanasonicCtiProbeCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cti:probe-panasonic
|
||||||
|
{--host=192.168.0.101 : Panasonic host/IP}
|
||||||
|
{--port=33333 : Panasonic CTI TCP port}
|
||||||
|
{--timeout=5 : Connect and read timeout in seconds}
|
||||||
|
{--send= : Optional ASCII payload to send after connect}
|
||||||
|
{--read-bytes=512 : Max bytes to read from socket}
|
||||||
|
{--hex : Print payload and response as hex dump}';
|
||||||
|
|
||||||
|
protected $description = 'Probe the Panasonic CTI TCP endpoint and print the first response bytes for protocol analysis.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$host = (string) $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use App\Models\ChiamataPostIt;
|
use App\Models\ChiamataPostIt;
|
||||||
use App\Models\CommunicationMessage;
|
use App\Models\CommunicationMessage;
|
||||||
use App\Models\User;
|
use App\Services\Cti\PbxRoutingService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -33,6 +33,7 @@ class SmdrListenCommand extends Command
|
||||||
{--user=SMDR : Login username}
|
{--user=SMDR : Login username}
|
||||||
{--pass=PCCSMDR : Login password}
|
{--pass=PCCSMDR : Login password}
|
||||||
{--timeout=0 : Read timeout in seconds (0 = no timeout)}
|
{--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=1 : Auto-reconnect when socket closes}
|
||||||
{--reconnect-delay=3 : Seconds before reconnect attempt}
|
{--reconnect-delay=3 : Seconds before reconnect attempt}
|
||||||
{--write-postit=1 : Create Post-it records from incoming SMDR lines}
|
{--write-postit=1 : Create Post-it records from incoming SMDR lines}
|
||||||
|
|
@ -49,6 +50,7 @@ public function handle(): int
|
||||||
$user = (string) $this->option('user');
|
$user = (string) $this->option('user');
|
||||||
$pass = (string) $this->option('pass');
|
$pass = (string) $this->option('pass');
|
||||||
$timeout = (int) $this->option('timeout');
|
$timeout = (int) $this->option('timeout');
|
||||||
|
$maxIdleTimeouts = max(0, (int) $this->option('max-idle-timeouts'));
|
||||||
$reconnect = (bool) ((int) $this->option('reconnect'));
|
$reconnect = (bool) ((int) $this->option('reconnect'));
|
||||||
$reconnectDelay = max(1, (int) $this->option('reconnect-delay'));
|
$reconnectDelay = max(1, (int) $this->option('reconnect-delay'));
|
||||||
$maxLines = (int) $this->option('max-lines');
|
$maxLines = (int) $this->option('max-lines');
|
||||||
|
|
@ -108,6 +110,7 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Listening for SMDR lines...');
|
$this->info('Listening for SMDR lines...');
|
||||||
|
$idleTimeouts = 0;
|
||||||
|
|
||||||
while (! feof($socket)) {
|
while (! feof($socket)) {
|
||||||
$line = fgets($socket);
|
$line = fgets($socket);
|
||||||
|
|
@ -115,7 +118,15 @@ public function handle(): int
|
||||||
if ($line === false) {
|
if ($line === false) {
|
||||||
$meta = stream_get_meta_data($socket);
|
$meta = stream_get_meta_data($socket);
|
||||||
if (($meta['timed_out'] ?? false) === true) {
|
if (($meta['timed_out'] ?? false) === true) {
|
||||||
|
$idleTimeouts++;
|
||||||
$this->warn('Read timeout reached, waiting for next data...');
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +134,8 @@ public function handle(): int
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$line = trim($line);
|
$idleTimeouts = 0;
|
||||||
|
$line = trim($line);
|
||||||
if ($line === '') {
|
if ($line === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -472,24 +484,14 @@ private function mapPostItDirection(string $direction, ?int $durationSeconds): s
|
||||||
|
|
||||||
private function resolveRouting(string $extension): array
|
private function resolveRouting(string $extension): array
|
||||||
{
|
{
|
||||||
$ext = trim($extension);
|
$routing = app(PbxRoutingService::class)->resolveByExtension($extension);
|
||||||
if ($ext === '' || ! Schema::hasColumn('users', 'pbx_extension')) {
|
if (! $routing['user']) {
|
||||||
return [null, null];
|
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 [
|
return [
|
||||||
(int) $user->id,
|
$routing['user_id'],
|
||||||
$stabileId ? (int) $stabileId : null,
|
$routing['stabile_id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class LettureServiziArchivio extends Page implements HasTable
|
||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
public ?int $servizioFilter = null;
|
public ?int $servizioFilter = null;
|
||||||
public string $archivioScope = 'active';
|
public string $archivioScope = 'active';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Letture servizi';
|
protected static ?string $navigationLabel = 'Letture servizi';
|
||||||
|
|
@ -190,7 +190,7 @@ public function table(Table $table): Table
|
||||||
}
|
}
|
||||||
|
|
||||||
$codice = trim((string) ($stabile->codice_stabile ?? ''));
|
$codice = trim((string) ($stabile->codice_stabile ?? ''));
|
||||||
$nome = trim((string) ($stabile->denominazione ?? ''));
|
$nome = trim((string) ($stabile->denominazione ?? ''));
|
||||||
|
|
||||||
return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
|
return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
|
||||||
})
|
})
|
||||||
|
|
@ -277,7 +277,7 @@ public function table(Table $table): Table
|
||||||
TextColumn::make('protocollo_numero')
|
TextColumn::make('protocollo_numero')
|
||||||
->label('Protocollo')
|
->label('Protocollo')
|
||||||
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
|
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
|
||||||
$numero = trim((string) ($state ?? ''));
|
$numero = trim((string) ($state ?? ''));
|
||||||
$categoria = trim((string) ($record->protocollo_categoria ?? ''));
|
$categoria = trim((string) ($record->protocollo_categoria ?? ''));
|
||||||
|
|
||||||
if ($numero === '' && $categoria === '') {
|
if ($numero === '' && $categoria === '') {
|
||||||
|
|
@ -418,12 +418,12 @@ public function table(Table $table): Table
|
||||||
Select::make('workflow_stato')
|
Select::make('workflow_stato')
|
||||||
->label('Workflow')
|
->label('Workflow')
|
||||||
->options([
|
->options([
|
||||||
'acquisita' => 'Acquisita',
|
'acquisita' => 'Acquisita',
|
||||||
'da_richiedere' => 'Da richiedere',
|
'da_richiedere' => 'Da richiedere',
|
||||||
'richiesta_inviata' => 'Richiesta inviata',
|
'richiesta_inviata' => 'Richiesta inviata',
|
||||||
'ricevuta' => 'Ricevuta',
|
'ricevuta' => 'Ricevuta',
|
||||||
'protocollata' => 'Protocollata',
|
'protocollata' => 'Protocollata',
|
||||||
'archiviata' => 'Archiviata',
|
'archiviata' => 'Archiviata',
|
||||||
])
|
])
|
||||||
->default('acquisita'),
|
->default('acquisita'),
|
||||||
TextInput::make('protocollo_categoria')->label('Categoria protocollo')->maxLength(40),
|
TextInput::make('protocollo_categoria')->label('Categoria protocollo')->maxLength(40),
|
||||||
|
|
@ -435,9 +435,9 @@ public function table(Table $table): Table
|
||||||
Select::make('rilevatore_tipo')
|
Select::make('rilevatore_tipo')
|
||||||
->label('Rilevatore')
|
->label('Rilevatore')
|
||||||
->options([
|
->options([
|
||||||
'condomino' => 'Condomino',
|
'condomino' => 'Condomino',
|
||||||
'inquilino' => 'Inquilino',
|
'inquilino' => 'Inquilino',
|
||||||
'letturista' => 'Letturista',
|
'letturista' => 'Letturista',
|
||||||
'amministrazione' => 'Amministrazione',
|
'amministrazione' => 'Amministrazione',
|
||||||
]),
|
]),
|
||||||
TextInput::make('rilevatore_nome')->label('Nome rilevatore')->maxLength(191),
|
TextInput::make('rilevatore_nome')->label('Nome rilevatore')->maxLength(191),
|
||||||
|
|
@ -468,32 +468,32 @@ public function table(Table $table): Table
|
||||||
}
|
}
|
||||||
|
|
||||||
$record = StabileServizioLettura::query()->create([
|
$record = StabileServizioLettura::query()->create([
|
||||||
'stabile_id' => (int) $stabileId,
|
'stabile_id' => (int) $stabileId,
|
||||||
'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0),
|
'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0),
|
||||||
'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null,
|
'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null,
|
||||||
'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null,
|
'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null,
|
||||||
'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null,
|
'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null,
|
||||||
'periodo_dal' => $data['periodo_dal'] ?? null,
|
'periodo_dal' => $data['periodo_dal'] ?? null,
|
||||||
'periodo_al' => $data['periodo_al'] ?? null,
|
'periodo_al' => $data['periodo_al'] ?? null,
|
||||||
'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
|
'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
|
||||||
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale',
|
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale',
|
||||||
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita',
|
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita',
|
||||||
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
|
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
|
||||||
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
|
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
|
||||||
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
|
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
|
||||||
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
|
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
|
||||||
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
|
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
|
||||||
'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null,
|
'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null,
|
||||||
'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null,
|
'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null,
|
||||||
'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null,
|
'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null,
|
||||||
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
|
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
|
||||||
'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null,
|
'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null,
|
||||||
'lettura_inizio' => $data['lettura_inizio'] ?? null,
|
'lettura_inizio' => $data['lettura_inizio'] ?? null,
|
||||||
'lettura_fine' => $data['lettura_fine'] ?? null,
|
'lettura_fine' => $data['lettura_fine'] ?? null,
|
||||||
'consumo_valore' => $data['consumo_valore'] ?? null,
|
'consumo_valore' => $data['consumo_valore'] ?? null,
|
||||||
'consumo_unita' => (string) ($data['consumo_unita'] ?? ''),
|
'consumo_unita' => (string) ($data['consumo_unita'] ?? ''),
|
||||||
'importo_totale' => $data['importo_totale'] ?? null,
|
'importo_totale' => $data['importo_totale'] ?? null,
|
||||||
'created_by' => (int) $user->id,
|
'created_by' => (int) $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->hydrateReadingWithPreviousData($record);
|
$this->hydrateReadingWithPreviousData($record);
|
||||||
|
|
@ -542,9 +542,9 @@ public function table(Table $table): Table
|
||||||
Select::make('rilevatore_tipo')
|
Select::make('rilevatore_tipo')
|
||||||
->label('Rilevatore')
|
->label('Rilevatore')
|
||||||
->options([
|
->options([
|
||||||
'condomino' => 'Condomino',
|
'condomino' => 'Condomino',
|
||||||
'inquilino' => 'Inquilino',
|
'inquilino' => 'Inquilino',
|
||||||
'letturista' => 'Letturista',
|
'letturista' => 'Letturista',
|
||||||
'amministrazione' => 'Amministrazione',
|
'amministrazione' => 'Amministrazione',
|
||||||
]),
|
]),
|
||||||
TextInput::make('rilevatore_nome')->label('Nome rilevatore')->maxLength(191),
|
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(),
|
TextInput::make('importo_totale')->label('Importo totale')->numeric(),
|
||||||
])
|
])
|
||||||
->fillForm(fn(StabileServizioLettura $record): array=> [
|
->fillForm(fn(StabileServizioLettura $record): array=> [
|
||||||
'stabile_servizio_id' => $record->stabile_servizio_id,
|
'stabile_servizio_id' => $record->stabile_servizio_id,
|
||||||
'voce_spesa_id' => $record->voce_spesa_id,
|
'voce_spesa_id' => $record->voce_spesa_id,
|
||||||
'fornitore_id' => $record->fornitore_id,
|
'fornitore_id' => $record->fornitore_id,
|
||||||
'unita_immobiliare_id' => $record->unita_immobiliare_id,
|
'unita_immobiliare_id' => $record->unita_immobiliare_id,
|
||||||
'periodo_dal' => $record->periodo_dal,
|
'periodo_dal' => $record->periodo_dal,
|
||||||
'periodo_al' => $record->periodo_al,
|
'periodo_al' => $record->periodo_al,
|
||||||
'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''),
|
'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''),
|
||||||
'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''),
|
'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''),
|
||||||
'workflow_stato' => (string) ($record->workflow_stato ?? ''),
|
'workflow_stato' => (string) ($record->workflow_stato ?? ''),
|
||||||
'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''),
|
'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''),
|
||||||
'protocollo_numero' => (string) ($record->protocollo_numero ?? ''),
|
'protocollo_numero' => (string) ($record->protocollo_numero ?? ''),
|
||||||
'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''),
|
'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''),
|
||||||
'richiesta_lettura_inviata_at' => $record->richiesta_lettura_inviata_at,
|
'richiesta_lettura_inviata_at' => $record->richiesta_lettura_inviata_at,
|
||||||
'prossima_lettura_scadenza_at' => $record->prossima_lettura_scadenza_at,
|
'prossima_lettura_scadenza_at' => $record->prossima_lettura_scadenza_at,
|
||||||
'deadline_lettura_at' => $record->deadline_lettura_at,
|
'deadline_lettura_at' => $record->deadline_lettura_at,
|
||||||
'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? ''),
|
'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? ''),
|
||||||
'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''),
|
'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''),
|
||||||
'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''),
|
'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''),
|
||||||
'lettura_precedente_valore' => $record->lettura_precedente_valore,
|
'lettura_precedente_valore' => $record->lettura_precedente_valore,
|
||||||
'lettura_inizio' => $record->lettura_inizio,
|
'lettura_inizio' => $record->lettura_inizio,
|
||||||
'lettura_fine' => $record->lettura_fine,
|
'lettura_fine' => $record->lettura_fine,
|
||||||
'consumo_valore' => $record->consumo_valore,
|
'consumo_valore' => $record->consumo_valore,
|
||||||
'consumo_unita' => (string) ($record->consumo_unita ?? ''),
|
'consumo_unita' => (string) ($record->consumo_unita ?? ''),
|
||||||
'importo_totale' => $record->importo_totale,
|
'importo_totale' => $record->importo_totale,
|
||||||
])
|
])
|
||||||
->action(function (StabileServizioLettura $record, array $data): void {
|
->action(function (StabileServizioLettura $record, array $data): void {
|
||||||
$record->fill([
|
$record->fill([
|
||||||
'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0),
|
'stabile_servizio_id' => (int) ($data['stabile_servizio_id'] ?? 0),
|
||||||
'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null,
|
'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null,
|
||||||
'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null,
|
'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null,
|
||||||
'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null,
|
'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null,
|
||||||
'periodo_dal' => $data['periodo_dal'] ?? null,
|
'periodo_dal' => $data['periodo_dal'] ?? null,
|
||||||
'periodo_al' => $data['periodo_al'] ?? null,
|
'periodo_al' => $data['periodo_al'] ?? null,
|
||||||
'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
|
'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
|
||||||
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null,
|
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null,
|
||||||
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null,
|
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null,
|
||||||
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
|
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
|
||||||
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
|
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
|
||||||
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
|
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
|
||||||
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
|
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
|
||||||
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
|
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
|
||||||
'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null,
|
'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null,
|
||||||
'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null,
|
'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null,
|
||||||
'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null,
|
'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null,
|
||||||
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
|
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
|
||||||
'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null,
|
'lettura_precedente_valore' => $data['lettura_precedente_valore'] ?? null,
|
||||||
'lettura_inizio' => $data['lettura_inizio'] ?? null,
|
'lettura_inizio' => $data['lettura_inizio'] ?? null,
|
||||||
'lettura_fine' => $data['lettura_fine'] ?? null,
|
'lettura_fine' => $data['lettura_fine'] ?? null,
|
||||||
'consumo_valore' => $data['consumo_valore'] ?? null,
|
'consumo_valore' => $data['consumo_valore'] ?? null,
|
||||||
'consumo_unita' => (string) ($data['consumo_unita'] ?? ''),
|
'consumo_unita' => (string) ($data['consumo_unita'] ?? ''),
|
||||||
'importo_totale' => $data['importo_totale'] ?? null,
|
'importo_totale' => $data['importo_totale'] ?? null,
|
||||||
]);
|
]);
|
||||||
$record->save();
|
$record->save();
|
||||||
$this->hydrateReadingWithPreviousData($record);
|
$this->hydrateReadingWithPreviousData($record);
|
||||||
|
|
@ -677,8 +677,8 @@ private function getStabiliOptions(): array
|
||||||
return StabileContext::accessibleStabili($user)
|
return StabileContext::accessibleStabili($user)
|
||||||
->mapWithKeys(function (Stabile $stabile): array {
|
->mapWithKeys(function (Stabile $stabile): array {
|
||||||
$codice = trim((string) ($stabile->codice_stabile ?? ''));
|
$codice = trim((string) ($stabile->codice_stabile ?? ''));
|
||||||
$nome = trim((string) ($stabile->denominazione ?? ''));
|
$nome = trim((string) ($stabile->denominazione ?? ''));
|
||||||
$label = trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
|
$label = trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
|
||||||
|
|
||||||
return [(int) $stabile->id => $label];
|
return [(int) $stabile->id => $label];
|
||||||
})
|
})
|
||||||
|
|
@ -802,16 +802,16 @@ private function getServiziOptionsByTipo(string $tipo): array
|
||||||
|
|
||||||
private function scheduleWaterCampaign(array $data): int
|
private function scheduleWaterCampaign(array $data): int
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0;
|
$stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0;
|
||||||
$servizioId = (int) ($data['stabile_servizio_id'] ?? 0);
|
$servizioId = (int) ($data['stabile_servizio_id'] ?? 0);
|
||||||
if ($stabileId <= 0 || $servizioId <= 0) {
|
if ($stabileId <= 0 || $servizioId <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$deadline = $data['deadline_lettura_at'] ?? null;
|
$deadline = $data['deadline_lettura_at'] ?? null;
|
||||||
$message = trim((string) ($data['messaggio_template'] ?? ''));
|
$message = trim((string) ($data['messaggio_template'] ?? ''));
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
$unitaIds = UnitaImmobiliare::query()
|
$unitaIds = UnitaImmobiliare::query()
|
||||||
->where('stabile_id', $stabileId)
|
->where('stabile_id', $stabileId)
|
||||||
|
|
@ -828,27 +828,27 @@ private function scheduleWaterCampaign(array $data): int
|
||||||
|
|
||||||
if (! $row instanceof StabileServizioLettura) {
|
if (! $row instanceof StabileServizioLettura) {
|
||||||
$row = StabileServizioLettura::query()->create([
|
$row = StabileServizioLettura::query()->create([
|
||||||
'stabile_id' => $stabileId,
|
'stabile_id' => $stabileId,
|
||||||
'stabile_servizio_id' => $servizioId,
|
'stabile_servizio_id' => $servizioId,
|
||||||
'unita_immobiliare_id' => (int) $unitaId,
|
'unita_immobiliare_id' => (int) $unitaId,
|
||||||
'workflow_stato' => 'richiesta_inviata',
|
'workflow_stato' => 'richiesta_inviata',
|
||||||
'canale_acquisizione' => 'manuale',
|
'canale_acquisizione' => 'manuale',
|
||||||
'richiesta_lettura_inviata_at' => now(),
|
'richiesta_lettura_inviata_at' => now(),
|
||||||
'deadline_lettura_at' => $deadline,
|
'deadline_lettura_at' => $deadline,
|
||||||
'raw' => ['campagna_acqua' => ['messaggio' => $message, 'creata_da' => Auth::id()]],
|
'raw' => ['campagna_acqua' => ['messaggio' => $message, 'creata_da' => Auth::id()]],
|
||||||
'created_by' => Auth::id(),
|
'created_by' => Auth::id(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$raw = is_array($row->raw ?? null) ? $row->raw : [];
|
$raw = is_array($row->raw ?? null) ? $row->raw : [];
|
||||||
$raw['campagna_acqua'] = [
|
$raw['campagna_acqua'] = [
|
||||||
'messaggio' => $message,
|
'messaggio' => $message,
|
||||||
'aggiornata_da' => Auth::id(),
|
'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->richiesta_lettura_inviata_at = $row->richiesta_lettura_inviata_at ?? now();
|
||||||
$row->deadline_lettura_at = $deadline;
|
$row->deadline_lettura_at = $deadline;
|
||||||
$row->raw = $raw;
|
$row->raw = $raw;
|
||||||
$row->save();
|
$row->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -860,7 +860,7 @@ private function scheduleWaterCampaign(array $data): int
|
||||||
|
|
||||||
private function markOverdueWaterReminders(): int
|
private function markOverdueWaterReminders(): int
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0;
|
$stabileId = $user instanceof User ? (int) StabileContext::resolveActiveStabileId($user) : 0;
|
||||||
if ($stabileId <= 0) {
|
if ($stabileId <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -877,15 +877,15 @@ private function markOverdueWaterReminders(): int
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$raw = is_array($row->raw ?? null) ? $row->raw : [];
|
$raw = is_array($row->raw ?? null) ? $row->raw : [];
|
||||||
$campagna = is_array($raw['campagna_acqua'] ?? null) ? $raw['campagna_acqua'] : [];
|
$campagna = is_array($raw['campagna_acqua'] ?? null) ? $raw['campagna_acqua'] : [];
|
||||||
$campagna['solleciti'] = (int) ($campagna['solleciti'] ?? 0) + 1;
|
$campagna['solleciti'] = (int) ($campagna['solleciti'] ?? 0) + 1;
|
||||||
$campagna['ultimo_sollecito_at'] = now()->toIso8601String();
|
$campagna['ultimo_sollecito_at'] = now()->toIso8601String();
|
||||||
$raw['campagna_acqua'] = $campagna;
|
$raw['campagna_acqua'] = $campagna;
|
||||||
|
|
||||||
$row->sollecito_inviato_at = now();
|
$row->sollecito_inviato_at = now();
|
||||||
$row->workflow_stato = 'richiesta_sollecitata';
|
$row->workflow_stato = 'richiesta_sollecitata';
|
||||||
$row->raw = $raw;
|
$row->raw = $raw;
|
||||||
$row->save();
|
$row->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -913,12 +913,12 @@ private function hydrateReadingWithPreviousData(StabileServizioLettura $record):
|
||||||
$changed = false;
|
$changed = false;
|
||||||
if ($record->lettura_precedente_valore === null && $previous->lettura_fine !== null) {
|
if ($record->lettura_precedente_valore === null && $previous->lettura_fine !== null) {
|
||||||
$record->lettura_precedente_valore = $previous->lettura_fine;
|
$record->lettura_precedente_valore = $previous->lettura_fine;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $record->lettura_precedente_foto_path && $previous->lettura_foto_path) {
|
if (! $record->lettura_precedente_foto_path && $previous->lettura_foto_path) {
|
||||||
$record->lettura_precedente_foto_path = $previous->lettura_foto_path;
|
$record->lettura_precedente_foto_path = $previous->lettura_foto_path;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($changed) {
|
if ($changed) {
|
||||||
|
|
@ -931,7 +931,7 @@ private function storeReadingUploads(StabileServizioLettura $record, array $data
|
||||||
$updated = false;
|
$updated = false;
|
||||||
|
|
||||||
foreach ([
|
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'],
|
'lettura_precedente_foto_upload' => ['field' => 'lettura_precedente_foto_path', 'prefix' => 'precedente'],
|
||||||
] as $input => $meta) {
|
] as $input => $meta) {
|
||||||
$upload = $data[$input] ?? null;
|
$upload = $data[$input] ?? null;
|
||||||
|
|
@ -942,14 +942,14 @@ private function storeReadingUploads(StabileServizioLettura $record, array $data
|
||||||
|
|
||||||
$record->{$meta['field']} = $stored['path'];
|
$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'];
|
$raw[$input . '_metadata'] = $stored['metadata'];
|
||||||
if ($input === 'lettura_foto_upload') {
|
if ($input === 'lettura_foto_upload') {
|
||||||
$record->lettura_foto_original_name = (string) ($stored['original_name'] ?? '');
|
$record->lettura_foto_original_name = (string) ($stored['original_name'] ?? '');
|
||||||
$record->lettura_foto_metadata = $stored['metadata'];
|
$record->lettura_foto_metadata = $stored['metadata'];
|
||||||
}
|
}
|
||||||
$record->raw = $raw;
|
$record->raw = $raw;
|
||||||
$updated = true;
|
$updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($updated) {
|
if ($updated) {
|
||||||
|
|
@ -963,8 +963,8 @@ private function storeReadingPhoto($upload, StabileServizioLettura $record, stri
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = strtolower((string) ($upload->getClientOriginalExtension() ?: 'jpg'));
|
$ext = strtolower((string) ($upload->getClientOriginalExtension() ?: 'jpg'));
|
||||||
$unit = (int) ($record->unita_immobiliare_id ?? 0);
|
$unit = (int) ($record->unita_immobiliare_id ?? 0);
|
||||||
$filename = implode('-', array_filter([
|
$filename = implode('-', array_filter([
|
||||||
'stabile' . (int) $record->stabile_id,
|
'stabile' . (int) $record->stabile_id,
|
||||||
'servizio' . (int) $record->stabile_servizio_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');
|
$path = $upload->storeAs('letture-servizi/foto', $filename, 'public');
|
||||||
return [
|
return [
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'original_name' => (string) $upload->getClientOriginalName(),
|
'original_name' => (string) $upload->getClientOriginalName(),
|
||||||
'metadata' => $this->extractImageMetadata($path),
|
'metadata' => $this->extractImageMetadata($path),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractImageMetadata(string $path): array
|
private function extractImageMetadata(string $path): array
|
||||||
{
|
{
|
||||||
$absolutePath = \Illuminate\Support\Facades\Storage::disk('public')->path($path);
|
$absolutePath = \Illuminate\Support\Facades\Storage::disk('public')->path($path);
|
||||||
$metadata = [
|
$metadata = [
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'size' => is_file($absolutePath) ? filesize($absolutePath) : null,
|
'size' => is_file($absolutePath) ? filesize($absolutePath) : null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$imageSize = @getimagesize($absolutePath);
|
$imageSize = @getimagesize($absolutePath);
|
||||||
if (is_array($imageSize)) {
|
if (is_array($imageSize)) {
|
||||||
$metadata['width'] = $imageSize[0] ?? null;
|
$metadata['width'] = $imageSize[0] ?? null;
|
||||||
$metadata['height'] = $imageSize[1] ?? null;
|
$metadata['height'] = $imageSize[1] ?? null;
|
||||||
$metadata['mime'] = $imageSize['mime'] ?? null;
|
$metadata['mime'] = $imageSize['mime'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (function_exists('exif_read_data')) {
|
if (function_exists('exif_read_data')) {
|
||||||
|
|
@ -1001,10 +1001,10 @@ private function extractImageMetadata(string $path): array
|
||||||
$exif = @exif_read_data($absolutePath);
|
$exif = @exif_read_data($absolutePath);
|
||||||
if (is_array($exif)) {
|
if (is_array($exif)) {
|
||||||
$metadata['exif_datetime'] = $exif['DateTimeOriginal'] ?? ($exif['DateTime'] ?? null);
|
$metadata['exif_datetime'] = $exif['DateTimeOriginal'] ?? ($exif['DateTime'] ?? null);
|
||||||
$metadata['gps'] = [
|
$metadata['gps'] = [
|
||||||
'lat' => $exif['GPSLatitude'] ?? null,
|
'lat' => $exif['GPSLatitude'] ?? null,
|
||||||
'lat_ref' => $exif['GPSLatitudeRef'] ?? null,
|
'lat_ref' => $exif['GPSLatitudeRef'] ?? null,
|
||||||
'lng' => $exif['GPSLongitude'] ?? null,
|
'lng' => $exif['GPSLongitude'] ?? null,
|
||||||
'lng_ref' => $exif['GPSLongitudeRef'] ?? null,
|
'lng_ref' => $exif['GPSLongitudeRef'] ?? null,
|
||||||
];
|
];
|
||||||
$metadata['device'] = trim(implode(' ', array_filter([
|
$metadata['device'] = trim(implode(' ', array_filter([
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
<?php
|
<?php
|
||||||
namespace App\Filament\Pages\Condomini;
|
namespace App\Filament\Pages\Condomini;
|
||||||
|
|
||||||
|
use App\Models\DocumentoCollegato;
|
||||||
use App\Models\InsuranceClaim;
|
use App\Models\InsuranceClaim;
|
||||||
use App\Models\InsurancePolicy;
|
use App\Models\InsurancePolicy;
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
use App\Models\Stabile as StabileModel;
|
use App\Models\Stabile as StabileModel;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketAttachment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Livewire\SupportsRubricaLookup;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class StabilePage extends Page
|
class StabilePage extends Page
|
||||||
{
|
{
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
use SupportsRubricaLookup;
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Stabile';
|
protected static ?string $navigationLabel = 'Stabile';
|
||||||
|
|
||||||
|
|
@ -69,6 +75,25 @@ class StabilePage extends Page
|
||||||
|
|
||||||
public ?int $selectedInsurancePolicyId = null;
|
public ?int $selectedInsurancePolicyId = null;
|
||||||
|
|
||||||
|
public string $insuranceBrokerSearch = '';
|
||||||
|
|
||||||
|
public string $insuranceCompanySearch = '';
|
||||||
|
|
||||||
|
/** @var array<int,array<string,mixed>> */
|
||||||
|
public array $insuranceBrokerMatches = [];
|
||||||
|
|
||||||
|
/** @var array<int,array<string,mixed>> */
|
||||||
|
public array $insuranceCompanyMatches = [];
|
||||||
|
|
||||||
|
/** @var array<int,array<string,mixed>> */
|
||||||
|
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<string,mixed>|null */
|
||||||
|
public ?array $insuranceAttachmentPreview = null;
|
||||||
|
|
||||||
public array $insurancePolicyForm = [
|
public array $insurancePolicyForm = [
|
||||||
'broker_rubrica_id' => null,
|
'broker_rubrica_id' => null,
|
||||||
'company_rubrica_id' => null,
|
'company_rubrica_id' => null,
|
||||||
|
|
@ -409,15 +434,39 @@ public function getInsuranceRubricaOptionsProperty(): array
|
||||||
->all();
|
->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);
|
$policy = $this->insurancePolicies->firstWhere('id', $policyId);
|
||||||
if (! $policy instanceof InsurancePolicy) {
|
if (! $policy instanceof InsurancePolicy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->selectedInsurancePolicyId = (int) $policy->id;
|
$this->selectedInsurancePolicyId = (int) $policy->id;
|
||||||
$this->insurancePolicyForm = [
|
$this->insurancePolicyForm = [
|
||||||
'broker_rubrica_id' => $policy->broker_rubrica_id,
|
'broker_rubrica_id' => $policy->broker_rubrica_id,
|
||||||
'company_rubrica_id' => $policy->company_rubrica_id,
|
'company_rubrica_id' => $policy->company_rubrica_id,
|
||||||
'policy_name' => (string) ($policy->policy_name ?? ''),
|
'policy_name' => (string) ($policy->policy_name ?? ''),
|
||||||
|
|
@ -435,12 +484,18 @@ public function selectInsurancePolicy(int $policyId): void
|
||||||
'claim_form_template' => (string) ($policy->claim_form_template ?? ''),
|
'claim_form_template' => (string) ($policy->claim_form_template ?? ''),
|
||||||
'notes' => (string) ($policy->notes ?? ''),
|
'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
|
public function resetInsurancePolicyForm(): void
|
||||||
{
|
{
|
||||||
$this->selectedInsurancePolicyId = null;
|
$this->selectedInsurancePolicyId = null;
|
||||||
$this->insurancePolicyForm = [
|
$this->insurancePolicyForm = [
|
||||||
'broker_rubrica_id' => null,
|
'broker_rubrica_id' => null,
|
||||||
'company_rubrica_id' => null,
|
'company_rubrica_id' => null,
|
||||||
'policy_name' => '',
|
'policy_name' => '',
|
||||||
|
|
@ -459,7 +514,61 @@ public function resetInsurancePolicyForm(): void
|
||||||
'notes' => '',
|
'notes' => '',
|
||||||
];
|
];
|
||||||
$this->insurancePolicyDocumentUpload = null;
|
$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
|
public function saveInsurancePolicy(): void
|
||||||
|
|
@ -468,6 +577,11 @@ public function saveInsurancePolicy(): void
|
||||||
return;
|
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, [
|
$data = validator($this->insurancePolicyForm, [
|
||||||
'broker_rubrica_id' => ['nullable', 'integer', 'exists:rubrica_universale,id'],
|
'broker_rubrica_id' => ['nullable', 'integer', 'exists:rubrica_universale,id'],
|
||||||
'company_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'],
|
'notes' => ['nullable', 'string', 'max:10000'],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
$policy = $this->selectedInsurancePolicyId
|
$paymentRows = $this->normalizeInsurancePaymentRows($this->insurancePaymentRows);
|
||||||
? InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($this->selectedInsurancePolicyId)
|
|
||||||
|
$selectedPolicyId = $this->normalizeInsuranceIdValue($this->selectedInsurancePolicyId);
|
||||||
|
|
||||||
|
$policy = $selectedPolicyId
|
||||||
|
? InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($selectedPolicyId)
|
||||||
: new InsurancePolicy();
|
: new InsurancePolicy();
|
||||||
|
|
||||||
if (! $policy instanceof InsurancePolicy) {
|
if (! $policy instanceof InsurancePolicy) {
|
||||||
|
|
@ -496,24 +614,25 @@ public function saveInsurancePolicy(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$policy->fill([
|
$policy->fill([
|
||||||
'stabile_id' => (int) $this->stabile->id,
|
'stabile_id' => (int) $this->stabile->id,
|
||||||
'broker_rubrica_id' => (int) ($data['broker_rubrica_id'] ?? 0) ?: null,
|
'broker_rubrica_id' => (int) ($data['broker_rubrica_id'] ?? 0) ?: null,
|
||||||
'company_rubrica_id' => (int) ($data['company_rubrica_id'] ?? 0) ?: null,
|
'company_rubrica_id' => (int) ($data['company_rubrica_id'] ?? 0) ?: null,
|
||||||
'policy_name' => $this->cleanInsuranceNullable($data['policy_name'] ?? null),
|
'policy_name' => $this->cleanInsuranceNullable($data['policy_name'] ?? null),
|
||||||
'policy_number' => $this->cleanInsuranceNullable($data['policy_number'] ?? null),
|
'policy_number' => $this->cleanInsuranceNullable($data['policy_number'] ?? null),
|
||||||
'policy_reference' => $this->cleanInsuranceNullable($data['policy_reference'] ?? null),
|
'policy_reference' => $this->cleanInsuranceNullable($data['policy_reference'] ?? null),
|
||||||
'status' => $this->cleanInsuranceNullable($data['status'] ?? 'attiva') ?: 'attiva',
|
'status' => $this->cleanInsuranceNullable($data['status'] ?? 'attiva') ?: 'attiva',
|
||||||
'started_at' => $data['started_at'] ?? null,
|
'started_at' => $data['started_at'] ?? null,
|
||||||
'expires_at' => $data['expires_at'] ?? null,
|
'expires_at' => $data['expires_at'] ?? null,
|
||||||
'renewal_at' => $data['renewal_at'] ?? null,
|
'renewal_at' => $data['renewal_at'] ?? null,
|
||||||
'annual_amount' => $data['annual_amount'] ?? null,
|
'annual_amount' => $data['annual_amount'] ?? null,
|
||||||
'payment_split' => $this->cleanInsuranceNullable($data['payment_split'] ?? null),
|
'payment_split' => $this->cleanInsuranceNullable($data['payment_split'] ?? null),
|
||||||
'payment_schedule_notes' => $this->cleanInsuranceNullable($data['payment_schedule_notes'] ?? null),
|
'payment_schedule_notes' => $this->cleanInsuranceNullable($this->serializeInsurancePaymentRows($paymentRows)),
|
||||||
'coverage_summary' => $this->cleanInsuranceNullable($data['coverage_summary'] ?? null),
|
'coverage_summary' => $this->cleanInsuranceNullable($data['coverage_summary'] ?? null),
|
||||||
'special_conditions' => $this->cleanInsuranceNullable($data['special_conditions'] ?? null),
|
'special_conditions' => $this->cleanInsuranceNullable($data['special_conditions'] ?? null),
|
||||||
'claim_form_template' => $this->cleanInsuranceNullable($data['claim_form_template'] ?? null),
|
'claim_form_template' => $this->cleanInsuranceNullable($data['claim_form_template'] ?? null),
|
||||||
'notes' => $this->cleanInsuranceNullable($data['notes'] ?? null),
|
'notes' => $this->cleanInsuranceNullable($data['notes'] ?? null),
|
||||||
'created_by' => Auth::id(),
|
'metadata' => array_merge((array) ($policy->metadata ?? []), ['payment_rows' => $paymentRows]),
|
||||||
|
'created_by' => Auth::id(),
|
||||||
]);
|
]);
|
||||||
$policy->save();
|
$policy->save();
|
||||||
|
|
||||||
|
|
@ -523,12 +642,17 @@ public function saveInsurancePolicy(): void
|
||||||
Notification::make()->title('Polizza assicurativa salvata')->success()->send();
|
Notification::make()->title('Polizza assicurativa salvata')->success()->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteInsurancePolicy(int $policyId): void
|
public function deleteInsurancePolicy($policyId): void
|
||||||
{
|
{
|
||||||
if (! $this->stabile instanceof StabileModel) {
|
if (! $this->stabile instanceof StabileModel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$policyId = $this->normalizeInsuranceIdValue($policyId);
|
||||||
|
if ($policyId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$policy = InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($policyId);
|
$policy = InsurancePolicy::query()->where('stabile_id', (int) $this->stabile->id)->find($policyId);
|
||||||
if (! $policy instanceof InsurancePolicy) {
|
if (! $policy instanceof InsurancePolicy) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -543,19 +667,37 @@ public function deleteInsurancePolicy(int $policyId): void
|
||||||
Notification::make()->title('Polizza assicurativa eliminata')->success()->send();
|
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 ?? ''));
|
if (! $policy instanceof InsurancePolicy) {
|
||||||
return $path !== '' ? Storage::disk('public')->url($path) : null;
|
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 ?? ''));
|
if (! $policy instanceof InsurancePolicy) {
|
||||||
return $path !== '' ? Storage::disk('public')->url($path) : null;
|
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) {
|
if (! $ticket instanceof Ticket) {
|
||||||
return null;
|
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');
|
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
|
private function storeInsuranceUploads(InsurancePolicy $policy): void
|
||||||
{
|
{
|
||||||
if (is_object($this->insurancePolicyDocumentUpload) && method_exists($this->insurancePolicyDocumentUpload, 'storeAs')) {
|
if (is_object($this->insurancePolicyDocumentUpload) && method_exists($this->insurancePolicyDocumentUpload, 'storeAs')) {
|
||||||
$ext = strtolower((string) ($this->insurancePolicyDocumentUpload->getClientOriginalExtension() ?: 'pdf'));
|
$ext = strtolower((string) ($this->insurancePolicyDocumentUpload->getClientOriginalExtension() ?: 'pdf'));
|
||||||
$fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-documento.' . $ext;
|
$fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-documento.' . $ext;
|
||||||
$path = $this->insurancePolicyDocumentUpload->storeAs('assicurazioni/polizze', $fileName, 'public');
|
$path = $this->insurancePolicyDocumentUpload->storeAs('assicurazioni/polizze', $fileName, 'public');
|
||||||
$policy->policy_document_path = $path;
|
$policy->policy_document_path = $path;
|
||||||
$policy->policy_document_name = (string) $this->insurancePolicyDocumentUpload->getClientOriginalName();
|
$policy->policy_document_name = (string) $this->insurancePolicyDocumentUpload->getClientOriginalName();
|
||||||
$policy->policy_document_mime = (string) $this->insurancePolicyDocumentUpload->getClientMimeType();
|
$policy->policy_document_mime = (string) $this->insurancePolicyDocumentUpload->getClientMimeType();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_object($this->insuranceSignatureUpload) && method_exists($this->insuranceSignatureUpload, 'storeAs')) {
|
if (is_object($this->insuranceSignatureUpload) && method_exists($this->insuranceSignatureUpload, 'storeAs')) {
|
||||||
$ext = strtolower((string) ($this->insuranceSignatureUpload->getClientOriginalExtension() ?: 'png'));
|
$ext = strtolower((string) ($this->insuranceSignatureUpload->getClientOriginalExtension() ?: 'png'));
|
||||||
$fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-firma.' . $ext;
|
$fileName = 'stabile-' . (int) $policy->stabile_id . '-polizza-' . (int) $policy->id . '-firma.' . $ext;
|
||||||
$policy->signature_image_path = $this->insuranceSignatureUpload->storeAs('assicurazioni/firme', $fileName, 'public');
|
$policy->signature_image_path = $this->insuranceSignatureUpload->storeAs('assicurazioni/firme', $fileName, 'public');
|
||||||
}
|
}
|
||||||
|
|
||||||
$policy->save();
|
$policy->save();
|
||||||
|
$this->syncInsuranceDocumentArchive($policy);
|
||||||
$this->insurancePolicyDocumentUpload = null;
|
$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<string,mixed>
|
||||||
|
*/
|
||||||
|
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<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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<int,array<string,mixed>> $rows
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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<int,array<string,mixed>> $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
|
private function cleanInsuranceNullable($value): ?string
|
||||||
|
|
|
||||||
|
|
@ -169,12 +169,12 @@ public function createCollaboratore(): void
|
||||||
'fornitoreEsternoId' => ['nullable', 'integer'],
|
'fornitoreEsternoId' => ['nullable', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tipo = (string) $validated['collaboratoreTipo'];
|
$tipo = (string) $validated['collaboratoreTipo'];
|
||||||
$tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica');
|
$tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica');
|
||||||
|
|
||||||
if ($tipo === FornitoreDipendente::TIPO_INTERNO) {
|
if ($tipo === FornitoreDipendente::TIPO_INTERNO) {
|
||||||
$hasPersonalName = trim((string) ($validated['nome'] ?? '')) !== '';
|
$hasPersonalName = trim((string) ($validated['nome'] ?? '')) !== '';
|
||||||
$hasCompanyName = trim((string) ($validated['ragioneSociale'] ?? '')) !== '';
|
$hasCompanyName = trim((string) ($validated['ragioneSociale'] ?? '')) !== '';
|
||||||
|
|
||||||
if ($tipoContatto === 'persona_giuridica' && ! $hasCompanyName) {
|
if ($tipoContatto === 'persona_giuridica' && ! $hasCompanyName) {
|
||||||
Notification::make()->title('Inserisci la ragione sociale del collaboratore')->warning()->send();
|
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
|
protected function upsertRubricaInterna(array $validated, FornitoreDipendente $dipendente): int
|
||||||
{
|
{
|
||||||
$email = trim((string) ($validated['email'] ?? ''));
|
$email = trim((string) ($validated['email'] ?? ''));
|
||||||
$rubrica = null;
|
$rubrica = null;
|
||||||
|
|
||||||
if ($email !== '') {
|
if ($email !== '') {
|
||||||
|
|
@ -557,41 +557,41 @@ protected function upsertRubricaInterna(array $validated, FornitoreDipendente $d
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $rubrica instanceof RubricaUniversale) {
|
if (! $rubrica instanceof RubricaUniversale) {
|
||||||
$rubrica = new RubricaUniversale();
|
$rubrica = new RubricaUniversale();
|
||||||
$rubrica->data_inserimento = now()->toDateString();
|
$rubrica->data_inserimento = now()->toDateString();
|
||||||
$rubrica->creato_da = Auth::id();
|
$rubrica->creato_da = Auth::id();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica');
|
$tipoContatto = (string) ($validated['tipoContatto'] ?? 'persona_fisica');
|
||||||
$telefono = trim((string) ($validated['telefono'] ?? ''));
|
$telefono = trim((string) ($validated['telefono'] ?? ''));
|
||||||
$ragioneSociale = trim((string) ($validated['ragioneSociale'] ?? ''));
|
$ragioneSociale = trim((string) ($validated['ragioneSociale'] ?? ''));
|
||||||
|
|
||||||
$rubrica->titolo_id = null;
|
$rubrica->titolo_id = null;
|
||||||
$rubrica->nome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['nome'] ?? '')) ?: null);
|
$rubrica->nome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['nome'] ?? '')) ?: null);
|
||||||
$rubrica->cognome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['cognome'] ?? '')) ?: null);
|
$rubrica->cognome = $tipoContatto === 'persona_giuridica' ? null : (trim((string) ($validated['cognome'] ?? '')) ?: null);
|
||||||
$rubrica->ragione_sociale = $tipoContatto === 'persona_giuridica' ? ($ragioneSociale ?: null) : null;
|
$rubrica->ragione_sociale = $tipoContatto === 'persona_giuridica' ? ($ragioneSociale ?: null): null;
|
||||||
$rubrica->tipo_contatto = $tipoContatto;
|
$rubrica->tipo_contatto = $tipoContatto;
|
||||||
$rubrica->codice_fiscale = trim((string) ($validated['codiceFiscale'] ?? '')) ?: null;
|
$rubrica->codice_fiscale = trim((string) ($validated['codiceFiscale'] ?? '')) ?: null;
|
||||||
$rubrica->partita_iva = trim((string) ($validated['partitaIva'] ?? '')) ?: null;
|
$rubrica->partita_iva = trim((string) ($validated['partitaIva'] ?? '')) ?: null;
|
||||||
$rubrica->sesso = trim((string) ($validated['sesso'] ?? '')) ?: null;
|
$rubrica->sesso = trim((string) ($validated['sesso'] ?? '')) ?: null;
|
||||||
$rubrica->data_nascita = trim((string) ($validated['dataNascita'] ?? '')) ?: null;
|
$rubrica->data_nascita = trim((string) ($validated['dataNascita'] ?? '')) ?: null;
|
||||||
$rubrica->luogo_nascita = trim((string) ($validated['luogoNascita'] ?? '')) ?: null;
|
$rubrica->luogo_nascita = trim((string) ($validated['luogoNascita'] ?? '')) ?: null;
|
||||||
$rubrica->provincia_nascita = trim((string) ($validated['provinciaNascita'] ?? '')) ?: null;
|
$rubrica->provincia_nascita = trim((string) ($validated['provinciaNascita'] ?? '')) ?: null;
|
||||||
$rubrica->indirizzo = trim((string) ($validated['indirizzo'] ?? '')) ?: null;
|
$rubrica->indirizzo = trim((string) ($validated['indirizzo'] ?? '')) ?: null;
|
||||||
$rubrica->civico = trim((string) ($validated['civico'] ?? '')) ?: null;
|
$rubrica->civico = trim((string) ($validated['civico'] ?? '')) ?: null;
|
||||||
$rubrica->cap = trim((string) ($validated['cap'] ?? '')) ?: null;
|
$rubrica->cap = trim((string) ($validated['cap'] ?? '')) ?: null;
|
||||||
$rubrica->citta = trim((string) ($validated['citta'] ?? '')) ?: null;
|
$rubrica->citta = trim((string) ($validated['citta'] ?? '')) ?: null;
|
||||||
$rubrica->provincia = trim((string) ($validated['provincia'] ?? '')) ?: null;
|
$rubrica->provincia = trim((string) ($validated['provincia'] ?? '')) ?: null;
|
||||||
$rubrica->nazione = trim((string) ($validated['nazione'] ?? '')) ?: 'Italia';
|
$rubrica->nazione = trim((string) ($validated['nazione'] ?? '')) ?: 'Italia';
|
||||||
$rubrica->telefono_ufficio = $tipoContatto === 'persona_giuridica' ? ($telefono ?: null) : null;
|
$rubrica->telefono_ufficio = $tipoContatto === 'persona_giuridica' ? ($telefono ?: null): null;
|
||||||
$rubrica->telefono_cellulare = $telefono ?: null;
|
$rubrica->telefono_cellulare = $telefono ?: null;
|
||||||
$rubrica->email = $email !== '' ? $email : null;
|
$rubrica->email = $email !== '' ? $email : null;
|
||||||
$rubrica->pec = trim((string) ($validated['pec'] ?? '')) ?: null;
|
$rubrica->pec = trim((string) ($validated['pec'] ?? '')) ?: null;
|
||||||
$rubrica->note = trim((string) ($validated['note'] ?? '')) ?: null;
|
$rubrica->note = trim((string) ($validated['note'] ?? '')) ?: null;
|
||||||
$rubrica->categoria = 'fornitore';
|
$rubrica->categoria = 'fornitore';
|
||||||
$rubrica->stato = 'attivo';
|
$rubrica->stato = 'attivo';
|
||||||
$rubrica->data_ultima_modifica = now()->toDateString();
|
$rubrica->data_ultima_modifica = now()->toDateString();
|
||||||
$rubrica->modificato_da = Auth::id();
|
$rubrica->modificato_da = Auth::id();
|
||||||
$rubrica->save();
|
$rubrica->save();
|
||||||
|
|
||||||
$dipendente->rubrica_id = (int) $rubrica->id;
|
$dipendente->rubrica_id = (int) $rubrica->id;
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,11 @@ class TicketInterventoScheda extends Page
|
||||||
|
|
||||||
/** @var array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string} */
|
/** @var array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string} */
|
||||||
public array $caller = [
|
public array $caller = [
|
||||||
'contatto' => '-',
|
'contatto' => '-',
|
||||||
'telefono' => '',
|
'telefono' => '',
|
||||||
'email' => '',
|
'email' => '',
|
||||||
'problema' => '',
|
'problema' => '',
|
||||||
'sorgente' => '',
|
'sorgente' => '',
|
||||||
'riferimento' => '',
|
'riferimento' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -418,11 +418,11 @@ protected function resolveCallerData(?Ticket $ticket): array
|
||||||
$parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? ''));
|
$parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? ''));
|
||||||
|
|
||||||
$caller = [
|
$caller = [
|
||||||
'contatto' => $parsed['contatto'],
|
'contatto' => $parsed['contatto'],
|
||||||
'telefono' => $parsed['telefono'],
|
'telefono' => $parsed['telefono'],
|
||||||
'email' => '',
|
'email' => '',
|
||||||
'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''),
|
'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''),
|
||||||
'sorgente' => 'Descrizione ticket',
|
'sorgente' => 'Descrizione ticket',
|
||||||
'riferimento' => $this->buildCallerReference($ticket),
|
'riferimento' => $this->buildCallerReference($ticket),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -434,7 +434,7 @@ protected function resolveCallerData(?Ticket $ticket): array
|
||||||
}
|
}
|
||||||
|
|
||||||
$caller['telefono'] = trim((string) ($soggetto->telefono ?? '')) ?: $caller['telefono'];
|
$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';
|
$caller['sorgente'] = 'Richiedente collegato al ticket';
|
||||||
|
|
||||||
return $caller;
|
return $caller;
|
||||||
|
|
@ -443,7 +443,7 @@ protected function resolveCallerData(?Ticket $ticket): array
|
||||||
$openedBy = $ticket?->apertoDaUser;
|
$openedBy = $ticket?->apertoDaUser;
|
||||||
if ($openedBy instanceof User) {
|
if ($openedBy instanceof User) {
|
||||||
$caller['contatto'] = trim((string) ($openedBy->name ?? '')) ?: $caller['contatto'];
|
$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';
|
$caller['sorgente'] = 'Utente che ha aperto il ticket';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,7 +461,7 @@ protected function buildCallerReference(?Ticket $ticket): string
|
||||||
$stabile = $ticket->stabile;
|
$stabile = $ticket->stabile;
|
||||||
if ($stabile) {
|
if ($stabile) {
|
||||||
$denominazione = trim((string) ($stabile->denominazione ?? ''));
|
$denominazione = trim((string) ($stabile->denominazione ?? ''));
|
||||||
$indirizzo = trim(implode(' ', array_filter([
|
$indirizzo = trim(implode(' ', array_filter([
|
||||||
$stabile->indirizzo ?? null,
|
$stabile->indirizzo ?? null,
|
||||||
$stabile->cap ?? null,
|
$stabile->cap ?? null,
|
||||||
$stabile->citta ?? null,
|
$stabile->citta ?? null,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ public function mount(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->fornitoreMatches = new Collection();
|
$this->fornitoreMatches = new Collection();
|
||||||
$this->searchInput = $this->fornitoriSearch;
|
$this->searchInput = $this->fornitoriSearch;
|
||||||
$this->searchFornitoreMatches();
|
$this->searchFornitoreMatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,7 +318,7 @@ public function applicaRicerca(): void
|
||||||
{
|
{
|
||||||
$this->fornitoriSearch = trim($this->searchInput);
|
$this->fornitoriSearch = trim($this->searchInput);
|
||||||
$this->searchFornitoreMatches();
|
$this->searchFornitoreMatches();
|
||||||
$this->activeTab = 'elenco';
|
$this->activeTab = 'elenco';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pulisciRicerca(): void
|
public function pulisciRicerca(): void
|
||||||
|
|
@ -326,7 +326,7 @@ public function pulisciRicerca(): void
|
||||||
$this->fornitoriSearch = '';
|
$this->fornitoriSearch = '';
|
||||||
$this->searchInput = '';
|
$this->searchInput = '';
|
||||||
$this->searchFornitoreMatches();
|
$this->searchFornitoreMatches();
|
||||||
$this->activeTab = 'elenco';
|
$this->activeTab = 'elenco';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function importaTagLegacy(bool $soloSelezionato = false, bool $dryRun = false): void
|
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->fornitoriSearch = implode(' e ', $tokens);
|
||||||
$this->searchInput = $this->fornitoriSearch;
|
$this->searchInput = $this->fornitoriSearch;
|
||||||
$this->searchFornitoreMatches();
|
$this->searchFornitoreMatches();
|
||||||
$this->activeTab = 'elenco';
|
$this->activeTab = 'elenco';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function apriScheda(int $fornitoreId): void
|
public function apriScheda(int $fornitoreId): void
|
||||||
|
|
@ -458,7 +458,7 @@ public function createFornitoreRapido(): void
|
||||||
|
|
||||||
$this->fornitoriCount = (int) $this->getTableQuery()->count();
|
$this->fornitoriCount = (int) $this->getTableQuery()->count();
|
||||||
$this->apriScheda((int) $fornitore->id);
|
$this->apriScheda((int) $fornitore->id);
|
||||||
$this->searchInput = $this->getFornitoreLabel($fornitore);
|
$this->searchInput = $this->getFornitoreLabel($fornitore);
|
||||||
$this->fornitoriSearch = $this->searchInput;
|
$this->fornitoriSearch = $this->searchInput;
|
||||||
$this->searchFornitoreMatches();
|
$this->searchFornitoreMatches();
|
||||||
|
|
||||||
|
|
@ -698,12 +698,12 @@ public function saveDipendenteInline(int $dipendenteId): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$state = $this->dipendenteInline[$dipendenteId] ?? [];
|
$state = $this->dipendenteInline[$dipendenteId] ?? [];
|
||||||
$data = Validator::make($state, [
|
$data = Validator::make($state, [
|
||||||
'nome' => ['required', 'string', 'max:255'],
|
'nome' => ['required', 'string', 'max:255'],
|
||||||
'cognome' => ['nullable', 'string', 'max:255'],
|
'cognome' => ['nullable', 'string', 'max:255'],
|
||||||
'email' => ['nullable', 'email', 'max:255'],
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
'telefono' => ['nullable', 'string', 'max:64'],
|
'telefono' => ['nullable', 'string', 'max:64'],
|
||||||
'attivo' => ['nullable', 'boolean'],
|
'attivo' => ['nullable', 'boolean'],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
$dipendente->nome = trim((string) ($data['nome'] ?? ''));
|
$dipendente->nome = trim((string) ($data['nome'] ?? ''));
|
||||||
|
|
@ -842,7 +842,7 @@ private function applyFornitoreSearch(Builder $query, string $raw): Builder
|
||||||
if ($tokens === []) {
|
if ($tokens === []) {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
$hasTags = Schema::hasColumn('fornitori', 'tags');
|
$hasTags = Schema::hasColumn('fornitori', 'tags');
|
||||||
|
|
||||||
foreach ($tokens as $token) {
|
foreach ($tokens as $token) {
|
||||||
$term = trim($token);
|
$term = trim($token);
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,37 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Pages\Gescon;
|
namespace App\Filament\Pages\Gescon;
|
||||||
|
|
||||||
use BackedEnum;
|
use App\Filament\Pages\Condomini\CruscottoStabile;
|
||||||
|
use App\Models\Amministratore;
|
||||||
|
use App\Models\Palazzina;
|
||||||
|
use App\Models\Persona;
|
||||||
|
use App\Models\PersonaUnitaRelazione;
|
||||||
|
use App\Models\PianoContiCondominio;
|
||||||
use App\Models\Stabile;
|
use App\Models\Stabile;
|
||||||
|
use App\Models\StabileAmministratoreTransfer;
|
||||||
|
use App\Models\UnitaImmobiliare;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\GesconImport\EssentialImportService;
|
use App\Services\GesconImport\EssentialImportService;
|
||||||
use App\Services\Stabili\StabileTransferService;
|
use App\Services\Stabili\StabileTransferService;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
use App\Filament\Pages\Condomini\CruscottoStabile;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Checkbox;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema as SchemaFacade;
|
use Illuminate\Support\Facades\Schema as SchemaFacade;
|
||||||
use Filament\Forms\Components\Checkbox;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use App\Models\Amministratore;
|
|
||||||
use App\Models\Palazzina;
|
|
||||||
use App\Models\StabileAmministratoreTransfer;
|
|
||||||
use App\Models\UnitaImmobiliare;
|
|
||||||
use App\Models\Persona;
|
|
||||||
use App\Models\PersonaUnitaRelazione;
|
|
||||||
use App\Models\PianoContiCondominio;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class StabiliArchivio extends Page implements HasTable
|
class StabiliArchivio extends Page implements HasTable
|
||||||
|
|
@ -58,7 +56,7 @@ public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
|
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
|
|
@ -143,21 +141,21 @@ public function table(Table $table): Table
|
||||||
}
|
}
|
||||||
|
|
||||||
$options = [
|
$options = [
|
||||||
'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'),
|
'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'),
|
||||||
'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null,
|
'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null,
|
||||||
'with_unita' => (bool) ($data['with_unita'] ?? false),
|
'with_unita' => (bool) ($data['with_unita'] ?? false),
|
||||||
'with_soggetti' => (bool) ($data['with_soggetti'] ?? false),
|
'with_soggetti' => (bool) ($data['with_soggetti'] ?? false),
|
||||||
'with_diritti' => (bool) ($data['with_diritti'] ?? false),
|
'with_diritti' => (bool) ($data['with_diritti'] ?? false),
|
||||||
'with_millesimi' => (bool) ($data['with_millesimi'] ?? false),
|
'with_millesimi' => (bool) ($data['with_millesimi'] ?? false),
|
||||||
'with_voci' => (bool) ($data['with_voci'] ?? false),
|
'with_voci' => (bool) ($data['with_voci'] ?? false),
|
||||||
'with_gestioni' => (bool) ($data['with_gestioni'] ?? false),
|
'with_gestioni' => (bool) ($data['with_gestioni'] ?? false),
|
||||||
'with_banche' => (bool) ($data['with_banche'] ?? false),
|
'with_banche' => (bool) ($data['with_banche'] ?? false),
|
||||||
'dry' => (bool) ($data['dry'] ?? false),
|
'dry' => (bool) ($data['dry'] ?? false),
|
||||||
];
|
];
|
||||||
|
|
||||||
$query = $this->getTableQuery();
|
$query = $this->getTableQuery();
|
||||||
$stabili = $query->get();
|
$stabili = $query->get();
|
||||||
$svc = new EssentialImportService();
|
$svc = new EssentialImportService();
|
||||||
foreach ($stabili as $record) {
|
foreach ($stabili as $record) {
|
||||||
$legacyCode = trim((string) ($record->codice_stabile ?? ''));
|
$legacyCode = trim((string) ($record->codice_stabile ?? ''));
|
||||||
if ($legacyCode === '') {
|
if ($legacyCode === '') {
|
||||||
|
|
@ -181,7 +179,7 @@ public function table(Table $table): Table
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore']);
|
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore']);
|
||||||
})
|
})
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('codice_stabile')
|
TextInput::make('codice_stabile')
|
||||||
|
|
@ -246,14 +244,14 @@ public function table(Table $table): Table
|
||||||
|
|
||||||
$stabile = Stabile::query()->create([
|
$stabile = Stabile::query()->create([
|
||||||
'amministratore_id' => $amm->id,
|
'amministratore_id' => $amm->id,
|
||||||
'codice_stabile' => $codice,
|
'codice_stabile' => $codice,
|
||||||
'denominazione' => (string) ($data['denominazione'] ?? $codice),
|
'denominazione' => (string) ($data['denominazione'] ?? $codice),
|
||||||
'indirizzo' => (string) ($data['indirizzo'] ?? ''),
|
'indirizzo' => (string) ($data['indirizzo'] ?? ''),
|
||||||
'cap' => (string) ($data['cap'] ?? ''),
|
'cap' => (string) ($data['cap'] ?? ''),
|
||||||
'citta' => (string) ($data['citta'] ?? ''),
|
'citta' => (string) ($data['citta'] ?? ''),
|
||||||
'provincia' => (string) ($data['provincia'] ?? ''),
|
'provincia' => (string) ($data['provincia'] ?? ''),
|
||||||
'stato' => 'attivo',
|
'stato' => 'attivo',
|
||||||
'attivo' => true,
|
'attivo' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|
@ -313,63 +311,63 @@ public function table(Table $table): Table
|
||||||
|
|
||||||
$stabile = Stabile::query()->create([
|
$stabile = Stabile::query()->create([
|
||||||
'amministratore_id' => $amm->id,
|
'amministratore_id' => $amm->id,
|
||||||
'codice_stabile' => $codice,
|
'codice_stabile' => $codice,
|
||||||
'denominazione' => (string) ($data['denominazione'] ?? 'Condominio Demo'),
|
'denominazione' => (string) ($data['denominazione'] ?? 'Condominio Demo'),
|
||||||
'indirizzo' => 'Via Demo 1',
|
'indirizzo' => 'Via Demo 1',
|
||||||
'cap' => '00000',
|
'cap' => '00000',
|
||||||
'citta' => 'Demo',
|
'citta' => 'Demo',
|
||||||
'provincia' => 'RM',
|
'provincia' => 'RM',
|
||||||
'stato' => 'attivo',
|
'stato' => 'attivo',
|
||||||
'attivo' => true,
|
'attivo' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$palazzina = Palazzina::query()->create([
|
$palazzina = Palazzina::query()->create([
|
||||||
'stabile_id' => $stabile->id,
|
'stabile_id' => $stabile->id,
|
||||||
'codice_palazzina' => 'A',
|
'codice_palazzina' => 'A',
|
||||||
'denominazione' => 'Palazzina A',
|
'denominazione' => 'Palazzina A',
|
||||||
'numero_scale' => 1,
|
'numero_scale' => 1,
|
||||||
'numero_piani_fuori_terra' => 3,
|
'numero_piani_fuori_terra' => 3,
|
||||||
'numero_piani_interrati' => 0,
|
'numero_piani_interrati' => 0,
|
||||||
'ha_piano_terra' => true,
|
'ha_piano_terra' => true,
|
||||||
'appartamenti_per_piano' => 2,
|
'appartamenti_per_piano' => 2,
|
||||||
'attiva' => true,
|
'attiva' => true,
|
||||||
'created_by' => $user->id,
|
'created_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$unita = UnitaImmobiliare::query()->create([
|
$unita = UnitaImmobiliare::query()->create([
|
||||||
'stabile_id' => $stabile->id,
|
'stabile_id' => $stabile->id,
|
||||||
'palazzina_id' => $palazzina->id,
|
'palazzina_id' => $palazzina->id,
|
||||||
'codice_unita' => 'A-1-0-01',
|
'codice_unita' => 'A-1-0-01',
|
||||||
'palazzina' => 'A',
|
'palazzina' => 'A',
|
||||||
'scala' => '1',
|
'scala' => '1',
|
||||||
'piano' => 0,
|
'piano' => 0,
|
||||||
'interno' => '01',
|
'interno' => '01',
|
||||||
'denominazione' => 'Unità Demo',
|
'denominazione' => 'Unità Demo',
|
||||||
'tipo_unita' => 'appartamento',
|
'tipo_unita' => 'appartamento',
|
||||||
'attiva' => true,
|
'attiva' => true,
|
||||||
'unita_demo' => true,
|
'unita_demo' => true,
|
||||||
'created_by' => $user->id,
|
'created_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$persona = Persona::query()->create([
|
$persona = Persona::query()->create([
|
||||||
'tipologia' => 'fisica',
|
'tipologia' => 'fisica',
|
||||||
'cognome' => 'Demo',
|
'cognome' => 'Demo',
|
||||||
'nome' => 'Condomino',
|
'nome' => 'Condomino',
|
||||||
'codice_fiscale' => null,
|
'codice_fiscale' => null,
|
||||||
'telefono_principale' => null,
|
'telefono_principale' => null,
|
||||||
'email_principale' => null,
|
'email_principale' => null,
|
||||||
'attivo' => true,
|
'attivo' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PersonaUnitaRelazione::query()->create([
|
PersonaUnitaRelazione::query()->create([
|
||||||
'persona_id' => $persona->id,
|
'persona_id' => $persona->id,
|
||||||
'unita_id' => $unita->id,
|
'unita_id' => $unita->id,
|
||||||
'tipo_relazione' => 'proprietario',
|
'tipo_relazione' => 'proprietario',
|
||||||
'quota_relazione' => 100,
|
'quota_relazione' => 100,
|
||||||
'attivo' => true,
|
'attivo' => true,
|
||||||
'riceve_comunicazioni' => true,
|
'riceve_comunicazioni' => true,
|
||||||
'riceve_convocazioni' => true,
|
'riceve_convocazioni' => true,
|
||||||
'vota_assemblea' => true,
|
'vota_assemblea' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|
@ -487,23 +485,23 @@ public function table(Table $table): Table
|
||||||
Select::make('stato')
|
Select::make('stato')
|
||||||
->label('Stato')
|
->label('Stato')
|
||||||
->options([
|
->options([
|
||||||
'attivo' => 'Attivo',
|
'attivo' => 'Attivo',
|
||||||
'inattivo' => 'Inattivo',
|
'inattivo' => 'Inattivo',
|
||||||
])
|
])
|
||||||
->default('attivo')
|
->default('attivo')
|
||||||
->required(),
|
->required(),
|
||||||
Checkbox::make('attivo')->label('Attivo')->default(true),
|
Checkbox::make('attivo')->label('Attivo')->default(true),
|
||||||
])
|
])
|
||||||
->fillForm(fn(Stabile $record): array => [
|
->fillForm(fn(Stabile $record): array=> [
|
||||||
'codice_stabile' => (string) ($record->codice_stabile ?? ''),
|
'codice_stabile' => (string) ($record->codice_stabile ?? ''),
|
||||||
'cod_stabile' => (string) ($record->cod_stabile ?? ''),
|
'cod_stabile' => (string) ($record->cod_stabile ?? ''),
|
||||||
'denominazione' => (string) ($record->denominazione ?? ''),
|
'denominazione' => (string) ($record->denominazione ?? ''),
|
||||||
'indirizzo' => (string) ($record->indirizzo ?? ''),
|
'indirizzo' => (string) ($record->indirizzo ?? ''),
|
||||||
'cap' => (string) ($record->cap ?? ''),
|
'cap' => (string) ($record->cap ?? ''),
|
||||||
'citta' => (string) ($record->citta ?? ''),
|
'citta' => (string) ($record->citta ?? ''),
|
||||||
'provincia' => (string) ($record->provincia ?? ''),
|
'provincia' => (string) ($record->provincia ?? ''),
|
||||||
'stato' => (string) ($record->stato ?? 'attivo'),
|
'stato' => (string) ($record->stato ?? 'attivo'),
|
||||||
'attivo' => (bool) ($record->attivo ?? true),
|
'attivo' => (bool) ($record->attivo ?? true),
|
||||||
])
|
])
|
||||||
->action(function (Stabile $record, array $data): void {
|
->action(function (Stabile $record, array $data): void {
|
||||||
$codice = trim((string) ($data['codice_stabile'] ?? ''));
|
$codice = trim((string) ($data['codice_stabile'] ?? ''));
|
||||||
|
|
@ -526,14 +524,14 @@ public function table(Table $table): Table
|
||||||
|
|
||||||
$record->fill([
|
$record->fill([
|
||||||
'codice_stabile' => $codice,
|
'codice_stabile' => $codice,
|
||||||
'cod_stabile' => $codOperatore,
|
'cod_stabile' => $codOperatore,
|
||||||
'denominazione' => (string) ($data['denominazione'] ?? $codice),
|
'denominazione' => (string) ($data['denominazione'] ?? $codice),
|
||||||
'indirizzo' => (string) ($data['indirizzo'] ?? ''),
|
'indirizzo' => (string) ($data['indirizzo'] ?? ''),
|
||||||
'cap' => (string) ($data['cap'] ?? ''),
|
'cap' => (string) ($data['cap'] ?? ''),
|
||||||
'citta' => (string) ($data['citta'] ?? ''),
|
'citta' => (string) ($data['citta'] ?? ''),
|
||||||
'provincia' => (string) ($data['provincia'] ?? ''),
|
'provincia' => (string) ($data['provincia'] ?? ''),
|
||||||
'stato' => (string) ($data['stato'] ?? 'attivo'),
|
'stato' => (string) ($data['stato'] ?? 'attivo'),
|
||||||
'attivo' => (bool) ($data['attivo'] ?? true),
|
'attivo' => (bool) ($data['attivo'] ?? true),
|
||||||
]);
|
]);
|
||||||
$record->save();
|
$record->save();
|
||||||
|
|
||||||
|
|
@ -599,7 +597,7 @@ public function table(Table $table): Table
|
||||||
if ($label === '') {
|
if ($label === '') {
|
||||||
$label = 'Amministratore';
|
$label = 'Amministratore';
|
||||||
}
|
}
|
||||||
$code = (string) ($a->codice_univoco ?: $a->codice_amministratore ?: '');
|
$code = (string) ($a->codice_univoco ?: $a->codice_amministratore ?: '');
|
||||||
$suffix = $code !== '' ? (' [' . $code . ']') : '';
|
$suffix = $code !== '' ? (' [' . $code . ']') : '';
|
||||||
return [$a->id => $label . $suffix . ' (ID ' . $a->id . ')'];
|
return [$a->id => $label . $suffix . ' (ID ' . $a->id . ')'];
|
||||||
})
|
})
|
||||||
|
|
@ -645,7 +643,7 @@ public function table(Table $table): Table
|
||||||
source: 'portal-superadmin',
|
source: 'portal-superadmin',
|
||||||
ipAddress: request()->ip(),
|
ipAddress: request()->ip(),
|
||||||
meta: [
|
meta: [
|
||||||
'panel' => 'admin-filament',
|
'panel' => 'admin-filament',
|
||||||
'action' => 'stabili-archivio-trasferisci',
|
'action' => 'stabili-archivio-trasferisci',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -676,7 +674,7 @@ public function table(Table $table): Table
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return view('filament.pages.gescon.partials.stabile-transfer-history', [
|
return view('filament.pages.gescon.partials.stabile-transfer-history', [
|
||||||
'stabile' => $record,
|
'stabile' => $record,
|
||||||
'transfers' => $transfers,
|
'transfers' => $transfers,
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
|
|
@ -754,16 +752,16 @@ public function table(Table $table): Table
|
||||||
|
|
||||||
$svc = new EssentialImportService();
|
$svc = new EssentialImportService();
|
||||||
$res = $svc->syncStabile($amm, $legacyCode, [
|
$res = $svc->syncStabile($amm, $legacyCode, [
|
||||||
'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'),
|
'path' => (string) ($data['path'] ?? '/mnt/gescon-archives/gescon'),
|
||||||
'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null,
|
'anno' => isset($data['anno']) && trim((string) $data['anno']) !== '' ? trim((string) $data['anno']) : null,
|
||||||
'with_unita' => (bool) ($data['with_unita'] ?? false),
|
'with_unita' => (bool) ($data['with_unita'] ?? false),
|
||||||
'with_soggetti' => (bool) ($data['with_soggetti'] ?? false),
|
'with_soggetti' => (bool) ($data['with_soggetti'] ?? false),
|
||||||
'with_diritti' => (bool) ($data['with_diritti'] ?? false),
|
'with_diritti' => (bool) ($data['with_diritti'] ?? false),
|
||||||
'with_millesimi' => (bool) ($data['with_millesimi'] ?? false),
|
'with_millesimi' => (bool) ($data['with_millesimi'] ?? false),
|
||||||
'with_voci' => (bool) ($data['with_voci'] ?? false),
|
'with_voci' => (bool) ($data['with_voci'] ?? false),
|
||||||
'with_gestioni' => (bool) ($data['with_gestioni'] ?? false),
|
'with_gestioni' => (bool) ($data['with_gestioni'] ?? false),
|
||||||
'with_banche' => (bool) ($data['with_banche'] ?? false),
|
'with_banche' => (bool) ($data['with_banche'] ?? false),
|
||||||
'dry' => (bool) ($data['dry'] ?? false),
|
'dry' => (bool) ($data['dry'] ?? false),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$title = ($res['changed'] ?? false)
|
$title = ($res['changed'] ?? false)
|
||||||
|
|
@ -798,15 +796,15 @@ public function table(Table $table): Table
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (Stabile $record): void {
|
->action(function (Stabile $record): void {
|
||||||
$checks = [
|
$checks = [
|
||||||
'Palazzine' => fn() => $record->palazzine()->exists(),
|
'Palazzine' => fn() => $record->palazzine()->exists(),
|
||||||
'Unità immobiliari' => fn() => $record->unitaImmobiliari()->exists(),
|
'Unità immobiliari' => fn() => $record->unitaImmobiliari()->exists(),
|
||||||
'Tabelle millesimali' => fn() => $record->tabelleMillesimali()->exists(),
|
'Tabelle millesimali' => fn() => $record->tabelleMillesimali()->exists(),
|
||||||
'Voci spesa' => fn() => $record->vociSpesa()->exists(),
|
'Voci spesa' => fn() => $record->vociSpesa()->exists(),
|
||||||
'Gestioni contabili' => fn() => $record->gestioniContabili()->exists(),
|
'Gestioni contabili' => fn() => $record->gestioniContabili()->exists(),
|
||||||
'Gestioni condominiali' => fn() => $record->gestioniCondominiali()->exists(),
|
'Gestioni condominiali' => fn() => $record->gestioniCondominiali()->exists(),
|
||||||
'Documenti collegati' => fn() => $record->documentiCollegati()->exists(),
|
'Documenti collegati' => fn() => $record->documentiCollegati()->exists(),
|
||||||
'Dati bancari' => fn() => $record->datiBancari()->exists(),
|
'Dati bancari' => fn() => $record->datiBancari()->exists(),
|
||||||
'Tickets' => fn() => $record->tickets()->exists(),
|
'Tickets' => fn() => $record->tickets()->exists(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$blocked = [];
|
$blocked = [];
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ public function form(Schema $schema): Schema
|
||||||
]),
|
]),
|
||||||
|
|
||||||
Section::make('Centralino studio')
|
Section::make('Centralino studio')
|
||||||
->columns(2)
|
->columns(3)
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('impostazioni.centralino.numero_principale')
|
TextInput::make('impostazioni.centralino.numero_principale')
|
||||||
->label('Numero principale centralino')
|
->label('Numero principale centralino')
|
||||||
|
|
@ -206,9 +206,30 @@ public function form(Schema $schema): Schema
|
||||||
TextInput::make('impostazioni.centralino.numero_emergenza')
|
TextInput::make('impostazioni.centralino.numero_emergenza')
|
||||||
->label('Numero emergenza')
|
->label('Numero emergenza')
|
||||||
->maxLength(30),
|
->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')
|
TextInput::make('impostazioni.centralino.note_instradamento')
|
||||||
->label('Note instradamento')
|
->label('Note instradamento')
|
||||||
->maxLength(255),
|
->maxLength(255)
|
||||||
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ public function getChiamateTecnicheProperty()
|
||||||
try {
|
try {
|
||||||
$query = CommunicationMessage::query()
|
$query = CommunicationMessage::query()
|
||||||
->with(['assignedUser:id,name', 'stabile:id,denominazione'])
|
->with(['assignedUser:id,name', 'stabile:id,denominazione'])
|
||||||
->where('channel', 'smdr')
|
->whereIn('channel', ['smdr', 'panasonic_csta'])
|
||||||
->latest('id');
|
->latest('id');
|
||||||
|
|
||||||
if ($this->tecnicoCallView === 'interne') {
|
if ($this->tecnicoCallView === 'interne') {
|
||||||
|
|
@ -268,6 +268,8 @@ public function creaPostItDaMessaggio(int $messageId): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$sourceKey = (string) ($message->channel ?: 'cti');
|
||||||
|
$sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR';
|
||||||
$smdr = (array) data_get($message->metadata, 'smdr', []);
|
$smdr = (array) data_get($message->metadata, 'smdr', []);
|
||||||
$line = trim((string) ($message->message_text ?? ''));
|
$line = trim((string) ($message->message_text ?? ''));
|
||||||
|
|
||||||
|
|
@ -275,13 +277,13 @@ public function creaPostItDaMessaggio(int $messageId): void
|
||||||
'stabile_id' => $message->stabile_id,
|
'stabile_id' => $message->stabile_id,
|
||||||
'creato_da_user_id' => Auth::id(),
|
'creato_da_user_id' => Auth::id(),
|
||||||
'telefono' => $message->phone_number,
|
'telefono' => $message->phone_number,
|
||||||
'nome_chiamante' => 'SMDR ' . strtoupper((string) $message->direction),
|
'nome_chiamante' => $sourceLabel . ' ' . strtoupper((string) $message->direction),
|
||||||
'direzione' => $this->normalizePostItDirection((string) $message->direction),
|
'direzione' => $this->normalizePostItDirection((string) $message->direction),
|
||||||
'origine' => 'smdr_table',
|
'origine' => $sourceKey . '_table',
|
||||||
'origine_id' => (string) $message->id,
|
'origine_id' => (string) $message->id,
|
||||||
'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null,
|
'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null,
|
||||||
'oggetto' => 'Chiamata da pannello tecnico SMDR',
|
'oggetto' => 'Chiamata da pannello tecnico ' . $sourceLabel,
|
||||||
'nota' => $line !== '' ? $line : 'Evento SMDR senza testo riga',
|
'nota' => $line !== '' ? $line : ('Evento ' . $sourceLabel . ' senza testo riga'),
|
||||||
'priorita' => 'Media',
|
'priorita' => 'Media',
|
||||||
'stato' => 'post_it',
|
'stato' => 'post_it',
|
||||||
'chiamata_il' => $message->received_at ?? now(),
|
'chiamata_il' => $message->received_at ?? now(),
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,7 @@ private function startUpdateJob(bool $fallback): void
|
||||||
'exit_code' => null,
|
'exit_code' => null,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
$adminId = $this->resolveAmministratoreId();
|
$adminId = $this->resolveAmministratoreId();
|
||||||
$requireDrive = $this->shouldRequireDrivePreupdateBackup();
|
$requireDrive = $this->shouldRequireDrivePreupdateBackup();
|
||||||
$driveEnabled = $this->shouldAttemptDrivePreupdateBackup();
|
$driveEnabled = $this->shouldAttemptDrivePreupdateBackup();
|
||||||
|
|
||||||
|
|
@ -478,7 +478,7 @@ private function startUpdateJob(bool $fallback): void
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($driveEnabled && $adminId > 0) {
|
if ($driveEnabled && $adminId > 0) {
|
||||||
$backupParams['--drive'] = true;
|
$backupParams['--drive'] = true;
|
||||||
$backupParams['--admin-id'] = (string) $adminId;
|
$backupParams['--admin-id'] = (string) $adminId;
|
||||||
|
|
||||||
if ($requireDrive) {
|
if ($requireDrive) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
namespace App\Filament\Pages\Supporto;
|
namespace App\Filament\Pages\Supporto;
|
||||||
|
|
||||||
use App\Models\CategoriaTicket;
|
use App\Models\CategoriaTicket;
|
||||||
|
use App\Models\Documento;
|
||||||
use App\Models\Fornitore;
|
use App\Models\Fornitore;
|
||||||
use App\Models\FornitoreDipendente;
|
use App\Models\FornitoreDipendente;
|
||||||
use App\Models\InsuranceClaim;
|
use App\Models\InsuranceClaim;
|
||||||
|
|
@ -10,6 +11,8 @@
|
||||||
use App\Models\TicketAttachment;
|
use App\Models\TicketAttachment;
|
||||||
use App\Models\TicketIntervento;
|
use App\Models\TicketIntervento;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Documenti\ImageTextExtractionService;
|
||||||
|
use App\Services\Documenti\PdfTextExtractionService;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
|
|
@ -17,6 +20,7 @@
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
@ -26,6 +30,9 @@ class TicketGestione extends Page
|
||||||
{
|
{
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
|
/** @var array<int,string>|null */
|
||||||
|
private ?array $documentiColumnsCache = null;
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Ticket Gestione';
|
protected static ?string $navigationLabel = 'Ticket Gestione';
|
||||||
|
|
||||||
protected static ?string $title = 'Gestione Ticket';
|
protected static ?string $title = 'Gestione Ticket';
|
||||||
|
|
@ -60,6 +67,22 @@ class TicketGestione extends Page
|
||||||
|
|
||||||
public ?string $insuranceClaimNumber = null;
|
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;
|
public ?string $insuranceNotes = null;
|
||||||
|
|
||||||
/** @var array<int,mixed> */
|
/** @var array<int,mixed> */
|
||||||
|
|
@ -430,28 +453,47 @@ public function salvaSinistroAssicurativo(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'insurancePolicyId' => ['nullable', 'integer', 'exists:insurance_policies,id'],
|
'insurancePolicyId' => ['nullable', 'integer', 'exists:insurance_policies,id'],
|
||||||
'insurancePolicyReference' => ['nullable', 'string', 'max:255'],
|
'insurancePolicyReference' => ['nullable', 'string', 'max:255'],
|
||||||
'insuranceClaimNumber' => ['nullable', 'string', 'max:255'],
|
'insuranceClaimNumber' => ['nullable', 'string', 'max:255'],
|
||||||
'insuranceNotes' => ['nullable', 'string', 'max:4000'],
|
'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(
|
$claim = InsuranceClaim::query()->updateOrCreate(
|
||||||
['ticket_id' => (int) $ticket->id],
|
['ticket_id' => (int) $ticket->id],
|
||||||
[
|
[
|
||||||
'insurance_policy_id' => (int) ($this->insurancePolicyId ?? 0) ?: null,
|
'insurance_policy_id' => (int) ($this->insurancePolicyId ?? 0) ?: null,
|
||||||
'stabile_id' => (int) $ticket->stabile_id,
|
'stabile_id' => (int) $ticket->stabile_id,
|
||||||
'policy_reference' => filled($this->insurancePolicyReference) ? trim((string) $this->insurancePolicyReference) : null,
|
'policy_reference' => filled($this->insurancePolicyReference) ? trim((string) $this->insurancePolicyReference) : null,
|
||||||
'claim_number' => filled($this->insuranceClaimNumber) ? trim((string) $this->insuranceClaimNumber) : null,
|
'claim_number' => filled($this->insuranceClaimNumber) ? trim((string) $this->insuranceClaimNumber) : null,
|
||||||
'status' => 'aperta',
|
'status' => filled($this->insuranceStatus) ? trim((string) $this->insuranceStatus) : 'aperta',
|
||||||
'opened_at' => $ticket->insuranceClaim?->opened_at ?? now(),
|
'opened_at' => $ticket->insuranceClaim?->opened_at ?? now(),
|
||||||
'notes' => filled($this->insuranceNotes) ? trim((string) $this->insuranceNotes) : null,
|
'notes' => filled($this->insuranceNotes) ? trim((string) $this->insuranceNotes) : null,
|
||||||
|
'metadata' => $claimMetadata,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$ticket->messages()->create([
|
$ticket->messages()->create([
|
||||||
'user_id' => Auth::id(),
|
'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',
|
'canale' => 'assicurazione',
|
||||||
'direzione' => 'outbound',
|
'direzione' => 'outbound',
|
||||||
'inviato_il' => now(),
|
'inviato_il' => now(),
|
||||||
|
|
@ -505,7 +547,7 @@ public function caricaAllegati(): void
|
||||||
$path
|
$path
|
||||||
);
|
);
|
||||||
|
|
||||||
TicketAttachment::query()->create([
|
$attachment = TicketAttachment::query()->create([
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'ticket_update_id' => null,
|
'ticket_update_id' => null,
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
|
|
@ -516,6 +558,10 @@ public function caricaAllegati(): void
|
||||||
'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket',
|
'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($attachment instanceof TicketAttachment) {
|
||||||
|
$this->archiveTicketAttachmentDocument($ticket, $attachment);
|
||||||
|
}
|
||||||
|
|
||||||
$saved++;
|
$saved++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1099,12 +1145,20 @@ private function syncSelectedTicketState(): void
|
||||||
$ticket = $this->selectedTicket;
|
$ticket = $this->selectedTicket;
|
||||||
|
|
||||||
if (! $ticket) {
|
if (! $ticket) {
|
||||||
$this->fornitoreSearch = '';
|
$this->fornitoreSearch = '';
|
||||||
$this->fornitoreMatches = [];
|
$this->fornitoreMatches = [];
|
||||||
$this->insurancePolicyId = null;
|
$this->insurancePolicyId = null;
|
||||||
$this->insurancePolicyReference = null;
|
$this->insurancePolicyReference = null;
|
||||||
$this->insuranceClaimNumber = null;
|
$this->insuranceClaimNumber = null;
|
||||||
$this->insuranceNotes = 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1120,10 +1174,20 @@ private function syncSelectedTicketState(): void
|
||||||
$this->fornitoreMatches = [];
|
$this->fornitoreMatches = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->insurancePolicyId = (int) ($ticket->insuranceClaim?->insurance_policy_id ?? 0) ?: null;
|
$this->insurancePolicyId = (int) ($ticket->insuranceClaim?->insurance_policy_id ?? 0) ?: null;
|
||||||
$this->insurancePolicyReference = (string) ($ticket->insuranceClaim?->policy_reference ?? '');
|
$this->insurancePolicyReference = (string) ($ticket->insuranceClaim?->policy_reference ?? '');
|
||||||
$this->insuranceClaimNumber = (string) ($ticket->insuranceClaim?->claim_number ?? '');
|
$this->insuranceClaimNumber = (string) ($ticket->insuranceClaim?->claim_number ?? '');
|
||||||
$this->insuranceNotes = (string) ($ticket->insuranceClaim?->notes ?? '');
|
$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();
|
->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<int,string>
|
||||||
|
*/
|
||||||
|
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
|
private function loadCounters(): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketAttachment;
|
use App\Models\TicketAttachment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Cti\PbxRoutingService;
|
||||||
use App\Support\StabileContext;
|
use App\Support\StabileContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
|
|
@ -159,16 +160,17 @@ public function refreshLiveCallBanner(): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$userExtension = trim((string) ($authUser->pbx_extension ?? ''));
|
$userExtension = trim((string) ($authUser->pbx_extension ?? ''));
|
||||||
$popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && $userExtension !== '';
|
$watchedExtensions = app(PbxRoutingService::class)->getWatchedExtensionsForUser($authUser);
|
||||||
|
$popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && count($watchedExtensions) > 0;
|
||||||
|
|
||||||
$latest = CommunicationMessage::query()
|
$latest = CommunicationMessage::query()
|
||||||
->where('channel', 'smdr')
|
->whereIn('channel', ['smdr', 'panasonic_csta'])
|
||||||
->where('direction', 'inbound')
|
->where('direction', 'inbound')
|
||||||
->whereNotNull('received_at')
|
->whereNotNull('received_at')
|
||||||
->where('received_at', '>=', now()->subMinutes(8))
|
->where('received_at', '>=', now()->subMinutes(8))
|
||||||
->when($popupRestricted, function ($q) use ($userExtension): void {
|
->when($popupRestricted, function ($q) use ($watchedExtensions): void {
|
||||||
$q->where('target_extension', $userExtension);
|
$q->whereIn('target_extension', $watchedExtensions);
|
||||||
})
|
})
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']);
|
->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\InsurancePolicy;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\StabileContext;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
class InsurancePolicyAssetViewController extends Controller
|
||||||
|
{
|
||||||
|
public function show(InsurancePolicy $policy, string $asset): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->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) . '"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ public function show(TicketAttachment $attachment): BinaryFileResponse
|
||||||
|
|
||||||
protected function authorizeSupplierAttachment(User $user, TicketAttachment $attachment): void
|
protected function authorizeSupplierAttachment(User $user, TicketAttachment $attachment): void
|
||||||
{
|
{
|
||||||
$fornitoreIds = [];
|
$fornitoreIds = [];
|
||||||
$currentSupplier = $this->resolveCurrentUserSupplier($user);
|
$currentSupplier = $this->resolveCurrentUserSupplier($user);
|
||||||
|
|
||||||
if ($currentSupplier instanceof Fornitore) {
|
if ($currentSupplier instanceof Fornitore) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ChiamataPostIt;
|
use App\Models\ChiamataPostIt;
|
||||||
|
use App\Models\CommunicationMessage;
|
||||||
|
use App\Models\PbxClickToCallRequest;
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
|
use App\Services\Cti\PbxRoutingService;
|
||||||
use App\Support\PhoneNumber;
|
use App\Support\PhoneNumber;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -24,15 +27,17 @@ public function incoming(Request $request): JsonResponse
|
||||||
$traceId = (string) Str::uuid();
|
$traceId = (string) Str::uuid();
|
||||||
|
|
||||||
$payload = $request->validate([
|
$payload = $request->validate([
|
||||||
'phone' => ['required', 'string', 'max:64'],
|
'phone' => ['required', 'string', 'max:64'],
|
||||||
'name' => ['nullable', 'string', 'max:255'],
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'],
|
'direction' => ['nullable', 'string', 'max:32'],
|
||||||
'outcome' => ['nullable', 'string', 'max:255'],
|
'outcome' => ['nullable', 'string', 'max:255'],
|
||||||
'event_id' => ['nullable', 'string', 'max:100'],
|
'event_id' => ['nullable', 'string', 'max:100'],
|
||||||
'called_extension' => ['nullable', 'string', 'max:30'],
|
'called_extension' => ['nullable', 'string', 'max:30'],
|
||||||
'note' => ['nullable', 'string'],
|
'calling_extension' => ['nullable', 'string', 'max:30'],
|
||||||
'called_at' => ['nullable', 'date'],
|
'event_type' => ['nullable', 'string', 'max:60'],
|
||||||
'is_test' => ['nullable', 'boolean'],
|
'note' => ['nullable', 'string'],
|
||||||
|
'called_at' => ['nullable', 'date'],
|
||||||
|
'is_test' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[$isTest, $classification] = $this->classifyCall($payload);
|
[$isTest, $classification] = $this->classifyCall($payload);
|
||||||
|
|
@ -44,21 +49,23 @@ public function incoming(Request $request): JsonResponse
|
||||||
|
|
||||||
$normalized = PhoneNumber::normalizeForMatch((string) $payload['phone']);
|
$normalized = PhoneNumber::normalizeForMatch((string) $payload['phone']);
|
||||||
$rubrica = $this->findRubricaByPhone($normalized);
|
$rubrica = $this->findRubricaByPhone($normalized);
|
||||||
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
||||||
|
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
|
||||||
|
|
||||||
$postIt = null;
|
$postIt = null;
|
||||||
if (Schema::hasTable('chiamate_post_it')) {
|
if (Schema::hasTable('chiamate_post_it')) {
|
||||||
$postIt = ChiamataPostIt::query()->create([
|
$postIt = ChiamataPostIt::query()->create([
|
||||||
'stabile_id' => null,
|
'stabile_id' => $routing['stabile_id'],
|
||||||
'rubrica_id' => $rubrica?->id,
|
'rubrica_id' => $rubrica?->id,
|
||||||
'ticket_id' => null,
|
'ticket_id' => null,
|
||||||
'creato_da_user_id' => null,
|
'creato_da_user_id' => $routing['user_id'],
|
||||||
'telefono' => $normalized !== '' ? $normalized : (string) $payload['phone'],
|
'telefono' => $normalized !== '' ? $normalized : (string) $payload['phone'],
|
||||||
'nome_chiamante' => $rubrica?->nome_completo ?: ($payload['name'] ?? null),
|
'nome_chiamante' => $rubrica?->nome_completo ?: ($payload['name'] ?? null),
|
||||||
'direzione' => $payload['direction'] ?? 'in_arrivo',
|
'direzione' => $direction['post_it'],
|
||||||
'esito' => $payload['outcome'] ?? null,
|
'esito' => $payload['outcome'] ?? null,
|
||||||
'origine' => 'panasonic_ns1000',
|
'origine' => 'panasonic_ns1000',
|
||||||
'origine_id' => $payload['event_id'] ?? null,
|
'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),
|
'nota' => $this->buildNote($payload),
|
||||||
'priorita' => 'Media',
|
'priorita' => 'Media',
|
||||||
'stato' => 'post_it',
|
'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->mirrorToPeerIfEnabled($request, 'incoming', $payload);
|
||||||
|
|
||||||
$this->logTrace('incoming.stored', $request, $payload, [
|
$this->logTrace('incoming.stored', $request, $payload, [
|
||||||
'trace_id' => $traceId,
|
'trace_id' => $traceId,
|
||||||
'post_it_id' => $postIt?->id,
|
'post_it_id' => $postIt?->id,
|
||||||
|
'message_id' => $message?->id,
|
||||||
'rubrica_id' => $rubrica?->id,
|
'rubrica_id' => $rubrica?->id,
|
||||||
'classification' => $classification,
|
'classification' => $classification,
|
||||||
'is_test' => $isTest,
|
'is_test' => $isTest,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'source' => 'panasonic_ns1000',
|
'source' => 'panasonic_ns1000',
|
||||||
'trace_id' => $traceId,
|
'trace_id' => $traceId,
|
||||||
'phone_normalized' => $normalized,
|
'phone_normalized' => $normalized,
|
||||||
'matched' => (bool) $rubrica,
|
'matched' => (bool) $rubrica,
|
||||||
'rubrica' => $rubrica ? [
|
'rubrica' => $rubrica ? [
|
||||||
'id' => $rubrica->id,
|
'id' => $rubrica->id,
|
||||||
'nome_completo' => $rubrica->nome_completo,
|
'nome_completo' => $rubrica->nome_completo,
|
||||||
'categoria' => $rubrica->categoria,
|
'categoria' => $rubrica->categoria,
|
||||||
] : null,
|
] : null,
|
||||||
'post_it_id' => $postIt?->id,
|
'post_it_id' => $postIt?->id,
|
||||||
'called_extension' => $payload['called_extension'] ?? null,
|
'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();
|
$traceId = (string) Str::uuid();
|
||||||
|
|
||||||
$payload = $request->validate([
|
$payload = $request->validate([
|
||||||
'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'],
|
'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'],
|
||||||
'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'],
|
'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'],
|
||||||
'outcome' => ['nullable', 'string', 'max:255'],
|
'outcome' => ['nullable', 'string', 'max:255'],
|
||||||
'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
|
'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
|
||||||
'ended_at' => ['nullable', 'date'],
|
'ended_at' => ['nullable', 'date'],
|
||||||
'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'],
|
'direction' => ['nullable', 'string', 'max:32'],
|
||||||
'called_extension' => ['nullable', 'string', 'max:30'],
|
'called_extension' => ['nullable', 'string', 'max:30'],
|
||||||
'note' => ['nullable', 'string'],
|
'calling_extension' => ['nullable', 'string', 'max:30'],
|
||||||
'is_test' => ['nullable', 'boolean'],
|
'event_type' => ['nullable', 'string', 'max:60'],
|
||||||
|
'note' => ['nullable', 'string'],
|
||||||
|
'is_test' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[$isTest, $classification] = $this->classifyCall($payload);
|
[$isTest, $classification] = $this->classifyCall($payload);
|
||||||
|
|
@ -150,6 +174,7 @@ public function callEnded(Request $request): JsonResponse
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
|
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
|
||||||
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
||||||
$postIt = $this->findOpenPostIt(
|
$postIt = $this->findOpenPostIt(
|
||||||
(string) ($payload['event_id'] ?? ''),
|
(string) ($payload['event_id'] ?? ''),
|
||||||
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '')
|
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '')
|
||||||
|
|
@ -167,7 +192,7 @@ public function callEnded(Request $request): JsonResponse
|
||||||
'creato_da_user_id' => null,
|
'creato_da_user_id' => null,
|
||||||
'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''),
|
'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''),
|
||||||
'nome_chiamante' => $rubrica?->nome_completo,
|
'nome_chiamante' => $rubrica?->nome_completo,
|
||||||
'direzione' => $payload['direction'] ?? 'in_arrivo',
|
'direzione' => $direction['post_it'],
|
||||||
'esito' => $payload['outcome'] ?? null,
|
'esito' => $payload['outcome'] ?? null,
|
||||||
'origine' => 'panasonic_ns1000',
|
'origine' => 'panasonic_ns1000',
|
||||||
'origine_id' => $payload['event_id'] ?? null,
|
'origine_id' => $payload['event_id'] ?? null,
|
||||||
|
|
@ -212,26 +237,145 @@ public function callEnded(Request $request): JsonResponse
|
||||||
|
|
||||||
$postIt->save();
|
$postIt->save();
|
||||||
|
|
||||||
|
$message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id);
|
||||||
|
|
||||||
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
|
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
|
||||||
|
|
||||||
$this->logTrace('call-ended.closed', $request, $payload, [
|
$this->logTrace('call-ended.closed', $request, $payload, [
|
||||||
'trace_id' => $traceId,
|
'trace_id' => $traceId,
|
||||||
'post_it_id' => $postIt->id,
|
'post_it_id' => $postIt->id,
|
||||||
|
'message_id' => $message?->id,
|
||||||
'created_new' => $created,
|
'created_new' => $created,
|
||||||
'classification' => $classification,
|
'classification' => $classification,
|
||||||
'is_test' => $isTest,
|
'is_test' => $isTest,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'source' => 'panasonic_ns1000',
|
'source' => 'panasonic_ns1000',
|
||||||
'trace_id' => $traceId,
|
'trace_id' => $traceId,
|
||||||
'closed' => true,
|
'closed' => true,
|
||||||
'created_new' => $created,
|
'created_new' => $created,
|
||||||
'post_it_id' => $postIt->id,
|
'post_it_id' => $postIt->id,
|
||||||
'stato' => $postIt->stato,
|
'communication_message_id' => $message?->id,
|
||||||
'esito' => $postIt->esito,
|
'stato' => $postIt->stato,
|
||||||
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
|
'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
|
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', '');
|
$configured = (string) env('NETGESCON_CTI_SHARED_TOKEN', '');
|
||||||
if ($configured === '') {
|
if ($configured === '') {
|
||||||
$configured = (string) env('CTI_SHARED_SECRET', '');
|
$configured = (string) env('CTI_SHARED_SECRET', '');
|
||||||
|
|
@ -272,7 +428,7 @@ private function resolveConfiguredToken(): string
|
||||||
|
|
||||||
private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array $payload): void
|
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) {
|
if (! $enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -281,7 +437,7 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = rtrim((string) env('NETGESCON_CTI_MIRROR_BASE_URL', ''), '/');
|
$baseUrl = rtrim((string) config('cti.mirror_base_url', ''), '/');
|
||||||
if ($baseUrl === '') {
|
if ($baseUrl === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +447,7 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array
|
||||||
return;
|
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, '/');
|
$url = $baseUrl . '/api/v1/cti/panasonic/' . ltrim($endpoint, '/');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -373,10 +529,12 @@ private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt
|
||||||
private function buildNote(array $payload): string
|
private function buildNote(array $payload): string
|
||||||
{
|
{
|
||||||
[$isTest, $classification] = $this->classifyCall($payload);
|
[$isTest, $classification] = $this->classifyCall($payload);
|
||||||
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
'Origine: Panasonic NS1000 (CSTA)',
|
'Origine: Panasonic NS1000 (CSTA)',
|
||||||
'Classificazione: ' . $classification,
|
'Classificazione: ' . $classification,
|
||||||
|
'Direzione normalizzata: ' . $direction['communication'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($isTest) {
|
if ($isTest) {
|
||||||
|
|
@ -387,6 +545,14 @@ private function buildNote(array $payload): string
|
||||||
$lines[] = 'Interno chiamato: ' . $payload['called_extension'];
|
$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'])) {
|
if (! empty($payload['event_id'])) {
|
||||||
$lines[] = 'Event ID: ' . $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);
|
$phoneDigits = PhoneNumber::normalizeDigits($phoneRaw);
|
||||||
|
|
||||||
Log::info('CTI Panasonic trace', array_merge([
|
Log::info('CTI Panasonic trace', array_merge([
|
||||||
'event' => $event,
|
'event' => $event,
|
||||||
'ip' => $request->ip(),
|
'ip' => $request->ip(),
|
||||||
'user_agent' => (string) $request->userAgent(),
|
'user_agent' => (string) $request->userAgent(),
|
||||||
'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1',
|
'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1',
|
||||||
'event_id' => (string) ($payload['event_id'] ?? ''),
|
'event_type' => (string) ($payload['event_type'] ?? ''),
|
||||||
'called_extension' => (string) ($payload['called_extension'] ?? ''),
|
'event_id' => (string) ($payload['event_id'] ?? ''),
|
||||||
'direction' => (string) ($payload['direction'] ?? ''),
|
'calling_extension' => (string) ($payload['calling_extension'] ?? ''),
|
||||||
'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '',
|
'called_extension' => (string) ($payload['called_extension'] ?? ''),
|
||||||
|
'direction' => (string) ($payload['direction'] ?? ''),
|
||||||
|
'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '',
|
||||||
], $extra));
|
], $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<string,mixed>|null $existing
|
||||||
|
* @param array<string,mixed> $newData
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
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<string,mixed>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,125 +215,6 @@ private function getUserPermissions($user, $activeRole = null)
|
||||||
|
|
||||||
return $permissions;
|
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)
|
* Ottiene tutti i ruoli dell'utente (sistema ruoli multipli)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class Documento extends Model
|
class Documento extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -51,12 +50,16 @@ class Documento extends Model
|
||||||
'tags',
|
'tags',
|
||||||
'note',
|
'note',
|
||||||
'numero_protocollo',
|
'numero_protocollo',
|
||||||
|
'archive_reference',
|
||||||
'percorso_file',
|
'percorso_file',
|
||||||
'numero_pagine',
|
'numero_pagine',
|
||||||
'dimensione_file',
|
'dimensione_file',
|
||||||
'estensione',
|
'estensione',
|
||||||
'contenuto_ocr',
|
'contenuto_ocr',
|
||||||
'metadati_ocr',
|
'metadati_ocr',
|
||||||
|
'visibility_scope',
|
||||||
|
'visibility_groups',
|
||||||
|
'visibility_roles',
|
||||||
'approvato',
|
'approvato',
|
||||||
'archiviato',
|
'archiviato',
|
||||||
'urgente',
|
'urgente',
|
||||||
|
|
@ -66,26 +69,28 @@ class Documento extends Model
|
||||||
'data_upload',
|
'data_upload',
|
||||||
'data_approvazione',
|
'data_approvazione',
|
||||||
'data_archiviazione',
|
'data_archiviazione',
|
||||||
'approvato_da'
|
'approvato_da',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'xml_data' => 'array',
|
'xml_data' => 'array',
|
||||||
'metadati_ocr' => 'array',
|
'metadati_ocr' => 'array',
|
||||||
'dimensione_file' => 'integer',
|
'visibility_groups' => 'array',
|
||||||
'data_documento' => 'date',
|
'visibility_roles' => 'array',
|
||||||
'data_scadenza' => 'date',
|
'dimensione_file' => 'integer',
|
||||||
'data_upload' => 'datetime',
|
'data_documento' => 'date',
|
||||||
'data_approvazione' => 'datetime',
|
'data_scadenza' => 'date',
|
||||||
|
'data_upload' => 'datetime',
|
||||||
|
'data_approvazione' => 'datetime',
|
||||||
'data_archiviazione' => 'datetime',
|
'data_archiviazione' => 'datetime',
|
||||||
'importo_collegato' => 'decimal:2',
|
'importo_collegato' => 'decimal:2',
|
||||||
'approvato' => 'boolean',
|
'approvato' => 'boolean',
|
||||||
'archiviato' => 'boolean',
|
'archiviato' => 'boolean',
|
||||||
'urgente' => 'boolean',
|
'urgente' => 'boolean',
|
||||||
'is_demo' => 'boolean',
|
'is_demo' => 'boolean',
|
||||||
'collegato_budget' => 'boolean',
|
'collegato_budget' => 'boolean',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -175,9 +180,11 @@ public function setTagsArrayAttribute($value)
|
||||||
|
|
||||||
public function getDimensioneFormattataAttribute()
|
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'];
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
|
||||||
for ($i = 0; $size > 1024 && $i < count($units) - 1; $i++) {
|
for ($i = 0; $size > 1024 && $i < count($units) - 1; $i++) {
|
||||||
|
|
@ -189,7 +196,9 @@ public function getDimensioneFormattataAttribute()
|
||||||
|
|
||||||
public function getGiorniAllaScadenzaAttribute()
|
public function getGiorniAllaScadenzaAttribute()
|
||||||
{
|
{
|
||||||
if (!$this->data_scadenza) return null;
|
if (! $this->data_scadenza) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return Carbon::now()->diffInDays($this->data_scadenza, false);
|
return Carbon::now()->diffInDays($this->data_scadenza, false);
|
||||||
}
|
}
|
||||||
|
|
@ -198,10 +207,21 @@ public function getStatoScadenzaAttribute()
|
||||||
{
|
{
|
||||||
$giorni = $this->giorni_alla_scadenza;
|
$giorni = $this->giorni_alla_scadenza;
|
||||||
|
|
||||||
if ($giorni === null) return 'nessuna_scadenza';
|
if ($giorni === null) {
|
||||||
if ($giorni < 0) return 'scaduto';
|
return 'nessuna_scadenza';
|
||||||
if ($giorni <= 7) return 'scadenza_imminente';
|
}
|
||||||
if ($giorni <= 30) return 'in_scadenza';
|
|
||||||
|
if ($giorni < 0) {
|
||||||
|
return 'scaduto';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($giorni <= 7) {
|
||||||
|
return 'scadenza_imminente';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($giorni <= 30) {
|
||||||
|
return 'in_scadenza';
|
||||||
|
}
|
||||||
|
|
||||||
return 'valido';
|
return 'valido';
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +232,7 @@ public function getStatoScadenzaAttribute()
|
||||||
public function generaNumeroProtocollo()
|
public function generaNumeroProtocollo()
|
||||||
{
|
{
|
||||||
$prefisso = strtoupper(substr($this->tipologia ?? 'DOC', 0, 4));
|
$prefisso = strtoupper(substr($this->tipologia ?? 'DOC', 0, 4));
|
||||||
$anno = Carbon::now()->year;
|
$anno = Carbon::now()->year;
|
||||||
|
|
||||||
// Trova il prossimo numero progressivo per l'anno
|
// Trova il prossimo numero progressivo per l'anno
|
||||||
$ultimoNumero = static::where('numero_protocollo', 'like', "{$prefisso}-{$anno}-%")
|
$ultimoNumero = static::where('numero_protocollo', 'like', "{$prefisso}-{$anno}-%")
|
||||||
|
|
@ -236,8 +256,8 @@ public function hasTag($tag)
|
||||||
public function addTag($tag)
|
public function addTag($tag)
|
||||||
{
|
{
|
||||||
$tags = $this->tags_array;
|
$tags = $this->tags_array;
|
||||||
if (!in_array($tag, $tags)) {
|
if (! in_array($tag, $tags)) {
|
||||||
$tags[] = $tag;
|
$tags[] = $tag;
|
||||||
$this->tags_array = $tags;
|
$this->tags_array = $tags;
|
||||||
}
|
}
|
||||||
return $this;
|
return $this;
|
||||||
|
|
@ -245,7 +265,7 @@ public function addTag($tag)
|
||||||
|
|
||||||
public function removeTag($tag)
|
public function removeTag($tag)
|
||||||
{
|
{
|
||||||
$tags = $this->tags_array;
|
$tags = $this->tags_array;
|
||||||
$index = array_search($tag, $tags);
|
$index = array_search($tag, $tags);
|
||||||
if ($index !== false) {
|
if ($index !== false) {
|
||||||
unset($tags[$index]);
|
unset($tags[$index]);
|
||||||
|
|
@ -256,7 +276,7 @@ public function removeTag($tag)
|
||||||
|
|
||||||
public function approva($userId = null)
|
public function approva($userId = null)
|
||||||
{
|
{
|
||||||
$this->approvato = true;
|
$this->approvato = true;
|
||||||
$this->data_approvazione = Carbon::now();
|
$this->data_approvazione = Carbon::now();
|
||||||
if ($userId) {
|
if ($userId) {
|
||||||
$this->approvato_da = $userId;
|
$this->approvato_da = $userId;
|
||||||
|
|
@ -266,7 +286,7 @@ public function approva($userId = null)
|
||||||
|
|
||||||
public function archivia()
|
public function archivia()
|
||||||
{
|
{
|
||||||
$this->archiviato = true;
|
$this->archiviato = true;
|
||||||
$this->data_archiviazione = Carbon::now();
|
$this->data_archiviazione = Carbon::now();
|
||||||
return $this->save();
|
return $this->save();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,11 @@ class InsurancePolicy extends Model
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'started_at' => 'date',
|
'started_at' => 'date',
|
||||||
'expires_at' => 'date',
|
'expires_at' => 'date',
|
||||||
'renewal_at' => 'date',
|
'renewal_at' => 'date',
|
||||||
'annual_amount' => 'decimal:2',
|
'annual_amount' => 'decimal:2',
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function stabile()
|
public function stabile()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\RubricaUniversale;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Models\RubricaUniversale;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
|
|
@ -117,44 +116,44 @@ class Stabile extends Model
|
||||||
'codice_interno',
|
'codice_interno',
|
||||||
'registro_anagrafe',
|
'registro_anagrafe',
|
||||||
'documenti_path',
|
'documenti_path',
|
||||||
'attivo'
|
'attivo',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
'anno_costruzione' => 'integer',
|
'anno_costruzione' => 'integer',
|
||||||
'numero_piani' => 'integer',
|
'numero_piani' => 'integer',
|
||||||
'numero_unita' => 'integer',
|
'numero_unita' => 'integer',
|
||||||
'superficie_totale' => 'decimal:2',
|
'superficie_totale' => 'decimal:2',
|
||||||
'numero_ascensori' => 'integer',
|
'numero_ascensori' => 'integer',
|
||||||
'numero_garage' => 'integer',
|
'numero_garage' => 'integer',
|
||||||
'numero_rate_ordinarie' => 'integer',
|
'numero_rate_ordinarie' => 'integer',
|
||||||
'numero_rate_straordinarie' => 'integer',
|
'numero_rate_straordinarie' => 'integer',
|
||||||
'presenza_ascensore' => 'boolean',
|
'presenza_ascensore' => 'boolean',
|
||||||
'presenza_giardino' => 'boolean',
|
'presenza_giardino' => 'boolean',
|
||||||
'presenza_piscina' => 'boolean',
|
'presenza_piscina' => 'boolean',
|
||||||
'presenza_garage' => 'boolean',
|
'presenza_garage' => 'boolean',
|
||||||
'registro_anagrafe' => 'boolean',
|
'registro_anagrafe' => 'boolean',
|
||||||
'attivo' => 'boolean',
|
'attivo' => 'boolean',
|
||||||
'mesi_rate_ordinarie' => 'array',
|
'mesi_rate_ordinarie' => 'array',
|
||||||
'mesi_rate_straordinarie' => 'array',
|
'mesi_rate_straordinarie' => 'array',
|
||||||
'configurazione_default_unita' => 'array',
|
'configurazione_default_unita' => 'array',
|
||||||
'struttura_fisica_json' => 'array',
|
'struttura_fisica_json' => 'array',
|
||||||
'palazzine_data' => 'array',
|
'palazzine_data' => 'array',
|
||||||
'locali_servizio' => 'array',
|
'locali_servizio' => 'array',
|
||||||
'configurazione_avanzata' => 'array',
|
'configurazione_avanzata' => 'array',
|
||||||
'rate_ordinarie_mesi' => 'array',
|
'rate_ordinarie_mesi' => 'array',
|
||||||
'rate_riscaldamento_mesi' => 'array',
|
'rate_riscaldamento_mesi' => 'array',
|
||||||
'rendita_catastale' => 'decimal:2',
|
'rendita_catastale' => 'decimal:2',
|
||||||
'superficie_catastale' => 'decimal:2',
|
'superficie_catastale' => 'decimal:2',
|
||||||
'fondo_riserva_minimo' => 'decimal:2',
|
'fondo_riserva_minimo' => 'decimal:2',
|
||||||
'importo_rata_standard' => 'decimal:2',
|
'importo_rata_standard' => 'decimal:2',
|
||||||
'inizio_esercizio' => 'date',
|
'inizio_esercizio' => 'date',
|
||||||
'fine_esercizio' => 'date',
|
'fine_esercizio' => 'date',
|
||||||
'data_nomina' => 'date',
|
'data_nomina' => 'date',
|
||||||
'scadenza_mandato' => 'date',
|
'scadenza_mandato' => 'date',
|
||||||
'ultima_generazione_unita' => 'datetime'
|
'ultima_generazione_unita' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -280,30 +279,25 @@ public function getIndirizzoCompletoAttribute()
|
||||||
*/
|
*/
|
||||||
public function getDatiCatastaliAttribute()
|
public function getDatiCatastaliAttribute()
|
||||||
{
|
{
|
||||||
$codiceComune = $this->codice_comune_catasto
|
$codiceComune = $this->codice_comune_catasto ?? $this->codice_catastale_comune;
|
||||||
?? $this->codice_catastale_comune;
|
|
||||||
|
|
||||||
$foglio = $this->foglio
|
$foglio = $this->foglio ?? $this->foglio_catasto;
|
||||||
?? $this->foglio_catasto;
|
|
||||||
|
|
||||||
$particella = $this->particella
|
$particella = $this->particella ?? $this->particella_catasto ?? $this->mappale;
|
||||||
?? $this->particella_catasto
|
|
||||||
?? $this->mappale;
|
|
||||||
|
|
||||||
$categoria = $this->categoria_catastale
|
$categoria = $this->categoria_catastale ?? $this->categoria;
|
||||||
?? $this->categoria;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codice_comune' => $codiceComune,
|
'codice_comune' => $codiceComune,
|
||||||
'foglio' => $foglio,
|
'foglio' => $foglio,
|
||||||
'particella' => $particella,
|
'particella' => $particella,
|
||||||
'subalterno' => $this->subalterno,
|
'subalterno' => $this->subalterno,
|
||||||
'sezione' => $this->sezione,
|
'sezione' => $this->sezione,
|
||||||
'categoria' => $categoria,
|
'categoria' => $categoria,
|
||||||
'classe' => $this->classe,
|
'classe' => $this->classe,
|
||||||
'consistenza' => $this->consistenza,
|
'consistenza' => $this->consistenza,
|
||||||
'rendita' => $this->rendita_catastale,
|
'rendita' => $this->rendita_catastale,
|
||||||
'superficie' => $this->superficie_catastale,
|
'superficie' => $this->superficie_catastale,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,8 +308,8 @@ public function getDatiSdiAttribute()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'codice_destinatario' => $this->codice_destinatario_sdi,
|
'codice_destinatario' => $this->codice_destinatario_sdi,
|
||||||
'pec_amministratore' => $this->pec_amministratore,
|
'pec_amministratore' => $this->pec_amministratore,
|
||||||
'pec_condominio' => $this->pec_condominio
|
'pec_condominio' => $this->pec_condominio,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,7 +320,7 @@ public function getConfigurazioneRateOrdinarieAttribute()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'numero_rate' => $this->numero_rate_ordinarie,
|
'numero_rate' => $this->numero_rate_ordinarie,
|
||||||
'mesi' => $this->mesi_rate_ordinarie
|
'mesi' => $this->mesi_rate_ordinarie,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,7 +331,7 @@ public function getConfigurazioneRateStraordinarieAttribute()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'numero_rate' => $this->numero_rate_straordinarie,
|
'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()
|
public function getCaratteristicheAttribute()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'anno_costruzione' => $this->anno_costruzione,
|
'anno_costruzione' => $this->anno_costruzione,
|
||||||
'numero_piani' => $this->numero_piani,
|
'numero_piani' => $this->numero_piani,
|
||||||
'numero_unita' => $this->numero_unita,
|
'numero_unita' => $this->numero_unita,
|
||||||
'superficie_totale' => $this->superficie_totale,
|
'superficie_totale' => $this->superficie_totale,
|
||||||
'tipo_riscaldamento' => $this->tipo_riscaldamento,
|
'tipo_riscaldamento' => $this->tipo_riscaldamento,
|
||||||
'tipo_acqua' => $this->tipo_acqua,
|
'tipo_acqua' => $this->tipo_acqua,
|
||||||
'presenza_ascensore' => $this->presenza_ascensore,
|
'presenza_ascensore' => $this->presenza_ascensore,
|
||||||
'numero_ascensori' => $this->numero_ascensori,
|
'numero_ascensori' => $this->numero_ascensori,
|
||||||
'presenza_giardino' => $this->presenza_giardino,
|
'presenza_giardino' => $this->presenza_giardino,
|
||||||
'presenza_piscina' => $this->presenza_piscina,
|
'presenza_piscina' => $this->presenza_piscina,
|
||||||
'presenza_garage' => $this->presenza_garage,
|
'presenza_garage' => $this->presenza_garage,
|
||||||
'numero_garage' => $this->numero_garage
|
'numero_garage' => $this->numero_garage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,8 +426,8 @@ public function getStatistiche()
|
||||||
$unita = $this->unitaImmobiliari()->where('attiva', true);
|
$unita = $this->unitaImmobiliari()->where('attiva', true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'totale_unita' => $unita->count(),
|
'totale_unita' => $unita->count(),
|
||||||
'unita_in_locazione' => $unita->whereHas('contrattiLocazione', function ($query) {
|
'unita_in_locazione' => $unita->whereHas('contrattiLocazione', function ($query) {
|
||||||
$query->where('stato', 'attivo')
|
$query->where('stato', 'attivo')
|
||||||
->whereDate('data_inizio', '<=', now())
|
->whereDate('data_inizio', '<=', now())
|
||||||
->where(function ($q) {
|
->where(function ($q) {
|
||||||
|
|
@ -441,10 +435,10 @@ public function getStatistiche()
|
||||||
->orWhereDate('data_fine', '>=', now());
|
->orWhereDate('data_fine', '>=', now());
|
||||||
});
|
});
|
||||||
})->count(),
|
})->count(),
|
||||||
'superficie_totale' => $unita->sum('superficie_commerciale'),
|
'superficie_totale' => $unita->sum('superficie_commerciale'),
|
||||||
'totale_millesimi_proprieta' => $this->getTotaleMillesimi('proprieta'),
|
'totale_millesimi_proprieta' => $this->getTotaleMillesimi('proprieta'),
|
||||||
'totale_proprietari' => $this->getProprietari()->count(),
|
'totale_proprietari' => $this->getProprietari()->count(),
|
||||||
'totale_inquilini' => $this->getInquilini()->count()
|
'totale_inquilini' => $this->getInquilini()->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -27,9 +26,9 @@ class StabileAmministratoreTransfer extends Model
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'also_rubrica' => 'boolean',
|
'also_rubrica' => 'boolean',
|
||||||
'meta' => 'array',
|
'meta' => 'array',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function stabile(): BelongsTo
|
public function stabile(): BelongsTo
|
||||||
|
|
|
||||||
|
|
@ -48,20 +48,20 @@ class StabileServizioLettura extends Model
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'periodo_dal' => 'date',
|
'periodo_dal' => 'date',
|
||||||
'periodo_al' => 'date',
|
'periodo_al' => 'date',
|
||||||
'richiesta_lettura_inviata_at' => 'datetime',
|
'richiesta_lettura_inviata_at' => 'datetime',
|
||||||
'prossima_lettura_scadenza_at' => 'datetime',
|
'prossima_lettura_scadenza_at' => 'datetime',
|
||||||
'deadline_lettura_at' => 'datetime',
|
'deadline_lettura_at' => 'datetime',
|
||||||
'sollecito_inviato_at' => 'datetime',
|
'sollecito_inviato_at' => 'datetime',
|
||||||
'lettura_precedente_valore' => 'decimal:3',
|
'lettura_precedente_valore' => 'decimal:3',
|
||||||
'lettura_inizio' => 'decimal:3',
|
'lettura_inizio' => 'decimal:3',
|
||||||
'lettura_fine' => 'decimal:3',
|
'lettura_fine' => 'decimal:3',
|
||||||
'lettura_foto_metadata' => 'array',
|
'lettura_foto_metadata' => 'array',
|
||||||
'lettura_ocr_confidenza' => 'decimal:2',
|
'lettura_ocr_confidenza' => 'decimal:2',
|
||||||
'consumo_valore' => 'decimal:3',
|
'consumo_valore' => 'decimal:3',
|
||||||
'importo_totale' => 'decimal:2',
|
'importo_totale' => 'decimal:2',
|
||||||
'raw' => 'array',
|
'raw' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function stabile(): BelongsTo
|
public function stabile(): BelongsTo
|
||||||
|
|
|
||||||
|
|
@ -107,5 +107,6 @@ public function panel(Panel $panel): Panel
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
app/Services/Cti/PbxRoutingService.php
Normal file
175
app/Services/Cti/PbxRoutingService.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Services\Cti;
|
||||||
|
|
||||||
|
use App\Models\Amministratore;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\StabileContext;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class PbxRoutingService
|
||||||
|
{
|
||||||
|
public function normalizeExtension(?string $extension): string
|
||||||
|
{
|
||||||
|
$digits = preg_replace('/\D+/', '', (string) $extension);
|
||||||
|
|
||||||
|
return is_string($digits) ? $digits : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{extension:string,user_id:int|null,stabile_id:int|null,user:?User,amministratore_id:int|null,studio_role:?string,studio_mode:?string}
|
||||||
|
*/
|
||||||
|
public function resolveByExtension(?string $extension): array
|
||||||
|
{
|
||||||
|
$normalized = $this->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<int,string>
|
||||||
|
*/
|
||||||
|
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<int,string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Services/Documenti/ImageTextExtractionService.php
Normal file
138
app/Services/Documenti/ImageTextExtractionService.php
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Services\Documenti;
|
||||||
|
|
||||||
|
use App\Models\Documento;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ImageTextExtractionService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{status: 'ok'|'skipped'|'error', message?: string, chars?: int, engine?: string}
|
||||||
|
*/
|
||||||
|
public function extractAndStore(Documento $documento, bool $force = false): array
|
||||||
|
{
|
||||||
|
$relativePath = $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,148 +1,4 @@
|
||||||
# MIGRAZIONE ARCHITETTURA CONTABILE MULTI-ANNO
|
<?php
|
||||||
|
|
||||||
## 🎯 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:
|
|
||||||
|
|
||||||
<function_calls>
|
|
||||||
<invoke name="create_file">
|
|
||||||
<parameter name="content"><?php
|
|
||||||
|
|
||||||
namespace App\Services\Import;
|
namespace App\Services\Import;
|
||||||
|
|
||||||
|
|
@ -494,35 +350,6 @@ private function importIncassiEstrattoContoForGestione(GestioneContabile $gestio
|
||||||
return $imported;
|
return $imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import rate emesse da generale_stabile
|
|
||||||
*/
|
|
||||||
private function importRateEmesse(string $mdbPath, int $year): int
|
|
||||||
{
|
|
||||||
// Questa funzione opera a livello di anno, non di gestione specifica
|
|
||||||
$imported = 0;
|
|
||||||
$tables = ['EMESS_DET', 'EMESS_DET_2', 'EMESS_GEN'];
|
|
||||||
|
|
||||||
foreach ($tables as $table) {
|
|
||||||
$rate = $this->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
|
* Costruisce WHERE clause per filtrare per gestione
|
||||||
*/
|
*/
|
||||||
|
|
@ -566,23 +393,6 @@ private function buildGestioneJoinClause(GestioneContabile $gestione, string $al
|
||||||
return "1=1";
|
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
|
* Import dati per una specifica gestione/anno
|
||||||
|
|
@ -925,6 +735,3 @@ private function parseGesconDate($dateValue): ?string
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Altri metodi helper...
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Stabili;
|
namespace App\Services\Stabili;
|
||||||
|
|
||||||
use App\Models\Amministratore;
|
use App\Models\Amministratore;
|
||||||
|
|
@ -22,7 +21,7 @@ public function transfer(
|
||||||
array $meta = []
|
array $meta = []
|
||||||
): StabileAmministratoreTransfer {
|
): StabileAmministratoreTransfer {
|
||||||
$fromId = (int) ($stabile->amministratore_id ?? 0);
|
$fromId = (int) ($stabile->amministratore_id ?? 0);
|
||||||
$toId = (int) $destination->id;
|
$toId = (int) $destination->id;
|
||||||
|
|
||||||
if ($fromId === $toId) {
|
if ($fromId === $toId) {
|
||||||
throw new \InvalidArgumentException('Lo stabile e` gia` assegnato a questo amministratore.');
|
throw new \InvalidArgumentException('Lo stabile e` gia` assegnato a questo amministratore.');
|
||||||
|
|
@ -45,16 +44,16 @@ public function transfer(
|
||||||
}
|
}
|
||||||
|
|
||||||
return StabileAmministratoreTransfer::query()->create([
|
return StabileAmministratoreTransfer::query()->create([
|
||||||
'stabile_id' => (int) $stabile->id,
|
'stabile_id' => (int) $stabile->id,
|
||||||
'from_amministratore_id' => $fromId > 0 ? $fromId : null,
|
'from_amministratore_id' => $fromId > 0 ? $fromId : null,
|
||||||
'to_amministratore_id' => $toId,
|
'to_amministratore_id' => $toId,
|
||||||
'changed_by_user_id' => $actor?->id,
|
'changed_by_user_id' => $actor?->id,
|
||||||
'changed_by_name' => $actor?->name ?: 'Sistema',
|
'changed_by_name' => $actor?->name ?: 'Sistema',
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
'reason' => $reason !== null ? trim($reason) : null,
|
'reason' => $reason !== null ? trim($reason) : null,
|
||||||
'also_rubrica' => $alsoRubrica,
|
'also_rubrica' => $alsoRubrica,
|
||||||
'ip_address' => $ipAddress,
|
'ip_address' => $ipAddress,
|
||||||
'meta' => $meta,
|
'meta' => $meta,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
app/Support/Livewire/SupportsRubricaLookup.php
Normal file
134
app/Support/Livewire/SupportsRubricaLookup.php
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Support\Livewire;
|
||||||
|
|
||||||
|
use App\Models\RubricaUniversale;
|
||||||
|
use BackedEnum;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
trait SupportsRubricaLookup
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalizza input Livewire eterogenei in una stringa di ricerca stabile.
|
||||||
|
*/
|
||||||
|
protected function normalizeRubricaLookupText($value): string
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = Arr::first(Arr::flatten($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof BackedEnum) {
|
||||||
|
$value = $value->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<string,mixed>
|
||||||
|
*/
|
||||||
|
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<int,string> $categories
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
use App\Console\Commands\NetgesconPreupdateBackupCommand;
|
use App\Console\Commands\NetgesconPreupdateBackupCommand;
|
||||||
use App\Console\Commands\NetgesconQaUnitaNominativiCommand;
|
use App\Console\Commands\NetgesconQaUnitaNominativiCommand;
|
||||||
use App\Console\Commands\NetgesconRestoreRecordCommand;
|
use App\Console\Commands\NetgesconRestoreRecordCommand;
|
||||||
|
use App\Console\Commands\PanasonicCstaBridgeCommand;
|
||||||
use App\Console\Commands\StabiliTransferCommand;
|
use App\Console\Commands\StabiliTransferCommand;
|
||||||
use App\Console\Commands\SyncSoggettiToPersone;
|
use App\Console\Commands\SyncSoggettiToPersone;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
NetgesconDistributionPullCommand::class,
|
NetgesconDistributionPullCommand::class,
|
||||||
NetgesconPreupdateBackupCommand::class,
|
NetgesconPreupdateBackupCommand::class,
|
||||||
NetgesconRestoreRecordCommand::class,
|
NetgesconRestoreRecordCommand::class,
|
||||||
|
PanasonicCstaBridgeCommand::class,
|
||||||
GoogleSyncRubricaContactsCommand::class,
|
GoogleSyncRubricaContactsCommand::class,
|
||||||
GooglePushRubricaContactsCommand::class,
|
GooglePushRubricaContactsCommand::class,
|
||||||
GoogleImportKeepNotesCommand::class,
|
GoogleImportKeepNotesCommand::class,
|
||||||
|
|
|
||||||
14
config/cti.php
Normal file
14
config/cti.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'shared_token' => env('NETGESCON_CTI_SHARED_TOKEN', ''),
|
||||||
|
'legacy_tokens' => [
|
||||||
|
env('CTI_SHARED_SECRET', ''),
|
||||||
|
env('CTI_SHARED_TOKEN', ''),
|
||||||
|
env('CTI_TOKEN', ''),
|
||||||
|
env('CTI_PANASONIC_TOKEN', ''),
|
||||||
|
],
|
||||||
|
'mirror_enabled' => (bool) env('NETGESCON_CTI_MIRROR_ENABLED', false),
|
||||||
|
'mirror_base_url' => env('NETGESCON_CTI_MIRROR_BASE_URL', ''),
|
||||||
|
'mirror_timeout_seconds' => (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2),
|
||||||
|
];
|
||||||
|
|
@ -2,27 +2,27 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Base URL del server che distribuisce i pacchetti di aggiornamento.
|
// 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).
|
// 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.
|
// 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.
|
// Credenziali lato client per canale licensed.
|
||||||
'licensed_code' => env('NETGESCON_LICENSED_CODE', ''),
|
'licensed_code' => env('NETGESCON_LICENSED_CODE', ''),
|
||||||
'licensed_auth' => env('NETGESCON_LICENSED_AUTH', ''),
|
'licensed_auth' => env('NETGESCON_LICENSED_AUTH', ''),
|
||||||
|
|
||||||
// Credenziali lato server: lista "code:auth" separata da virgola.
|
// Credenziali lato server: lista "code:auth" separata da virgola.
|
||||||
// Esempio: NODEA:abc123,NODEB:def456
|
// 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.
|
// 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.
|
// 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.
|
// Backup pre-update: abilita upload su Google Drive solo se richiesto dal nodo.
|
||||||
'preupdate_drive_enabled' => (bool) env('NETGESCON_PREUPDATE_DRIVE_ENABLED', false),
|
'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).
|
// Override opzionale DNS per richieste update (formato CSV host:port:ip).
|
||||||
// Esempio: updates.netgescon.it:443:192.168.0.53
|
// Esempio: updates.netgescon.it:443:192.168.0.53
|
||||||
'http_resolve' => env('NETGESCON_UPDATE_RESOLVE', ''),
|
'http_resolve' => env('NETGESCON_UPDATE_RESOLVE', ''),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration {
|
return new class extends Migration
|
||||||
|
{
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
if (Schema::hasTable('stabile_amministratore_transfers')) {
|
if (Schema::hasTable('stabile_amministratore_transfers')) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('documenti', function (Blueprint $table): void {
|
||||||
|
if (! Schema::hasColumn('documenti', 'dimensione_file')) {
|
||||||
|
$table->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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).
|
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**
|
## 🏗️ **ARCHITETTURA GIT DISTRIBUITA**
|
||||||
|
|
@ -29,9 +44,11 @@ ### 🎯 **FILOSOFIA SISTEMA**
|
||||||
- **Plugin system** per estensibilità
|
- **Plugin system** per estensibilità
|
||||||
- **Licenza A-GPL** per protezione IP
|
- **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**
|
### 🌐 **STRUTTURA GIT MULTI-LIVELLO**
|
||||||
|
|
||||||
```
|
```text
|
||||||
🏢 NETGESCON GIT ECOSYSTEM
|
🏢 NETGESCON GIT ECOSYSTEM
|
||||||
├── 🔒 Git Server Interno (MASTER)
|
├── 🔒 Git Server Interno (MASTER)
|
||||||
│ ├── netgescon-core.git # Core applicazione
|
│ ├── netgescon-core.git # Core applicazione
|
||||||
|
|
@ -54,20 +71,18 @@ ### 🌐 **STRUTTURA GIT MULTI-LIVELLO**
|
||||||
|
|
||||||
## ✅ **STATUS IMPLEMENTAZIONE**
|
## ✅ **STATUS IMPLEMENTAZIONE**
|
||||||
|
|
||||||
### 🎯 **COMPLETATO**
|
### 🎯 **COMPLETATO / VERIFICATO OGGI**
|
||||||
|
|
||||||
- ✅ Repository Git inizializzato con commit iniziale
|
- ✅ Repository attivo individuato: `netgescon-day0`
|
||||||
- ✅ Struttura branches creata: master, development, release, hotfix
|
- ✅ Remote Gitea verificato su `origin`
|
||||||
- ✅ Scripts di automazione Git workflow
|
- ✅ Branch attivo verificato: `main`
|
||||||
- ✅ Sistema di packaging e distribuzione
|
- ✅ Flusso reale confermato: commit e push su Gitea prima di aggiornare staging
|
||||||
- ✅ Licenza A-GPL definita
|
|
||||||
- ✅ Plugin system progettato
|
|
||||||
|
|
||||||
### 🔄 **IN CORSO**
|
### 🔄 **IN CORSO**
|
||||||
|
|
||||||
- 🚧 Setup Git server (Gitea) su macchina master
|
- 🚧 Normalizzazione della documentazione al flusso Git/Gitea reale
|
||||||
- 🚧 Configurazione domini: git.netgescon.it
|
- 🚧 Formalizzazione del meccanismo di allineamento staging dal repository Gitea
|
||||||
- 🚧 Sistema distribuzione pacchetti
|
- 🚧 Scelta definitiva del modello di canali distribuzione/licensing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -114,7 +129,7 @@ ### 🗂️ **Repository Structure**
|
||||||
|
|
||||||
#### 📦 **netgescon-core.git**
|
#### 📦 **netgescon-core.git**
|
||||||
|
|
||||||
```
|
```text
|
||||||
netgescon-core/
|
netgescon-core/
|
||||||
├── app/ # Laravel application
|
├── app/ # Laravel application
|
||||||
├── database/ # Migrations & seeds
|
├── database/ # Migrations & seeds
|
||||||
|
|
@ -129,7 +144,7 @@ #### 📦 **netgescon-core.git**
|
||||||
|
|
||||||
#### 🔌 **netgescon-plugins.git**
|
#### 🔌 **netgescon-plugins.git**
|
||||||
|
|
||||||
```
|
```text
|
||||||
netgescon-plugins/
|
netgescon-plugins/
|
||||||
├── core-plugins/ # Plugin essenziali
|
├── core-plugins/ # Plugin essenziali
|
||||||
│ ├── fatturazione-enhanced/
|
│ ├── fatturazione-enhanced/
|
||||||
|
|
@ -143,7 +158,7 @@ #### 🔌 **netgescon-plugins.git**
|
||||||
|
|
||||||
#### 🎨 **netgescon-themes.git**
|
#### 🎨 **netgescon-themes.git**
|
||||||
|
|
||||||
```
|
```text
|
||||||
netgescon-themes/
|
netgescon-themes/
|
||||||
├── default/ # Tema di default
|
├── default/ # Tema di default
|
||||||
├── modern-ui/ # Tema moderno
|
├── modern-ui/ # Tema moderno
|
||||||
|
|
@ -159,6 +174,18 @@ ## 📋 **WORKFLOW DI SVILUPPO**
|
||||||
|
|
||||||
### 🔄 **Processo di Sviluppo Interno**
|
### 🔄 **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)**
|
#### 1️⃣ **Sviluppo Locale (Michele + AI)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -205,7 +232,7 @@ # Trigger deploy automatico
|
||||||
|
|
||||||
### 🚀 **Branching Strategy**
|
### 🚀 **Branching Strategy**
|
||||||
|
|
||||||
```
|
```text
|
||||||
main # Produzione stabile
|
main # Produzione stabile
|
||||||
├── develop # Sviluppo attivo
|
├── develop # Sviluppo attivo
|
||||||
├── feature/* # Nuove funzionalità
|
├── feature/* # Nuove funzionalità
|
||||||
|
|
@ -213,6 +240,8 @@ ### 🚀 **Branching Strategy**
|
||||||
└── release/* # Preparazione release
|
└── 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**
|
## 📦 **SISTEMA DISTRIBUZIONE AUTOMATICA**
|
||||||
|
|
@ -410,7 +439,7 @@ #### 📋 **Implementazione Licenza**
|
||||||
|
|
||||||
#### 💼 **Modello Business A-GPL**
|
#### 💼 **Modello Business A-GPL**
|
||||||
|
|
||||||
```
|
```text
|
||||||
🆓 COMMUNITY EDITION (A-GPL)
|
🆓 COMMUNITY EDITION (A-GPL)
|
||||||
├── ✅ Core completo
|
├── ✅ Core completo
|
||||||
├── ✅ Plugin base
|
├── ✅ Plugin base
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,87 @@
|
||||||
# Distribuzione e aggiornamenti (Open Source)
|
# Distribuzione e aggiornamenti
|
||||||
|
|
||||||
## Obiettivo
|
## Obiettivo
|
||||||
Linee guida per pubblicare NetGescon e distribuire aggiornamenti, con focus su Docker.
|
|
||||||
|
|
||||||
## Specifiche demo online
|
Allineare NetGescon a un flusso di distribuzione reale, verificabile e ripetibile.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Setup online (end-to-end)
|
Stato operativo attuale:
|
||||||
- Guida completa: `docs/000-FILAMENT/10-ONLINE-SETUP.md`
|
|
||||||
- Compose online (Traefik + app): `docker/docker-compose.online.yml`
|
|
||||||
- Env online: `.env.online.example`
|
|
||||||
|
|
||||||
## Opzione consigliata: Docker + Versioning
|
- sviluppo nel repository `netgescon-day0`
|
||||||
### Struttura immagini
|
- versionamento e condivisione su Gitea interno
|
||||||
- `netgescon/app` → PHP + Laravel + Filament
|
- staging e nodi aggiornati partendo da Gitea
|
||||||
- `netgescon/nginx` → Nginx
|
- Docker, APT, immagini pubbliche e canali commerciali restano scenari possibili ma non il percorso principale di oggi
|
||||||
- `netgescon/mysql` → DB (opzionale per demo)
|
|
||||||
|
|
||||||
### Versionamento
|
## Flusso reale da usare oggi
|
||||||
- Tag immagine: `vX.Y.Z`
|
|
||||||
- `latest` solo per preview
|
|
||||||
- Release notes su GitHub
|
|
||||||
|
|
||||||
### Aggiornamento
|
1. sviluppo e test nel workspace locale `netgescon-day0`
|
||||||
- Pull nuova immagine
|
2. commit Git locali
|
||||||
- `php artisan migrate --force`
|
3. push verso `origin` su Gitea interno
|
||||||
- `php artisan optimize:clear`
|
4. aggiornamento di staging allineando il repository pubblicato su Gitea
|
||||||
|
5. aggiornamento degli altri siti con lo stesso meccanismo
|
||||||
|
|
||||||
## Docker (distribuzione pronta)
|
Regola chiave:
|
||||||
- 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`
|
|
||||||
|
|
||||||
### Installazione base
|
- evitare come flusso ordinario il deploy manuale diretto di file o patch dalla macchina di sviluppo verso staging
|
||||||
1. Configura file `.env` per DB e APP_URL.
|
- la macchina di staging deve essere riallineata dal repository Gitea
|
||||||
2. Avvia:
|
|
||||||
- `docker compose -f docker/docker-compose.prod.yml up -d`
|
|
||||||
|
|
||||||
### DNS (esempio)
|
## Stato canali distribuzione
|
||||||
- `A` record → IP del server
|
|
||||||
- `CNAME` (opzionale) → `demo` → root
|
|
||||||
|
|
||||||
### TLS (Nginx esterno)
|
- infrastruttura `free` / `licensed` gia prevista nel codice
|
||||||
- Termina TLS su reverse proxy (Nginx/Traefik) davanti al container
|
- uso reale attuale: una sola linea di distribuzione
|
||||||
- Forward verso `http://127.0.0.1:8080`
|
- il nome del canale tecnico presente nelle config non equivale ancora a un modello commerciale consolidato
|
||||||
|
|
||||||
### Demo online
|
## Gitea interno
|
||||||
- Avvio demo: `scripts/deploy-demo.sh`
|
|
||||||
- Compose demo: `docker/docker-compose.demo.yml`
|
|
||||||
|
|
||||||
### Aggiornamento autonomo
|
- remote principale attuale: `ssh://git@192.168.0.53:2222/michele/netgescon-day0.git`
|
||||||
- Script: `scripts/update-docker.sh`
|
- branch operativo corrente: `main`
|
||||||
- Azioni: pull immagine → restart → migrate → clear cache
|
- Gitea e il punto di passaggio obbligato per distribuire il codice sviluppato
|
||||||
|
|
||||||
## Database e dati
|
## Procedura minima di aggiornamento
|
||||||
- Volume persistente per DB
|
|
||||||
- Volume persistente per storage
|
|
||||||
- Backup obbligatorio prima di update
|
|
||||||
|
|
||||||
## Strategie aggiornamento
|
1. verificare modifiche e contesto con `git status`
|
||||||
1) **Rolling update** (prod)
|
2. creare commit coerenti
|
||||||
2) **Blue/Green** (consigliata per installazioni grandi)
|
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
|
## Cosa non considerare flusso principale
|
||||||
- Non includere in immagini
|
|
||||||
- Montare `/mnt/gescon-archives/gescon` come volume read-only
|
|
||||||
|
|
||||||
## Checklist update
|
- pubblicazione GitHub pubblica
|
||||||
- backup DB
|
- distribuzione Docker come percorso standard di tutti i siti
|
||||||
- backup storage
|
- repository multipli separati per core/plugin/theme come assetto gia in esercizio
|
||||||
- pull immagine
|
- differenziazione commerciale gia attiva tra `free` e `licensed`
|
||||||
- migrate
|
|
||||||
- clear cache
|
|
||||||
- health check
|
|
||||||
|
|
||||||
## Backup pre-update applicativo
|
## Scenari futuri, non prioritari
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Note d'aggiornamento
|
- server update dedicato
|
||||||
- File: `CHANGELOG.md`
|
- pacchettizzazione release
|
||||||
- Versione: `VERSION`
|
- canali distinti di licensing
|
||||||
|
- distribuzione containerizzata piu automatica
|
||||||
|
|
||||||
## Materiale Open Source minimo
|
Questi scenari possono restare documentati, ma devono essere marcati come successivi rispetto al flusso Git/Gitea attuale.
|
||||||
- `README.md` con setup e run
|
|
||||||
- `CONTRIBUTING.md` con workflow PR e standard
|
|
||||||
- `CHANGELOG.md`
|
|
||||||
- `LICENSE`
|
|
||||||
- Documentazione in `docs/` aggiornata
|
|
||||||
|
|
||||||
## Note sicurezza
|
## Checklist pratica attuale
|
||||||
- Non pubblicare credenziali
|
|
||||||
- Configurare `.env` esterno
|
- repository corretto: `netgescon-day0`
|
||||||
- Permessi minimi sui volumi
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,102 @@
|
||||||
# NetGescon - Macchina Git e Distribuzione (Blueprint 2026)
|
# 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,
|
## 1. Stato reale attuale
|
||||||
- distribuzione aggiornamenti,
|
|
||||||
- accesso selettivo con scadenza,
|
|
||||||
- moduli premium con pagamento.
|
|
||||||
|
|
||||||
## 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.
|
Per il flusso operativo quotidiano fare riferimento prima alla documentazione Day0/Gitea e ai runbook di distribuzione, non a questo blueprint.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Perche Forgejo/Gitea
|
## 2. Obiettivo del blueprint
|
||||||
|
|
||||||
1. Leggero rispetto a GitLab e facile da mantenere.
|
L obiettivo 2026 resta costruire una piattaforma unica per piu progetti con:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
## 3. Stack consigliato
|
||||||
- `updates.netgescon.it`: API update/licensing (manifest/package/license).
|
|
||||||
- `storage.netgescon.it`: MinIO S3 (privato, firmato).
|
|
||||||
- `registry.netgescon.it` opzionale: container registry.
|
|
||||||
|
|
||||||
## 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.
|
## 4. Architettura target
|
||||||
- `licensed`: accesso con `code+auth` e controlli avanzati.
|
|
||||||
|
|
||||||
Controlli raccomandati:
|
Separazione logica consigliata:
|
||||||
|
|
||||||
- scadenza licenza (`expires_at`),
|
- `git.netgescon.it`: repository, review, release, chiavi SSH;
|
||||||
- limite nodi (`max_nodes`),
|
- `updates.netgescon.it`: manifest, package, audit applicazione update;
|
||||||
- modulo abilitato (`modules[]`),
|
- `storage.netgescon.it`: bucket privati per pacchetti e snapshot pubblicabili;
|
||||||
- versione minima consentita (`min_supported`),
|
- `registry.netgescon.it`: opzionale, solo se entrano in gioco immagini container.
|
||||||
- revoca immediata (`revoked_at`).
|
|
||||||
|
|
||||||
## 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`
|
I canali oggi esistono nel codice per compatibilita tecnica:
|
||||||
- `licenses`
|
|
||||||
- `license_modules`
|
|
||||||
- `payments`
|
|
||||||
- `entitlements_audit`
|
|
||||||
|
|
||||||
Flusso:
|
- `free`: percorso standard attuale;
|
||||||
|
- `licensed`: percorso riservato per futuri controlli piu stretti.
|
||||||
|
|
||||||
1. Cliente acquista piano base o modulo.
|
Se e quando il modello verra attivato davvero, i controlli consigliati sono:
|
||||||
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.
|
|
||||||
|
|
||||||
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).
|
Fino a quel momento il canale `free` non deve essere comunicato come offerta commerciale finale.
|
||||||
- Paddle se vuoi gestione VAT UE piu assistita.
|
|
||||||
|
|
||||||
## Sicurezza minima obbligatoria
|
## 6. Governance minima da mantenere gia ora
|
||||||
|
|
||||||
1. 2FA obbligatoria per admin Forgejo.
|
Anche prima della piattaforma completa, conviene mantenere da subito queste regole:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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`
|
Passi coerenti con il codice gia presente:
|
||||||
- `scripts/ops/git-stack/.env.example`
|
|
||||||
|
|
||||||
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
|
## 8. Tema commerciale
|
||||||
cd scripts/ops/git-stack
|
|
||||||
cp .env.example .env
|
|
||||||
# modifica password, domini e secret
|
|
||||||
|
|
||||||
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.
|
- `customers`;
|
||||||
2. Crea org/progetti NetGescon e branch policy.
|
- `licenses`;
|
||||||
3. Attiva API update/licensing su `updates.netgescon.it`.
|
- `license_modules`;
|
||||||
4. Pubblica primo pacchetto baseline e manifest firmato.
|
- `payments`;
|
||||||
5. Aggiorna staging con `php artisan netgescon:update --apply`.
|
- `entitlements_audit`.
|
||||||
6. Integra billing moduli premium (Stripe webhook).
|
|
||||||
7. Estendi stessa piattaforma agli altri progetti.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ ## 1) Endpoint NetGescon da usare
|
||||||
- `POST /api/v1/cti/panasonic/incoming`
|
- `POST /api/v1/cti/panasonic/incoming`
|
||||||
- `POST /api/v1/cti/panasonic/call-ended`
|
- `POST /api/v1/cti/panasonic/call-ended`
|
||||||
- `GET /api/v1/cti/panasonic/lookup?phone=...`
|
- `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:
|
Esempio base:
|
||||||
|
|
||||||
|
|
@ -25,6 +28,13 @@ ## 3) Porta 33333 / servizi monitoraggio
|
||||||
- Se usata, configurare IP sorgente autorizzati e ACL precise (mai aperta pubblicamente).
|
- 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.
|
- 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)
|
## 4) Syslog NS1000 (consigliato)
|
||||||
|
|
||||||
- Abilitare invio syslog remoto dal PBX verso un collector interno.
|
- 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`.
|
3. Chiudi la chiamata e verifica aggiornamento su endpoint `call-ended`.
|
||||||
4. Controlla log applicativi e, se attivo, log syslog del PBX.
|
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
|
## 7) Opzione interno VOIP monitor
|
||||||
|
|
||||||
- Si puo configurare un interno tecnico di monitoraggio per ascolto/diagnostica, rispettando policy privacy.
|
- Si puo configurare un interno tecnico di monitoraggio per ascolto/diagnostica, rispettando policy privacy.
|
||||||
|
|
|
||||||
541
docs/CTI-WINDOWS-TAPI-PROBE.md
Normal file
541
docs/CTI-WINDOWS-TAPI-PROBE.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -9,10 +9,37 @@ ## Nota Operativa Day0
|
||||||
- Workspace attivo: `/home/michele/netgescon/netgescon-day0`
|
- Workspace attivo: `/home/michele/netgescon/netgescon-day0`
|
||||||
- Documentazione autorevole: `/home/michele/netgescon/netgescon-day0/docs`
|
- Documentazione autorevole: `/home/michele/netgescon/netgescon-day0/docs`
|
||||||
- Repository Git da usare: `netgescon-day0`
|
- 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.
|
- 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.
|
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
|
## 📎 Descrizione
|
||||||
|
|
||||||
NetGescon è una piattaforma web sviluppata in **Laravel** per la gestione completa di condomini e amministrazioni condominiali. Il sistema offre funzionalità avanzate per:
|
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
|
tail -f storage/logs/laravel.log
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 **Contributi**
|
## Git e collaborazione
|
||||||
|
|
||||||
Per contribuire al progetto:
|
Flusso minimo consigliato:
|
||||||
|
|
||||||
1. Fork del repository
|
1. aggiornare il workspace attivo `netgescon-day0`
|
||||||
2. Creazione branch feature (`git checkout -b feature/nome-feature`)
|
2. verificare stato con `git status`
|
||||||
3. Commit modifiche (`git commit -am 'Aggiunta nuova feature'`)
|
3. creare commit coerenti e leggibili
|
||||||
4. Push del branch (`git push origin feature/nome-feature`)
|
4. fare push su `origin`
|
||||||
5. Creazione Pull Request
|
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
|
- documentazione operativa: `docs/`
|
||||||
- **Email:** <info@netgescon.it>
|
- repository autorevole: Gitea interno
|
||||||
- **Documentazione:** Disponibile nel repository
|
- issue tracking e flusso di aggiornamento: da riallineare al sistema Gitea interno
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,18 @@ # NetGescon - Runbook Distribuzione Remota
|
||||||
|
|
||||||
Questo runbook e pensato per essere eseguito sulla macchina remota dove stai preparando l'ambiente.
|
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)
|
## Stato Aggiornato (10-03-2026)
|
||||||
|
|
||||||
Modifiche gia implementate nel progetto per distribuzione/update:
|
Modifiche gia implementate nel progetto per distribuzione/update:
|
||||||
|
|
@ -53,6 +65,11 @@ ## 1. Obiettivo
|
||||||
3. Aggiornamento automatico nodi (staging e poi altri nodi).
|
3. Aggiornamento automatico nodi (staging e poi altri nodi).
|
||||||
4. Base pronta per licensing con scadenza e moduli premium.
|
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
|
## 2. Architettura consigliata
|
||||||
|
|
||||||
Domini consigliati:
|
Domini consigliati:
|
||||||
|
|
@ -171,6 +188,13 @@ # Esempio
|
||||||
|
|
||||||
## 7. Setup update API su remoto
|
## 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:
|
Nel progetto Laravel (istanza update server) devi avere:
|
||||||
|
|
||||||
- endpoint manifest/package
|
- endpoint manifest/package
|
||||||
|
|
@ -184,6 +208,8 @@ ## 7. Setup update API su remoto
|
||||||
NETGESCON_LICENSED_PAIRS="CLIENTA:tokenA,CLIENTB:tokenB"
|
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:
|
Poi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -233,7 +259,26 @@ ### 8.2 Pubblica manifest
|
||||||
--base-url https://updates.netgescon.it
|
--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`:
|
Sul nodo staging configura `.env`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Prerequisiti verificati
|
||||||
|
|
||||||
- Connessione TCP PBX CSTA: `192.168.0.101:33333` raggiungibile.
|
- Connessione TCP PBX CSTA: `192.168.0.101:33333` raggiungibile.
|
||||||
|
|
@ -13,6 +19,13 @@ ## 1) Configurazione applicazione NetGescon
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NETGESCON_CTI_SHARED_TOKEN=<TOKEN_LUNGO_RANDOM>
|
NETGESCON_CTI_SHARED_TOKEN=<TOKEN_LUNGO_RANDOM>
|
||||||
|
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:
|
Poi:
|
||||||
|
|
@ -36,11 +49,19 @@ ## 2) Endpoint disponibili
|
||||||
- `POST /api/v1/cti/panasonic/incoming`
|
- `POST /api/v1/cti/panasonic/incoming`
|
||||||
- crea Post-it chiamata in `chiamate_post_it`
|
- crea Post-it chiamata in `chiamate_post_it`
|
||||||
- fa match automatico numero -> `rubrica_universale`
|
- 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`
|
- `POST /api/v1/cti/panasonic/call-ended`
|
||||||
- chiude automaticamente il Post-it aperto (match su `event_id`, fallback su numero)
|
- chiude automaticamente il Post-it aperto (match su `event_id`, fallback su numero)
|
||||||
- salva `esito`, `durata_secondi`, `chiusa_il`
|
- salva `esito`, `durata_secondi`, `chiusa_il`
|
||||||
|
- chiude anche il record `communication_messages` CSTA corrispondente
|
||||||
- `GET /api/v1/cti/panasonic/lookup?phone=...`
|
- `GET /api/v1/cti/panasonic/lookup?phone=...`
|
||||||
- solo lookup rubrica (match yes/no)
|
- 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: <TOKEN>`.
|
Autenticazione: header `X-CTI-Token: <TOKEN>`.
|
||||||
|
|
||||||
|
|
@ -93,24 +114,46 @@ ## 5) Comando minimo per sniffing CSTA grezzo (porta 33333)
|
||||||
sudo tcpdump -i any -nn -s0 -A port 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
|
```bash
|
||||||
sudo tcpdump -i any -nn "tcp port 33333"
|
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
|
```bash
|
||||||
sudo tcpdump -i any -nn -s0 -A host 192.168.0.101 and \(port 33333 or port 33334 or port 33335\)
|
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
|
```bash
|
||||||
sudo ss -lntp | grep -E ':33333|:8099|:80'
|
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)
|
## 6) Listener terminale con script (log su file)
|
||||||
|
|
||||||
Metti questo listener in ascolto e punta il PBX a `http://<IP_SERVER_200>:8099` per vedere esattamente i dati in arrivo.
|
Metti questo listener in ascolto e punta il PBX a `http://<IP_SERVER_200>:8099` per vedere esattamente i dati in arrivo.
|
||||||
|
|
@ -140,13 +183,13 @@ ## 8) Comandi singoli per simulare chiamate da terminale
|
||||||
Evento incoming (una riga):
|
Evento incoming (una riga):
|
||||||
|
|
||||||
```bash
|
```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: <TOKEN_LUNGO_RANDOM>" -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: <TOKEN_LUNGO_RANDOM>" -d '{"phone":"+393331112233","direction":"inbound","event_type":"Delivered","called_extension":"101","event_id":"evt-001","note":"test incoming"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Evento call-ended (una riga):
|
Evento call-ended (una riga):
|
||||||
|
|
||||||
```bash
|
```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: <TOKEN_LUNGO_RANDOM>" -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: <TOKEN_LUNGO_RANDOM>" -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):
|
Lookup rubrica (una riga):
|
||||||
|
|
@ -188,6 +231,8 @@ ## 9) Esempio comandi da usare lato interfaccia centralino
|
||||||
-H "X-CTI-Token: <TOKEN_LUNGO_RANDOM>" \
|
-H "X-CTI-Token: <TOKEN_LUNGO_RANDOM>" \
|
||||||
-d '{
|
-d '{
|
||||||
"phone": "<CALLING_NUMBER>",
|
"phone": "<CALLING_NUMBER>",
|
||||||
|
"direction": "inbound",
|
||||||
|
"event_type": "ConnectionCleared",
|
||||||
"event_id": "<PBX_EVENT_ID>",
|
"event_id": "<PBX_EVENT_ID>",
|
||||||
"outcome": "risposta",
|
"outcome": "risposta",
|
||||||
"duration_seconds": 83,
|
"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
|
## 10) Cosa fa il match numeri
|
||||||
|
|
||||||
- normalizza il numero (es. `+39`, `0039`, spazi, trattini)
|
- normalizza il numero (es. `+39`, `0039`, spazi, trattini)
|
||||||
|
|
@ -231,3 +302,34 @@ ## 12) Step successivo consigliato
|
||||||
1. Agganciare evento reale CSTA incoming.
|
1. Agganciare evento reale CSTA incoming.
|
||||||
2. Agganciare evento call-ended per aggiornare `esito` e durata.
|
2. Agganciare evento call-ended per aggiornare `esito` e durata.
|
||||||
3. Mostrare popup operatore in Filament con scheda rubrica trovata.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
## 1. Flusso attivo oggi
|
||||||
- pubblicare aggiornamenti da una macchina di distribuzione,
|
|
||||||
- applicare update automatici sui nodi (`free` e `licensed`).
|
|
||||||
|
|
||||||
## 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
|
||||||
bash scripts/ops/netgescon-baseline-freeze.sh \
|
bash scripts/ops/netgescon-baseline-freeze.sh \
|
||||||
|
|
@ -16,100 +41,120 @@ ## 1. Punto zero (baseline stabile)
|
||||||
--version 0.8.0-baseline
|
--version 0.8.0-baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
Risultato:
|
Produce:
|
||||||
|
|
||||||
- snapshot codice (`code.tar.gz`),
|
- archivio codice;
|
||||||
- backup `.env`,
|
- copia `.env`;
|
||||||
- dump DB best-effort,
|
- dump database best-effort;
|
||||||
- file metadati baseline.
|
- metadati baseline.
|
||||||
|
|
||||||
## 2. Macchina di distribuzione update
|
Per il backup pre-update applicativo usare invece:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/ops/netgescon-publish-manifest.sh \
|
php artisan netgescon:preupdate-backup --differential --tag=manuale
|
||||||
--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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Per licensed:
|
Con copia anche su Google Drive:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/ops/netgescon-publish-manifest.sh \
|
php artisan netgescon:preupdate-backup \
|
||||||
--channel licensed \
|
--differential \
|
||||||
--version 0.8.1 \
|
--tag=manuale \
|
||||||
--package /var/www/netgescon/storage/app/distribution/licensed/netgescon-0.8.1.zip \
|
--drive \
|
||||||
--base-url https://updates.netgescon.it
|
--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
|
```env
|
||||||
NETGESCON_UPDATE_URL=https://updates.netgescon.it
|
NETGESCON_UPDATE_URL=https://updates.netgescon.it
|
||||||
NETGESCON_UPDATE_CHANNEL=free
|
NETGESCON_UPDATE_CHANNEL=free
|
||||||
NETGESCON_NODE_CODE=staging-01
|
NETGESCON_NODE_CODE=staging-01
|
||||||
NETGESCON_LICENSED_CODE=
|
NETGESCON_PREUPDATE_DRIVE_ENABLED=true
|
||||||
NETGESCON_LICENSED_AUTH=
|
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
|
```bash
|
||||||
php artisan netgescon:update --channel=free
|
php artisan netgescon:update --channel=free
|
||||||
```
|
|
||||||
|
|
||||||
### Applicazione update
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan netgescon:update --channel=free --apply
|
php artisan netgescon:update --channel=free --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
### Apply con licensed
|
La modalita `licensed` rimane disponibile solo come compatibilita tecnica o scenario futuro:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan netgescon:update --channel=licensed --apply --code=CLIENTA --auth=tokenA
|
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,
|
- hash SHA256 del pacchetto verificato prima dell apply;
|
||||||
- backup `.env` prima dell'applicazione,
|
- backup `.env` locale durante l update;
|
||||||
- esclusione `storage/` e `.env` dalla sovrascrittura,
|
- snapshot pre-update con manifest file e indice record;
|
||||||
- `php artisan optimize:clear` post update,
|
- esclusione dei percorsi sensibili dalla sovrascrittura;
|
||||||
- migrate automatico se richiesto da manifest.
|
- `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.
|
I passaggi sotto restano obiettivi successivi, non prerequisiti del flusso attuale:
|
||||||
2. Endpoint audit update (chi ha scaricato/applicato e quando).
|
|
||||||
3. UI admin Filament per vedere canale/versione e lanciare update.
|
1. firma manifest con chiave privata e verifica lato nodo;
|
||||||
4. Rollback automatico completo (codice + DB) con policy di retention.
|
2. audit centralizzato di download e apply;
|
||||||
|
3. packaging/release automatizzati da pipeline Gitea;
|
||||||
|
4. rollback completo codice piu database con retention formalizzata.
|
||||||
|
|
|
||||||
|
|
@ -44,21 +44,67 @@
|
||||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="mb-1 block font-medium">Broker / referente polizza</span>
|
<span class="mb-1 block font-medium">Broker / referente polizza</span>
|
||||||
<select wire:model.defer="insurancePolicyForm.broker_rubrica_id" class="w-full rounded-lg border-gray-300">
|
<input type="text" wire:model.live.debounce.250ms="insuranceBrokerSearch" class="w-full rounded-lg border-gray-300" placeholder="Cerca broker, referente, email, telefono" />
|
||||||
<option value="">Seleziona contatto</option>
|
<div class="mt-1 text-xs text-gray-500">Ricerca libera su anagrafica unica, con deduplica dei nominativi ripetuti.</div>
|
||||||
@foreach($this->insuranceRubricaOptions as $id => $label)
|
@if((int) ($insurancePolicyForm['broker_rubrica_id'] ?? 0) > 0)
|
||||||
<option value="{{ $id }}">{{ $label }}</option>
|
<div class="mt-2 rounded-lg border border-emerald-200 bg-emerald-50 p-2 text-xs text-emerald-800">
|
||||||
@endforeach
|
Broker selezionato: <span class="font-semibold">{{ $insuranceBrokerSearch }}</span>
|
||||||
</select>
|
<button type="button" wire:click="resetInsuranceBrokerSelection" class="ml-2 inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-700 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Cambia</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(count($insuranceBrokerMatches) > 0)
|
||||||
|
<div class="mt-2 space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-2">
|
||||||
|
@foreach($insuranceBrokerMatches as $match)
|
||||||
|
<button type="button" wire:click="selectInsuranceBroker({{ (int) $match['id'] }})" class="w-full rounded-lg border bg-white p-3 text-left hover:bg-slate-50">
|
||||||
|
<div class="text-sm font-medium">{{ $match['label'] }}</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
{{ $match['phone'] ?: 'Telefono non valorizzato' }}
|
||||||
|
@if($match['email'])
|
||||||
|
· {{ $match['email'] }}
|
||||||
|
@endif
|
||||||
|
@if($match['category'])
|
||||||
|
· {{ ucfirst($match['category']) }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($match['note'])
|
||||||
|
<div class="mt-1 text-xs text-gray-500">{{ $match['note'] }}</div>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="mb-1 block font-medium">Compagnia assicurativa</span>
|
<span class="mb-1 block font-medium">Compagnia assicurativa</span>
|
||||||
<select wire:model.defer="insurancePolicyForm.company_rubrica_id" class="w-full rounded-lg border-gray-300">
|
<input type="text" wire:model.live.debounce.250ms="insuranceCompanySearch" class="w-full rounded-lg border-gray-300" placeholder="Cerca compagnia, es. Generali" />
|
||||||
<option value="">Seleziona compagnia</option>
|
<div class="mt-1 text-xs text-gray-500">Funziona anche senza conoscere il singolo referente.</div>
|
||||||
@foreach($this->insuranceRubricaOptions as $id => $label)
|
@if((int) ($insurancePolicyForm['company_rubrica_id'] ?? 0) > 0)
|
||||||
<option value="{{ $id }}">{{ $label }}</option>
|
<div class="mt-2 rounded-lg border border-emerald-200 bg-emerald-50 p-2 text-xs text-emerald-800">
|
||||||
@endforeach
|
Compagnia selezionata: <span class="font-semibold">{{ $insuranceCompanySearch }}</span>
|
||||||
</select>
|
<button type="button" wire:click="resetInsuranceCompanySelection" class="ml-2 inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-700 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Cambia</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(count($insuranceCompanyMatches) > 0)
|
||||||
|
<div class="mt-2 space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-2">
|
||||||
|
@foreach($insuranceCompanyMatches as $match)
|
||||||
|
<button type="button" wire:click="selectInsuranceCompany({{ (int) $match['id'] }})" class="w-full rounded-lg border bg-white p-3 text-left hover:bg-slate-50">
|
||||||
|
<div class="text-sm font-medium">{{ $match['label'] }}</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
{{ $match['phone'] ?: 'Telefono non valorizzato' }}
|
||||||
|
@if($match['email'])
|
||||||
|
· {{ $match['email'] }}
|
||||||
|
@endif
|
||||||
|
@if($match['category'])
|
||||||
|
· {{ ucfirst($match['category']) }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($match['note'])
|
||||||
|
<div class="mt-1 text-xs text-gray-500">{{ $match['note'] }}</div>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="mb-1 block font-medium">Nome polizza</span>
|
<span class="mb-1 block font-medium">Nome polizza</span>
|
||||||
|
|
@ -101,10 +147,38 @@
|
||||||
<span class="mb-1 block font-medium">Come viene diviso il pagamento</span>
|
<span class="mb-1 block font-medium">Come viene diviso il pagamento</span>
|
||||||
<input type="text" wire:model.defer="insurancePolicyForm.payment_split" class="w-full rounded-lg border-gray-300" placeholder="Annuale, semestrale, rateizzato" />
|
<input type="text" wire:model.defer="insurancePolicyForm.payment_split" class="w-full rounded-lg border-gray-300" placeholder="Annuale, semestrale, rateizzato" />
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm md:col-span-2">
|
<div class="block text-sm md:col-span-2">
|
||||||
<span class="mb-1 block font-medium">Note pagamenti / quietanze</span>
|
<span class="mb-1 block font-medium">Note pagamenti / quietanze</span>
|
||||||
<textarea wire:model.defer="insurancePolicyForm.payment_schedule_notes" rows="3" class="w-full rounded-lg border-gray-300" placeholder="Quietanze pagate, riferimenti contabili, scadenze rate"></textarea>
|
<div class="grid gap-3">
|
||||||
</label>
|
@foreach($insurancePaymentRows as $index => $row)
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">Riga contabile {{ $index + 1 }}</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-4">
|
||||||
|
<label class="block text-sm md:col-span-2">
|
||||||
|
<span class="mb-1 block text-xs font-medium">Descrizione</span>
|
||||||
|
<input type="text" wire:model.defer="insurancePaymentRows.{{ $index }}.label" class="w-full rounded-lg border-gray-300" placeholder="Premio, quietanza, appendice, conguaglio" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block text-xs font-medium">Scadenza</span>
|
||||||
|
<input type="date" wire:model.defer="insurancePaymentRows.{{ $index }}.due_date" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block text-xs font-medium">Pagata il</span>
|
||||||
|
<input type="date" wire:model.defer="insurancePaymentRows.{{ $index }}.paid_at" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block text-xs font-medium">Importo</span>
|
||||||
|
<input type="number" step="0.01" wire:model.defer="insurancePaymentRows.{{ $index }}.amount" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm md:col-span-3">
|
||||||
|
<span class="mb-1 block text-xs font-medium">Note riga</span>
|
||||||
|
<input type="text" wire:model.defer="insurancePaymentRows.{{ $index }}.notes" class="w-full rounded-lg border-gray-300" placeholder="Quietanza, CRO, riferimento contabile, note operative" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="block text-sm md:col-span-2">
|
<label class="block text-sm md:col-span-2">
|
||||||
<span class="mb-1 block font-medium">Garanzie principali</span>
|
<span class="mb-1 block font-medium">Garanzie principali</span>
|
||||||
<textarea wire:model.defer="insurancePolicyForm.coverage_summary" rows="4" class="w-full rounded-lg border-gray-300" placeholder="Riassunto garanzie, massimali, franchigie"></textarea>
|
<textarea wire:model.defer="insurancePolicyForm.coverage_summary" rows="4" class="w-full rounded-lg border-gray-300" placeholder="Riassunto garanzie, massimali, franchigie"></textarea>
|
||||||
|
|
@ -136,7 +210,10 @@
|
||||||
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-700">
|
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-700">
|
||||||
<div class="font-medium">Documento polizza</div>
|
<div class="font-medium">Documento polizza</div>
|
||||||
@if($this->getInsuranceDocumentUrl($selectedPolicy))
|
@if($this->getInsuranceDocumentUrl($selectedPolicy))
|
||||||
<a href="{{ $this->getInsuranceDocumentUrl($selectedPolicy) }}" target="_blank" class="mt-2 inline-flex text-primary-600 hover:underline">Apri PDF / allegato polizza</a>
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" wire:click="openInsuranceAttachmentPreview({{ (int) $selectedPolicy->id }}, 'document')" class="inline-flex rounded-md bg-primary-600 px-3 py-1.5 text-[11px] font-medium text-white hover:bg-primary-500">Apri in modal</button>
|
||||||
|
<a href="{{ $this->getInsuranceDocumentUrl($selectedPolicy) }}" target="_blank" class="inline-flex rounded-md bg-slate-100 px-3 py-1.5 text-[11px] font-medium text-slate-700 hover:bg-slate-200">Apri file</a>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="mt-2">Nessun documento caricato.</div>
|
<div class="mt-2">Nessun documento caricato.</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -144,7 +221,10 @@
|
||||||
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-700">
|
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-700">
|
||||||
<div class="font-medium">Firma immagine</div>
|
<div class="font-medium">Firma immagine</div>
|
||||||
@if($this->getInsuranceSignatureUrl($selectedPolicy))
|
@if($this->getInsuranceSignatureUrl($selectedPolicy))
|
||||||
<img src="{{ $this->getInsuranceSignatureUrl($selectedPolicy) }}" alt="Firma" class="mt-2 max-h-24 rounded border bg-white p-1" />
|
<div class="mt-2 flex items-center gap-3">
|
||||||
|
<img src="{{ $this->getInsuranceSignatureUrl($selectedPolicy) }}" alt="Firma" class="max-h-24 rounded border bg-white p-1" />
|
||||||
|
<button type="button" wire:click="openInsuranceAttachmentPreview({{ (int) $selectedPolicy->id }}, 'signature')" class="inline-flex rounded-md bg-slate-100 px-3 py-1.5 text-[11px] font-medium text-slate-700 hover:bg-slate-200">Vedi firma</button>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="mt-2">Nessuna firma immagine caricata.</div>
|
<div class="mt-2">Nessuna firma immagine caricata.</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -178,7 +258,16 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-3 py-2 align-top">{{ optional($claim->opened_at)->format('d/m/Y H:i') ?: '-' }}</td>
|
<td class="px-3 py-2 align-top">{{ optional($claim->opened_at)->format('d/m/Y H:i') ?: '-' }}</td>
|
||||||
<td class="px-3 py-2 align-top">{{ $claim->insurancePolicy?->display_name ?: ($claim->policy_reference ?: '-') }}</td>
|
<td class="px-3 py-2 align-top">{{ $claim->insurancePolicy?->display_name ?: ($claim->policy_reference ?: '-') }}</td>
|
||||||
<td class="px-3 py-2 align-top">{{ $claim->claim_number ?: '-' }}<div class="text-xs text-gray-500">{{ $claim->status ?: 'aperta' }}</div></td>
|
<td class="px-3 py-2 align-top">
|
||||||
|
{{ $claim->claim_number ?: '-' }}
|
||||||
|
<div class="text-xs text-gray-500">{{ $claim->status ?: 'aperta' }}</div>
|
||||||
|
@if(data_get($claim->metadata, 'taken_in_charge_at'))
|
||||||
|
<div class="text-[11px] text-gray-500">Preso in carico {{ \Carbon\Carbon::parse(data_get($claim->metadata, 'taken_in_charge_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($claim->metadata, 'email_sent_at'))
|
||||||
|
<div class="text-[11px] text-gray-500">Email {{ \Carbon\Carbon::parse(data_get($claim->metadata, 'email_sent_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td class="px-3 py-2 align-top">
|
<td class="px-3 py-2 align-top">
|
||||||
@if($claim->ticket)
|
@if($claim->ticket)
|
||||||
<a href="{{ $this->getInsuranceTicketUrl($claim->ticket) }}" class="text-primary-600 hover:underline">Ticket #{{ (int) $claim->ticket->id }}</a>
|
<a href="{{ $this->getInsuranceTicketUrl($claim->ticket) }}" class="text-primary-600 hover:underline">Ticket #{{ (int) $claim->ticket->id }}</a>
|
||||||
|
|
@ -198,4 +287,49 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($insuranceAttachmentPreview)
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data="{ zoom: 1, pdfPage: 1, iframeKey: 0, refreshPdf() { this.iframeKey++; }, zoomIn() { this.zoom = Math.min(this.zoom + 0.2, 4); }, zoomOut() { this.zoom = Math.max(this.zoom - 0.2, 0.5); }, resetZoom() { this.zoom = 1; }, nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); }, prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); } }">
|
||||||
|
<div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold">{{ $insuranceAttachmentPreview['name'] ?? 'Allegato assicurazione' }}</div>
|
||||||
|
@if(!empty($insuranceAttachmentPreview['description']))
|
||||||
|
<div class="text-xs text-gray-500">{{ $insuranceAttachmentPreview['description'] }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if($insuranceAttachmentPreview['is_image'] ?? false)
|
||||||
|
<button type="button" x-on:click="zoomOut()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">-</button>
|
||||||
|
<button type="button" x-on:click="resetZoom()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">100%</button>
|
||||||
|
<button type="button" x-on:click="zoomIn()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">+</button>
|
||||||
|
@elseif($insuranceAttachmentPreview['is_pdf'] ?? false)
|
||||||
|
<button type="button" x-on:click="prevPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina precedente</button>
|
||||||
|
<span class="text-xs text-slate-500">Pagina <span x-text="pdfPage"></span></span>
|
||||||
|
<button type="button" x-on:click="nextPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina successiva</button>
|
||||||
|
@endif
|
||||||
|
<a href="{{ $insuranceAttachmentPreview['url'] }}" target="_blank" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri file</a>
|
||||||
|
<button type="button" wire:click="closeInsuranceAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
|
||||||
|
@if($insuranceAttachmentPreview['is_image'] ?? false)
|
||||||
|
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
|
||||||
|
<div class="flex min-h-full min-w-full items-start justify-center">
|
||||||
|
<img src="{{ $insuranceAttachmentPreview['url'] }}" alt="Anteprima allegato assicurazione" class="max-w-none rounded border bg-white shadow" x-bind:style="`width: ${zoom * 100}%; height: auto;`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@elseif($insuranceAttachmentPreview['is_pdf'] ?? false)
|
||||||
|
<iframe x-bind:key="iframeKey" x-bind:src="@js($insuranceAttachmentPreview['url']) + '#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width&page=' + pdfPage" class="h-full w-full rounded border"></iframe>
|
||||||
|
@else
|
||||||
|
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
|
||||||
|
Anteprima non disponibile. Apri il file direttamente:
|
||||||
|
<a href="{{ $insuranceAttachmentPreview['url'] }}" class="ml-1 text-primary-600 hover:underline">Apri file</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-3">
|
<div class="rounded-lg border border-gray-200 bg-white p-3">
|
||||||
<div class="mb-2 text-sm font-semibold text-gray-700">Centralino studio e interni collaboratori</div>
|
<div class="mb-2 text-sm font-semibold text-gray-700">Centralino studio e interni collaboratori</div>
|
||||||
<div class="mb-3 text-xs text-gray-500">I numeri studio sono salvati nella scheda amministratore. Gli interni PBX dei collaboratori sono usati per instradamento SMDR/post-it.</div>
|
<div class="mb-3 text-xs text-gray-500">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.</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_principale" class="rounded-md border-gray-300 text-xs" placeholder="Numero principale centralino" />
|
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_principale" class="rounded-md border-gray-300 text-xs" placeholder="Numero principale centralino" />
|
||||||
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_backup" class="rounded-md border-gray-300 text-xs" placeholder="Numero backup" />
|
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_backup" class="rounded-md border-gray-300 text-xs" placeholder="Numero backup" />
|
||||||
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_emergenza" class="rounded-md border-gray-300 text-xs" placeholder="Numero emergenza" />
|
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_emergenza" class="rounded-md border-gray-300 text-xs" placeholder="Numero emergenza" />
|
||||||
<input type="text" wire:model.defer="data.impostazioni.centralino.note_instradamento" class="rounded-md border-gray-300 text-xs" placeholder="Note instradamento" />
|
<input type="text" wire:model.defer="data.impostazioni.centralino.interno_centralino" class="rounded-md border-gray-300 text-xs" placeholder="Interno centralino, es. 201" />
|
||||||
|
<input type="text" wire:model.defer="data.impostazioni.centralino.interno_operatore" class="rounded-md border-gray-300 text-xs" placeholder="Interno operatore, es. 205" />
|
||||||
|
<input type="text" wire:model.defer="data.impostazioni.centralino.interno_amministratore" class="rounded-md border-gray-300 text-xs" placeholder="Interno amministratore, es. 206" />
|
||||||
|
<input type="text" wire:model.defer="data.impostazioni.centralino.interno_gruppo_giorno" class="rounded-md border-gray-300 text-xs" placeholder="Gruppo giorno, es. 601" />
|
||||||
|
<input type="text" wire:model.defer="data.impostazioni.centralino.interno_gruppo_notte" class="rounded-md border-gray-300 text-xs" placeholder="Gruppo notte, es. 603" />
|
||||||
|
<input type="text" wire:model.defer="data.impostazioni.centralino.note_instradamento" class="rounded-md border-gray-300 text-xs md:col-span-3" placeholder="Note instradamento" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 overflow-x-auto">
|
<div class="mt-3 overflow-x-auto">
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,45 @@
|
||||||
<span class="mb-1 block font-medium">Numero sinistro</span>
|
<span class="mb-1 block font-medium">Numero sinistro</span>
|
||||||
<input type="text" wire:model.defer="insuranceClaimNumber" class="w-full rounded-lg border-gray-300" placeholder="Numero pratica/sinistro" />
|
<input type="text" wire:model.defer="insuranceClaimNumber" class="w-full rounded-lg border-gray-300" placeholder="Numero pratica/sinistro" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Stato pratica</span>
|
||||||
|
<select wire:model.defer="insuranceStatus" class="w-full rounded-lg border-gray-300">
|
||||||
|
<option value="aperta">Aperta</option>
|
||||||
|
<option value="presa_in_carico">Presa in carico</option>
|
||||||
|
<option value="documenti_inviati">Documenti inviati</option>
|
||||||
|
<option value="in_valutazione">In valutazione</option>
|
||||||
|
<option value="in_perizia">In perizia</option>
|
||||||
|
<option value="chiusa">Chiusa</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Presa in carico il</span>
|
||||||
|
<input type="datetime-local" wire:model.defer="insuranceTakenInChargeAt" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Email spedita il</span>
|
||||||
|
<input type="datetime-local" wire:model.defer="insuranceEmailSentAt" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Parlato con assicuratore il</span>
|
||||||
|
<input type="datetime-local" wire:model.defer="insuranceInsurerContactedAt" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Documenti inviati il</span>
|
||||||
|
<input type="datetime-local" wire:model.defer="insuranceDocumentsSentAt" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Appuntamento / perizia</span>
|
||||||
|
<input type="datetime-local" wire:model.defer="insuranceAppointmentAt" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium">Chiusa il</span>
|
||||||
|
<input type="datetime-local" wire:model.defer="insuranceClosedAt" class="w-full rounded-lg border-gray-300" />
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm md:col-span-2">
|
||||||
|
<span class="mb-1 block font-medium">Prossima azione</span>
|
||||||
|
<input type="text" wire:model.defer="insuranceNextAction" class="w-full rounded-lg border-gray-300" placeholder="Richiamare il perito, inviare quietanza, attendere risposta, ecc." />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="mt-3 block text-sm">
|
<label class="mt-3 block text-sm">
|
||||||
|
|
@ -403,6 +442,27 @@
|
||||||
@if($ticket->insuranceClaim->policy_reference)
|
@if($ticket->insuranceClaim->policy_reference)
|
||||||
· Polizza {{ $ticket->insuranceClaim->policy_reference }}
|
· Polizza {{ $ticket->insuranceClaim->policy_reference }}
|
||||||
@endif
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'taken_in_charge_at'))
|
||||||
|
<div class="mt-1">Presa in carico: {{ \Carbon\Carbon::parse(data_get($ticket->insuranceClaim->metadata, 'taken_in_charge_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'email_sent_at'))
|
||||||
|
<div>Email spedita: {{ \Carbon\Carbon::parse(data_get($ticket->insuranceClaim->metadata, 'email_sent_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'insurer_contacted_at'))
|
||||||
|
<div>Parlato con assicuratore: {{ \Carbon\Carbon::parse(data_get($ticket->insuranceClaim->metadata, 'insurer_contacted_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'documents_sent_at'))
|
||||||
|
<div>Documenti inviati: {{ \Carbon\Carbon::parse(data_get($ticket->insuranceClaim->metadata, 'documents_sent_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'appointment_at'))
|
||||||
|
<div>Appuntamento/perizia: {{ \Carbon\Carbon::parse(data_get($ticket->insuranceClaim->metadata, 'appointment_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'closed_at'))
|
||||||
|
<div>Chiusa il: {{ \Carbon\Carbon::parse(data_get($ticket->insuranceClaim->metadata, 'closed_at'))->format('d/m/Y H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(data_get($ticket->insuranceClaim->metadata, 'next_action'))
|
||||||
|
<div>Prossima azione: {{ data_get($ticket->insuranceClaim->metadata, 'next_action') }}</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
@ -431,7 +491,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($attachmentPreview)
|
@if($attachmentPreview)
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data="{ zoom: 1, pdfPage: 1, pdfZoom: 'page-fit', iframeKey: 0, refreshPdf() { this.iframeKey++; }, zoomIn() { this.zoom = Math.min(this.zoom + 0.15, 3); }, zoomOut() { this.zoom = Math.max(this.zoom - 0.15, 0.4); }, resetZoom() { this.zoom = 1; }, nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); }, prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); }, printPdf() { const frame = this.$refs.pdfFrame; if (frame?.contentWindow) { frame.contentWindow.focus(); frame.contentWindow.print(); } else { window.open(@js($attachmentPreview['url']), '_blank'); } } }">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data="{ zoom: 1, pdfPage: 1, iframeKey: 0, refreshPdf() { this.iframeKey++; }, zoomIn() { this.zoom = Math.min(this.zoom + 0.2, 4); }, zoomOut() { this.zoom = Math.max(this.zoom - 0.2, 0.5); }, resetZoom() { this.zoom = 1; }, nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); }, prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); }, printPdf() { const frame = this.$refs.pdfFrame; if (frame?.contentWindow) { frame.contentWindow.focus(); frame.contentWindow.print(); } else { window.open(@js($attachmentPreview['url']), '_blank'); } } }">
|
||||||
<div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
|
<div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
|
||||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -458,11 +518,13 @@
|
||||||
|
|
||||||
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
|
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
|
||||||
@if($attachmentPreview['is_image'] ?? false)
|
@if($attachmentPreview['is_image'] ?? false)
|
||||||
<div class="flex min-h-full items-center justify-center overflow-auto rounded-lg bg-slate-50 p-4">
|
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
|
||||||
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="max-h-[84vh] max-w-full rounded border object-contain shadow" x-bind:style="`transform: scale(${zoom}); transform-origin: center center;`" />
|
<div class="flex min-h-full min-w-full items-start justify-center">
|
||||||
|
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="max-w-none rounded border bg-white shadow" x-bind:style="`width: ${zoom * 100}%; height: auto;`" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif($attachmentPreview['is_pdf'] ?? false)
|
@elseif($attachmentPreview['is_pdf'] ?? false)
|
||||||
<iframe x-ref="pdfFrame" x-bind:key="iframeKey" x-bind:src="@js($attachmentPreview['url']) + '#page=' + pdfPage + '&zoom=' + pdfZoom + '&toolbar=1&navpanes=0'" class="h-[84vh] w-full rounded border"></iframe>
|
<iframe x-ref="pdfFrame" x-bind:key="iframeKey" x-bind:src="@js($attachmentPreview['url']) + '#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width&page=' + pdfPage" class="h-[84vh] w-full rounded border"></iframe>
|
||||||
@else
|
@else
|
||||||
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
|
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
|
||||||
Anteprima non disponibile per questo formato. Scarica o apri il file:
|
Anteprima non disponibile per questo formato. Scarica o apri il file:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,15 @@
|
||||||
|
|
||||||
Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup'])
|
Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup'])
|
||||||
->name('api.cti.panasonic.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) ---
|
// --- API Comunicazioni Omnicanale (Telegram / WhatsApp test) ---
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
use App\Http\Controllers\Admin\FornitoreController;
|
use App\Http\Controllers\Admin\FornitoreController;
|
||||||
use App\Http\Controllers\Admin\GestioneAttivaController;
|
use App\Http\Controllers\Admin\GestioneAttivaController;
|
||||||
use App\Http\Controllers\Admin\ImpostazioniController;
|
use App\Http\Controllers\Admin\ImpostazioniController;
|
||||||
|
use App\Http\Controllers\Admin\InsurancePolicyAssetViewController;
|
||||||
use App\Http\Controllers\Admin\PianoRateizzazioneController;
|
use App\Http\Controllers\Admin\PianoRateizzazioneController;
|
||||||
use App\Http\Controllers\Admin\PreventivoController;
|
use App\Http\Controllers\Admin\PreventivoController;
|
||||||
use App\Http\Controllers\Admin\RataController;
|
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}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open');
|
||||||
Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store');
|
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('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', [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}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify');
|
||||||
Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice');
|
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}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open');
|
||||||
Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store');
|
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('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', [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}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify');
|
||||||
Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice');
|
Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice');
|
||||||
|
|
|
||||||
42
scripts/ops/netgescon-panasonic-csta-bridge.sh
Executable file
42
scripts/ops/netgescon-panasonic-csta-bridge.sh
Executable file
|
|
@ -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[@]}"
|
||||||
139
scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1
Normal file
139
scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1
Normal file
|
|
@ -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."
|
||||||
106
scripts/ops/windows/inspect-netgescon-panasonic-address.ps1
Normal file
106
scripts/ops/windows/inspect-netgescon-panasonic-address.ps1
Normal file
|
|
@ -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"
|
||||||
160
scripts/ops/windows/inspect-netgescon-tapi-interop.ps1
Normal file
160
scripts/ops/windows/inspect-netgescon-tapi-interop.ps1
Normal file
|
|
@ -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."
|
||||||
|
|
@ -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)
|
||||||
265
scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1
Normal file
265
scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
69
scripts/ops/windows/start-netgescon-panasonic-live.ps1
Normal file
69
scripts/ops/windows/start-netgescon-panasonic-live.ps1
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
70
scripts/ops/windows/test-netgescon-panasonic-api.ps1
Normal file
70
scripts/ops/windows/test-netgescon-panasonic-api.ps1
Normal file
|
|
@ -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."
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
1417
scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1
Normal file
1417
scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1
Normal file
File diff suppressed because it is too large
Load Diff
281
scripts/ops/windows/watch-netgescon-panasonic-tapi-events.vbs
Normal file
281
scripts/ops/windows/watch-netgescon-panasonic-tapi-events.vbs
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user