Allinea lotto Day0: CTI Panasonic, workflow update e documentazione

This commit is contained in:
michele 2026-03-28 08:46:48 +00:00
parent 1a468fdd78
commit cc1c8f69aa
64 changed files with 6728 additions and 1194 deletions

4
.gitignore vendored
View File

@ -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

View 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) : '';
}
}

View 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;
}
}

View File

@ -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,6 +134,7 @@ public function handle(): int
continue; continue;
} }
$idleTimeouts = 0;
$line = trim($line); $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'],
]; ];
} }
} }

View File

@ -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,8 +434,32 @@ 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;
@ -435,6 +484,12 @@ 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
@ -460,6 +515,60 @@ public function resetInsurancePolicyForm(): void
]; ];
$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) {
@ -508,11 +626,12 @@ public function saveInsurancePolicy(): void
'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),
'metadata' => array_merge((array) ($policy->metadata ?? []), ['payment_rows' => $paymentRows]),
'created_by' => Auth::id(), '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;
} }
public function getInsuranceSignatureUrl(InsurancePolicy $policy): ?string $path = $this->normalizeInsuranceSearchValue($policy->policy_document_path ?? '');
{ $routeName = $this->resolveInsuranceAssetRouteName();
$path = trim((string) ($policy->signature_image_path ?? ''));
return $path !== '' ? Storage::disk('public')->url($path) : null; return $path !== ''
&& $routeName !== null
? route($routeName, ['policy' => (int) $policy->id, 'asset' => 'document'])
: null;
} }
public function getInsuranceTicketUrl(?Ticket $ticket): ?string public function getInsuranceSignatureUrl($policy): ?string
{
if (! $policy instanceof InsurancePolicy) {
return null;
}
$path = $this->normalizeInsuranceSearchValue($policy->signature_image_path ?? '');
$routeName = $this->resolveInsuranceAssetRouteName();
return $path !== ''
&& $routeName !== null
? route($routeName, ['policy' => (int) $policy->id, 'asset' => 'signature'])
: null;
}
public function getInsuranceTicketUrl($ticket): ?string
{ {
if (! $ticket instanceof Ticket) { if (! $ticket instanceof Ticket) {
return null; return null;
@ -564,6 +706,63 @@ 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')) {
@ -582,10 +781,193 @@ private function storeInsuranceUploads(InsurancePolicy $policy): void
} }
$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
{ {
$value = trim((string) $value); $value = trim((string) $value);

View File

@ -569,7 +569,7 @@ protected function upsertRubricaInterna(array $validated, FornitoreDipendente $d
$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;
@ -583,7 +583,7 @@ protected function upsertRubricaInterna(array $validated, FornitoreDipendente $d
$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;

View File

@ -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
@ -494,7 +492,7 @@ public function table(Table $table): Table
->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 ?? ''),

View File

@ -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(),
]), ]),
]), ]),

View File

@ -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(),

View File

@ -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> */
@ -433,9 +456,27 @@ public function salvaSinistroAssicurativo(): void
'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'],
'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'], '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],
[ [
@ -443,15 +484,16 @@ public function salvaSinistroAssicurativo(): void
'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++;
} }
@ -1104,6 +1150,14 @@ private function syncSelectedTicketState(): void
$this->insurancePolicyId = null; $this->insurancePolicyId = null;
$this->insurancePolicyReference = null; $this->insurancePolicyReference = null;
$this->insuranceClaimNumber = null; $this->insuranceClaimNumber = null;
$this->insuranceStatus = null;
$this->insuranceTakenInChargeAt = null;
$this->insuranceEmailSentAt = null;
$this->insuranceInsurerContactedAt = null;
$this->insuranceDocumentsSentAt = null;
$this->insuranceAppointmentAt = null;
$this->insuranceClosedAt = null;
$this->insuranceNextAction = null;
$this->insuranceNotes = null; $this->insuranceNotes = null;
return; return;
} }
@ -1123,7 +1177,17 @@ private function syncSelectedTicketState(): void
$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->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->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();

View File

@ -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;
@ -160,15 +161,16 @@ public function refreshLiveCallBanner(): void
} }
$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']);

View File

@ -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) . '"',
]);
}
}

View File

