feat: allinea cti crm e catalogo fornitore per staging

This commit is contained in:
michele 2026-04-04 10:25:11 +00:00
parent 89dabf9d72
commit 89fde6b582
73 changed files with 6236 additions and 677 deletions

View File

@ -3,10 +3,16 @@ # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Initial open-source release prep. - Initial open-source release prep.
- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea. - Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea.
- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`. - Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`.
- Hardened Panasonic Windows live bridge scripts, runtime publish flow, and TAPI diagnostics for local-path deployment and aggregated Panasonic addresses.
- Added live-call CRM improvements in Ticket Mobile and Post-it Gestione: cleaner call context, operator note before conversion, clearer SMDR/CSTA labels, and line/group filtering.
- Extended PBX day0 preset coverage to include main/admin lines `0001` and `0003`, with aligned studio watchlist for broader staging monitoring.
- Documented current CTI staging status and Windows restart procedure so the deployment trail remains in-repo even if the active chat is interrupted.
## [0.1.0] - 2026-01-17 ## [0.1.0] - 2026-01-17
- Docker production compose and update workflow. - Docker production compose and update workflow.
- Filament documentation baseline. - Filament documentation baseline.

View File

@ -0,0 +1,208 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Services\FattureElettroniche\CassettoFiscaleDownloadService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class FeCassettoImportSoggettoCommand extends Command
{
protected $signature = 'fe:cassetto-import-soggetto
{amministratore_id : ID amministratore proprietario del contenitore separato}
{codice_fiscale : CF/P.IVA del soggetto da interrogare sul cassetto fiscale}
{--label= : Denominazione del contenitore separato}
{--from= : Data inizio YYYY-MM-DD}
{--to= : Data fine YYYY-MM-DD}
{--quarterly=1 : 1 per spezzare automaticamente per trimestre}
{--metadati=0 : 1 per richiedere anche i metadati}
{--user_id=0 : User ID audit}
{--force_update=0 : 1 per forzare update/import anche su periodo già eseguito}
{--create_fornitore_if_missing=1 : 1 per creare i fornitori mancanti da FE}
{--importa_righe=auto : auto|si|no}';
protected $description = 'Scarica e importa FE dal Cassetto Fiscale per un soggetto fiscale in un contenitore separato dedicato.';
public function handle(CassettoFiscaleDownloadService $service): int
{
$amministratore = Amministratore::query()->find((int) $this->argument('amministratore_id'));
if (! $amministratore instanceof Amministratore) {
$this->error('Amministratore non trovato.');
return self::FAILURE;
}
$cf = $this->normalizeTaxId((string) $this->argument('codice_fiscale'));
if ($cf === '') {
$this->error('Codice fiscale / P.IVA non valido.');
return self::FAILURE;
}
$from = $this->parseYmd((string) $this->option('from'));
$to = $this->parseYmd((string) $this->option('to'));
if (! $from || ! $to) {
$this->error('Specificare --from=YYYY-MM-DD e --to=YYYY-MM-DD');
return self::FAILURE;
}
if ($from > $to) {
$this->error('Intervallo date non valido.');
return self::FAILURE;
}
$label = trim((string) $this->option('label'));
if ($label === '') {
$label = 'Archivio FE separato ' . $cf;
}
$stabile = $this->ensureDedicatedStabile($amministratore, $cf, $label);
$periods = (string) $this->option('quarterly') === '1'
? $this->splitQuarterly($from, $to)
: [[$from, $to]];
$rows = [];
$hasErrors = false;
foreach ($periods as [$dal, $al]) {
$result = $service->downloadAndImport(
$amministratore,
$stabile,
$dal,
$al,
(bool) ((int) $this->option('metadati') === 1),
(int) $this->option('user_id'),
[
'force_update' => (int) $this->option('force_update') === 1,
'create_fornitore_if_missing' => (int) $this->option('create_fornitore_if_missing') === 1,
'importa_righe' => (string) $this->option('importa_righe'),
'no_skip' => (int) $this->option('force_update') === 1,
],
);
$rows[] = [
'periodo' => $dal->format('Y-m-d') . ' -> ' . $al->format('Y-m-d'),
'status' => (string) ($result['status'] ?? 'error'),
'imported' => (string) ((int) ($result['imported'] ?? 0)),
'duplicates' => (string) ((int) ($result['duplicates'] ?? 0)),
'errors' => (string) ((int) ($result['errors'] ?? 0)),
'files' => (string) ((int) ($result['files_total'] ?? 0)),
'message' => Str::limit((string) ($result['message'] ?? ''), 120, '...'),
];
if (($result['status'] ?? 'error') === 'error') {
$hasErrors = true;
}
}
$this->line('Contenitore separato: stabile #' . (int) $stabile->id . ' [' . (string) $stabile->codice_stabile . '] ' . (string) $stabile->denominazione);
$this->table(['Periodo', 'Status', 'Importate', 'Duplicate', 'Errori', 'File', 'Messaggio'], $rows);
return $hasErrors ? self::FAILURE : self::SUCCESS;
}
private function ensureDedicatedStabile(Amministratore $amministratore, string $cf, string $label): Stabile
{
$existing = Stabile::query()
->where('amministratore_id', (int) $amministratore->id)
->where('codice_fiscale', $cf)
->orderBy('id')
->first();
if ($existing instanceof Stabile) {
$dirty = false;
if (! filled($existing->denominazione)) {
$existing->denominazione = $label;
$dirty = true;
}
if (! filled($existing->cod_fisc_amministratore) && filled($amministratore->codice_fiscale_studio)) {
$existing->cod_fisc_amministratore = (string) $amministratore->codice_fiscale_studio;
$dirty = true;
}
if ($dirty) {
$existing->save();
}
return $existing;
}
return Stabile::query()->create([
'amministratore_id' => (int) $amministratore->id,
'codice_stabile' => $this->nextDedicatedCode($amministratore, $cf),
'denominazione' => $label,
'codice_fiscale' => $cf,
'cod_fisc_amministratore' => (string) ($amministratore->codice_fiscale_studio ?? ''),
'indirizzo' => (string) ($amministratore->indirizzo_studio ?? 'Archivio separato FE'),
'cap' => (string) ($amministratore->cap_studio ?? '00000'),
'citta' => (string) ($amministratore->citta_studio ?? 'Roma'),
'provincia' => (string) ($amministratore->provincia_studio ?? 'RM'),
'attivo' => true,
'stato' => 'attivo',
'note' => 'Contenitore separato creato per import FE soggetto fiscale ' . $cf,
'amministratore_nome' => trim((string) (($amministratore->nome ?? '') . ' ' . ($amministratore->cognome ?? ''))),
'amministratore_email' => (string) ($amministratore->email_studio ?? ''),
'configurazione_avanzata' => ['scope' => 'fe_soggetto_separato'],
]);
}
private function nextDedicatedCode(Amministratore $amministratore, string $cf): string
{
$base = 'FE' . substr(preg_replace('/\D+/', '', $cf) ?: '000000', -6);
$base = str_pad(substr($base, 0, 8), 8, '0');
$code = $base;
$suffix = 1;
while (Stabile::query()->where('codice_stabile', $code)->exists()) {
$suffixText = str_pad((string) $suffix, 2, '0', STR_PAD_LEFT);
$code = substr($base, 0, 6) . $suffixText;
$suffix++;
}
return $code;
}
/**
* @return array<int,array{0:\DateTimeImmutable,1:\DateTimeImmutable}>
*/
private function splitQuarterly(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$periods = [];
$cursor = $from;
while ($cursor <= $to) {
$quarter = intdiv(((int) $cursor->format('n')) - 1, 3) + 1;
$quarterEndMonth = $quarter * 3;
$quarterEnd = new \DateTimeImmutable($cursor->format('Y') . '-' . str_pad((string) $quarterEndMonth, 2, '0', STR_PAD_LEFT) . '-01');
$quarterEnd = $quarterEnd->modify('last day of this month');
if ($quarterEnd > $to) {
$quarterEnd = $to;
}
$periods[] = [$cursor, $quarterEnd];
$cursor = $quarterEnd->modify('+1 day');
}
return $periods;
}
private function parseYmd(string $value): ?\DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
return null;
}
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
return $date instanceof \DateTimeImmutable ? $date : null;
}
private function normalizeTaxId(string $value) : string
{
return strtoupper(trim(preg_replace('/\s+/', '', $value) ?? ''));
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
@ -25,7 +24,7 @@ public function handle(): int
$apply = (bool) $this->option('apply'); $apply = (bool) $this->option('apply');
$limit = max(1, min((int) $this->option('limit'), 50000)); $limit = max(1, min((int) $this->option('limit'), 50000));
$ids = array_values(array_filter(array_map(fn($value) => is_numeric($value) ? (int) $value : 0, (array) $this->option('id')))); $ids = array_values(array_filter(array_map(fn($value) => is_numeric($value) ? (int) $value : 0, (array) $this->option('id'))));
$query = RubricaUniversale::query() $query = RubricaUniversale::query()
->where('note', 'like', '%Dati anagrafici legacy:%') ->where('note', 'like', '%Dati anagrafici legacy:%')
@ -43,11 +42,11 @@ public function handle(): int
} }
$processed = 0; $processed = 0;
$updated = 0; $updated = 0;
foreach ($rows as $rubrica) { foreach ($rows as $rubrica) {
$processed++; $processed++;
$legacy = $this->extractLegacyIdentityFromNote($rubrica->note); $legacy = $this->extractLegacyIdentityFromNote($rubrica->note);
$changes = []; $changes = [];
if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) { if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) {
@ -94,20 +93,20 @@ private function extractLegacyIdentityFromNote(mixed $note): array
$raw = trim((string) ($note ?? '')); $raw = trim((string) ($note ?? ''));
if ($raw === '') { if ($raw === '') {
return [ return [
'note_clean' => null, 'note_clean' => null,
'titolo_id' => null, 'titolo_id' => null,
'sesso' => null, 'sesso' => null,
'data_nascita' => null, 'data_nascita' => null,
'luogo_nascita' => null, 'luogo_nascita' => null,
'provincia_nascita' => null, 'provincia_nascita' => null,
]; ];
} }
$parsed = [ $parsed = [
'titolo_id' => null, 'titolo_id' => null,
'sesso' => null, 'sesso' => null,
'data_nascita' => null, 'data_nascita' => null,
'luogo_nascita' => null, 'luogo_nascita' => null,
'provincia_nascita' => null, 'provincia_nascita' => null,
]; ];
@ -122,11 +121,11 @@ private function extractLegacyIdentityFromNote(mixed $note): array
} }
$payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:'))); $payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:')));
$bits = preg_split('/\s*\|\s*/', $payload) ?: []; $bits = preg_split('/\s*\|\s*/', $payload) ?: [];
foreach ($bits as $bit) { foreach ($bits as $bit) {
[$key, $value] = array_pad(explode(':', $bit, 2), 2, null); [$key, $value] = array_pad(explode(':', $bit, 2), 2, null);
$key = mb_strtolower(trim((string) $key)); $key = mb_strtolower(trim((string) $key));
$value = trim((string) $value); $value = trim((string) $value);
if ($value === '') { if ($value === '') {
continue; continue;
} }
@ -182,7 +181,7 @@ private function normalizeLegacyDateValue(mixed $value): ?string
private function normalizeBirthDateYear(\DateTime $date): ?string private function normalizeBirthDateYear(\DateTime $date): ?string
{ {
$year = (int) $date->format('Y'); $year = (int) $date->format('Y');
$currentYear = (int) now()->format('Y'); $currentYear = (int) now()->format('Y');
if ($year > $currentYear + 1) { if ($year > $currentYear + 1) {

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Persona; use App\Models\Persona;
@ -74,12 +73,12 @@ public function handle(): int
} }
$stats = [ $stats = [
'processed' => 0, 'processed' => 0,
'skipped' => 0, 'skipped' => 0,
'persona_created' => 0, 'persona_created' => 0,
'persona_updated' => 0, 'persona_updated' => 0,
'rel_created' => 0, 'rel_created' => 0,
'rel_updated' => 0, 'rel_updated' => 0,
]; ];
foreach ($rows as $row) { foreach ($rows as $row) {
@ -118,14 +117,14 @@ public function handle(): int
} }
$relationPayload = [ $relationPayload = [
'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null), 'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null),
'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01', 'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01',
'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null), 'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null),
'attivo' => $this->isRelationActive($row->data_fine ?? null), 'attivo' => $this->isRelationActive($row->data_fine ?? null),
'riceve_comunicazioni' => true, 'riceve_comunicazioni' => true,
'riceve_convocazioni' => $ruoloRate === 'C', 'riceve_convocazioni' => $ruoloRate === 'C',
'vota_assemblea' => $ruoloRate === 'C', 'vota_assemblea' => $ruoloRate === 'C',
'note_relazione' => $this->buildRelationNote($row, $tipoRelazione), 'note_relazione' => $this->buildRelationNote($row, $tipoRelazione),
]; ];
if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) { if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) {
@ -147,20 +146,20 @@ public function handle(): int
$stats['rel_updated']++; $stats['rel_updated']++;
} else { } else {
DB::table('persone_unita_relazioni')->insert([ DB::table('persone_unita_relazioni')->insert([
'persona_id' => (int) $persona->id, 'persona_id' => (int) $persona->id,
'unita_id' => (int) $row->unita_immobiliare_id, 'unita_id' => (int) $row->unita_immobiliare_id,
'tipo_relazione' => $tipoRelazione, 'tipo_relazione' => $tipoRelazione,
'quota_relazione' => $relationPayload['quota_relazione'], 'quota_relazione' => $relationPayload['quota_relazione'],
'data_inizio' => $relationPayload['data_inizio'], 'data_inizio' => $relationPayload['data_inizio'],
'data_fine' => $relationPayload['data_fine'], 'data_fine' => $relationPayload['data_fine'],
'attivo' => $relationPayload['attivo'], 'attivo' => $relationPayload['attivo'],
'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'], 'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'],
'riceve_convocazioni' => $relationPayload['riceve_convocazioni'], 'riceve_convocazioni' => $relationPayload['riceve_convocazioni'],
'vota_assemblea' => $relationPayload['vota_assemblea'], 'vota_assemblea' => $relationPayload['vota_assemblea'],
'note_relazione' => $relationPayload['note_relazione'], 'note_relazione' => $relationPayload['note_relazione'],
'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null, 'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
$stats['rel_created']++; $stats['rel_created']++;
} }
@ -185,10 +184,10 @@ private function findMatchingRubrica(object $row, string $tipoRelazione): ?Rubri
return null; return null;
} }
$role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino'; $role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino';
$codCond = trim((string) ($row->legacy_cond_id ?? '')); $codCond = trim((string) ($row->legacy_cond_id ?? ''));
$codStabile = trim((string) ($row->codice_stabile ?? '')); $codStabile = trim((string) ($row->codice_stabile ?? ''));
$adminId = (int) ($row->amministratore_id ?? 0); $adminId = (int) ($row->amministratore_id ?? 0);
if ($codCond !== '' && $codStabile !== '') { if ($codCond !== '' && $codStabile !== '') {
$stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')]))); $stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')])));
@ -228,7 +227,7 @@ private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica):
{ {
if ($rubrica) { if ($rubrica) {
$ragione = $this->sanitizeString($rubrica->ragione_sociale, 255); $ragione = $this->sanitizeString($rubrica->ragione_sociale, 255);
$nome = $this->sanitizeString($rubrica->nome, 100); $nome = $this->sanitizeString($rubrica->nome, 100);
$cognome = $this->sanitizeString($rubrica->cognome, 100); $cognome = $this->sanitizeString($rubrica->cognome, 100);
} else { } else {
[$cognome, $nome, $ragione] = $this->splitNomeCognomeOrRagione((string) ($row->nominativo ?? '')); [$cognome, $nome, $ragione] = $this->splitNomeCognomeOrRagione((string) ($row->nominativo ?? ''));
@ -245,23 +244,23 @@ private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica):
} }
$rawFiscal = $rubrica?->codice_fiscale ?: $rubrica?->partita_iva; $rawFiscal = $rubrica?->codice_fiscale ?: $rubrica?->partita_iva;
$fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null); $fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null);
return [ return [
'tipologia' => $ragione ? 'giuridica' : 'fisica', 'tipologia' => $ragione ? 'giuridica' : 'fisica',
'nome' => $nome, 'nome' => $nome,
'cognome' => $cognome, 'cognome' => $cognome,
'ragione_sociale' => $ragione, 'ragione_sociale' => $ragione,
'codice_fiscale' => $fiscal['cf'], 'codice_fiscale' => $fiscal['cf'],
'partita_iva' => $fiscal['piva'], 'partita_iva' => $fiscal['piva'],
'email_principale' => $this->sanitizeEmail($rubrica?->email), 'email_principale' => $this->sanitizeEmail($rubrica?->email),
'email_pec' => $this->sanitizeEmail($rubrica?->pec), 'email_pec' => $this->sanitizeEmail($rubrica?->pec),
'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio), 'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio),
'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio), 'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio),
'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255), 'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255),
'note' => $this->buildPersonaNote($row, $rubrica), 'note' => $this->buildPersonaNote($row, $rubrica),
'attivo' => true, 'attivo' => true,
'name_key' => $this->normalizeNameKey($nome, $cognome), 'name_key' => $this->normalizeNameKey($nome, $cognome),
]; ];
} }
@ -319,19 +318,19 @@ private function resolvePersonaMatch(array $payload): ?object
private function createPersonaFromPayload(array $payload): ?Persona private function createPersonaFromPayload(array $payload): ?Persona
{ {
$data = [ $data = [
'tipologia' => $payload['tipologia'] ?? 'fisica', 'tipologia' => $payload['tipologia'] ?? 'fisica',
'cognome' => $payload['cognome'], 'cognome' => $payload['cognome'],
'nome' => $payload['nome'], 'nome' => $payload['nome'],
'ragione_sociale' => $payload['ragione_sociale'], 'ragione_sociale' => $payload['ragione_sociale'],
'codice_fiscale' => $payload['codice_fiscale'], 'codice_fiscale' => $payload['codice_fiscale'],
'partita_iva' => $payload['partita_iva'], 'partita_iva' => $payload['partita_iva'],
'residenza_via' => $payload['residenza_via'], 'residenza_via' => $payload['residenza_via'],
'telefono_principale' => $payload['telefono_principale'], 'telefono_principale' => $payload['telefono_principale'],
'telefono_secondario' => $payload['telefono_secondario'], 'telefono_secondario' => $payload['telefono_secondario'],
'email_principale' => $payload['email_principale'], 'email_principale' => $payload['email_principale'],
'email_pec' => $payload['email_pec'], 'email_pec' => $payload['email_pec'],
'note' => $payload['note'], 'note' => $payload['note'],
'attivo' => $payload['attivo'] ?? true, 'attivo' => $payload['attivo'] ?? true,
]; ];
if ($data['telefono_principale'] && DB::table('persone')->where('telefono_principale', $data['telefono_principale'])->exists()) { if ($data['telefono_principale'] && DB::table('persone')->where('telefono_principale', $data['telefono_principale'])->exists()) {
@ -559,7 +558,7 @@ private function splitNomeCognomeOrRagione(?string $raw): array
$parts = array_values(array_filter(preg_split('/\s+/', $value) ?: [])); $parts = array_values(array_filter(preg_split('/\s+/', $value) ?: []));
if (count($parts) >= 2) { if (count($parts) >= 2) {
$nome = array_pop($parts); $nome = array_pop($parts);
$cognome = implode(' ', $parts); $cognome = implode(' ', $parts);
return [mb_substr($cognome, 0, 100), mb_substr($nome, 0, 100), null]; return [mb_substr($cognome, 0, 100), mb_substr($nome, 0, 100), null];
@ -635,11 +634,11 @@ private function normalizeRoleCustom(string $value): ?string
$value = trim($value, '_'); $value = trim($value, '_');
return match ($value) { return match ($value) {
'comproprietario', 'co_proprietario' => 'comproprietario', 'comproprietario', 'co_proprietario' => 'comproprietario',
'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario', 'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario',
'usufruttuario', 'usufrutto' => 'usufruttuario', 'usufruttuario', 'usufrutto' => 'usufruttuario',
'titolare' => 'titolare', 'titolare' => 'titolare',
default => null, default => null,
}; };
} }

View File

@ -1,15 +1,13 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Persona; use App\Models\Persona;
use App\Models\PersonaUnitaRelazione; use App\Models\PersonaUnitaRelazione;
use App\Models\RubricaRuolo; use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
class GesconSyncRubricaRuoli extends Command class GesconSyncRubricaRuoli extends Command
{ {
@ -23,17 +21,22 @@ class GesconSyncRubricaRuoli extends Command
public function handle(): int public function handle(): int
{ {
if (!Schema::hasTable('rubrica_universale') || !Schema::hasTable('rubrica_ruoli')) { if (! Schema::hasTable('rubrica_universale') || ! Schema::hasTable('rubrica_ruoli')) {
$this->error('Tabelle rubrica_universale/rubrica_ruoli non disponibili.'); $this->error('Tabelle rubrica_universale/rubrica_ruoli non disponibili.');
return 1; return 1;
} }
$apply = (bool)$this->option('apply'); $apply = (bool) $this->option('apply');
$createMissing = (bool) $this->option('create-missing'); $createMissing = (bool) $this->option('create-missing');
$stabileFilter = $this->option('stabile'); $stabileFilter = $this->option('stabile');
$limit = (int)$this->option('limit'); $limit = (int) $this->option('limit');
if ($limit < 100) $limit = 100; if ($limit < 100) {
if ($limit > 20000) $limit = 20000; $limit = 100;
}
if ($limit > 20000) {
$limit = 20000;
}
$query = PersonaUnitaRelazione::with(['persona', 'unitaImmobiliare.stabile']) $query = PersonaUnitaRelazione::with(['persona', 'unitaImmobiliare.stabile'])
->orderBy('id'); ->orderBy('id');
@ -55,38 +58,38 @@ public function handle(): int
foreach ($chunk as $relazione) { foreach ($chunk as $relazione) {
$processed++; $processed++;
$persona = $relazione->persona; $persona = $relazione->persona;
$unita = $relazione->unitaImmobiliare; $unita = $relazione->unitaImmobiliare;
$stabile = $unita?->stabile; $stabile = $unita?->stabile;
if (!$persona || !$unita || !$stabile) { if (! $persona || ! $unita || ! $stabile) {
$skipped++; $skipped++;
continue; continue;
} }
$rubrica = $this->trovaRubricaPerPersona($persona, $stabile->amministratore_id ?? null, $apply && $createMissing); $rubrica = $this->trovaRubricaPerPersona($persona, $stabile->amministratore_id ?? null, $apply && $createMissing);
if (!$rubrica) { if (! $rubrica) {
$skipped++; $skipped++;
continue; continue;
} }
$ruoloStandard = $this->mapRuolo($relazione->tipo_relazione); $ruoloStandard = $this->mapRuolo($relazione->tipo_relazione);
$match = [ $match = [
'rubrica_id' => $rubrica->id, 'rubrica_id' => $rubrica->id,
'stabile_id' => $stabile->id, 'stabile_id' => $stabile->id,
'unita_immobiliare_id' => $unita->id, 'unita_immobiliare_id' => $unita->id,
'ruolo_standard' => $ruoloStandard, 'ruolo_standard' => $ruoloStandard,
]; ];
$payload = [ $payload = [
'is_attivo' => $relazione->isAttiva(), 'is_attivo' => $relazione->isAttiva(),
'is_preferito' => $relazione->isAttiva(), 'is_preferito' => $relazione->isAttiva(),
'meta' => [ 'meta' => [
'percentuale_possesso' => $relazione->quota_relazione, 'percentuale_possesso' => $relazione->quota_relazione,
'tipo_relazione_orig' => $relazione->tipo_relazione, 'tipo_relazione_orig' => $relazione->tipo_relazione,
'persona_id_orig' => $persona->id, 'persona_id_orig' => $persona->id,
], ],
]; ];
if (!$apply) { if (! $apply) {
RubricaRuolo::where($match)->exists() ? $updated++ : $inserted++; RubricaRuolo::where($match)->exists() ? $updated++ : $inserted++;
continue; continue;
} }
@ -102,7 +105,7 @@ public function handle(): int
protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = null, bool $createIfMissing = false): ?RubricaUniversale protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = null, bool $createIfMissing = false): ?RubricaUniversale
{ {
if (!$this->personaHasRubricaIdentity($persona)) { if (! $this->personaHasRubricaIdentity($persona)) {
return null; return null;
} }
@ -111,13 +114,19 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
$cf = trim((string) ($persona->codice_fiscale ?? '')); $cf = trim((string) ($persona->codice_fiscale ?? ''));
if ($cf) { if ($cf) {
$found = (clone $query)->where('codice_fiscale', $cf)->first(); $found = (clone $query)->where('codice_fiscale', $cf)->first();
if ($found) return $found; if ($found) {
return $found;
}
} }
$piva = trim((string) ($persona->partita_iva ?? '')); $piva = trim((string) ($persona->partita_iva ?? ''));
if ($piva) { if ($piva) {
$found = (clone $query)->where('partita_iva', $piva)->first(); $found = (clone $query)->where('partita_iva', $piva)->first();
if ($found) return $found; if ($found) {
return $found;
}
} }
foreach ($this->collectPersonaEmails($persona) as $email) { foreach ($this->collectPersonaEmails($persona) as $email) {
@ -148,7 +157,7 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
} }
} }
$nome = trim((string) ($persona->nome ?? '')); $nome = trim((string) ($persona->nome ?? ''));
$cognome = trim((string) ($persona->cognome ?? '')); $cognome = trim((string) ($persona->cognome ?? ''));
if ($nome !== '' && $cognome !== '') { if ($nome !== '' && $cognome !== '') {
$found = (clone $query) $found = (clone $query)
@ -160,29 +169,29 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
} }
} }
if (!$createIfMissing) { if (! $createIfMissing) {
return null; return null;
} }
$tipo = $persona->tipologia === 'giuridica' ? 'persona_giuridica' : 'persona_fisica'; $tipo = $persona->tipologia === 'giuridica' ? 'persona_giuridica' : 'persona_fisica';
$now = now(); $now = now();
return RubricaUniversale::query()->create([ return RubricaUniversale::query()->create([
'amministratore_id' => $amministratoreId ? (int) $amministratoreId : null, 'amministratore_id' => $amministratoreId ? (int) $amministratoreId : null,
'nome' => $tipo === 'persona_fisica' ? ($persona->nome ?? null) : null, 'nome' => $tipo === 'persona_fisica' ? ($persona->nome ?? null) : null,
'cognome' => $tipo === 'persona_fisica' ? ($persona->cognome ?? null) : null, 'cognome' => $tipo === 'persona_fisica' ? ($persona->cognome ?? null) : null,
'ragione_sociale' => $tipo === 'persona_giuridica' ? ($persona->ragione_sociale ?? $persona->nome_completo) : null, 'ragione_sociale' => $tipo === 'persona_giuridica' ? ($persona->ragione_sociale ?? $persona->nome_completo) : null,
'tipo_contatto' => $tipo, 'tipo_contatto' => $tipo,
'categoria' => 'condomino', 'categoria' => 'condomino',
'stato' => 'attivo', 'stato' => 'attivo',
'codice_fiscale' => $cf ?: null, 'codice_fiscale' => $cf ?: null,
'partita_iva' => $persona->partita_iva ?: null, 'partita_iva' => $persona->partita_iva ?: null,
'email' => $persona->email_principale ?? null, 'email' => $persona->email_principale ?? null,
'pec' => $persona->email_pec ?? null, 'pec' => $persona->email_pec ?? null,
'telefono_ufficio' => $persona->telefono_principale ?? null, 'telefono_ufficio' => $persona->telefono_principale ?? null,
'telefono_cellulare' => $persona->telefono_secondario ?? null, 'telefono_cellulare' => $persona->telefono_secondario ?? null,
'note' => $persona->note ?? null, 'note' => $persona->note ?? null,
'data_inserimento' => $now, 'data_inserimento' => $now,
'data_ultima_modifica' => $now, 'data_ultima_modifica' => $now,
]); ]);
} }
@ -265,11 +274,11 @@ private function personaHasRubricaIdentity(Persona $persona): bool
protected function mapRuolo(?string $tipo): string protected function mapRuolo(?string $tipo): string
{ {
$tipo = strtolower((string)$tipo); $tipo = strtolower((string) $tipo);
return match ($tipo) { return match ($tipo) {
'inquilino' => 'inquilino', 'inquilino' => 'inquilino',
'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino',
default => 'condomino', default => 'condomino',
}; };
} }
} }

View File

@ -0,0 +1,102 @@
<?php
namespace App\Console\Commands;
use App\Models\Fornitore;
use App\Services\Catalog\FornitoreProductCatalogService;
use Illuminate\Console\Command;
class ImportFornitoreWholesaleCsvCommand extends Command
{
protected $signature = 'fornitore:import-wholesale-csv
{fornitore : ID, P.IVA o ragione sociale del fornitore}
{--csv= : Percorso al CSV listino}
{--limit=0 : Limite righe da importare}
{--dry-run : Simula senza scrivere}';
protected $description = 'Importa un listino CSV fornitore nel catalogo prodotti unificato.';
public function handle(FornitoreProductCatalogService $catalogService): int
{
$fornitore = $this->resolveFornitore((string) $this->argument('fornitore'));
if (! $fornitore instanceof Fornitore) {
$this->error('Fornitore non trovato.');
return self::FAILURE;
}
$csvPath = trim((string) $this->option('csv'));
if ($csvPath === '') {
$this->error('Specificare --csv=/percorso/file.csv');
return self::FAILURE;
}
try {
$stats = $catalogService->importWholesaleCsv(
$fornitore,
$csvPath,
max(0, (int) $this->option('limit')),
(bool) $this->option('dry-run'),
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->table(
['Campo', 'Valore'],
[
['Fornitore', (string) ($fornitore->ragione_sociale ?? ('#' . $fornitore->id))],
['CSV', $csvPath],
['Dry run', (bool) $this->option('dry-run') ? 'si' : 'no'],
['Righe lette', (string) $stats['rows']],
['Prodotti creati', (string) $stats['products']],
['Prodotti aggiornati', (string) $stats['updated']],
['Codici creati', (string) $stats['identifiers']],
['Offerte create/aggiornate', (string) ($stats['offers'] ?? 0)],
['Link sorgente interni', (string) ($stats['private_links'] ?? 0)],
['Media remoti rimossi', (string) ($stats['remote_media_pruned'] ?? 0)],
['Media creati', (string) $stats['media']],
['Righe saltate', (string) $stats['skipped']],
]
);
return self::SUCCESS;
}
private function resolveFornitore(string $value): ?Fornitore
{
$value = trim($value);
if ($value === '') {
return null;
}
$query = Fornitore::query();
if (is_numeric($value)) {
$exact = (clone $query)
->where('id', (int) $value)
->orWhere('partita_iva', $value)
->first();
if ($exact instanceof Fornitore) {
return $exact;
}
}
$exact = (clone $query)
->where('partita_iva', $value)
->orWhere('codice_fiscale', $value)
->first();
if ($exact instanceof Fornitore) {
return $exact;
}
return (clone $query)
->where('ragione_sociale', 'like', '%' . $value . '%')
->orWhere('nome', 'like', '%' . $value . '%')
->orWhere('cognome', 'like', '%' . $value . '%')
->orderBy('ragione_sociale')
->first();
}
}

View File

@ -109,7 +109,7 @@ private function stepRelazioni(?int $limit = null): int
{ {
$params = [ $params = [
'--stabile' => (string) $this->option('stabile'), '--stabile' => (string) $this->option('stabile'),
'--limit' => $limit ?? 10000, '--limit' => $limit ?? 10000,
]; ];
if (! $this->option('dry-run')) { if (! $this->option('dry-run')) {
@ -5114,7 +5114,7 @@ private function buildComproprietariHistoryPayloads(array $historyRows, ?string
$sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey; $sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey;
$sameRight = $snapshotRightKey === $currentRightKey; $sameRight = $snapshotRightKey === $currentRightKey;
$samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null $samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null
|| abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005; || abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005;
if ($sameName && $sameRight && $samePercent) { if ($sameName && $sameRight && $samePercent) {
$current['data_inizio'] = $snapshotStart ?? $current['data_inizio']; $current['data_inizio'] = $snapshotStart ?? $current['data_inizio'];
@ -5150,7 +5150,7 @@ private function upsertUnitaNominativiFromLegacy(int $unitaId, int $stabileId, ?
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'C', $legacyCondId), $this->buildRoleHistoryPayloads($historyRowsCondomin, 'C', $legacyCondId),
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'I', $legacyCondId) $this->buildRoleHistoryPayloads($historyRowsCondomin, 'I', $legacyCondId)
); );
$payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId); $payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId);
DB::table('unita_immobiliare_nominativi') DB::table('unita_immobiliare_nominativi')
->where('unita_immobiliare_id', $unitaId) ->where('unita_immobiliare_id', $unitaId)

View File

@ -23,9 +23,9 @@ public function handle(): int
return self::FAILURE; return self::FAILURE;
} }
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$this->hasLegacyDescrizioneColumn = Schema::hasColumn('fornitori', 'legacy_descrizione'); $this->hasLegacyDescrizioneColumn = Schema::hasColumn('fornitori', 'legacy_descrizione');
$filterIds = collect((array) $this->option('fornitore-id')) $filterIds = collect((array) $this->option('fornitore-id'))
->map(fn($v) => (int) $v) ->map(fn($v) => (int) $v)
->filter(fn(int $v): bool => $v > 0) ->filter(fn(int $v): bool => $v > 0)
->values() ->values()
@ -78,7 +78,7 @@ public function handle(): int
} }
$legacyDescrizione = $this->buildLegacyDescription($legacy); $legacyDescrizione = $this->buildLegacyDescription($legacy);
$incoming = $this->extractTags($legacyDescrizione); $incoming = $this->extractTags($legacyDescrizione);
if (count($incoming) === 0) { if (count($incoming) === 0) {
$skipped++; $skipped++;
continue; continue;
@ -88,8 +88,8 @@ public function handle(): int
$merged = array_values(array_unique(array_merge($current, $incoming))); $merged = array_values(array_unique(array_merge($current, $incoming)));
sort($merged, SORT_NATURAL | SORT_FLAG_CASE); sort($merged, SORT_NATURAL | SORT_FLAG_CASE);
$newTagsValue = implode(', ', $merged); $newTagsValue = implode(', ', $merged);
$changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue $changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue
|| ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione; || ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione;
if (! $changed) { if (! $changed) {
@ -121,37 +121,37 @@ private function resolveLegacySource(): ?array
{ {
$sources = [ $sources = [
[ [
'table' => 'mdb_fornitori', 'table' => 'mdb_fornitori',
'id_column' => 'id_fornitore', 'id_column' => 'id_fornitore',
'code_column' => 'cod_forn', 'code_column' => 'cod_forn',
'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'], 'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'],
'piva_column' => 'p_iva', 'piva_column' => 'p_iva',
'cf_column' => 'cod_fisc', 'cf_column' => 'cod_fisc',
'description_column' => 'descrizione', 'description_column' => 'descrizione',
'notes_column' => 'note', 'notes_column' => 'note',
'text_columns' => ['descrizione', 'note'], 'text_columns' => ['descrizione', 'note'],
], ],
[ [
'table' => 'fornitori', 'table' => 'fornitori',
'id_column' => 'id', 'id_column' => 'id',
'code_column' => 'cod_forn', 'code_column' => 'cod_forn',
'name_columns' => ['ragione_sociale', 'cognome', 'nome'], 'name_columns' => ['ragione_sociale', 'cognome', 'nome'],
'piva_column' => 'partita_iva', 'piva_column' => 'partita_iva',
'cf_column' => 'codice_fiscale', 'cf_column' => 'codice_fiscale',
'description_column' => 'descrizione', 'description_column' => 'descrizione',
'notes_column' => 'note', 'notes_column' => 'note',
'text_columns' => ['descrizione', 'note'], 'text_columns' => ['descrizione', 'note'],
], ],
[ [
'table' => 'fornitori_gescon', 'table' => 'fornitori_gescon',
'id_column' => 'id', 'id_column' => 'id',
'code_column' => 'cod_forn', 'code_column' => 'cod_forn',
'name_columns' => ['ragione_sociale'], 'name_columns' => ['ragione_sociale'],
'piva_column' => 'partita_iva', 'piva_column' => 'partita_iva',
'cf_column' => 'codice_fiscale', 'cf_column' => 'codice_fiscale',
'description_column' => 'descr', 'description_column' => 'descr',
'notes_column' => 'note', 'notes_column' => 'note',
'text_columns' => ['descr', 'note'], 'text_columns' => ['descr', 'note'],
], ],
]; ];
@ -190,7 +190,7 @@ private function resolveLegacySource(): ?array
continue; continue;
} }
$source['text_columns'] = $availableTextColumns; $source['text_columns'] = $availableTextColumns;
$source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true) $source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true)
? $source['description_column'] ? $source['description_column']
: null; : null;
@ -332,51 +332,51 @@ private function extractKeywordTags(string $input): array
} }
$keywords = [ $keywords = [
'idr' => 'idraulico', 'idr' => 'idraulico',
'termoidraul' => 'termoidraulico', 'termoidraul' => 'termoidraulico',
'elettric' => 'elettricista', 'elettric' => 'elettricista',
'energia elet' => 'energia elettrica', 'energia elet' => 'energia elettrica',
'ascens' => 'ascensorista', 'ascens' => 'ascensorista',
'puliz' => 'pulizie', 'puliz' => 'pulizie',
'giardin' => 'giardiniere', 'giardin' => 'giardiniere',
'spurgh' => 'spurgo', 'spurgh' => 'spurgo',
'edil' => 'edile', 'edil' => 'edile',
'murator' => 'edile', 'murator' => 'edile',
'fabbro' => 'fabbro', 'fabbro' => 'fabbro',
'ferrament' => 'ferramenta', 'ferrament' => 'ferramenta',
'serrament' => 'serramenti', 'serrament' => 'serramenti',
'portier' => 'portierato', 'portier' => 'portierato',
'citofon' => 'citofonia', 'citofon' => 'citofonia',
'videosorv' => 'videosorveglianza', 'videosorv' => 'videosorveglianza',
'sicurezz' => 'sicurezza', 'sicurezz' => 'sicurezza',
'antincend' => 'antincendio', 'antincend' => 'antincendio',
'estintor' => 'antincendio', 'estintor' => 'antincendio',
'calda' => 'caldaia', 'calda' => 'caldaia',
'riscald' => 'riscaldamento', 'riscald' => 'riscaldamento',
'condizion' => 'condizionamento', 'condizion' => 'condizionamento',
'gas' => 'gas', 'gas' => 'gas',
'acqua' => 'acqua', 'acqua' => 'acqua',
'fogna' => 'fognatura', 'fogna' => 'fognatura',
'antenna' => 'antenna', 'antenna' => 'antenna',
'telecom' => 'telefonia', 'telecom' => 'telefonia',
'pec' => 'pec', 'pec' => 'pec',
'aruba' => 'pec', 'aruba' => 'pec',
'posta' => 'postali', 'posta' => 'postali',
'raccomand' => 'postali', 'raccomand' => 'postali',
'spediz' => 'spedizioni', 'spediz' => 'spedizioni',
'infest' => 'disinfestazione', 'infest' => 'disinfestazione',
'amminist' => 'amministrazione', 'amminist' => 'amministrazione',
'assicur' => 'assicurazione', 'assicur' => 'assicurazione',
'avvocat' => 'legale', 'avvocat' => 'legale',
'legale' => 'legale', 'legale' => 'legale',
'commercial' => 'commercialista', 'commercial' => 'commercialista',
'geometr' => 'geometra', 'geometr' => 'geometra',
'privacy' => 'privacy', 'privacy' => 'privacy',
'timbri' => 'timbri', 'timbri' => 'timbri',
'targhe' => 'targhe', 'targhe' => 'targhe',
'paghe' => 'paghe contributi', 'paghe' => 'paghe contributi',
'contribut' => 'paghe contributi', 'contribut' => 'paghe contributi',
'utenz' => 'utenze', 'utenz' => 'utenze',
]; ];
$tags = []; $tags = [];
@ -409,18 +409,18 @@ private function canonicalizeTag(string $raw): ?string
} }
$map = [ $map = [
'idr' => 'idraulico', 'idr' => 'idraulico',
'idraul' => 'idraulico', 'idraul' => 'idraulico',
'elett' => 'elettricista', 'elett' => 'elettricista',
'elettric' => 'elettricista', 'elettric' => 'elettricista',
'ascens' => 'ascensorista', 'ascens' => 'ascensorista',
'puliz' => 'pulizie', 'puliz' => 'pulizie',
'giardin' => 'giardiniere', 'giardin' => 'giardiniere',
'assicur' => 'assicurazione', 'assicur' => 'assicurazione',
'manut' => 'manutenzione', 'manut' => 'manutenzione',
'murator' => 'edile', 'murator' => 'edile',
'privacy' => 'privacy', 'privacy' => 'privacy',
'ferrament'=> 'ferramenta', 'ferrament' => 'ferramenta',
]; ];
foreach ($map as $needle => $canonical) { foreach ($map as $needle => $canonical) {

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Amministratore; use App\Models\Amministratore;
@ -10,15 +9,18 @@ class NetgesconApplyPbxPresetCommand extends Command
protected $signature = 'netgescon:pbx-apply-day0-preset protected $signature = 'netgescon:pbx-apply-day0-preset
{admin : ID o codice amministratore} {admin : ID o codice amministratore}
{--apply : Salva il preset sull\'amministratore} {--apply : Salva il preset sull\'amministratore}
{--main-line=0001 : Linea principale studio}
{--main-number=0639731100 : Numero esterno linea principale}
{--day-group=601 : Gruppo risposta giorno} {--day-group=601 : Gruppo risposta giorno}
{--night-group=603 : Gruppo risposta notte} {--night-group=603 : Gruppo risposta notte}
{--day-fallback=201 : Interno fallback giorno} {--day-fallback=201 : Interno fallback giorno}
{--night-fallback=206 : Interno fallback notte} {--night-fallback=206 : Interno fallback notte}
{--admin-line=0003 : Linea/DID amministratore} {--admin-line=0003 : Linea/DID amministratore}
{--admin-number=0688812703 : Numero esterno linea amministratore}
{--admin-target= : Interno target amministratore, se diverso dall\'attuale} {--admin-target= : Interno target amministratore, se diverso dall\'attuale}
{--watch=201,206,601,603,0003 : Interni/linee monitorati CSV}'; {--watch=0001,0003,201,206,601,603 : Interni/linee monitorati CSV}';
protected $description = 'Applica o mostra il preset PBX Day0 con gruppi 601/603, fallback 201/206 e linea amministratore 0003.'; protected $description = 'Applica o mostra il preset PBX Day0 con linee 0001/0003, gruppi 601/603 e fallback 201/206.';
public function handle(): int public function handle(): int
{ {
@ -37,7 +39,10 @@ public function handle(): int
$nightGroup = $this->digits((string) $this->option('night-group')); $nightGroup = $this->digits((string) $this->option('night-group'));
$dayFallback = $this->digits((string) $this->option('day-fallback')); $dayFallback = $this->digits((string) $this->option('day-fallback'));
$nightFallback = $this->digits((string) $this->option('night-fallback')); $nightFallback = $this->digits((string) $this->option('night-fallback'));
$mainLine = $this->digits((string) $this->option('main-line'));
$mainNumber = $this->digits((string) $this->option('main-number'));
$adminLine = $this->digits((string) $this->option('admin-line')); $adminLine = $this->digits((string) $this->option('admin-line'));
$adminNumber = $this->digits((string) $this->option('admin-number'));
$adminTarget = $this->digits((string) $this->option('admin-target')); $adminTarget = $this->digits((string) $this->option('admin-target'));
if ($adminTarget === '') { if ($adminTarget === '') {
@ -48,6 +53,13 @@ public function handle(): int
$centralino['interno_amministratore'] = $adminTarget; $centralino['interno_amministratore'] = $adminTarget;
} }
if ($mainNumber !== '') {
$centralino['numero_principale'] = $mainNumber;
}
if ($adminNumber !== '') {
$centralino['numero_backup'] = $adminNumber;
}
$centralino['interno_gruppo_giorno'] = $dayGroup; $centralino['interno_gruppo_giorno'] = $dayGroup;
$centralino['interno_gruppo_notte'] = $nightGroup; $centralino['interno_gruppo_notte'] = $nightGroup;
@ -57,8 +69,8 @@ public function handle(): int
$watch = preg_split('/\s*,\s*/', (string) $this->option('watch'), -1, PREG_SPLIT_NO_EMPTY) ?: []; $watch = preg_split('/\s*,\s*/', (string) $this->option('watch'), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$watch = array_values(array_unique(array_filter(array_map( $watch = array_values(array_unique(array_filter(array_map(
fn (string $value): string => $this->digits($value), fn(string $value): string => $this->digits($value),
array_merge($watch, [$dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget]) array_merge($watch, [$mainLine, $dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget])
)))); ))));
$pbx['watch_extensions'] = implode(',', $watch); $pbx['watch_extensions'] = implode(',', $watch);
@ -75,12 +87,23 @@ public function handle(): int
'mode' => 'notte', 'mode' => 'notte',
'target_extension' => $nightFallback, 'target_extension' => $nightFallback,
], ],
], fn (array $row): bool => ($row['extension'] ?? '') !== '')); ], fn(array $row): bool => ($row['extension'] ?? '') !== ''));
$incomingLines = []; $incomingLines = [];
if ($mainLine !== '') {
$incomingLines[] = [
'number' => $mainLine,
'external_number' => $mainNumber,
'label' => 'Linea principale studio',
'route_group' => $dayGroup,
'target_extension' => $dayFallback,
'notes' => 'Linea esterna 0001 del centralino con instradamento gruppi 601/603 e fallback operativo.',
];
}
if ($adminLine !== '') { if ($adminLine !== '') {
$incomingLines[] = [ $incomingLines[] = [
'number' => $adminLine, 'number' => $adminLine,
'external_number' => $adminNumber,
'label' => 'Linea amministratore', 'label' => 'Linea amministratore',
'route_group' => '', 'route_group' => '',
'target_extension' => $adminTarget, 'target_extension' => $adminTarget,
@ -122,7 +145,7 @@ private function resolveAdmin(string $value): ?Amministratore
} }
return Amministratore::query() return Amministratore::query()
->when(is_numeric($value), fn ($query) => $query->orWhere('id', (int) $value)) ->when(is_numeric($value), fn($query) => $query->orWhere('id', (int) $value))
->orWhere('codice_amministratore', $value) ->orWhere('codice_amministratore', $value)
->orWhere('codice_univoco', $value) ->orWhere('codice_univoco', $value)
->first(); ->first();

View File

@ -0,0 +1,332 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use App\Models\AssistenzaTecnorepairAllegato;
use App\Models\AssistenzaTecnorepairScheda;
use App\Models\Fornitore;
use App\Models\ProductSerial;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Support\TecnoRepairMdbReader;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class TecnoRepairImportLegacyArchiveCommand extends Command
{
protected $signature = 'tecnorepair:import-legacy
{amministratore : ID o codice amministratore}
{--mdb=/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb : Percorso archivio MDB TecnoRepair}
{--fornitore-id= : ID fornitore da collegare alle schede}
{--fornitore-term=NETHOME : Ragione sociale o frammento per trovare il fornitore di riferimento}
{--limit=0 : Limita le schede importate}
{--dry-run : Esegue solo validazione e riepilogo senza scrivere}
{--force-primary : Marca il fornitore collegato come centro TecnoRepair principale}';
protected $description = 'Importa in staging l\'archivio TecnoRepair MDB e lo collega al fornitore operativo, di default NETHOME.';
public function handle(TecnoRepairMdbReader $reader, FornitoreProductCatalogService $catalogService): int
{
$admin = $this->resolveAdmin((string) $this->argument('amministratore'));
if (! $admin instanceof Amministratore) {
$this->error('Amministratore non trovato.');
return self::FAILURE;
}
$mdbPath = realpath((string) $this->option('mdb')) ?: (string) $this->option('mdb');
if ($mdbPath === '' || ! is_file($mdbPath)) {
$this->error('Archivio MDB non trovato: ' . $mdbPath);
return self::FAILURE;
}
$fornitore = $this->resolveFornitore($admin, (string) $this->option('fornitore-id'), (string) $this->option('fornitore-term'));
$limit = max(0, (int) $this->option('limit'));
$dryRun = (bool) $this->option('dry-run');
$forcePrimary = (bool) $this->option('force-primary');
try {
$tables = $reader->listTables($mdbPath);
} catch (\Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
foreach (['TClienti', 'TApparecchi', 'TAllegati'] as $requiredTable) {
if (! in_array($requiredTable, $tables, true)) {
$this->error('Tabella MDB mancante: ' . $requiredTable);
return self::FAILURE;
}
}
try {
$clientiRows = $reader->exportTable($mdbPath, 'TClienti');
$schedeRows = $reader->exportTable($mdbPath, 'TApparecchi');
$allegatiRows = $reader->exportTable($mdbPath, 'TAllegati');
} catch (\Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
if ($limit > 0) {
$schedeRows = array_slice($schedeRows, 0, $limit);
}
$clienti = [];
foreach ($clientiRows as $clienteRow) {
$clienteId = $this->toInt($clienteRow['ID'] ?? null);
if ($clienteId !== null) {
$clienti[$clienteId] = $clienteRow;
}
}
$allegatiByScheda = [];
foreach ($allegatiRows as $allegatoRow) {
$schedaId = $this->toInt($allegatoRow['ID_Scheda'] ?? null);
if ($schedaId === null) {
continue;
}
$allegatiByScheda[$schedaId][] = $allegatoRow;
}
$stats = [
'schede_lette' => count($schedeRows),
'schede_create' => 0,
'schede_aggiornate' => 0,
'schede_saltate' => 0,
'allegati_importati' => 0,
'seriali_allineati' => 0,
];
$runner = function () use ($admin, $mdbPath, $fornitore, $dryRun, $forcePrimary, $schedeRows, $clienti, $allegatiByScheda, $catalogService, &$stats): void {
if ($fornitore instanceof Fornitore && $forcePrimary && ! $dryRun) {
$fornitore->forceFill(['is_tecnorepair_primary' => true])->save();
}
foreach ($schedeRows as $row) {
$legacyId = $this->toInt($row['ID'] ?? null);
if ($legacyId === null) {
$stats['schede_saltate']++;
continue;
}
$legacyClienteId = $this->toInt($row['ID_Cliente'] ?? null);
$cliente = $legacyClienteId !== null ? ($clienti[$legacyClienteId] ?? []) : [];
$statusLabel = $this->clean($row['StatoRiparazione'] ?? null) ?: $this->clean($row['ID_StatoRip'] ?? null);
$payload = [
'amministratore_id' => (int) $admin->id,
'fornitore_id' => $fornitore?->id,
'legacy_id' => $legacyId,
'legacy_cliente_id' => $legacyClienteId,
'legacy_centro_ass_id' => $this->toInt($row['ID_CentroAss'] ?? null),
'legacy_committente_codice' => $this->clean($row['Cod_Committente'] ?? null),
'legacy_numero_scheda' => $this->clean($row['NumeroScheda'] ?? null),
'customer_name' => $this->clean($cliente['NomeCognome'] ?? null),
'customer_phone' => $this->clean($cliente['NumeroTelefono'] ?? null),
'customer_phone_alt' => $this->clean($cliente['TelFisso'] ?? null),
'customer_email' => $this->clean($cliente['Email'] ?? null),
'product_model' => $this->clean($row['Modello'] ?? null),
'product_code' => $this->clean($row['CodiceProdotto'] ?? null),
'serial_number' => $this->clean($row['SerialNumber'] ?? null),
'serial_number_2' => $this->clean($row['SerialNumber2'] ?? null),
'status_code' => $this->clean($row['ID_StatoRip'] ?? null),
'status_label' => $statusLabel,
'status_bucket' => AssistenzaTecnorepairScheda::normalizeStatusBucket($statusLabel),
'defect_reported' => $this->clean($row['DifettoSegnalato'] ?? null),
'repair_description' => $this->clean($row['DescrizioneRiparazione'] ?? null),
'communications' => $this->clean($row['Comunicazioni'] ?? null),
'operator_name' => $this->clean($row['NomeOperatore'] ?? null),
'technician_name' => $this->clean($row['NomeTecnicoRiparatore'] ?? null),
'date_received' => $this->normalizeDate($row['DataIngresso'] ?? null),
'ordered_at' => $this->normalizeDate($row['DataOrdine'] ?? null),
'order_number' => $this->clean($row['NumOrdine'] ?? null),
'rma_code' => $this->clean($row['CodiceRMA'] ?? null),
'pin_code' => $this->clean($row['CodicePIN'] ?? null),
'unlock_code' => $this->clean($row['CodiceSblocco'] ?? null),
'legacy_attachment_path' => $this->clean($row['FileAllegato1'] ?? null),
'imported_from_path' => $mdbPath,
'imported_at' => now(),
'metadata' => [
'cliente' => $cliente,
'raw' => $row,
],
];
$existing = AssistenzaTecnorepairScheda::query()
->where('imported_from_path', $mdbPath)
->where('legacy_id', $legacyId)
->first();
if ($dryRun) {
if ($existing) {
$stats['schede_aggiornate']++;
} else {
$stats['schede_create']++;
}
continue;
}
$scheda = AssistenzaTecnorepairScheda::query()->updateOrCreate(
[
'imported_from_path' => $mdbPath,
'legacy_id' => $legacyId,
],
$payload,
);
if ($existing) {
$stats['schede_aggiornate']++;
} else {
$stats['schede_create']++;
}
$serial = ProductSerial::query()->updateOrCreate(
['legacy_scheda_id' => (int) $scheda->id],
[
'fornitore_id' => $fornitore?->id,
'customer_name' => $scheda->customer_name,
'product_model' => $scheda->product_model,
'product_code' => $scheda->product_code,
'serial_number' => $scheda->serial_number,
'serial_number_2' => $scheda->serial_number_2,
'date_received' => $scheda->date_received,
'internal_notes' => $scheda->communications,
'source' => 'tecnorepair_mdb',
'source_reference' => (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id),
]
);
$stats['seriali_allineati']++;
if ($fornitore instanceof Fornitore) {
$catalogService->syncTecnorepairProduct($fornitore, $scheda, $serial);
}
AssistenzaTecnorepairAllegato::query()->where('scheda_id', (int) $scheda->id)->delete();
foreach ($allegatiByScheda[$legacyId] ?? [] as $allegatoRow) {
AssistenzaTecnorepairAllegato::query()->create([
'scheda_id' => (int) $scheda->id,
'legacy_id' => $this->toInt($allegatoRow['ID'] ?? null),
'legacy_scheda_id' => $legacyId,
'legacy_attachment_number' => $this->toInt($allegatoRow['NumAllegato'] ?? null),
'file_name' => basename((string) ($allegatoRow['FileAllegato'] ?? 'allegato')),
'file_path' => $this->clean($allegatoRow['FileAllegato'] ?? null),
'imported_from_path' => $mdbPath,
'metadata' => ['raw' => $allegatoRow],
]);
$stats['allegati_importati']++;
}
}
};
if ($dryRun) {
$runner();
} else {
DB::transaction($runner);
}
$this->info('Import TecnoRepair completato.');
$this->table(
['Campo', 'Valore'],
[
['Amministratore', (string) ($admin->denominazione_studio ?: ($admin->nome . ' ' . $admin->cognome))],
['Archivio MDB', $mdbPath],
['Fornitore collegato', $fornitore?->ragione_sociale ?: 'nessuno'],
['Dry run', $dryRun ? 'si' : 'no'],
['Schede lette', (string) $stats['schede_lette']],
['Schede create', (string) $stats['schede_create']],
['Schede aggiornate', (string) $stats['schede_aggiornate']],
['Schede saltate', (string) $stats['schede_saltate']],
['Allegati importati', (string) $stats['allegati_importati']],
['Seriali allineati', (string) $stats['seriali_allineati']],
]
);
return self::SUCCESS;
}
private function resolveAdmin(string $value): ?Amministratore
{
$value = trim($value);
if ($value === '') {
return null;
}
return Amministratore::query()
->when(is_numeric($value), fn($query) => $query->orWhere('id', (int) $value))
->orWhere('codice_amministratore', $value)
->orWhere('codice_univoco', $value)
->first();
}
private function resolveFornitore(Amministratore $admin, string $fornitoreIdOption, string $fornitoreTermOption): ?Fornitore
{
$fornitoreId = (int) $fornitoreIdOption;
if ($fornitoreId > 0) {
return Fornitore::query()
->where('amministratore_id', (int) $admin->id)
->whereKey($fornitoreId)
->first();
}
$term = trim($fornitoreTermOption);
if ($term === '') {
return null;
}
return Fornitore::query()
->where('amministratore_id', (int) $admin->id)
->where(function ($query) use ($term): void {
$query->where('ragione_sociale', 'like', '%' . $term . '%')
->orWhere('nome', 'like', '%' . $term . '%')
->orWhere('cognome', 'like', '%' . $term . '%')
->orWhere('tags', 'like', '%' . $term . '%');
})
->orderByDesc('is_tecnorepair_primary')
->orderBy('ragione_sociale')
->first();
}
private function clean(mixed $value): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function toInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (! is_numeric($value)) {
return null;
}
return (int) $value;
}
private function normalizeDate(mixed $value): ?string
{
$value = $this->clean($value);
if ($value === null) {
return null;
}
try {
return Carbon::parse($value)->toDateTimeString();
} catch (\Throwable) {
return null;
}
}
}

View File

@ -2,6 +2,10 @@
namespace App\Filament\Auth; namespace App\Filament\Auth;
use App\Filament\Pages\Fornitore\TicketOperativi; use App\Filament\Pages\Fornitore\TicketOperativi;
use App\Filament\Pages\Gescon\FornitoreScheda;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\User;
use Filament\Auth\Http\Responses\Contracts\LoginResponse; use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin; use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -18,13 +22,46 @@ protected function getRedirectUrl(): string
{ {
$user = Auth::user(); $user = Auth::user();
if ($user instanceof \App\Models\User && $user->hasRole('fornitore')) { if ($user instanceof User && $user->hasRole('fornitore')) {
return TicketOperativi::getUrl(panel: 'admin-filament'); return $this->resolveSupplierRedirectUrl($user);
} }
return '/admin-filament'; return '/admin-filament';
} }
private function resolveSupplierRedirectUrl(User $user): string
{
$email = mb_strtolower(trim((string) $user->email));
if ($email !== '') {
$fornitore = Fornitore::query()
->whereRaw('LOWER(email) = ?', [$email])
->orderByDesc('id')
->first();
if ($fornitore instanceof Fornitore) {
return FornitoreScheda::getUrl(['record' => (int) $fornitore->id], panel: 'admin-filament');
}
}
$dipendente = FornitoreDipendente::query()
->where('attivo', true)
->where(function ($query) use ($user, $email): void {
$query->where('user_id', (int) $user->id);
if ($email !== '') {
$query->orWhereRaw('LOWER(email) = ?', [$email]);
}
})
->orderByDesc('id')
->first();
if ($dipendente instanceof FornitoreDipendente) {
return FornitoreScheda::getUrl(['record' => (int) $dipendente->fornitore_id], panel: 'admin-filament');
}
return TicketOperativi::getUrl(panel: 'admin-filament');
}
public function authenticate(): ?LoginResponse public function authenticate(): ?LoginResponse
{ {
$response = parent::authenticate(); $response = parent::authenticate();

View File

@ -1094,14 +1094,29 @@ private function needsCompleta(): bool
private function isFatturaCompatibleWithCurrentStabileCf(): bool private function isFatturaCompatibleWithCurrentStabileCf(): bool
{ {
$stabileCf = $this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale); $candidateIds = array_filter([
$destCf = $this->normalizeMatchValue($this->fattura->destinatario_cf); $this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale),
$this->normalizeMatchValue($this->fattura->stabile?->cod_fisc_amministratore),
$this->normalizeMatchValue($this->fattura->stabile?->amministratore?->codice_fiscale_studio),
$this->normalizeMatchValue($this->fattura->stabile?->amministratore?->partita_iva),
]);
if ($stabileCf === '' || $destCf === '') { $destIds = array_filter([
$this->normalizeMatchValue($this->fattura->destinatario_cf),
$this->normalizeMatchValue($this->fattura->destinatario_piva ?? null),
]);
if ($candidateIds === [] || $destIds === []) {
return false; return false;
} }
return $stabileCf === $destCf; foreach ($destIds as $destId) {
if (in_array($destId, $candidateIds, true)) {
return true;
}
}
return false;
} }
private function normalizeMatchValue(?string $value): string private function normalizeMatchValue(?string $value): string

View File

@ -16,6 +16,7 @@
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
@ -44,6 +45,27 @@ class FattureElettronicheP7mRicevute extends Page implements HasTable
public array $quarterDownloads = []; public array $quarterDownloads = [];
public function getActiveStabileLabelProperty(): string
{
$user = Auth::user();
if (! $user instanceof User) {
return 'Stabile non selezionato';
}
$stabile = StabileContext::getActiveStabile($user);
if (! $stabile instanceof Stabile) {
return 'Stabile non selezionato';
}
$parts = array_values(array_filter([
trim((string) ($stabile->cod_stabile ?? '')),
trim((string) ($stabile->codice_stabile ?? '')),
trim((string) ($stabile->denominazione ?? '')),
]));
return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile->id);
}
private function safeUtf8(string $value): string private function safeUtf8(string $value): string
{ {
$value = preg_replace('/^\xEF\xBB\xBF/', '', $value) ?? $value; $value = preg_replace('/^\xEF\xBB\xBF/', '', $value) ?? $value;
@ -578,6 +600,10 @@ protected function getHeaderActions(): array
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin', 'amministratore']); return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin', 'amministratore']);
}) })
->form([ ->form([
Placeholder::make('stabile_attivo_info')
->label('Stabile di destinazione')
->content(fn(): string => $this->getActiveStabileLabelProperty()),
Select::make('anno') Select::make('anno')
->label('Anno') ->label('Anno')
->options(function (): array { ->options(function (): array {
@ -1368,8 +1394,9 @@ private function inboxBaseForStabile(int $stabileId, string $ym): string
private function resolveStabileIdFromParsed(array $parsed): ?int private function resolveStabileIdFromParsed(array $parsed): ?int
{ {
$cf = $parsed['destinatario_cf'] ?? null; $cf = isset($parsed['destinatario_cf']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_cf']))) : null;
$codice = $parsed['codice_destinatario'] ?? null; $piva = isset($parsed['destinatario_piva']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_piva']))) : null;
$codice = isset($parsed['codice_destinatario']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['codice_destinatario']))) : null;
$pec = $parsed['pec_destinatario'] ?? null; $pec = $parsed['pec_destinatario'] ?? null;
if ($cf) { if ($cf) {
@ -1408,6 +1435,18 @@ private function resolveStabileIdFromParsed(array $parsed): ?int
} }
} }
if ($piva) {
$match = Stabile::query()
->select('stabili.id')
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
->where('stabili.attivo', true)
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$piva])
->get();
if ($match->count() === 1) {
return (int) $match->first()->id;
}
}
return null; return null;
} }
@ -1455,6 +1494,35 @@ public function table(Table $table): Table
->defaultPaginationPageOption(50) ->defaultPaginationPageOption(50)
->defaultSort('data_fattura', 'desc') ->defaultSort('data_fattura', 'desc')
->filters([ ->filters([
SelectFilter::make('anno')
->label('Anno fattura')
->options(function (): array {
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
if (! $activeStabileId) {
return [];
}
return FatturaElettronica::query()
->where('stabile_id', $activeStabileId)
->whereNotNull('data_fattura')
->selectRaw('YEAR(data_fattura) as anno')
->distinct()
->orderByDesc('anno')
->pluck('anno', 'anno')
->mapWithKeys(fn($anno) => [(string) $anno => (string) $anno])
->all();
})
->query(function (Builder $query, array $data): Builder {
$anno = is_numeric($data['value'] ?? null) ? (int) $data['value'] : 0;
return $anno > 0 ? $query->whereYear('data_fattura', $anno) : $query;
}),
SelectFilter::make('ordine') SelectFilter::make('ordine')
->label('Ordine') ->label('Ordine')
->options([ ->options([
@ -1514,7 +1582,8 @@ public function table(Table $table): Table
->wrap(), ->wrap(),
TextColumn::make('destinatario_cf') TextColumn::make('destinatario_cf')
->label('CF destinatario') ->label('Identificativo destinatario')
->formatStateUsing(fn(FatturaElettronica $record): string => trim((string) ($record->destinatario_cf ?: $record->destinatario_piva ?: '—')))
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->wrap(), ->wrap(),
]) ])

View File

@ -1,11 +1,13 @@
<?php <?php
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Documento; use App\Models\Documento;
use App\Models\FatturaElettronica; use App\Models\FatturaElettronica;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente; use App\Models\FornitoreDipendente;
use App\Models\FornitoreStabileImpostazione; use App\Models\FornitoreStabileImpostazione;
use App\Models\Product;
use App\Models\RegistroRitenuteAcconto; use App\Models\RegistroRitenuteAcconto;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Models\Stabile; use App\Models\Stabile;
@ -15,6 +17,9 @@
use App\Models\VoceSpesa; use App\Models\VoceSpesa;
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore; use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
use App\Modules\Contabilita\Models\PianoConti; use App\Modules\Contabilita\Models\PianoConti;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Services\Catalog\ProductAssetIngestionService;
use App\Services\Catalog\ProductOfferService;
use App\Services\Consumi\AcquaPdfTextParser; use App\Services\Consumi\AcquaPdfTextParser;
use App\Services\Consumi\ConsumiAcquaIngestionService; use App\Services\Consumi\ConsumiAcquaIngestionService;
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService; use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
@ -37,6 +42,8 @@
class FornitoreScheda extends Page class FornitoreScheda extends Page
{ {
use ResolvesOperatoreContext;
protected static ?string $title = 'Scheda fornitore'; protected static ?string $title = 'Scheda fornitore';
protected static ?string $slug = 'anagrafica/fornitori/{record}'; protected static ?string $slug = 'anagrafica/fornitori/{record}';
@ -65,6 +72,12 @@ class FornitoreScheda extends Page
/** @var array<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
public array $raVersateRows = []; public array $raVersateRows = [];
/** @var array<int, array<string, mixed>> */
public array $catalogoProdottiRows = [];
/** @var array<int, array<string, mixed>> */
public array $tecnorepairRows = [];
public string $tagsInput = ''; public string $tagsInput = '';
/** @var array<int, string> */ /** @var array<int, string> */
@ -105,59 +118,64 @@ public function mount(int | string $record): void
abort(403); abort(403);
} }
if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) { if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore'])) {
abort(403); abort(403);
} }
$this->fornitore = Fornitore::query()->with(['rubrica', 'amministratore'])->findOrFail((int) $record); if ($user->hasRole('fornitore') && ! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
[$resolvedFornitore] = $this->resolveOperatoreContext((int) $record);
$this->fornitore = $resolvedFornitore->loadMissing(['rubrica', 'amministratore']);
} else {
$this->fornitore = Fornitore::query()->with(['rubrica', 'amministratore'])->findOrFail((int) $record);
// Tenant guard: impedisce accesso cross-amministratore via URL. // Tenant guard: impedisce accesso cross-amministratore via URL.
if (! $user->hasAnyRole(['super-admin', 'admin'])) { if (! $user->hasAnyRole(['super-admin', 'admin'])) {
$allowedAdminIds = []; $allowedAdminIds = [];
$activeStabile = StabileContext::getActiveStabile($user); $activeStabile = StabileContext::getActiveStabile($user);
$activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0); $activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0);
if ($activeAdminId > 0) { if ($activeAdminId > 0) {
$allowedAdminIds[] = $activeAdminId; $allowedAdminIds[] = $activeAdminId;
} }
$userAdminId = (int) ($user->amministratore?->id ?: 0); $userAdminId = (int) ($user->amministratore?->id ?: 0);
if ($userAdminId > 0) { if ($userAdminId > 0) {
$allowedAdminIds[] = $userAdminId; $allowedAdminIds[] = $userAdminId;
} }
$ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0); $ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0);
if ($ownerAdminId > 0) { if ($ownerAdminId > 0) {
$allowedAdminIds[] = $ownerAdminId; $allowedAdminIds[] = $ownerAdminId;
} }
// Collaboratori: includi anche gli amministratori degli stabili assegnati. // Collaboratori: includi anche gli amministratori degli stabili assegnati.
try { try {
$assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all(); $assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all();
foreach ($assigned as $aid) { foreach ($assigned as $aid) {
$aid = (int) $aid; $aid = (int) $aid;
if ($aid > 0) { if ($aid > 0) {
$allowedAdminIds[] = $aid; $allowedAdminIds[] = $aid;
}
} }
} catch (\Throwable) {
// ignore
} }
} catch (\Throwable) {
// ignore
}
$allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0))); $allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0)));
$fornitoreAdminIds = array_values(array_unique(array_filter([ $fornitoreAdminIds = array_values(array_unique(array_filter([
(int) ($this->fornitore->amministratore_id ?? 0), (int) ($this->fornitore->amministratore_id ?? 0),
(int) ($this->fornitore->rubrica?->amministratore_id ?? 0), (int) ($this->fornitore->rubrica?->amministratore_id ?? 0),
], fn($v) => (int) $v > 0))); ], fn($v) => (int) $v > 0)));
if ($fornitoreAdminIds === []) { if ($fornitoreAdminIds === []) {
if (! $user->hasAnyRole(['admin', 'amministratore'])) { if (! $user->hasAnyRole(['admin', 'amministratore'])) {
abort(404); abort(404);
} }
} else { } else {
$hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0; $hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0;
if (! $hasIntersection) { if (! $hasIntersection) {
abort(404); abort(404);
}
} }
} }
} }
@ -166,6 +184,7 @@ public function mount(int | string $record): void
$this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->tagsInput = (string) ($this->fornitore->tags ?? '');
$this->loadTagSuggestions(); $this->loadTagSuggestions();
$this->refreshDipendentiRows(); $this->refreshDipendentiRows();
$this->refreshCatalogRows();
} }
public function creaDipendenteFornitore(): void public function creaDipendenteFornitore(): void
@ -448,6 +467,138 @@ private function refreshDipendentiRows(): void
->all(); ->all();
} }
private function refreshCatalogRows(): void
{
$this->catalogoProdottiRows = [];
$this->tecnorepairRows = [];
if (Schema::hasTable('products')) {
$query = Product::query()
->withCount(['identifiers', 'serials', 'media'])
->where('default_fornitore_id', (int) $this->fornitore->id)
->orderBy('name')
->limit(20);
if (Schema::hasTable('product_offers')) {
$query->with(['offers' => function ($offerQuery): void {
$offerQuery->where('is_active', true)->orderBy('price_amount');
}]);
}
$this->catalogoProdottiRows = $query
->get(['id', 'internal_code', 'name', 'brand', 'model', 'color_label', 'track_serials', 'meta'])
->map(function (Product $product): array {
$offers = Schema::hasTable('product_offers') ? ($product->offers ?? collect()) : collect();
$internalOffer = $offers->first(fn($offer) => (bool) ($offer->is_internal ?? false));
$bestCompetitor = $offers
->filter(fn($offer) => ! (bool) ($offer->is_internal ?? false) && filled($offer->price_amount))
->sortBy('price_amount')
->first();
$amazonOffer = $offers->first(fn($offer) => (string) ($offer->source_type ?? '') === 'amazon_referral');
return [
'id' => (int) $product->id,
'internal_code' => (string) ($product->internal_code ?? ''),
'name' => (string) ($product->name ?? ''),
'brand' => (string) ($product->brand ?? ''),
'model' => (string) ($product->model ?? ''),
'color_label' => (string) ($product->color_label ?? ''),
'track_serials' => (bool) $product->track_serials,
'identifiers_count' => (int) ($product->identifiers_count ?? 0),
'serials_count' => (int) ($product->serials_count ?? 0),
'media_count' => (int) ($product->media_count ?? 0),
'source' => (string) (($product->meta['source'] ?? 'manual') ?: 'manual'),
'publication_mode' => (string) (($product->meta['catalog']['publication_mode'] ?? '') ?: ''),
'public_reference' => (string) (($product->meta['catalog']['public_reference'] ?? '') ?: ($product->internal_code ?? '')),
'private_links' => count(array_filter(is_array($product->meta['catalog']['private_source_links'] ?? null) ? $product->meta['catalog']['private_source_links'] : [], fn($value) => filled($value))),
'categories' => (string) (($product->meta['catalog']['categories'] ?? '') ?: ''),
'stock_quantity' => is_numeric($product->meta['stock']['quantity'] ?? null) ? (int) $product->meta['stock']['quantity'] : null,
'price_wholesale' => is_numeric($product->meta['pricing']['wholesale'] ?? null) ? (float) $product->meta['pricing']['wholesale'] : null,
'offers_count' => $offers->count(),
'own_offer_price' => $internalOffer && filled($internalOffer->price_amount) ? (float) $internalOffer->price_amount : null,
'best_competitor_price' => $bestCompetitor && filled($bestCompetitor->price_amount) ? (float) $bestCompetitor->price_amount : null,
'best_competitor_source' => $bestCompetitor ? (string) ($bestCompetitor->source_name ?? $bestCompetitor->source_type ?? '') : '',
'amazon_referral_url' => $amazonOffer ? (string) ($amazonOffer->referral_url ?? '') : '',
];
})
->all();
}
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
$this->tecnorepairRows = $this->fornitore->tecnorepairSchede()
->withCount('allegati')
->orderByRaw("CASE status_bucket WHEN 'open' THEN 0 WHEN 'waiting' THEN 1 WHEN 'closed' THEN 2 ELSE 3 END")
->orderByDesc('date_received')
->limit(12)
->get(['id', 'legacy_numero_scheda', 'product_model', 'product_code', 'serial_number', 'status_bucket', 'status_label'])
->map(fn($scheda) => [
'id' => (int) $scheda->id,
'legacy_numero' => (string) ($scheda->legacy_numero_scheda ?? ''),
'product_model' => (string) ($scheda->product_model ?? ''),
'product_code' => (string) ($scheda->product_code ?? ''),
'serial_number' => (string) ($scheda->serial_number ?? ''),
'status_bucket' => (string) ($scheda->status_bucket ?? ''),
'status_label' => (string) ($scheda->status_label ?? ''),
'allegati_count' => (int) ($scheda->allegati_count ?? 0),
])
->all();
}
}
private function resolveFornitoreAdminCode(): ?string
{
$admin = $this->fornitore->amministratore;
if (! $admin) {
return null;
}
$code = trim((string) ($admin->codice_amministratore ?? ''));
return $code !== '' ? $code : (string) $admin->id;
}
private function suggestWholesaleCsvPath(): string
{
$basePath = base_path('Miki-Bug-workspace/Fornitori/Listini da importare');
if (! is_dir($basePath)) {
return '';
}
$normalizedName = Str::of((string) ($this->fornitore->ragione_sociale ?? ''))
->ascii()
->lower()
->replaceMatches('/[^a-z0-9]+/', ' ')
->trim()
->value();
$candidates = glob($basePath . '/*/wholesale.csv') ?: [];
foreach ($candidates as $candidate) {
$segment = Str::of((string) basename(dirname($candidate)))
->ascii()
->lower()
->replaceMatches('/[^a-z0-9]+/', ' ')
->trim()
->value();
if ($segment !== '' && $normalizedName !== '' && Str::contains($normalizedName, $segment)) {
return $candidate;
}
}
return (string) ($candidates[0] ?? '');
}
private function refreshOperationalBoxes(): void
{
$user = Auth::user();
if ($user instanceof User) {
$this->hydrateBoxData($user);
}
$this->fornitore->refresh();
$this->refreshCatalogRows();
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@ -521,6 +672,28 @@ private function hydrateBoxData(User $user): void
'voce_spesa_default_id' => null, 'voce_spesa_default_id' => null,
'conto_costo_default_id' => null, 'conto_costo_default_id' => null,
], ],
'catalogo_modulo' => [
'scope_code' => null,
'fe_products' => 0,
'csv_products' => 0,
'manual_products' => 0,
'internal_only' => 0,
'private_links' => 0,
'offers' => 0,
'amazon_links' => 0,
],
'prodotti' => [
'count' => 0,
'serializzati' => 0,
'identifiers' => 0,
'with_media' => 0,
],
'tecnorepair' => [
'schede' => 0,
'aperte' => 0,
'chiuse' => 0,
'seriali' => 0,
],
'ade' => ['count' => 0, 'lordo' => 0.0], 'ade' => ['count' => 0, 'lordo' => 0.0],
'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0], 'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0],
'contabilita' => [ 'contabilita' => [
@ -550,6 +723,54 @@ private function hydrateBoxData(User $user): void
$this->box['stabile_id'] = $activeStabileId; $this->box['stabile_id'] = $activeStabileId;
if (Schema::hasTable('products')) {
$productsBase = Product::query()->where('default_fornitore_id', (int) $this->fornitore->id);
$catalogProducts = (clone $productsBase)->get(['id', 'meta']);
$this->box['prodotti']['count'] = (int) (clone $productsBase)->count();
$this->box['prodotti']['serializzati'] = (int) (clone $productsBase)->where('track_serials', true)->count();
$this->box['prodotti']['with_media'] = (int) (clone $productsBase)->whereHas('media')->count();
$this->box['prodotti']['identifiers'] = (int) (clone $productsBase)->withCount('identifiers')->get()->sum('identifiers_count');
$scopeCode = trim((string) ($this->fornitore->codice_univoco ?? ''));
$this->box['catalogo_modulo']['scope_code'] = $scopeCode !== '' ? $scopeCode : ('FORN-' . str_pad((string) $this->fornitore->id, 6, '0', STR_PAD_LEFT));
foreach ($catalogProducts as $product) {
$meta = is_array($product->meta ?? null) ? $product->meta : [];
$source = (string) ($meta['source'] ?? 'manual');
$catalog = is_array($meta['catalog'] ?? null) ? $meta['catalog'] : [];
$privateLinks = is_array($catalog['private_source_links'] ?? null) ? $catalog['private_source_links'] : [];
if ($source === 'fattura_elettronica') {
$this->box['catalogo_modulo']['fe_products']++;
} elseif ($source === 'ncom_wholesale_csv') {
$this->box['catalogo_modulo']['csv_products']++;
} else {
$this->box['catalogo_modulo']['manual_products']++;
}
if ((string) ($catalog['publication_mode'] ?? '') === 'internal_only') {
$this->box['catalogo_modulo']['internal_only']++;
}
$this->box['catalogo_modulo']['private_links'] += count(array_filter($privateLinks, fn($value) => filled($value)));
}
if (Schema::hasTable('product_offers')) {
$offersBase = $this->fornitore->productOffers()->where('is_active', true);
$this->box['catalogo_modulo']['offers'] = (int) (clone $offersBase)->count();
$this->box['catalogo_modulo']['amazon_links'] = (int) (clone $offersBase)->where('source_type', 'amazon_referral')->count();
}
}
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
$stats = $this->fornitore->tecnorepair_stats;
$this->box['tecnorepair']['schede'] = (int) ($stats['total'] ?? 0);
$this->box['tecnorepair']['aperte'] = (int) ($stats['open'] ?? 0);
$this->box['tecnorepair']['chiuse'] = (int) ($stats['closed'] ?? 0);
$this->box['tecnorepair']['seriali'] = (int) ($stats['serials'] ?? 0);
}
// Feature flags & defaults (per stabile) + fallback (per fornitore) // Feature flags & defaults (per stabile) + fallback (per fornitore)
$settings = null; $settings = null;
if (Schema::hasTable('fornitore_stabile_impostazioni')) { if (Schema::hasTable('fornitore_stabile_impostazioni')) {
@ -770,6 +991,266 @@ protected function getHeaderActions(): array
->icon('heroicon-o-tag') ->icon('heroicon-o-tag')
->action(fn() => $this->importLegacyTags()), ->action(fn() => $this->importLegacyTags()),
Action::make('nuovo_prodotto')
->label('Nuovo prodotto')
->icon('heroicon-o-cube')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('name')->label('Nome prodotto')->required()->maxLength(255),
TextInput::make('brand')->label('Marca')->maxLength(255),
TextInput::make('model')->label('Modello')->maxLength(255),
TextInput::make('color_label')->label('Colore')->maxLength(255),
TextInput::make('variant_label')->label('Variante')->maxLength(255),
Toggle::make('track_serials')->label('Gestione seriali')->default(false),
TextInput::make('vendor_sku')->label('Codice fornitore')->maxLength(255),
TextInput::make('ean_code')->label('EAN / GTIN')->maxLength(32),
TextInput::make('qr_code')->label('QR / payload')->maxLength(255),
])
->action(function (array $data): void {
$service = app(FornitoreProductCatalogService::class);
$resolved = $service->resolveOrCreateProduct($this->fornitore, [
'name' => (string) ($data['name'] ?? ''),
'brand' => (string) ($data['brand'] ?? ''),
'model' => (string) ($data['model'] ?? ''),
'color_label' => (string) ($data['color_label'] ?? ''),
'variant_label' => (string) ($data['variant_label'] ?? ''),
'track_serials' => (bool) ($data['track_serials'] ?? false),
'type' => 'product',
'unit_measure' => 'pz',
]);
if (filled($data['vendor_sku'] ?? null)) {
$service->upsertIdentifier($resolved['product'], [
'fornitore_id' => (int) $this->fornitore->id,
'code_type' => 'vendor_sku',
'code_role' => 'supplier',
'code_value' => (string) $data['vendor_sku'],
'source' => 'manual',
]);
}
if (filled($data['ean_code'] ?? null)) {
$service->upsertIdentifier($resolved['product'], [
'fornitore_id' => null,
'code_type' => 'ean',
'code_role' => 'barcode',
'code_value' => (string) $data['ean_code'],
'source' => 'manual',
]);
}
if (filled($data['qr_code'] ?? null)) {
$service->upsertIdentifier($resolved['product'], [
'fornitore_id' => null,
'code_type' => 'qr',
'code_role' => 'barcode',
'code_value' => (string) $data['qr_code'],
'source' => 'manual',
]);
}
$this->refreshOperationalBoxes();
Notification::make()->title('Prodotto salvato')->success()->send();
}),
Action::make('estrai_prodotti_fe')
->label('Estrai prodotti da FE')
->icon('heroicon-o-document-magnifying-glass')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('limit')->label('Numero FE da analizzare')->numeric()->default(40)->required(),
])
->action(function (array $data): void {
$user = Auth::user();
if (! $user instanceof User) {
Notification::make()->title('Utente non valido')->danger()->send();
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Seleziona uno stabile attivo')->warning()->send();
return;
}
$stats = app(FornitoreProductCatalogService::class)->extractFromFattureElettroniche(
$this->fornitore,
(int) $stabileId,
max(1, (int) ($data['limit'] ?? 40)),
);
$this->refreshOperationalBoxes();
Notification::make()
->title('Estrazione prodotti completata')
->body('FE: ' . $stats['fatture'] . ' | righe: ' . $stats['lines'] . ' | nuovi prodotti: ' . $stats['products'] . ' | codici: ' . $stats['identifiers'])
->success()
->send();
}),
Action::make('importa_listino_csv')
->label('Importa listino CSV')
->icon('heroicon-o-arrow-down-tray')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('csv_path')
->label('Percorso CSV listino')
->default(fn(): string => $this->suggestWholesaleCsvPath())
->required(),
TextInput::make('limit')->label('Limite righe')->numeric()->default(0),
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
])
->action(function (array $data): void {
try {
$stats = app(FornitoreProductCatalogService::class)->importWholesaleCsv(
$this->fornitore,
(string) ($data['csv_path'] ?? ''),
max(0, (int) ($data['limit'] ?? 0)),
(bool) ($data['dry_run'] ?? false),
);
} catch (\Throwable $e) {
Notification::make()->title('Import listino fallito')->body($e->getMessage())->danger()->send();
return;
}
$this->refreshOperationalBoxes();
Notification::make()
->title((bool) ($data['dry_run'] ?? false) ? 'Simulazione listino completata' : 'Import listino completato')
->body(
'righe: ' . $stats['rows']
. ' | nuovi: ' . $stats['products']
. ' | aggiornati: ' . $stats['updated']
. ' | codici: ' . $stats['identifiers']
. ' | offerte: ' . ($stats['offers'] ?? 0)
. ' | link sorgente interni: ' . ($stats['private_links'] ?? 0)
. ' | media remoti rimossi: ' . ($stats['remote_media_pruned'] ?? 0)
)
->success()
->send();
}),
Action::make('scarica_asset_catalogo')
->label('Internalizza asset')
->icon('heroicon-o-photo')
->visible(fn(): bool => Schema::hasTable('products'))
->form([
TextInput::make('limit')->label('Limite prodotti')->numeric()->default(50)->required(),
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
])
->action(function (array $data): void {
$stats = app(ProductAssetIngestionService::class)->ingestForFornitore(
$this->fornitore,
max(1, (int) ($data['limit'] ?? 50)),
(bool) ($data['dry_run'] ?? false),
);
$this->refreshOperationalBoxes();
Notification::make()
->title((bool) ($data['dry_run'] ?? false) ? 'Simulazione asset completata' : 'Asset catalogo internalizzati')
->body(
'prodotti: ' . $stats['products']
. ' | immagini: ' . $stats['images']
. ' | documenti: ' . $stats['documents']
. ' | esistenti: ' . $stats['skipped_existing']
. ' | non supportati: ' . $stats['skipped_unsupported']
. ' | errori: ' . $stats['failed']
)
->success()
->send();
}),
Action::make('genera_referral_amazon')
->label('Prepara link Amazon')
->icon('heroicon-o-shopping-bag')
->visible(fn(): bool => Schema::hasTable('products') && Schema::hasTable('product_offers'))
->form([
TextInput::make('associate_tag')->label('Referral tag Amazon')->default((string) config('catalog.amazon.associate_tag', ''))->maxLength(64),
Select::make('locale')->label('Marketplace')->options([
'it' => 'Amazon IT',
'de' => 'Amazon DE',
'fr' => 'Amazon FR',
'es' => 'Amazon ES',
'uk' => 'Amazon UK',
])->default((string) config('catalog.amazon.default_locale', 'it'))->required(),
TextInput::make('limit')->label('Limite prodotti')->numeric()->default(200)->required(),
])
->action(function (array $data): void {
$service = app(ProductOfferService::class);
$products = Product::query()
->where('default_fornitore_id', (int) $this->fornitore->id)
->orderBy('id')
->limit(max(1, (int) ($data['limit'] ?? 200)))
->get(['id', 'name', 'brand', 'model']);
$created = 0;
foreach ($products as $product) {
$offer = $service->syncAmazonReferralOffer($product, (string) ($data['associate_tag'] ?? ''), (string) ($data['locale'] ?? 'it'));
if ($offer !== null) {
$created++;
}
}
$this->refreshOperationalBoxes();
Notification::make()
->title('Link Amazon preparati')
->body('prodotti elaborati: ' . $products->count() . ' | link attivi: ' . $created)
->success()
->send();
}),
Action::make('importa_tecnorepair')
->label('Importa TecnoRepair')
->icon('heroicon-o-wrench-screwdriver')
->form([
TextInput::make('mdb_path')
->label('Percorso MDB TecnoRepair')
->default('/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb')
->required(),
TextInput::make('limit')->label('Limite schede')->numeric()->default(0),
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
Toggle::make('force_primary')->label('Marca come centro principale')->default(true),
])
->action(function (array $data): void {
$adminCode = $this->resolveFornitoreAdminCode();
if ($adminCode === null) {
Notification::make()->title('Amministratore fornitore non trovato')->danger()->send();
return;
}
$params = [
'amministratore' => $adminCode,
'--mdb' => (string) ($data['mdb_path'] ?? ''),
'--fornitore-id' => (int) $this->fornitore->id,
];
$limit = max(0, (int) ($data['limit'] ?? 0));
if ($limit > 0) {
$params['--limit'] = $limit;
}
if ((bool) ($data['dry_run'] ?? false)) {
$params['--dry-run'] = true;
}
if ((bool) ($data['force_primary'] ?? true)) {
$params['--force-primary'] = true;
}
try {
Artisan::call('tecnorepair:import-legacy', $params);
} catch (\Throwable $e) {
Notification::make()->title('Import TecnoRepair fallito')->body($e->getMessage())->danger()->send();
return;
}
$this->refreshOperationalBoxes();
Notification::make()
->title('Import TecnoRepair completato')
->body(trim(Artisan::output()) ?: 'Operazione completata.')
->success()
->send();
}),
Action::make('impostazioni_fe') Action::make('impostazioni_fe')
->label('Impostazioni FE') ->label('Impostazioni FE')
->icon('heroicon-o-adjustments-horizontal') ->icon('heroicon-o-adjustments-horizontal')

