diff --git a/CHANGELOG.md b/CHANGELOG.md index cb542d1..d9e1118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,16 @@ # Changelog All notable changes to this project will be documented in this file. ## [Unreleased] + - 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 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 + - Docker production compose and update workflow. - Filament documentation baseline. diff --git a/app/Console/Commands/FeCassettoImportSoggettoCommand.php b/app/Console/Commands/FeCassettoImportSoggettoCommand.php new file mode 100644 index 0000000..526d8eb --- /dev/null +++ b/app/Console/Commands/FeCassettoImportSoggettoCommand.php @@ -0,0 +1,208 @@ +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 + */ + 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) ?? '')); + } +} diff --git a/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php b/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php index 9b87781..d910161 100644 --- a/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php +++ b/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php @@ -1,5 +1,4 @@ option('apply'); $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() ->where('note', 'like', '%Dati anagrafici legacy:%') @@ -43,11 +42,11 @@ public function handle(): int } $processed = 0; - $updated = 0; + $updated = 0; foreach ($rows as $rubrica) { $processed++; - $legacy = $this->extractLegacyIdentityFromNote($rubrica->note); + $legacy = $this->extractLegacyIdentityFromNote($rubrica->note); $changes = []; if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) { @@ -94,20 +93,20 @@ private function extractLegacyIdentityFromNote(mixed $note): array $raw = trim((string) ($note ?? '')); if ($raw === '') { return [ - 'note_clean' => null, - 'titolo_id' => null, - 'sesso' => null, - 'data_nascita' => null, - 'luogo_nascita' => null, + 'note_clean' => null, + 'titolo_id' => null, + 'sesso' => null, + 'data_nascita' => null, + 'luogo_nascita' => null, 'provincia_nascita' => null, ]; } $parsed = [ - 'titolo_id' => null, - 'sesso' => null, - 'data_nascita' => null, - 'luogo_nascita' => null, + 'titolo_id' => null, + 'sesso' => null, + 'data_nascita' => null, + 'luogo_nascita' => null, 'provincia_nascita' => null, ]; @@ -122,11 +121,11 @@ private function extractLegacyIdentityFromNote(mixed $note): array } $payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:'))); - $bits = preg_split('/\s*\|\s*/', $payload) ?: []; + $bits = preg_split('/\s*\|\s*/', $payload) ?: []; foreach ($bits as $bit) { [$key, $value] = array_pad(explode(':', $bit, 2), 2, null); - $key = mb_strtolower(trim((string) $key)); - $value = trim((string) $value); + $key = mb_strtolower(trim((string) $key)); + $value = trim((string) $value); if ($value === '') { continue; } @@ -182,7 +181,7 @@ private function normalizeLegacyDateValue(mixed $value): ?string private function normalizeBirthDateYear(\DateTime $date): ?string { - $year = (int) $date->format('Y'); + $year = (int) $date->format('Y'); $currentYear = (int) now()->format('Y'); if ($year > $currentYear + 1) { diff --git a/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php b/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php index 1843fee..48828fc 100644 --- a/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php +++ b/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php @@ -1,5 +1,4 @@ 0, - 'skipped' => 0, + 'processed' => 0, + 'skipped' => 0, 'persona_created' => 0, 'persona_updated' => 0, - 'rel_created' => 0, - 'rel_updated' => 0, + 'rel_created' => 0, + 'rel_updated' => 0, ]; foreach ($rows as $row) { @@ -118,14 +117,14 @@ public function handle(): int } $relationPayload = [ - 'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null), - 'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01', - 'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null), - 'attivo' => $this->isRelationActive($row->data_fine ?? null), + 'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null), + 'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01', + 'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null), + 'attivo' => $this->isRelationActive($row->data_fine ?? null), 'riceve_comunicazioni' => true, - 'riceve_convocazioni' => $ruoloRate === 'C', - 'vota_assemblea' => $ruoloRate === 'C', - 'note_relazione' => $this->buildRelationNote($row, $tipoRelazione), + 'riceve_convocazioni' => $ruoloRate === 'C', + 'vota_assemblea' => $ruoloRate === 'C', + 'note_relazione' => $this->buildRelationNote($row, $tipoRelazione), ]; if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) { @@ -147,20 +146,20 @@ public function handle(): int $stats['rel_updated']++; } else { DB::table('persone_unita_relazioni')->insert([ - 'persona_id' => (int) $persona->id, - 'unita_id' => (int) $row->unita_immobiliare_id, - 'tipo_relazione' => $tipoRelazione, - 'quota_relazione' => $relationPayload['quota_relazione'], - 'data_inizio' => $relationPayload['data_inizio'], - 'data_fine' => $relationPayload['data_fine'], - 'attivo' => $relationPayload['attivo'], + 'persona_id' => (int) $persona->id, + 'unita_id' => (int) $row->unita_immobiliare_id, + 'tipo_relazione' => $tipoRelazione, + 'quota_relazione' => $relationPayload['quota_relazione'], + 'data_inizio' => $relationPayload['data_inizio'], + 'data_fine' => $relationPayload['data_fine'], + 'attivo' => $relationPayload['attivo'], 'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'], - 'riceve_convocazioni' => $relationPayload['riceve_convocazioni'], - 'vota_assemblea' => $relationPayload['vota_assemblea'], - 'note_relazione' => $relationPayload['note_relazione'], - 'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null, - 'created_at' => now(), - 'updated_at' => now(), + 'riceve_convocazioni' => $relationPayload['riceve_convocazioni'], + 'vota_assemblea' => $relationPayload['vota_assemblea'], + 'note_relazione' => $relationPayload['note_relazione'], + 'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null, + 'created_at' => now(), + 'updated_at' => now(), ]); $stats['rel_created']++; } @@ -185,10 +184,10 @@ private function findMatchingRubrica(object $row, string $tipoRelazione): ?Rubri return null; } - $role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino'; - $codCond = trim((string) ($row->legacy_cond_id ?? '')); + $role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino'; + $codCond = trim((string) ($row->legacy_cond_id ?? '')); $codStabile = trim((string) ($row->codice_stabile ?? '')); - $adminId = (int) ($row->amministratore_id ?? 0); + $adminId = (int) ($row->amministratore_id ?? 0); if ($codCond !== '' && $codStabile !== '') { $stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')]))); @@ -228,7 +227,7 @@ private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica): { if ($rubrica) { $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); } else { [$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; - $fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null); + $fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null); return [ - 'tipologia' => $ragione ? 'giuridica' : 'fisica', - 'nome' => $nome, - 'cognome' => $cognome, - 'ragione_sociale' => $ragione, - 'codice_fiscale' => $fiscal['cf'], - 'partita_iva' => $fiscal['piva'], - 'email_principale' => $this->sanitizeEmail($rubrica?->email), - 'email_pec' => $this->sanitizeEmail($rubrica?->pec), + 'tipologia' => $ragione ? 'giuridica' : 'fisica', + 'nome' => $nome, + 'cognome' => $cognome, + 'ragione_sociale' => $ragione, + 'codice_fiscale' => $fiscal['cf'], + 'partita_iva' => $fiscal['piva'], + 'email_principale' => $this->sanitizeEmail($rubrica?->email), + 'email_pec' => $this->sanitizeEmail($rubrica?->pec), 'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio), 'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio), - 'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255), - 'note' => $this->buildPersonaNote($row, $rubrica), - 'attivo' => true, - 'name_key' => $this->normalizeNameKey($nome, $cognome), + 'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255), + 'note' => $this->buildPersonaNote($row, $rubrica), + 'attivo' => true, + 'name_key' => $this->normalizeNameKey($nome, $cognome), ]; } @@ -319,19 +318,19 @@ private function resolvePersonaMatch(array $payload): ?object private function createPersonaFromPayload(array $payload): ?Persona { $data = [ - 'tipologia' => $payload['tipologia'] ?? 'fisica', - 'cognome' => $payload['cognome'], - 'nome' => $payload['nome'], - 'ragione_sociale' => $payload['ragione_sociale'], - 'codice_fiscale' => $payload['codice_fiscale'], - 'partita_iva' => $payload['partita_iva'], - 'residenza_via' => $payload['residenza_via'], + 'tipologia' => $payload['tipologia'] ?? 'fisica', + 'cognome' => $payload['cognome'], + 'nome' => $payload['nome'], + 'ragione_sociale' => $payload['ragione_sociale'], + 'codice_fiscale' => $payload['codice_fiscale'], + 'partita_iva' => $payload['partita_iva'], + 'residenza_via' => $payload['residenza_via'], 'telefono_principale' => $payload['telefono_principale'], 'telefono_secondario' => $payload['telefono_secondario'], - 'email_principale' => $payload['email_principale'], - 'email_pec' => $payload['email_pec'], - 'note' => $payload['note'], - 'attivo' => $payload['attivo'] ?? true, + 'email_principale' => $payload['email_principale'], + 'email_pec' => $payload['email_pec'], + 'note' => $payload['note'], + 'attivo' => $payload['attivo'] ?? true, ]; 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) ?: [])); if (count($parts) >= 2) { - $nome = array_pop($parts); + $nome = array_pop($parts); $cognome = implode(' ', $parts); 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, '_'); return match ($value) { - 'comproprietario', 'co_proprietario' => 'comproprietario', + 'comproprietario', 'co_proprietario' => 'comproprietario', 'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario', - 'usufruttuario', 'usufrutto' => 'usufruttuario', + 'usufruttuario', 'usufrutto' => 'usufruttuario', 'titolare' => 'titolare', - default => null, + default => null, }; } @@ -656,4 +655,4 @@ private function decodeLegacyPayload(mixed $payload): array return []; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/GesconSyncRubricaRuoli.php b/app/Console/Commands/GesconSyncRubricaRuoli.php index 38ba190..e18de6a 100644 --- a/app/Console/Commands/GesconSyncRubricaRuoli.php +++ b/app/Console/Commands/GesconSyncRubricaRuoli.php @@ -1,15 +1,13 @@ error('Tabelle rubrica_universale/rubrica_ruoli non disponibili.'); return 1; } - $apply = (bool)$this->option('apply'); + $apply = (bool) $this->option('apply'); $createMissing = (bool) $this->option('create-missing'); $stabileFilter = $this->option('stabile'); - $limit = (int)$this->option('limit'); - if ($limit < 100) $limit = 100; - if ($limit > 20000) $limit = 20000; + $limit = (int) $this->option('limit'); + if ($limit < 100) { + $limit = 100; + } + + if ($limit > 20000) { + $limit = 20000; + } $query = PersonaUnitaRelazione::with(['persona', 'unitaImmobiliare.stabile']) ->orderBy('id'); @@ -55,38 +58,38 @@ public function handle(): int foreach ($chunk as $relazione) { $processed++; $persona = $relazione->persona; - $unita = $relazione->unitaImmobiliare; + $unita = $relazione->unitaImmobiliare; $stabile = $unita?->stabile; - if (!$persona || !$unita || !$stabile) { + if (! $persona || ! $unita || ! $stabile) { $skipped++; continue; } $rubrica = $this->trovaRubricaPerPersona($persona, $stabile->amministratore_id ?? null, $apply && $createMissing); - if (!$rubrica) { + if (! $rubrica) { $skipped++; continue; } $ruoloStandard = $this->mapRuolo($relazione->tipo_relazione); - $match = [ - 'rubrica_id' => $rubrica->id, - 'stabile_id' => $stabile->id, + $match = [ + 'rubrica_id' => $rubrica->id, + 'stabile_id' => $stabile->id, 'unita_immobiliare_id' => $unita->id, - 'ruolo_standard' => $ruoloStandard, + 'ruolo_standard' => $ruoloStandard, ]; $payload = [ - 'is_attivo' => $relazione->isAttiva(), + 'is_attivo' => $relazione->isAttiva(), 'is_preferito' => $relazione->isAttiva(), - 'meta' => [ + 'meta' => [ 'percentuale_possesso' => $relazione->quota_relazione, - 'tipo_relazione_orig' => $relazione->tipo_relazione, - 'persona_id_orig' => $persona->id, + 'tipo_relazione_orig' => $relazione->tipo_relazione, + 'persona_id_orig' => $persona->id, ], ]; - if (!$apply) { + if (! $apply) { RubricaRuolo::where($match)->exists() ? $updated++ : $inserted++; continue; } @@ -102,7 +105,7 @@ public function handle(): int protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = null, bool $createIfMissing = false): ?RubricaUniversale { - if (!$this->personaHasRubricaIdentity($persona)) { + if (! $this->personaHasRubricaIdentity($persona)) { return null; } @@ -111,13 +114,19 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = $cf = trim((string) ($persona->codice_fiscale ?? '')); if ($cf) { $found = (clone $query)->where('codice_fiscale', $cf)->first(); - if ($found) return $found; + if ($found) { + return $found; + } + } $piva = trim((string) ($persona->partita_iva ?? '')); if ($piva) { $found = (clone $query)->where('partita_iva', $piva)->first(); - if ($found) return $found; + if ($found) { + return $found; + } + } 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 ?? '')); if ($nome !== '' && $cognome !== '') { $found = (clone $query) @@ -160,29 +169,29 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = } } - if (!$createIfMissing) { + if (! $createIfMissing) { return null; } $tipo = $persona->tipologia === 'giuridica' ? 'persona_giuridica' : 'persona_fisica'; - $now = now(); + $now = now(); return RubricaUniversale::query()->create([ - 'amministratore_id' => $amministratoreId ? (int) $amministratoreId : null, - 'nome' => $tipo === 'persona_fisica' ? ($persona->nome ?? null) : null, - 'cognome' => $tipo === 'persona_fisica' ? ($persona->cognome ?? null) : null, - 'ragione_sociale' => $tipo === 'persona_giuridica' ? ($persona->ragione_sociale ?? $persona->nome_completo) : null, - 'tipo_contatto' => $tipo, - 'categoria' => 'condomino', - 'stato' => 'attivo', - 'codice_fiscale' => $cf ?: null, - 'partita_iva' => $persona->partita_iva ?: null, - 'email' => $persona->email_principale ?? null, - 'pec' => $persona->email_pec ?? null, - 'telefono_ufficio' => $persona->telefono_principale ?? null, - 'telefono_cellulare' => $persona->telefono_secondario ?? null, - 'note' => $persona->note ?? null, - 'data_inserimento' => $now, + 'amministratore_id' => $amministratoreId ? (int) $amministratoreId : null, + 'nome' => $tipo === 'persona_fisica' ? ($persona->nome ?? null) : null, + 'cognome' => $tipo === 'persona_fisica' ? ($persona->cognome ?? null) : null, + 'ragione_sociale' => $tipo === 'persona_giuridica' ? ($persona->ragione_sociale ?? $persona->nome_completo) : null, + 'tipo_contatto' => $tipo, + 'categoria' => 'condomino', + 'stato' => 'attivo', + 'codice_fiscale' => $cf ?: null, + 'partita_iva' => $persona->partita_iva ?: null, + 'email' => $persona->email_principale ?? null, + 'pec' => $persona->email_pec ?? null, + 'telefono_ufficio' => $persona->telefono_principale ?? null, + 'telefono_cellulare' => $persona->telefono_secondario ?? null, + 'note' => $persona->note ?? null, + 'data_inserimento' => $now, 'data_ultima_modifica' => $now, ]); } @@ -265,11 +274,11 @@ private function personaHasRubricaIdentity(Persona $persona): bool protected function mapRuolo(?string $tipo): string { - $tipo = strtolower((string)$tipo); + $tipo = strtolower((string) $tipo); return match ($tipo) { 'inquilino' => 'inquilino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino', - default => 'condomino', + default => 'condomino', }; } } diff --git a/app/Console/Commands/ImportFornitoreWholesaleCsvCommand.php b/app/Console/Commands/ImportFornitoreWholesaleCsvCommand.php new file mode 100644 index 0000000..c69abfc --- /dev/null +++ b/app/Console/Commands/ImportFornitoreWholesaleCsvCommand.php @@ -0,0 +1,102 @@ +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(); + } +} diff --git a/app/Console/Commands/ImportGesconFullPipeline.php b/app/Console/Commands/ImportGesconFullPipeline.php index 18b45f4..61d6829 100644 --- a/app/Console/Commands/ImportGesconFullPipeline.php +++ b/app/Console/Commands/ImportGesconFullPipeline.php @@ -109,7 +109,7 @@ private function stepRelazioni(?int $limit = null): int { $params = [ '--stabile' => (string) $this->option('stabile'), - '--limit' => $limit ?? 10000, + '--limit' => $limit ?? 10000, ]; if (! $this->option('dry-run')) { @@ -5114,7 +5114,7 @@ private function buildComproprietariHistoryPayloads(array $historyRows, ?string $sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey; $sameRight = $snapshotRightKey === $currentRightKey; $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) { $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, 'I', $legacyCondId) ); - $payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId); + $payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId); DB::table('unita_immobiliare_nominativi') ->where('unita_immobiliare_id', $unitaId) diff --git a/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php b/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php index c1a33ae..695e1f1 100644 --- a/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php +++ b/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php @@ -23,9 +23,9 @@ public function handle(): int return self::FAILURE; } - $dryRun = (bool) $this->option('dry-run'); + $dryRun = (bool) $this->option('dry-run'); $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) ->filter(fn(int $v): bool => $v > 0) ->values() @@ -78,7 +78,7 @@ public function handle(): int } $legacyDescrizione = $this->buildLegacyDescription($legacy); - $incoming = $this->extractTags($legacyDescrizione); + $incoming = $this->extractTags($legacyDescrizione); if (count($incoming) === 0) { $skipped++; continue; @@ -88,8 +88,8 @@ public function handle(): int $merged = array_values(array_unique(array_merge($current, $incoming))); sort($merged, SORT_NATURAL | SORT_FLAG_CASE); - $newTagsValue = implode(', ', $merged); - $changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue + $newTagsValue = implode(', ', $merged); + $changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue || ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione; if (! $changed) { @@ -121,37 +121,37 @@ private function resolveLegacySource(): ?array { $sources = [ [ - 'table' => 'mdb_fornitori', - 'id_column' => 'id_fornitore', - 'code_column' => 'cod_forn', - 'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'], - 'piva_column' => 'p_iva', - 'cf_column' => 'cod_fisc', + 'table' => 'mdb_fornitori', + 'id_column' => 'id_fornitore', + 'code_column' => 'cod_forn', + 'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'], + 'piva_column' => 'p_iva', + 'cf_column' => 'cod_fisc', 'description_column' => 'descrizione', - 'notes_column' => 'note', - 'text_columns' => ['descrizione', 'note'], + 'notes_column' => 'note', + 'text_columns' => ['descrizione', 'note'], ], [ - 'table' => 'fornitori', - 'id_column' => 'id', - 'code_column' => 'cod_forn', - 'name_columns' => ['ragione_sociale', 'cognome', 'nome'], - 'piva_column' => 'partita_iva', - 'cf_column' => 'codice_fiscale', + 'table' => 'fornitori', + 'id_column' => 'id', + 'code_column' => 'cod_forn', + 'name_columns' => ['ragione_sociale', 'cognome', 'nome'], + 'piva_column' => 'partita_iva', + 'cf_column' => 'codice_fiscale', 'description_column' => 'descrizione', - 'notes_column' => 'note', - 'text_columns' => ['descrizione', 'note'], + 'notes_column' => 'note', + 'text_columns' => ['descrizione', 'note'], ], [ - 'table' => 'fornitori_gescon', - 'id_column' => 'id', - 'code_column' => 'cod_forn', - 'name_columns' => ['ragione_sociale'], - 'piva_column' => 'partita_iva', - 'cf_column' => 'codice_fiscale', + 'table' => 'fornitori_gescon', + 'id_column' => 'id', + 'code_column' => 'cod_forn', + 'name_columns' => ['ragione_sociale'], + 'piva_column' => 'partita_iva', + 'cf_column' => 'codice_fiscale', 'description_column' => 'descr', - 'notes_column' => 'note', - 'text_columns' => ['descr', 'note'], + 'notes_column' => 'note', + 'text_columns' => ['descr', 'note'], ], ]; @@ -190,7 +190,7 @@ private function resolveLegacySource(): ?array continue; } - $source['text_columns'] = $availableTextColumns; + $source['text_columns'] = $availableTextColumns; $source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true) ? $source['description_column'] : null; @@ -332,51 +332,51 @@ private function extractKeywordTags(string $input): array } $keywords = [ - 'idr' => 'idraulico', - 'termoidraul' => 'termoidraulico', - 'elettric' => 'elettricista', + 'idr' => 'idraulico', + 'termoidraul' => 'termoidraulico', + 'elettric' => 'elettricista', 'energia elet' => 'energia elettrica', - 'ascens' => 'ascensorista', - 'puliz' => 'pulizie', - 'giardin' => 'giardiniere', - 'spurgh' => 'spurgo', - 'edil' => 'edile', - 'murator' => 'edile', - 'fabbro' => 'fabbro', - 'ferrament' => 'ferramenta', - 'serrament' => 'serramenti', - 'portier' => 'portierato', - 'citofon' => 'citofonia', - 'videosorv' => 'videosorveglianza', - 'sicurezz' => 'sicurezza', - 'antincend' => 'antincendio', - 'estintor' => 'antincendio', - 'calda' => 'caldaia', - 'riscald' => 'riscaldamento', - 'condizion' => 'condizionamento', - 'gas' => 'gas', - 'acqua' => 'acqua', - 'fogna' => 'fognatura', - 'antenna' => 'antenna', - 'telecom' => 'telefonia', - 'pec' => 'pec', - 'aruba' => 'pec', - 'posta' => 'postali', - 'raccomand' => 'postali', - 'spediz' => 'spedizioni', - 'infest' => 'disinfestazione', - 'amminist' => 'amministrazione', - 'assicur' => 'assicurazione', - 'avvocat' => 'legale', - 'legale' => 'legale', - 'commercial' => 'commercialista', - 'geometr' => 'geometra', - 'privacy' => 'privacy', - 'timbri' => 'timbri', - 'targhe' => 'targhe', - 'paghe' => 'paghe contributi', - 'contribut' => 'paghe contributi', - 'utenz' => 'utenze', + 'ascens' => 'ascensorista', + 'puliz' => 'pulizie', + 'giardin' => 'giardiniere', + 'spurgh' => 'spurgo', + 'edil' => 'edile', + 'murator' => 'edile', + 'fabbro' => 'fabbro', + 'ferrament' => 'ferramenta', + 'serrament' => 'serramenti', + 'portier' => 'portierato', + 'citofon' => 'citofonia', + 'videosorv' => 'videosorveglianza', + 'sicurezz' => 'sicurezza', + 'antincend' => 'antincendio', + 'estintor' => 'antincendio', + 'calda' => 'caldaia', + 'riscald' => 'riscaldamento', + 'condizion' => 'condizionamento', + 'gas' => 'gas', + 'acqua' => 'acqua', + 'fogna' => 'fognatura', + 'antenna' => 'antenna', + 'telecom' => 'telefonia', + 'pec' => 'pec', + 'aruba' => 'pec', + 'posta' => 'postali', + 'raccomand' => 'postali', + 'spediz' => 'spedizioni', + 'infest' => 'disinfestazione', + 'amminist' => 'amministrazione', + 'assicur' => 'assicurazione', + 'avvocat' => 'legale', + 'legale' => 'legale', + 'commercial' => 'commercialista', + 'geometr' => 'geometra', + 'privacy' => 'privacy', + 'timbri' => 'timbri', + 'targhe' => 'targhe', + 'paghe' => 'paghe contributi', + 'contribut' => 'paghe contributi', + 'utenz' => 'utenze', ]; $tags = []; @@ -409,18 +409,18 @@ private function canonicalizeTag(string $raw): ?string } $map = [ - 'idr' => 'idraulico', - 'idraul' => 'idraulico', - 'elett' => 'elettricista', - 'elettric' => 'elettricista', - 'ascens' => 'ascensorista', - 'puliz' => 'pulizie', - 'giardin' => 'giardiniere', - 'assicur' => 'assicurazione', - 'manut' => 'manutenzione', - 'murator' => 'edile', - 'privacy' => 'privacy', - 'ferrament'=> 'ferramenta', + 'idr' => 'idraulico', + 'idraul' => 'idraulico', + 'elett' => 'elettricista', + 'elettric' => 'elettricista', + 'ascens' => 'ascensorista', + 'puliz' => 'pulizie', + 'giardin' => 'giardiniere', + 'assicur' => 'assicurazione', + 'manut' => 'manutenzione', + 'murator' => 'edile', + 'privacy' => 'privacy', + 'ferrament' => 'ferramenta', ]; foreach ($map as $needle => $canonical) { diff --git a/app/Console/Commands/NetgesconApplyPbxPresetCommand.php b/app/Console/Commands/NetgesconApplyPbxPresetCommand.php index 53727dc..3ac0eb1 100644 --- a/app/Console/Commands/NetgesconApplyPbxPresetCommand.php +++ b/app/Console/Commands/NetgesconApplyPbxPresetCommand.php @@ -1,5 +1,4 @@ digits((string) $this->option('night-group')); $dayFallback = $this->digits((string) $this->option('day-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')); + $adminNumber = $this->digits((string) $this->option('admin-number')); $adminTarget = $this->digits((string) $this->option('admin-target')); if ($adminTarget === '') { @@ -48,6 +53,13 @@ public function handle(): int $centralino['interno_amministratore'] = $adminTarget; } + if ($mainNumber !== '') { + $centralino['numero_principale'] = $mainNumber; + } + if ($adminNumber !== '') { + $centralino['numero_backup'] = $adminNumber; + } + $centralino['interno_gruppo_giorno'] = $dayGroup; $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 = array_values(array_unique(array_filter(array_map( - fn (string $value): string => $this->digits($value), - array_merge($watch, [$dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget]) + fn(string $value): string => $this->digits($value), + array_merge($watch, [$mainLine, $dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget]) )))); $pbx['watch_extensions'] = implode(',', $watch); @@ -75,12 +87,23 @@ public function handle(): int 'mode' => 'notte', 'target_extension' => $nightFallback, ], - ], fn (array $row): bool => ($row['extension'] ?? '') !== '')); + ], fn(array $row): bool => ($row['extension'] ?? '') !== '')); $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 !== '') { $incomingLines[] = [ 'number' => $adminLine, + 'external_number' => $adminNumber, 'label' => 'Linea amministratore', 'route_group' => '', 'target_extension' => $adminTarget, @@ -122,7 +145,7 @@ private function resolveAdmin(string $value): ?Amministratore } 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_univoco', $value) ->first(); @@ -132,4 +155,4 @@ private function digits(string $value): string { return preg_replace('/\D+/', '', $value) ?: ''; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php b/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php new file mode 100644 index 0000000..e643f88 --- /dev/null +++ b/app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php @@ -0,0 +1,332 @@ +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; + } + } +} diff --git a/app/Filament/Auth/Login.php b/app/Filament/Auth/Login.php index f63886c..6460d12 100644 --- a/app/Filament/Auth/Login.php +++ b/app/Filament/Auth/Login.php @@ -2,6 +2,10 @@ namespace App\Filament\Auth; 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\Pages\Login as BaseLogin; use Filament\Facades\Filament; @@ -18,13 +22,46 @@ protected function getRedirectUrl(): string { $user = Auth::user(); - if ($user instanceof \App\Models\User && $user->hasRole('fornitore')) { - return TicketOperativi::getUrl(panel: 'admin-filament'); + if ($user instanceof User && $user->hasRole('fornitore')) { + return $this->resolveSupplierRedirectUrl($user); } 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 { $response = parent::authenticate(); diff --git a/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php b/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php index 2416b7b..aaa71d0 100644 --- a/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php +++ b/app/Filament/Pages/Contabilita/FatturaElettronicaScheda.php @@ -1094,14 +1094,29 @@ private function needsCompleta(): bool private function isFatturaCompatibleWithCurrentStabileCf(): bool { - $stabileCf = $this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale); - $destCf = $this->normalizeMatchValue($this->fattura->destinatario_cf); + $candidateIds = array_filter([ + $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 $stabileCf === $destCf; + foreach ($destIds as $destId) { + if (in_array($destId, $candidateIds, true)) { + return true; + } + } + + return false; } private function normalizeMatchValue(?string $value): string diff --git a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php index 3589950..c675f38 100644 --- a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php +++ b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php @@ -16,6 +16,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -44,6 +45,27 @@ class FattureElettronicheP7mRicevute extends Page implements HasTable 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 { $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']); }) ->form([ + Placeholder::make('stabile_attivo_info') + ->label('Stabile di destinazione') + ->content(fn(): string => $this->getActiveStabileLabelProperty()), + Select::make('anno') ->label('Anno') ->options(function (): array { @@ -1368,8 +1394,9 @@ private function inboxBaseForStabile(int $stabileId, string $ym): string private function resolveStabileIdFromParsed(array $parsed): ?int { - $cf = $parsed['destinatario_cf'] ?? null; - $codice = $parsed['codice_destinatario'] ?? null; + $cf = isset($parsed['destinatario_cf']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_cf']))) : 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; 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; } @@ -1455,6 +1494,35 @@ public function table(Table $table): Table ->defaultPaginationPageOption(50) ->defaultSort('data_fattura', 'desc') ->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') ->label('Ordine') ->options([ @@ -1514,7 +1582,8 @@ public function table(Table $table): Table ->wrap(), 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) ->wrap(), ]) diff --git a/app/Filament/Pages/Gescon/FornitoreScheda.php b/app/Filament/Pages/Gescon/FornitoreScheda.php index 76570ed..9984f20 100644 --- a/app/Filament/Pages/Gescon/FornitoreScheda.php +++ b/app/Filament/Pages/Gescon/FornitoreScheda.php @@ -1,11 +1,13 @@ > */ public array $raVersateRows = []; + /** @var array> */ + public array $catalogoProdottiRows = []; + + /** @var array> */ + public array $tecnorepairRows = []; + public string $tagsInput = ''; /** @var array */ @@ -105,59 +118,64 @@ public function mount(int | string $record): void abort(403); } - if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) { + if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore'])) { 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. - if (! $user->hasAnyRole(['super-admin', 'admin'])) { - $allowedAdminIds = []; + // Tenant guard: impedisce accesso cross-amministratore via URL. + if (! $user->hasAnyRole(['super-admin', 'admin'])) { + $allowedAdminIds = []; - $activeStabile = StabileContext::getActiveStabile($user); - $activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0); - if ($activeAdminId > 0) { - $allowedAdminIds[] = $activeAdminId; - } + $activeStabile = StabileContext::getActiveStabile($user); + $activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0); + if ($activeAdminId > 0) { + $allowedAdminIds[] = $activeAdminId; + } - $userAdminId = (int) ($user->amministratore?->id ?: 0); - if ($userAdminId > 0) { - $allowedAdminIds[] = $userAdminId; - } + $userAdminId = (int) ($user->amministratore?->id ?: 0); + if ($userAdminId > 0) { + $allowedAdminIds[] = $userAdminId; + } - $ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0); - if ($ownerAdminId > 0) { - $allowedAdminIds[] = $ownerAdminId; - } + $ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0); + if ($ownerAdminId > 0) { + $allowedAdminIds[] = $ownerAdminId; + } - // Collaboratori: includi anche gli amministratori degli stabili assegnati. - try { - $assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all(); - foreach ($assigned as $aid) { - $aid = (int) $aid; - if ($aid > 0) { - $allowedAdminIds[] = $aid; + // Collaboratori: includi anche gli amministratori degli stabili assegnati. + try { + $assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all(); + foreach ($assigned as $aid) { + $aid = (int) $aid; + if ($aid > 0) { + $allowedAdminIds[] = $aid; + } } + } catch (\Throwable) { + // ignore } - } catch (\Throwable) { - // ignore - } - $allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0))); - $fornitoreAdminIds = array_values(array_unique(array_filter([ - (int) ($this->fornitore->amministratore_id ?? 0), - (int) ($this->fornitore->rubrica?->amministratore_id ?? 0), - ], 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([ + (int) ($this->fornitore->amministratore_id ?? 0), + (int) ($this->fornitore->rubrica?->amministratore_id ?? 0), + ], fn($v) => (int) $v > 0))); - if ($fornitoreAdminIds === []) { - if (! $user->hasAnyRole(['admin', 'amministratore'])) { - abort(404); - } - } else { - $hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0; - if (! $hasIntersection) { - abort(404); + if ($fornitoreAdminIds === []) { + if (! $user->hasAnyRole(['admin', 'amministratore'])) { + abort(404); + } + } else { + $hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0; + if (! $hasIntersection) { + abort(404); + } } } } @@ -166,6 +184,7 @@ public function mount(int | string $record): void $this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->loadTagSuggestions(); $this->refreshDipendentiRows(); + $this->refreshCatalogRows(); } public function creaDipendenteFornitore(): void @@ -448,6 +467,138 @@ private function refreshDipendentiRows(): void ->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 */ @@ -521,6 +672,28 @@ private function hydrateBoxData(User $user): void 'voce_spesa_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], 'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0], 'contabilita' => [ @@ -550,6 +723,54 @@ private function hydrateBoxData(User $user): void $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) $settings = null; if (Schema::hasTable('fornitore_stabile_impostazioni')) { @@ -770,6 +991,266 @@ protected function getHeaderActions(): array ->icon('heroicon-o-tag') ->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') ->label('Impostazioni FE') ->icon('heroicon-o-adjustments-horizontal') diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index 2653be6..c0e02d8 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -1395,8 +1395,8 @@ public function canRequestClickToCall(): bool $user = Auth::user(); return $user instanceof User - && (bool) ($user->pbx_click_to_call_enabled ?? false) - && trim((string) ($user->pbx_extension ?? '')) !== ''; + && (bool) ($user->pbx_click_to_call_enabled ?? false) + && trim((string) ($user->pbx_extension ?? '')) !== ''; } public function richiediClickToCallRubrica(string $field): void diff --git a/app/Filament/Pages/Strumenti/PostItGestione.php b/app/Filament/Pages/Strumenti/PostItGestione.php index c7a8539..1e58549 100644 --- a/app/Filament/Pages/Strumenti/PostItGestione.php +++ b/app/Filament/Pages/Strumenti/PostItGestione.php @@ -44,11 +44,16 @@ class PostItGestione extends Page public string $tecnicoSearch = ''; + public string $tecnicoLineFilter = ''; + public string $tecnicoScopeFilter = 'tutti'; /** @var array */ public array $riaperturaNote = []; + /** @var array */ + public array $conversioneNote = []; + /** @var array */ private array $rubricaPhoneCache = []; @@ -57,7 +62,7 @@ class PostItGestione extends Page 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; if (request()->query('tab') === 'storico') { @@ -104,7 +109,7 @@ public function convertiInTicket(int $postItId): void 'stabile_id' => $postIt->stabile_id, 'aperto_da_user_id' => Auth::id(), '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(), 'stato' => 'Aperto', 'priorita' => $postIt->priorita, @@ -119,6 +124,8 @@ public function convertiInTicket(int $postItId): void ->body("Ticket #{$ticket->id} creato dal Post-it #{$postIt->id}") ->success() ->send(); + + unset($this->conversioneNote[$postItId]); } 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(); } + $lineFilter = trim($this->tecnicoLineFilter); + if ($lineFilter !== '') { + $items = $items->filter(fn(CommunicationMessage $message): bool => $this->matchesTecnicoLineFilter($message, $lineFilter))->values(); + } + return $items; } catch (QueryException) { return collect(); @@ -323,18 +335,45 @@ public function creaPostItDaMessaggio(int $messageId): void $sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR'; $smdr = (array) data_get($message->metadata, 'smdr', []); $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([ 'stabile_id' => $message->stabile_id, 'creato_da_user_id' => Auth::id(), '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), 'origine' => $sourceKey . '_table', 'origine_id' => (string) $message->id, 'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null, - 'oggetto' => 'Chiamata da pannello tecnico ' . $sourceLabel, - 'nota' => $line !== '' ? $line : ('Evento ' . $sourceLabel . ' senza testo riga'), + 'oggetto' => $oggetto, + 'nota' => $notaParts !== [] ? implode("\n", $notaParts) : ('Evento ' . $sourceLabel . ' senza dettagli operativi'), 'priorita' => 'Media', 'stato' => 'post_it', 'chiamata_il' => $message->received_at ?? now(), @@ -436,6 +475,49 @@ public function getTecnicoLineaLabel(CommunicationMessage $message): string 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 { $user = Auth::user(); diff --git a/app/Filament/Pages/Supporto/AssistenzaLegacy.php b/app/Filament/Pages/Supporto/AssistenzaLegacy.php new file mode 100644 index 0000000..6e6f4e9 --- /dev/null +++ b/app/Filament/Pages/Supporto/AssistenzaLegacy.php @@ -0,0 +1,164 @@ + */ + 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; + } +} diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index e15f9c1..1fb0781 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -55,6 +55,8 @@ class TicketMobile extends Page public ?string $newTicketDescrizione = null; + public ?string $newTicketCallNote = null; + public ?string $newTicketLuogo = null; public string $newTicketPriorita = 'Media'; @@ -207,8 +209,8 @@ public function creaPostItDaChiamataInIngresso(): void 'direzione' => 'in_arrivo', 'origine' => 'smdr_live_banner', 'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''), - 'oggetto' => 'Richiesta telefonica da valutare', - 'nota' => $line !== '' ? $line : ('Chiamata in ingresso da ' . $phone), + 'oggetto' => 'Chiamata ricevuta da gestire', + 'nota' => $this->buildLiveCallPostItNote($phone, $line), 'priorita' => 'Media', 'stato' => 'post_it', 'chiamata_il' => now(), @@ -574,18 +576,9 @@ public function creaTicketRapido(): void $descrizione = trim((string) $this->newTicketDescrizione); if ($caller) { $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) { - $descrizione .= "\nTelefono: {$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}"; + $descrizione .= "\nTelefono richiamabile: {$telefono}"; } } @@ -624,6 +617,7 @@ public function creaTicketRapido(): void $this->newTicketTitolo = null; $this->newTicketDescrizione = null; + $this->newTicketCallNote = null; $this->newTicketLuogo = null; $this->newTicketPriorita = 'Media'; $this->newTicketCategoriaId = null; @@ -928,6 +922,7 @@ private function prefillTicketFromLiveCall(string $phone, string $line): void { $this->callerSearch = $phone; $this->searchCaller(); + $this->newTicketCallNote = trim($line) !== '' ? trim($line) : null; $rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0); if ($rubricaId > 0) { @@ -940,14 +935,68 @@ private function prefillTicketFromLiveCall(string $phone, string $line): void } if (blank($this->newTicketTitolo)) { - $this->newTicketTitolo = 'Chiamata in ingresso ' . $phone; + $this->newTicketTitolo = 'Segnalazione telefonica in arrivo'; } 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 { return preg_replace('/\D+/', '', $value) ?: ''; @@ -993,14 +1042,14 @@ private function selectCallerRepresentative(Collection $group, int $selectedCall $representative = $selected instanceof RubricaUniversale ? $selected : $group - ->sortByDesc(fn(RubricaUniversale $match): array => [ - (int) (! empty($match->amministratore_id)), - (int) (! empty($match->riferimento_stabile)), - (int) (! empty($match->riferimento_unita)), - (int) (($match->categoria ?? '') === 'condomino'), - -1 * (int) $match->id, - ]) - ->first(); + ->sortByDesc(fn(RubricaUniversale $match): array=> [ + (int) (! empty($match->amministratore_id)), + (int) (! empty($match->riferimento_stabile)), + (int) (! empty($match->riferimento_unita)), + (int) (($match->categoria ?? '') === 'condomino'), + -1 * (int) $match->id, + ]) + ->first(); $duplicateCategories = $group ->pluck('categoria') diff --git a/app/Filament/Pages/UnitaImmobiliarePage.php b/app/Filament/Pages/UnitaImmobiliarePage.php index fa7aa56..08277ee 100644 --- a/app/Filament/Pages/UnitaImmobiliarePage.php +++ b/app/Filament/Pages/UnitaImmobiliarePage.php @@ -322,7 +322,7 @@ protected function getHeaderActions(): array ->columnSpan(3), Select::make('persona_id') ->label('Persona collegata') - ->options(fn(): array => $this->getPersonaRecapitoOptions()) + ->options(fn(): array=> $this->getPersonaRecapitoOptions()) ->searchable() ->preload() ->native(false) @@ -353,7 +353,7 @@ protected function getHeaderActions(): array ]) ->columns(12), ]) - ->fillForm(fn(): array => ['recapiti' => $this->getRecapitiServizioFormRows()]) + ->fillForm(fn(): array=> ['recapiti' => $this->getRecapitiServizioFormRows()]) ->action(function (array $data): void { $this->saveRecapitiServizioOverrides($data['recapiti'] ?? []); $this->loadUnita(); @@ -806,7 +806,7 @@ public function hydrateNominativiStorici(): void $percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%'; } - $details = []; + $details = []; $dirittoLabel = trim((string) ($payload['diritto_label'] ?? '')); if ($dirittoLabel !== '') { $details[] = 'Diritto: ' . $dirittoLabel; @@ -871,8 +871,8 @@ private function formatNominativoStoricoRuolo(string $ruolo, string $fonte, arra } return match (strtoupper($ruolo)) { - 'I' => 'Inquilino', - 'C' => 'Condomino', + 'I' => 'Inquilino', + 'C' => 'Condomino', default => $ruolo !== '' ? $ruolo : '—', }; } diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index 5670806..18f6e89 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -8,6 +8,7 @@ use App\Models\RubricaUniversale; use App\Services\Cti\PbxRoutingService; use App\Support\PhoneNumber; +use Carbon\CarbonInterface; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -52,7 +53,8 @@ public function incoming(Request $request): JsonResponse $direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); $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')) { $postIt = ChiamataPostIt::query()->create([ 'stabile_id' => $routing['stabile_id'], @@ -69,7 +71,7 @@ public function incoming(Request $request): JsonResponse 'nota' => $this->buildNote($payload), 'priorita' => 'Media', '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, ]); - $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); - $direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); - $postIt = $this->findOpenPostIt( - (string) ($payload['event_id'] ?? ''), - $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') - ); + try { + $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); + $direction = $this->normalizeDirection((string) ($payload['direction'] ?? '')); + $endedAt = $this->normalizePayloadDateTime($payload['ended_at'] ?? null); + $postIt = $this->findOpenPostIt( + (string) ($payload['event_id'] ?? ''), + $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') + ); - $created = false; - if (! $postIt && Schema::hasTable('chiamate_post_it')) { - $created = true; - $rubrica = $this->findRubricaByPhone($normalized); + $created = false; + if (! $postIt && Schema::hasTable('chiamate_post_it')) { + $created = true; + $rubrica = $this->findRubricaByPhone($normalized); - $postIt = ChiamataPostIt::query()->create([ - 'stabile_id' => null, - 'rubrica_id' => $rubrica?->id, - 'ticket_id' => null, - 'creato_da_user_id' => null, - 'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''), - 'nome_chiamante' => $rubrica?->nome_completo, - 'direzione' => $direction['post_it'], - 'esito' => $payload['outcome'] ?? null, - 'origine' => 'panasonic_ns1000', - 'origine_id' => $payload['event_id'] ?? null, - 'oggetto' => 'Chiamata terminata (evento senza incoming)', - 'nota' => $this->buildNote($payload), - 'priorita' => 'Media', - 'stato' => 'post_it', - 'chiamata_il' => $payload['ended_at'] ?? now(), - ]); - } + $postIt = ChiamataPostIt::query()->create([ + 'stabile_id' => null, + 'rubrica_id' => $rubrica?->id, + 'ticket_id' => null, + 'creato_da_user_id' => null, + 'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''), + 'nome_chiamante' => $rubrica?->nome_completo, + 'direzione' => $direction['post_it'], + 'esito' => $payload['outcome'] ?? null, + 'origine' => 'panasonic_ns1000', + 'origine_id' => $payload['event_id'] ?? null, + 'oggetto' => 'Chiamata terminata (evento senza incoming)', + 'nota' => $this->buildNote($payload), + 'priorita' => 'Media', + 'stato' => 'post_it', + 'chiamata_il' => $endedAt ?? now(), + ]); + } - if (! $postIt) { - $this->logTrace('call-ended.not-found', $request, $payload, [ + if (! $postIt) { + $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, + '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, + ]); + } 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([ 'ok' => false, - 'message' => 'No matching Post-it and table unavailable', + 'message' => 'Call ended processing failed', '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 @@ -640,7 +663,7 @@ private function storeIncomingCommunicationMessage( 'message_text' => $this->buildNote($payload), 'attachments' => null, 'status' => 'received', - 'received_at' => $payload['called_at'] ?? now(), + 'received_at' => $this->normalizePayloadDateTime($payload['called_at'] ?? null) ?? now(), 'metadata' => [ 'provider' => 'panasonic_ns1000', 'amministratore_id' => $routing['amministratore_id'], @@ -701,7 +724,7 @@ private function closeCommunicationMessage(array $payload, string $normalizedPho if ($message) { $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['outcome'] = $payload['outcome'] ?? null; $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['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['outcome'] = $payload['outcome'] ?? null; $metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared'); @@ -799,6 +822,28 @@ private function mergeMetadata(?array $existing, array $newData): array 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 */ diff --git a/app/Http/Middleware/ForceFilamentUiMiddleware.php b/app/Http/Middleware/ForceFilamentUiMiddleware.php index c2031c2..42d94b1 100644 --- a/app/Http/Middleware/ForceFilamentUiMiddleware.php +++ b/app/Http/Middleware/ForceFilamentUiMiddleware.php @@ -1,5 +1,4 @@ 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) { return $next($request); } + if ($this->shouldBypassRedirect($request)) { + return $next($request); + } + // Redirect only navigational requests. Non-GET calls are left as-is. if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) { return $next($request); @@ -29,4 +32,25 @@ public function handle(Request $request, Closure $next): Response 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; + } } diff --git a/app/Livewire/Filament/TopbarLiveCall.php b/app/Livewire/Filament/TopbarLiveCall.php index 060ac60..8f27162 100644 --- a/app/Livewire/Filament/TopbarLiveCall.php +++ b/app/Livewire/Filament/TopbarLiveCall.php @@ -64,8 +64,8 @@ public function createPostIt(): void $this->redirect( PostItGestione::getUrl([ - 'tab' => 'storico', - 'focus_post_it' => (int) $postIt->id, + 'tab' => 'storico', + 'focus_post_it' => (int) $postIt->id, ], panel: 'admin-filament'), navigate: true, ); diff --git a/app/Models/Amministratore.php b/app/Models/Amministratore.php index cf6c2dc..3f29a28 100755 --- a/app/Models/Amministratore.php +++ b/app/Models/Amministratore.php @@ -55,12 +55,25 @@ class Amministratore extends Model 'cartella_dati', 'database_attivo', '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 = [ - 'abilita_import_stabili' => 'boolean', - 'impostazioni' => 'array', - 'provisioned_at' => 'datetime', + 'abilita_import_stabili' => 'boolean', + 'fe_cassetto_enabled' => 'boolean', + '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', ]; /** diff --git a/app/Models/AssistenzaTecnorepairAllegato.php b/app/Models/AssistenzaTecnorepairAllegato.php new file mode 100644 index 0000000..3ca3665 --- /dev/null +++ b/app/Models/AssistenzaTecnorepairAllegato.php @@ -0,0 +1,35 @@ + 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function scheda(): BelongsTo + { + return $this->belongsTo(AssistenzaTecnorepairScheda::class, 'scheda_id'); + } +} diff --git a/app/Models/AssistenzaTecnorepairScheda.php b/app/Models/AssistenzaTecnorepairScheda.php new file mode 100644 index 0000000..522b0c2 --- /dev/null +++ b/app/Models/AssistenzaTecnorepairScheda.php @@ -0,0 +1,188 @@ + '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'; + } +} diff --git a/app/Models/FatturaElettronica.php b/app/Models/FatturaElettronica.php index 4653010..ec6615d 100644 --- a/app/Models/FatturaElettronica.php +++ b/app/Models/FatturaElettronica.php @@ -1,5 +1,4 @@ 'date', - 'data_scadenza' => 'date', - 'imponibile' => 'decimal:2', - 'iva' => 'decimal:2', - 'totale' => 'decimal:2', + 'data_fattura' => 'date', + 'data_scadenza' => 'date', + 'imponibile' => 'decimal:2', + 'iva' => 'decimal:2', + 'totale' => 'decimal:2', 'consumo_valore' => 'decimal:3', ]; diff --git a/app/Models/Fornitore.php b/app/Models/Fornitore.php index 0b73364..f99756e 100755 --- a/app/Models/Fornitore.php +++ b/app/Models/Fornitore.php @@ -4,6 +4,7 @@ use App\Support\ArchivioPaths; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -48,14 +49,16 @@ class Fornitore extends Model 'regime_fiscale_id', 'tipologia_cassa_id', 'aliquota_iva_id', + 'is_tecnorepair_primary', 'old_id', ]; protected $casts = [ - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'escludi_righe_fe' => 'boolean', - 'fe_features' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'escludi_righe_fe' => 'boolean', + 'fe_features' => 'array', + 'is_tecnorepair_primary' => 'boolean', ]; /** @@ -168,6 +171,52 @@ public function dipendenti() 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 + */ + 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 */ diff --git a/app/Models/PersonaUnitaRelazione.php b/app/Models/PersonaUnitaRelazione.php index c0c9bb8..f63bc44 100755 --- a/app/Models/PersonaUnitaRelazione.php +++ b/app/Models/PersonaUnitaRelazione.php @@ -1,5 +1,4 @@ 'date', - 'data_fine' => 'date', - 'quota_relazione' => 'decimal:2', - 'ruolo_rate' => 'string', - 'attivo' => 'boolean', + 'data_inizio' => 'date', + 'data_fine' => 'date', + 'quota_relazione' => 'decimal:2', + 'ruolo_rate' => 'string', + 'attivo' => 'boolean', 'riceve_comunicazioni' => 'boolean', - 'riceve_convocazioni' => 'boolean', - 'vota_assemblea' => 'boolean' + 'riceve_convocazioni' => 'boolean', + 'vota_assemblea' => 'boolean', ]; /** @@ -109,7 +108,7 @@ public function getDescrizioneRelazioneAttribute() */ public function getStatoRelazioneAttribute() { - if (!$this->attivo) { + if (! $this->attivo) { return 'Non Attiva'; } diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..8189047 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,76 @@ + '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'); + } +} diff --git a/app/Models/ProductIdentifier.php b/app/Models/ProductIdentifier.php new file mode 100644 index 0000000..9ed1f56 --- /dev/null +++ b/app/Models/ProductIdentifier.php @@ -0,0 +1,43 @@ + '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'); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 0000000..dedf9d6 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,38 @@ + 'integer', + 'sort_order' => 'integer', + 'meta' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } +} diff --git a/app/Models/ProductOffer.php b/app/Models/ProductOffer.php new file mode 100644 index 0000000..41d9ca8 --- /dev/null +++ b/app/Models/ProductOffer.php @@ -0,0 +1,61 @@ + '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'); + } +} diff --git a/app/Models/ProductSerial.php b/app/Models/ProductSerial.php new file mode 100644 index 0000000..9997162 --- /dev/null +++ b/app/Models/ProductSerial.php @@ -0,0 +1,51 @@ + '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'); + } +} diff --git a/app/Modules/Contabilita/Models/FatturaFornitoreRiga.php b/app/Modules/Contabilita/Models/FatturaFornitoreRiga.php index 129ccf0..043aca4 100644 --- a/app/Modules/Contabilita/Models/FatturaFornitoreRiga.php +++ b/app/Modules/Contabilita/Models/FatturaFornitoreRiga.php @@ -10,6 +10,7 @@ class FatturaFornitoreRiga extends Model protected $fillable = [ 'fattura_id', + 'product_id', 'riga', 'descrizione', 'imponibile_euro', @@ -39,6 +40,11 @@ public function fattura(): BelongsTo return $this->belongsTo(FatturaFornitore::class, 'fattura_id'); } + public function product(): BelongsTo + { + return $this->belongsTo(\App\Models\Product::class, 'product_id'); + } + public function contoCosto(): BelongsTo { return $this->belongsTo(PianoConti::class, 'conto_costo_id'); diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index 3704084..64749ce 100644 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -5,10 +5,15 @@ use App\Filament\Pages\Contabilita\ContoMastrino; use App\Filament\Pages\Contabilita\PrimaNotaDettaglio; 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\Supporto\SupportoDashboard; use App\Filament\Pages\Supporto\TicketMobile; 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\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -88,6 +93,15 @@ public function panel(Panel $panel): Panel return $user instanceof \App\Models\User && $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') ->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages') @@ -117,6 +131,46 @@ public function panel(Panel $panel): Panel ->authMiddleware([ 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'); + } } diff --git a/app/Services/Catalog/FornitoreProductCatalogService.php b/app/Services/Catalog/FornitoreProductCatalogService.php new file mode 100644 index 0000000..2a34bda --- /dev/null +++ b/app/Services/Catalog/FornitoreProductCatalogService.php @@ -0,0 +1,790 @@ + + */ + 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 $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 $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 + */ + 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 $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> + */ + 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|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 $mapped + * @return array + */ + 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 $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 $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; + } +} diff --git a/app/Services/Catalog/ProductAssetIngestionService.php b/app/Services/Catalog/ProductAssetIngestionService.php new file mode 100644 index 0000000..da0adab --- /dev/null +++ b/app/Services/Catalog/ProductAssetIngestionService.php @@ -0,0 +1,150 @@ + + */ + 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', + }; + } +} diff --git a/app/Services/Catalog/ProductOfferService.php b/app/Services/Catalog/ProductOfferService.php new file mode 100644 index 0000000..6f8fe0a --- /dev/null +++ b/app/Services/Catalog/ProductOfferService.php @@ -0,0 +1,246 @@ + $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 $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 $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; + } +} diff --git a/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php b/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php index ee78f91..2f157ec 100644 --- a/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php +++ b/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php @@ -1,17 +1,16 @@ id; } return [ - 'status' => 'error', + 'status' => 'error', '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; if ($expires instanceof \DateTimeInterface) { $today = new \DateTimeImmutable('today'); - $exp = new \DateTimeImmutable($expires->format('Y-m-d')); + $exp = new \DateTimeImmutable($expires->format('Y-m-d')); if ($exp < $today) { return [ - 'status' => 'error', + 'status' => 'error', '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(); - if (! $cfg) { + if (! $cfg && ! $amministratore->fe_cassetto_base_url && ! $amministratore->fe_cassetto_username) { 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 === '') { $baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php'; } - $passwordScript = (string) ($cfg->password_script ?? ''); - $username = (string) ($cfg->username ?? ''); - $password = (string) ($cfg->password ?? ''); - $pin = (string) ($cfg->pin ?? ''); + $passwordScript = trim((string) ($amministratore->fe_cassetto_password_script ?: ($cfg?->password_script ?? ''))); + $username = trim((string) ($amministratore->fe_cassetto_username ?: ($cfg?->username ?? ''))); + $password = trim((string) ($amministratore->fe_cassetto_password ?: ($cfg?->password ?? ''))); + $pin = trim((string) ($amministratore->fe_cassetto_pin ?: ($cfg?->pin ?? ''))); // passwordScript: nel servizio esterno viene usato come "token"/licenza. if ($passwordScript === '') { @@ -86,7 +85,7 @@ public function downloadAndImport( } $dalStr = $dal->format('d-m-Y'); - $alStr = $al->format('d-m-Y'); + $alStr = $al->format('d-m-Y'); $requestHash = hash('sha256', implode('|', [ 'cf=' . $cf, @@ -110,34 +109,34 @@ public function downloadAndImport( if ($existingOk && $allowSkip) { return [ - 'status' => 'skipped', + 'status' => 'skipped', '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([ - 'amministratore_id' => (int) $amministratore->id, - 'stabile_id' => (int) $stabile->id, + 'amministratore_id' => (int) $amministratore->id, + 'stabile_id' => (int) $stabile->id, 'codice_fiscale_soggetto' => $cf, - 'dal' => $dal->format('Y-m-d'), - 'al' => $al->format('Y-m-d'), - 'metadati' => $metadati ? 1 : 0, - 'request_hash' => $requestHash, - 'status' => 'started', - 'created_at' => now(), - 'updated_at' => now(), + 'dal' => $dal->format('Y-m-d'), + 'al' => $al->format('Y-m-d'), + 'metadati' => $metadati ? 1 : 0, + 'request_hash' => $requestHash, + 'status' => 'started', + 'created_at' => now(), + 'updated_at' => now(), ]); try { $query = [ - 'passwordScript' => $passwordScript, + 'passwordScript' => $passwordScript, 'usernameEntratel' => $username, 'passwordEntratel' => $password, - 'pinEntratel' => $pin, - 'cf' => $cf, - 'dal' => $dalStr, - 'al' => $alStr, + 'pinEntratel' => $pin, + 'cf' => $cf, + 'dal' => $dalStr, + 'al' => $alStr, ]; if ($metadati) { @@ -168,8 +167,8 @@ public function downloadAndImport( $msg .= ' | preview: ' . $preview; } DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ - 'status' => 'error', - 'message' => $msg, + 'status' => 'error', + 'message' => $msg, 'updated_at' => now(), ]); return ['status' => 'error', 'message' => $msg, 'log_id' => $logId]; @@ -186,22 +185,22 @@ public function downloadAndImport( throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview); } - $zipSha = hash('sha256', $bytes); - $ym = now()->format('Y-m'); + $zipSha = hash('sha256', $bytes); + $ym = now()->format('Y-m'); - $adminBase = ArchivioPaths::adminBase($amministratore); + $adminBase = ArchivioPaths::adminBase($amministratore); $stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id); - $stabileBase = $adminBase . '/stabili/' . $stabileFolder; - $basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym; + $stabileBase = $adminBase . '/stabili/' . $stabileFolder; + $basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym; - $uuid = (string) Str::uuid(); - $zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip'; + $uuid = (string) Str::uuid(); + $zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip'; Storage::disk('local')->put($zipPath, $bytes); DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'zip_sha256' => $zipSha, - 'zip_bytes' => strlen($bytes), - 'status' => 'downloaded', + 'zip_bytes' => strlen($bytes), + 'status' => 'downloaded', 'updated_at' => now(), ]); @@ -223,12 +222,12 @@ public function downloadAndImport( $zip->extractTo($tmpFsPath); $zip->close(); - $riepilogo = $this->findRiepilogoJson($tmpFsPath); + $riepilogo = $this->findRiepilogoJson($tmpFsPath); $riepilogoStoredPath = null; - $riepilogoWarnings = []; + $riepilogoWarnings = []; if ($riepilogo !== null) { - $riepilogoFilename = $riepilogo['filename']; + $riepilogoFilename = $riepilogo['filename']; $riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename; 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); - $importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser()); - $extractor = app(P7mExtractor::class); - $imported = 0; + $importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser()); + $extractor = app(P7mExtractor::class); + $imported = 0; $duplicates = 0; - $errors = 0; + $errors = 0; $errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m'); Storage::disk('local')->makeDirectory($errorsDir); @@ -263,7 +262,7 @@ public function downloadAndImport( foreach ($files as $filePath) { $filename = basename($filePath); - $lower = strtolower($filename); + $lower = strtolower($filename); $bytes = @file_get_contents($filePath); if (! is_string($bytes) || trim($bytes) === '') { @@ -271,12 +270,12 @@ public function downloadAndImport( continue; } - $unique = (string) Str::uuid(); + $unique = (string) Str::uuid(); $storedP7mPath = null; $storedXmlPath = null; $isP7m = str_ends_with($lower, '.p7m'); - $xml = $isP7m + $xml = $isP7m ? null : $bytes; @@ -296,8 +295,8 @@ public function downloadAndImport( $errors++; try { Storage::disk('local')->append($errorsLogPath, json_encode([ - 'type' => 'empty_xml', - 'filename' => $filename, + 'type' => 'empty_xml', + 'filename' => $filename, 'stored_p7m_path' => $storedP7mPath, 'stored_xml_path' => $storedXmlPath, ], JSON_UNESCAPED_SLASHES)); @@ -315,16 +314,16 @@ public function downloadAndImport( try { $result = $importer->importXml($xml, 0, $xmlFilename, [ - 'xml_path' => $storedXmlPath, - 'p7m_path' => $storedP7mPath, - 'nome_file_p7m' => $isP7m ? $filename : null, - 'force_stabile_id' => (int) $stabile->id, - 'allow_fallback_stabile' => false, - 'force_update' => (bool) ($opts['force_update'] ?? false), - 'auto_repair_duplicate' => true, - 'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'), + 'xml_path' => $storedXmlPath, + 'p7m_path' => $storedP7mPath, + 'nome_file_p7m' => $isP7m ? $filename : null, + 'force_stabile_id' => (int) $stabile->id, + 'allow_fallback_stabile' => false, + 'force_update' => (bool) ($opts['force_update'] ?? false), + 'auto_repair_duplicate' => true, + 'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'), 'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true), - 'user_id' => $userId, + 'user_id' => $userId, ]); $status = (string) ($result['status'] ?? 'error'); @@ -336,12 +335,12 @@ public function downloadAndImport( $errors++; try { Storage::disk('local')->append($errorsLogPath, json_encode([ - 'type' => 'import_error', - 'filename' => $filename, - 'xml_filename' => $xmlFilename, + 'type' => 'import_error', + 'filename' => $filename, + 'xml_filename' => $xmlFilename, 'stored_p7m_path' => $storedP7mPath, 'stored_xml_path' => $storedXmlPath, - 'result' => $result, + 'result' => $result, ], JSON_UNESCAPED_SLASHES)); } catch (\Throwable) { } @@ -350,19 +349,19 @@ public function downloadAndImport( $errors++; try { Storage::disk('local')->append($errorsLogPath, json_encode([ - 'type' => 'exception', - 'filename' => $filename, - 'xml_filename' => $xmlFilename, + 'type' => 'exception', + 'filename' => $filename, + 'xml_filename' => $xmlFilename, 'stored_p7m_path' => $storedP7mPath, 'stored_xml_path' => $storedXmlPath, - 'message' => $e->getMessage(), + 'message' => $e->getMessage(), ], JSON_UNESCAPED_SLASHES)); } catch (\Throwable) { } } } - $messageParts = []; + $messageParts = []; $messageParts[] = 'zip=' . $zipPath; if ($riepilogo !== null) { $tot = $riepilogo['data']['totaleFatture'] ?? null; @@ -379,26 +378,26 @@ public function downloadAndImport( DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ 'files_total' => $filesTotal, - 'imported' => $imported, - 'duplicates' => $duplicates, - 'errors' => $errors, - 'status' => 'ok', - 'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null, - 'updated_at' => now(), + 'imported' => $imported, + 'duplicates' => $duplicates, + 'errors' => $errors, + 'status' => 'ok', + 'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null, + 'updated_at' => now(), ]); return [ - 'status' => 'ok', - 'log_id' => $logId, + 'status' => 'ok', + 'log_id' => $logId, 'files_total' => $filesTotal, - 'imported' => $imported, - 'duplicates' => $duplicates, - 'errors' => $errors, + 'imported' => $imported, + 'duplicates' => $duplicates, + 'errors' => $errors, ]; } catch (\Throwable $e) { DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([ - 'status' => 'error', - 'message' => $e->getMessage(), + 'status' => 'error', + 'message' => $e->getMessage(), 'updated_at' => now(), ]); @@ -423,7 +422,7 @@ private function collectFeFiles(string $rootDir): array continue; } - $name = $file->getFilename(); + $name = $file->getFilename(); $lower = strtolower($name); // File di supporto del portale (non sono fatture elettroniche). @@ -475,7 +474,7 @@ private function findRiepilogoJson(string $rootDir): ?array continue; } - $name = $file->getFilename(); + $name = $file->getFilename(); $lower = strtolower($name); if (! str_ends_with($lower, '.json')) { continue; @@ -498,7 +497,7 @@ private function findRiepilogoJson(string $rootDir): ?array return [ 'filename' => $name, 'contents' => $contents, - 'data' => $decoded, + 'data' => $decoded, ]; } @@ -523,32 +522,32 @@ private function upsertAdeFromRiepilogo(Amministratore $amministratore, Stabile } $date = null; - $d = trim((string) ($row['dataFattura'] ?? '')); + $d = trim((string) ($row['dataFattura'] ?? '')); if ($d !== '') { $date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null; } $imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0')); - $imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0')); + $imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0')); StgFatturaAde::query()->updateOrCreate( [ 'amministratore_id' => (int) $amministratore->id, - 'stabile_id' => (int) $stabile->id, - 'sdi_file' => $sdi, + 'stabile_id' => (int) $stabile->id, + 'sdi_file' => $sdi, ], [ - 'cartella_legacy' => null, - 'tipo_documento' => (string) ($row['tipoDocumento'] ?? null), - 'numero_documento' => (string) ($row['numeroFattura'] ?? null), - 'data_emissione' => $date ? $date->format('Y-m-d') : null, + 'cartella_legacy' => null, + 'tipo_documento' => (string) ($row['tipoDocumento'] ?? null), + 'numero_documento' => (string) ($row['numeroFattura'] ?? null), + 'data_emissione' => $date ? $date->format('Y-m-d') : null, 'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))), - 'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null), - 'imponibile' => $imponibile, - 'imposta' => $imposta, - 'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null), - 'source_file' => $sourceFile, - 'raw' => $row, + 'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null), + 'imponibile' => $imponibile, + 'imposta' => $imposta, + 'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null), + 'source_file' => $sourceFile, + 'raw' => $row, ] ); } diff --git a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php index c270596..cc38c8a 100644 --- a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php +++ b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php @@ -10,6 +10,7 @@ use App\Models\Stabile; use App\Modules\Contabilita\Models\FatturaFornitore; use App\Modules\Contabilita\Models\RegolaPrimaNota; +use App\Services\Catalog\FornitoreProductCatalogService; use App\Support\ArchivioPaths; use DOMDocument; use DOMXPath; @@ -113,10 +114,13 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original } } - $forcedStabileId = (int) ($extra['force_stabile_id'] ?? 0); - $stabileId = $forcedStabileId > 0 - ? $forcedStabileId - : $this->resolveStabileIdFromXml($data, $fallbackStabileId, ! empty($extra['allow_fallback_stabile'])); + $preferredStabileId = (int) ($extra['force_stabile_id'] ?? 0); + $stabileRouting = $this->resolveStabileRoutingFromXml( + $data, + $preferredStabileId > 0 ? $preferredStabileId : $fallbackStabileId, + ! empty($extra['allow_fallback_stabile']) + ); + $stabileId = (int) $stabileRouting['stabile_id']; // 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. @@ -230,6 +234,7 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original 'codice_destinatario' => $data['codice_destinatario'] ?? null, 'pec_destinatario' => $data['pec_destinatario'] ?? null, 'destinatario_cf' => $data['destinatario_cf'] ?? null, + 'destinatario_piva' => $data['destinatario_piva'] ?? null, 'destinatario_denominazione' => $data['destinatario_denominazione'] ?? null, 'stato' => 'ricevuta', 'xml_content' => $xml, @@ -351,6 +356,17 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original $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]; } @@ -1230,51 +1246,163 @@ private function xpathFirstString(DOMXPath $xpath, string $expr, \DOMNode $conte private function resolveStabileIdFromXml(array $parsed, int $fallbackStabileId, bool $allowFallback): int { - $cf = $parsed['destinatario_cf'] ?? null; - $codice = $parsed['codice_destinatario'] ?? null; - $pec = $parsed['pec_destinatario'] ?? null; + return (int) $this->resolveStabileRoutingFromXml($parsed, $fallbackStabileId, $allowFallback)['stabile_id']; + } - 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() ->whereNotNull('codice_fiscale') - ->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf]) + ->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$destCf]) ->where('attivo', true) ->get(); 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() ->whereNotNull('codice_destinatario_sdi') ->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice]) ->where('attivo', true) ->get(); if ($match->count() === 1) { - return (int) $match->first()->id; + return [ + 'stabile_id' => (int) $match->first()->id, + 'source' => 'codice_destinatario', + ]; } } - if ($pec) { - $pec = trim(strtolower((string) $pec)); + if ($pec !== '') { $match = Stabile::query() ->where(function ($q) use ($pec) { - $q->whereRaw('LOWER(pec_condominio) = ?', [$pec]) - ->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]); + $q->whereRaw('LOWER(pec_condominio) = ?', [$pec]); + + if (Schema::hasColumn('stabili', 'pec_amministratore')) { + $q->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]); + } }) ->where('attivo', true) ->get(); if ($match->count() === 1) { - return (int) $match->first()->id; + return [ + 'stabile_id' => (int) $match->first()->id, + 'source' => 'pec_destinatario', + ]; } } - if ($allowFallback && $fallbackStabileId > 0) { - return $fallbackStabileId; + if ($destPiva !== '') { + $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 @@ -1348,6 +1476,7 @@ private function updateExisting(FatturaElettronica $existing, string $xml, ?stri 'codice_destinatario' => $data['codice_destinatario'] ?? $existing->codice_destinatario, 'pec_destinatario' => $data['pec_destinatario'] ?? $existing->pec_destinatario, 'destinatario_cf' => $data['destinatario_cf'] ?? $existing->destinatario_cf, + 'destinatario_piva' => $data['destinatario_piva'] ?? $existing->destinatario_piva, 'destinatario_denominazione' => $data['destinatario_denominazione'] ?? $existing->destinatario_denominazione, 'xml_content' => $xml, 'nome_file_xml' => $originalFilename ?: $existing->nome_file_xml, diff --git a/app/Services/FattureElettroniche/FatturaElettronicaXmlParser.php b/app/Services/FattureElettroniche/FatturaElettronicaXmlParser.php index 069c1fc..8cd2a49 100644 --- a/app/Services/FattureElettroniche/FatturaElettronicaXmlParser.php +++ b/app/Services/FattureElettroniche/FatturaElettronicaXmlParser.php @@ -35,6 +35,7 @@ class FatturaElettronicaXmlParser * codice_destinatario: string|null, * pec_destinatario: string|null, * destinatario_cf: string|null, + * destinatario_piva: string|null, * destinatario_denominazione: string|null, * pagamento_modalita: 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']"); $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']"); - $destinatarioId = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']"); - if (! $destinatarioCf) { - $destinatarioCf = $destinatarioId; - } + $destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']"); + $destinatarioPiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']"); $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']"); @@ -187,6 +185,7 @@ public function parse(string $xml): array 'codice_destinatario' => $codiceDest ?: null, 'pec_destinatario' => $pecDest ?: null, 'destinatario_cf' => $this->normalizeId($destinatarioCf), + 'destinatario_piva' => $this->normalizeId($destinatarioPiva), 'destinatario_denominazione' => $destDenominazione ?: null, 'pagamento_modalita' => $modalitaPagamento ?: null, diff --git a/app/Support/TecnoRepairMdbReader.php b/app/Support/TecnoRepairMdbReader.php new file mode 100644 index 0000000..5dcbb6e --- /dev/null +++ b/app/Support/TecnoRepairMdbReader.php @@ -0,0 +1,104 @@ + + */ + 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> + */ + 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> + */ + 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; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index bf774e8..4b776ef 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Console\Commands\CtiSyncPostItFromStagingCommand; use App\Console\Commands\DedupeRateEmesseCommand; use App\Console\Commands\FeCassettoImportLocal; +use App\Console\Commands\FeCassettoImportSoggettoCommand; use App\Console\Commands\GesconCollegaFornitoreStabili; use App\Console\Commands\GesconImportAlignCommand; use App\Console\Commands\GesconImportBanche; @@ -17,21 +18,23 @@ use App\Console\Commands\GoogleSyncFornitoriRubricaCommand; use App\Console\Commands\GoogleSyncRubricaContactsCommand; use App\Console\Commands\GoogleSyncTicketCalendarCommand; +use App\Console\Commands\ImportFornitoreWholesaleCsvCommand; use App\Console\Commands\ImportGesconAcquaLegacyCommand; use App\Console\Commands\ImportGesconF24LegacyCommand; use App\Console\Commands\IstatSetIndiceCommand; use App\Console\Commands\IstatSyncIndiciCommand; +use App\Console\Commands\NetgesconApplyPbxPresetCommand; use App\Console\Commands\NetgesconArchiveRegistrySyncCommand; use App\Console\Commands\NetgesconDevelopmentSnapshotCommand; use App\Console\Commands\NetgesconDistributionPullCommand; use App\Console\Commands\NetgesconGitSyncCommand; -use App\Console\Commands\NetgesconApplyPbxPresetCommand; use App\Console\Commands\NetgesconPreupdateBackupCommand; use App\Console\Commands\NetgesconQaUnitaNominativiCommand; use App\Console\Commands\NetgesconRestoreRecordCommand; use App\Console\Commands\PanasonicCstaBridgeCommand; use App\Console\Commands\StabiliTransferCommand; use App\Console\Commands\SyncSoggettiToPersone; +use App\Console\Commands\TecnoRepairImportLegacyArchiveCommand; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -56,9 +59,11 @@ GesconQaContatori::class, GesconQaEmissione::class, FeCassettoImportLocal::class, + FeCassettoImportSoggettoCommand::class, IstatSyncIndiciCommand::class, IstatSetIndiceCommand::class, ImportGesconAcquaLegacyCommand::class, + ImportFornitoreWholesaleCsvCommand::class, GesconImportAlignCommand::class, ImportGesconF24LegacyCommand::class, NetgesconQaUnitaNominativiCommand::class, @@ -69,6 +74,7 @@ NetgesconGitSyncCommand::class, NetgesconPreupdateBackupCommand::class, NetgesconRestoreRecordCommand::class, + TecnoRepairImportLegacyArchiveCommand::class, PanasonicCstaBridgeCommand::class, GoogleSyncRubricaContactsCommand::class, GooglePushRubricaContactsCommand::class, diff --git a/config/catalog.php b/config/catalog.php new file mode 100644 index 0000000..37cc0fb --- /dev/null +++ b/config/catalog.php @@ -0,0 +1,8 @@ + [ + 'associate_tag' => env('CATALOG_AMAZON_ASSOCIATE_TAG', 'netgescon-21'), + 'default_locale' => env('CATALOG_AMAZON_LOCALE', 'it'), + ], +]; diff --git a/database/migrations/2026_02_01_090000_create_assistenza_tecnorepair_legacy_tables.php b/database/migrations/2026_02_01_090000_create_assistenza_tecnorepair_legacy_tables.php new file mode 100644 index 0000000..81265e4 --- /dev/null +++ b/database/migrations/2026_02_01_090000_create_assistenza_tecnorepair_legacy_tables.php @@ -0,0 +1,120 @@ +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'); + } + }); + } +}; diff --git a/database/migrations/2026_04_02_120000_add_destinatario_piva_to_fatture_elettroniche.php b/database/migrations/2026_04_02_120000_add_destinatario_piva_to_fatture_elettroniche.php new file mode 100644 index 0000000..ec251fa --- /dev/null +++ b/database/migrations/2026_04_02_120000_add_destinatario_piva_to_fatture_elettroniche.php @@ -0,0 +1,28 @@ +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'); + } + }); + } +}; diff --git a/database/migrations/2026_04_02_140000_create_supplier_product_catalog_tables.php b/database/migrations/2026_04_02_140000_create_supplier_product_catalog_tables.php new file mode 100644 index 0000000..ecd44d1 --- /dev/null +++ b/database/migrations/2026_04_02_140000_create_supplier_product_catalog_tables.php @@ -0,0 +1,113 @@ +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'); + } + } +}; diff --git a/database/migrations/2026_04_03_120000_create_product_offers_table.php b/database/migrations/2026_04_03_120000_create_product_offers_table.php new file mode 100644 index 0000000..8c67bc4 --- /dev/null +++ b/database/migrations/2026_04_03_120000_create_product_offers_table.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md b/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md index da1d750..a019978 100644 --- a/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md +++ b/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md @@ -7,6 +7,7 @@ ## Obiettivo ## Sorgenti da considerare ### 1. Metadati stabili + - `dbc/Stabili.mdb` - campi chiave attesi: - `cod_stabile` @@ -14,6 +15,7 @@ ### 1. Metadati stabili - campo CF stabile/condominio valorizzato ### 2. Dati annuali per stabile + - `//singolo_anno.mdb` - tabelle minime da validare: - `condomin` @@ -25,6 +27,7 @@ ### 2. Dati annuali per stabile - `straordinarie` ### 3. Dati trasversali stabile + - `/generale_stabile.mdb` - 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. ### Import base + - anno corrente legacy piu recente disponibile - anno precedente ### Import esteso solo per straordinarie + - includere fino a 5 anni indietro - fermarsi prima se non esistono MDB annuali o non esistono righe utili nelle tabelle straordinarie ### Storico oltre la finestra base + - da importare solo su richiesta o quando serve per quadrature, estratti conto storici o audit mirati ## 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. ### A. Controllo metadati + - `cod_stabile` valorizzato - `nome_directory` valorizzato - CF stabile valorizzato - cartella legacy esistente ### B. Controllo annualita + - individuato ultimo anno disponibile - individuato anno precedente disponibile - individuate eventuali annualita straordinarie fino a -5 anni ### C. Controllo file + - esiste `singolo_anno.mdb` per ogni anno scelto - se presente, esiste `generale_stabile.mdb` - `Stabili.mdb` leggibile senza errori ### D. Controllo tabelle minime + - `condomin` leggibile - `operazioni` leggibile - `rate` leggibile @@ -108,6 +118,7 @@ ### D. Controllo tabelle minime - `straordinarie` leggibile o assente in modo coerente ### E. QA dominio post-import + - stabile creato/aggiornato correttamente - unita importate - rubrica/nominativi deduplicati @@ -128,4 +139,4 @@ ## Raccomandazione di rollout ## Nota importante -La tabella staging `gescon_import.stabili` non va usata come unica base per questa preselezione se i CF risultano non materializzati. In quel caso la fonte corretta per lo smoke test resta il `Stabili.mdb` reale. \ No newline at end of file +La tabella staging `gescon_import.stabili` non va usata come unica base per questa preselezione se i CF risultano non materializzati. In quel caso la fonte corretta per lo smoke test resta il `Stabili.mdb` reale. diff --git a/docs/CTI-WINDOWS-TAPI-PROBE.md b/docs/CTI-WINDOWS-TAPI-PROBE.md index 2da47c8..ee77128 100644 --- a/docs/CTI-WINDOWS-TAPI-PROBE.md +++ b/docs/CTI-WINDOWS-TAPI-PROBE.md @@ -114,6 +114,79 @@ ### Caso B: trovi TAPI ma zero indirizzi 3. IP PBX e porta `33333` 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` 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 ``` +## 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 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" ``` +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 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" ``` -### 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 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: 1. `Strumenti -> Post-it Gestione` - - legge i record `chiamate_post_it` - - e il punto principale per vedere le chiamate create dal bridge -2. flussi tecnici basati su `communication_messages` - - canale `panasonic_csta` - - utili per verifica tecnica e correlazione con routing/intercettazione operatore + +- legge i record `chiamate_post_it` +- e il punto principale per vedere le chiamate create dal bridge + +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. @@ -469,9 +695,9 @@ ### 11.5 Procedura rapida di ripartenza se staging smette di ricevere chiamate Sul PC Windows: ```powershell -cd S:\ +cd C:\ProgramData\NetGescon\panasonic-live\current 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" Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait ``` diff --git a/docs/support/AGGIORNAMENTI-STAGING-GIT.md b/docs/support/AGGIORNAMENTI-STAGING-GIT.md index 2198c53..94e114e 100644 --- a/docs/support/AGGIORNAMENTI-STAGING-GIT.md +++ b/docs/support/AGGIORNAMENTI-STAGING-GIT.md @@ -102,4 +102,4 @@ ## Regola finale - sorgente canonica: repository Gitea - punto di esecuzione assistita: UI `Supporto > Modifiche` - comando tecnico sottostante: `netgescon:git-sync` -- documentazione di cio che si sta distribuendo: file Markdown versionati nel repository \ No newline at end of file +- documentazione di cio che si sta distribuendo: file Markdown versionati nel repository diff --git a/docs/support/ASSISTENZA-TECNOREPAIR-MDB.md b/docs/support/ASSISTENZA-TECNOREPAIR-MDB.md index f53d81d..8ab799e 100644 --- a/docs/support/ASSISTENZA-TECNOREPAIR-MDB.md +++ b/docs/support/ASSISTENZA-TECNOREPAIR-MDB.md @@ -72,6 +72,28 @@ ## Prossimo step pragmatico 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 +## 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 - Si: NetGescon puo leggere l'MDB subito. diff --git a/resources/views/filament/modals/pdf-viewer.blade.php b/resources/views/filament/modals/pdf-viewer.blade.php index e18b1cb..b2688a4 100644 --- a/resources/views/filament/modals/pdf-viewer.blade.php +++ b/resources/views/filament/modals/pdf-viewer.blade.php @@ -1,7 +1,26 @@ -
- +
+ + +
+ + + +
diff --git a/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php b/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php index f5492b9..e8d7f70 100644 --- a/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php +++ b/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php @@ -35,6 +35,12 @@
+
+
Stabile attivo per archivio FE
+
{{ $this->activeStabileLabel }}
+
L'elenco mostra solo le fatture collegate allo stabile attivo selezionato nella topbar.
+
+ {{ $this->table }}
@@ -51,6 +57,12 @@ Scarichi da Cassetto Fiscale +
+
Destinazione scarico corrente
+
{{ $this->activeStabileLabel }}
+
I trimestri scaricati in questa tab vengono associati allo stabile attivo visibile in topbar al momento del click.
+
+
Verifica trimestri già scaricati (se completo, evita ulteriori scarichi). diff --git a/resources/views/filament/pages/gescon/fornitore-scheda.blade.php b/resources/views/filament/pages/gescon/fornitore-scheda.blade.php index 49408f8..b160cad 100644 --- a/resources/views/filament/pages/gescon/fornitore-scheda.blade.php +++ b/resources/views/filament/pages/gescon/fornitore-scheda.blade.php @@ -115,6 +115,190 @@
+
+
+
+
Modulo fornitore separato
+
Catalogo isolato per questo fornitore tramite codice univoco, con riferimenti pubblici interni e sorgenti esterne archiviate solo nel gestionale.
+
+
+
Scope
+
{{ $box['catalogo_modulo']['scope_code'] ?? ($fornitore->codice_univoco ?? ('FORN-' . $fornitore->id)) }}
+
+
+ +
+
+
Da FE
+
{{ (int) ($box['catalogo_modulo']['fe_products'] ?? 0) }}
+
+
+
Da CSV
+
{{ (int) ($box['catalogo_modulo']['csv_products'] ?? 0) }}
+
+
+
Manuali
+
{{ (int) ($box['catalogo_modulo']['manual_products'] ?? 0) }}
+
+
+
Solo interni
+
{{ (int) ($box['catalogo_modulo']['internal_only'] ?? 0) }}
+
+
+
Link sorgente
+
{{ (int) ($box['catalogo_modulo']['private_links'] ?? 0) }}
+
+
+
Offerte
+
{{ (int) ($box['catalogo_modulo']['offers'] ?? 0) }}
+
+
+
Amazon link
+
{{ (int) ($box['catalogo_modulo']['amazon_links'] ?? 0) }}
+
+
+ +
+ Nuovo prodotto + Estrai da FE + Importa listino CSV + Internalizza asset + Prepara link Amazon +
+
+ +
+
+
+
Catalogo operativo interno
+
Anagrafica prodotti/servizi del fornitore. I riferimenti esterni importati restano interni al gestionale e la pubblicazione usa il codice interno del catalogo.
+
+
+ Nuovo prodotto + Estrai da FE + Importa CSV + Asset interni + Amazon +
+
+ +
+
+
Prodotti
+
{{ (int) ($box['prodotti']['count'] ?? 0) }}
+
+
+
Serializzati
+
{{ (int) ($box['prodotti']['serializzati'] ?? 0) }}
+
+
+
Codici agganciati
+
{{ (int) ($box['prodotti']['identifiers'] ?? 0) }}
+
+
+
Media
+
{{ (int) ($box['prodotti']['with_media'] ?? 0) }}
+
+
+ +
+ + + + + + + + + + + + + + + + @forelse($catalogoProdottiRows as $row) + + + + + + + + + + + + @empty + + + + @endforelse + +
CodiceProdottoMarca / modelloOrigineRif. internoCodiciSerialiOfferteCatalogo
{{ $row['internal_code'] !== '' ? $row['internal_code'] : '-' }} +
{{ $row['name'] !== '' ? $row['name'] : '-' }}
+ @if($row['color_label'] !== '') +
Colore: {{ $row['color_label'] }}
+ @endif +
{{ trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')) ?: '-' }} + @if(($row['source'] ?? '') === 'fattura_elettronica') + FE + @elseif(($row['source'] ?? '') === 'ncom_wholesale_csv') + CSV + @else + manuale + @endif + +
{{ $row['public_reference'] !== '' ? $row['public_reference'] : '-' }}
+ @if(($row['publication_mode'] ?? '') === 'internal_only') +
solo interno
+ @endif +
{{ (int) ($row['identifiers_count'] ?? 0) }} + {{ (int) ($row['serials_count'] ?? 0) }} + @if($row['track_serials']) + track + @endif + +
{{ (int) ($row['offers_count'] ?? 0) }} totali
+
+ @if(($row['own_offer_price'] ?? null) !== null) + tuo € {{ number_format((float) $row['own_offer_price'], 2, ',', '.') }} + @else + tuo n/d + @endif +
+
+ @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 +
+ @if(($row['amazon_referral_url'] ?? '') !== '') +
Amazon referral pronto
+ @endif +
+
Link sorgente: {{ (int) ($row['private_links'] ?? 0) }}
+
+ @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 +
+ @if(($row['categories'] ?? '') !== '') +
{{ $row['categories'] }}
+ @endif +
Nessun prodotto presente per questo fornitore.
+
+
+
Pagamenti
@@ -178,6 +362,59 @@ class="w-full rounded-lg border-gray-300 text-sm" $acquaScan = (bool) ($acqua['scan_enabled'] ?? false); @endphp +
+
+
+
TecnoRepair / seriali
+
Import MDB in anagrafica fornitore e aggancio automatico di seriali e prodotti assistenza.
+
+ + Importa TecnoRepair + +
+ +
+
+ Schede + {{ (int) ($box['tecnorepair']['schede'] ?? 0) }} +
+
+ Aperte / attesa + {{ (int) ($box['tecnorepair']['aperte'] ?? 0) }} +
+
+ Chiuse + {{ (int) ($box['tecnorepair']['chiuse'] ?? 0) }} +
+
+ Seriali + {{ (int) ($box['tecnorepair']['seriali'] ?? 0) }} +
+
+ +
+ @forelse($tecnorepairRows as $row) +
+
+
+
{{ $row['legacy_numero'] !== '' ? $row['legacy_numero'] : ('Scheda #' . $row['id']) }}
+
{{ $row['product_model'] !== '' ? $row['product_model'] : '-' }}
+
Codice: {{ $row['product_code'] !== '' ? $row['product_code'] : '-' }} | Seriale: {{ $row['serial_number'] !== '' ? $row['serial_number'] : '-' }}
+
+
+
+ {{ $row['status_label'] !== '' ? $row['status_label'] : ($row['status_bucket'] !== '' ? $row['status_bucket'] : 'stato') }} +
+
Allegati: {{ (int) ($row['allegati_count'] ?? 0) }}
+
+
+
+ @empty +
Nessuna scheda TecnoRepair collegata a questo fornitore.
+ @endforelse +
+
+
Automazioni / Consumi
Configura acquisizione dati (PDF) e scansione massiva sulle FE già importate.
diff --git a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php index f18e188..7b8c34c 100644 --- a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php +++ b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php @@ -77,7 +77,10 @@ @endif @if(!$riga->ticket_id && $riga->stato !== 'chiusa') - Converti in ticket +
+ + Converti in ticket +
@endif @if($riga->stato !== 'chiusa') @@ -174,6 +177,10 @@
+
+ + +
-
+
diff --git a/resources/views/filament/pages/supporto/assistenza-legacy.blade.php b/resources/views/filament/pages/supporto/assistenza-legacy.blade.php new file mode 100644 index 0000000..c5b4948 --- /dev/null +++ b/resources/views/filament/pages/supporto/assistenza-legacy.blade.php @@ -0,0 +1,164 @@ + +
+
+
+
+
Archivio assistenza TecnoRepair
+
Elenco filtrabile delle schede legacy importate da MDB e collegate al fornitore operativo, tipicamente NETHOME.
+
+
+ Import comando: {{ $this->importCommandHint }} +
+
+ +
+
Totali: {{ $counters['all'] ?? 0 }}
+
Aperti: {{ $counters['open'] ?? 0 }}
+
In attesa: {{ $counters['waiting'] ?? 0 }}
+
Chiusi: {{ $counters['closed'] ?? 0 }}
+
+
+ +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + @forelse($this->rows as $scheda) + + + + + + + + + + @empty + + + + @endforelse + +
SchedaClienteProdottoSerialiStatoFornitoreAzioni
+
{{ $scheda->display_title }}
+
Ingresso {{ optional($scheda->date_received)->format('d/m/Y') ?: '-' }}
+
+
{{ $scheda->customer_name ?: '-' }}
+
{{ $scheda->customer_phone ?: ($scheda->customer_email ?: 'recapito non presente') }}
+
+
{{ $scheda->product_model ?: '-' }}
+
{{ $scheda->product_code ?: 'codice non presente' }}
+
{{ $scheda->serial_label }} + {{ $scheda->status_label ?: 'Stato non indicato' }} + + @if($scheda->fornitore) +
{{ $scheda->fornitore->ragione_sociale ?: ('Fornitore #' . $scheda->fornitore->id) }}
+ @if($scheda->fornitore->is_tecnorepair_primary) +
Centro primario
+ @endif + @else + - + @endif +
+ +
Nessuna scheda legacy importata. Usa il comando indicato sopra per caricare l'archivio TecnoRepair.
+
+
+ +
+ @php($selected = $this->selectedScheda) + @if(! $selected) +
+ Seleziona una riga dall'elenco per aprire la scheda tecnica legacy con cliente, seriali, allegati e note operative. +
+ @else +
+
+
{{ $selected->display_title }}
+
Importata da {{ $selected->imported_from_path ?: 'sorgente sconosciuta' }}
+
+ {{ $selected->status_label ?: 'Stato non indicato' }} +
+ +
+
+
Cliente
+
{{ $selected->customer_name ?: '-' }}
+
{{ $selected->customer_phone ?: '-' }}
+
{{ $selected->customer_phone_alt ?: '-' }}
+
{{ $selected->customer_email ?: '-' }}
+
+
+
Prodotto
+
{{ $selected->product_model ?: '-' }}
+
Codice {{ $selected->product_code ?: '-' }}
+
Seriali {{ $selected->serial_label }}
+
+
+ +
+
Difetto segnalato
+
{{ $selected->defect_reported ?: 'Non valorizzato' }}
+
+ +
+
Riparazione / comunicazioni
+
{{ $selected->repair_description ?: 'Nessuna descrizione riparazione' }}
+ @if($selected->communications) +
{{ $selected->communications }}
+ @endif +
+ +
+
Fornitore e allegati
+
+ @if($selected->fornitore) + @php($fornitoreUrl = $this->getFornitoreUrl((int) $selected->fornitore->id)) + @if($fornitoreUrl) + {{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }} + @else + {{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }} + @endif + @else + Nessun fornitore collegato + @endif +
+ +
+ @forelse($selected->allegati as $allegato) +
+
{{ $allegato->file_name ?: 'Allegato legacy' }}
+
{{ $allegato->file_path ?: 'Percorso non disponibile' }}
+
+ @empty +
Nessun allegato legacy indicizzato.
+ @endforelse +
+
+ @endif +
+
+
+
\ No newline at end of file diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index 342fe67..b788728 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -132,9 +132,71 @@
@endif + @php($ticketCallContext = $this->ticketCallContext) + @if(!empty($ticketCallContext['phone']) || !empty($ticketCallContext['caller_name']) || !empty($ticketCallContext['target_extension'])) +
+
Contesto chiamata
+
+ @if(!empty($ticketCallContext['phone'])) +
+
Numero chiamante
+
{{ $ticketCallContext['phone'] }}
+
+ @endif + @if(!empty($ticketCallContext['target_extension'])) +
+
Interno o gruppo chiamato
+
{{ $ticketCallContext['target_extension'] }}
+
+ @endif + @if(!empty($ticketCallContext['received_at'])) +
+
Ricevuta alle
+
{{ $ticketCallContext['received_at'] }}
+
+ @endif + @if(!empty($ticketCallContext['caller_name'])) +
+
Contatto associato
+
{{ $ticketCallContext['caller_name'] }}
+
+ @endif + @if(!empty($ticketCallContext['caller_profile'])) +
+
Profilo
+
{{ $ticketCallContext['caller_profile'] }}
+
+ @endif + @if(!empty($ticketCallContext['riferimento_stabile'])) +
+
Riferimento stabile
+
{{ $ticketCallContext['riferimento_stabile'] }}
+
+ @endif + @if(!empty($ticketCallContext['riferimento_unita'])) +
+
Riferimento unità
+
{{ $ticketCallContext['riferimento_unita'] }}
+
+ @endif + @if(!empty($ticketCallContext['stabile_label'])) +
+
Stabile collegato
+
{{ $ticketCallContext['stabile_label'] }}
+
+ @endif +
+ @if(!empty($ticketCallContext['call_note'])) +
+
Nota operativa importata
+
{{ $ticketCallContext['call_note'] }}
+
+ @endif +
+ @endif
@@ -174,7 +236,7 @@
+ @else +
+
+
CTI live
+
In attesa di chiamate recenti
+
Il box operativo si apre automaticamente quando arriva una inbound valida.
+
+ + +
@endif
\ No newline at end of file diff --git a/scripts/ops/windows/bootstrap-netgescon-panasonic-live.cmd b/scripts/ops/windows/bootstrap-netgescon-panasonic-live.cmd new file mode 100644 index 0000000..fad0ecc --- /dev/null +++ b/scripts/ops/windows/bootstrap-netgescon-panasonic-live.cmd @@ -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" %* diff --git a/scripts/ops/windows/bootstrap-netgescon-panasonic-live.ps1 b/scripts/ops/windows/bootstrap-netgescon-panasonic-live.ps1 new file mode 100644 index 0000000..b9dddee --- /dev/null +++ b/scripts/ops/windows/bootstrap-netgescon-panasonic-live.ps1 @@ -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 + } +} \ No newline at end of file diff --git a/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 b/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 index dff4fd2..81018b8 100644 --- a/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 +++ b/scripts/ops/windows/get-netgescon-panasonic-tapi-info.ps1 @@ -34,7 +34,7 @@ function Convert-ComCollectionToArray { catch { } - return $items + return ,$items } function Get-AddressSnapshot { @@ -88,7 +88,7 @@ Write-Host "[2/5] Inizializzo TAPI..." $tapi.Initialize() Write-Host "[3/5] Leggo le informazioni generali..." -$addressesRaw = Convert-ComCollectionToArray -Collection $tapi.Addresses +$addressesRaw = @(Convert-ComCollectionToArray -Collection $tapi.Addresses) $addressSnapshots = @() foreach ($address in $addressesRaw) { $addressSnapshots += Get-AddressSnapshot -Address $address -FilterValue $Filter diff --git a/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 b/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 index 6530ccf..3144a81 100644 --- a/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 +++ b/scripts/ops/windows/inspect-netgescon-panasonic-address.ps1 @@ -53,13 +53,13 @@ function Convert-ComCollectionToArray { catch { } - return $items + return ,$items } $tapi = New-Object -ComObject TAPI.TAPI $tapi.Initialize() -$addresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$addresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses) $target = $addresses | Where-Object { ($_.DialableAddress -as [string]) -eq $Extension -or ($_.AddressName -as [string]) -match [regex]::Escape($Extension) diff --git a/scripts/ops/windows/inspect-netgescon-panasonic-tapi-provider.ps1 b/scripts/ops/windows/inspect-netgescon-panasonic-tapi-provider.ps1 new file mode 100644 index 0000000..61648fe --- /dev/null +++ b/scripts/ops/windows/inspect-netgescon-panasonic-tapi-provider.ps1 @@ -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) \ No newline at end of file diff --git a/scripts/ops/windows/install-netgescon-panasonic-live-task.cmd b/scripts/ops/windows/install-netgescon-panasonic-live-task.cmd new file mode 100644 index 0000000..403805b --- /dev/null +++ b/scripts/ops/windows/install-netgescon-panasonic-live-task.cmd @@ -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" %* diff --git a/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 b/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 index d89625f..0512b68 100644 --- a/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 +++ b/scripts/ops/windows/install-netgescon-panasonic-live-task.ps1 @@ -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( [string]$TaskName = 'NetGescon Panasonic Live Bridge', [string]$BaseUrl = 'https://staging.netgescon.it', @@ -13,6 +25,31 @@ param( Set-StrictMode -Version Latest $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 { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) @@ -33,6 +70,9 @@ if ([string]::IsNullOrWhiteSpace($InteropOutputDir)) { } } +Ensure-ParentDirectory -Path $LogFile +Ensure-Directory -Path $InteropOutputDir + $argumentList = @( '-NoProfile' '-ExecutionPolicy', 'Bypass' diff --git a/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 b/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 index 7a1f2af..59c6559 100644 --- a/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 +++ b/scripts/ops/windows/poll-netgescon-panasonic-active-calls.ps1 @@ -99,7 +99,7 @@ function Convert-ComCollectionToArray { catch { } - return $items + return ,$items } function Normalize-ExtensionToken { @@ -180,7 +180,7 @@ $tapi = New-Object -ComObject TAPI.TAPI $tapi.Initialize() $tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate -$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$allAddresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses) $addresses = @($allAddresses | Where-Object { Should-WatchAddress -Address $_ -WatchList $watchList }) diff --git a/scripts/ops/windows/publish-netgescon-panasonic-runtime.ps1 b/scripts/ops/windows/publish-netgescon-panasonic-runtime.ps1 new file mode 100644 index 0000000..4ec7c4e --- /dev/null +++ b/scripts/ops/windows/publish-netgescon-panasonic-runtime.ps1 @@ -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')) \ No newline at end of file diff --git a/scripts/ops/windows/start-netgescon-panasonic-live.cmd b/scripts/ops/windows/start-netgescon-panasonic-live.cmd new file mode 100644 index 0000000..ae00001 --- /dev/null +++ b/scripts/ops/windows/start-netgescon-panasonic-live.cmd @@ -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" %* diff --git a/scripts/ops/windows/start-netgescon-panasonic-live.ps1 b/scripts/ops/windows/start-netgescon-panasonic-live.ps1 index fb24041..0952221 100644 --- a/scripts/ops/windows/start-netgescon-panasonic-live.ps1 +++ b/scripts/ops/windows/start-netgescon-panasonic-live.ps1 @@ -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( [string]$BaseUrl = 'https://staging.netgescon.it', [string]$Extensions = '201-208,601,603,0001,0003', @@ -11,6 +24,31 @@ param( Set-StrictMode -Version Latest $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' if (-not (Test-Path $scriptPath)) { throw "Watcher non trovato: $scriptPath" @@ -40,7 +78,11 @@ elseif (-not [System.IO.Path]::IsPathRooted($InteropOutputDir)) { $InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir } +Ensure-ParentDirectory -Path $LogFile +Ensure-Directory -Path $InteropOutputDir + 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' Write-Host "$timestamp | AVVIO bridge live Panasonic -> NetGescon" @@ -65,5 +107,6 @@ while ($true) { Write-Host "$timestamp | ERRORE bridge live: $($_.Exception.Message)" } + # Evita restart-loop aggressivo in caso di errore ripetuto. Start-Sleep -Seconds ([Math]::Max(1, $RestartDelaySeconds)) } \ No newline at end of file diff --git a/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 b/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 index 4e08520..9e78e97 100644 --- a/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 +++ b/scripts/ops/windows/test-netgescon-panasonic-register-notifications.ps1 @@ -37,7 +37,7 @@ function Convert-ComCollectionToArray { catch { } - return $items + return ,$items } function Normalize-ExtensionToken { diff --git a/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 b/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 index ef341d1..645c79c 100644 --- a/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 +++ b/scripts/ops/windows/watch-netgescon-panasonic-tapi-com-events.ps1 @@ -45,7 +45,7 @@ function Convert-ComCollectionToArray { catch { } - return $items + return ,$items } function Normalize-ExtensionToken { @@ -124,7 +124,7 @@ $tapi = New-Object -ComObject TAPI.TAPI $tapi.Initialize() $tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate -$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$allAddresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses) $addresses = @($allAddresses | Where-Object { Should-WatchAddress -Address $_ -WatchList $watchList }) diff --git a/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 b/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 index ca162cf..42edb7e 100644 --- a/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 +++ b/scripts/ops/windows/watch-netgescon-panasonic-tapi-dotnet-events.ps1 @@ -97,13 +97,13 @@ function Convert-ToSafeString { function Get-FirstNonEmptyProperty { param( - $Object, + $Object, [Parameter(Mandatory = $true)] [string[]]$Names ) - if ($null -eq $Object) { - return '' - } + if ($null -eq $Object) { + return '' + } foreach ($name in $Names) { $value = Get-SafeProperty -Object $Object -Name $name @@ -172,7 +172,7 @@ function Parse-WatchList { } } - return @($watchItems) + return @($watchItems.ToArray()) } function Is-WatchedExtensionValue { @@ -186,7 +186,13 @@ function Is-WatchedExtensionValue { return $false } - return $WatchList -contains $token + foreach ($watch in $WatchList) { + if ((Normalize-ExtensionToken -Value $watch) -eq $token) { + return $true + } + } + + return $false } function Select-ExternalPhoneCandidate { @@ -229,7 +235,32 @@ function Convert-ComCollectionToArray { 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 { @@ -251,6 +282,113 @@ function Normalize-ExtensionToken { 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 { param([string]$AddressName) @@ -294,35 +432,9 @@ function Should-WatchAddress { $rawDial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress') $rawName = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName') - $dial = Normalize-ExtensionToken -Value $rawDial - $name = Normalize-ExtensionToken -Value $rawName - $category = Get-AddressCategory -AddressName $rawName - - 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 - } + $addressMatch = Find-AddressWatchMatch -Values @($rawDial, $rawName) -WatchList $WatchList + if ($null -ne $addressMatch) { + return $true } return $false @@ -468,7 +580,7 @@ function Find-BridgeCallMatchBySignature { } return [pscustomobject]@{ - EventId = [string]$eventId + EventId = [string]$eventId CallRecord = $callRecord } } @@ -529,16 +641,16 @@ function Try-RegisterCallNotifications { try { $cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0) return [pscustomobject]@{ - Ok = $true + Ok = $true Cookie = $cookie - Error = '' + Error = '' } } catch { return [pscustomobject]@{ - Ok = $false + Ok = $false Cookie = $null - Error = $_.Exception.Message + Error = $_.Exception.Message } } } @@ -681,7 +793,7 @@ function Describe-Call { } $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 $valueString = Convert-ToSafeString -Value $value if ($null -ne $value -and $valueString -ne '') { @@ -808,11 +920,25 @@ function Get-CallSnapshot { $addressName = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'AddressName') $dialableAddress = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'DialableAddress') + $addressMatch = Find-AddressWatchMatch -Values @($dialableAddress, $addressName) -WatchList $WatchList $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 '') { $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') $cause = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'Cause') @@ -850,17 +976,39 @@ function Get-CallSnapshot { $callingExtension = '' $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' $externalPhone = Select-ExternalPhoneCandidate -Candidates @($calledIdAddress, $connectedIdAddress, $callerIdAddress) -WatchList $WatchList $callingExtension = $callerToken $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 + 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 + $watchedExtension = $connectedToken + if ($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') { + $addressCategory = Get-TokenCategory -Value $connectedIdAddress + } } $eventId = Get-CallIdentity -Call $call @@ -869,40 +1017,43 @@ function Get-CallSnapshot { } return [pscustomobject]@{ - EventType = $EventType - EventId = $eventId - CallState = $callState - Cause = $cause - AddressName = $addressName - AddressCategory = $addressCategory - WatchedExtension = $watchedExtension - CalledExtension = $calledExtension - CallingExtension = $callingExtension - Direction = $direction - ExternalPhone = $externalPhone - RawCallerIdAddress = $rawCallerIdAddress - RawCalledIdAddress = $rawCalledIdAddress + EventType = $EventType + EventId = $eventId + CallState = $callState + Cause = $cause + AddressName = $addressName + AddressCategory = $addressCategory + WatchedExtension = $watchedExtension + CalledExtension = $calledExtension + CallingExtension = $callingExtension + Direction = $direction + ExternalPhone = $externalPhone + RawCallerIdAddress = $rawCallerIdAddress + RawCalledIdAddress = $rawCalledIdAddress RawConnectedIdAddress = $rawConnectedIdAddress - CallerName = $callerName - CallObject = $call + CallerName = $callerName + CallObject = $call } } function Build-IncomingPayload { 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]@{ - phone = $CallRecord.ExternalPhone - name = $CallRecord.CallerName - direction = $CallRecord.Direction - outcome = $null - event_id = $CallRecord.EventId - called_extension = $CallRecord.CalledExtension + phone = $CallRecord.ExternalPhone + name = $CallRecord.CallerName + direction = $CallRecord.Direction + outcome = $null + event_id = $CallRecord.EventId + called_extension = $CallRecord.CalledExtension calling_extension = $CallRecord.CallingExtension - event_type = $CallRecord.LastEventType - note = 'bridge dry-run da watcher TAPI .NET' - called_at = $CallRecord.StartedAt.ToString('o') - is_test = $true + event_type = $CallRecord.LastEventType + note = $bridgeNote + called_at = $CallRecord.StartedAt.ToString('o') + is_test = $isTest } } @@ -915,18 +1066,21 @@ function Build-CallEndedPayload { $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]@{ - phone = $CallRecord.ExternalPhone - event_id = $CallRecord.EventId - outcome = $outcome - duration_seconds = $durationSeconds - ended_at = $CallRecord.EndedAt.ToString('o') - direction = $CallRecord.Direction - called_extension = $CallRecord.CalledExtension + phone = $CallRecord.ExternalPhone + event_id = $CallRecord.EventId + outcome = $outcome + duration_seconds = $durationSeconds + ended_at = $CallRecord.EndedAt.ToString('o') + direction = $CallRecord.Direction + called_extension = $CallRecord.CalledExtension calling_extension = $CallRecord.CallingExtension - event_type = $CallRecord.LastEventType - note = 'bridge dry-run da watcher TAPI .NET' - is_test = $true + event_type = $CallRecord.LastEventType + note = $bridgeNote + is_test = $isTest } } @@ -999,17 +1153,17 @@ function Register-BridgeIncoming { } $callRecord = [ordered]@{ - EventId = $resolvedEventId - StartedAt = Get-Date - EndedAt = $null - LastEventType = $Snapshot.EventType - CalledExtension = $Snapshot.CalledExtension + EventId = $resolvedEventId + StartedAt = Get-Date + EndedAt = $null + LastEventType = $Snapshot.EventType + CalledExtension = $Snapshot.CalledExtension CallingExtension = $Snapshot.CallingExtension - Direction = $Snapshot.Direction - ExternalPhone = $Snapshot.ExternalPhone - CallerName = $Snapshot.CallerName - Answered = $false - IncomingSent = $false + Direction = $Snapshot.Direction + ExternalPhone = $Snapshot.ExternalPhone + CallerName = $Snapshot.CallerName + Answered = $false + IncomingSent = $false } if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) { @@ -1021,7 +1175,7 @@ function Register-BridgeIncoming { 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 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 } @@ -1071,17 +1225,17 @@ function Register-BridgeStateTransition { } else { $callRecord = [ordered]@{ - EventId = $resolvedEventId - StartedAt = Get-Date - EndedAt = $null - LastEventType = $Snapshot.EventType - CalledExtension = $Snapshot.CalledExtension + EventId = $resolvedEventId + StartedAt = Get-Date + EndedAt = $null + LastEventType = $Snapshot.EventType + CalledExtension = $Snapshot.CalledExtension CallingExtension = $Snapshot.CallingExtension - Direction = $Snapshot.Direction - ExternalPhone = $Snapshot.ExternalPhone - CallerName = $Snapshot.CallerName - Answered = $false - IncomingSent = $false + Direction = $Snapshot.Direction + ExternalPhone = $Snapshot.ExternalPhone + CallerName = $Snapshot.CallerName + Answered = $false + IncomingSent = $false } if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) { @@ -1131,7 +1285,7 @@ function Register-BridgeStateTransition { 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 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 { 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 ',')) if (-not ($events -contains 'Event')) { return [pscustomobject]@{ - Ok = $false + Ok = $false Error = 'evento .NET Event non esposto' } } Register-ObjectEvent -InputObject $InputObject -EventName Event -SourceIdentifier $SourceIdentifier | Out-Null return [pscustomobject]@{ - Ok = $true + Ok = $true Error = '' } } catch { return [pscustomobject]@{ - Ok = $false + Ok = $false Error = $_.Exception.Message } } @@ -1307,10 +1461,10 @@ if ($script:IncludeDiagnosticEvents) { } $tapi.EventFilter = $eventFilter -$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses +$allAddresses = @(Flatten-ObjectArray -Items @(Convert-ComCollectionToArray -Collection $tapi.Addresses)) $addresses = @($allAddresses | Where-Object { - Should-WatchAddress -Address $_ -WatchList $watchList -}) + Should-WatchAddress -Address $_ -WatchList $watchList + }) Write-LogLine "=== AVVIO WATCH DOTNET TAPI ===" Write-LogLine ("Assembly interop: {0}" -f $assembly.FullName)