@ -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;
@ -26,10 +29,12 @@ public function incoming(Request $request): JsonResponse
$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'],
'calling_extension' => ['nullable', 'string', 'max:30'],
'event_type' => ['nullable', 'string', 'max:60'],
'note' => ['nullable', 'string'], 'note' => ['nullable', 'string'],
'called_at' => ['nullable', 'date'], 'called_at' => ['nullable', 'date'],
'is_test' => ['nullable', 'boolean'], 'is_test' => ['nullable', 'boolean'],
@ -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,11 +73,21 @@ 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,
@ -88,6 +105,11 @@ public function incoming(Request $request): JsonResponse
'categoria' => $rubrica->categoria, 'categoria' => $rubrica->categoria,
] : null, ] : null,
'post_it_id' => $postIt?->id, 'post_it_id' => $postIt?->id,
'communication_message_id' => $message?->id,
'assigned_user_id' => $routing['user_id'],
'amministratore_id' => $routing['amministratore_id'],
'studio_role' => $routing['studio_role'],
'studio_mode' => $routing['studio_mode'],
'called_extension' => $payload['called_extension'] ?? null, 'called_extension' => $payload['called_extension'] ?? null,
]); ]);
} }
@ -136,8 +158,10 @@ public function callEnded(Request $request): JsonResponse
'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'],
'calling_extension' => ['nullable', 'string', 'max:30'],
'event_type' => ['nullable', 'string', 'max:60'],
'note' => ['nullable', 'string'], 'note' => ['nullable', 'string'],
'is_test' => ['nullable', 'boolean'], 'is_test' => ['nullable', 'boolean'],
]); ]);
@ -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,11 +237,14 @@ 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,
@ -229,12 +257,128 @@ public function callEnded(Request $request): JsonResponse
'closed' => true, 'closed' => true,
'created_new' => $created, 'created_new' => $created,
'post_it_id' => $postIt->id, 'post_it_id' => $postIt->id,
'communication_message_id' => $message?->id,
'stato' => $postIt->stato, 'stato' => $postIt->stato,
'esito' => $postIt->esito, 'esito' => $postIt->esito,
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, '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),
]);
}
private function isAuthorized(Request $request): bool private function isAuthorized(Request $request): bool
{ {
$configured = $this->resolveConfiguredToken(); $configured = $this->resolveConfiguredToken();
@ -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'];
} }
@ -443,10 +609,216 @@ private function logTrace(string $event, Request $request, array $payload, array
'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_type' => (string) ($payload['event_type'] ?? ''),
'event_id' => (string) ($payload['event_id'] ?? ''), 'event_id' => (string) ($payload['event_id'] ?? ''),
'calling_extension' => (string) ($payload['calling_extension'] ?? ''),
'called_extension' => (string) ($payload['called_extension'] ?? ''), 'called_extension' => (string) ($payload['called_extension'] ?? ''),
'direction' => (string) ($payload['direction'] ?? ''), 'direction' => (string) ($payload['direction'] ?? ''),
'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '', '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(),
];
}
} }

View File

@ -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)

View File

@ -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,12 +69,14 @@ 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',
'visibility_groups' => 'array',
'visibility_roles' => 'array',
'dimensione_file' => 'integer', 'dimensione_file' => 'integer',
'data_documento' => 'date', 'data_documento' => 'date',
'data_scadenza' => 'date', 'data_scadenza' => 'date',
@ -175,7 +180,9 @@ 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'];
@ -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';
} }
@ -236,7 +256,7 @@ 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;
} }

View File

@ -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,7 +116,7 @@ class Stabile extends Model
'codice_interno', 'codice_interno',
'registro_anagrafe', 'registro_anagrafe',
'documenti_path', 'documenti_path',
'attivo' 'attivo',
]; ];
protected $casts = [ protected $casts = [
@ -154,7 +153,7 @@ class Stabile extends Model
'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,18 +279,13 @@ 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,
@ -315,7 +309,7 @@ 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,
]; ];
} }
@ -358,7 +352,7 @@ public function getCaratteristicheAttribute()
'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,
]; ];
} }
@ -444,7 +438,7 @@ public function getStatistiche()
'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(),
]; ];
} }

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

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

View 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;
}
}

View 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();
}
}

View File

@ -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...
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Services\Stabili; namespace App\Services\Stabili;
use App\Models\Amministratore; use App\Models\Amministratore;

View 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;
}
}

View File

@ -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
View 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),
];

View File

@ -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')) {

View File

@ -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);
}
}
});
}
};

View File

@ -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

View File

@ -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 Lets 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.

View 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.

View File

@ -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.

View 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.

View File

@ -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
--- ---

View File

@ -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`:

View File

@ -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.

View File

@ -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.

View File

@ -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">
Broker selezionato: <span class="font-semibold">{{ $insuranceBrokerSearch }}</span>
<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 @endforeach
</select> </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">
Compagnia selezionata: <span class="font-semibold">{{ $insuranceCompanySearch }}</span>
<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 @endforeach
</select> </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">
@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>
<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>

View File

@ -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">

View File

@ -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:

View 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) ---

View File

@ -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');

View 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[@]}"

View 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."

View 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"

View 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."

View File

@ -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)

View 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
}

View 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))
}

View 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."

View File

@ -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)
}
}
}

View File

@ -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
}
}

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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