View File

@ -1395,8 +1395,8 @@ public function canRequestClickToCall(): bool
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& (bool) ($user->pbx_click_to_call_enabled ?? false) && (bool) ($user->pbx_click_to_call_enabled ?? false)
&& trim((string) ($user->pbx_extension ?? '')) !== ''; && trim((string) ($user->pbx_extension ?? '')) !== '';
} }
public function richiediClickToCallRubrica(string $field): void public function richiediClickToCallRubrica(string $field): void

View File

@ -44,11 +44,16 @@ class PostItGestione extends Page
public string $tecnicoSearch = ''; public string $tecnicoSearch = '';
public string $tecnicoLineFilter = '';
public string $tecnicoScopeFilter = 'tutti'; public string $tecnicoScopeFilter = 'tutti';
/** @var array<int,string> */ /** @var array<int,string> */
public array $riaperturaNote = []; public array $riaperturaNote = [];
/** @var array<int,string> */
public array $conversioneNote = [];
/** @var array<string, int|null> */ /** @var array<string, int|null> */
private array $rubricaPhoneCache = []; private array $rubricaPhoneCache = [];
@ -57,7 +62,7 @@ class PostItGestione extends Page
public function mount(): void public function mount(): void
{ {
$focus = (int) request()->query('focus_post_it', 0); $focus = (int) request()->query('focus_post_it', 0);
$this->focusPostItId = $focus > 0 ? $focus : null; $this->focusPostItId = $focus > 0 ? $focus : null;
if (request()->query('tab') === 'storico') { if (request()->query('tab') === 'storico') {
@ -104,7 +109,7 @@ public function convertiInTicket(int $postItId): void
'stabile_id' => $postIt->stabile_id, 'stabile_id' => $postIt->stabile_id,
'aperto_da_user_id' => Auth::id(), 'aperto_da_user_id' => Auth::id(),
'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')), 'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')),
'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"), 'descrizione' => $this->buildTicketDescriptionFromPostIt($postItId, $postIt),
'data_apertura' => now(), 'data_apertura' => now(),
'stato' => 'Aperto', 'stato' => 'Aperto',
'priorita' => $postIt->priorita, 'priorita' => $postIt->priorita,
@ -119,6 +124,8 @@ public function convertiInTicket(int $postItId): void
->body("Ticket #{$ticket->id} creato dal Post-it #{$postIt->id}") ->body("Ticket #{$ticket->id} creato dal Post-it #{$postIt->id}")
->success() ->success()
->send(); ->send();
unset($this->conversioneNote[$postItId]);
} }
public function chiudiPostIt(int $postItId): void public function chiudiPostIt(int $postItId): void
@ -270,6 +277,11 @@ public function getChiamateTecnicheProperty()
$items = $items->filter(fn(CommunicationMessage $message): bool => $this->getTecnicoScope($message) === $this->tecnicoScopeFilter)->values(); $items = $items->filter(fn(CommunicationMessage $message): bool => $this->getTecnicoScope($message) === $this->tecnicoScopeFilter)->values();
} }
$lineFilter = trim($this->tecnicoLineFilter);
if ($lineFilter !== '') {
$items = $items->filter(fn(CommunicationMessage $message): bool => $this->matchesTecnicoLineFilter($message, $lineFilter))->values();
}
return $items; return $items;
} catch (QueryException) { } catch (QueryException) {
return collect(); return collect();
@ -323,18 +335,45 @@ public function creaPostItDaMessaggio(int $messageId): void
$sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR'; $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 ?? ''));
$phone = (string) ($message->phone_number ?: ($smdr['dial_number'] ?? ''));
$nominativo = $this->getTecnicoNominativo($message);
$lineLabel = $this->getTecnicoLineaLabel($message);
$direction = match (strtolower(trim((string) $message->direction))) {
'inbound' => 'Chiamata ricevuta',
'outbound' => 'Chiamata effettuata',
'internal' => 'Chiamata interna',
default => 'Evento telefonico',
};
$oggetto = $direction;
if ($lineLabel !== '-') {
$oggetto .= (string) $message->channel === 'smdr'
? ' - linea ' . $lineLabel
: ' - ' . $lineLabel;
}
$notaParts = [];
if ($phone !== '') {
$notaParts[] = 'Numero: ' . $phone;
}
if ($nominativo) {
$notaParts[] = 'Contatto: ' . $nominativo;
}
if ($line !== '') {
$notaParts[] = 'Dettaglio sorgente: ' . $line;
}
$postIt = ChiamataPostIt::query()->create([ $postIt = ChiamataPostIt::query()->create([
'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' => $sourceLabel . ' ' . strtoupper((string) $message->direction), 'nome_chiamante' => $nominativo ?: ($phone !== '' ? $phone : ($sourceLabel . ' ' . strtoupper((string) $message->direction))),
'direzione' => $this->normalizePostItDirection((string) $message->direction), 'direzione' => $this->normalizePostItDirection((string) $message->direction),
'origine' => $sourceKey . '_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 ' . $sourceLabel, 'oggetto' => $oggetto,
'nota' => $line !== '' ? $line : ('Evento ' . $sourceLabel . ' senza testo riga'), 'nota' => $notaParts !== [] ? implode("\n", $notaParts) : ('Evento ' . $sourceLabel . ' senza dettagli operativi'),
'priorita' => 'Media', 'priorita' => 'Media',
'stato' => 'post_it', 'stato' => 'post_it',
'chiamata_il' => $message->received_at ?? now(), 'chiamata_il' => $message->received_at ?? now(),
@ -436,6 +475,49 @@ public function getTecnicoLineaLabel(CommunicationMessage $message): string
return $parts !== [] ? implode(' / ', $parts) : '-'; return $parts !== [] ? implode(' / ', $parts) : '-';
} }
private function matchesTecnicoLineFilter(CommunicationMessage $message, string $filter): bool
{
$needle = mb_strtolower(trim($filter));
if ($needle === '') {
return true;
}
$haystacks = [
mb_strtolower($this->getTecnicoLineaLabel($message)),
mb_strtolower((string) ($message->target_extension ?? '')),
mb_strtolower((string) data_get($message->metadata, 'smdr.co', '')),
mb_strtolower((string) data_get($message->metadata, 'provider_call_id', '')),
mb_strtolower((string) ($message->message_text ?? '')),
];
foreach ($haystacks as $haystack) {
if ($haystack !== '' && str_contains($haystack, $needle)) {
return true;
}
}
return false;
}
private function buildTicketDescriptionFromPostIt(int $postItId, ChiamataPostIt $postIt): string
{
$parts = [];
$nota = trim((string) ($postIt->nota ?? ''));
if ($nota !== '') {
$parts[] = $nota;
}
$conversioneNote = trim((string) ($this->conversioneNote[$postItId] ?? ''));
if ($conversioneNote !== '') {
$parts[] = 'Nota operatore prima della conversione:' . "\n" . $conversioneNote;
}
$parts[] = 'Riferimento chiamata: #' . $postIt->id;
return implode("\n\n", $parts);
}
public function richiediClickToCallDaMessaggio(int $messageId): void public function richiediClickToCallDaMessaggio(int $messageId): void
{ {
$user = Auth::user(); $user = Auth::user();

View File

@ -0,0 +1,164 @@
<?php
namespace App\Filament\Pages\Supporto;
use App\Filament\Pages\Gescon\FornitoreScheda;
use App\Models\Amministratore;
use App\Models\AssistenzaTecnorepairScheda;
use App\Models\User;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class AssistenzaLegacy extends Page
{
protected static ?string $navigationLabel = 'Assistenza Legacy';
protected static ?string $title = 'Assistenza Legacy';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
protected static ?int $navigationSort = 28;
protected static ?string $slug = 'supporto/assistenza-legacy';
protected string $view = 'filament.pages.supporto.assistenza-legacy';
public string $status = 'all';
public string $search = '';
public ?int $selectedSchedaId = null;
/** @var array<string,int> */
public array $counters = [
'all' => 0,
'open' => 0,
'waiting' => 0,
'closed' => 0,
];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$this->selectedSchedaId = (int) request()->query('scheda', 0) ?: null;
$this->refreshCounters();
}
public function updatedStatus(): void
{
$this->refreshCounters();
}
public function updatedSearch(): void
{
$this->refreshCounters();
}
public function apriScheda(int $schedaId): void
{
$this->selectedSchedaId = $schedaId;
}
public function getRowsProperty()
{
return $this->baseQuery()
->with(['fornitore:id,ragione_sociale,is_tecnorepair_primary', 'allegati'])
->search($this->search)
->statusBucket($this->status)
->orderByRaw("CASE status_bucket WHEN 'open' THEN 0 WHEN 'waiting' THEN 1 WHEN 'closed' THEN 2 ELSE 3 END")
->orderByDesc('date_received')
->orderByDesc('id')
->limit(250)
->get();
}
public function getSelectedSchedaProperty(): ?AssistenzaTecnorepairScheda
{
if ((int) ($this->selectedSchedaId ?? 0) <= 0) {
return null;
}
return $this->baseQuery()
->with(['fornitore:id,ragione_sociale,is_tecnorepair_primary', 'allegati', 'productSerials'])
->find((int) $this->selectedSchedaId);
}
public function getFornitoreUrl(?int $fornitoreId): ?string
{
if ((int) $fornitoreId <= 0) {
return null;
}
return FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament');
}
public function getImportCommandHintProperty(): string
{
$admin = $this->resolveAmministratore();
return $admin instanceof Amministratore
? 'php artisan tecnorepair:import-legacy ' . ((string) ($admin->codice_amministratore ?: $admin->id)) . ' --force-primary'
: 'php artisan tecnorepair:import-legacy {amministratore} --force-primary';
}
private function refreshCounters(): void
{
$query = $this->baseQuery()->search($this->search);
$this->counters = [
'all' => (int) (clone $query)->count(),
'open' => (int) (clone $query)->where('status_bucket', 'open')->count(),
'waiting' => (int) (clone $query)->where('status_bucket', 'waiting')->count(),
'closed' => (int) (clone $query)->where('status_bucket', 'closed')->count(),
];
}
private function baseQuery(): Builder
{
$query = AssistenzaTecnorepairScheda::query();
$user = Auth::user();
if (! $user instanceof User) {
return $query->whereRaw('1 = 0');
}
if ($user->hasAnyRole(['super-admin', 'admin'])) {
return $query;
}
$amministratore = $this->resolveAmministratore();
if (! $amministratore instanceof Amministratore) {
return $query->whereRaw('1 = 0');
}
return $query->forAmministratore((int) $amministratore->id);
}
private function resolveAmministratore(): ?Amministratore
{
$user = Auth::user();
if (! $user instanceof User) {
return null;
}
if ($user->amministratore instanceof Amministratore) {
return $user->amministratore;
}
$stabile = StabileContext::getActiveStabile($user);
return $stabile?->amministratore;
}
}

View File

@ -55,6 +55,8 @@ class TicketMobile extends Page
public ?string $newTicketDescrizione = null; public ?string $newTicketDescrizione = null;
public ?string $newTicketCallNote = null;
public ?string $newTicketLuogo = null; public ?string $newTicketLuogo = null;
public string $newTicketPriorita = 'Media'; public string $newTicketPriorita = 'Media';
@ -207,8 +209,8 @@ public function creaPostItDaChiamataInIngresso(): void
'direzione' => 'in_arrivo', 'direzione' => 'in_arrivo',
'origine' => 'smdr_live_banner', 'origine' => 'smdr_live_banner',
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''), 'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
'oggetto' => 'Richiesta telefonica da valutare', 'oggetto' => 'Chiamata ricevuta da gestire',
'nota' => $line !== '' ? $line : ('Chiamata in ingresso da ' . $phone), 'nota' => $this->buildLiveCallPostItNote($phone, $line),
'priorita' => 'Media', 'priorita' => 'Media',
'stato' => 'post_it', 'stato' => 'post_it',
'chiamata_il' => now(), 'chiamata_il' => now(),
@ -574,18 +576,9 @@ public function creaTicketRapido(): void
$descrizione = trim((string) $this->newTicketDescrizione); $descrizione = trim((string) $this->newTicketDescrizione);
if ($caller) { if ($caller) {
$telefono = $caller->telefono_cellulare ?: ($caller->telefono_ufficio ?: $caller->telefono_casa); $telefono = $caller->telefono_cellulare ?: ($caller->telefono_ufficio ?: $caller->telefono_casa);
$descrizione .= "\n\nChiamante selezionato: " . ($caller->nome_completo ?: $caller->ragione_sociale ?: ('Rubrica #' . $caller->id)); $descrizione .= "\n\nContatto associato: " . ($caller->nome_completo ?: $caller->ragione_sociale ?: ('Rubrica #' . $caller->id));
if ($telefono) { if ($telefono) {
$descrizione .= "\nTelefono: {$telefono}"; $descrizione .= "\nTelefono richiamabile: {$telefono}";
}
if ($caller->tipo_utenza_call) {
$descrizione .= "\nProfilo: {$caller->tipo_utenza_call}";
}
if ($caller->riferimento_stabile) {
$descrizione .= "\nRif. stabile: {$caller->riferimento_stabile}";
}
if ($caller->riferimento_unita) {
$descrizione .= "\nRif. unità: {$caller->riferimento_unita}";
} }
} }
@ -624,6 +617,7 @@ public function creaTicketRapido(): void
$this->newTicketTitolo = null; $this->newTicketTitolo = null;
$this->newTicketDescrizione = null; $this->newTicketDescrizione = null;
$this->newTicketCallNote = null;
$this->newTicketLuogo = null; $this->newTicketLuogo = null;
$this->newTicketPriorita = 'Media'; $this->newTicketPriorita = 'Media';
$this->newTicketCategoriaId = null; $this->newTicketCategoriaId = null;
@ -928,6 +922,7 @@ private function prefillTicketFromLiveCall(string $phone, string $line): void
{ {
$this->callerSearch = $phone; $this->callerSearch = $phone;
$this->searchCaller(); $this->searchCaller();
$this->newTicketCallNote = trim($line) !== '' ? trim($line) : null;
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0); $rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
if ($rubricaId > 0) { if ($rubricaId > 0) {
@ -940,14 +935,68 @@ private function prefillTicketFromLiveCall(string $phone, string $line): void
} }
if (blank($this->newTicketTitolo)) { if (blank($this->newTicketTitolo)) {
$this->newTicketTitolo = 'Chiamata in ingresso ' . $phone; $this->newTicketTitolo = 'Segnalazione telefonica in arrivo';
} }
if (blank($this->newTicketDescrizione)) { if (blank($this->newTicketDescrizione)) {
$this->newTicketDescrizione = 'Segnalazione aperta durante chiamata in ingresso da ' . $phone . ".\n" . $line; $this->newTicketDescrizione = 'Segnalazione aperta durante chiamata ricevuta dal numero ' . $phone . '.';
} }
} }
/**
* @return array{phone:?string,target_extension:?string,received_at:?string,caller_name:?string,caller_profile:?string,riferimento_stabile:?string,riferimento_unita:?string,stabile_label:?string,call_note:?string}
*/
public function getTicketCallContextProperty(): array
{
$caller = $this->selectedCallerId
? RubricaUniversale::query()->find($this->selectedCallerId)
: null;
$phone = null;
if (! empty($this->liveIncomingCall['phone'])) {
$phone = (string) $this->liveIncomingCall['phone'];
} else {
$digits = $this->normalizePhoneDigits((string) $this->callerSearch);
$phone = $digits !== '' ? $digits : null;
}
return [
'phone' => $phone,
'target_extension' => ! empty($this->liveIncomingCall['target_extension']) ? (string) $this->liveIncomingCall['target_extension'] : null,
'received_at' => ! empty($this->liveIncomingCall['received_at']) ? (string) $this->liveIncomingCall['received_at'] : null,
'caller_name' => $caller ? (string) ($caller->nome_completo ?: $caller->ragione_sociale ?: '') : (! empty($this->liveIncomingCall['rubrica_nome']) ? (string) $this->liveIncomingCall['rubrica_nome'] : null),
'caller_profile' => $caller ? ((string) ($caller->tipo_utenza_call ?: '') ?: null) : null,
'riferimento_stabile' => $caller ? ((string) ($caller->riferimento_stabile ?: '') ?: null) : null,
'riferimento_unita' => $caller ? ((string) ($caller->riferimento_unita ?: '') ?: null) : null,
'stabile_label' => $caller ? $this->getCallerStabileLabel((int) $caller->id) : null,
'call_note' => $this->newTicketCallNote,
];
}
private function buildLiveCallPostItNote(string $phone, string $line): string
{
$parts = [
'Chiamata ricevuta dal numero ' . $phone,
];
$targetExtension = trim((string) ($this->liveIncomingCall['target_extension'] ?? ''));
if ($targetExtension !== '') {
$parts[] = 'Interno/gruppo chiamato: ' . $targetExtension;
}
$callerName = trim((string) ($this->liveIncomingCall['rubrica_nome'] ?? ''));
if ($callerName !== '') {
$parts[] = 'Contatto riconosciuto: ' . $callerName;
}
$cleanLine = trim($line);
if ($cleanLine !== '') {
$parts[] = 'Nota operativa: ' . $cleanLine;
}
return implode("\n", $parts);
}
private function normalizePhoneDigits(string $value): string private function normalizePhoneDigits(string $value): string
{ {
return preg_replace('/\D+/', '', $value) ?: ''; return preg_replace('/\D+/', '', $value) ?: '';
@ -993,14 +1042,14 @@ private function selectCallerRepresentative(Collection $group, int $selectedCall
$representative = $selected instanceof RubricaUniversale $representative = $selected instanceof RubricaUniversale
? $selected ? $selected
: $group : $group
->sortByDesc(fn(RubricaUniversale $match): array => [ ->sortByDesc(fn(RubricaUniversale $match): array=> [
(int) (! empty($match->amministratore_id)), (int) (! empty($match->amministratore_id)),
(int) (! empty($match->riferimento_stabile)), (int) (! empty($match->riferimento_stabile)),
(int) (! empty($match->riferimento_unita)), (int) (! empty($match->riferimento_unita)),
(int) (($match->categoria ?? '') === 'condomino'), (int) (($match->categoria ?? '') === 'condomino'),
-1 * (int) $match->id, -1 * (int) $match->id,
]) ])
->first(); ->first();
$duplicateCategories = $group $duplicateCategories = $group
->pluck('categoria') ->pluck('categoria')

View File

@ -322,7 +322,7 @@ protected function getHeaderActions(): array
->columnSpan(3), ->columnSpan(3),
Select::make('persona_id') Select::make('persona_id')
->label('Persona collegata') ->label('Persona collegata')
->options(fn(): array => $this->getPersonaRecapitoOptions()) ->options(fn(): array=> $this->getPersonaRecapitoOptions())
->searchable() ->searchable()
->preload() ->preload()
->native(false) ->native(false)
@ -353,7 +353,7 @@ protected function getHeaderActions(): array
]) ])
->columns(12), ->columns(12),
]) ])
->fillForm(fn(): array => ['recapiti' => $this->getRecapitiServizioFormRows()]) ->fillForm(fn(): array=> ['recapiti' => $this->getRecapitiServizioFormRows()])
->action(function (array $data): void { ->action(function (array $data): void {
$this->saveRecapitiServizioOverrides($data['recapiti'] ?? []); $this->saveRecapitiServizioOverrides($data['recapiti'] ?? []);
$this->loadUnita(); $this->loadUnita();
@ -806,7 +806,7 @@ public function hydrateNominativiStorici(): void
$percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%'; $percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%';
} }
$details = []; $details = [];
$dirittoLabel = trim((string) ($payload['diritto_label'] ?? '')); $dirittoLabel = trim((string) ($payload['diritto_label'] ?? ''));
if ($dirittoLabel !== '') { if ($dirittoLabel !== '') {
$details[] = 'Diritto: ' . $dirittoLabel; $details[] = 'Diritto: ' . $dirittoLabel;
@ -871,8 +871,8 @@ private function formatNominativoStoricoRuolo(string $ruolo, string $fonte, arra
} }
return match (strtoupper($ruolo)) { return match (strtoupper($ruolo)) {
'I' => 'Inquilino', 'I' => 'Inquilino',
'C' => 'Condomino', 'C' => 'Condomino',
default => $ruolo !== '' ? $ruolo : '—', default => $ruolo !== '' ? $ruolo : '—',
}; };
} }

View File

@ -8,6 +8,7 @@
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Services\Cti\PbxRoutingService; use App\Services\Cti\PbxRoutingService;
use App\Support\PhoneNumber; use App\Support\PhoneNumber;
use Carbon\CarbonInterface;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -52,7 +53,8 @@ public function incoming(Request $request): JsonResponse
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); $direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? '')); $routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
$postIt = null; $postIt = null;
$calledAt = $this->normalizePayloadDateTime($payload['called_at'] ?? null);
if (Schema::hasTable('chiamate_post_it')) { if (Schema::hasTable('chiamate_post_it')) {
$postIt = ChiamataPostIt::query()->create([ $postIt = ChiamataPostIt::query()->create([
'stabile_id' => $routing['stabile_id'], 'stabile_id' => $routing['stabile_id'],
@ -69,7 +71,7 @@ public function incoming(Request $request): JsonResponse
'nota' => $this->buildNote($payload), 'nota' => $this->buildNote($payload),
'priorita' => 'Media', 'priorita' => 'Media',
'stato' => 'post_it', 'stato' => 'post_it',
'chiamata_il' => $payload['called_at'] ?? now(), 'chiamata_il' => $calledAt ?? now(),
]); ]);
} }
@ -173,95 +175,116 @@ public function callEnded(Request $request): JsonResponse
'is_test' => $isTest, 'is_test' => $isTest,
]); ]);
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); try {
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
$postIt = $this->findOpenPostIt( $direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
(string) ($payload['event_id'] ?? ''), $endedAt = $this->normalizePayloadDateTime($payload['ended_at'] ?? null);
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') $postIt = $this->findOpenPostIt(
); (string) ($payload['event_id'] ?? ''),
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '')
);
$created = false; $created = false;
if (! $postIt && Schema::hasTable('chiamate_post_it')) { if (! $postIt && Schema::hasTable('chiamate_post_it')) {
$created = true; $created = true;
$rubrica = $this->findRubricaByPhone($normalized); $rubrica = $this->findRubricaByPhone($normalized);
$postIt = ChiamataPostIt::query()->create([ $postIt = ChiamataPostIt::query()->create([
'stabile_id' => null, 'stabile_id' => null,
'rubrica_id' => $rubrica?->id, 'rubrica_id' => $rubrica?->id,
'ticket_id' => null, 'ticket_id' => null,
'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' => $direction['post_it'], '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 terminata (evento senza incoming)', 'oggetto' => 'Chiamata terminata (evento senza incoming)',
'nota' => $this->buildNote($payload), 'nota' => $this->buildNote($payload),
'priorita' => 'Media', 'priorita' => 'Media',
'stato' => 'post_it', 'stato' => 'post_it',
'chiamata_il' => $payload['ended_at'] ?? now(), 'chiamata_il' => $endedAt ?? now(),
]); ]);
} }
if (! $postIt) { if (! $postIt) {
$this->logTrace('call-ended.not-found', $request, $payload, [ $this->logTrace('call-ended.not-found', $request, $payload, [
'trace_id' => $traceId,
'classification' => $classification,
'is_test' => $isTest,
]);
return response()->json([
'ok' => false,
'message' => 'No matching Post-it and table unavailable',
'trace_id' => $traceId,
], 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 = $endedAt ?? 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();
$message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id);
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
$this->logTrace('call-ended.closed', $request, $payload, [
'trace_id' => $traceId, 'trace_id' => $traceId,
'post_it_id' => $postIt->id,
'message_id' => $message?->id,
'created_new' => $created,
'classification' => $classification, 'classification' => $classification,
'is_test' => $isTest, 'is_test' => $isTest,
]); ]);
return response()->json([
'ok' => true,
'source' => 'panasonic_ns1000',
'trace_id' => $traceId,
'closed' => true,
'created_new' => $created,
'post_it_id' => $postIt->id,
'communication_message_id' => $message?->id,
'stato' => $postIt->stato,
'esito' => $postIt->esito,
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
]);
} catch (\Throwable $e) {
Log::error('CTI Panasonic call-ended failed', [
'trace_id' => $traceId,
'event_id' => (string) ($payload['event_id'] ?? ''),
'called_extension' => (string) ($payload['called_extension'] ?? ''),
'calling_extension' => (string) ($payload['calling_extension'] ?? ''),
'direction' => (string) ($payload['direction'] ?? ''),
'phone' => (string) ($payload['phone'] ?? ''),
'error' => $e->getMessage(),
'exception' => get_class($e),
]);
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'No matching Post-it and table unavailable', 'message' => 'Call ended processing failed',
'trace_id' => $traceId, 'trace_id' => $traceId,
], 404); 'error' => $e->getMessage(),
], 500);
} }
$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();
$message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id);
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
$this->logTrace('call-ended.closed', $request, $payload, [
'trace_id' => $traceId,
'post_it_id' => $postIt->id,
'message_id' => $message?->id,
'created_new' => $created,
'classification' => $classification,
'is_test' => $isTest,
]);
return response()->json([
'ok' => true,
'source' => 'panasonic_ns1000',
'trace_id' => $traceId,
'closed' => true,
'created_new' => $created,
'post_it_id' => $postIt->id,
'communication_message_id' => $message?->id,
'stato' => $postIt->stato,
'esito' => $postIt->esito,
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
]);
} }
public function pendingClickToCall(Request $request): JsonResponse public function pendingClickToCall(Request $request): JsonResponse
@ -640,7 +663,7 @@ private function storeIncomingCommunicationMessage(
'message_text' => $this->buildNote($payload), 'message_text' => $this->buildNote($payload),
'attachments' => null, 'attachments' => null,
'status' => 'received', 'status' => 'received',
'received_at' => $payload['called_at'] ?? now(), 'received_at' => $this->normalizePayloadDateTime($payload['called_at'] ?? null) ?? now(),
'metadata' => [ 'metadata' => [
'provider' => 'panasonic_ns1000', 'provider' => 'panasonic_ns1000',
'amministratore_id' => $routing['amministratore_id'], 'amministratore_id' => $routing['amministratore_id'],
@ -701,7 +724,7 @@ private function closeCommunicationMessage(array $payload, string $normalizedPho
if ($message) { if ($message) {
$metadata = is_array($message->metadata) ? $message->metadata : []; $metadata = is_array($message->metadata) ? $message->metadata : [];
$metadata['ended_at'] = ($payload['ended_at'] ?? now())->toDateTimeString(); $metadata['ended_at'] = ($this->normalizePayloadDateTime($payload['ended_at'] ?? null) ?? now())->toDateTimeString();
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null; $metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
$metadata['outcome'] = $payload['outcome'] ?? null; $metadata['outcome'] = $payload['outcome'] ?? null;
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared'); $metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
@ -714,7 +737,7 @@ private function closeCommunicationMessage(array $payload, string $normalizedPho
} }
$metadata = is_array($message->metadata) ? $message->metadata : []; $metadata = is_array($message->metadata) ? $message->metadata : [];
$metadata['ended_at'] = ($payload['ended_at'] ?? now())->toDateTimeString(); $metadata['ended_at'] = ($this->normalizePayloadDateTime($payload['ended_at'] ?? null) ?? now())->toDateTimeString();
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null; $metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
$metadata['outcome'] = $payload['outcome'] ?? null; $metadata['outcome'] = $payload['outcome'] ?? null;
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared'); $metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
@ -799,6 +822,28 @@ private function mergeMetadata(?array $existing, array $newData): array
return $base; return $base;
} }
private function normalizePayloadDateTime(mixed $value): ?CarbonInterface
{
if ($value instanceof CarbonInterface) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return now()->setTimestamp($value->getTimestamp());
}
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
try {
return now()->parse($raw);
} catch (\Throwable) {
return null;
}
}
/** /**
* @return array<string,mixed> * @return array<string,mixed>
*/ */

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Closure; use Closure;
@ -16,12 +15,16 @@ public function handle(Request $request, Closure $next): Response
// Keep Filament panel and non-admin paths untouched. // Keep Filament panel and non-admin paths untouched.
$isLegacyAdminPath = $request->is('admin') || $request->is('admin/*'); $isLegacyAdminPath = $request->is('admin') || $request->is('admin/*');
$isFilamentPath = $request->is('admin-filament') || $request->is('admin-filament/*'); $isFilamentPath = $request->is('admin-filament') || $request->is('admin-filament/*');
if (! $isLegacyAdminPath || $isFilamentPath) { if (! $isLegacyAdminPath || $isFilamentPath) {
return $next($request); return $next($request);
} }
if ($this->shouldBypassRedirect($request)) {
return $next($request);
}
// Redirect only navigational requests. Non-GET calls are left as-is. // Redirect only navigational requests. Non-GET calls are left as-is.
if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) { if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) {
return $next($request); return $next($request);
@ -29,4 +32,25 @@ public function handle(Request $request, Closure $next): Response
return redirect('/admin-filament'); return redirect('/admin-filament');
} }
private function shouldBypassRedirect(Request $request): bool
{
foreach ([
'admin/fatture-elettroniche/*/download-xml',
'admin/fatture-elettroniche/*/download-pdf',
'admin/tickets/attachments/*/view',
'admin/insurance-policies/*/assets/*',
'admin/documenti/*/download',
'admin/documenti-collegati/*/download',
'admin/contabilita-gescon/documenti/*/download',
'admin/filament/import-inbox/*/download',
'admin/filament/tickets/attachments/*/view',
] as $pattern) {
if ($request->is($pattern)) {
return true;
}
}
return false;
}
} }

