CTI Panasonic: mirror staging->dev e comando sync unidirezionale

This commit is contained in:
michele 2026-03-11 21:36:09 +00:00
parent 3ce1113cd2
commit 11f078b8b2
4 changed files with 187 additions and 4 deletions

View File

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CtiSyncPostItFromStagingCommand extends Command
{
protected $signature = 'cti:sync-postit-from-staging
{--from-connection=staging_mysql : Connessione sorgente (staging)}
{--to-connection=mysql : Connessione destinazione (sviluppo)}
{--since-id=0 : Sincronizza record con id maggiore di questo valore}
{--limit=2000 : Numero massimo record da leggere per esecuzione}
{--replace : Rimuove prima i record Panasonic in destinazione}
{--apply : Applica realmente (senza flag e\' dry-run)}';
protected $description = 'Sincronizza chiamate_post_it Panasonic da staging a sviluppo in modo unidirezionale.';
public function handle(): int
{
$fromConnection = (string) $this->option('from-connection');
$toConnection = (string) $this->option('to-connection');
$sinceId = max(0, (int) $this->option('since-id'));
$limit = max(1, min(20000, (int) $this->option('limit')));
$replace = (bool) $this->option('replace');
$apply = (bool) $this->option('apply');
if (! Config::has('database.connections.' . $fromConnection)) {
$this->error("Connessione sorgente non definita: {$fromConnection}");
return self::FAILURE;
}
if (! Config::has('database.connections.' . $toConnection)) {
$this->error("Connessione destinazione non definita: {$toConnection}");
return self::FAILURE;
}
if ($fromConnection === $toConnection) {
$this->error('Connessione sorgente e destinazione coincidono. Interrompo per sicurezza.');
return self::FAILURE;
}
$fromCfg = (array) config('database.connections.' . $fromConnection, []);
$toCfg = (array) config('database.connections.' . $toConnection, []);
$samePhysicalDb =
strtolower((string) ($fromCfg['host'] ?? '')) === strtolower((string) ($toCfg['host'] ?? '')) &&
(string) ($fromCfg['port'] ?? '') === (string) ($toCfg['port'] ?? '') &&
strtolower((string) ($fromCfg['database'] ?? '')) === strtolower((string) ($toCfg['database'] ?? '')) &&
strtolower((string) ($fromCfg['username'] ?? '')) === strtolower((string) ($toCfg['username'] ?? '')) &&
(string) ($fromCfg['unix_socket'] ?? '') === (string) ($toCfg['unix_socket'] ?? '');
if ($samePhysicalDb) {
$this->error('Sorgente e destinazione puntano allo stesso DB fisico. Interrompo per sicurezza.');
return self::FAILURE;
}
if (! Schema::connection($fromConnection)->hasTable('chiamate_post_it')) {
$this->error('Tabella chiamate_post_it assente sulla connessione sorgente.');
return self::FAILURE;
}
if (! Schema::connection($toConnection)->hasTable('chiamate_post_it')) {
$this->error('Tabella chiamate_post_it assente sulla connessione destinazione.');
return self::FAILURE;
}
$sourceColumns = Schema::connection($fromConnection)->getColumnListing('chiamate_post_it');
$targetColumns = Schema::connection($toConnection)->getColumnListing('chiamate_post_it');
$columns = array_values(array_intersect($sourceColumns, $targetColumns));
if (! in_array('id', $columns, true)) {
$this->error('La tabella destinazione deve avere la colonna id per l\'upsert.');
return self::FAILURE;
}
$rows = DB::connection($fromConnection)
->table('chiamate_post_it')
->where('origine', 'panasonic_ns1000')
->where('id', '>', $sinceId)
->orderBy('id')
->limit($limit)
->get($columns);
if ($rows->isEmpty()) {
$this->info('Nessun record Panasonic da sincronizzare.');
return self::SUCCESS;
}
$firstId = (int) ($rows->first()->id ?? 0);
$lastId = (int) ($rows->last()->id ?? 0);
$this->line("Record letti: {$rows->count()} (id {$firstId} -> {$lastId})");
$this->line("Direzione: {$fromConnection} -> {$toConnection}");
if (! $apply) {
$this->warn('Dry-run attivo. Usa --apply per eseguire la sincronizzazione.');
return self::SUCCESS;
}
$payload = $rows
->map(fn ($row): array => Arr::only((array) $row, $columns))
->all();
$updateColumns = array_values(array_filter($columns, fn (string $col): bool => $col !== 'id'));
DB::connection($toConnection)->transaction(function () use ($toConnection, $replace, $payload, $updateColumns): void {
$query = DB::connection($toConnection)->table('chiamate_post_it');
if ($replace) {
$deleted = $query->where('origine', 'panasonic_ns1000')->delete();
$this->line("Record Panasonic rimossi in destinazione: {$deleted}");
}
foreach (array_chunk($payload, 500) as $chunk) {
DB::connection($toConnection)
->table('chiamate_post_it')
->upsert($chunk, ['id'], $updateColumns);
}
});
$this->info('Sincronizzazione completata con successo.');
return self::SUCCESS;
}
}

View File

@ -8,6 +8,8 @@
use App\Support\PhoneNumber;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class PanasonicCstaController extends Controller
@ -53,6 +55,8 @@ public function incoming(Request $request): JsonResponse
]);
}
$this->mirrorToPeerIfEnabled($request, 'incoming', $payload);
return response()->json([
'ok' => true,
'source' => 'panasonic_ns1000',
@ -169,6 +173,8 @@ public function callEnded(Request $request): JsonResponse
$postIt->save();
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
return response()->json([
'ok' => true,
'source' => 'panasonic_ns1000',
@ -182,6 +188,19 @@ public function callEnded(Request $request): JsonResponse
}
private function isAuthorized(Request $request): bool
{
$configured = $this->resolveConfiguredToken();
if ($configured === '') {
return false;
}
$token = (string) ($request->header('X-CTI-Token') ?: $request->input('token', ''));
return hash_equals($configured, $token);
}
private function resolveConfiguredToken(): string
{
$configured = (string) env('NETGESCON_CTI_SHARED_TOKEN', '');
if ($configured === '') {
@ -200,13 +219,47 @@ private function isAuthorized(Request $request): bool
$configured = (string) env('CTI_PANASONIC_TOKEN', '');
}
if ($configured === '') {
return false;
return $configured;
}
private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array $payload): void
{
$enabled = filter_var((string) env('NETGESCON_CTI_MIRROR_ENABLED', 'false'), FILTER_VALIDATE_BOOL);
if (! $enabled) {
return;
}
$token = (string) ($request->header('X-CTI-Token') ?: $request->input('token', ''));
if ((string) $request->header('X-CTI-Mirrored', '0') === '1') {
return;
}
return hash_equals($configured, $token);
$baseUrl = rtrim((string) env('NETGESCON_CTI_MIRROR_BASE_URL', ''), '/');
if ($baseUrl === '') {
return;
}
$token = $this->resolveConfiguredToken();
if ($token === '') {
return;
}
$timeout = (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2);
$url = $baseUrl . '/api/v1/cti/panasonic/' . ltrim($endpoint, '/');
try {
Http::timeout(max(1, $timeout))
->withHeaders([
'X-CTI-Token' => $token,
'X-CTI-Mirrored' => '1',
])
->post($url, $payload);
} catch (\Throwable $e) {
Log::warning('CTI mirror failed', [
'endpoint' => $endpoint,
'url' => $url,
'error' => $e->getMessage(),
]);
}
}
private function findRubricaByPhone(string $phone): ?RubricaUniversale

View File

@ -1,6 +1,7 @@
<?php
use App\Console\Commands\DedupeRateEmesseCommand;
use App\Console\Commands\CtiSyncPostItFromStagingCommand;
use App\Console\Commands\FeCassettoImportLocal;
use App\Console\Commands\GesconCollegaFornitoreStabili;
use App\Console\Commands\GesconImportAlignCommand;
@ -57,6 +58,7 @@
GoogleImportKeepNotesCommand::class,
GoogleSyncTicketCalendarCommand::class,
GoogleSyncFornitoriRubricaCommand::class,
CtiSyncPostItFromStagingCommand::class,
])
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\ForceFilamentUiMiddleware::class);

View File

@ -20,3 +20,4 @@ # Modifiche NetGescon
| 11/03/2026 | 0.8.x-dev | CTI Panasonic / Post-It | Nuovi endpoint incoming, lookup e call-ended con chiusura automatica Post-it, salvataggio esito e durata chiamata | [U] |
| 11/03/2026 | 0.8.x-dev | Day-0 / Staging / Operativita | Handoff operativo completo, comandi diagnostica payload CSTA/HTTP e allineamento staging nginx su /var/www/netgescon:80 | [P] |
| 11/03/2026 | 0.8.x-dev | CTI Panasonic / DB condiviso | Verificato allineamento DB dev/staging su base unica, applicata migrazione campi `durata_secondi`/`chiusa_il`, fallback token CTI multi-chiave e test end-to-end incoming/call-ended/lookup con chiusura Post-it validata | [U] |
| 11/03/2026 | 0.8.x-dev | CTI Panasonic / Sync staging->sviluppo | Introdotti mirror payload CTI opzionale (solo staging) e comando `cti:sync-postit-from-staging` per riallineamento unidirezionale record `chiamate_post_it` Panasonic verso sviluppo | [U] |