diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index bd9f725..fa8fb30 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -96,6 +96,91 @@ public function lookup(Request $request): JsonResponse ]); } + public function callEnded(Request $request): JsonResponse + { + if (! $this->isAuthorized($request)) { + return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); + } + + $payload = $request->validate([ + 'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'], + 'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'], + 'outcome' => ['nullable', 'string', 'max:255'], + 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'ended_at' => ['nullable', 'date'], + 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], + 'called_extension' => ['nullable', 'string', 'max:30'], + 'note' => ['nullable', 'string'], + ]); + + $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); + $postIt = $this->findOpenPostIt( + (string) ($payload['event_id'] ?? ''), + $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') + ); + + $created = false; + if (! $postIt && Schema::hasTable('chiamate_post_it')) { + $created = true; + $rubrica = $this->findRubricaByPhone($normalized); + + $postIt = ChiamataPostIt::query()->create([ + 'stabile_id' => null, + 'rubrica_id' => $rubrica?->id, + 'ticket_id' => null, + 'creato_da_user_id' => null, + 'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''), + 'nome_chiamante' => $rubrica?->nome_completo, + 'direzione' => $payload['direction'] ?? 'in_arrivo', + 'esito' => $payload['outcome'] ?? null, + 'origine' => 'panasonic_ns1000', + 'origine_id' => $payload['event_id'] ?? null, + 'oggetto' => 'Chiamata terminata (evento senza incoming)', + 'nota' => $this->buildNote($payload), + 'priorita' => 'Media', + 'stato' => 'post_it', + 'chiamata_il' => $payload['ended_at'] ?? now(), + ]); + } + + if (! $postIt) { + return response()->json([ + 'ok' => false, + 'message' => 'No matching Post-it and table unavailable', + ], 404); + } + + $postIt->esito = $payload['outcome'] ?? $postIt->esito; + $postIt->stato = 'chiusa'; + + if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) { + $postIt->durata_secondi = $payload['duration_seconds'] ?? $postIt->durata_secondi; + } + + if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) { + $postIt->chiusa_il = $payload['ended_at'] ?? now(); + } + + if (! empty($payload['note'])) { + $existing = trim((string) ($postIt->nota ?? '')); + $suffix = 'Note chiusura PBX: ' . $payload['note']; + $postIt->nota = $existing !== '' ? ($existing . "\n" . $suffix) : $suffix; + } + + $postIt->save(); + + return response()->json([ + 'ok' => true, + 'source' => 'panasonic_ns1000', + 'closed' => true, + 'created_new' => $created, + 'post_it_id' => $postIt->id, + 'stato' => $postIt->stato, + 'esito' => $postIt->esito, + 'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, + ]); + } + private function isAuthorized(Request $request): bool { $configured = (string) env('NETGESCON_CTI_SHARED_TOKEN', ''); @@ -131,6 +216,43 @@ private function findRubricaByPhone(string $phone): ?RubricaUniversale ->first(); } + private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt + { + if (! Schema::hasTable('chiamate_post_it')) { + return null; + } + + if ($eventId !== '') { + $byEvent = ChiamataPostIt::query() + ->where('origine', 'panasonic_ns1000') + ->where('origine_id', $eventId) + ->where('stato', '!=', 'chiusa') + ->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 ChiamataPostIt::query() + ->where('stato', '!=', 'chiusa') + ->where('origine', 'panasonic_ns1000') + ->where('telefono', 'like', '%' . $tail . '%') + ->orderByDesc('chiamata_il') + ->first(); + } + private function buildNote(array $payload): string { $lines = [ diff --git a/app/Models/ChiamataPostIt.php b/app/Models/ChiamataPostIt.php index 78e4a5e..d82b7e0 100644 --- a/app/Models/ChiamataPostIt.php +++ b/app/Models/ChiamataPostIt.php @@ -21,15 +21,19 @@ class ChiamataPostIt extends Model 'esito', 'origine', 'origine_id', + 'durata_secondi', 'oggetto', 'nota', 'priorita', 'stato', 'chiamata_il', + 'chiusa_il', ]; protected $casts = [ 'chiamata_il' => 'datetime', + 'chiusa_il' => 'datetime', + 'durata_secondi' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/database/migrations/2026_03_11_101500_add_call_end_fields_to_chiamate_post_it_table.php b/database/migrations/2026_03_11_101500_add_call_end_fields_to_chiamate_post_it_table.php new file mode 100644 index 0000000..f852597 --- /dev/null +++ b/database/migrations/2026_03_11_101500_add_call_end_fields_to_chiamate_post_it_table.php @@ -0,0 +1,34 @@ +unsignedInteger('durata_secondi')->nullable()->after('esito'); + } + + if (! Schema::hasColumn('chiamate_post_it', 'chiusa_il')) { + $table->timestamp('chiusa_il')->nullable()->after('chiamata_il'); + } + }); + } + + public function down(): void + { + Schema::table('chiamate_post_it', function (Blueprint $table): void { + if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) { + $table->dropColumn('chiusa_il'); + } + + if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) { + $table->dropColumn('durata_secondi'); + } + }); + } +}; diff --git a/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md b/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md index 1f8bf65..874f485 100644 --- a/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md +++ b/docs/ai/restart/CTI-PANASONIC-NS1000-SETUP.md @@ -1,6 +1,6 @@ # CTI Panasonic NS1000 - Setup operativo -Obiettivo: intercettare chiamate in arrivo dal centralino Panasonic NS1000 (CSTA porta 33333) e creare 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. ## Prerequisiti verificati @@ -29,12 +29,31 @@ ## 2) Endpoint disponibili - `POST /api/v1/cti/panasonic/incoming` - crea Post-it chiamata in `chiamate_post_it` - fa match automatico numero -> `rubrica_universale` +- `POST /api/v1/cti/panasonic/call-ended` + - chiude automaticamente il Post-it aperto (match su `event_id`, fallback su numero) + - salva `esito`, `durata_secondi`, `chiusa_il` - `GET /api/v1/cti/panasonic/lookup?phone=...` - solo lookup rubrica (match yes/no) Autenticazione: header `X-CTI-Token: `. -## 3) Test rapido terminale +## 3) Comando server web prova (porta 8000) + +```bash +cd /home/michele/netgescon/netgescon-day0 +php artisan serve --host=0.0.0.0 --port=8000 +``` + +## 4) Listener terminale per vedere payload grezzi + +Metti questo listener in ascolto e punta il PBX a `http://:8099` per vedere esattamente i dati in arrivo. + +```bash +cd /home/michele/netgescon/netgescon-day0 +bash scripts/ops/netgescon-cti-listener.sh --port 8099 +``` + +## 5) Test rapido terminale (incoming + call-ended) ```bash cd /home/michele/netgescon/netgescon-day0 @@ -44,10 +63,12 @@ ## 3) Test rapido terminale --app-url http://127.0.0.1:8000 \ --token "" \ --phone "+393331112233" \ - --ext "101" + --ext "101" \ + --duration 90 \ + --outcome "risposta" ``` -## 4) Esempio comando da usare lato interfaccia centralino +## 6) Esempio comandi da usare lato interfaccia centralino Se il centralino puo inviare HTTP su evento incoming call, usa: @@ -72,7 +93,23 @@ ## 4) Esempio comando da usare lato interfaccia centralino -H "X-CTI-Token: " ``` -## 5) Cosa fa il match numeri +Per evento fine chiamata (chiusura automatica Post-it): + +```bash +curl -sS -X POST "http://:8000/api/v1/cti/panasonic/call-ended" \ + -H "Content-Type: application/json" \ + -H "X-CTI-Token: " \ + -d '{ + "phone": "", + "event_id": "", + "outcome": "risposta", + "duration_seconds": 83, + "ended_at": "2026-03-11T10:20:00+01:00", + "note": "evento call-ended NS1000" + }' +``` + +## 7) Cosa fa il match numeri - normalizza il numero (es. `+39`, `0039`, spazi, trattini) - confronta le ultime 7 cifre con: @@ -81,7 +118,26 @@ ## 5) Cosa fa il match numeri - `telefono_casa` - `telefono_cellulare_nazionale` -## 6) Step successivo consigliato +## 8) Dev + staging sulla stessa macchina (.200) + +- Sviluppo consigliato: `http://127.0.0.1:8000` +- Staging consigliato: `http://127.0.0.1:8001` + +Comandi esempio: + +```bash +# sviluppo +cd /home/michele/netgescon/netgescon-day0 +php artisan serve --host=0.0.0.0 --port=8000 + +# staging (altro terminale, altra working copy o env staging) +cd /home/michele/netgescon/netgescon-day0 +APP_ENV=staging php artisan serve --host=0.0.0.0 --port=8001 +``` + +Nel PBX configura URL diversi per ambiente, mantenendo stesso payload/token o token distinti. + +## 9) Step successivo consigliato 1. Agganciare evento reale CSTA incoming. 2. Agganciare evento call-ended per aggiornare `esito` e durata. diff --git a/routes/api.php b/routes/api.php index c2d35e0..e79a903 100755 --- a/routes/api.php +++ b/routes/api.php @@ -82,6 +82,9 @@ Route::post('/incoming', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'incoming']) ->name('api.cti.panasonic.incoming'); + Route::post('/call-ended', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'callEnded']) + ->name('api.cti.panasonic.call_ended'); + Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup']) ->name('api.cti.panasonic.lookup'); }); diff --git a/scripts/ops/netgescon-cti-listener.sh b/scripts/ops/netgescon-cti-listener.sh new file mode 100755 index 0000000..0f45524 --- /dev/null +++ b/scripts/ops/netgescon-cti-listener.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Minimal raw HTTP listener to inspect payload sent by PBX/webhook tools. +# Use this only for diagnostics in a trusted network. + +PORT="8099" +OUT_DIR="storage/logs" +OUT_FILE="" + +usage() { + cat <<'EOF' +Usage: + bash scripts/ops/netgescon-cti-listener.sh [options] + +Options: + --port Local listen port (default: 8099) + --out Output file (default: storage/logs/cti-listener-.log) + +Examples: + bash scripts/ops/netgescon-cti-listener.sh + bash scripts/ops/netgescon-cti-listener.sh --port 8099 --out storage/logs/cti-live.log +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + --out) OUT_FILE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$OUT_FILE" ]]; then + mkdir -p "$OUT_DIR" + OUT_FILE="$OUT_DIR/cti-listener-$(date +%Y%m%d-%H%M%S).log" +fi + +if command -v ncat >/dev/null 2>&1; then + echo "[listener] ncat in ascolto su 0.0.0.0:${PORT}" + echo "[listener] output: ${OUT_FILE}" + echo "[listener] stop: Ctrl+C" + ncat -kl "$PORT" | tee -a "$OUT_FILE" + exit 0 +fi + +if command -v nc >/dev/null 2>&1; then + echo "[listener] nc in ascolto su 0.0.0.0:${PORT}" + echo "[listener] output: ${OUT_FILE}" + echo "[listener] stop: Ctrl+C" + while true; do + nc -l -p "$PORT" | tee -a "$OUT_FILE" + printf '\n----- END REQUEST %s -----\n' "$(date -Iseconds)" | tee -a "$OUT_FILE" + done +fi + +echo "Missing netcat tool. Install ncat or nc first." >&2 +exit 1 diff --git a/scripts/ops/netgescon-panasonic-csta-test.sh b/scripts/ops/netgescon-panasonic-csta-test.sh old mode 100644 new mode 100755 index 463502a..1ff9746 --- a/scripts/ops/netgescon-panasonic-csta-test.sh +++ b/scripts/ops/netgescon-panasonic-csta-test.sh @@ -9,6 +9,8 @@ APP_BASE_URL="http://127.0.0.1:8000" CTI_TOKEN="${NETGESCON_CTI_SHARED_TOKEN:-}" TEST_PHONE="+393331112233" TEST_EXT="101" +TEST_DURATION="75" +TEST_OUTCOME="risposta" usage() { cat <<'EOF' @@ -22,6 +24,8 @@ Options: --token Shared token (or env NETGESCON_CTI_SHARED_TOKEN) --phone Test caller number --ext Test called extension + --duration Test call duration in seconds (default: 75) + --outcome Test call outcome (default: risposta) Example: bash scripts/ops/netgescon-panasonic-csta-test.sh --token 'my-secret-token' @@ -36,6 +40,8 @@ while [[ $# -gt 0 ]]; do --token) CTI_TOKEN="$2"; shift 2 ;; --phone) TEST_PHONE="$2"; shift 2 ;; --ext) TEST_EXT="$2"; shift 2 ;; + --duration) TEST_DURATION="$2"; shift 2 ;; + --outcome) TEST_OUTCOME="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac @@ -53,10 +59,18 @@ echo "[2/3] Lookup numero in rubrica" curl -sS "${APP_BASE_URL%/}/api/v1/cti/panasonic/lookup?phone=${TEST_PHONE}" \ -H "X-CTI-Token: ${CTI_TOKEN}" | sed -n '1,120p' -echo "[3/3] Simulazione evento chiamata in arrivo -> Post-it" +EVENT_ID="test-$(date +%s)" + +echo "[3/4] Simulazione evento chiamata in arrivo -> Post-it" curl -sS -X POST "${APP_BASE_URL%/}/api/v1/cti/panasonic/incoming" \ -H "Content-Type: application/json" \ -H "X-CTI-Token: ${CTI_TOKEN}" \ - -d "{\"phone\":\"${TEST_PHONE}\",\"direction\":\"in_arrivo\",\"called_extension\":\"${TEST_EXT}\",\"event_id\":\"test-$(date +%s)\",\"note\":\"test webhook panasonic\"}" | sed -n '1,160p' + -d "{\"phone\":\"${TEST_PHONE}\",\"direction\":\"in_arrivo\",\"called_extension\":\"${TEST_EXT}\",\"event_id\":\"${EVENT_ID}\",\"note\":\"test webhook panasonic incoming\"}" | sed -n '1,160p' + +echo "[4/4] Simulazione evento call-ended -> chiusura automatica Post-it" +curl -sS -X POST "${APP_BASE_URL%/}/api/v1/cti/panasonic/call-ended" \ + -H "Content-Type: application/json" \ + -H "X-CTI-Token: ${CTI_TOKEN}" \ + -d "{\"phone\":\"${TEST_PHONE}\",\"event_id\":\"${EVENT_ID}\",\"outcome\":\"${TEST_OUTCOME}\",\"duration_seconds\":${TEST_DURATION},\"ended_at\":\"$(date -Iseconds)\",\"note\":\"test webhook panasonic call-ended\"}" | sed -n '1,200p' echo "Done"