View File

@ -64,8 +64,8 @@ public function createPostIt(): void
$this->redirect( $this->redirect(
PostItGestione::getUrl([ PostItGestione::getUrl([
'tab' => 'storico', 'tab' => 'storico',
'focus_post_it' => (int) $postIt->id, 'focus_post_it' => (int) $postIt->id,
], panel: 'admin-filament'), ], panel: 'admin-filament'),
navigate: true, navigate: true,
); );

View File

@ -55,12 +55,25 @@ class Amministratore extends Model
'cartella_dati', 'cartella_dati',
'database_attivo', 'database_attivo',
'abilita_import_stabili', 'abilita_import_stabili',
'fe_cassetto_enabled',
'fe_cassetto_expires_at',
'fe_cassetto_base_url',
'fe_cassetto_password_script',
'fe_cassetto_username',
'fe_cassetto_password',
'fe_cassetto_pin',
]; ];
protected $casts = [ protected $casts = [
'abilita_import_stabili' => 'boolean', 'abilita_import_stabili' => 'boolean',
'impostazioni' => 'array', 'fe_cassetto_enabled' => 'boolean',
'provisioned_at' => 'datetime', 'fe_cassetto_expires_at' => 'datetime',
'fe_cassetto_password_script' => 'encrypted',
'fe_cassetto_username' => 'encrypted',
'fe_cassetto_password' => 'encrypted',
'fe_cassetto_pin' => 'encrypted',
'impostazioni' => 'array',
'provisioned_at' => 'datetime',
]; ];
/** /**

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AssistenzaTecnorepairAllegato extends Model
{
use HasFactory;
protected $table = 'assistenza_tecnorepair_allegati_legacy';
protected $fillable = [
'scheda_id',
'legacy_id',
'legacy_scheda_id',
'legacy_attachment_number',
'file_name',
'file_path',
'imported_from_path',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function scheda(): BelongsTo
{
return $this->belongsTo(AssistenzaTecnorepairScheda::class, 'scheda_id');
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class AssistenzaTecnorepairScheda extends Model
{
use HasFactory;
protected $table = 'assistenza_tecnorepair_schede_legacy';
protected $fillable = [
'amministratore_id',
'fornitore_id',
'legacy_id',
'legacy_cliente_id',
'legacy_centro_ass_id',
'legacy_committente_codice',
'legacy_numero_scheda',
'customer_name',
'customer_phone',
'customer_phone_alt',
'customer_email',
'product_model',
'product_code',
'serial_number',
'serial_number_2',
'status_code',
'status_label',
'status_bucket',
'defect_reported',
'repair_description',
'communications',
'operator_name',
'technician_name',
'date_received',
'ordered_at',
'order_number',
'rma_code',
'pin_code',
'unlock_code',
'legacy_attachment_path',
'imported_from_path',
'imported_at',
'metadata',
];
protected $casts = [
'date_received' => 'datetime',
'ordered_at' => 'datetime',
'imported_at' => 'datetime',
'metadata' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function amministratore(): BelongsTo
{
return $this->belongsTo(Amministratore::class, 'amministratore_id');
}
public function fornitore(): BelongsTo
{
return $this->belongsTo(Fornitore::class, 'fornitore_id');
}
public function allegati(): HasMany
{
return $this->hasMany(AssistenzaTecnorepairAllegato::class, 'scheda_id');
}
public function productSerials(): HasMany
{
return $this->hasMany(ProductSerial::class, 'legacy_scheda_id');
}
public function scopeForAmministratore(Builder $query, int $amministratoreId): Builder
{
return $query->where('amministratore_id', $amministratoreId);
}
public function scopeSearch(Builder $query, string $term): Builder
{
$term = trim($term);
if ($term === '') {
return $query;
}
return $query->where(function (Builder $inner) use ($term): void {
$like = '%' . $term . '%';
$inner->where('legacy_numero_scheda', 'like', $like)
->orWhere('customer_name', 'like', $like)
->orWhere('customer_phone', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('product_model', 'like', $like)
->orWhere('product_code', 'like', $like)
->orWhere('serial_number', 'like', $like)
->orWhere('serial_number_2', 'like', $like)
->orWhere('defect_reported', 'like', $like)
->orWhere('repair_description', 'like', $like);
});
}
public function scopeStatusBucket(Builder $query, string $bucket): Builder
{
if ($bucket === 'all') {
return $query;
}
return $query->where('status_bucket', $bucket);
}
public function getDisplayTitleAttribute(): string
{
$numero = trim((string) ($this->legacy_numero_scheda ?? ''));
$modello = trim((string) ($this->product_model ?? ''));
if ($numero !== '' && $modello !== '') {
return $numero . ' · ' . $modello;
}
return $numero !== '' ? $numero : ($modello !== '' ? $modello : 'Scheda TecnoRepair');
}
public function getStatusColorAttribute(): string
{
return match ((string) $this->status_bucket) {
'closed' => 'emerald',
'waiting' => 'amber',
'open' => 'rose',
default => 'slate',
};
}
public function getStatusBadgeClassesAttribute(): string
{
return match ((string) $this->status_bucket) {
'closed' => 'border-emerald-200 bg-emerald-50 text-emerald-800',
'waiting' => 'border-amber-200 bg-amber-50 text-amber-800',
'open' => 'border-rose-200 bg-rose-50 text-rose-800',
default => 'border-slate-200 bg-slate-50 text-slate-700',
};
}
public function getRowClassesAttribute(): string
{
return match ((string) $this->status_bucket) {
'closed' => 'bg-emerald-50/70 hover:bg-emerald-50',
'waiting' => 'bg-amber-50/70 hover:bg-amber-50',
'open' => 'bg-rose-50/60 hover:bg-rose-50',
default => 'bg-white hover:bg-slate-50',
};
}
public function getSerialLabelAttribute(): string
{
$parts = array_values(array_filter([
trim((string) ($this->serial_number ?? '')),
trim((string) ($this->serial_number_2 ?? '')),
]));
return $parts !== [] ? implode(' · ', $parts) : '-';
}
public static function normalizeStatusBucket(?string $status): string
{
$value = Str::lower(trim((string) $status));
if ($value === '') {
return 'other';
}
if (Str::contains($value, ['chius', 'complet', 'consegn', 'ritirat', 'ok'])) {
return 'closed';
}
if (Str::contains($value, ['attesa', 'ordine', 'ricambi', 'preventiv', 'approv'])) {
return 'waiting';
}
return 'open';
}
}

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;
@ -36,6 +35,7 @@ class FatturaElettronica extends Model
'codice_destinatario', 'codice_destinatario',
'pec_destinatario', 'pec_destinatario',
'destinatario_cf', 'destinatario_cf',
'destinatario_piva',
'destinatario_denominazione', 'destinatario_denominazione',
'stato', 'stato',
'xml_content', 'xml_content',
@ -51,11 +51,11 @@ class FatturaElettronica extends Model
]; ];
protected $casts = [ protected $casts = [
'data_fattura' => 'date', 'data_fattura' => 'date',
'data_scadenza' => 'date', 'data_scadenza' => 'date',
'imponibile' => 'decimal:2', 'imponibile' => 'decimal:2',
'iva' => 'decimal:2', 'iva' => 'decimal:2',
'totale' => 'decimal:2', 'totale' => 'decimal:2',
'consumo_valore' => 'decimal:3', 'consumo_valore' => 'decimal:3',
]; ];

View File

@ -4,6 +4,7 @@
use App\Support\ArchivioPaths; use App\Support\ArchivioPaths;
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\HasMany;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -48,14 +49,16 @@ class Fornitore extends Model
'regime_fiscale_id', 'regime_fiscale_id',
'tipologia_cassa_id', 'tipologia_cassa_id',
'aliquota_iva_id', 'aliquota_iva_id',
'is_tecnorepair_primary',
'old_id', 'old_id',
]; ];
protected $casts = [ protected $casts = [
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
'escludi_righe_fe' => 'boolean', 'escludi_righe_fe' => 'boolean',
'fe_features' => 'array', 'fe_features' => 'array',
'is_tecnorepair_primary' => 'boolean',
]; ];
/** /**
@ -168,6 +171,52 @@ public function dipendenti()
return $this->hasMany(FornitoreDipendente::class, 'fornitore_id', 'id'); return $this->hasMany(FornitoreDipendente::class, 'fornitore_id', 'id');
} }
public function tecnorepairSchede(): HasMany
{
return $this->hasMany(AssistenzaTecnorepairScheda::class, 'fornitore_id', 'id');
}
public function productSerials(): HasMany
{
return $this->hasMany(ProductSerial::class, 'fornitore_id', 'id');
}
public function products(): HasMany
{
return $this->hasMany(Product::class, 'default_fornitore_id', 'id');
}
public function productOffers(): HasMany
{
return $this->hasMany(ProductOffer::class, 'fornitore_id', 'id');
}
public function getIsTecnoRepairCenterAttribute(): bool
{
if ((bool) ($this->is_tecnorepair_primary ?? false)) {
return true;
}
$label = Str::upper(trim((string) ($this->ragione_sociale ?? '')));
return $label !== '' && Str::contains($label, 'NETHOME');
}
/**
* @return array<string,int>
*/
public function getTecnoRepairStatsAttribute(): array
{
$base = $this->tecnorepairSchede();
return [
'total' => (int) (clone $base)->count(),
'open' => (int) (clone $base)->whereIn('status_bucket', ['open', 'waiting'])->count(),
'closed' => (int) (clone $base)->where('status_bucket', 'closed')->count(),
'serials' => (int) $this->productSerials()->count(),
];
}
/** /**
* Accessor per l'indirizzo completo * Accessor per l'indirizzo completo
*/ */

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;
@ -24,18 +23,18 @@ class PersonaUnitaRelazione extends Model
'riceve_comunicazioni', 'riceve_comunicazioni',
'riceve_convocazioni', 'riceve_convocazioni',
'vota_assemblea', 'vota_assemblea',
'note_relazione' 'note_relazione',
]; ];
protected $casts = [ protected $casts = [
'data_inizio' => 'date', 'data_inizio' => 'date',
'data_fine' => 'date', 'data_fine' => 'date',
'quota_relazione' => 'decimal:2', 'quota_relazione' => 'decimal:2',
'ruolo_rate' => 'string', 'ruolo_rate' => 'string',
'attivo' => 'boolean', 'attivo' => 'boolean',
'riceve_comunicazioni' => 'boolean', 'riceve_comunicazioni' => 'boolean',
'riceve_convocazioni' => 'boolean', 'riceve_convocazioni' => 'boolean',
'vota_assemblea' => 'boolean' 'vota_assemblea' => 'boolean',
]; ];
/** /**
@ -109,7 +108,7 @@ public function getDescrizioneRelazioneAttribute()
*/ */
public function getStatoRelazioneAttribute() public function getStatoRelazioneAttribute()
{ {
if (!$this->attivo) { if (! $this->attivo) {
return 'Non Attiva'; return 'Non Attiva';
} }

76
app/Models/Product.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Product extends Model
{
use HasFactory;
protected $table = 'products';
protected $fillable = [
'default_fornitore_id',
'type',
'internal_code',
'canonical_key',
'name',
'brand',
'model',
'variant_group',
'variant_label',
'color_label',
'unit_measure',
'description',
'track_serials',
'is_active',
'meta',
];
protected $casts = [
'track_serials' => 'boolean',
'is_active' => 'boolean',
'meta' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
protected static function booted(): void
{
static::created(function (self $product): void {
if (! is_string($product->internal_code) || trim($product->internal_code) === '') {
$product->forceFill([
'internal_code' => 'ART-' . str_pad((string) $product->id, 6, '0', STR_PAD_LEFT),
])->saveQuietly();
}
});
}
public function fornitore(): BelongsTo
{
return $this->belongsTo(Fornitore::class, 'default_fornitore_id');
}
public function identifiers(): HasMany
{
return $this->hasMany(ProductIdentifier::class, 'product_id');
}
public function media(): HasMany
{
return $this->hasMany(ProductMedia::class, 'product_id');
}
public function serials(): HasMany
{
return $this->hasMany(ProductSerial::class, 'product_id');
}
public function offers(): HasMany
{
return $this->hasMany(ProductOffer::class, 'product_id');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductIdentifier extends Model
{
use HasFactory;
protected $table = 'product_identifiers';
protected $fillable = [
'product_id',
'fornitore_id',
'code_type',
'code_role',
'code_value',
'normalized_code',
'source',
'source_reference',
'is_primary',
'payload',
];
protected $casts = [
'is_primary' => 'boolean',
'payload' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
public function fornitore(): BelongsTo
{
return $this->belongsTo(Fornitore::class, 'fornitore_id');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductMedia extends Model
{
use HasFactory;
protected $table = 'product_media';
protected $fillable = [
'product_id',
'media_type',
'disk',
'path',
'title',
'mime_type',
'size_bytes',
'sort_order',
'meta',
];
protected $casts = [
'size_bytes' => 'integer',
'sort_order' => 'integer',
'meta' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductOffer extends Model
{
use HasFactory;
protected $table = 'product_offers';
protected $fillable = [
'product_id',
'fornitore_id',
'offer_key',
'source_type',
'source_name',
'external_product_id',
'external_sku',
'title',
'condition_label',
'currency',
'price_amount',
'price_compare_at',
'shipping_amount',
'availability',
'stock_qty',
'external_url',
'referral_url',
'referral_code',
'is_active',
'is_internal',
'last_seen_at',
'meta',
];
protected $casts = [
'price_amount' => 'decimal:2',
'price_compare_at' => 'decimal:2',
'shipping_amount' => 'decimal:2',
'stock_qty' => 'integer',
'is_active' => 'boolean',
'is_internal' => 'boolean',
'last_seen_at' => 'datetime',
'meta' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
public function fornitore(): BelongsTo
{
return $this->belongsTo(Fornitore::class, 'fornitore_id');
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductSerial extends Model
{
use HasFactory;
protected $table = 'product_serials';
protected $fillable = [
'fornitore_id',
'product_id',
'legacy_scheda_id',
'customer_name',
'product_model',
'product_code',
'serial_number',
'serial_number_2',
'date_received',
'date_resold',
'internal_notes',
'source',
'source_reference',
];
protected $casts = [
'date_received' => 'datetime',
'date_resold' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function fornitore(): BelongsTo
{
return $this->belongsTo(Fornitore::class, 'fornitore_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
public function legacyScheda(): BelongsTo
{
return $this->belongsTo(AssistenzaTecnorepairScheda::class, 'legacy_scheda_id');
}
}

View File

@ -10,6 +10,7 @@ class FatturaFornitoreRiga extends Model
protected $fillable = [ protected $fillable = [
'fattura_id', 'fattura_id',
'product_id',
'riga', 'riga',
'descrizione', 'descrizione',
'imponibile_euro', 'imponibile_euro',
@ -39,6 +40,11 @@ public function fattura(): BelongsTo
return $this->belongsTo(FatturaFornitore::class, 'fattura_id'); return $this->belongsTo(FatturaFornitore::class, 'fattura_id');
} }
public function product(): BelongsTo
{
return $this->belongsTo(\App\Models\Product::class, 'product_id');
}
public function contoCosto(): BelongsTo public function contoCosto(): BelongsTo
{ {
return $this->belongsTo(PianoConti::class, 'conto_costo_id'); return $this->belongsTo(PianoConti::class, 'conto_costo_id');

View File

@ -5,10 +5,15 @@
use App\Filament\Pages\Contabilita\ContoMastrino; use App\Filament\Pages\Contabilita\ContoMastrino;
use App\Filament\Pages\Contabilita\PrimaNotaDettaglio; use App\Filament\Pages\Contabilita\PrimaNotaDettaglio;
use App\Filament\Pages\Contabilita\PrimaNotaModifica; use App\Filament\Pages\Contabilita\PrimaNotaModifica;
use App\Filament\Pages\Fornitore\TicketOperativi;
use App\Filament\Pages\Gescon\FornitoreScheda;
use App\Filament\Pages\Gescon\RubricaUniversaleArchivio; use App\Filament\Pages\Gescon\RubricaUniversaleArchivio;
use App\Filament\Pages\Supporto\SupportoDashboard; use App\Filament\Pages\Supporto\SupportoDashboard;
use App\Filament\Pages\Supporto\TicketMobile; use App\Filament\Pages\Supporto\TicketMobile;
use App\Filament\Widgets\GoogleScadenziarioOverview; use App\Filament\Widgets\GoogleScadenziarioOverview;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\User;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -88,6 +93,15 @@ public function panel(Panel $panel): Panel
return $user instanceof \App\Models\User return $user instanceof \App\Models\User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Catalogo Fornitore')
->group('Fornitore')
->icon('heroicon-o-cube')
->url(fn() => $this->resolveSupplierCatalogUrl())
->visible(function (): bool {
$user = Auth::user();
return $user instanceof User && $user->hasRole('fornitore');
}),
]) ])
->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources') ->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages') ->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages')
@ -117,6 +131,46 @@ public function panel(Panel $panel): Panel
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);
} }
protected function resolveSupplierCatalogUrl(): string
{
$user = Auth::user();
if (! $user instanceof User) {
return '/admin-filament';
}
$email = mb_strtolower(trim((string) $user->email));
if ($email !== '') {
$fornitore = Fornitore::query()
->whereRaw('LOWER(email) = ?', [$email])
->orderByDesc('id')
->first();
if ($fornitore instanceof Fornitore) {
return FornitoreScheda::getUrl(['record' => (int) $fornitore->id], panel: 'admin-filament');
}
}
$dipendente = FornitoreDipendente::query()
->where('attivo', true)
->where(function ($query) use ($user, $email): void {
$query->where('user_id', (int) $user->id);
if ($email !== '') {
$query->orWhereRaw('LOWER(email) = ?', [$email]);
}
})
->orderByDesc('id')
->first();
if ($dipendente instanceof FornitoreDipendente) {
return FornitoreScheda::getUrl(['record' => (int) $dipendente->fornitore_id], panel: 'admin-filament');
}
return TicketOperativi::getUrl(panel: 'admin-filament');
}
} }

View File

@ -0,0 +1,790 @@
<?php
namespace App\Services\Catalog;
use App\Models\AssistenzaTecnorepairScheda;
use App\Models\FatturaElettronica;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductMedia;
use App\Models\ProductSerial;
use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use Illuminate\Support\Str;
class FornitoreProductCatalogService
{
public function __construct(
private readonly FatturaElettronicaXmlParser $xmlParser,
private readonly ProductOfferService $productOfferService,
) {
}
/**
* @return array<string,int>
*/
public function extractFromFattureElettroniche(Fornitore $fornitore, int $stabileId, int $limit = 100): array
{
$stats = [
'fatture' => 0,
'lines' => 0,
'products' => 0,
'identifiers' => 0,
'linked_rows' => 0,
'skipped' => 0,
];
$fatture = FatturaElettronica::query()
->where('stabile_id', $stabileId)
->where('fornitore_id', (int) $fornitore->id)
->whereNotNull('xml_content')
->orderByDesc('data_fattura')
->orderByDesc('id')
->limit(max(1, $limit))
->get(['id', 'xml_content']);
foreach ($fatture as $fattura) {
$stats['fatture']++;
try {
$parsed = $this->xmlParser->parse((string) $fattura->xml_content);
} catch (\Throwable) {
$stats['skipped']++;
continue;
}
$righe = is_array($parsed['righe'] ?? null) ? $parsed['righe'] : [];
if ($righe === []) {
continue;
}
$fatturaContabileId = FatturaFornitore::query()
->where('fattura_elettronica_id', (int) $fattura->id)
->value('id');
$fatturaContabileId = is_numeric($fatturaContabileId) ? (int) $fatturaContabileId : null;
foreach ($righe as $riga) {
$stats['lines']++;
$descrizione = trim((string) ($riga['descrizione'] ?? ''));
if ($this->shouldSkipLine($descrizione)) {
$stats['skipped']++;
continue;
}
$identifiers = $this->extractIdentifiersFromText($descrizione, $fornitore);
$product = $this->resolveOrCreateProduct($fornitore, [
'name' => $descrizione,
'type' => 'product',
'unit_measure' => $this->resolveUnitMeasure($descrizione),
'track_serials' => $this->hasIdentifierType($identifiers, 'serial'),
'identifiers' => $identifiers,
'meta' => [
'source' => 'fattura_elettronica',
'source_invoice' => (int) $fattura->id,
'prezzo_unitario' => $riga['prezzo_unitario'] ?? null,
'quantita' => $riga['quantita'] ?? null,
],
]);
$stats['products'] += $product['created'] ? 1 : 0;
foreach ($identifiers as $identifier) {
$createdIdentifier = $this->upsertIdentifier($product['product'], $identifier);
$stats['identifiers'] += $createdIdentifier ? 1 : 0;
}
$this->syncSerialsFromIdentifiers($product['product'], $fornitore, $identifiers, 'fe:' . (int) $fattura->id);
if ($fatturaContabileId !== null) {
$rigaNumero = isset($riga['numero_linea']) && is_numeric($riga['numero_linea']) ? (int) $riga['numero_linea'] : null;
if ($rigaNumero !== null) {
$updated = FatturaFornitore::query()
->whereKey($fatturaContabileId)
->first()?->righe()
->where('riga', $rigaNumero)
->whereNull('product_id')
->update(['product_id' => (int) $product['product']->id]);
$stats['linked_rows'] += (int) $updated;
}
}
}
}
return $stats;
}
public function syncTecnorepairProduct(Fornitore $fornitore, AssistenzaTecnorepairScheda $scheda, ?ProductSerial $serial = null): Product
{
$payload = [
'name' => trim((string) ($scheda->product_model ?: $scheda->product_code ?: 'Prodotto TecnoRepair')),
'type' => 'product',
'brand' => $this->extractBrandFromText((string) ($scheda->product_model ?? '')),
'model' => $this->cleanNullable((string) ($scheda->product_model ?? '')),
'track_serials' => true,
'identifiers' => array_values(array_filter([
$this->buildIdentifierPayload((int) $fornitore->id, 'vendor_sku', 'supplier', (string) ($scheda->product_code ?? ''), 'tecnorepair_mdb', (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id)),
])),
'meta' => [
'source' => 'tecnorepair_mdb',
'legacy_scheda' => (int) $scheda->id,
'legacy_numero' => (string) ($scheda->legacy_numero_scheda ?? ''),
'repair_status' => (string) ($scheda->status_bucket ?? ''),
],
];
$resolved = $this->resolveOrCreateProduct($fornitore, $payload);
$product = $resolved['product'];
if ($serial instanceof ProductSerial) {
$serial->product_id = (int) $product->id;
$serial->save();
}
$vendorCode = $this->cleanNullable((string) ($scheda->product_code ?? ''));
if ($vendorCode !== null) {
$this->upsertIdentifier($product, [
'fornitore_id' => (int) $fornitore->id,
'code_type' => 'vendor_sku',
'code_role' => 'supplier',
'code_value' => $vendorCode,
'source' => 'tecnorepair_mdb',
'source_reference' => (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id),
'payload' => null,
]);
}
foreach (array_filter([
$this->cleanNullable((string) ($scheda->serial_number ?? '')),
$this->cleanNullable((string) ($scheda->serial_number_2 ?? '')),
]) as $serialCode) {
$this->upsertIdentifier($product, [
'fornitore_id' => null,
'code_type' => 'serial',
'code_role' => 'serial',
'code_value' => $serialCode,
'source' => 'tecnorepair_mdb',
'source_reference' => (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id),
'payload' => null,
]);
}
return $product;
}
/**
* @param array<string,mixed> $payload
* @return array{product: Product, created: bool}
*/
public function resolveOrCreateProduct(Fornitore $fornitore, array $payload): array
{
$name = trim((string) ($payload['name'] ?? ''));
if ($name === '') {
$name = 'Prodotto';
}
$canonicalKey = $this->buildCanonicalKey($fornitore, $payload);
$product = $this->resolveProductByIdentifiers($payload['identifiers'] ?? null);
if (! $product instanceof Product) {
$product = Product::query()->where('canonical_key', $canonicalKey)->first();
}
$created = false;
if (! $product instanceof Product) {
$product = Product::query()->create([
'default_fornitore_id' => (int) $fornitore->id,
'type' => (string) ($payload['type'] ?? 'product'),
'canonical_key' => $canonicalKey,
'name' => Str::limit($name, 255, ''),
'brand' => $this->cleanNullable((string) ($payload['brand'] ?? '')),
'model' => $this->cleanNullable((string) ($payload['model'] ?? '')),
'variant_group' => $this->cleanNullable((string) ($payload['variant_group'] ?? '')),
'variant_label' => $this->cleanNullable((string) ($payload['variant_label'] ?? '')),
'color_label' => $this->cleanNullable((string) ($payload['color_label'] ?? '')),
'unit_measure' => $this->cleanNullable((string) ($payload['unit_measure'] ?? '')),
'description' => $this->cleanNullable((string) ($payload['description'] ?? '')),
'track_serials' => (bool) ($payload['track_serials'] ?? false),
'is_active' => true,
'meta' => is_array($payload['meta'] ?? null) ? $payload['meta'] : null,
]);
$created = true;
} else {
$dirty = false;
foreach (['brand', 'model', 'variant_group', 'variant_label', 'color_label', 'unit_measure', 'description'] as $field) {
$incoming = $this->cleanNullable((string) ($payload[$field] ?? ''));
if ($incoming !== null && ! filled($product->{$field})) {
$product->{$field} = $incoming;
$dirty = true;
}
}
if ((bool) ($payload['track_serials'] ?? false) && ! (bool) $product->track_serials) {
$product->track_serials = true;
$dirty = true;
}
$incomingMeta = is_array($payload['meta'] ?? null) ? $payload['meta'] : [];
if ($incomingMeta !== []) {
$existingMeta = is_array($product->meta ?? null) ? $product->meta : [];
$mergedMeta = array_replace_recursive($existingMeta, $incomingMeta);
if ($mergedMeta !== $existingMeta) {
$product->meta = $mergedMeta;
$dirty = true;
}
}
if ($dirty) {
$product->save();
}
}
return ['product' => $product, 'created' => $created];
}
/**
* @param array<string,mixed> $payload
*/
public function upsertIdentifier(Product $product, array $payload): bool
{
$codeValue = trim((string) ($payload['code_value'] ?? ''));
$codeType = trim((string) ($payload['code_type'] ?? ''));
if ($codeValue === '' || $codeType === '') {
return false;
}
$normalized = $this->normalizeCode($codeValue);
if ($normalized === '') {
return false;
}
$identifier = ProductIdentifier::query()->firstOrNew([
'fornitore_id' => isset($payload['fornitore_id']) && is_numeric($payload['fornitore_id']) ? (int) $payload['fornitore_id'] : null,
'code_type' => $codeType,
'normalized_code' => $normalized,
]);
$created = ! $identifier->exists;
$identifier->product_id = (int) $product->id;
$identifier->code_role = $this->cleanNullable((string) ($payload['code_role'] ?? ''));
$identifier->code_value = $codeValue;
$identifier->source = $this->cleanNullable((string) ($payload['source'] ?? ''));
$identifier->source_reference = $this->cleanNullable((string) ($payload['source_reference'] ?? ''));
$identifier->is_primary = (bool) ($payload['is_primary'] ?? false);
$identifier->payload = is_array($payload['payload'] ?? null) ? $payload['payload'] : null;
$identifier->save();
return $created;
}
/**
* @return array<string,int>
*/
public function importWholesaleCsv(Fornitore $fornitore, string $csvPath, int $limit = 0, bool $dryRun = false): array
{
if (! is_file($csvPath)) {
throw new \RuntimeException('CSV non trovato: ' . $csvPath);
}
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException('Impossibile aprire il CSV: ' . $csvPath);
}
$stats = [
'rows' => 0,
'products' => 0,
'updated' => 0,
'identifiers' => 0,
'media' => 0,
'offers' => 0,
'private_links' => 0,
'remote_media_pruned' => 0,
'skipped' => 0,
];
$headers = fgetcsv($handle, 0, ';', '"');
if (! is_array($headers) || $headers === []) {
fclose($handle);
throw new \RuntimeException('Header CSV NCOM non valido.');
}
$headers = array_map(fn($value) => trim((string) $value), $headers);
while (($row = fgetcsv($handle, 0, ';', '"')) !== false) {
$stats['rows']++;
if ($limit > 0 && $stats['rows'] > $limit) {
break;
}
$mapped = [];
foreach ($headers as $index => $header) {
if ($header === '') {
continue;
}
$mapped[$header] = trim((string) ($row[$index] ?? ''));
}
$sku = $this->cleanNullable((string) ($mapped['SKU'] ?? ''));
$name = $this->cleanNullable((string) ($mapped['DESCRIZIONE_BREVE'] ?? ''));
$description = $this->cleanNullable((string) ($mapped['DESCRIZIONE_ESTESA'] ?? ''));
if ($sku === null && $name === null && $description === null) {
$stats['skipped']++;
continue;
}
$payload = [
'name' => $name ?? $description ?? $sku ?? 'Prodotto NCOM',
'type' => 'product',
'brand' => $this->cleanNullable((string) ($mapped['MARCA'] ?? '')),
'model' => $this->cleanNullable((string) ($mapped['MODELLO'] ?? '')),
'description' => $description,
'track_serials' => true,
'identifiers' => array_values(array_filter([
$this->buildIdentifierPayload((int) $fornitore->id, 'vendor_sku', 'supplier_catalog', (string) ($mapped['SKU'] ?? ''), 'ncom_wholesale_csv', basename($csvPath)),
])),
'meta' => [
'source' => 'ncom_wholesale_csv',
'pricing' => [
'wholesale' => $this->toDecimal((string) ($mapped['PREZZO'] ?? '')),
'offer' => $this->toDecimal((string) ($mapped['PREZZO_OFFERTA'] ?? '')),
'public' => $this->toDecimal((string) ($mapped['PREZZO_CONSIGLIATO_PUBBLICO'] ?? '')),
],
'stock' => [
'quantity' => is_numeric($mapped['QUANTITA'] ?? null) ? (int) $mapped['QUANTITA'] : null,
],
'catalog' => [
'categories' => $this->cleanNullable((string) ($mapped['CATEGORIE'] ?? '')),
'publication_mode' => 'internal_only',
'source_file' => basename($csvPath),
'private_source_links' => $this->buildCatalogPrivateSourceLinks($mapped),
],
],
];
if ($dryRun) {
$existingProduct = $this->resolveCatalogProductCandidate($fornitore, $payload);
if ($existingProduct instanceof Product) {
$stats['updated']++;
} else {
$stats['products']++;
}
$stats['identifiers'] += count($payload['identifiers']);
$stats['private_links'] += $this->countPrivateSourceLinks($mapped);
continue;
}
$resolved = $this->resolveOrCreateProduct($fornitore, $payload);
$product = $resolved['product'];
if ($resolved['created']) {
$stats['products']++;
} else {
$stats['updated']++;
}
foreach ($payload['identifiers'] as $identifier) {
$stats['identifiers'] += $this->upsertIdentifier($product, $identifier) ? 1 : 0;
}
$this->productOfferService->syncInternalSupplierOffer($product, $fornitore, [
'external_sku' => (string) ($mapped['SKU'] ?? ''),
'title' => (string) ($product->name ?? ''),
'currency' => 'EUR',
'price_amount' => $mapped['PREZZO'] ?? null,
'price_compare_at' => $mapped['PREZZO_CONSIGLIATO_PUBBLICO'] ?? null,
'availability' => is_numeric($mapped['QUANTITA'] ?? null) && (int) $mapped['QUANTITA'] > 0 ? 'in_stock' : 'unknown',
'stock_qty' => is_numeric($mapped['QUANTITA'] ?? null) ? (int) $mapped['QUANTITA'] : null,
'external_url' => $this->cleanNullable((string) ($mapped['SCHEDA_PRODOTTO'] ?? '')),
'meta' => [
'source' => 'ncom_wholesale_csv',
'offer_price' => $this->toDecimal((string) ($mapped['PREZZO_OFFERTA'] ?? '')),
'categories' => $this->cleanNullable((string) ($mapped['CATEGORIE'] ?? '')),
'reference_file' => basename($csvPath),
],
]);
$stats['offers']++;
$stats['private_links'] += $this->countPrivateSourceLinks($mapped);
$stats['remote_media_pruned'] += $this->pruneRemoteSourceMedia($product);
$this->syncInternalCatalogPublication($product, basename($csvPath), $mapped);
}
fclose($handle);
return $stats;
}
/**
* @param array<string,mixed> $payload
*/
private function resolveCatalogProductCandidate(Fornitore $fornitore, array $payload): ?Product
{
$product = $this->resolveProductByIdentifiers($payload['identifiers'] ?? null);
if ($product instanceof Product) {
return $product;
}
return Product::query()
->where('canonical_key', $this->buildCanonicalKey($fornitore, $payload))
->first();
}
/**
* @return array<int,array<string,mixed>>
*/
private function extractIdentifiersFromText(string $description, Fornitore $fornitore): array
{
$description = trim($description);
if ($description === '') {
return [];
}
$identifiers = [];
if (preg_match_all('/\b(?:EAN|GTIN)[\s:\-#]*([0-9]{8,14})\b/i', $description, $matches)) {
foreach ($matches[1] as $code) {
$identifiers[] = [
'fornitore_id' => null,
'code_type' => 'ean',
'code_role' => 'barcode',
'code_value' => $code,
'source' => 'fattura_elettronica',
'source_reference' => null,
'payload' => ['raw' => $description],
];
}
}
if (preg_match('/\b(?:SKU|ART|COD(?:ICE)?|MODELLO|MPN)[\s:\-#]*([A-Z0-9._\/-]{4,})\b/i', $description, $match)) {
$identifiers[] = [
'fornitore_id' => (int) $fornitore->id,
'code_type' => 'vendor_sku',
'code_role' => 'supplier',
'code_value' => $match[1],
'source' => 'fattura_elettronica',
'source_reference' => null,
'payload' => ['raw' => $description],
];
}
if (preg_match_all('/(?:S\/?N|SERIALE|SERIAL(?:\s+NUMBER)?)\s*[:#-]?\s*([A-Z0-9._\/-]{5,})/iu', $description, $matches)) {
foreach ($matches[1] as $serial) {
$identifiers[] = [
'fornitore_id' => null,
'code_type' => 'serial',
'code_role' => 'serial',
'code_value' => $serial,
'source' => 'fattura_elettronica',
'source_reference' => null,
'payload' => ['raw' => $description],
];
}
}
if (preg_match_all('/(?:INTERNO|CODICE\s+ARTICOLO\s+INTERNO)\s*[:#-]?\s*([A-Z0-9+._\/-]{4,})/iu', $description, $matches)) {
foreach ($matches[1] as $code) {
$identifiers[] = [
'fornitore_id' => (int) $fornitore->id,
'code_type' => 'vendor_sku',
'code_role' => 'supplier_internal',
'code_value' => $code,
'source' => 'fattura_elettronica',
'source_reference' => null,
'payload' => ['raw' => $description],
];
}
}
return $identifiers;
}
private function resolveProductByIdentifiers(mixed $identifiers): ?Product
{
if (! is_array($identifiers)) {
return null;
}
foreach ($identifiers as $identifier) {
if (! is_array($identifier)) {
continue;
}
$codeType = trim((string) ($identifier['code_type'] ?? ''));
$codeValue = trim((string) ($identifier['code_value'] ?? ''));
if ($codeType === '' || $codeValue === '') {
continue;
}
$normalized = $this->normalizeCode($codeValue);
if ($normalized === '') {
continue;
}
$query = ProductIdentifier::query()
->where('code_type', $codeType)
->where('normalized_code', $normalized);
if (array_key_exists('fornitore_id', $identifier)) {
$fornitoreId = is_numeric($identifier['fornitore_id']) ? (int) $identifier['fornitore_id'] : null;
$query->where('fornitore_id', $fornitoreId);
}
$productId = $query->value('product_id');
if (is_numeric($productId)) {
return Product::query()->find((int) $productId);
}
}
return null;
}
private function hasIdentifierType(array $identifiers, string $type): bool
{
foreach ($identifiers as $identifier) {
if (is_array($identifier) && (string) ($identifier['code_type'] ?? '') === $type) {
return true;
}
}
return false;
}
/**
* @return array<string,mixed>|null
*/
private function buildIdentifierPayload(?int $fornitoreId, string $codeType, string $codeRole, string $codeValue, string $source, ?string $reference): ?array
{
$codeValue = trim($codeValue);
if ($codeValue === '') {
return null;
}
return [
'fornitore_id' => $fornitoreId,
'code_type' => $codeType,
'code_role' => $codeRole,
'code_value' => $codeValue,
'source' => $source,
'source_reference' => $reference,
'payload' => null,
];
}
private function upsertRemoteMedia(Product $product, string $mediaType, string $url, string $title): bool
{
$url = trim($url);
if ($url === '' || ! str_starts_with($url, 'http')) {
return false;
}
$media = ProductMedia::query()->firstOrNew([
'product_id' => (int) $product->id,
'media_type' => $mediaType,
'disk' => 'remote-url',
'path' => $url,
]);
$created = ! $media->exists;
$media->title = $title;
$media->meta = ['remote' => true];
$media->save();
return $created;
}
/**
* @param array<string,mixed> $mapped
* @return array<string,string>
*/
private function buildCatalogPrivateSourceLinks(array $mapped): array
{
return array_filter([
'product_page' => trim((string) ($mapped['SCHEDA_PRODOTTO'] ?? '')),
'image_link' => trim((string) ($mapped['IMAGE_LINK'] ?? '')),
], static fn(string $value): bool => $value !== '');
}
/**
* @param array<string,mixed> $mapped
*/
private function countPrivateSourceLinks(array $mapped): int
{
return count($this->buildCatalogPrivateSourceLinks($mapped));
}
private function pruneRemoteSourceMedia(Product $product): int
{
$query = ProductMedia::query()
->where('product_id', (int) $product->id)
->where('disk', 'remote-url');
$count = (clone $query)->count();
if ($count > 0) {
$query->delete();
}
return (int) $count;
}
/**
* @param array<string,mixed> $mapped
*/
private function syncInternalCatalogPublication(Product $product, string $sourceFile, array $mapped): void
{
$meta = is_array($product->meta ?? null) ? $product->meta : [];
$catalog = is_array($meta['catalog'] ?? null) ? $meta['catalog'] : [];
$updatedCatalog = array_replace_recursive($catalog, [
'publication_mode' => 'internal_only',
'public_reference' => (string) ($product->internal_code ?? ''),
'source_file' => $sourceFile,
'private_source_links' => $this->buildCatalogPrivateSourceLinks($mapped),
]);
if ($updatedCatalog === $catalog) {
return;
}
$meta['catalog'] = $updatedCatalog;
$product->meta = $meta;
$product->save();
}
private function syncSerialsFromIdentifiers(Product $product, Fornitore $fornitore, array $identifiers, string $reference): void
{
foreach ($identifiers as $identifier) {
if (! is_array($identifier) || (string) ($identifier['code_type'] ?? '') !== 'serial') {
continue;
}
$serial = trim((string) ($identifier['code_value'] ?? ''));
if ($serial === '') {
continue;
}
$existing = ProductSerial::query()
->where('product_id', (int) $product->id)
->where('serial_number', $serial)
->first();
if ($existing instanceof ProductSerial) {
continue;
}
ProductSerial::query()->create([
'fornitore_id' => (int) $fornitore->id,
'product_id' => (int) $product->id,
'customer_name' => null,
'product_model' => $product->name,
'product_code' => $product->internal_code,
'serial_number' => $serial,
'serial_number_2' => null,
'date_received' => null,
'date_resold' => null,
'internal_notes' => 'Seriale acquisito da FE',
'source' => 'fattura_elettronica',
'source_reference' => $reference,
]);
}
}
private function toDecimal(string $value): ?float
{
$value = trim($value);
if ($value === '') {
return null;
}
$value = str_replace('.', '', $value);
$value = str_replace(',', '.', $value);
return is_numeric($value) ? round((float) $value, 2) : null;
}
private function buildCanonicalKey(Fornitore $fornitore, array $payload): string
{
$name = $this->normalizeText((string) ($payload['name'] ?? ''));
$brand = $this->normalizeText((string) ($payload['brand'] ?? ''));
$model = $this->normalizeText((string) ($payload['model'] ?? ''));
$color = $this->normalizeText((string) ($payload['color_label'] ?? ''));
$key = implode('|', array_values(array_filter([$brand, $model, $name, $color])));
if ($key === '') {
$key = 'fornitore:' . (int) $fornitore->id . '|' . $this->normalizeText((string) ($fornitore->ragione_sociale ?? 'prodotto'));
}
return Str::limit($key, 190, '');
}
private function normalizeText(string $value): string
{
$value = Str::of($value)
->ascii()
->lower()
->replaceMatches('/[^a-z0-9]+/', ' ')
->trim()
->replace(' pez', '')
->replace(' pz', '')
->value();
return preg_replace('/\s+/', '-', $value) ?: '';
}
private function normalizeCode(string $value): string
{
return strtoupper(preg_replace('/[^A-Z0-9]+/i', '', trim($value)) ?? '');
}
private function resolveUnitMeasure(string $description): ?string
{
$lower = Str::lower($description);
return match (true) {
Str::contains($lower, [' kg', ' kilo']) => 'kg',
Str::contains($lower, [' lt', ' litro']) => 'lt',
Str::contains($lower, [' mq', ' metro quadro']) => 'mq',
default => 'pz',
};
}
private function extractBrandFromText(string $description): ?string
{
$description = trim($description);
if ($description === '') {
return null;
}
$parts = preg_split('/\s+/', $description) ?: [];
$first = trim((string) ($parts[0] ?? ''));
return $first !== '' && mb_strlen($first) >= 3 ? Str::upper($first) : null;
}
private function shouldSkipLine(string $description): bool
{
if ($description === '') {
return true;
}
$clean = Str::lower($description);
if (Str::contains($clean, ['sconto', 'arrotond', 'bollo', 'cassa previdenziale'])) {
return true;
}
return mb_strlen($clean) < 4;
}
private function cleanNullable(string $value): ?string
{
$value = trim($value);
return $value !== '' ? $value : null;
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Services\Catalog;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Support\ArchivioPaths;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ProductAssetIngestionService
{
/**
* @return array<string,int>
*/
public function ingestForFornitore(Fornitore $fornitore, int $limit = 50, bool $dryRun = false): array
{
$stats = [
'products' => 0,
'images' => 0,
'documents' => 0,
'skipped_existing' => 0,
'skipped_unsupported' => 0,
'failed' => 0,
];
$products = Product::query()
->where('default_fornitore_id', (int) $fornitore->id)
->orderBy('id')
->limit(max(1, $limit))
->get(['id', 'internal_code', 'meta']);
foreach ($products as $product) {
$stats['products']++;
$catalog = is_array($product->meta['catalog'] ?? null) ? $product->meta['catalog'] : [];
$links = is_array($catalog['private_source_links'] ?? null) ? $catalog['private_source_links'] : [];
$imageResult = $this->ingestSingleAsset($product, $fornitore, (string) ($links['image_link'] ?? ''), 'image', $dryRun);
$stats[$imageResult['bucket']]++;
$documentResult = $this->ingestSingleAsset($product, $fornitore, (string) ($links['product_page'] ?? ''), 'document', $dryRun);
$stats[$documentResult['bucket']]++;
}
return $stats;
}
/**
* @return array{bucket:string}
*/
private function ingestSingleAsset(Product $product, Fornitore $fornitore, string $url, string $mediaType, bool $dryRun): array
{
$url = trim($url);
if ($url === '' || ! str_starts_with($url, 'http')) {
return ['bucket' => 'skipped_unsupported'];
}
$existing = ProductMedia::query()
->where('product_id', (int) $product->id)
->where('media_type', $mediaType)
->whereIn('disk', ['local', 'public'])
->exists();
if ($existing) {
return ['bucket' => 'skipped_existing'];
}
if ($dryRun) {
return ['bucket' => $mediaType === 'image' ? 'images' : 'documents'];
}
try {
$response = Http::timeout(20)
->withHeaders(['User-Agent' => 'NetgesconCatalogBot/1.0'])
->get($url);
} catch (\Throwable) {
return ['bucket' => 'failed'];
}
if (! $response->successful()) {
return ['bucket' => 'failed'];
}
$mime = strtolower(trim((string) $response->header('Content-Type')));
if ($mime !== '' && str_contains($mime, ';')) {
$mime = trim((string) Str::before($mime, ';'));
}
if ($mediaType === 'image' && ! str_starts_with($mime, 'image/')) {
return ['bucket' => 'skipped_unsupported'];
}
if ($mediaType === 'document' && ! str_contains($mime, 'pdf') && ! str_ends_with(Str::lower(parse_url($url, PHP_URL_PATH) ?: ''), '.pdf')) {
return ['bucket' => 'skipped_unsupported'];
}
$extension = $this->guessExtension($url, $mime, $mediaType);
$folder = $this->buildMediaFolder($fornitore, $product, $mediaType);
$filename = Str::slug((string) ($product->internal_code ?? ('product-' . $product->id))) . '-' . substr(sha1($url), 0, 12) . '.' . $extension;
$path = $folder . '/' . $filename;
Storage::disk('public')->put($path, $response->body());
ProductMedia::query()->create([
'product_id' => (int) $product->id,
'media_type' => $mediaType,
'disk' => 'public',
'path' => $path,
'title' => $mediaType === 'image' ? 'Immagine catalogo interna' : 'Scheda prodotto interna',
'mime_type' => $mime !== '' ? $mime : null,
'size_bytes' => strlen($response->body()),
'sort_order' => 0,
'meta' => [
'source_url' => $url,
'source_system' => 'private_catalog_link',
'internalized' => true,
],
]);
return ['bucket' => $mediaType === 'image' ? 'images' : 'documents'];
}
private function buildMediaFolder(Fornitore $fornitore, Product $product, string $mediaType): string
{
$base = ArchivioPaths::fornitoreBase($fornitore);
if (! is_string($base) || $base === '') {
$base = 'catalogo/fornitori/' . ($fornitore->codice_univoco ?: $fornitore->id);
}
return trim('catalogo-interno/' . trim($base, '/') . '/prodotti/' . ($product->internal_code ?: $product->id) . '/' . $mediaType, '/');
}
private function guessExtension(string $url, string $mime, string $mediaType): string
{
$path = Str::lower((string) (parse_url($url, PHP_URL_PATH) ?: ''));
$ext = pathinfo($path, PATHINFO_EXTENSION);
if (is_string($ext) && $ext !== '') {
return $ext;
}
return match (true) {
str_contains($mime, 'png') => 'png',
str_contains($mime, 'webp') => 'webp',
str_contains($mime, 'jpeg'), str_contains($mime, 'jpg') => 'jpg',
$mediaType === 'document' => 'pdf',
default => 'bin',
};
}
}

View File

@ -0,0 +1,246 @@
<?php
namespace App\Services\Catalog;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductOffer;
use Illuminate\Support\Str;
class ProductOfferService
{
/**
* @param array<string,mixed> $payload
*/
public function syncOffer(Product $product, array $payload): ProductOffer
{
$sourceType = trim((string) ($payload['source_type'] ?? 'internal_supplier'));
$fornitoreId = isset($payload['fornitore_id']) && is_numeric($payload['fornitore_id']) ? (int) $payload['fornitore_id'] : null;
$externalProductId = $this->cleanNullable((string) ($payload['external_product_id'] ?? ''));
$externalSku = $this->cleanNullable((string) ($payload['external_sku'] ?? ''));
$offerKey = trim((string) ($payload['offer_key'] ?? ''));
if ($offerKey === '') {
$offerKey = $this->buildOfferKey($product, $sourceType, $fornitoreId, $externalProductId, $externalSku, (string) ($payload['title'] ?? ''));
}
$offer = ProductOffer::query()->firstOrNew(['offer_key' => $offerKey]);
$offer->fill([
'product_id' => (int) $product->id,
'fornitore_id' => $fornitoreId,
'source_type' => $sourceType,
'source_name' => $this->cleanNullable((string) ($payload['source_name'] ?? '')),
'external_product_id' => $externalProductId,
'external_sku' => $externalSku,
'title' => $this->cleanNullable((string) ($payload['title'] ?? '')),
'condition_label' => $this->cleanNullable((string) ($payload['condition_label'] ?? '')),
'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))) ?: 'EUR',
'price_amount' => $this->normalizeMoney($payload['price_amount'] ?? null),
'price_compare_at' => $this->normalizeMoney($payload['price_compare_at'] ?? null),
'shipping_amount' => $this->normalizeMoney($payload['shipping_amount'] ?? null),
'availability' => $this->cleanNullable((string) ($payload['availability'] ?? '')),
'stock_qty' => is_numeric($payload['stock_qty'] ?? null) ? (int) $payload['stock_qty'] : null,
'external_url' => $this->cleanNullable((string) ($payload['external_url'] ?? '')),
'referral_url' => $this->cleanNullable((string) ($payload['referral_url'] ?? '')),
'referral_code' => $this->cleanNullable((string) ($payload['referral_code'] ?? '')),
'is_active' => array_key_exists('is_active', $payload) ? (bool) $payload['is_active'] : true,
'is_internal' => (bool) ($payload['is_internal'] ?? false),
'last_seen_at' => $payload['last_seen_at'] ?? now(),
'meta' => is_array($payload['meta'] ?? null) ? $payload['meta'] : null,
]);
$offer->save();
return $offer;
}
/**
* @param array<string,mixed> $payload
*/
public function syncInternalSupplierOffer(Product $product, Fornitore $fornitore, array $payload): ProductOffer
{
return $this->syncOffer($product, array_replace_recursive($payload, [
'fornitore_id' => (int) $fornitore->id,
'source_type' => 'internal_supplier',
'source_name' => (string) ($fornitore->ragione_sociale ?? ('Fornitore #' . $fornitore->id)),
'is_internal' => true,
'is_active' => true,
]));
}
public function syncAmazonReferralOffer(Product $product, ?string $associateTag = null, string $locale = 'it'): ?ProductOffer
{
$link = $this->buildAmazonReferralUrl($product, $associateTag, $locale);
if ($link === null) {
return null;
}
$asin = $this->findIdentifierValue($product, ['asin']);
$ean = $this->findIdentifierValue($product, ['ean']);
$resolution = $asin !== null ? 'asin' : ($ean !== null ? 'ean' : 'search');
return $this->syncOffer($product, [
'source_type' => 'amazon_referral',
'source_name' => 'Amazon',
'external_product_id' => $asin ?? $ean ?? null,
'title' => (string) ($product->name ?? ''),
'currency' => 'EUR',
'availability' => 'link_referral_only',
'external_url' => $this->stripAmazonAssociateTag($link),
'referral_url' => $link,
'referral_code' => $this->cleanNullable((string) $associateTag),
'is_internal' => false,
'is_active' => true,
'meta' => [
'locale' => $locale,
'resolution' => $resolution,
'generated_from' => [
'asin' => $asin,
'ean' => $ean,
],
],
]);
}
public function buildAmazonReferralUrl(Product $product, ?string $associateTag = null, string $locale = 'it'): ?string
{
$domain = match (strtolower($locale)) {
'de' => 'www.amazon.de',
'fr' => 'www.amazon.fr',
'es' => 'www.amazon.es',
'uk' => 'www.amazon.co.uk',
default => 'www.amazon.it',
};
$asin = $this->findIdentifierValue($product, ['asin']);
if ($asin !== null) {
return $this->appendQuery('https://' . $domain . '/dp/' . rawurlencode($asin), $associateTag);
}
$ean = $this->findIdentifierValue($product, ['ean']);
if ($ean !== null) {
return $this->appendQuery('https://' . $domain . '/s?k=' . rawurlencode($ean), $associateTag);
}
$query = $this->buildAmazonSearchQuery($product);
if ($query === '') {
return null;
}
return $this->appendQuery('https://' . $domain . '/s?k=' . rawurlencode($query), $associateTag);
}
private function buildOfferKey(Product $product, string $sourceType, ?int $fornitoreId, ?string $externalProductId, ?string $externalSku, string $title): string
{
$seed = implode('|', [
(int) $product->id,
$sourceType,
$fornitoreId ?? 'null',
$externalProductId ?? '',
$externalSku ?? '',
Str::limit(Str::lower(trim($title)), 80, ''),
]);
return Str::limit($sourceType . ':' . sha1($seed), 190, '');
}
/**
* @param array<int,string> $types
*/
private function findIdentifierValue(Product $product, array $types): ?string
{
$identifier = ProductIdentifier::query()
->where('product_id', (int) $product->id)
->whereIn('code_type', $types)
->orderByDesc('is_primary')
->orderBy('id')
->value('code_value');
$value = trim((string) $identifier);
return $value !== '' ? $value : null;
}
private function appendQuery(string $url, ?string $associateTag = null): string
{
$tag = $this->cleanNullable((string) $associateTag);
if ($tag === null) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . 'tag=' . rawurlencode($tag);
}
private function buildAmazonSearchQuery(Product $product): string
{
$parts = array_filter([
$this->cleanSearchPart((string) ($product->brand ?? '')),
$this->cleanSearchPart((string) ($product->model ?? '')),
$this->cleanSearchPart((string) ($product->name ?? '')),
], static fn(string $value): bool => $value !== '');
$parts = array_values(array_unique($parts));
$query = trim(implode(' ', $parts));
return Str::limit($query, 120, '');
}
private function cleanSearchPart(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
$value = preg_replace('/\bS\/?N\s*[:#-]?\s*[A-Z0-9._\/-]{4,}\b/iu', ' ', $value) ?? $value;
$value = preg_replace('/\b(?:INTERNO|CODICE\s+ARTICOLO\s+INTERNO|SKU|ART|COD(?:ICE)?|MPN)\s*[:#-]?\s*[A-Z0-9._\/-]{3,}\b/iu', ' ', $value) ?? $value;
$value = preg_replace('/\b(?:GRADO\s+[A-Z0-9+]+|WINDOWS\s+\d+(?:\s+PRO)?|RICONDIZIONAT[OA])\b/iu', ' ', $value) ?? $value;
$value = str_replace(['//', '|', ',', ';'], ' ', $value);
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return trim($value);
}
private function stripAmazonAssociateTag(string $url): string
{
$parts = parse_url($url);
if (! is_array($parts)) {
return $url;
}
$query = [];
parse_str((string) ($parts['query'] ?? ''), $query);
unset($query['tag']);
$base = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . ($parts['path'] ?? '');
if ($query === []) {
return $base;
}
return $base . '?' . http_build_query($query);
}
private function cleanNullable(string $value): ?string
{
$value = trim($value);
return $value !== '' ? $value : null;
}
private function normalizeMoney(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_string($value)) {
$value = trim($value);
$value = str_replace('.', '', $value);
$value = str_replace(',', '.', $value);
}
return is_numeric($value) ? round((float) $value, 2) : null;
}
}

View File

@ -1,17 +1,16 @@
<?php <?php
namespace App\Services\FattureElettroniche; namespace App\Services\FattureElettroniche;
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\FeCassettoServiceConfig; use App\Models\FeCassettoServiceConfig;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\StgFatturaAde; use App\Models\StgFatturaAde;
use App\Services\FattureElettroniche\P7mExtractor;
use App\Support\ArchivioPaths; use App\Support\ArchivioPaths;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Services\FattureElettroniche\P7mExtractor;
class CassettoFiscaleDownloadService class CassettoFiscaleDownloadService
{ {
@ -38,7 +37,7 @@ public function downloadAndImport(
$adminLabel = 'ID-' . (int) $amministratore->id; $adminLabel = 'ID-' . (int) $amministratore->id;
} }
return [ return [
'status' => 'error', 'status' => 'error',
'message' => 'Servizio non abilitato per amministratore ' . $adminLabel . ' (amm_id=' . (int) $amministratore->id . ', stabile_id=' . (int) $stabile->id . ')', 'message' => 'Servizio non abilitato per amministratore ' . $adminLabel . ' (amm_id=' . (int) $amministratore->id . ', stabile_id=' . (int) $stabile->id . ')',
]; ];
} }
@ -48,10 +47,10 @@ public function downloadAndImport(
$expires = $amministratore->fe_cassetto_expires_at; $expires = $amministratore->fe_cassetto_expires_at;
if ($expires instanceof \DateTimeInterface) { if ($expires instanceof \DateTimeInterface) {
$today = new \DateTimeImmutable('today'); $today = new \DateTimeImmutable('today');
$exp = new \DateTimeImmutable($expires->format('Y-m-d')); $exp = new \DateTimeImmutable($expires->format('Y-m-d'));
if ($exp < $today) { if ($exp < $today) {
return [ return [
'status' => 'error', 'status' => 'error',
'message' => 'Servizio scaduto al ' . $exp->format('d/m/Y') . ' (amm_id=' . (int) $amministratore->id . ')', 'message' => 'Servizio scaduto al ' . $exp->format('d/m/Y') . ' (amm_id=' . (int) $amministratore->id . ')',
]; ];
} }
@ -62,19 +61,19 @@ public function downloadAndImport(
} }
$cfg = FeCassettoServiceConfig::query()->first(); $cfg = FeCassettoServiceConfig::query()->first();
if (! $cfg) { if (! $cfg && ! $amministratore->fe_cassetto_base_url && ! $amministratore->fe_cassetto_username) {
return ['status' => 'error', 'message' => 'Configurazione servizio Cassetto Fiscale mancante (Super Admin)']; return ['status' => 'error', 'message' => 'Configurazione servizio Cassetto Fiscale mancante (Super Admin)'];
} }
$baseUrl = trim((string) ($cfg->base_url ?? '')); $baseUrl = trim((string) ($amministratore->fe_cassetto_base_url ?: ($cfg->base_url ?? '')));
if ($baseUrl === '') { if ($baseUrl === '') {
$baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php'; $baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php';
} }
$passwordScript = (string) ($cfg->password_script ?? ''); $passwordScript = trim((string) ($amministratore->fe_cassetto_password_script ?: ($cfg?->password_script ?? '')));
$username = (string) ($cfg->username ?? ''); $username = trim((string) ($amministratore->fe_cassetto_username ?: ($cfg?->username ?? '')));
$password = (string) ($cfg->password ?? ''); $password = trim((string) ($amministratore->fe_cassetto_password ?: ($cfg?->password ?? '')));
$pin = (string) ($cfg->pin ?? ''); $pin = trim((string) ($amministratore->fe_cassetto_pin ?: ($cfg?->pin ?? '')));
// passwordScript: nel servizio esterno viene usato come "token"/licenza. // passwordScript: nel servizio esterno viene usato come "token"/licenza.
if ($passwordScript === '') { if ($passwordScript === '') {
@ -86,7 +85,7 @@ public function downloadAndImport(
} }
$dalStr = $dal->format('d-m-Y'); $dalStr = $dal->format('d-m-Y');
$alStr = $al->format('d-m-Y'); $alStr = $al->format('d-m-Y');
$requestHash = hash('sha256', implode('|', [ $requestHash = hash('sha256', implode('|', [
'cf=' . $cf, 'cf=' . $cf,
@ -110,34 +109,34 @@ public function downloadAndImport(
if ($existingOk && $allowSkip) { if ($existingOk && $allowSkip) {
return [ return [
'status' => 'skipped', 'status' => 'skipped',
'message' => 'Download già eseguito per lo stesso periodo (log #' . (int) $existingOk->id . ')', 'message' => 'Download già eseguito per lo stesso periodo (log #' . (int) $existingOk->id . ')',
'log_id' => (int) $existingOk->id, 'log_id' => (int) $existingOk->id,
]; ];
} }
$logId = (int) DB::table('fe_cassetto_download_logs')->insertGetId([ $logId = (int) DB::table('fe_cassetto_download_logs')->insertGetId([
'amministratore_id' => (int) $amministratore->id, 'amministratore_id' => (int) $amministratore->id,
'stabile_id' => (int) $stabile->id, 'stabile_id' => (int) $stabile->id,
'codice_fiscale_soggetto' => $cf, 'codice_fiscale_soggetto' => $cf,
'dal' => $dal->format('Y-m-d'), 'dal' => $dal->format('Y-m-d'),
'al' => $al->format('Y-m-d'), 'al' => $al->format('Y-m-d'),
'metadati' => $metadati ? 1 : 0, 'metadati' => $metadati ? 1 : 0,
'request_hash' => $requestHash, 'request_hash' => $requestHash,
'status' => 'started', 'status' => 'started',
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
try { try {
$query = [ $query = [
'passwordScript' => $passwordScript, 'passwordScript' => $passwordScript,
'usernameEntratel' => $username, 'usernameEntratel' => $username,
'passwordEntratel' => $password, 'passwordEntratel' => $password,
'pinEntratel' => $pin, 'pinEntratel' => $pin,
'cf' => $cf, 'cf' => $cf,
'dal' => $dalStr, 'dal' => $dalStr,
'al' => $alStr, 'al' => $alStr,
]; ];
if ($metadati) { if ($metadati) {
@ -168,8 +167,8 @@ public function downloadAndImport(
$msg .= ' | preview: ' . $preview; $msg .= ' | preview: ' . $preview;
} }
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'status' => 'error', 'status' => 'error',
'message' => $msg, 'message' => $msg,
'updated_at' => now(), 'updated_at' => now(),
]); ]);
return ['status' => 'error', 'message' => $msg, 'log_id' => $logId]; return ['status' => 'error', 'message' => $msg, 'log_id' => $logId];
@ -186,22 +185,22 @@ public function downloadAndImport(
throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview); throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview);
} }
$zipSha = hash('sha256', $bytes); $zipSha = hash('sha256', $bytes);
$ym = now()->format('Y-m'); $ym = now()->format('Y-m');
$adminBase = ArchivioPaths::adminBase($amministratore); $adminBase = ArchivioPaths::adminBase($amministratore);
$stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id); $stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id);
$stabileBase = $adminBase . '/stabili/' . $stabileFolder; $stabileBase = $adminBase . '/stabili/' . $stabileFolder;
$basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym; $basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym;
$uuid = (string) Str::uuid(); $uuid = (string) Str::uuid();
$zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip'; $zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip';
Storage::disk('local')->put($zipPath, $bytes); Storage::disk('local')->put($zipPath, $bytes);
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'zip_sha256' => $zipSha, 'zip_sha256' => $zipSha,
'zip_bytes' => strlen($bytes), 'zip_bytes' => strlen($bytes),
'status' => 'downloaded', 'status' => 'downloaded',
'updated_at' => now(), 'updated_at' => now(),
]); ]);
@ -223,12 +222,12 @@ public function downloadAndImport(
$zip->extractTo($tmpFsPath); $zip->extractTo($tmpFsPath);
$zip->close(); $zip->close();
$riepilogo = $this->findRiepilogoJson($tmpFsPath); $riepilogo = $this->findRiepilogoJson($tmpFsPath);
$riepilogoStoredPath = null; $riepilogoStoredPath = null;
$riepilogoWarnings = []; $riepilogoWarnings = [];
if ($riepilogo !== null) { if ($riepilogo !== null) {
$riepilogoFilename = $riepilogo['filename']; $riepilogoFilename = $riepilogo['filename'];
$riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename; $riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename;
Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']); Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']);
@ -244,14 +243,14 @@ public function downloadAndImport(
} }
} }
$files = $this->collectFeFiles($tmpFsPath); $files = $this->collectFeFiles($tmpFsPath);
$filesTotal = count($files); $filesTotal = count($files);
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser()); $importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
$extractor = app(P7mExtractor::class); $extractor = app(P7mExtractor::class);
$imported = 0; $imported = 0;
$duplicates = 0; $duplicates = 0;
$errors = 0; $errors = 0;
$errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m'); $errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m');
Storage::disk('local')->makeDirectory($errorsDir); Storage::disk('local')->makeDirectory($errorsDir);
@ -263,7 +262,7 @@ public function downloadAndImport(
foreach ($files as $filePath) { foreach ($files as $filePath) {
$filename = basename($filePath); $filename = basename($filePath);
$lower = strtolower($filename); $lower = strtolower($filename);
$bytes = @file_get_contents($filePath); $bytes = @file_get_contents($filePath);
if (! is_string($bytes) || trim($bytes) === '') { if (! is_string($bytes) || trim($bytes) === '') {
@ -271,12 +270,12 @@ public function downloadAndImport(
continue; continue;
} }
$unique = (string) Str::uuid(); $unique = (string) Str::uuid();
$storedP7mPath = null; $storedP7mPath = null;
$storedXmlPath = null; $storedXmlPath = null;
$isP7m = str_ends_with($lower, '.p7m'); $isP7m = str_ends_with($lower, '.p7m');
$xml = $isP7m $xml = $isP7m
? null ? null
: $bytes; : $bytes;
@ -296,8 +295,8 @@ public function downloadAndImport(
$errors++; $errors++;
try { try {
Storage::disk('local')->append($errorsLogPath, json_encode([ Storage::disk('local')->append($errorsLogPath, json_encode([
'type' => 'empty_xml', 'type' => 'empty_xml',
'filename' => $filename, 'filename' => $filename,
'stored_p7m_path' => $storedP7mPath, 'stored_p7m_path' => $storedP7mPath,
'stored_xml_path' => $storedXmlPath, 'stored_xml_path' => $storedXmlPath,
], JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_SLASHES));
@ -315,16 +314,16 @@ public function downloadAndImport(
try { try {
$result = $importer->importXml($xml, 0, $xmlFilename, [ $result = $importer->importXml($xml, 0, $xmlFilename, [
'xml_path' => $storedXmlPath, 'xml_path' => $storedXmlPath,
'p7m_path' => $storedP7mPath, 'p7m_path' => $storedP7mPath,
'nome_file_p7m' => $isP7m ? $filename : null, 'nome_file_p7m' => $isP7m ? $filename : null,
'force_stabile_id' => (int) $stabile->id, 'force_stabile_id' => (int) $stabile->id,
'allow_fallback_stabile' => false, 'allow_fallback_stabile' => false,
'force_update' => (bool) ($opts['force_update'] ?? false), 'force_update' => (bool) ($opts['force_update'] ?? false),
'auto_repair_duplicate' => true, 'auto_repair_duplicate' => true,
'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'), 'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'),
'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true), 'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true),
'user_id' => $userId, 'user_id' => $userId,
]); ]);
$status = (string) ($result['status'] ?? 'error'); $status = (string) ($result['status'] ?? 'error');
@ -336,12 +335,12 @@ public function downloadAndImport(
$errors++; $errors++;
try { try {
Storage::disk('local')->append($errorsLogPath, json_encode([ Storage::disk('local')->append($errorsLogPath, json_encode([
'type' => 'import_error', 'type' => 'import_error',
'filename' => $filename, 'filename' => $filename,
'xml_filename' => $xmlFilename, 'xml_filename' => $xmlFilename,
'stored_p7m_path' => $storedP7mPath, 'stored_p7m_path' => $storedP7mPath,
'stored_xml_path' => $storedXmlPath, 'stored_xml_path' => $storedXmlPath,
'result' => $result, 'result' => $result,
], JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_SLASHES));
} catch (\Throwable) { } catch (\Throwable) {
} }
@ -350,19 +349,19 @@ public function downloadAndImport(
$errors++; $errors++;
try { try {
Storage::disk('local')->append($errorsLogPath, json_encode([ Storage::disk('local')->append($errorsLogPath, json_encode([
'type' => 'exception', 'type' => 'exception',
'filename' => $filename, 'filename' => $filename,
'xml_filename' => $xmlFilename, 'xml_filename' => $xmlFilename,
'stored_p7m_path' => $storedP7mPath, 'stored_p7m_path' => $storedP7mPath,
'stored_xml_path' => $storedXmlPath, 'stored_xml_path' => $storedXmlPath,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
], JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_SLASHES));
} catch (\Throwable) { } catch (\Throwable) {
} }
} }
} }
$messageParts = []; $messageParts = [];
$messageParts[] = 'zip=' . $zipPath; $messageParts[] = 'zip=' . $zipPath;
if ($riepilogo !== null) { if ($riepilogo !== null) {
$tot = $riepilogo['data']['totaleFatture'] ?? null; $tot = $riepilogo['data']['totaleFatture'] ?? null;
@ -379,26 +378,26 @@ public function downloadAndImport(
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'files_total' => $filesTotal, 'files_total' => $filesTotal,
'imported' => $imported, 'imported' => $imported,
'duplicates' => $duplicates, 'duplicates' => $duplicates,
'errors' => $errors, 'errors' => $errors,
'status' => 'ok', 'status' => 'ok',
'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null, 'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null,
'updated_at' => now(), 'updated_at' => now(),
]); ]);
return [ return [
'status' => 'ok', 'status' => 'ok',
'log_id' => $logId, 'log_id' => $logId,
'files_total' => $filesTotal, 'files_total' => $filesTotal,
'imported' => $imported, 'imported' => $imported,
'duplicates' => $duplicates, 'duplicates' => $duplicates,
'errors' => $errors, 'errors' => $errors,
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
'status' => 'error', 'status' => 'error',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
@ -423,7 +422,7 @@ private function collectFeFiles(string $rootDir): array
continue; continue;
} }
$name = $file->getFilename(); $name = $file->getFilename();
$lower = strtolower($name); $lower = strtolower($name);
// File di supporto del portale (non sono fatture elettroniche). // File di supporto del portale (non sono fatture elettroniche).
@ -475,7 +474,7 @@ private function findRiepilogoJson(string $rootDir): ?array
continue; continue;
} }
$name = $file->getFilename(); $name = $file->getFilename();
$lower = strtolower($name); $lower = strtolower($name);
if (! str_ends_with($lower, '.json')) { if (! str_ends_with($lower, '.json')) {
continue; continue;
@ -498,7 +497,7 @@ private function findRiepilogoJson(string $rootDir): ?array
return [ return [
'filename' => $name, 'filename' => $name,
'contents' => $contents, 'contents' => $contents,
'data' => $decoded, 'data' => $decoded,
]; ];
} }
@ -523,32 +522,32 @@ private function upsertAdeFromRiepilogo(Amministratore $amministratore, Stabile
} }
$date = null; $date = null;
$d = trim((string) ($row['dataFattura'] ?? '')); $d = trim((string) ($row['dataFattura'] ?? ''));
if ($d !== '') { if ($d !== '') {
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null; $date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null;
} }
$imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0')); $imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0'));
$imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0')); $imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0'));
StgFatturaAde::query()->updateOrCreate( StgFatturaAde::query()->updateOrCreate(
[ [
'amministratore_id' => (int) $amministratore->id, 'amministratore_id' => (int) $amministratore->id,
'stabile_id' => (int) $stabile->id, 'stabile_id' => (int) $stabile->id,
'sdi_file' => $sdi, 'sdi_file' => $sdi,
], ],
[ [
'cartella_legacy' => null, 'cartella_legacy' => null,
'tipo_documento' => (string) ($row['tipoDocumento'] ?? null), 'tipo_documento' => (string) ($row['tipoDocumento'] ?? null),
'numero_documento' => (string) ($row['numeroFattura'] ?? null), 'numero_documento' => (string) ($row['numeroFattura'] ?? null),
'data_emissione' => $date ? $date->format('Y-m-d') : null, 'data_emissione' => $date ? $date->format('Y-m-d') : null,
'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))), 'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))),
'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null), 'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null),
'imponibile' => $imponibile, 'imponibile' => $imponibile,
'imposta' => $imposta, 'imposta' => $imposta,
'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null), 'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null),
'source_file' => $sourceFile, 'source_file' => $sourceFile,
'raw' => $row, 'raw' => $row,
] ]
); );
} }

View File

@ -10,6 +10,7 @@
use App\Models\Stabile; use App\Models\Stabile;
use App\Modules\Contabilita\Models\FatturaFornitore; use App\Modules\Contabilita\Models\FatturaFornitore;
use App\Modules\Contabilita\Models\RegolaPrimaNota; use App\Modules\Contabilita\Models\RegolaPrimaNota;
use App\Services\Catalog\FornitoreProductCatalogService;
use App\Support\ArchivioPaths; use App\Support\ArchivioPaths;
use DOMDocument; use DOMDocument;
use DOMXPath; use DOMXPath;
@ -113,10 +114,13 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
} }
} }
$forcedStabileId = (int) ($extra['force_stabile_id'] ?? 0); $preferredStabileId = (int) ($extra['force_stabile_id'] ?? 0);
$stabileId = $forcedStabileId > 0 $stabileRouting = $this->resolveStabileRoutingFromXml(
? $forcedStabileId $data,
: $this->resolveStabileIdFromXml($data, $fallbackStabileId, ! empty($extra['allow_fallback_stabile'])); $preferredStabileId > 0 ? $preferredStabileId : $fallbackStabileId,
! empty($extra['allow_fallback_stabile'])
);
$stabileId = (int) $stabileRouting['stabile_id'];
// Dedup “logico” anche se l'XML differisce (formattazione ecc.) // Dedup “logico” anche se l'XML differisce (formattazione ecc.)
// Nota: evitiamo dedup “logico” se i campi sono placeholder (es. numero = ND) oppure se P.IVA/CF non è disponibile. // Nota: evitiamo dedup “logico” se i campi sono placeholder (es. numero = ND) oppure se P.IVA/CF non è disponibile.
@ -230,6 +234,7 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
'codice_destinatario' => $data['codice_destinatario'] ?? null, 'codice_destinatario' => $data['codice_destinatario'] ?? null,
'pec_destinatario' => $data['pec_destinatario'] ?? null, 'pec_destinatario' => $data['pec_destinatario'] ?? null,
'destinatario_cf' => $data['destinatario_cf'] ?? null, 'destinatario_cf' => $data['destinatario_cf'] ?? null,
'destinatario_piva' => $data['destinatario_piva'] ?? null,
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? null, 'destinatario_denominazione' => $data['destinatario_denominazione'] ?? null,
'stato' => 'ricevuta', 'stato' => 'ricevuta',
'xml_content' => $xml, 'xml_content' => $xml,
@ -351,6 +356,17 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
$this->postProcess($created, $xml, $extra); $this->postProcess($created, $xml, $extra);
if ($fornitoreId && Schema::hasTable('products')) {
try {
$fornitore = Fornitore::query()->find((int) $fornitoreId);
if ($fornitore instanceof Fornitore) {
app(FornitoreProductCatalogService::class)->extractFromFattureElettroniche($fornitore, (int) $stabileId, 1);
}
} catch (\Throwable) {
// Il catalogo prodotti non deve interrompere l'import della FE.
}
}
return ['status' => 'imported', 'id' => (int) $created->id, 'contabilita_fattura_id' => $contabilitaFatturaId]; return ['status' => 'imported', 'id' => (int) $created->id, 'contabilita_fattura_id' => $contabilitaFatturaId];
} }
@ -1230,51 +1246,163 @@ private function xpathFirstString(DOMXPath $xpath, string $expr, \DOMNode $conte
private function resolveStabileIdFromXml(array $parsed, int $fallbackStabileId, bool $allowFallback): int private function resolveStabileIdFromXml(array $parsed, int $fallbackStabileId, bool $allowFallback): int
{ {
$cf = $parsed['destinatario_cf'] ?? null; return (int) $this->resolveStabileRoutingFromXml($parsed, $fallbackStabileId, $allowFallback)['stabile_id'];
$codice = $parsed['codice_destinatario'] ?? null; }
$pec = $parsed['pec_destinatario'] ?? null;
if ($cf) { /**
* @return array{stabile_id:int, source:string}
*/
private function resolveStabileRoutingFromXml(array $parsed, int $preferredStabileId, bool $allowPreferredFallback): array
{
$directMatch = $this->matchUniqueStabileIdFromParsedIdentifiers($parsed);
if ($directMatch !== null) {
return $directMatch;
}
if ($preferredStabileId > 0 && $this->stabileMatchesParsedRecipientContext($preferredStabileId, $parsed)) {
return [
'stabile_id' => $preferredStabileId,
'source' => 'preferred_context',
];
}
if ($allowPreferredFallback && $preferredStabileId > 0) {
return [
'stabile_id' => $preferredStabileId,
'source' => 'preferred_fallback',
];
}
throw new \RuntimeException('Impossibile determinare lo stabile dalla FE (identificativi destinatario non trovati o non compatibili)');
}
/**
* @return array{stabile_id:int, source:string}|null
*/
private function matchUniqueStabileIdFromParsedIdentifiers(array $parsed): ?array
{
$destCf = $this->normalizeId(isset($parsed['destinatario_cf']) ? (string) $parsed['destinatario_cf'] : '');
$destPiva = $this->normalizeId(isset($parsed['destinatario_piva']) ? (string) $parsed['destinatario_piva'] : '');
$codice = $this->normalizeId(isset($parsed['codice_destinatario']) ? (string) $parsed['codice_destinatario'] : '');
$pec = strtolower(trim((string) ($parsed['pec_destinatario'] ?? '')));
if ($destCf !== '') {
$match = Stabile::query() $match = Stabile::query()
->whereNotNull('codice_fiscale') ->whereNotNull('codice_fiscale')
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf]) ->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$destCf])
->where('attivo', true) ->where('attivo', true)
->get(); ->get();
if ($match->count() === 1) { if ($match->count() === 1) {
return (int) $match->first()->id; return [
'stabile_id' => (int) $match->first()->id,
'source' => 'destinatario_cf',
];
} }
} }
if ($codice) { if ($codice !== '') {
$match = Stabile::query() $match = Stabile::query()
->whereNotNull('codice_destinatario_sdi') ->whereNotNull('codice_destinatario_sdi')
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice]) ->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
->where('attivo', true) ->where('attivo', true)
->get(); ->get();
if ($match->count() === 1) { if ($match->count() === 1) {
return (int) $match->first()->id; return [
'stabile_id' => (int) $match->first()->id,
'source' => 'codice_destinatario',
];
} }
} }
if ($pec) { if ($pec !== '') {
$pec = trim(strtolower((string) $pec));
$match = Stabile::query() $match = Stabile::query()
->where(function ($q) use ($pec) { ->where(function ($q) use ($pec) {
$q->whereRaw('LOWER(pec_condominio) = ?', [$pec]) $q->whereRaw('LOWER(pec_condominio) = ?', [$pec]);
->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$q->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
}
}) })
->where('attivo', true) ->where('attivo', true)
->get(); ->get();
if ($match->count() === 1) { if ($match->count() === 1) {
return (int) $match->first()->id; return [
'stabile_id' => (int) $match->first()->id,
'source' => 'pec_destinatario',
];
} }
} }
if ($allowFallback && $fallbackStabileId > 0) { if ($destPiva !== '') {
return $fallbackStabileId; $match = Stabile::query()
->select('stabili.id')
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
->where('stabili.attivo', true)
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$destPiva])
->get();
if ($match->count() === 1) {
return [
'stabile_id' => (int) $match->first()->id,
'source' => 'destinatario_piva_amministratore',
];
}
} }
throw new \RuntimeException('Impossibile determinare lo stabile dalla FE (CF/SDI/PEC non trovati o non univoci)'); return null;
}
private function stabileMatchesParsedRecipientContext(int $stabileId, array $parsed): bool
{
$stabileColumns = ['id', 'amministratore_id', 'codice_fiscale', 'cod_fisc_amministratore', 'codice_destinatario_sdi', 'pec_condominio'];
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
$stabileColumns[] = 'pec_amministratore';
}
$stabile = Stabile::query()
->with('amministratore:id,partita_iva,codice_fiscale_studio')
->select($stabileColumns)
->find($stabileId);
if (! $stabile) {
return false;
}
$destCf = $this->normalizeId(isset($parsed['destinatario_cf']) ? (string) $parsed['destinatario_cf'] : '');
$destPiva = $this->normalizeId(isset($parsed['destinatario_piva']) ? (string) $parsed['destinatario_piva'] : '');
$codice = $this->normalizeId(isset($parsed['codice_destinatario']) ? (string) $parsed['codice_destinatario'] : '');
$pec = strtolower(trim((string) ($parsed['pec_destinatario'] ?? '')));
$candidateIds = array_filter([
$this->normalizeId((string) ($stabile->codice_fiscale ?? '')),
$this->normalizeId((string) ($stabile->cod_fisc_amministratore ?? '')),
$this->normalizeId((string) ($stabile->amministratore?->codice_fiscale_studio ?? '')),
$this->normalizeId((string) ($stabile->amministratore?->partita_iva ?? '')),
]);
if ($destCf !== '' && in_array($destCf, $candidateIds, true)) {
return true;
}
if ($destPiva !== '' && in_array($destPiva, $candidateIds, true)) {
return true;
}
if ($codice !== '' && $this->normalizeId((string) ($stabile->codice_destinatario_sdi ?? '')) === $codice) {
return true;
}
if ($pec !== '') {
$candidatePec = array_filter([
strtolower(trim((string) ($stabile->pec_condominio ?? ''))),
strtolower(trim((string) ($stabile->pec_amministratore ?? ''))),
]);
if (in_array($pec, $candidatePec, true)) {
return true;
}
}
return false;
} }
private function shouldImportRighe(?int $fornitoreId, array $extra): bool private function shouldImportRighe(?int $fornitoreId, array $extra): bool
@ -1348,6 +1476,7 @@ private function updateExisting(FatturaElettronica $existing, string $xml, ?stri
'codice_destinatario' => $data['codice_destinatario'] ?? $existing->codice_destinatario, 'codice_destinatario' => $data['codice_destinatario'] ?? $existing->codice_destinatario,
'pec_destinatario' => $data['pec_destinatario'] ?? $existing->pec_destinatario, 'pec_destinatario' => $data['pec_destinatario'] ?? $existing->pec_destinatario,
'destinatario_cf' => $data['destinatario_cf'] ?? $existing->destinatario_cf, 'destinatario_cf' => $data['destinatario_cf'] ?? $existing->destinatario_cf,
'destinatario_piva' => $data['destinatario_piva'] ?? $existing->destinatario_piva,
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? $existing->destinatario_denominazione, 'destinatario_denominazione' => $data['destinatario_denominazione'] ?? $existing->destinatario_denominazione,
'xml_content' => $xml, 'xml_content' => $xml,
'nome_file_xml' => $originalFilename ?: $existing->nome_file_xml, 'nome_file_xml' => $originalFilename ?: $existing->nome_file_xml,

View File

@ -35,6 +35,7 @@ class FatturaElettronicaXmlParser
* codice_destinatario: string|null, * codice_destinatario: string|null,
* pec_destinatario: string|null, * pec_destinatario: string|null,
* destinatario_cf: string|null, * destinatario_cf: string|null,
* destinatario_piva: string|null,
* destinatario_denominazione: string|null, * destinatario_denominazione: string|null,
* pagamento_modalita: string|null, * pagamento_modalita: string|null,
* pagamento_iban: string|null, * pagamento_iban: string|null,
@ -120,11 +121,8 @@ public function parse(string $xml): array
$codiceDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='CodiceDestinatario']"); $codiceDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='CodiceDestinatario']");
$pecDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='PECDestinatario']"); $pecDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='PECDestinatario']");
$destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']"); $destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']");
$destinatarioId = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']"); $destinatarioPiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']");
if (! $destinatarioCf) {
$destinatarioCf = $destinatarioId;
}
$destDenominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']"); $destDenominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']");
$destNome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']"); $destNome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']");
@ -187,6 +185,7 @@ public function parse(string $xml): array
'codice_destinatario' => $codiceDest ?: null, 'codice_destinatario' => $codiceDest ?: null,
'pec_destinatario' => $pecDest ?: null, 'pec_destinatario' => $pecDest ?: null,
'destinatario_cf' => $this->normalizeId($destinatarioCf), 'destinatario_cf' => $this->normalizeId($destinatarioCf),
'destinatario_piva' => $this->normalizeId($destinatarioPiva),
'destinatario_denominazione' => $destDenominazione ?: null, 'destinatario_denominazione' => $destDenominazione ?: null,
'pagamento_modalita' => $modalitaPagamento ?: null, 'pagamento_modalita' => $modalitaPagamento ?: null,

View File

@ -0,0 +1,104 @@
<?php
namespace App\Support;
use RuntimeException;
use Symfony\Component\Process\Process;
class TecnoRepairMdbReader
{
/**
* @return array<int,string>
*/
public function listTables(string $mdbPath): array
{
$bin = $this->resolveBinary('mdb-tables');
$process = new Process([$bin, '-1', $mdbPath]);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Impossibile leggere le tabelle MDB.');
}
return array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $process->getOutput()) ?: [])));
}
/**
* @return array<int,array<string,string|null>>
*/
public function exportTable(string $mdbPath, string $table): array
{
$bin = $this->resolveBinary('mdb-export');
$process = new Process([$bin, '-D', '%Y-%m-%d %H:%M:%S', '-q', '^', $mdbPath, $table]);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(trim($process->getErrorOutput()) ?: ('Impossibile esportare la tabella ' . $table));
}
$csv = $process->getOutput();
if (trim($csv) === '') {
return [];
}
return $this->parseCsv($csv);
}
public function resolveBinary(string $name): string
{
$process = new Process(['sh', '-lc', 'command -v ' . escapeshellarg($name)]);
$process->run();
$path = trim($process->getOutput());
if ($path === '') {
throw new RuntimeException($name . ' non trovato. Installa mdbtools sul server Day0.');
}
return $path;
}
/**
* @return array<int,array<string,string|null>>
*/
private function parseCsv(string $csv): array
{
$handle = fopen('php://temp', 'r+');
if ($handle === false) {
throw new RuntimeException('Impossibile creare buffer CSV in memoria.');
}
fwrite($handle, $csv);
rewind($handle);
$headers = fgetcsv($handle, 0, ',', '^');
if (! is_array($headers) || $headers === []) {
fclose($handle);
return [];
}
$headers = array_map(static fn($value): string => trim((string) $value), $headers);
$rows = [];
while (($line = fgetcsv($handle, 0, ',', '^')) !== false) {
if ($line === [null] || $line === []) {
continue;
}
$row = [];
foreach ($headers as $index => $header) {
if ($header === '') {
continue;
}
$value = $line[$index] ?? null;
$row[$header] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
fclose($handle);
return $rows;
}
}

View File

@ -3,6 +3,7 @@
use App\Console\Commands\CtiSyncPostItFromStagingCommand; use App\Console\Commands\CtiSyncPostItFromStagingCommand;
use App\Console\Commands\DedupeRateEmesseCommand; use App\Console\Commands\DedupeRateEmesseCommand;
use App\Console\Commands\FeCassettoImportLocal; use App\Console\Commands\FeCassettoImportLocal;
use App\Console\Commands\FeCassettoImportSoggettoCommand;
use App\Console\Commands\GesconCollegaFornitoreStabili; use App\Console\Commands\GesconCollegaFornitoreStabili;
use App\Console\Commands\GesconImportAlignCommand; use App\Console\Commands\GesconImportAlignCommand;
use App\Console\Commands\GesconImportBanche; use App\Console\Commands\GesconImportBanche;
@ -17,21 +18,23 @@
use App\Console\Commands\GoogleSyncFornitoriRubricaCommand; use App\Console\Commands\GoogleSyncFornitoriRubricaCommand;
use App\Console\Commands\GoogleSyncRubricaContactsCommand; use App\Console\Commands\GoogleSyncRubricaContactsCommand;
use App\Console\Commands\GoogleSyncTicketCalendarCommand; use App\Console\Commands\GoogleSyncTicketCalendarCommand;
use App\Console\Commands\ImportFornitoreWholesaleCsvCommand;
use App\Console\Commands\ImportGesconAcquaLegacyCommand; use App\Console\Commands\ImportGesconAcquaLegacyCommand;
use App\Console\Commands\ImportGesconF24LegacyCommand; use App\Console\Commands\ImportGesconF24LegacyCommand;
use App\Console\Commands\IstatSetIndiceCommand; use App\Console\Commands\IstatSetIndiceCommand;
use App\Console\Commands\IstatSyncIndiciCommand; use App\Console\Commands\IstatSyncIndiciCommand;
use App\Console\Commands\NetgesconApplyPbxPresetCommand;
use App\Console\Commands\NetgesconArchiveRegistrySyncCommand; use App\Console\Commands\NetgesconArchiveRegistrySyncCommand;
use App\Console\Commands\NetgesconDevelopmentSnapshotCommand; use App\Console\Commands\NetgesconDevelopmentSnapshotCommand;
use App\Console\Commands\NetgesconDistributionPullCommand; use App\Console\Commands\NetgesconDistributionPullCommand;
use App\Console\Commands\NetgesconGitSyncCommand; use App\Console\Commands\NetgesconGitSyncCommand;
use App\Console\Commands\NetgesconApplyPbxPresetCommand;
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\PanasonicCstaBridgeCommand;
use App\Console\Commands\StabiliTransferCommand; use App\Console\Commands\StabiliTransferCommand;
use App\Console\Commands\SyncSoggettiToPersone; use App\Console\Commands\SyncSoggettiToPersone;
use App\Console\Commands\TecnoRepairImportLegacyArchiveCommand;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@ -56,9 +59,11 @@
GesconQaContatori::class, GesconQaContatori::class,
GesconQaEmissione::class, GesconQaEmissione::class,
FeCassettoImportLocal::class, FeCassettoImportLocal::class,
FeCassettoImportSoggettoCommand::class,
IstatSyncIndiciCommand::class, IstatSyncIndiciCommand::class,
IstatSetIndiceCommand::class, IstatSetIndiceCommand::class,
ImportGesconAcquaLegacyCommand::class, ImportGesconAcquaLegacyCommand::class,
ImportFornitoreWholesaleCsvCommand::class,
GesconImportAlignCommand::class, GesconImportAlignCommand::class,
ImportGesconF24LegacyCommand::class, ImportGesconF24LegacyCommand::class,
NetgesconQaUnitaNominativiCommand::class, NetgesconQaUnitaNominativiCommand::class,
@ -69,6 +74,7 @@
NetgesconGitSyncCommand::class, NetgesconGitSyncCommand::class,
NetgesconPreupdateBackupCommand::class, NetgesconPreupdateBackupCommand::class,
NetgesconRestoreRecordCommand::class, NetgesconRestoreRecordCommand::class,
TecnoRepairImportLegacyArchiveCommand::class,
PanasonicCstaBridgeCommand::class, PanasonicCstaBridgeCommand::class,
GoogleSyncRubricaContactsCommand::class, GoogleSyncRubricaContactsCommand::class,
GooglePushRubricaContactsCommand::class, GooglePushRubricaContactsCommand::class,

8
config/catalog.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'amazon' => [
'associate_tag' => env('CATALOG_AMAZON_ASSOCIATE_TAG', 'netgescon-21'),
'default_locale' => env('CATALOG_AMAZON_LOCALE', 'it'),
],
];

View File

@ -0,0 +1,120 @@
<?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('fornitori', function (Blueprint $table) {
if (! Schema::hasColumn('fornitori', 'is_tecnorepair_primary')) {
$table->boolean('is_tecnorepair_primary')->default(false)->after('aliquota_iva_id');
}
});
if (! Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
Schema::create('assistenza_tecnorepair_schede_legacy', function (Blueprint $table) {
$table->id();
$table->foreignId('amministratore_id')->nullable()->constrained('amministratori')->nullOnDelete();
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
$table->unsignedBigInteger('legacy_id')->nullable();
$table->unsignedBigInteger('legacy_cliente_id')->nullable();
$table->unsignedBigInteger('legacy_centro_ass_id')->nullable();
$table->string('legacy_committente_codice')->nullable();
$table->string('legacy_numero_scheda')->nullable();
$table->string('customer_name')->nullable();
$table->string('customer_phone')->nullable();
$table->string('customer_phone_alt')->nullable();
$table->string('customer_email')->nullable();
$table->string('product_model')->nullable();
$table->string('product_code')->nullable();
$table->string('serial_number')->nullable();
$table->string('serial_number_2')->nullable();
$table->string('status_code')->nullable();
$table->string('status_label')->nullable();
$table->string('status_bucket', 24)->default('other')->index();
$table->text('defect_reported')->nullable();
$table->longText('repair_description')->nullable();
$table->longText('communications')->nullable();
$table->string('operator_name')->nullable();
$table->string('technician_name')->nullable();
$table->dateTime('date_received')->nullable();
$table->dateTime('ordered_at')->nullable();
$table->string('order_number')->nullable();
$table->string('rma_code')->nullable();
$table->string('pin_code')->nullable();
$table->string('unlock_code')->nullable();
$table->string('legacy_attachment_path')->nullable();
$table->string('imported_from_path')->nullable();
$table->timestamp('imported_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['amministratore_id', 'legacy_numero_scheda'], 'ass_tecnorepair_admin_numero_idx');
$table->unique(['imported_from_path', 'legacy_id'], 'ass_tecnorepair_source_legacy_unique');
});
}
if (! Schema::hasTable('assistenza_tecnorepair_allegati_legacy')) {
Schema::create('assistenza_tecnorepair_allegati_legacy', function (Blueprint $table) {
$table->id();
$table->foreignId('scheda_id')->constrained('assistenza_tecnorepair_schede_legacy')->cascadeOnDelete();
$table->unsignedBigInteger('legacy_id')->nullable();
$table->unsignedBigInteger('legacy_scheda_id')->nullable();
$table->unsignedInteger('legacy_attachment_number')->nullable();
$table->string('file_name')->nullable();
$table->string('file_path')->nullable();
$table->string('imported_from_path')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['scheda_id', 'legacy_attachment_number'], 'ass_tecnorepair_allegati_scheda_num_idx');
});
}
if (! Schema::hasTable('product_serials')) {
Schema::create('product_serials', function (Blueprint $table) {
$table->id();
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
$table->foreignId('legacy_scheda_id')->nullable()->constrained('assistenza_tecnorepair_schede_legacy')->nullOnDelete();
$table->string('customer_name')->nullable();
$table->string('product_model')->nullable();
$table->string('product_code')->nullable();
$table->string('serial_number')->nullable();
$table->string('serial_number_2')->nullable();
$table->dateTime('date_received')->nullable();
$table->dateTime('date_resold')->nullable();
$table->text('internal_notes')->nullable();
$table->string('source')->nullable();
$table->string('source_reference')->nullable();
$table->timestamps();
$table->unique(['legacy_scheda_id'], 'product_serials_legacy_scheda_unique');
$table->index(['fornitore_id', 'serial_number'], 'product_serials_vendor_serial_idx');
});
}
}
public function down(): void
{
if (Schema::hasTable('product_serials')) {
Schema::dropIfExists('product_serials');
}
if (Schema::hasTable('assistenza_tecnorepair_allegati_legacy')) {
Schema::dropIfExists('assistenza_tecnorepair_allegati_legacy');
}
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
Schema::dropIfExists('assistenza_tecnorepair_schede_legacy');
}
Schema::table('fornitori', function (Blueprint $table) {
if (Schema::hasColumn('fornitori', 'is_tecnorepair_primary')) {
$table->dropColumn('is_tecnorepair_primary');
}
});
}
};

View File

@ -0,0 +1,28 @@
<?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('fatture_elettroniche', function (Blueprint $table) {
if (! Schema::hasColumn('fatture_elettroniche', 'destinatario_piva')) {
$table->string('destinatario_piva', 32)->nullable()->after('destinatario_cf');
$table->index('destinatario_piva');
}
});
}
public function down(): void
{
Schema::table('fatture_elettroniche', function (Blueprint $table) {
if (Schema::hasColumn('fatture_elettroniche', 'destinatario_piva')) {
$table->dropIndex(['destinatario_piva']);
$table->dropColumn('destinatario_piva');
}
});
}
};

View File

@ -0,0 +1,113 @@
<?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
{
if (! Schema::hasTable('products')) {
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('default_fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
$table->string('type', 24)->default('product')->index();
$table->string('internal_code')->nullable()->unique();
$table->string('canonical_key')->unique();
$table->string('name');
$table->string('brand')->nullable()->index();
$table->string('model')->nullable()->index();
$table->string('variant_group')->nullable();
$table->string('variant_label')->nullable();
$table->string('color_label')->nullable();
$table->string('unit_measure', 24)->nullable();
$table->text('description')->nullable();
$table->boolean('track_serials')->default(false);
$table->boolean('is_active')->default(true);
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['default_fornitore_id', 'is_active'], 'products_supplier_active_idx');
});
}
if (! Schema::hasTable('product_identifiers')) {
Schema::create('product_identifiers', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
$table->string('code_type', 32)->index();
$table->string('code_role', 32)->nullable()->index();
$table->string('code_value');
$table->string('normalized_code')->index();
$table->string('source', 32)->nullable()->index();
$table->string('source_reference')->nullable();
$table->boolean('is_primary')->default(false);
$table->json('payload')->nullable();
$table->timestamps();
$table->unique(['fornitore_id', 'code_type', 'normalized_code'], 'product_identifiers_scope_unique');
});
}
if (! Schema::hasTable('product_media')) {
Schema::create('product_media', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
$table->string('media_type', 24)->default('image')->index();
$table->string('disk', 32)->default('local');
$table->string('path');
$table->string('title')->nullable();
$table->string('mime_type', 120)->nullable();
$table->unsignedBigInteger('size_bytes')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['product_id', 'media_type', 'sort_order'], 'product_media_lookup_idx');
});
}
Schema::table('product_serials', function (Blueprint $table) {
if (! Schema::hasColumn('product_serials', 'product_id')) {
$table->foreignId('product_id')->nullable()->after('fornitore_id')->constrained('products')->nullOnDelete();
$table->index(['product_id', 'serial_number'], 'product_serials_product_serial_idx');
}
});
Schema::table('contabilita_fatture_fornitori_righe', function (Blueprint $table) {
if (! Schema::hasColumn('contabilita_fatture_fornitori_righe', 'product_id')) {
$table->foreignId('product_id')->nullable()->after('fattura_id')->constrained('products')->nullOnDelete();
$table->index(['product_id', 'fattura_id'], 'cont_fatture_righe_product_idx');
}
});
}
public function down(): void
{
Schema::table('contabilita_fatture_fornitori_righe', function (Blueprint $table) {
if (Schema::hasColumn('contabilita_fatture_fornitori_righe', 'product_id')) {
$table->dropConstrainedForeignId('product_id');
}
});
Schema::table('product_serials', function (Blueprint $table) {
if (Schema::hasColumn('product_serials', 'product_id')) {
$table->dropConstrainedForeignId('product_id');
}
});
if (Schema::hasTable('product_media')) {
Schema::dropIfExists('product_media');
}
if (Schema::hasTable('product_identifiers')) {
Schema::dropIfExists('product_identifiers');
}
if (Schema::hasTable('products')) {
Schema::dropIfExists('products');
}
}
};

View File

@ -0,0 +1,50 @@
<?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
{
if (Schema::hasTable('product_offers')) {
return;
}
Schema::create('product_offers', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
$table->string('offer_key')->unique();
$table->string('source_type', 32)->index();
$table->string('source_name', 64)->nullable()->index();
$table->string('external_product_id')->nullable()->index();
$table->string('external_sku')->nullable()->index();
$table->string('title')->nullable();
$table->string('condition_label', 32)->nullable();
$table->string('currency', 8)->default('EUR');
$table->decimal('price_amount', 12, 2)->nullable();
$table->decimal('price_compare_at', 12, 2)->nullable();
$table->decimal('shipping_amount', 12, 2)->nullable();
$table->string('availability', 64)->nullable();
$table->integer('stock_qty')->nullable();
$table->text('external_url')->nullable();
$table->text('referral_url')->nullable();
$table->string('referral_code', 64)->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('is_internal')->default(false);
$table->timestamp('last_seen_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['product_id', 'is_active', 'price_amount'], 'product_offers_lookup_idx');
$table->index(['source_type', 'fornitore_id', 'is_active'], 'product_offers_source_idx');
});
}
public function down(): void
{
Schema::dropIfExists('product_offers');
}
};

View File

@ -7,6 +7,7 @@ ## Obiettivo
## Sorgenti da considerare ## Sorgenti da considerare
### 1. Metadati stabili ### 1. Metadati stabili
- `dbc/Stabili.mdb` - `dbc/Stabili.mdb`
- campi chiave attesi: - campi chiave attesi:
- `cod_stabile` - `cod_stabile`
@ -14,6 +15,7 @@ ### 1. Metadati stabili
- campo CF stabile/condominio valorizzato - campo CF stabile/condominio valorizzato
### 2. Dati annuali per stabile ### 2. Dati annuali per stabile
- `<cod_stabile>/<anno>/singolo_anno.mdb` - `<cod_stabile>/<anno>/singolo_anno.mdb`
- tabelle minime da validare: - tabelle minime da validare:
- `condomin` - `condomin`
@ -25,6 +27,7 @@ ### 2. Dati annuali per stabile
- `straordinarie` - `straordinarie`
### 3. Dati trasversali stabile ### 3. Dati trasversali stabile
- `<cod_stabile>/generale_stabile.mdb` - `<cod_stabile>/generale_stabile.mdb`
- utile per verifiche aggiuntive su rate emesse, casse e dati generali - utile per verifiche aggiuntive su rate emesse, casse e dati generali
@ -49,14 +52,17 @@ ## Finestra temporale smoke test
Per il primo passaggio operativo non importare tutto lo storico. Per il primo passaggio operativo non importare tutto lo storico.
### Import base ### Import base
- anno corrente legacy piu recente disponibile - anno corrente legacy piu recente disponibile
- anno precedente - anno precedente
### Import esteso solo per straordinarie ### Import esteso solo per straordinarie
- includere fino a 5 anni indietro - includere fino a 5 anni indietro
- fermarsi prima se non esistono MDB annuali o non esistono righe utili nelle tabelle straordinarie - fermarsi prima se non esistono MDB annuali o non esistono righe utili nelle tabelle straordinarie
### Storico oltre la finestra base ### Storico oltre la finestra base
- da importare solo su richiesta o quando serve per quadrature, estratti conto storici o audit mirati - da importare solo su richiesta o quando serve per quadrature, estratti conto storici o audit mirati
## Copertura minima richiesta per rendere operativo il sistema ## Copertura minima richiesta per rendere operativo il sistema
@ -83,22 +89,26 @@ ## Smoke Test pratico per stabile
Per ogni stabile filtrato da `Stabili.mdb` eseguire questa checklist. Per ogni stabile filtrato da `Stabili.mdb` eseguire questa checklist.
### A. Controllo metadati ### A. Controllo metadati
- `cod_stabile` valorizzato - `cod_stabile` valorizzato
- `nome_directory` valorizzato - `nome_directory` valorizzato
- CF stabile valorizzato - CF stabile valorizzato
- cartella legacy esistente - cartella legacy esistente
### B. Controllo annualita ### B. Controllo annualita
- individuato ultimo anno disponibile - individuato ultimo anno disponibile
- individuato anno precedente disponibile - individuato anno precedente disponibile
- individuate eventuali annualita straordinarie fino a -5 anni - individuate eventuali annualita straordinarie fino a -5 anni
### C. Controllo file ### C. Controllo file
- esiste `singolo_anno.mdb` per ogni anno scelto - esiste `singolo_anno.mdb` per ogni anno scelto
- se presente, esiste `generale_stabile.mdb` - se presente, esiste `generale_stabile.mdb`
- `Stabili.mdb` leggibile senza errori - `Stabili.mdb` leggibile senza errori
### D. Controllo tabelle minime ### D. Controllo tabelle minime
- `condomin` leggibile - `condomin` leggibile
- `operazioni` leggibile - `operazioni` leggibile
- `rate` leggibile - `rate` leggibile
@ -108,6 +118,7 @@ ### D. Controllo tabelle minime
- `straordinarie` leggibile o assente in modo coerente - `straordinarie` leggibile o assente in modo coerente
### E. QA dominio post-import ### E. QA dominio post-import
- stabile creato/aggiornato correttamente - stabile creato/aggiornato correttamente
- unita importate - unita importate
- rubrica/nominativi deduplicati - rubrica/nominativi deduplicati

View File

@ -114,6 +114,79 @@ ### Caso B: trovi TAPI ma zero indirizzi
3. IP PBX e porta `33333` 3. IP PBX e porta `33333`
4. licenza CTI lato centralino 4. licenza CTI lato centralino
### Caso B2: Panasonic esiste nel registry ma `Telephony Providers` e zero
Se il report di diagnostica mostra contemporaneamente:
- chiavi Panasonic presenti sotto `HKLM\SOFTWARE\Panasonic\KX_TDA_TSP`
- `Telephony providers x64: 0`
- `Telephony providers wow64: 0`
- nessun provider Panasonic tra i provider TAPI registrati
allora il problema non e il bridge NetGescon.
Il problema e che il TSP Panasonic e installato o configurato nei suoi dati locali, ma non e registrato correttamente dentro il sottosistema Telephony di Windows.
In pratica Windows vede TAPI, ma non vede il provider Panasonic come provider utilizzabile.
In questo stato:
1. gli script NetGescon possono partire
2. il bridge HTTP puo funzionare
3. ma TAPI continuera a esporre zero linee reali o un solo address vuoto
La correzione va fatta su Windows:
1. aprire `telephon.cpl`
2. verificare nella sezione provider che Panasonic sia realmente presente
3. se assente, reinstallare o registrare di nuovo il `KX-TDA TSP`
4. verificare compatibilita 32 bit / 64 bit del TSP sulla macchina
5. solo dopo ripetere `get-netgescon-panasonic-tapi-info.ps1`
## Checklist operativa rapida
Usa questa checklist quando il bridge NetGescon parte ma il provider TAPI non espone linee reali.
### Stato gia confermato su questa macchina
1. il task `NetGescon Panasonic Live Bridge` parte ed e in stato `Running`
2. il watcher .NET TAPI carica l'assembly e si sottoscrive agli eventi
3. il bridge HTTP NetGescon non e il blocco principale
4. TAPI vede solo `1` address vuoto
5. `Telephony providers x64 = 0`
6. `Telephony providers wow64 = 0`
7. il registry Panasonic esiste sotto `HKLM\SOFTWARE\Panasonic\KX_TDA_TSP`
### Cosa manca davvero
1. il provider Panasonic deve comparire dentro `telephon.cpl`
2. il provider Panasonic deve essere registrato nei `Telephony Providers` di Windows
3. il TSP deve essere coerente con l'architettura della macchina `32/64 bit`
4. la connessione del TSP deve puntare al PBX corretto e non restare su profilo `USB:PC`
5. dopo la correzione, `get-netgescon-panasonic-tapi-info.ps1` deve mostrare address reali con `AddressName` o `DialableAddress`
### Sequenza pratica consigliata
1. apri `telephon.cpl`
2. se Panasonic non compare tra i provider, reinstalla `KX-TDA TSP` come amministratore
3. se Panasonic compare, apri la configurazione del provider e verifica:
- IP PBX `192.168.0.101`
- porta `33333`
- nessun trasporto bloccato su `USB:PC` se il collegamento e IP
4. verifica che il centralino abbia licenza CTI attiva lato TSP/CSTA
2. salva la configurazione e riavvia il tool Panasonic se richiesto
3. rilancia `get-netgescon-panasonic-tapi-info.ps1`
4. conferma che compaiano interni reali come `201`, `205`, `206`
### Solo se Panasonic non compare ancora
1. controlla in `Program Files` se i file `.tsp` Panasonic sono presenti
2. controlla se l'installer Panasonic usato e la versione giusta per il sistema operativo
3. esegui reinstallazione pulita del TSP
4. riapri `telephon.cpl` e verifica di nuovo i provider
### Caso C: gli script API falliscono con `401 Unauthorized` ### Caso C: gli script API falliscono con `401 Unauthorized`
Significa che il token non coincide con quello configurato in NetGescon. Significa che il token non coincide con quello configurato in NetGescon.
@ -247,6 +320,119 @@ ## Passo 7: ascolto eventi COM reali
.\scripts\ops\windows\watch-netgescon-panasonic-tapi-com-events.ps1 -Extensions "201,205,206" -LogFile .\netgescon-tapi-com-events.log .\scripts\ops\windows\watch-netgescon-panasonic-tapi-com-events.ps1 -Extensions "201,205,206" -LogFile .\netgescon-tapi-com-events.log
``` ```
## Ripartenza domani mattina
Questa e la situazione gia confermata e non va rimessa in discussione salvo nuovi errori evidenti.
### Stato validato
1. Panasonic TAPI espone linee reali.
2. Il watcher .NET registra correttamente `GRP00603`.
3. `RegisterCallNotifications` funziona su `603`.
4. Il bridge `incoming` in modalita `live` arriva a staging e crea Post-it e Communication Message.
5. Il bug residuo e solo `call-ended` verso staging.
### Comando rapido di test live
Da Windows, nel repo:
```powershell
cd C:\NetGescon\netgescon-day0\scripts\ops\windows
.\bootstrap-netgescon-panasonic-live.cmd -Action live-run -Extensions "603"
```
In un'altra finestra:
```powershell
Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 120 -Wait
```
### Cosa deve essere considerato corretto
Se compaiono righe come queste, la parte Windows e ok:
1. `REGISTER OK mode=monitor+owner`
2. `BRIDGE LIVE OK endpoint=incoming`
3. `BRIDGE MARK answered`
### Bug residuo da chiudere
Se compare ancora:
```text
BRIDGE LIVE KO endpoint=call-ended error=Richiesta annullata: Chiusura imprevista della connessione.
```
allora il problema non e piu Panasonic Windows.
Il problema e sul lato staging, in uno di questi livelli:
1. codice Laravel deployato non allineato
2. PHP-FPM / web server che chiude la richiesta `call-ended`
3. eccezione backend prima della risposta JSON
### Verifica server minima
Sul server staging:
```bash
grep -n "call-ended\|CTI Panasonic call-ended failed" storage/logs/laravel.log | tail -n 50
```
Se non esce nulla di nuovo mentre `incoming` continua a funzionare, controlla anche i log del web server o di PHP-FPM all'orario della chiamata.
### Strategia pratica consigliata
Per non bloccare il lavoro:
1. usa TAPI live per `incoming`
2. usa SMDR per la conferma durata / chiusura chiamata finche `call-ended` non e chiuso lato staging
3. affronta il bug `call-ended` come task separato lato server
## Stato applicativo da portare su staging
Questo e il pacchetto minimo gia pronto lato applicazione e documentazione.
### Ticket Mobile
1. il contesto chiamata viene mostrato sopra il titolo ticket
2. numero chiamante, interno o gruppo chiamato, riferimenti stabile/unita e nota operativa non vengono piu mischiati nel testo libero del problema
3. il titolo precompilato resta operativo e la descrizione non viene appesantita da dettagli tecnici PBX
### Post-it Gestione
1. prima della conversione in ticket c'e una nota libera operatore
2. le chiamate tecniche SMDR e CSTA usano etichette piu chiare come `Chiamata ricevuta`, `Chiamata effettuata`, `Chiamata interna`
3. il filtro tecnico permette di cercare per linea o gruppo, per esempio `0003`, `603`, `GRP00603`
### Watchlist pratica corrente
Per il preset Day0 la watchlist operativa da considerare e questa:
1. `0001`
2. `0003`
3. `201`
4. `206`
5. `601`
6. `603`
Lato applicazione, la risoluzione estensioni osservate e gia compatibile con:
1. `watch_extensions`
2. `response_groups`
3. `incoming_lines`
### Punto aperto che non deve bloccare il deploy
Se `incoming` continua a funzionare ma `call-ended` continua a chiudere la connessione, il deploy staging va comunque avanti per validare:
1. banner live operatore
2. Ticket Mobile aggiornato
3. Post-it Gestione aggiornato
4. filtri linea e conversione con nota operatore
In questo scenario la chiusura chiamata resta temporaneamente coperta da SMDR.
### Cosa deve succedere nell'ispezione interop ### Cosa deve succedere nell'ispezione interop
All'avvio vedrai le righe di setup e `REGISTER OK`. All'avvio vedrai le righe di setup e `REGISTER OK`.
@ -375,6 +561,18 @@ ### 10.1 Avvio live manuale
.\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" .\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"
``` ```
Se PowerShell blocca gli script con `ExecutionPolicy`, usa il wrapper `.cmd` oppure lancia esplicitamente PowerShell in bypass solo per il processo corrente:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\ops\windows\start-netgescon-panasonic-live.ps1 -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log"
```
oppure:
```powershell
.\scripts\ops\windows\start-netgescon-panasonic-live.cmd -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log"
```
### 10.2 Installazione come task automatico all'avvio macchina con account SYSTEM ### 10.2 Installazione come task automatico all'avvio macchina con account SYSTEM
In PowerShell amministratore: In PowerShell amministratore:
@ -399,9 +597,34 @@ ### 10.3 Installazione come task al logon utente corrente
.\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" .\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:\` ### 10.4 Sequenza rapida 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. Attenzione: `S:\` in Windows di solito e un drive mappato di sessione.
Questo significa che:
- puo essere visibile in Esplora file ma non in PowerShell elevata
- puo essere visibile nel tuo utente ma non nei task eseguiti come `SYSTEM`
- non va considerato affidabile per il bridge live automatico all'avvio macchina
Se `S:` non esiste nel prompt PowerShell, non usare `S:\` per installare o avviare il task.
Usa invece una di queste due strade:
1. meglio: copia il repo o almeno `scripts\ops\windows` in un percorso locale, per esempio `C:\NetGescon` oppure `C:\ProgramData\NetGescon\repo`
2. in alternativa: usa il percorso UNC reale della share invece del drive mappato, se il task gira con un utente che ha accesso alla share
Procedura piu robusta consigliata:
```powershell
Set-ExecutionPolicy -Scope Process Bypass -Force
cd C:\NetGescon\netgescon-day0\scripts\ops\windows
.\publish-netgescon-panasonic-runtime.ps1
cd C:\ProgramData\NetGescon\panasonic-live\current
.\install-netgescon-panasonic-live-task.cmd -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"
```
Con questo approccio, quando aggiorni il repo ti basta rilanciare `publish-netgescon-panasonic-runtime.ps1` e poi reinstallare il task se hai cambiato i launcher.
Solo se `S:` esiste davvero nella stessa sessione PowerShell puoi usare i comandi sotto.
```powershell ```powershell
Set-ExecutionPolicy -Scope Process Bypass -Force Set-ExecutionPolicy -Scope Process Bypass -Force
@ -456,11 +679,14 @@ ### 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: Quando l'invio live va a buon fine, le nuove chiamate Panasonic compaiono in due punti applicativi:
1. `Strumenti -> Post-it Gestione` 1. `Strumenti -> Post-it Gestione`
- legge i record `chiamate_post_it`
- e il punto principale per vedere le chiamate create dal bridge - legge i record `chiamate_post_it`
2. flussi tecnici basati su `communication_messages` - e il punto principale per vedere le chiamate create dal bridge
- canale `panasonic_csta`
- utili per verifica tecnica e correlazione con routing/intercettazione operatore 1. 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. 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.
@ -469,9 +695,9 @@ ### 11.5 Procedura rapida di ripartenza se staging smette di ricevere chiamate
Sul PC Windows: Sul PC Windows:
```powershell ```powershell
cd S:\ cd C:\ProgramData\NetGescon\panasonic-live\current
Stop-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" -ErrorAction SilentlyContinue 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" .\install-netgescon-panasonic-live-task.cmd -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" Start-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge"
Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait
``` ```

View File

@ -72,6 +72,28 @@ ## Prossimo step pragmatico
3. collegare `TClienti` e `TFornitori` alla rubrica/fornitori NetGescon con matching su telefono, email, CF/PIVA 3. collegare `TClienti` e `TFornitori` alla rubrica/fornitori NetGescon con matching su telefono, email, CF/PIVA
4. esporre in UI la scheda riparazione collegata a cliente, fornitore e ticket/intervento 4. esporre in UI la scheda riparazione collegata a cliente, fornitore e ticket/intervento
## Primo slice disponibile in Day0
- Tabelle staging create:
- `assistenza_tecnorepair_schede_legacy`
- `assistenza_tecnorepair_allegati_legacy`
- `product_serials`
- Nuovo flag fornitore: `fornitori.is_tecnorepair_primary`
- Nuovo comando import:
- `php artisan tecnorepair:import-legacy {amministratore} --force-primary`
- opzionale: `--fornitore-id=` oppure `--fornitore-term=NETHOME`
- Nuova pagina Filament:
- `Supporto > Assistenza Legacy`
- elenco filtrabile con colori per stato, dettaglio scheda, seriali e allegati legacy
### Note operative del comando
- default MDB:
- `/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb`
- import collega di default le schede al primo fornitore dello stesso amministratore che matcha `NETHOME`
- con `--force-primary` il fornitore collegato viene marcato come centro TecnoRepair principale
- l'import resta idempotente sul vincolo `imported_from_path + legacy_id`
## Decisione corrente ## Decisione corrente
- Si: NetGescon puo leggere l'MDB subito. - Si: NetGescon puo leggere l'MDB subito.

View File

@ -1,7 +1,26 @@
<div class="-m-6"> <div class="-m-6 space-y-3 p-2">
<iframe <div class="flex items-center justify-end gap-2 px-4 pt-4">
title="PDF" <a
class="h-[75vh] w-full" href="{{ $url }}"
src="{{ $url }}" target="_blank"
></iframe> rel="noopener"
class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700"
>
Apri in nuova scheda
</a>
</div>
<div class="h-[78vh] w-full px-4 pb-4">
<object
data="{{ $url }}#toolbar=1&navpanes=1&view=FitH"
type="application/pdf"
class="h-full w-full rounded-lg border bg-white"
>
<iframe
title="PDF"
class="h-full w-full rounded-lg border"
src="{{ $url }}#toolbar=1&navpanes=1&view=FitH"
></iframe>
</object>
</div>
</div> </div>

View File

@ -35,6 +35,12 @@
</x-filament::tabs> </x-filament::tabs>
<div x-show="tab === 'fe'"> <div x-show="tab === 'fe'">
<div class="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
<div class="font-semibold">Stabile attivo per archivio FE</div>
<div class="mt-1">{{ $this->activeStabileLabel }}</div>
<div class="mt-1 text-xs text-sky-700">L'elenco mostra solo le fatture collegate allo stabile attivo selezionato nella topbar.</div>
</div>
{{ $this->table }} {{ $this->table }}
<div class="mt-2 text-sm text-gray-500"> <div class="mt-2 text-sm text-gray-500">
@ -51,6 +57,12 @@
<x-filament::section> <x-filament::section>
<x-slot name="heading">Scarichi da Cassetto Fiscale</x-slot> <x-slot name="heading">Scarichi da Cassetto Fiscale</x-slot>
<div class="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div class="font-semibold">Destinazione scarico corrente</div>
<div class="mt-1">{{ $this->activeStabileLabel }}</div>
<div class="mt-1 text-xs text-amber-700">I trimestri scaricati in questa tab vengono associati allo stabile attivo visibile in topbar al momento del click.</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
Verifica trimestri già scaricati (se completo, evita ulteriori scarichi). Verifica trimestri già scaricati (se completo, evita ulteriori scarichi).

View File

@ -115,6 +115,190 @@
</div> </div>
</div> </div>
<div class="rounded-lg border p-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-lg font-semibold">Modulo fornitore separato</div>
<div class="mt-1 text-xs text-gray-500">Catalogo isolato per questo fornitore tramite codice univoco, con riferimenti pubblici interni e sorgenti esterne archiviate solo nel gestionale.</div>
</div>
<div class="rounded-md bg-slate-900 px-3 py-2 text-right text-white">
<div class="text-[10px] uppercase tracking-[0.18em] text-slate-300">Scope</div>
<div class="font-mono text-sm font-semibold">{{ $box['catalogo_modulo']['scope_code'] ?? ($fornitore->codice_univoco ?? ('FORN-' . $fornitore->id)) }}</div>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-7">
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Da FE</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['fe_products'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Da CSV</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['csv_products'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Manuali</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['manual_products'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Solo interni</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['internal_only'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Link sorgente</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['private_links'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Offerte</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['offers'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Amazon link</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['amazon_links'] ?? 0) }}</div>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa listino CSV</x-filament::button>
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Internalizza asset</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Prepara link Amazon</x-filament::button>
</div>
</div>
<div class="rounded-lg border p-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-lg font-semibold">Catalogo operativo interno</div>
<div class="mt-1 text-xs text-gray-500">Anagrafica prodotti/servizi del fornitore. I riferimenti esterni importati restano interni al gestionale e la pubblicazione usa il codice interno del catalogo.</div>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa CSV</x-filament::button>
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Asset interni</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Amazon</x-filament::button>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Prodotti</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['count'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Serializzati</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['serializzati'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Codici agganciati</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['identifiers'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Media</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['with_media'] ?? 0) }}</div>
</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Codice</th>
<th class="border px-2 py-1.5 text-left">Prodotto</th>
<th class="border px-2 py-1.5 text-left">Marca / modello</th>
<th class="border px-2 py-1.5 text-left">Origine</th>
<th class="border px-2 py-1.5 text-left">Rif. interno</th>
<th class="border px-2 py-1.5 text-left">Codici</th>
<th class="border px-2 py-1.5 text-left">Seriali</th>
<th class="border px-2 py-1.5 text-left">Offerte</th>
<th class="border px-2 py-1.5 text-left">Catalogo</th>
</tr>
</thead>
<tbody>
@forelse($catalogoProdottiRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5 font-medium">{{ $row['internal_code'] !== '' ? $row['internal_code'] : '-' }}</td>
<td class="border px-2 py-1.5">
<div>{{ $row['name'] !== '' ? $row['name'] : '-' }}</div>
@if($row['color_label'] !== '')
<div class="text-[11px] text-slate-500">Colore: {{ $row['color_label'] }}</div>
@endif
</td>
<td class="border px-2 py-1.5">{{ trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')) ?: '-' }}</td>
<td class="border px-2 py-1.5">
@if(($row['source'] ?? '') === 'fattura_elettronica')
<span class="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-800">FE</span>
@elseif(($row['source'] ?? '') === 'ncom_wholesale_csv')
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800">CSV</span>
@else
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700">manuale</span>
@endif
</td>
<td class="border px-2 py-1.5">
<div class="font-mono">{{ $row['public_reference'] !== '' ? $row['public_reference'] : '-' }}</div>
@if(($row['publication_mode'] ?? '') === 'internal_only')
<div class="text-[11px] text-slate-500">solo interno</div>
@endif
</td>
<td class="border px-2 py-1.5">{{ (int) ($row['identifiers_count'] ?? 0) }}</td>
<td class="border px-2 py-1.5">
{{ (int) ($row['serials_count'] ?? 0) }}
@if($row['track_serials'])
<span class="ml-1 rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-800">track</span>
@endif
</td>
<td class="border px-2 py-1.5">
<div>{{ (int) ($row['offers_count'] ?? 0) }} totali</div>
<div class="text-[11px] text-slate-500">
@if(($row['own_offer_price'] ?? null) !== null)
tuo {{ number_format((float) $row['own_offer_price'], 2, ',', '.') }}
@else
tuo n/d
@endif
</div>
<div class="text-[11px] text-slate-500">
@if(($row['best_competitor_price'] ?? null) !== null)
competitor {{ number_format((float) $row['best_competitor_price'], 2, ',', '.') }}
@if(($row['best_competitor_source'] ?? '') !== '')
| {{ $row['best_competitor_source'] }}
@endif
@else
competitor n/d
@endif
</div>
@if(($row['amazon_referral_url'] ?? '') !== '')
<div class="text-[11px] text-slate-500">Amazon referral pronto</div>
@endif
</td>
<td class="border px-2 py-1.5">
<div>Link sorgente: {{ (int) ($row['private_links'] ?? 0) }}</div>
<div class="text-[11px] text-slate-500">
@if(($row['price_wholesale'] ?? null) !== null)
{{ number_format((float) $row['price_wholesale'], 2, ',', '.') }}
@else
prezzo n/d
@endif
@if(($row['stock_quantity'] ?? null) !== null)
| stock {{ (int) $row['stock_quantity'] }}
@endif
</div>
@if(($row['categories'] ?? '') !== '')
<div class="truncate text-[11px] text-slate-500">{{ $row['categories'] }}</div>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="9" class="border px-2 py-3 text-center text-gray-500">Nessun prodotto presente per questo fornitore.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border p-4"> <div class="rounded-lg border p-4">
<div class="text-lg font-semibold">Pagamenti</div> <div class="text-lg font-semibold">Pagamenti</div>
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2"> <div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
@ -178,6 +362,59 @@ class="w-full rounded-lg border-gray-300 text-sm"
$acquaScan = (bool) ($acqua['scan_enabled'] ?? false); $acquaScan = (bool) ($acqua['scan_enabled'] ?? false);
@endphp @endphp
<div class="rounded-lg border p-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-base font-semibold">TecnoRepair / seriali</div>
<div class="mt-1 text-sm text-gray-600">Import MDB in anagrafica fornitore e aggancio automatico di seriali e prodotti assistenza.</div>
</div>
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('importa_tecnorepair')">
Importa TecnoRepair
</x-filament::button>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
<span>Schede</span>
<span class="font-semibold">{{ (int) ($box['tecnorepair']['schede'] ?? 0) }}</span>
</div>
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
<span>Aperte / attesa</span>
<span class="font-semibold">{{ (int) ($box['tecnorepair']['aperte'] ?? 0) }}</span>
</div>
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
<span>Chiuse</span>
<span class="font-semibold">{{ (int) ($box['tecnorepair']['chiuse'] ?? 0) }}</span>
</div>
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
<span>Seriali</span>
<span class="font-semibold">{{ (int) ($box['tecnorepair']['seriali'] ?? 0) }}</span>
</div>
</div>
<div class="mt-4 space-y-2 text-sm">
@forelse($tecnorepairRows as $row)
<div class="rounded-md border px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-medium">{{ $row['legacy_numero'] !== '' ? $row['legacy_numero'] : ('Scheda #' . $row['id']) }}</div>
<div class="truncate text-gray-600">{{ $row['product_model'] !== '' ? $row['product_model'] : '-' }}</div>
<div class="truncate text-xs text-gray-500">Codice: {{ $row['product_code'] !== '' ? $row['product_code'] : '-' }} | Seriale: {{ $row['serial_number'] !== '' ? $row['serial_number'] : '-' }}</div>
</div>
<div class="text-right">
<div class="rounded px-2 py-0.5 text-[10px] font-semibold {{ ($row['status_bucket'] ?? '') === 'closed' ? 'bg-emerald-100 text-emerald-800' : ((($row['status_bucket'] ?? '') === 'waiting') ? 'bg-amber-100 text-amber-800' : 'bg-rose-100 text-rose-800') }}">
{{ $row['status_label'] !== '' ? $row['status_label'] : ($row['status_bucket'] !== '' ? $row['status_bucket'] : 'stato') }}
</div>
<div class="mt-1 text-xs text-gray-500">Allegati: {{ (int) ($row['allegati_count'] ?? 0) }}</div>
</div>
</div>
</div>
@empty
<div class="text-sm text-gray-600">Nessuna scheda TecnoRepair collegata a questo fornitore.</div>
@endforelse
</div>
</div>
<div class="rounded-lg border p-4"> <div class="rounded-lg border p-4">
<div class="text-base font-semibold">Automazioni / Consumi</div> <div class="text-base font-semibold">Automazioni / Consumi</div>
<div class="mt-1 text-sm text-gray-600">Configura acquisizione dati (PDF) e scansione massiva sulle FE già importate.</div> <div class="mt-1 text-sm text-gray-600">Configura acquisizione dati (PDF) e scansione massiva sulle FE già importate.</div>

View File

@ -77,7 +77,10 @@
@endif @endif
@if(!$riga->ticket_id && $riga->stato !== 'chiusa') @if(!$riga->ticket_id && $riga->stato !== 'chiusa')
<x-filament::button size="sm" color="primary" wire:click="convertiInTicket({{ $riga->id }})">Converti in ticket</x-filament::button> <div class="flex flex-wrap items-center gap-2 rounded-md border bg-slate-50 px-2 py-1">
<input type="text" wire:model.defer="conversioneNote.{{ (int) $riga->id }}" class="w-56 rounded-md border-gray-300 text-xs" placeholder="Nota libera prima del ticket (opzionale)" />
<x-filament::button size="sm" color="primary" wire:click="convertiInTicket({{ $riga->id }})">Converti in ticket</x-filament::button>
</div>
@endif @endif
@if($riga->stato !== 'chiusa') @if($riga->stato !== 'chiusa')
@ -174,6 +177,10 @@
<option value="esterno">Solo numeri esterni</option> <option value="esterno">Solo numeri esterni</option>
</select> </select>
</div> </div>
<div>
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Linea o gruppo</label>
<input type="text" wire:model.live.debounce.300ms="tecnicoLineFilter" class="w-full rounded-md border-gray-300 text-xs" placeholder="Es. 0003, 603, GRP00603" />
</div>
<div> <div>
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Direzione</label> <label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Direzione</label>
<select wire:model.live="tecnicoDirectionFilter" class="w-full rounded-md border-gray-300 text-xs"> <select wire:model.live="tecnicoDirectionFilter" class="w-full rounded-md border-gray-300 text-xs">
@ -183,7 +190,7 @@
<option value="internal">Internal</option> <option value="internal">Internal</option>
</select> </select>
</div> </div>
<div class="md:col-span-1"> <div class="md:col-span-2">
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Ricerca</label> <label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Ricerca</label>
<input type="text" wire:model.live.debounce.300ms="tecnicoSearch" class="w-full rounded-md border-gray-300 text-xs" placeholder="Numero, interno, testo riga, nominativo tecnico" /> <input type="text" wire:model.live.debounce.300ms="tecnicoSearch" class="w-full rounded-md border-gray-300 text-xs" placeholder="Numero, interno, testo riga, nominativo tecnico" />
</div> </div>

View File

@ -0,0 +1,164 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Archivio assistenza TecnoRepair</div>
<div class="text-xs text-slate-500">Elenco filtrabile delle schede legacy importate da MDB e collegate al fornitore operativo, tipicamente NETHOME.</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Import comando: <span class="font-mono text-slate-800">{{ $this->importCommandHint }}</span>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 md:grid-cols-4">
<div class="rounded-lg border bg-slate-50 px-3 py-2 text-xs">Totali: <span class="font-semibold">{{ $counters['all'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-rose-50 px-3 py-2 text-xs text-rose-800">Aperti: <span class="font-semibold">{{ $counters['open'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-amber-50 px-3 py-2 text-xs text-amber-800">In attesa: <span class="font-semibold">{{ $counters['waiting'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-emerald-50 px-3 py-2 text-xs text-emerald-800">Chiusi: <span class="font-semibold">{{ $counters['closed'] ?? 0 }}</span></div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center gap-2">
<input type="text" wire:model.live.debounce.300ms="search" class="min-w-[240px] flex-1 rounded-lg border-gray-300 text-sm" placeholder="Cerca scheda, cliente, seriale, modello, difetto" />
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm">
<option value="all">Tutti gli stati</option>
<option value="open">Aperti</option>
<option value="waiting">In attesa</option>
<option value="closed">Chiusi</option>
</select>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-2 text-left">Scheda</th>
<th class="border px-2 py-2 text-left">Cliente</th>
<th class="border px-2 py-2 text-left">Prodotto</th>
<th class="border px-2 py-2 text-left">Seriali</th>
<th class="border px-2 py-2 text-left">Stato</th>
<th class="border px-2 py-2 text-left">Fornitore</th>
<th class="border px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($this->rows as $scheda)
<tr class="{{ $scheda->row_classes }}">
<td class="border px-2 py-2 align-top">
<div class="font-medium text-slate-900">{{ $scheda->display_title }}</div>
<div class="text-[11px] text-slate-500">Ingresso {{ optional($scheda->date_received)->format('d/m/Y') ?: '-' }}</div>
</td>
<td class="border px-2 py-2 align-top">
<div>{{ $scheda->customer_name ?: '-' }}</div>
<div class="text-[11px] text-slate-500">{{ $scheda->customer_phone ?: ($scheda->customer_email ?: 'recapito non presente') }}</div>
</td>
<td class="border px-2 py-2 align-top">
<div>{{ $scheda->product_model ?: '-' }}</div>
<div class="text-[11px] text-slate-500">{{ $scheda->product_code ?: 'codice non presente' }}</div>
</td>
<td class="border px-2 py-2 align-top">{{ $scheda->serial_label }}</td>
<td class="border px-2 py-2 align-top">
<span class="inline-flex rounded-full border px-2 py-1 text-[11px] font-medium {{ $scheda->status_badge_classes }}">{{ $scheda->status_label ?: 'Stato non indicato' }}</span>
</td>
<td class="border px-2 py-2 align-top">
@if($scheda->fornitore)
<div class="font-medium">{{ $scheda->fornitore->ragione_sociale ?: ('Fornitore #' . $scheda->fornitore->id) }}</div>
@if($scheda->fornitore->is_tecnorepair_primary)
<div class="text-[11px] text-emerald-700">Centro primario</div>
@endif
@else
-
@endif
</td>
<td class="border px-2 py-2 align-top">
<button type="button" wire:click="apriScheda({{ (int) $scheda->id }})" class="inline-flex items-center rounded-md bg-slate-800 px-2 py-1 text-[11px] font-medium text-white hover:bg-slate-700">Scheda</button>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="border px-2 py-6 text-center text-sm text-slate-500">Nessuna scheda legacy importata. Usa il comando indicato sopra per caricare l'archivio TecnoRepair.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
@php($selected = $this->selectedScheda)
@if(! $selected)
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Seleziona una riga dall'elenco per aprire la scheda tecnica legacy con cliente, seriali, allegati e note operative.
</div>
@else
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-lg font-semibold text-slate-900">{{ $selected->display_title }}</div>
<div class="mt-1 text-xs text-slate-500">Importata da {{ $selected->imported_from_path ?: 'sorgente sconosciuta' }}</div>
</div>
<span class="inline-flex rounded-full border px-2 py-1 text-[11px] font-medium {{ $selected->status_badge_classes }}">{{ $selected->status_label ?: 'Stato non indicato' }}</span>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Cliente</div>
<div class="mt-1 text-sm font-medium text-slate-900">{{ $selected->customer_name ?: '-' }}</div>
<div class="mt-1 text-xs text-slate-600">{{ $selected->customer_phone ?: '-' }}</div>
<div class="text-xs text-slate-600">{{ $selected->customer_phone_alt ?: '-' }}</div>
<div class="text-xs text-slate-600">{{ $selected->customer_email ?: '-' }}</div>
</div>
<div class="rounded-lg border bg-slate-50 p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Prodotto</div>
<div class="mt-1 text-sm font-medium text-slate-900">{{ $selected->product_model ?: '-' }}</div>
<div class="mt-1 text-xs text-slate-600">Codice {{ $selected->product_code ?: '-' }}</div>
<div class="text-xs text-slate-600">Seriali {{ $selected->serial_label }}</div>
</div>
</div>
<div class="mt-4 rounded-lg border bg-white p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Difetto segnalato</div>
<div class="mt-1 whitespace-pre-wrap text-sm text-slate-800">{{ $selected->defect_reported ?: 'Non valorizzato' }}</div>
</div>
<div class="mt-3 rounded-lg border bg-white p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Riparazione / comunicazioni</div>
<div class="mt-1 whitespace-pre-wrap text-sm text-slate-800">{{ $selected->repair_description ?: 'Nessuna descrizione riparazione' }}</div>
@if($selected->communications)
<div class="mt-3 whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700">{{ $selected->communications }}</div>
@endif
</div>
<div class="mt-3 rounded-lg border bg-white p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Fornitore e allegati</div>
<div class="mt-1 text-sm text-slate-800">
@if($selected->fornitore)
@php($fornitoreUrl = $this->getFornitoreUrl((int) $selected->fornitore->id))
@if($fornitoreUrl)
<a href="{{ $fornitoreUrl }}" class="font-medium text-sky-700 hover:text-sky-600">{{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }}</a>
@else
{{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }}
@endif
@else
Nessun fornitore collegato
@endif
</div>
<div class="mt-3 space-y-2">
@forelse($selected->allegati as $allegato)
<div class="rounded-md border bg-slate-50 px-3 py-2 text-xs text-slate-700">
<div class="font-medium">{{ $allegato->file_name ?: 'Allegato legacy' }}</div>
<div class="text-slate-500">{{ $allegato->file_path ?: 'Percorso non disponibile' }}</div>
</div>
@empty
<div class="text-xs text-slate-500">Nessun allegato legacy indicizzato.</div>
@endforelse
</div>
</div>
@endif
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -132,9 +132,71 @@
</ul> </ul>
</div> </div>
@endif @endif
@php($ticketCallContext = $this->ticketCallContext)
@if(!empty($ticketCallContext['phone']) || !empty($ticketCallContext['caller_name']) || !empty($ticketCallContext['target_extension']))
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">
<div class="font-semibold uppercase tracking-wide">Contesto chiamata</div>
<div class="mt-2 grid gap-2 md:grid-cols-2">
@if(!empty($ticketCallContext['phone']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Numero chiamante</div>
<div class="text-sm font-semibold">{{ $ticketCallContext['phone'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['target_extension']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Interno o gruppo chiamato</div>
<div class="text-sm font-semibold">{{ $ticketCallContext['target_extension'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['received_at']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Ricevuta alle</div>
<div>{{ $ticketCallContext['received_at'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['caller_name']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Contatto associato</div>
<div>{{ $ticketCallContext['caller_name'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['caller_profile']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Profilo</div>
<div>{{ $ticketCallContext['caller_profile'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['riferimento_stabile']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento stabile</div>
<div>{{ $ticketCallContext['riferimento_stabile'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['riferimento_unita']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento unità</div>
<div>{{ $ticketCallContext['riferimento_unita'] }}</div>
</div>
@endif
@if(!empty($ticketCallContext['stabile_label']))
<div>
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Stabile collegato</div>
<div>{{ $ticketCallContext['stabile_label'] }}</div>
</div>
@endif
</div>
@if(!empty($ticketCallContext['call_note']))
<div class="mt-2 rounded-md bg-white/70 px-3 py-2 text-[11px] text-emerald-800">
<div class="font-semibold">Nota operativa importata</div>
<div class="mt-1 whitespace-pre-wrap">{{ $ticketCallContext['call_note'] }}</div>
</div>
@endif
</div>
@endif
<div class="mt-3 grid gap-3 md:grid-cols-2"> <div class="mt-3 grid gap-3 md:grid-cols-2">
<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">Titolo</span> <span class="mb-1 block font-medium">Titolo operativo</span>
<input type="text" wire:model.defer="newTicketTitolo" class="w-full rounded-lg border-gray-300" placeholder="Es. Guasto ascensore scala B" /> <input type="text" wire:model.defer="newTicketTitolo" class="w-full rounded-lg border-gray-300" placeholder="Es. Guasto ascensore scala B" />
</label> </label>
@ -174,7 +236,7 @@
<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">Descrizione</span> <span class="mb-1 block font-medium">Descrizione</span>
<textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema..." ></textarea> <textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema senza ripetere i dati tecnici della chiamata..." ></textarea>
</label> </label>
<label class="block text-sm md:col-span-2"> <label class="block text-sm md:col-span-2">

View File

@ -31,5 +31,19 @@
@endif @endif
</div> </div>
</div> </div>
@else
<div class="mx-3 flex w-full max-w-xl items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 shadow-sm">
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-700">CTI live</div>
<div class="truncate text-sm font-medium text-slate-900">In attesa di chiamate recenti</div>
<div class="truncate text-xs text-slate-600">Il box operativo si apre automaticamente quando arriva una inbound valida.</div>
</div>
<div class="shrink-0">
<a href="{{ \App\Filament\Pages\Strumenti\PostItGestione::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100">
Log chiamate
</a>
</div>
</div>
@endif @endif
</div> </div>

View File

@ -0,0 +1,10 @@
@echo off
setlocal
rem Launcher unico Windows per setup/test/status/log/stop del bridge Panasonic live.
rem Esempi:
rem bootstrap-netgescon-panasonic-live.cmd
rem bootstrap-netgescon-panasonic-live.cmd -Action status
rem bootstrap-netgescon-panasonic-live.cmd -Action log
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0bootstrap-netgescon-panasonic-live.ps1" %*

View File

@ -0,0 +1,263 @@
<#
Bootstrap operativo unico per il bridge Panasonic live su Windows.
Comandi supportati:
- setup : imposta token, pubblica runtime, testa HTTP, installa task e lo avvia
- test : esegue solo lo smoke test HTTP verso staging
- status : mostra token, cartelle, stato task e ultime righe log
- log : segue il log live in tail -wait
- stop : ferma il task schedulato
Uso rapido:
.\bootstrap-netgescon-panasonic-live.ps1
.\bootstrap-netgescon-panasonic-live.ps1 -Action status
#>
param(
[ValidateSet('setup', 'test', 'status', 'log', 'stop', 'dry-run', 'live-run')]
[string]$Action = 'setup',
[string]$BaseUrl = 'https://staging.netgescon.it',
[string]$Token = 'netgescon-cti-test-20260311',
[string]$Extensions = '201-208,601,603,0001,0003',
[string]$TaskName = 'NetGescon Panasonic Live Bridge',
[string]$RuntimeDir = 'C:\ProgramData\NetGescon\panasonic-live\current',
[string]$LogFile = '',
[string]$InteropOutputDir = '',
[switch]$SkipHttpTest
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step {
param([string]$Message)
Write-Host ''
Write-Host ('=== {0} ===' -f $Message)
}
function Ensure-Directory {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Get-ResolvedLogFile {
if (-not [string]::IsNullOrWhiteSpace($LogFile)) {
return $LogFile
}
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
return (Join-Path $env:LOCALAPPDATA 'NetGescon\Logs\netgescon-tapi-dotnet-events-live.log')
}
return 'C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log'
}
function Get-ResolvedInteropDir {
if (-not [string]::IsNullOrWhiteSpace($InteropOutputDir)) {
return $InteropOutputDir
}
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
return (Join-Path $env:LOCALAPPDATA 'NetGescon\tapi-interop')
}
return 'C:\ProgramData\NetGescon\tapi-interop'
}
function Get-RepoWindowsOpsRoot {
$scriptRoot = $PSScriptRoot
$publishScript = Join-Path $scriptRoot 'publish-netgescon-panasonic-runtime.ps1'
if (Test-Path $publishScript) {
return $scriptRoot
}
if (Test-Path (Join-Path $RuntimeDir 'publish-netgescon-panasonic-runtime.ps1')) {
return $RuntimeDir
}
throw 'Script publish-netgescon-panasonic-runtime.ps1 non trovato. Lancia il bootstrap dalla cartella scripts\ops\windows del repo.'
}
function Publish-Runtime {
param([string]$SourceRoot)
$publishScript = Join-Path $SourceRoot 'publish-netgescon-panasonic-runtime.ps1'
if (-not (Test-Path $publishScript)) {
throw "Script publish non trovato: $publishScript"
}
Write-Step 'Publish runtime locale'
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $publishScript -TargetRoot $RuntimeDir
}
function Test-HttpBridge {
$testScript = Join-Path $RuntimeDir 'test-netgescon-panasonic-api.ps1'
if (-not (Test-Path $testScript)) {
throw "Script test non trovato: $testScript"
}
Write-Step 'Smoke test HTTP verso staging'
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testScript -BaseUrl $BaseUrl -Token $Token -Extension '205'
}
function Install-Task {
$installCmd = Join-Path $RuntimeDir 'install-netgescon-panasonic-live-task.cmd'
if (-not (Test-Path $installCmd)) {
throw "Launcher task non trovato: $installCmd"
}
Write-Step 'Installazione task'
& $installCmd -Mode current-user-logon -BaseUrl $BaseUrl -Extensions $Extensions -LogFile $script:ResolvedLogFile -InteropOutputDir $script:ResolvedInteropDir
}
function Start-BridgeTask {
Write-Step 'Avvio task'
Start-ScheduledTask -TaskName $TaskName
Start-Sleep -Seconds 2
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List LastRunTime, LastTaskResult, NextRunTime, TaskName
}
function Stop-BridgeTask {
Write-Step 'Stop task'
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List LastRunTime, LastTaskResult, NextRunTime, TaskName
}
function Show-Status {
Write-Step 'Stato bridge'
Write-Host ('BaseUrl : {0}' -f $BaseUrl)
Write-Host ('RuntimeDir : {0}' -f $RuntimeDir)
Write-Host ('LogFile : {0}' -f $script:ResolvedLogFile)
Write-Host ('InteropDir : {0}' -f $script:ResolvedInteropDir)
Write-Host ('Token user : {0}' -f ([Environment]::GetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', 'User')))
try {
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns, TaskName
}
catch {
Write-Host ('Task non trovato: {0}' -f $TaskName)
}
if (Test-Path $script:ResolvedLogFile) {
Write-Host ''
Write-Host 'Ultime 30 righe log:'
Get-Content $script:ResolvedLogFile -Tail 30
}
else {
Write-Host ''
Write-Host 'Log non ancora presente.'
}
}
function Follow-Log {
Write-Step 'Follow log live'
Ensure-Directory -Path (Split-Path -Parent $script:ResolvedLogFile)
if (-not (Test-Path $script:ResolvedLogFile)) {
New-Item -ItemType File -Path $script:ResolvedLogFile -Force | Out-Null
}
Get-Content $script:ResolvedLogFile -Tail 100 -Wait
}
function Start-DryRunWatcher {
$watchScript = Join-Path $RuntimeDir 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
if (-not (Test-Path $watchScript)) {
throw "Watcher non trovato: $watchScript"
}
Write-Step 'Dry-run watcher Panasonic'
Write-Host ('Runtime script: {0}' -f $watchScript)
Write-Host ('Log file : {0}' -f $script:ResolvedLogFile)
Write-Host ('Interop dir : {0}' -f $script:ResolvedInteropDir)
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchScript `
-Extensions $Extensions `
-BridgeMode 'dry-run' `
-LogFile $script:ResolvedLogFile `
-InteropOutputDir $script:ResolvedInteropDir `
-IncludeDiagnosticEvents
}
function Start-LiveWatcher {
$watchScript = Join-Path $RuntimeDir 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
if (-not (Test-Path $watchScript)) {
throw "Watcher non trovato: $watchScript"
}
Write-Step 'Live watcher Panasonic'
Write-Host ('Runtime script: {0}' -f $watchScript)
Write-Host ('BaseUrl : {0}' -f $BaseUrl)
Write-Host ('Log file : {0}' -f $script:ResolvedLogFile)
Write-Host ('Interop dir : {0}' -f $script:ResolvedInteropDir)
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchScript `
-Extensions $Extensions `
-BridgeMode 'live' `
-BaseUrl $BaseUrl `
-Token $Token `
-LogFile $script:ResolvedLogFile `
-InteropOutputDir $script:ResolvedInteropDir
}
$script:ResolvedLogFile = Get-ResolvedLogFile
$script:ResolvedInteropDir = Get-ResolvedInteropDir
Ensure-Directory -Path (Split-Path -Parent $script:ResolvedLogFile)
Ensure-Directory -Path $script:ResolvedInteropDir
switch ($Action) {
'setup' {
Write-Step 'Impostazione token utente'
[Environment]::SetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', $Token, 'User')
Write-Host ('Token utente impostato: {0}' -f ([Environment]::GetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', 'User')))
$sourceRoot = Get-RepoWindowsOpsRoot
Publish-Runtime -SourceRoot $sourceRoot
if (-not $SkipHttpTest.IsPresent) {
Test-HttpBridge
}
Install-Task
Start-BridgeTask
Show-Status
break
}
'test' {
Test-HttpBridge
break
}
'status' {
Show-Status
break
}
'log' {
Follow-Log
break
}
'stop' {
Stop-BridgeTask
break
}
'dry-run' {
$sourceRoot = Get-RepoWindowsOpsRoot
Publish-Runtime -SourceRoot $sourceRoot
Start-DryRunWatcher
break
}
'live-run' {
[Environment]::SetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', $Token, 'User')
$sourceRoot = Get-RepoWindowsOpsRoot
Publish-Runtime -SourceRoot $sourceRoot
Start-LiveWatcher
break
}
}

View File

@ -34,7 +34,7 @@ function Convert-ComCollectionToArray {
catch { catch {
} }
return $items return ,$items
} }
function Get-AddressSnapshot { function Get-AddressSnapshot {
@ -88,7 +88,7 @@ Write-Host "[2/5] Inizializzo TAPI..."
$tapi.Initialize() $tapi.Initialize()
Write-Host "[3/5] Leggo le informazioni generali..." Write-Host "[3/5] Leggo le informazioni generali..."
$addressesRaw = Convert-ComCollectionToArray -Collection $tapi.Addresses $addressesRaw = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
$addressSnapshots = @() $addressSnapshots = @()
foreach ($address in $addressesRaw) { foreach ($address in $addressesRaw) {
$addressSnapshots += Get-AddressSnapshot -Address $address -FilterValue $Filter $addressSnapshots += Get-AddressSnapshot -Address $address -FilterValue $Filter

View File

@ -53,13 +53,13 @@ function Convert-ComCollectionToArray {
catch { catch {
} }
return $items return ,$items
} }
$tapi = New-Object -ComObject TAPI.TAPI $tapi = New-Object -ComObject TAPI.TAPI
$tapi.Initialize() $tapi.Initialize()
$addresses = Convert-ComCollectionToArray -Collection $tapi.Addresses $addresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
$target = $addresses | Where-Object { $target = $addresses | Where-Object {
($_.DialableAddress -as [string]) -eq $Extension -or ($_.DialableAddress -as [string]) -eq $Extension -or
($_.AddressName -as [string]) -match [regex]::Escape($Extension) ($_.AddressName -as [string]) -match [regex]::Escape($Extension)

View File

@ -0,0 +1,189 @@
param(
[string]$OutFile = ".\netgescon-tapi-provider-report.json"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Get-RegistryValues {
param([string]$Path)
if (-not (Test-Path $Path)) {
return $null
}
$item = Get-ItemProperty -Path $Path
$props = @{}
foreach ($property in $item.PSObject.Properties) {
if ($property.Name -in @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')) {
continue
}
$props[$property.Name] = $property.Value
}
return $props
}
function Get-RegistryChildren {
param([string]$Path)
if (-not (Test-Path $Path)) {
return @()
}
return @(Get-ChildItem -Path $Path | ForEach-Object {
[pscustomobject]@{
Name = $_.PSChildName
Path = $_.PSPath
Values = Get-RegistryValues -Path $_.PSPath
}
})
}
function Get-MapValue {
param(
[object]$Map,
[string]$Key
)
if ($null -eq $Map) {
return $null
}
if ($Map -is [System.Collections.IDictionary] -and $Map.Contains($Key)) {
return $Map[$Key]
}
return $null
}
function Get-CollectionCount {
param([object]$Value)
if ($null -eq $Value) {
return 0
}
if ($Value -is [string]) {
return 1
}
if ($Value -is [System.Collections.ICollection]) {
return $Value.Count
}
if ($Value -is [System.Array]) {
return $Value.Length
}
return @($Value).Count
}
Write-Host "[1/6] Leggo stato servizio Telefonia..."
$tapiService = Get-Service -Name TapiSrv -ErrorAction Stop
Write-Host "[2/6] Leggo servizi dipendenti..."
$dependentServices = @($tapiService.DependentServices | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
DisplayName = $_.DisplayName
Status = $_.Status.ToString()
}
})
Write-Host "[3/6] Leggo chiavi registry Telephony..."
$telephonyRoot = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Telephony'
$telephonyWowRoot = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Telephony'
$telephonyValues = Get-RegistryValues -Path $telephonyRoot
$telephonyWowValues = Get-RegistryValues -Path $telephonyWowRoot
$telephonyProviders = Get-RegistryChildren -Path (Join-Path $telephonyRoot 'Providers')
$telephonyWowProviders = Get-RegistryChildren -Path (Join-Path $telephonyWowRoot 'Providers')
Write-Host "[4/6] Cerco riferimenti Panasonic nel registry..."
$panasonicHits = @()
foreach ($root in @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Telephony',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Telephony',
'HKLM:\SOFTWARE\Panasonic',
'HKLM:\SOFTWARE\WOW6432Node\Panasonic'
)) {
if (-not (Test-Path $root)) {
continue
}
$panasonicHits += @(Get-ChildItem -Path $root -Recurse -ErrorAction SilentlyContinue | Where-Object {
$_.Name -match 'Panasonic|KX|TSP|TAPI'
} | Select-Object -First 100 | ForEach-Object {
[pscustomobject]@{
Path = $_.PSPath
Name = $_.PSChildName
Values = Get-RegistryValues -Path $_.PSPath
}
})
}
Write-Host "[5/6] Leggo provider COM TAPI registrati..."
$clsidHits = @()
foreach ($root in @(
'HKCR:\CLSID',
'HKLM:\SOFTWARE\Classes\CLSID'
)) {
if (-not (Test-Path $root)) {
continue
}
$clsidHits += @(Get-ChildItem -Path $root -ErrorAction SilentlyContinue | ForEach-Object {
$values = Get-RegistryValues -Path $_.PSPath
$defaultValue = Get-MapValue -Map $values -Key '(default)'
$localizedString = Get-MapValue -Map $values -Key 'LocalizedString'
if ($null -ne $values -and (($defaultValue -as [string]) -match 'Panasonic|TAPI' -or ($localizedString -as [string]) -match 'Panasonic|TAPI')) {
[pscustomobject]@{
Path = $_.PSPath
Name = $_.PSChildName
Values = $values
}
}
})
}
Write-Host "[6/6] Scrivo report JSON..."
$report = [pscustomobject]@{
GeneratedAt = (Get-Date).ToString('o')
ComputerName = $env:COMPUTERNAME
UserName = $env:USERNAME
TapiService = [pscustomobject]@{
Name = $tapiService.Name
DisplayName = $tapiService.DisplayName
Status = $tapiService.Status.ToString()
StartType = (Get-CimInstance Win32_Service -Filter "Name='TapiSrv'" | Select-Object -ExpandProperty StartMode)
}
DependentServices = $dependentServices
Telephony = [pscustomobject]@{
RootValues = $telephonyValues
WowRootValues = $telephonyWowValues
Providers = $telephonyProviders
WowProviders = $telephonyWowProviders
}
PanasonicRegistryHits = $panasonicHits
TapiClsidHits = $clsidHits
}
$outDirectory = Split-Path -Path $OutFile -Parent
if (-not [string]::IsNullOrWhiteSpace($outDirectory) -and -not (Test-Path $outDirectory)) {
New-Item -ItemType Directory -Path $outDirectory -Force | Out-Null
}
$report | ConvertTo-Json -Depth 8 | Set-Content -Path $OutFile -Encoding UTF8
Write-Host ""
Write-Host ("TapiSrv: {0} ({1})" -f $report.TapiService.Status, $report.TapiService.StartType)
Write-Host ("Dependent services: {0}" -f (Get-CollectionCount -Value $report.DependentServices))
Write-Host ("Telephony providers x64: {0}" -f (Get-CollectionCount -Value $report.Telephony.Providers))
Write-Host ("Telephony providers wow64: {0}" -f (Get-CollectionCount -Value $report.Telephony.WowProviders))
Write-Host ("Panasonic registry hits: {0}" -f (Get-CollectionCount -Value $report.PanasonicRegistryHits))
Write-Host ("TAPI CLSID hits: {0}" -f (Get-CollectionCount -Value $report.TapiClsidHits))
Write-Host ("File creato: {0}" -f $OutFile)

View File

@ -0,0 +1,6 @@
@echo off
setlocal
rem Wrapper Windows: installa/aggiorna il task schedulato senza dover cambiare ExecutionPolicy della macchina.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-netgescon-panasonic-live-task.ps1" %*

View File

@ -1,3 +1,15 @@
<#
Installa o aggiorna il task schedulato che lancia il bridge Panasonic live.
Modalita disponibili:
- system-startup: parte all'avvio macchina come SYSTEM, richiede admin e path locali affidabili
- current-user-logon: parte al logon dell'utente corrente, adatta quando non hai privilegi admin
Importante:
- i drive mappati tipo S: non sono affidabili per i task schedulati
- per produzione usa un path locale tipo C:\NetGescon o C:\ProgramData\NetGescon
#>
param( param(
[string]$TaskName = 'NetGescon Panasonic Live Bridge', [string]$TaskName = 'NetGescon Panasonic Live Bridge',
[string]$BaseUrl = 'https://staging.netgescon.it', [string]$BaseUrl = 'https://staging.netgescon.it',
@ -13,6 +25,31 @@ param(
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
function Ensure-ParentDirectory {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
$parent = Split-Path -Path $Path -Parent
if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
}
function Ensure-Directory {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Test-IsAdministrator { function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent() $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity) $principal = [Security.Principal.WindowsPrincipal]::new($identity)
@ -33,6 +70,9 @@ if ([string]::IsNullOrWhiteSpace($InteropOutputDir)) {
} }
} }
Ensure-ParentDirectory -Path $LogFile
Ensure-Directory -Path $InteropOutputDir
$argumentList = @( $argumentList = @(
'-NoProfile' '-NoProfile'
'-ExecutionPolicy', 'Bypass' '-ExecutionPolicy', 'Bypass'

View File

@ -99,7 +99,7 @@ function Convert-ComCollectionToArray {
catch { catch {
} }
return $items return ,$items
} }
function Normalize-ExtensionToken { function Normalize-ExtensionToken {
@ -180,7 +180,7 @@ $tapi = New-Object -ComObject TAPI.TAPI
$tapi.Initialize() $tapi.Initialize()
$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate $tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate
$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses $allAddresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
$addresses = @($allAddresses | Where-Object { $addresses = @($allAddresses | Where-Object {
Should-WatchAddress -Address $_ -WatchList $watchList Should-WatchAddress -Address $_ -WatchList $watchList
}) })

View File

@ -0,0 +1,74 @@
<#
Copia i file runtime Panasonic/TAPI in una cartella locale stabile Windows.
Uso tipico:
1. tieni il repo dove vuoi anche temporaneamente
2. lancia questo script
3. installa e avvia il bridge dalla cartella runtime locale pubblicata
Vantaggio:
- il task non dipende da S:
- se aggiorni il repo, rilanci questo script e riallinei il runtime locale
#>
param(
[string]$TargetRoot = 'C:\ProgramData\NetGescon\panasonic-live\current',
[switch]$IncludeDocs
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
$sourceRoot = $PSScriptRoot
if (-not (Test-Path $sourceRoot)) {
throw "Cartella sorgente non trovata: $sourceRoot"
}
Ensure-Directory -Path $TargetRoot
$files = @(
'start-netgescon-panasonic-live.ps1',
'start-netgescon-panasonic-live.cmd',
'install-netgescon-panasonic-live-task.ps1',
'install-netgescon-panasonic-live-task.cmd',
'watch-netgescon-panasonic-tapi-dotnet-events.ps1',
'watch-netgescon-panasonic-tapi-com-events.ps1',
'poll-netgescon-panasonic-active-calls.ps1',
'get-netgescon-panasonic-tapi-info.ps1',
'inspect-netgescon-panasonic-address.ps1',
'inspect-netgescon-tapi-interop.ps1',
'test-netgescon-panasonic-api.ps1',
'test-netgescon-panasonic-register-notifications.ps1'
)
foreach ($file in $files) {
$sourcePath = Join-Path $sourceRoot $file
if (Test-Path $sourcePath) {
Copy-Item -Path $sourcePath -Destination (Join-Path $TargetRoot $file) -Force
Write-Host ("COPIATO {0}" -f $file)
}
}
if ($IncludeDocs.IsPresent) {
$docsRoot = Split-Path -Path $sourceRoot -Parent | Split-Path -Parent | Split-Path -Parent
$docPath = Join-Path $docsRoot 'docs\CTI-WINDOWS-TAPI-PROBE.md'
if (Test-Path $docPath) {
Copy-Item -Path $docPath -Destination (Join-Path $TargetRoot 'CTI-WINDOWS-TAPI-PROBE.md') -Force
Write-Host 'COPIATO CTI-WINDOWS-TAPI-PROBE.md'
}
}
Write-Host ''
Write-Host ('Runtime pubblicato in: {0}' -f $TargetRoot)
Write-Host 'Avvio manuale consigliato:'
Write-Host (' {0}' -f (Join-Path $TargetRoot 'start-netgescon-panasonic-live.cmd'))
Write-Host 'Installazione task consigliata:'
Write-Host (' {0} -Mode current-user-logon' -f (Join-Path $TargetRoot 'install-netgescon-panasonic-live-task.cmd'))

View File

@ -0,0 +1,7 @@
@echo off
setlocal
rem Wrapper Windows: evita il blocco ExecutionPolicy quando vuoi lanciare il bridge a mano.
rem Usa sempre il .ps1 nella stessa cartella del file .cmd.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-netgescon-panasonic-live.ps1" %*

View File

@ -1,3 +1,16 @@
<#
Avvia il bridge live Panasonic -> NetGescon in loop continuo.
Cosa fa:
1. risolve watcher, token, log e cartella interop
2. avvia il watcher TAPI .NET in modalita live
3. se il watcher termina con errore, aspetta pochi secondi e riparte
Note operative:
- va eseguito preferibilmente da un percorso locale Windows, non da drive mappati tipo S:
- il task schedulato puo richiamare questo script in sicurezza con -ExecutionPolicy Bypass
#>
param( param(
[string]$BaseUrl = 'https://staging.netgescon.it', [string]$BaseUrl = 'https://staging.netgescon.it',
[string]$Extensions = '201-208,601,603,0001,0003', [string]$Extensions = '201-208,601,603,0001,0003',
@ -11,6 +24,31 @@ param(
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
function Ensure-ParentDirectory {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
$parent = Split-Path -Path $Path -Parent
if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
}
function Ensure-Directory {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
$scriptPath = Join-Path $PSScriptRoot 'watch-netgescon-panasonic-tapi-dotnet-events.ps1' $scriptPath = Join-Path $PSScriptRoot 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
if (-not (Test-Path $scriptPath)) { if (-not (Test-Path $scriptPath)) {
throw "Watcher non trovato: $scriptPath" throw "Watcher non trovato: $scriptPath"
@ -40,7 +78,11 @@ elseif (-not [System.IO.Path]::IsPathRooted($InteropOutputDir)) {
$InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir $InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir
} }
Ensure-ParentDirectory -Path $LogFile
Ensure-Directory -Path $InteropOutputDir
while ($true) { while ($true) {
# Ciclo supervisore: il watcher gestisce la logica TAPI, questo launcher lo riavvia se cade.
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "$timestamp | AVVIO bridge live Panasonic -> NetGescon" Write-Host "$timestamp | AVVIO bridge live Panasonic -> NetGescon"
@ -65,5 +107,6 @@ while ($true) {
Write-Host "$timestamp | ERRORE bridge live: $($_.Exception.Message)" Write-Host "$timestamp | ERRORE bridge live: $($_.Exception.Message)"
} }
# Evita restart-loop aggressivo in caso di errore ripetuto.
Start-Sleep -Seconds ([Math]::Max(1, $RestartDelaySeconds)) Start-Sleep -Seconds ([Math]::Max(1, $RestartDelaySeconds))
} }

View File

@ -37,7 +37,7 @@ function Convert-ComCollectionToArray {
catch { catch {
} }
return $items return ,$items
} }
function Normalize-ExtensionToken { function Normalize-ExtensionToken {

View File

@ -45,7 +45,7 @@ function Convert-ComCollectionToArray {
catch { catch {
} }
return $items return ,$items
} }
function Normalize-ExtensionToken { function Normalize-ExtensionToken {
@ -124,7 +124,7 @@ $tapi = New-Object -ComObject TAPI.TAPI
$tapi.Initialize() $tapi.Initialize()
$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate $tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate
$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses $allAddresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
$addresses = @($allAddresses | Where-Object { $addresses = @($allAddresses | Where-Object {
Should-WatchAddress -Address $_ -WatchList $watchList Should-WatchAddress -Address $_ -WatchList $watchList
}) })

View File

@ -97,13 +97,13 @@ function Convert-ToSafeString {
function Get-FirstNonEmptyProperty { function Get-FirstNonEmptyProperty {
param( param(
$Object, $Object,
[Parameter(Mandatory = $true)] [string[]]$Names [Parameter(Mandatory = $true)] [string[]]$Names
) )
if ($null -eq $Object) { if ($null -eq $Object) {
return '' return ''
} }
foreach ($name in $Names) { foreach ($name in $Names) {
$value = Get-SafeProperty -Object $Object -Name $name $value = Get-SafeProperty -Object $Object -Name $name
@ -172,7 +172,7 @@ function Parse-WatchList {
} }
} }
return @($watchItems) return @($watchItems.ToArray())
} }
function Is-WatchedExtensionValue { function Is-WatchedExtensionValue {
@ -186,7 +186,13 @@ function Is-WatchedExtensionValue {
return $false return $false
} }
return $WatchList -contains $token foreach ($watch in $WatchList) {
if ((Normalize-ExtensionToken -Value $watch) -eq $token) {
return $true
}
}
return $false
} }
function Select-ExternalPhoneCandidate { function Select-ExternalPhoneCandidate {
@ -229,7 +235,32 @@ function Convert-ComCollectionToArray {
catch { catch {
} }
return $items return , $items
}
function Flatten-ObjectArray {
param([object[]]$Items)
$flattened = @()
foreach ($item in $Items) {
if ($null -eq $item) {
continue
}
if ($item -is [System.Array]) {
foreach ($nested in $item) {
if ($null -ne $nested) {
$flattened += $nested
}
}
continue
}
$flattened += $item
}
return @($flattened)
} }
function Normalize-ExtensionToken { function Normalize-ExtensionToken {
@ -251,6 +282,113 @@ function Normalize-ExtensionToken {
return $trimmed return $trimmed
} }
function Get-TokenCategory {
param([string]$Value)
if ($null -eq $Value) {
return ''
}
$trimmed = ([string]$Value).Trim()
if ($trimmed -eq '') {
return ''
}
$upper = $trimmed.ToUpperInvariant()
if ($upper -match '^EXT\d+$') {
return 'EXT'
}
if ($upper -match '^GRP\d+$') {
return 'GRP'
}
if ($upper -match '^CO\d+$') {
return 'CO'
}
if ($upper -eq 'SYSTEM') {
return 'SYSTEM'
}
$normalized = Normalize-ExtensionToken -Value $trimmed
if ($normalized -eq '') {
return ''
}
if ($trimmed -match '^0\d+$') {
return 'CO'
}
if ($normalized -match '^6\d\d$') {
return 'GRP'
}
if ($normalized -match '^\d+$') {
return 'EXT'
}
return ''
}
function Get-AddressTokenInfos {
param([string[]]$Values)
$tokens = New-Object System.Collections.Generic.List[object]
foreach ($value in $Values) {
$text = Convert-ToSafeString -Value $value
if ($text -eq '') {
continue
}
foreach ($piece in @([regex]::Split($text.Trim(), '[\s,;\|]+') | Where-Object { $_ -ne '' })) {
$tokens.Add([pscustomobject]@{
Raw = $piece
Normalized = Normalize-ExtensionToken -Value $piece
Category = Get-TokenCategory -Value $piece
})
}
}
return @($tokens.ToArray())
}
function Find-AddressWatchMatch {
param(
[string[]]$Values,
[Parameter(Mandatory = $true)] [string[]]$WatchList
)
$tokenInfos = @(Get-AddressTokenInfos -Values $Values)
foreach ($tokenInfo in $tokenInfos) {
if ($null -eq $tokenInfo -or $tokenInfo.Normalized -eq '') {
continue
}
foreach ($watch in $WatchList) {
$watchNormalized = Normalize-ExtensionToken -Value $watch
if ($watchNormalized -eq '' -or $watchNormalized -ne $tokenInfo.Normalized) {
continue
}
$category = $tokenInfo.Category
if ($category -eq '' -or $category -eq 'SYSTEM') {
$category = Get-TokenCategory -Value $watch
}
return [pscustomobject]@{
Raw = $tokenInfo.Raw
Normalized = $tokenInfo.Normalized
Category = $category
WatchValue = [string]$watch
}
}
}
return $null
}
function Get-AddressCategory { function Get-AddressCategory {
param([string]$AddressName) param([string]$AddressName)
@ -294,35 +432,9 @@ function Should-WatchAddress {
$rawDial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress') $rawDial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress')
$rawName = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName') $rawName = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName')
$dial = Normalize-ExtensionToken -Value $rawDial $addressMatch = Find-AddressWatchMatch -Values @($rawDial, $rawName) -WatchList $WatchList
$name = Normalize-ExtensionToken -Value $rawName if ($null -ne $addressMatch) {
$category = Get-AddressCategory -AddressName $rawName return $true
foreach ($watch in $WatchList) {
$watchRaw = Convert-ToSafeString -Value $watch
$token = Normalize-ExtensionToken -Value $watchRaw
if ($token -eq '') {
continue
}
if ($watchRaw -match '^0\d+$') {
if ($category -ne 'CO') {
continue
}
$watchInt = Convert-ToNullableInt -Value $watchRaw
$nameInt = Convert-ToNullableInt -Value $rawName
$dialInt = Convert-ToNullableInt -Value $rawDial
if (($null -ne $watchInt -and $null -ne $nameInt -and $watchInt -eq $nameInt) -or ($null -ne $watchInt -and $null -ne $dialInt -and $watchInt -eq $dialInt)) {
return $true
}
continue
}
if ($dial -eq $token -or $name -eq $token) {
return $true
}
} }
return $false return $false
@ -468,7 +580,7 @@ function Find-BridgeCallMatchBySignature {
} }
return [pscustomobject]@{ return [pscustomobject]@{
EventId = [string]$eventId EventId = [string]$eventId
CallRecord = $callRecord CallRecord = $callRecord
} }
} }
@ -529,16 +641,16 @@ function Try-RegisterCallNotifications {
try { try {
$cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0) $cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0)
return [pscustomobject]@{ return [pscustomobject]@{
Ok = $true Ok = $true
Cookie = $cookie Cookie = $cookie
Error = '' Error = ''
} }
} }
catch { catch {
return [pscustomobject]@{ return [pscustomobject]@{
Ok = $false Ok = $false
Cookie = $null Cookie = $null
Error = $_.Exception.Message Error = $_.Exception.Message
} }
} }
} }
@ -681,7 +793,7 @@ function Describe-Call {
} }
$parts = @() $parts = @()
foreach ($name in @('CallState', 'Privilege', 'MediaType', 'CallerIDAddress', 'CalledIDAddress', 'ConnectedIDAddress', 'CallerIDName', 'CalledIDName', 'ConnectedIDName')) { foreach ($name in @('CallState', 'Privilege', 'MediaType', 'CallerIDAddress', 'CalledIDAddress', 'ConnectedIDAddress', 'CallerIDName', 'CalledIDName', 'ConnectedIDName')) {
$value = Get-SafeProperty -Object $Call -Name $name $value = Get-SafeProperty -Object $Call -Name $name
$valueString = Convert-ToSafeString -Value $value $valueString = Convert-ToSafeString -Value $value
if ($null -ne $value -and $valueString -ne '') { if ($null -ne $value -and $valueString -ne '') {
@ -808,11 +920,25 @@ function Get-CallSnapshot {
$addressName = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'AddressName') $addressName = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'AddressName')
$dialableAddress = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'DialableAddress') $dialableAddress = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'DialableAddress')
$addressMatch = Find-AddressWatchMatch -Values @($dialableAddress, $addressName) -WatchList $WatchList
$addressCategory = Get-AddressCategory -AddressName $addressName $addressCategory = Get-AddressCategory -AddressName $addressName
$watchedExtension = Normalize-ExtensionToken -Value $dialableAddress $watchedExtension = ''
if ($null -ne $addressMatch) {
$watchedExtension = $addressMatch.Normalized
if ($addressMatch.Category -ne '') {
$addressCategory = $addressMatch.Category
}
}
if ($watchedExtension -eq '') {
$watchedExtension = Normalize-ExtensionToken -Value $dialableAddress
}
if ($watchedExtension -eq '') { if ($watchedExtension -eq '') {
$watchedExtension = Normalize-ExtensionToken -Value $addressName $watchedExtension = Normalize-ExtensionToken -Value $addressName
} }
if (($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') -and $watchedExtension -ne '') {
$addressCategory = Get-TokenCategory -Value $watchedExtension
}
$callState = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'State') $callState = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'State')
$cause = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'Cause') $cause = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'Cause')
@ -850,17 +976,39 @@ function Get-CallSnapshot {
$callingExtension = '' $callingExtension = ''
$calledExtension = $watchedExtension $calledExtension = $watchedExtension
if ($callerToken -ne '' -and ($WatchList -contains $callerToken) -and $calledIdAddress -ne '' -and -not ($WatchList -contains $calledToken)) { if (Is-WatchedExtensionValue -Value $calledToken -WatchList $WatchList) {
$calledExtension = $calledToken
if ($watchedExtension -eq '' -or $addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
$watchedExtension = $calledToken
$addressCategory = Get-TokenCategory -Value $calledIdAddress
}
}
if ($callerToken -ne '' -and (Is-WatchedExtensionValue -Value $callerToken -WatchList $WatchList) -and $calledIdAddress -ne '' -and -not (Is-WatchedExtensionValue -Value $calledToken -WatchList $WatchList)) {
$direction = 'outbound' $direction = 'outbound'
$externalPhone = Select-ExternalPhoneCandidate -Candidates @($calledIdAddress, $connectedIdAddress, $callerIdAddress) -WatchList $WatchList $externalPhone = Select-ExternalPhoneCandidate -Candidates @($calledIdAddress, $connectedIdAddress, $callerIdAddress) -WatchList $WatchList
$callingExtension = $callerToken $callingExtension = $callerToken
$calledExtension = $watchedExtension $calledExtension = $watchedExtension
if ($watchedExtension -eq '') {
$watchedExtension = $callerToken
}
if ($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
$addressCategory = Get-TokenCategory -Value $callerIdAddress
}
} }
elseif ($connectedToken -ne '' -and ($WatchList -contains $connectedToken) -and $callerToken -eq '') { elseif ($connectedToken -ne '' -and (Is-WatchedExtensionValue -Value $connectedToken -WatchList $WatchList) -and $callerToken -eq '') {
$calledExtension = $connectedToken $calledExtension = $connectedToken
if ($watchedExtension -eq '' -or $addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
$watchedExtension = $connectedToken
$addressCategory = Get-TokenCategory -Value $connectedIdAddress
}
} }
elseif ($watchedExtension -eq '' -and $connectedToken -ne '' -and ($WatchList -contains $connectedToken)) { elseif ($watchedExtension -eq '' -and $connectedToken -ne '' -and (Is-WatchedExtensionValue -Value $connectedToken -WatchList $WatchList)) {
$calledExtension = $connectedToken $calledExtension = $connectedToken
$watchedExtension = $connectedToken
if ($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
$addressCategory = Get-TokenCategory -Value $connectedIdAddress
}
} }
$eventId = Get-CallIdentity -Call $call $eventId = Get-CallIdentity -Call $call
@ -869,40 +1017,43 @@ function Get-CallSnapshot {
} }
return [pscustomobject]@{ return [pscustomobject]@{
EventType = $EventType EventType = $EventType
EventId = $eventId EventId = $eventId
CallState = $callState CallState = $callState
Cause = $cause Cause = $cause
AddressName = $addressName AddressName = $addressName
AddressCategory = $addressCategory AddressCategory = $addressCategory
WatchedExtension = $watchedExtension WatchedExtension = $watchedExtension
CalledExtension = $calledExtension CalledExtension = $calledExtension
CallingExtension = $callingExtension CallingExtension = $callingExtension
Direction = $direction Direction = $direction
ExternalPhone = $externalPhone ExternalPhone = $externalPhone
RawCallerIdAddress = $rawCallerIdAddress RawCallerIdAddress = $rawCallerIdAddress
RawCalledIdAddress = $rawCalledIdAddress RawCalledIdAddress = $rawCalledIdAddress
RawConnectedIdAddress = $rawConnectedIdAddress RawConnectedIdAddress = $rawConnectedIdAddress
CallerName = $callerName CallerName = $callerName
CallObject = $call CallObject = $call
} }
} }
function Build-IncomingPayload { function Build-IncomingPayload {
param([Parameter(Mandatory = $true)] $CallRecord) param([Parameter(Mandatory = $true)] $CallRecord)
$isTest = $script:BridgeMode -ne 'live'
$bridgeNote = if ($isTest) { 'bridge dry-run da watcher TAPI .NET' } else { 'bridge live da watcher TAPI .NET' }
return [ordered]@{ return [ordered]@{
phone = $CallRecord.ExternalPhone phone = $CallRecord.ExternalPhone
name = $CallRecord.CallerName name = $CallRecord.CallerName
direction = $CallRecord.Direction direction = $CallRecord.Direction
outcome = $null outcome = $null
event_id = $CallRecord.EventId event_id = $CallRecord.EventId
called_extension = $CallRecord.CalledExtension called_extension = $CallRecord.CalledExtension
calling_extension = $CallRecord.CallingExtension calling_extension = $CallRecord.CallingExtension
event_type = $CallRecord.LastEventType event_type = $CallRecord.LastEventType
note = 'bridge dry-run da watcher TAPI .NET' note = $bridgeNote
called_at = $CallRecord.StartedAt.ToString('o') called_at = $CallRecord.StartedAt.ToString('o')
is_test = $true is_test = $isTest
} }
} }
@ -915,18 +1066,21 @@ function Build-CallEndedPayload {
$outcome = 'risposta' $outcome = 'risposta'
} }
$isTest = $script:BridgeMode -ne 'live'
$bridgeNote = if ($isTest) { 'bridge dry-run da watcher TAPI .NET' } else { 'bridge live da watcher TAPI .NET' }
return [ordered]@{ return [ordered]@{
phone = $CallRecord.ExternalPhone phone = $CallRecord.ExternalPhone
event_id = $CallRecord.EventId event_id = $CallRecord.EventId
outcome = $outcome outcome = $outcome
duration_seconds = $durationSeconds duration_seconds = $durationSeconds
ended_at = $CallRecord.EndedAt.ToString('o') ended_at = $CallRecord.EndedAt.ToString('o')
direction = $CallRecord.Direction direction = $CallRecord.Direction
called_extension = $CallRecord.CalledExtension called_extension = $CallRecord.CalledExtension
calling_extension = $CallRecord.CallingExtension calling_extension = $CallRecord.CallingExtension
event_type = $CallRecord.LastEventType event_type = $CallRecord.LastEventType
note = 'bridge dry-run da watcher TAPI .NET' note = $bridgeNote
is_test = $true is_test = $isTest
} }
} }
@ -999,17 +1153,17 @@ function Register-BridgeIncoming {
} }
$callRecord = [ordered]@{ $callRecord = [ordered]@{
EventId = $resolvedEventId EventId = $resolvedEventId
StartedAt = Get-Date StartedAt = Get-Date
EndedAt = $null EndedAt = $null
LastEventType = $Snapshot.EventType LastEventType = $Snapshot.EventType
CalledExtension = $Snapshot.CalledExtension CalledExtension = $Snapshot.CalledExtension
CallingExtension = $Snapshot.CallingExtension CallingExtension = $Snapshot.CallingExtension
Direction = $Snapshot.Direction Direction = $Snapshot.Direction
ExternalPhone = $Snapshot.ExternalPhone ExternalPhone = $Snapshot.ExternalPhone
CallerName = $Snapshot.CallerName CallerName = $Snapshot.CallerName
Answered = $false Answered = $false
IncomingSent = $false IncomingSent = $false
} }
if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) { if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) {
@ -1021,7 +1175,7 @@ function Register-BridgeIncoming {
if ($callRecord.ExternalPhone -eq '') { if ($callRecord.ExternalPhone -eq '') {
Write-LogLine ("BRIDGE SKIP endpoint=incoming reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension) Write-LogLine ("BRIDGE SKIP endpoint=incoming reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension)
Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress) Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress)
Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject)) Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject))
return return
} }
@ -1071,17 +1225,17 @@ function Register-BridgeStateTransition {
} }
else { else {
$callRecord = [ordered]@{ $callRecord = [ordered]@{
EventId = $resolvedEventId EventId = $resolvedEventId
StartedAt = Get-Date StartedAt = Get-Date
EndedAt = $null EndedAt = $null
LastEventType = $Snapshot.EventType LastEventType = $Snapshot.EventType
CalledExtension = $Snapshot.CalledExtension CalledExtension = $Snapshot.CalledExtension
CallingExtension = $Snapshot.CallingExtension CallingExtension = $Snapshot.CallingExtension
Direction = $Snapshot.Direction Direction = $Snapshot.Direction
ExternalPhone = $Snapshot.ExternalPhone ExternalPhone = $Snapshot.ExternalPhone
CallerName = $Snapshot.CallerName CallerName = $Snapshot.CallerName
Answered = $false Answered = $false
IncomingSent = $false IncomingSent = $false
} }
if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) { if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) {
@ -1131,7 +1285,7 @@ function Register-BridgeStateTransition {
if ($callRecord.ExternalPhone -eq '') { if ($callRecord.ExternalPhone -eq '') {
Write-LogLine ("BRIDGE SKIP endpoint=call-ended reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension) Write-LogLine ("BRIDGE SKIP endpoint=call-ended reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension)
Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress) Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress)
Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject)) Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject))
} }
else { else {
Invoke-BridgeRequest -Endpoint 'call-ended' -Payload (Build-CallEndedPayload -CallRecord $callRecord) Invoke-BridgeRequest -Endpoint 'call-ended' -Payload (Build-CallEndedPayload -CallRecord $callRecord)
@ -1222,20 +1376,20 @@ function Try-SubscribeDotNetEvent {
Write-LogLine ("DOTNET CHECK label={0} type={1} events={2}" -f $Label, $InputObject.GetType().FullName, ($events -join ',')) Write-LogLine ("DOTNET CHECK label={0} type={1} events={2}" -f $Label, $InputObject.GetType().FullName, ($events -join ','))
if (-not ($events -contains 'Event')) { if (-not ($events -contains 'Event')) {
return [pscustomobject]@{ return [pscustomobject]@{
Ok = $false Ok = $false
Error = 'evento .NET Event non esposto' Error = 'evento .NET Event non esposto'
} }
} }
Register-ObjectEvent -InputObject $InputObject -EventName Event -SourceIdentifier $SourceIdentifier | Out-Null Register-ObjectEvent -InputObject $InputObject -EventName Event -SourceIdentifier $SourceIdentifier | Out-Null
return [pscustomobject]@{ return [pscustomobject]@{
Ok = $true Ok = $true
Error = '' Error = ''
} }
} }
catch { catch {
return [pscustomobject]@{ return [pscustomobject]@{
Ok = $false Ok = $false
Error = $_.Exception.Message Error = $_.Exception.Message
} }
} }
@ -1307,10 +1461,10 @@ if ($script:IncludeDiagnosticEvents) {
} }
$tapi.EventFilter = $eventFilter $tapi.EventFilter = $eventFilter
$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses $allAddresses = @(Flatten-ObjectArray -Items @(Convert-ComCollectionToArray -Collection $tapi.Addresses))
$addresses = @($allAddresses | Where-Object { $addresses = @($allAddresses | Where-Object {
Should-WatchAddress -Address $_ -WatchList $watchList Should-WatchAddress -Address $_ -WatchList $watchList
}) })
Write-LogLine "=== AVVIO WATCH DOTNET TAPI ===" Write-LogLine "=== AVVIO WATCH DOTNET TAPI ==="
Write-LogLine ("Assembly interop: {0}" -f $assembly.FullName) Write-LogLine ("Assembly interop: {0}" -f $assembly.FullName)