feat: allinea cti crm e catalogo fornitore per staging
This commit is contained in:
parent
89dabf9d72
commit
89fde6b582
|
|
@ -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.
|
||||
|
|
|
|||
208
app/Console/Commands/FeCassettoImportSoggettoCommand.php
Normal file
208
app/Console/Commands/FeCassettoImportSoggettoCommand.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Amministratore;
|
||||
use App\Models\Stabile;
|
||||
use App\Services\FattureElettroniche\CassettoFiscaleDownloadService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FeCassettoImportSoggettoCommand extends Command
|
||||
{
|
||||
protected $signature = 'fe:cassetto-import-soggetto
|
||||
{amministratore_id : ID amministratore proprietario del contenitore separato}
|
||||
{codice_fiscale : CF/P.IVA del soggetto da interrogare sul cassetto fiscale}
|
||||
{--label= : Denominazione del contenitore separato}
|
||||
{--from= : Data inizio YYYY-MM-DD}
|
||||
{--to= : Data fine YYYY-MM-DD}
|
||||
{--quarterly=1 : 1 per spezzare automaticamente per trimestre}
|
||||
{--metadati=0 : 1 per richiedere anche i metadati}
|
||||
{--user_id=0 : User ID audit}
|
||||
{--force_update=0 : 1 per forzare update/import anche su periodo già eseguito}
|
||||
{--create_fornitore_if_missing=1 : 1 per creare i fornitori mancanti da FE}
|
||||
{--importa_righe=auto : auto|si|no}';
|
||||
|
||||
protected $description = 'Scarica e importa FE dal Cassetto Fiscale per un soggetto fiscale in un contenitore separato dedicato.';
|
||||
|
||||
public function handle(CassettoFiscaleDownloadService $service): int
|
||||
{
|
||||
$amministratore = Amministratore::query()->find((int) $this->argument('amministratore_id'));
|
||||
if (! $amministratore instanceof Amministratore) {
|
||||
$this->error('Amministratore non trovato.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cf = $this->normalizeTaxId((string) $this->argument('codice_fiscale'));
|
||||
if ($cf === '') {
|
||||
$this->error('Codice fiscale / P.IVA non valido.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$from = $this->parseYmd((string) $this->option('from'));
|
||||
$to = $this->parseYmd((string) $this->option('to'));
|
||||
if (! $from || ! $to) {
|
||||
$this->error('Specificare --from=YYYY-MM-DD e --to=YYYY-MM-DD');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($from > $to) {
|
||||
$this->error('Intervallo date non valido.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$label = trim((string) $this->option('label'));
|
||||
if ($label === '') {
|
||||
$label = 'Archivio FE separato ' . $cf;
|
||||
}
|
||||
|
||||
$stabile = $this->ensureDedicatedStabile($amministratore, $cf, $label);
|
||||
|
||||
$periods = (string) $this->option('quarterly') === '1'
|
||||
? $this->splitQuarterly($from, $to)
|
||||
: [[$from, $to]];
|
||||
|
||||
$rows = [];
|
||||
$hasErrors = false;
|
||||
|
||||
foreach ($periods as [$dal, $al]) {
|
||||
$result = $service->downloadAndImport(
|
||||
$amministratore,
|
||||
$stabile,
|
||||
$dal,
|
||||
$al,
|
||||
(bool) ((int) $this->option('metadati') === 1),
|
||||
(int) $this->option('user_id'),
|
||||
[
|
||||
'force_update' => (int) $this->option('force_update') === 1,
|
||||
'create_fornitore_if_missing' => (int) $this->option('create_fornitore_if_missing') === 1,
|
||||
'importa_righe' => (string) $this->option('importa_righe'),
|
||||
'no_skip' => (int) $this->option('force_update') === 1,
|
||||
],
|
||||
);
|
||||
|
||||
$rows[] = [
|
||||
'periodo' => $dal->format('Y-m-d') . ' -> ' . $al->format('Y-m-d'),
|
||||
'status' => (string) ($result['status'] ?? 'error'),
|
||||
'imported' => (string) ((int) ($result['imported'] ?? 0)),
|
||||
'duplicates' => (string) ((int) ($result['duplicates'] ?? 0)),
|
||||
'errors' => (string) ((int) ($result['errors'] ?? 0)),
|
||||
'files' => (string) ((int) ($result['files_total'] ?? 0)),
|
||||
'message' => Str::limit((string) ($result['message'] ?? ''), 120, '...'),
|
||||
];
|
||||
|
||||
if (($result['status'] ?? 'error') === 'error') {
|
||||
$hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('Contenitore separato: stabile #' . (int) $stabile->id . ' [' . (string) $stabile->codice_stabile . '] ' . (string) $stabile->denominazione);
|
||||
$this->table(['Periodo', 'Status', 'Importate', 'Duplicate', 'Errori', 'File', 'Messaggio'], $rows);
|
||||
|
||||
return $hasErrors ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function ensureDedicatedStabile(Amministratore $amministratore, string $cf, string $label): Stabile
|
||||
{
|
||||
$existing = Stabile::query()
|
||||
->where('amministratore_id', (int) $amministratore->id)
|
||||
->where('codice_fiscale', $cf)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Stabile) {
|
||||
$dirty = false;
|
||||
if (! filled($existing->denominazione)) {
|
||||
$existing->denominazione = $label;
|
||||
$dirty = true;
|
||||
}
|
||||
if (! filled($existing->cod_fisc_amministratore) && filled($amministratore->codice_fiscale_studio)) {
|
||||
$existing->cod_fisc_amministratore = (string) $amministratore->codice_fiscale_studio;
|
||||
$dirty = true;
|
||||
}
|
||||
if ($dirty) {
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return Stabile::query()->create([
|
||||
'amministratore_id' => (int) $amministratore->id,
|
||||
'codice_stabile' => $this->nextDedicatedCode($amministratore, $cf),
|
||||
'denominazione' => $label,
|
||||
'codice_fiscale' => $cf,
|
||||
'cod_fisc_amministratore' => (string) ($amministratore->codice_fiscale_studio ?? ''),
|
||||
'indirizzo' => (string) ($amministratore->indirizzo_studio ?? 'Archivio separato FE'),
|
||||
'cap' => (string) ($amministratore->cap_studio ?? '00000'),
|
||||
'citta' => (string) ($amministratore->citta_studio ?? 'Roma'),
|
||||
'provincia' => (string) ($amministratore->provincia_studio ?? 'RM'),
|
||||
'attivo' => true,
|
||||
'stato' => 'attivo',
|
||||
'note' => 'Contenitore separato creato per import FE soggetto fiscale ' . $cf,
|
||||
'amministratore_nome' => trim((string) (($amministratore->nome ?? '') . ' ' . ($amministratore->cognome ?? ''))),
|
||||
'amministratore_email' => (string) ($amministratore->email_studio ?? ''),
|
||||
'configurazione_avanzata' => ['scope' => 'fe_soggetto_separato'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function nextDedicatedCode(Amministratore $amministratore, string $cf): string
|
||||
{
|
||||
$base = 'FE' . substr(preg_replace('/\D+/', '', $cf) ?: '000000', -6);
|
||||
$base = str_pad(substr($base, 0, 8), 8, '0');
|
||||
$code = $base;
|
||||
$suffix = 1;
|
||||
|
||||
while (Stabile::query()->where('codice_stabile', $code)->exists()) {
|
||||
$suffixText = str_pad((string) $suffix, 2, '0', STR_PAD_LEFT);
|
||||
$code = substr($base, 0, 6) . $suffixText;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{0:\DateTimeImmutable,1:\DateTimeImmutable}>
|
||||
*/
|
||||
private function splitQuarterly(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
$periods = [];
|
||||
$cursor = $from;
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$quarter = intdiv(((int) $cursor->format('n')) - 1, 3) + 1;
|
||||
$quarterEndMonth = $quarter * 3;
|
||||
$quarterEnd = new \DateTimeImmutable($cursor->format('Y') . '-' . str_pad((string) $quarterEndMonth, 2, '0', STR_PAD_LEFT) . '-01');
|
||||
$quarterEnd = $quarterEnd->modify('last day of this month');
|
||||
|
||||
if ($quarterEnd > $to) {
|
||||
$quarterEnd = $to;
|
||||
}
|
||||
|
||||
$periods[] = [$cursor, $quarterEnd];
|
||||
$cursor = $quarterEnd->modify('+1 day');
|
||||
}
|
||||
|
||||
return $periods;
|
||||
}
|
||||
|
||||
private function parseYmd(string $value): ?\DateTimeImmutable
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
|
||||
return $date instanceof \DateTimeImmutable ? $date : null;
|
||||
}
|
||||
|
||||
private function normalizeTaxId(string $value) : string
|
||||
{
|
||||
return strtoupper(trim(preg_replace('/\s+/', '', $value) ?? ''));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\RubricaUniversale;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Persona;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use App\Models\Persona;
|
||||
use App\Models\PersonaUnitaRelazione;
|
||||
use App\Models\RubricaRuolo;
|
||||
use App\Models\RubricaUniversale;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class GesconSyncRubricaRuoli extends Command
|
||||
{
|
||||
|
|
@ -32,8 +30,13 @@ public function handle(): int
|
|||
$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;
|
||||
if ($limit < 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
if ($limit > 20000) {
|
||||
$limit = 20000;
|
||||
}
|
||||
|
||||
$query = PersonaUnitaRelazione::with(['persona', 'unitaImmobiliare.stabile'])
|
||||
->orderBy('id');
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
102
app/Console/Commands/ImportFornitoreWholesaleCsvCommand.php
Normal file
102
app/Console/Commands/ImportFornitoreWholesaleCsvCommand.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Fornitore;
|
||||
use App\Services\Catalog\FornitoreProductCatalogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ImportFornitoreWholesaleCsvCommand extends Command
|
||||
{
|
||||
protected $signature = 'fornitore:import-wholesale-csv
|
||||
{fornitore : ID, P.IVA o ragione sociale del fornitore}
|
||||
{--csv= : Percorso al CSV listino}
|
||||
{--limit=0 : Limite righe da importare}
|
||||
{--dry-run : Simula senza scrivere}';
|
||||
|
||||
protected $description = 'Importa un listino CSV fornitore nel catalogo prodotti unificato.';
|
||||
|
||||
public function handle(FornitoreProductCatalogService $catalogService): int
|
||||
{
|
||||
$fornitore = $this->resolveFornitore((string) $this->argument('fornitore'));
|
||||
if (! $fornitore instanceof Fornitore) {
|
||||
$this->error('Fornitore non trovato.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$csvPath = trim((string) $this->option('csv'));
|
||||
if ($csvPath === '') {
|
||||
$this->error('Specificare --csv=/percorso/file.csv');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$stats = $catalogService->importWholesaleCsv(
|
||||
$fornitore,
|
||||
$csvPath,
|
||||
max(0, (int) $this->option('limit')),
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Campo', 'Valore'],
|
||||
[
|
||||
['Fornitore', (string) ($fornitore->ragione_sociale ?? ('#' . $fornitore->id))],
|
||||
['CSV', $csvPath],
|
||||
['Dry run', (bool) $this->option('dry-run') ? 'si' : 'no'],
|
||||
['Righe lette', (string) $stats['rows']],
|
||||
['Prodotti creati', (string) $stats['products']],
|
||||
['Prodotti aggiornati', (string) $stats['updated']],
|
||||
['Codici creati', (string) $stats['identifiers']],
|
||||
['Offerte create/aggiornate', (string) ($stats['offers'] ?? 0)],
|
||||
['Link sorgente interni', (string) ($stats['private_links'] ?? 0)],
|
||||
['Media remoti rimossi', (string) ($stats['remote_media_pruned'] ?? 0)],
|
||||
['Media creati', (string) $stats['media']],
|
||||
['Righe saltate', (string) $stats['skipped']],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveFornitore(string $value): ?Fornitore
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = Fornitore::query();
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$exact = (clone $query)
|
||||
->where('id', (int) $value)
|
||||
->orWhere('partita_iva', $value)
|
||||
->first();
|
||||
if ($exact instanceof Fornitore) {
|
||||
return $exact;
|
||||
}
|
||||
}
|
||||
|
||||
$exact = (clone $query)
|
||||
->where('partita_iva', $value)
|
||||
->orWhere('codice_fiscale', $value)
|
||||
->first();
|
||||
if ($exact instanceof Fornitore) {
|
||||
return $exact;
|
||||
}
|
||||
|
||||
return (clone $query)
|
||||
->where('ragione_sociale', 'like', '%' . $value . '%')
|
||||
->orWhere('nome', 'like', '%' . $value . '%')
|
||||
->orWhere('cognome', 'like', '%' . $value . '%')
|
||||
->orderBy('ragione_sociale')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Amministratore;
|
||||
|
|
@ -10,15 +9,18 @@ class NetgesconApplyPbxPresetCommand extends Command
|
|||
protected $signature = 'netgescon:pbx-apply-day0-preset
|
||||
{admin : ID o codice amministratore}
|
||||
{--apply : Salva il preset sull\'amministratore}
|
||||
{--main-line=0001 : Linea principale studio}
|
||||
{--main-number=0639731100 : Numero esterno linea principale}
|
||||
{--day-group=601 : Gruppo risposta giorno}
|
||||
{--night-group=603 : Gruppo risposta notte}
|
||||
{--day-fallback=201 : Interno fallback giorno}
|
||||
{--night-fallback=206 : Interno fallback notte}
|
||||
{--admin-line=0003 : Linea/DID amministratore}
|
||||
{--admin-number=0688812703 : Numero esterno linea amministratore}
|
||||
{--admin-target= : Interno target amministratore, se diverso dall\'attuale}
|
||||
{--watch=201,206,601,603,0003 : Interni/linee monitorati CSV}';
|
||||
{--watch=0001,0003,201,206,601,603 : Interni/linee monitorati CSV}';
|
||||
|
||||
protected $description = 'Applica o mostra il preset PBX Day0 con gruppi 601/603, fallback 201/206 e linea amministratore 0003.';
|
||||
protected $description = 'Applica o mostra il preset PBX Day0 con linee 0001/0003, gruppi 601/603 e fallback 201/206.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
|
@ -37,7 +39,10 @@ public function handle(): int
|
|||
$nightGroup = $this->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;
|
||||
|
||||
|
|
@ -58,7 +70,7 @@ 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])
|
||||
array_merge($watch, [$mainLine, $dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget])
|
||||
))));
|
||||
|
||||
$pbx['watch_extensions'] = implode(',', $watch);
|
||||
|
|
@ -78,9 +90,20 @@ public function handle(): int
|
|||
], 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,
|
||||
|
|
|
|||
332
app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php
Normal file
332
app/Console/Commands/TecnoRepairImportLegacyArchiveCommand.php
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<?php
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Amministratore;
|
||||
use App\Models\AssistenzaTecnorepairAllegato;
|
||||
use App\Models\AssistenzaTecnorepairScheda;
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\ProductSerial;
|
||||
use App\Services\Catalog\FornitoreProductCatalogService;
|
||||
use App\Support\TecnoRepairMdbReader;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TecnoRepairImportLegacyArchiveCommand extends Command
|
||||
{
|
||||
protected $signature = 'tecnorepair:import-legacy
|
||||
{amministratore : ID o codice amministratore}
|
||||
{--mdb=/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb : Percorso archivio MDB TecnoRepair}
|
||||
{--fornitore-id= : ID fornitore da collegare alle schede}
|
||||
{--fornitore-term=NETHOME : Ragione sociale o frammento per trovare il fornitore di riferimento}
|
||||
{--limit=0 : Limita le schede importate}
|
||||
{--dry-run : Esegue solo validazione e riepilogo senza scrivere}
|
||||
{--force-primary : Marca il fornitore collegato come centro TecnoRepair principale}';
|
||||
|
||||
protected $description = 'Importa in staging l\'archivio TecnoRepair MDB e lo collega al fornitore operativo, di default NETHOME.';
|
||||
|
||||
public function handle(TecnoRepairMdbReader $reader, FornitoreProductCatalogService $catalogService): int
|
||||
{
|
||||
$admin = $this->resolveAdmin((string) $this->argument('amministratore'));
|
||||
if (! $admin instanceof Amministratore) {
|
||||
$this->error('Amministratore non trovato.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$mdbPath = realpath((string) $this->option('mdb')) ?: (string) $this->option('mdb');
|
||||
if ($mdbPath === '' || ! is_file($mdbPath)) {
|
||||
$this->error('Archivio MDB non trovato: ' . $mdbPath);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$fornitore = $this->resolveFornitore($admin, (string) $this->option('fornitore-id'), (string) $this->option('fornitore-term'));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$forcePrimary = (bool) $this->option('force-primary');
|
||||
|
||||
try {
|
||||
$tables = $reader->listTables($mdbPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach (['TClienti', 'TApparecchi', 'TAllegati'] as $requiredTable) {
|
||||
if (! in_array($requiredTable, $tables, true)) {
|
||||
$this->error('Tabella MDB mancante: ' . $requiredTable);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$clientiRows = $reader->exportTable($mdbPath, 'TClienti');
|
||||
$schedeRows = $reader->exportTable($mdbPath, 'TApparecchi');
|
||||
$allegatiRows = $reader->exportTable($mdbPath, 'TAllegati');
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($limit > 0) {
|
||||
$schedeRows = array_slice($schedeRows, 0, $limit);
|
||||
}
|
||||
|
||||
$clienti = [];
|
||||
foreach ($clientiRows as $clienteRow) {
|
||||
$clienteId = $this->toInt($clienteRow['ID'] ?? null);
|
||||
if ($clienteId !== null) {
|
||||
$clienti[$clienteId] = $clienteRow;
|
||||
}
|
||||
}
|
||||
|
||||
$allegatiByScheda = [];
|
||||
foreach ($allegatiRows as $allegatoRow) {
|
||||
$schedaId = $this->toInt($allegatoRow['ID_Scheda'] ?? null);
|
||||
if ($schedaId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allegatiByScheda[$schedaId][] = $allegatoRow;
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'schede_lette' => count($schedeRows),
|
||||
'schede_create' => 0,
|
||||
'schede_aggiornate' => 0,
|
||||
'schede_saltate' => 0,
|
||||
'allegati_importati' => 0,
|
||||
'seriali_allineati' => 0,
|
||||
];
|
||||
|
||||
$runner = function () use ($admin, $mdbPath, $fornitore, $dryRun, $forcePrimary, $schedeRows, $clienti, $allegatiByScheda, $catalogService, &$stats): void {
|
||||
if ($fornitore instanceof Fornitore && $forcePrimary && ! $dryRun) {
|
||||
$fornitore->forceFill(['is_tecnorepair_primary' => true])->save();
|
||||
}
|
||||
|
||||
foreach ($schedeRows as $row) {
|
||||
$legacyId = $this->toInt($row['ID'] ?? null);
|
||||
if ($legacyId === null) {
|
||||
$stats['schede_saltate']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyClienteId = $this->toInt($row['ID_Cliente'] ?? null);
|
||||
$cliente = $legacyClienteId !== null ? ($clienti[$legacyClienteId] ?? []) : [];
|
||||
|
||||
$statusLabel = $this->clean($row['StatoRiparazione'] ?? null) ?: $this->clean($row['ID_StatoRip'] ?? null);
|
||||
$payload = [
|
||||
'amministratore_id' => (int) $admin->id,
|
||||
'fornitore_id' => $fornitore?->id,
|
||||
'legacy_id' => $legacyId,
|
||||
'legacy_cliente_id' => $legacyClienteId,
|
||||
'legacy_centro_ass_id' => $this->toInt($row['ID_CentroAss'] ?? null),
|
||||
'legacy_committente_codice' => $this->clean($row['Cod_Committente'] ?? null),
|
||||
'legacy_numero_scheda' => $this->clean($row['NumeroScheda'] ?? null),
|
||||
'customer_name' => $this->clean($cliente['NomeCognome'] ?? null),
|
||||
'customer_phone' => $this->clean($cliente['NumeroTelefono'] ?? null),
|
||||
'customer_phone_alt' => $this->clean($cliente['TelFisso'] ?? null),
|
||||
'customer_email' => $this->clean($cliente['Email'] ?? null),
|
||||
'product_model' => $this->clean($row['Modello'] ?? null),
|
||||
'product_code' => $this->clean($row['CodiceProdotto'] ?? null),
|
||||
'serial_number' => $this->clean($row['SerialNumber'] ?? null),
|
||||
'serial_number_2' => $this->clean($row['SerialNumber2'] ?? null),
|
||||
'status_code' => $this->clean($row['ID_StatoRip'] ?? null),
|
||||
'status_label' => $statusLabel,
|
||||
'status_bucket' => AssistenzaTecnorepairScheda::normalizeStatusBucket($statusLabel),
|
||||
'defect_reported' => $this->clean($row['DifettoSegnalato'] ?? null),
|
||||
'repair_description' => $this->clean($row['DescrizioneRiparazione'] ?? null),
|
||||
'communications' => $this->clean($row['Comunicazioni'] ?? null),
|
||||
'operator_name' => $this->clean($row['NomeOperatore'] ?? null),
|
||||
'technician_name' => $this->clean($row['NomeTecnicoRiparatore'] ?? null),
|
||||
'date_received' => $this->normalizeDate($row['DataIngresso'] ?? null),
|
||||
'ordered_at' => $this->normalizeDate($row['DataOrdine'] ?? null),
|
||||
'order_number' => $this->clean($row['NumOrdine'] ?? null),
|
||||
'rma_code' => $this->clean($row['CodiceRMA'] ?? null),
|
||||
'pin_code' => $this->clean($row['CodicePIN'] ?? null),
|
||||
'unlock_code' => $this->clean($row['CodiceSblocco'] ?? null),
|
||||
'legacy_attachment_path' => $this->clean($row['FileAllegato1'] ?? null),
|
||||
'imported_from_path' => $mdbPath,
|
||||
'imported_at' => now(),
|
||||
'metadata' => [
|
||||
'cliente' => $cliente,
|
||||
'raw' => $row,
|
||||
],
|
||||
];
|
||||
|
||||
$existing = AssistenzaTecnorepairScheda::query()
|
||||
->where('imported_from_path', $mdbPath)
|
||||
->where('legacy_id', $legacyId)
|
||||
->first();
|
||||
|
||||
if ($dryRun) {
|
||||
if ($existing) {
|
||||
$stats['schede_aggiornate']++;
|
||||
} else {
|
||||
$stats['schede_create']++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheda = AssistenzaTecnorepairScheda::query()->updateOrCreate(
|
||||
[
|
||||
'imported_from_path' => $mdbPath,
|
||||
'legacy_id' => $legacyId,
|
||||
],
|
||||
$payload,
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
$stats['schede_aggiornate']++;
|
||||
} else {
|
||||
$stats['schede_create']++;
|
||||
}
|
||||
|
||||
$serial = ProductSerial::query()->updateOrCreate(
|
||||
['legacy_scheda_id' => (int) $scheda->id],
|
||||
[
|
||||
'fornitore_id' => $fornitore?->id,
|
||||
'customer_name' => $scheda->customer_name,
|
||||
'product_model' => $scheda->product_model,
|
||||
'product_code' => $scheda->product_code,
|
||||
'serial_number' => $scheda->serial_number,
|
||||
'serial_number_2' => $scheda->serial_number_2,
|
||||
'date_received' => $scheda->date_received,
|
||||
'internal_notes' => $scheda->communications,
|
||||
'source' => 'tecnorepair_mdb',
|
||||
'source_reference' => (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id),
|
||||
]
|
||||
);
|
||||
$stats['seriali_allineati']++;
|
||||
|
||||
if ($fornitore instanceof Fornitore) {
|
||||
$catalogService->syncTecnorepairProduct($fornitore, $scheda, $serial);
|
||||
}
|
||||
|
||||
AssistenzaTecnorepairAllegato::query()->where('scheda_id', (int) $scheda->id)->delete();
|
||||
foreach ($allegatiByScheda[$legacyId] ?? [] as $allegatoRow) {
|
||||
AssistenzaTecnorepairAllegato::query()->create([
|
||||
'scheda_id' => (int) $scheda->id,
|
||||
'legacy_id' => $this->toInt($allegatoRow['ID'] ?? null),
|
||||
'legacy_scheda_id' => $legacyId,
|
||||
'legacy_attachment_number' => $this->toInt($allegatoRow['NumAllegato'] ?? null),
|
||||
'file_name' => basename((string) ($allegatoRow['FileAllegato'] ?? 'allegato')),
|
||||
'file_path' => $this->clean($allegatoRow['FileAllegato'] ?? null),
|
||||
'imported_from_path' => $mdbPath,
|
||||
'metadata' => ['raw' => $allegatoRow],
|
||||
]);
|
||||
$stats['allegati_importati']++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if ($dryRun) {
|
||||
$runner();
|
||||
} else {
|
||||
DB::transaction($runner);
|
||||
}
|
||||
|
||||
$this->info('Import TecnoRepair completato.');
|
||||
$this->table(
|
||||
['Campo', 'Valore'],
|
||||
[
|
||||
['Amministratore', (string) ($admin->denominazione_studio ?: ($admin->nome . ' ' . $admin->cognome))],
|
||||
['Archivio MDB', $mdbPath],
|
||||
['Fornitore collegato', $fornitore?->ragione_sociale ?: 'nessuno'],
|
||||
['Dry run', $dryRun ? 'si' : 'no'],
|
||||
['Schede lette', (string) $stats['schede_lette']],
|
||||
['Schede create', (string) $stats['schede_create']],
|
||||
['Schede aggiornate', (string) $stats['schede_aggiornate']],
|
||||
['Schede saltate', (string) $stats['schede_saltate']],
|
||||
['Allegati importati', (string) $stats['allegati_importati']],
|
||||
['Seriali allineati', (string) $stats['seriali_allineati']],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveAdmin(string $value): ?Amministratore
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Amministratore::query()
|
||||
->when(is_numeric($value), fn($query) => $query->orWhere('id', (int) $value))
|
||||
->orWhere('codice_amministratore', $value)
|
||||
->orWhere('codice_univoco', $value)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveFornitore(Amministratore $admin, string $fornitoreIdOption, string $fornitoreTermOption): ?Fornitore
|
||||
{
|
||||
$fornitoreId = (int) $fornitoreIdOption;
|
||||
if ($fornitoreId > 0) {
|
||||
return Fornitore::query()
|
||||
->where('amministratore_id', (int) $admin->id)
|
||||
->whereKey($fornitoreId)
|
||||
->first();
|
||||
}
|
||||
|
||||
$term = trim($fornitoreTermOption);
|
||||
if ($term === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Fornitore::query()
|
||||
->where('amministratore_id', (int) $admin->id)
|
||||
->where(function ($query) use ($term): void {
|
||||
$query->where('ragione_sociale', 'like', '%' . $term . '%')
|
||||
->orWhere('nome', 'like', '%' . $term . '%')
|
||||
->orWhere('cognome', 'like', '%' . $term . '%')
|
||||
->orWhere('tags', 'like', '%' . $term . '%');
|
||||
})
|
||||
->orderByDesc('is_tecnorepair_primary')
|
||||
->orderBy('ragione_sociale')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function clean(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) && ! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function toInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
$value = $this->clean($value);
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<?php
|
||||
namespace App\Filament\Pages\Gescon;
|
||||
|
||||
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
|
||||
use App\Models\Documento;
|
||||
use App\Models\FatturaElettronica;
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\FornitoreDipendente;
|
||||
use App\Models\FornitoreStabileImpostazione;
|
||||
use App\Models\Product;
|
||||
use App\Models\RegistroRitenuteAcconto;
|
||||
use App\Models\RubricaUniversale;
|
||||
use App\Models\Stabile;
|
||||
|
|
@ -15,6 +17,9 @@
|
|||
use App\Models\VoceSpesa;
|
||||
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
|
||||
use App\Modules\Contabilita\Models\PianoConti;
|
||||
use App\Services\Catalog\FornitoreProductCatalogService;
|
||||
use App\Services\Catalog\ProductAssetIngestionService;
|
||||
use App\Services\Catalog\ProductOfferService;
|
||||
use App\Services\Consumi\AcquaPdfTextParser;
|
||||
use App\Services\Consumi\ConsumiAcquaIngestionService;
|
||||
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
|
||||
|
|
@ -37,6 +42,8 @@
|
|||
|
||||
class FornitoreScheda extends Page
|
||||
{
|
||||
use ResolvesOperatoreContext;
|
||||
|
||||
protected static ?string $title = 'Scheda fornitore';
|
||||
|
||||
protected static ?string $slug = 'anagrafica/fornitori/{record}';
|
||||
|
|
@ -65,6 +72,12 @@ class FornitoreScheda extends Page
|
|||
/** @var array<int, array<string, mixed>> */
|
||||
public array $raVersateRows = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $catalogoProdottiRows = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $tecnorepairRows = [];
|
||||
|
||||
public string $tagsInput = '';
|
||||
|
||||
/** @var array<int, string> */
|
||||
|
|
@ -105,10 +118,14 @@ 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);
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -161,11 +178,13 @@ public function mount(int | string $record): void
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->hydrateBoxData($user);
|
||||
$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<int, string>
|
||||
*/
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -44,11 +44,16 @@ class PostItGestione extends Page
|
|||
|
||||
public string $tecnicoSearch = '';
|
||||
|
||||
public string $tecnicoLineFilter = '';
|
||||
|
||||
public string $tecnicoScopeFilter = 'tutti';
|
||||
|
||||
/** @var array<int,string> */
|
||||
public array $riaperturaNote = [];
|
||||
|
||||
/** @var array<int,string> */
|
||||
public array $conversioneNote = [];
|
||||
|
||||
/** @var array<string, int|null> */
|
||||
private array $rubricaPhoneCache = [];
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
164
app/Filament/Pages/Supporto/AssistenzaLegacy.php
Normal file
164
app/Filament/Pages/Supporto/AssistenzaLegacy.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
namespace App\Filament\Pages\Supporto;
|
||||
|
||||
use App\Filament\Pages\Gescon\FornitoreScheda;
|
||||
use App\Models\Amministratore;
|
||||
use App\Models\AssistenzaTecnorepairScheda;
|
||||
use App\Models\User;
|
||||
use App\Support\StabileContext;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
|
||||
class AssistenzaLegacy extends Page
|
||||
{
|
||||
protected static ?string $navigationLabel = 'Assistenza Legacy';
|
||||
|
||||
protected static ?string $title = 'Assistenza Legacy';
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
|
||||
|
||||
protected static ?int $navigationSort = 28;
|
||||
|
||||
protected static ?string $slug = 'supporto/assistenza-legacy';
|
||||
|
||||
protected string $view = 'filament.pages.supporto.assistenza-legacy';
|
||||
|
||||
public string $status = 'all';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public ?int $selectedSchedaId = null;
|
||||
|
||||
/** @var array<string,int> */
|
||||
public array $counters = [
|
||||
'all' => 0,
|
||||
'open' => 0,
|
||||
'waiting' => 0,
|
||||
'closed' => 0,
|
||||
];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedSchedaId = (int) request()->query('scheda', 0) ?: null;
|
||||
$this->refreshCounters();
|
||||
}
|
||||
|
||||
public function updatedStatus(): void
|
||||
{
|
||||
$this->refreshCounters();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->refreshCounters();
|
||||
}
|
||||
|
||||
public function apriScheda(int $schedaId): void
|
||||
{
|
||||
$this->selectedSchedaId = $schedaId;
|
||||
}
|
||||
|
||||
public function getRowsProperty()
|
||||
{
|
||||
return $this->baseQuery()
|
||||
->with(['fornitore:id,ragione_sociale,is_tecnorepair_primary', 'allegati'])
|
||||
->search($this->search)
|
||||
->statusBucket($this->status)
|
||||
->orderByRaw("CASE status_bucket WHEN 'open' THEN 0 WHEN 'waiting' THEN 1 WHEN 'closed' THEN 2 ELSE 3 END")
|
||||
->orderByDesc('date_received')
|
||||
->orderByDesc('id')
|
||||
->limit(250)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getSelectedSchedaProperty(): ?AssistenzaTecnorepairScheda
|
||||
{
|
||||
if ((int) ($this->selectedSchedaId ?? 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->baseQuery()
|
||||
->with(['fornitore:id,ragione_sociale,is_tecnorepair_primary', 'allegati', 'productSerials'])
|
||||
->find((int) $this->selectedSchedaId);
|
||||
}
|
||||
|
||||
public function getFornitoreUrl(?int $fornitoreId): ?string
|
||||
{
|
||||
if ((int) $fornitoreId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament');
|
||||
}
|
||||
|
||||
public function getImportCommandHintProperty(): string
|
||||
{
|
||||
$admin = $this->resolveAmministratore();
|
||||
|
||||
return $admin instanceof Amministratore
|
||||
? 'php artisan tecnorepair:import-legacy ' . ((string) ($admin->codice_amministratore ?: $admin->id)) . ' --force-primary'
|
||||
: 'php artisan tecnorepair:import-legacy {amministratore} --force-primary';
|
||||
}
|
||||
|
||||
private function refreshCounters(): void
|
||||
{
|
||||
$query = $this->baseQuery()->search($this->search);
|
||||
|
||||
$this->counters = [
|
||||
'all' => (int) (clone $query)->count(),
|
||||
'open' => (int) (clone $query)->where('status_bucket', 'open')->count(),
|
||||
'waiting' => (int) (clone $query)->where('status_bucket', 'waiting')->count(),
|
||||
'closed' => (int) (clone $query)->where('status_bucket', 'closed')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function baseQuery(): Builder
|
||||
{
|
||||
$query = AssistenzaTecnorepairScheda::query();
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
if ($user->hasAnyRole(['super-admin', 'admin'])) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$amministratore = $this->resolveAmministratore();
|
||||
if (! $amministratore instanceof Amministratore) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->forAmministratore((int) $amministratore->id);
|
||||
}
|
||||
|
||||
private function resolveAmministratore(): ?Amministratore
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user->amministratore instanceof Amministratore) {
|
||||
return $user->amministratore;
|
||||
}
|
||||
|
||||
$stabile = StabileContext::getActiveStabile($user);
|
||||
|
||||
return $stabile?->amministratore;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ?: '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -53,6 +54,7 @@ public function incoming(Request $request): JsonResponse
|
|||
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
|
||||
|
||||
$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,8 +175,10 @@ public function callEnded(Request $request): JsonResponse
|
|||
'is_test' => $isTest,
|
||||
]);
|
||||
|
||||
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'] ?? '')
|
||||
|
|
@ -200,7 +204,7 @@ public function callEnded(Request $request): JsonResponse
|
|||
'nota' => $this->buildNote($payload),
|
||||
'priorita' => 'Media',
|
||||
'stato' => 'post_it',
|
||||
'chiamata_il' => $payload['ended_at'] ?? now(),
|
||||
'chiamata_il' => $endedAt ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +230,7 @@ public function callEnded(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) {
|
||||
$postIt->chiusa_il = $payload['ended_at'] ?? now();
|
||||
$postIt->chiusa_il = $endedAt ?? now();
|
||||
}
|
||||
|
||||
if (! empty($payload['note'])) {
|
||||
|
|
@ -262,6 +266,25 @@ public function callEnded(Request $request): JsonResponse
|
|||
'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' => 'Call ended processing failed',
|
||||
'trace_id' => $traceId,
|
||||
'error' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
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<string,mixed>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
|
|
@ -22,6 +21,10 @@ public function handle(Request $request, Closure $next): Response
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,23 @@ 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',
|
||||
'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',
|
||||
];
|
||||
|
|
|
|||
35
app/Models/AssistenzaTecnorepairAllegato.php
Normal file
35
app/Models/AssistenzaTecnorepairAllegato.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AssistenzaTecnorepairAllegato extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'assistenza_tecnorepair_allegati_legacy';
|
||||
|
||||
protected $fillable = [
|
||||
'scheda_id',
|
||||
'legacy_id',
|
||||
'legacy_scheda_id',
|
||||
'legacy_attachment_number',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'imported_from_path',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scheda(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AssistenzaTecnorepairScheda::class, 'scheda_id');
|
||||
}
|
||||
}
|
||||
188
app/Models/AssistenzaTecnorepairScheda.php
Normal file
188
app/Models/AssistenzaTecnorepairScheda.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AssistenzaTecnorepairScheda extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'assistenza_tecnorepair_schede_legacy';
|
||||
|
||||
protected $fillable = [
|
||||
'amministratore_id',
|
||||
'fornitore_id',
|
||||
'legacy_id',
|
||||
'legacy_cliente_id',
|
||||
'legacy_centro_ass_id',
|
||||
'legacy_committente_codice',
|
||||
'legacy_numero_scheda',
|
||||
'customer_name',
|
||||
'customer_phone',
|
||||
'customer_phone_alt',
|
||||
'customer_email',
|
||||
'product_model',
|
||||
'product_code',
|
||||
'serial_number',
|
||||
'serial_number_2',
|
||||
'status_code',
|
||||
'status_label',
|
||||
'status_bucket',
|
||||
'defect_reported',
|
||||
'repair_description',
|
||||
'communications',
|
||||
'operator_name',
|
||||
'technician_name',
|
||||
'date_received',
|
||||
'ordered_at',
|
||||
'order_number',
|
||||
'rma_code',
|
||||
'pin_code',
|
||||
'unlock_code',
|
||||
'legacy_attachment_path',
|
||||
'imported_from_path',
|
||||
'imported_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date_received' => 'datetime',
|
||||
'ordered_at' => 'datetime',
|
||||
'imported_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function amministratore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Amministratore::class, 'amministratore_id');
|
||||
}
|
||||
|
||||
public function fornitore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornitore::class, 'fornitore_id');
|
||||
}
|
||||
|
||||
public function allegati(): HasMany
|
||||
{
|
||||
return $this->hasMany(AssistenzaTecnorepairAllegato::class, 'scheda_id');
|
||||
}
|
||||
|
||||
public function productSerials(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductSerial::class, 'legacy_scheda_id');
|
||||
}
|
||||
|
||||
public function scopeForAmministratore(Builder $query, int $amministratoreId): Builder
|
||||
{
|
||||
return $query->where('amministratore_id', $amministratoreId);
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, string $term): Builder
|
||||
{
|
||||
$term = trim($term);
|
||||
if ($term === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $inner) use ($term): void {
|
||||
$like = '%' . $term . '%';
|
||||
|
||||
$inner->where('legacy_numero_scheda', 'like', $like)
|
||||
->orWhere('customer_name', 'like', $like)
|
||||
->orWhere('customer_phone', 'like', $like)
|
||||
->orWhere('customer_email', 'like', $like)
|
||||
->orWhere('product_model', 'like', $like)
|
||||
->orWhere('product_code', 'like', $like)
|
||||
->orWhere('serial_number', 'like', $like)
|
||||
->orWhere('serial_number_2', 'like', $like)
|
||||
->orWhere('defect_reported', 'like', $like)
|
||||
->orWhere('repair_description', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeStatusBucket(Builder $query, string $bucket): Builder
|
||||
{
|
||||
if ($bucket === 'all') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('status_bucket', $bucket);
|
||||
}
|
||||
|
||||
public function getDisplayTitleAttribute(): string
|
||||
{
|
||||
$numero = trim((string) ($this->legacy_numero_scheda ?? ''));
|
||||
$modello = trim((string) ($this->product_model ?? ''));
|
||||
|
||||
if ($numero !== '' && $modello !== '') {
|
||||
return $numero . ' · ' . $modello;
|
||||
}
|
||||
|
||||
return $numero !== '' ? $numero : ($modello !== '' ? $modello : 'Scheda TecnoRepair');
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ((string) $this->status_bucket) {
|
||||
'closed' => 'emerald',
|
||||
'waiting' => 'amber',
|
||||
'open' => 'rose',
|
||||
default => 'slate',
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusBadgeClassesAttribute(): string
|
||||
{
|
||||
return match ((string) $this->status_bucket) {
|
||||
'closed' => 'border-emerald-200 bg-emerald-50 text-emerald-800',
|
||||
'waiting' => 'border-amber-200 bg-amber-50 text-amber-800',
|
||||
'open' => 'border-rose-200 bg-rose-50 text-rose-800',
|
||||
default => 'border-slate-200 bg-slate-50 text-slate-700',
|
||||
};
|
||||
}
|
||||
|
||||
public function getRowClassesAttribute(): string
|
||||
{
|
||||
return match ((string) $this->status_bucket) {
|
||||
'closed' => 'bg-emerald-50/70 hover:bg-emerald-50',
|
||||
'waiting' => 'bg-amber-50/70 hover:bg-amber-50',
|
||||
'open' => 'bg-rose-50/60 hover:bg-rose-50',
|
||||
default => 'bg-white hover:bg-slate-50',
|
||||
};
|
||||
}
|
||||
|
||||
public function getSerialLabelAttribute(): string
|
||||
{
|
||||
$parts = array_values(array_filter([
|
||||
trim((string) ($this->serial_number ?? '')),
|
||||
trim((string) ($this->serial_number_2 ?? '')),
|
||||
]));
|
||||
|
||||
return $parts !== [] ? implode(' · ', $parts) : '-';
|
||||
}
|
||||
|
||||
public static function normalizeStatusBucket(?string $status): string
|
||||
{
|
||||
$value = Str::lower(trim((string) $status));
|
||||
if ($value === '') {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
if (Str::contains($value, ['chius', 'complet', 'consegn', 'ritirat', 'ok'])) {
|
||||
return 'closed';
|
||||
}
|
||||
|
||||
if (Str::contains($value, ['attesa', 'ordine', 'ricambi', 'preventiv', 'approv'])) {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -36,6 +35,7 @@ class FatturaElettronica extends Model
|
|||
'codice_destinatario',
|
||||
'pec_destinatario',
|
||||
'destinatario_cf',
|
||||
'destinatario_piva',
|
||||
'destinatario_denominazione',
|
||||
'stato',
|
||||
'xml_content',
|
||||
|
|
|
|||
|
|
@ -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,6 +49,7 @@ class Fornitore extends Model
|
|||
'regime_fiscale_id',
|
||||
'tipologia_cassa_id',
|
||||
'aliquota_iva_id',
|
||||
'is_tecnorepair_primary',
|
||||
'old_id',
|
||||
];
|
||||
|
||||
|
|
@ -56,6 +58,7 @@ class Fornitore extends Model
|
|||
'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<string,int>
|
||||
*/
|
||||
public function getTecnoRepairStatsAttribute(): array
|
||||
{
|
||||
$base = $this->tecnorepairSchede();
|
||||
|
||||
return [
|
||||
'total' => (int) (clone $base)->count(),
|
||||
'open' => (int) (clone $base)->whereIn('status_bucket', ['open', 'waiting'])->count(),
|
||||
'closed' => (int) (clone $base)->where('status_bucket', 'closed')->count(),
|
||||
'serials' => (int) $this->productSerials()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor per l'indirizzo completo
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -24,7 +23,7 @@ class PersonaUnitaRelazione extends Model
|
|||
'riceve_comunicazioni',
|
||||
'riceve_convocazioni',
|
||||
'vota_assemblea',
|
||||
'note_relazione'
|
||||
'note_relazione',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -35,7 +34,7 @@ class PersonaUnitaRelazione extends Model
|
|||
'attivo' => 'boolean',
|
||||
'riceve_comunicazioni' => 'boolean',
|
||||
'riceve_convocazioni' => 'boolean',
|
||||
'vota_assemblea' => 'boolean'
|
||||
'vota_assemblea' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
76
app/Models/Product.php
Normal file
76
app/Models/Product.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'products';
|
||||
|
||||
protected $fillable = [
|
||||
'default_fornitore_id',
|
||||
'type',
|
||||
'internal_code',
|
||||
'canonical_key',
|
||||
'name',
|
||||
'brand',
|
||||
'model',
|
||||
'variant_group',
|
||||
'variant_label',
|
||||
'color_label',
|
||||
'unit_measure',
|
||||
'description',
|
||||
'track_serials',
|
||||
'is_active',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'track_serials' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'meta' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (self $product): void {
|
||||
if (! is_string($product->internal_code) || trim($product->internal_code) === '') {
|
||||
$product->forceFill([
|
||||
'internal_code' => 'ART-' . str_pad((string) $product->id, 6, '0', STR_PAD_LEFT),
|
||||
])->saveQuietly();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function fornitore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornitore::class, 'default_fornitore_id');
|
||||
}
|
||||
|
||||
public function identifiers(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductIdentifier::class, 'product_id');
|
||||
}
|
||||
|
||||
public function media(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductMedia::class, 'product_id');
|
||||
}
|
||||
|
||||
public function serials(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductSerial::class, 'product_id');
|
||||
}
|
||||
|
||||
public function offers(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductOffer::class, 'product_id');
|
||||
}
|
||||
}
|
||||
43
app/Models/ProductIdentifier.php
Normal file
43
app/Models/ProductIdentifier.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductIdentifier extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'product_identifiers';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'fornitore_id',
|
||||
'code_type',
|
||||
'code_role',
|
||||
'code_value',
|
||||
'normalized_code',
|
||||
'source',
|
||||
'source_reference',
|
||||
'is_primary',
|
||||
'payload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'payload' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
|
||||
public function fornitore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornitore::class, 'fornitore_id');
|
||||
}
|
||||
}
|
||||
38
app/Models/ProductMedia.php
Normal file
38
app/Models/ProductMedia.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductMedia extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'product_media';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'media_type',
|
||||
'disk',
|
||||
'path',
|
||||
'title',
|
||||
'mime_type',
|
||||
'size_bytes',
|
||||
'sort_order',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'size_bytes' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'meta' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
}
|
||||
61
app/Models/ProductOffer.php
Normal file
61
app/Models/ProductOffer.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductOffer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'product_offers';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'fornitore_id',
|
||||
'offer_key',
|
||||
'source_type',
|
||||
'source_name',
|
||||
'external_product_id',
|
||||
'external_sku',
|
||||
'title',
|
||||
'condition_label',
|
||||
'currency',
|
||||
'price_amount',
|
||||
'price_compare_at',
|
||||
'shipping_amount',
|
||||
'availability',
|
||||
'stock_qty',
|
||||
'external_url',
|
||||
'referral_url',
|
||||
'referral_code',
|
||||
'is_active',
|
||||
'is_internal',
|
||||
'last_seen_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_amount' => 'decimal:2',
|
||||
'price_compare_at' => 'decimal:2',
|
||||
'shipping_amount' => 'decimal:2',
|
||||
'stock_qty' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'is_internal' => 'boolean',
|
||||
'last_seen_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
|
||||
public function fornitore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornitore::class, 'fornitore_id');
|
||||
}
|
||||
}
|
||||
51
app/Models/ProductSerial.php
Normal file
51
app/Models/ProductSerial.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductSerial extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'product_serials';
|
||||
|
||||
protected $fillable = [
|
||||
'fornitore_id',
|
||||
'product_id',
|
||||
'legacy_scheda_id',
|
||||
'customer_name',
|
||||
'product_model',
|
||||
'product_code',
|
||||
'serial_number',
|
||||
'serial_number_2',
|
||||
'date_received',
|
||||
'date_resold',
|
||||
'internal_notes',
|
||||
'source',
|
||||
'source_reference',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date_received' => 'datetime',
|
||||
'date_resold' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function fornitore(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornitore::class, 'fornitore_id');
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
|
||||
public function legacyScheda(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AssistenzaTecnorepairScheda::class, 'legacy_scheda_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -119,4 +133,44 @@ public function panel(Panel $panel): Panel
|
|||
]);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
790
app/Services/Catalog/FornitoreProductCatalogService.php
Normal file
790
app/Services/Catalog/FornitoreProductCatalogService.php
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
<?php
|
||||
namespace App\Services\Catalog;
|
||||
|
||||
use App\Models\AssistenzaTecnorepairScheda;
|
||||
use App\Models\FatturaElettronica;
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIdentifier;
|
||||
use App\Models\ProductMedia;
|
||||
use App\Models\ProductSerial;
|
||||
use App\Modules\Contabilita\Models\FatturaFornitore;
|
||||
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FornitoreProductCatalogService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FatturaElettronicaXmlParser $xmlParser,
|
||||
private readonly ProductOfferService $productOfferService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public function extractFromFattureElettroniche(Fornitore $fornitore, int $stabileId, int $limit = 100): array
|
||||
{
|
||||
$stats = [
|
||||
'fatture' => 0,
|
||||
'lines' => 0,
|
||||
'products' => 0,
|
||||
'identifiers' => 0,
|
||||
'linked_rows' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
$fatture = FatturaElettronica::query()
|
||||
->where('stabile_id', $stabileId)
|
||||
->where('fornitore_id', (int) $fornitore->id)
|
||||
->whereNotNull('xml_content')
|
||||
->orderByDesc('data_fattura')
|
||||
->orderByDesc('id')
|
||||
->limit(max(1, $limit))
|
||||
->get(['id', 'xml_content']);
|
||||
|
||||
foreach ($fatture as $fattura) {
|
||||
$stats['fatture']++;
|
||||
|
||||
try {
|
||||
$parsed = $this->xmlParser->parse((string) $fattura->xml_content);
|
||||
} catch (\Throwable) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$righe = is_array($parsed['righe'] ?? null) ? $parsed['righe'] : [];
|
||||
if ($righe === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fatturaContabileId = FatturaFornitore::query()
|
||||
->where('fattura_elettronica_id', (int) $fattura->id)
|
||||
->value('id');
|
||||
$fatturaContabileId = is_numeric($fatturaContabileId) ? (int) $fatturaContabileId : null;
|
||||
|
||||
foreach ($righe as $riga) {
|
||||
$stats['lines']++;
|
||||
|
||||
$descrizione = trim((string) ($riga['descrizione'] ?? ''));
|
||||
if ($this->shouldSkipLine($descrizione)) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifiers = $this->extractIdentifiersFromText($descrizione, $fornitore);
|
||||
|
||||
$product = $this->resolveOrCreateProduct($fornitore, [
|
||||
'name' => $descrizione,
|
||||
'type' => 'product',
|
||||
'unit_measure' => $this->resolveUnitMeasure($descrizione),
|
||||
'track_serials' => $this->hasIdentifierType($identifiers, 'serial'),
|
||||
'identifiers' => $identifiers,
|
||||
'meta' => [
|
||||
'source' => 'fattura_elettronica',
|
||||
'source_invoice' => (int) $fattura->id,
|
||||
'prezzo_unitario' => $riga['prezzo_unitario'] ?? null,
|
||||
'quantita' => $riga['quantita'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
$stats['products'] += $product['created'] ? 1 : 0;
|
||||
|
||||
foreach ($identifiers as $identifier) {
|
||||
$createdIdentifier = $this->upsertIdentifier($product['product'], $identifier);
|
||||
$stats['identifiers'] += $createdIdentifier ? 1 : 0;
|
||||
}
|
||||
|
||||
$this->syncSerialsFromIdentifiers($product['product'], $fornitore, $identifiers, 'fe:' . (int) $fattura->id);
|
||||
|
||||
if ($fatturaContabileId !== null) {
|
||||
$rigaNumero = isset($riga['numero_linea']) && is_numeric($riga['numero_linea']) ? (int) $riga['numero_linea'] : null;
|
||||
if ($rigaNumero !== null) {
|
||||
$updated = FatturaFornitore::query()
|
||||
->whereKey($fatturaContabileId)
|
||||
->first()?->righe()
|
||||
->where('riga', $rigaNumero)
|
||||
->whereNull('product_id')
|
||||
->update(['product_id' => (int) $product['product']->id]);
|
||||
$stats['linked_rows'] += (int) $updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public function syncTecnorepairProduct(Fornitore $fornitore, AssistenzaTecnorepairScheda $scheda, ?ProductSerial $serial = null): Product
|
||||
{
|
||||
$payload = [
|
||||
'name' => trim((string) ($scheda->product_model ?: $scheda->product_code ?: 'Prodotto TecnoRepair')),
|
||||
'type' => 'product',
|
||||
'brand' => $this->extractBrandFromText((string) ($scheda->product_model ?? '')),
|
||||
'model' => $this->cleanNullable((string) ($scheda->product_model ?? '')),
|
||||
'track_serials' => true,
|
||||
'identifiers' => array_values(array_filter([
|
||||
$this->buildIdentifierPayload((int) $fornitore->id, 'vendor_sku', 'supplier', (string) ($scheda->product_code ?? ''), 'tecnorepair_mdb', (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id)),
|
||||
])),
|
||||
'meta' => [
|
||||
'source' => 'tecnorepair_mdb',
|
||||
'legacy_scheda' => (int) $scheda->id,
|
||||
'legacy_numero' => (string) ($scheda->legacy_numero_scheda ?? ''),
|
||||
'repair_status' => (string) ($scheda->status_bucket ?? ''),
|
||||
],
|
||||
];
|
||||
|
||||
$resolved = $this->resolveOrCreateProduct($fornitore, $payload);
|
||||
$product = $resolved['product'];
|
||||
|
||||
if ($serial instanceof ProductSerial) {
|
||||
$serial->product_id = (int) $product->id;
|
||||
$serial->save();
|
||||
}
|
||||
|
||||
$vendorCode = $this->cleanNullable((string) ($scheda->product_code ?? ''));
|
||||
if ($vendorCode !== null) {
|
||||
$this->upsertIdentifier($product, [
|
||||
'fornitore_id' => (int) $fornitore->id,
|
||||
'code_type' => 'vendor_sku',
|
||||
'code_role' => 'supplier',
|
||||
'code_value' => $vendorCode,
|
||||
'source' => 'tecnorepair_mdb',
|
||||
'source_reference' => (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id),
|
||||
'payload' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (array_filter([
|
||||
$this->cleanNullable((string) ($scheda->serial_number ?? '')),
|
||||
$this->cleanNullable((string) ($scheda->serial_number_2 ?? '')),
|
||||
]) as $serialCode) {
|
||||
$this->upsertIdentifier($product, [
|
||||
'fornitore_id' => null,
|
||||
'code_type' => 'serial',
|
||||
'code_role' => 'serial',
|
||||
'code_value' => $serialCode,
|
||||
'source' => 'tecnorepair_mdb',
|
||||
'source_reference' => (string) ($scheda->legacy_numero_scheda ?: $scheda->legacy_id),
|
||||
'payload' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{product: Product, created: bool}
|
||||
*/
|
||||
public function resolveOrCreateProduct(Fornitore $fornitore, array $payload): array
|
||||
{
|
||||
$name = trim((string) ($payload['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = 'Prodotto';
|
||||
}
|
||||
|
||||
$canonicalKey = $this->buildCanonicalKey($fornitore, $payload);
|
||||
|
||||
$product = $this->resolveProductByIdentifiers($payload['identifiers'] ?? null);
|
||||
if (! $product instanceof Product) {
|
||||
$product = Product::query()->where('canonical_key', $canonicalKey)->first();
|
||||
}
|
||||
$created = false;
|
||||
|
||||
if (! $product instanceof Product) {
|
||||
$product = Product::query()->create([
|
||||
'default_fornitore_id' => (int) $fornitore->id,
|
||||
'type' => (string) ($payload['type'] ?? 'product'),
|
||||
'canonical_key' => $canonicalKey,
|
||||
'name' => Str::limit($name, 255, ''),
|
||||
'brand' => $this->cleanNullable((string) ($payload['brand'] ?? '')),
|
||||
'model' => $this->cleanNullable((string) ($payload['model'] ?? '')),
|
||||
'variant_group' => $this->cleanNullable((string) ($payload['variant_group'] ?? '')),
|
||||
'variant_label' => $this->cleanNullable((string) ($payload['variant_label'] ?? '')),
|
||||
'color_label' => $this->cleanNullable((string) ($payload['color_label'] ?? '')),
|
||||
'unit_measure' => $this->cleanNullable((string) ($payload['unit_measure'] ?? '')),
|
||||
'description' => $this->cleanNullable((string) ($payload['description'] ?? '')),
|
||||
'track_serials' => (bool) ($payload['track_serials'] ?? false),
|
||||
'is_active' => true,
|
||||
'meta' => is_array($payload['meta'] ?? null) ? $payload['meta'] : null,
|
||||
]);
|
||||
$created = true;
|
||||
} else {
|
||||
$dirty = false;
|
||||
|
||||
foreach (['brand', 'model', 'variant_group', 'variant_label', 'color_label', 'unit_measure', 'description'] as $field) {
|
||||
$incoming = $this->cleanNullable((string) ($payload[$field] ?? ''));
|
||||
if ($incoming !== null && ! filled($product->{$field})) {
|
||||
$product->{$field} = $incoming;
|
||||
$dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((bool) ($payload['track_serials'] ?? false) && ! (bool) $product->track_serials) {
|
||||
$product->track_serials = true;
|
||||
$dirty = true;
|
||||
}
|
||||
|
||||
$incomingMeta = is_array($payload['meta'] ?? null) ? $payload['meta'] : [];
|
||||
if ($incomingMeta !== []) {
|
||||
$existingMeta = is_array($product->meta ?? null) ? $product->meta : [];
|
||||
$mergedMeta = array_replace_recursive($existingMeta, $incomingMeta);
|
||||
if ($mergedMeta !== $existingMeta) {
|
||||
$product->meta = $mergedMeta;
|
||||
$dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dirty) {
|
||||
$product->save();
|
||||
}
|
||||
}
|
||||
|
||||
return ['product' => $product, 'created' => $created];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
public function upsertIdentifier(Product $product, array $payload): bool
|
||||
{
|
||||
$codeValue = trim((string) ($payload['code_value'] ?? ''));
|
||||
$codeType = trim((string) ($payload['code_type'] ?? ''));
|
||||
if ($codeValue === '' || $codeType === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeCode($codeValue);
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$identifier = ProductIdentifier::query()->firstOrNew([
|
||||
'fornitore_id' => isset($payload['fornitore_id']) && is_numeric($payload['fornitore_id']) ? (int) $payload['fornitore_id'] : null,
|
||||
'code_type' => $codeType,
|
||||
'normalized_code' => $normalized,
|
||||
]);
|
||||
|
||||
$created = ! $identifier->exists;
|
||||
$identifier->product_id = (int) $product->id;
|
||||
$identifier->code_role = $this->cleanNullable((string) ($payload['code_role'] ?? ''));
|
||||
$identifier->code_value = $codeValue;
|
||||
$identifier->source = $this->cleanNullable((string) ($payload['source'] ?? ''));
|
||||
$identifier->source_reference = $this->cleanNullable((string) ($payload['source_reference'] ?? ''));
|
||||
$identifier->is_primary = (bool) ($payload['is_primary'] ?? false);
|
||||
$identifier->payload = is_array($payload['payload'] ?? null) ? $payload['payload'] : null;
|
||||
$identifier->save();
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public function importWholesaleCsv(Fornitore $fornitore, string $csvPath, int $limit = 0, bool $dryRun = false): array
|
||||
{
|
||||
if (! is_file($csvPath)) {
|
||||
throw new \RuntimeException('CSV non trovato: ' . $csvPath);
|
||||
}
|
||||
|
||||
$handle = fopen($csvPath, 'r');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Impossibile aprire il CSV: ' . $csvPath);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'rows' => 0,
|
||||
'products' => 0,
|
||||
'updated' => 0,
|
||||
'identifiers' => 0,
|
||||
'media' => 0,
|
||||
'offers' => 0,
|
||||
'private_links' => 0,
|
||||
'remote_media_pruned' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
$headers = fgetcsv($handle, 0, ';', '"');
|
||||
if (! is_array($headers) || $headers === []) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException('Header CSV NCOM non valido.');
|
||||
}
|
||||
|
||||
$headers = array_map(fn($value) => trim((string) $value), $headers);
|
||||
|
||||
while (($row = fgetcsv($handle, 0, ';', '"')) !== false) {
|
||||
$stats['rows']++;
|
||||
|
||||
if ($limit > 0 && $stats['rows'] > $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$mapped = [];
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped[$header] = trim((string) ($row[$index] ?? ''));
|
||||
}
|
||||
|
||||
$sku = $this->cleanNullable((string) ($mapped['SKU'] ?? ''));
|
||||
$name = $this->cleanNullable((string) ($mapped['DESCRIZIONE_BREVE'] ?? ''));
|
||||
$description = $this->cleanNullable((string) ($mapped['DESCRIZIONE_ESTESA'] ?? ''));
|
||||
if ($sku === null && $name === null && $description === null) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'name' => $name ?? $description ?? $sku ?? 'Prodotto NCOM',
|
||||
'type' => 'product',
|
||||
'brand' => $this->cleanNullable((string) ($mapped['MARCA'] ?? '')),
|
||||
'model' => $this->cleanNullable((string) ($mapped['MODELLO'] ?? '')),
|
||||
'description' => $description,
|
||||
'track_serials' => true,
|
||||
'identifiers' => array_values(array_filter([
|
||||
$this->buildIdentifierPayload((int) $fornitore->id, 'vendor_sku', 'supplier_catalog', (string) ($mapped['SKU'] ?? ''), 'ncom_wholesale_csv', basename($csvPath)),
|
||||
])),
|
||||
'meta' => [
|
||||
'source' => 'ncom_wholesale_csv',
|
||||
'pricing' => [
|
||||
'wholesale' => $this->toDecimal((string) ($mapped['PREZZO'] ?? '')),
|
||||
'offer' => $this->toDecimal((string) ($mapped['PREZZO_OFFERTA'] ?? '')),
|
||||
'public' => $this->toDecimal((string) ($mapped['PREZZO_CONSIGLIATO_PUBBLICO'] ?? '')),
|
||||
],
|
||||
'stock' => [
|
||||
'quantity' => is_numeric($mapped['QUANTITA'] ?? null) ? (int) $mapped['QUANTITA'] : null,
|
||||
],
|
||||
'catalog' => [
|
||||
'categories' => $this->cleanNullable((string) ($mapped['CATEGORIE'] ?? '')),
|
||||
'publication_mode' => 'internal_only',
|
||||
'source_file' => basename($csvPath),
|
||||
'private_source_links' => $this->buildCatalogPrivateSourceLinks($mapped),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
$existingProduct = $this->resolveCatalogProductCandidate($fornitore, $payload);
|
||||
if ($existingProduct instanceof Product) {
|
||||
$stats['updated']++;
|
||||
} else {
|
||||
$stats['products']++;
|
||||
}
|
||||
|
||||
$stats['identifiers'] += count($payload['identifiers']);
|
||||
$stats['private_links'] += $this->countPrivateSourceLinks($mapped);
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveOrCreateProduct($fornitore, $payload);
|
||||
$product = $resolved['product'];
|
||||
|
||||
if ($resolved['created']) {
|
||||
$stats['products']++;
|
||||
} else {
|
||||
$stats['updated']++;
|
||||
}
|
||||
|
||||
foreach ($payload['identifiers'] as $identifier) {
|
||||
$stats['identifiers'] += $this->upsertIdentifier($product, $identifier) ? 1 : 0;
|
||||
}
|
||||
|
||||
$this->productOfferService->syncInternalSupplierOffer($product, $fornitore, [
|
||||
'external_sku' => (string) ($mapped['SKU'] ?? ''),
|
||||
'title' => (string) ($product->name ?? ''),
|
||||
'currency' => 'EUR',
|
||||
'price_amount' => $mapped['PREZZO'] ?? null,
|
||||
'price_compare_at' => $mapped['PREZZO_CONSIGLIATO_PUBBLICO'] ?? null,
|
||||
'availability' => is_numeric($mapped['QUANTITA'] ?? null) && (int) $mapped['QUANTITA'] > 0 ? 'in_stock' : 'unknown',
|
||||
'stock_qty' => is_numeric($mapped['QUANTITA'] ?? null) ? (int) $mapped['QUANTITA'] : null,
|
||||
'external_url' => $this->cleanNullable((string) ($mapped['SCHEDA_PRODOTTO'] ?? '')),
|
||||
'meta' => [
|
||||
'source' => 'ncom_wholesale_csv',
|
||||
'offer_price' => $this->toDecimal((string) ($mapped['PREZZO_OFFERTA'] ?? '')),
|
||||
'categories' => $this->cleanNullable((string) ($mapped['CATEGORIE'] ?? '')),
|
||||
'reference_file' => basename($csvPath),
|
||||
],
|
||||
]);
|
||||
$stats['offers']++;
|
||||
|
||||
$stats['private_links'] += $this->countPrivateSourceLinks($mapped);
|
||||
$stats['remote_media_pruned'] += $this->pruneRemoteSourceMedia($product);
|
||||
$this->syncInternalCatalogPublication($product, basename($csvPath), $mapped);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function resolveCatalogProductCandidate(Fornitore $fornitore, array $payload): ?Product
|
||||
{
|
||||
$product = $this->resolveProductByIdentifiers($payload['identifiers'] ?? null);
|
||||
if ($product instanceof Product) {
|
||||
return $product;
|
||||
}
|
||||
|
||||
return Product::query()
|
||||
->where('canonical_key', $this->buildCanonicalKey($fornitore, $payload))
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function extractIdentifiersFromText(string $description, Fornitore $fornitore): array
|
||||
{
|
||||
$description = trim($description);
|
||||
if ($description === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$identifiers = [];
|
||||
|
||||
if (preg_match_all('/\b(?:EAN|GTIN)[\s:\-#]*([0-9]{8,14})\b/i', $description, $matches)) {
|
||||
foreach ($matches[1] as $code) {
|
||||
$identifiers[] = [
|
||||
'fornitore_id' => null,
|
||||
'code_type' => 'ean',
|
||||
'code_role' => 'barcode',
|
||||
'code_value' => $code,
|
||||
'source' => 'fattura_elettronica',
|
||||
'source_reference' => null,
|
||||
'payload' => ['raw' => $description],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:SKU|ART|COD(?:ICE)?|MODELLO|MPN)[\s:\-#]*([A-Z0-9._\/-]{4,})\b/i', $description, $match)) {
|
||||
$identifiers[] = [
|
||||
'fornitore_id' => (int) $fornitore->id,
|
||||
'code_type' => 'vendor_sku',
|
||||
'code_role' => 'supplier',
|
||||
'code_value' => $match[1],
|
||||
'source' => 'fattura_elettronica',
|
||||
'source_reference' => null,
|
||||
'payload' => ['raw' => $description],
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match_all('/(?:S\/?N|SERIALE|SERIAL(?:\s+NUMBER)?)\s*[:#-]?\s*([A-Z0-9._\/-]{5,})/iu', $description, $matches)) {
|
||||
foreach ($matches[1] as $serial) {
|
||||
$identifiers[] = [
|
||||
'fornitore_id' => null,
|
||||
'code_type' => 'serial',
|
||||
'code_role' => 'serial',
|
||||
'code_value' => $serial,
|
||||
'source' => 'fattura_elettronica',
|
||||
'source_reference' => null,
|
||||
'payload' => ['raw' => $description],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/(?:INTERNO|CODICE\s+ARTICOLO\s+INTERNO)\s*[:#-]?\s*([A-Z0-9+._\/-]{4,})/iu', $description, $matches)) {
|
||||
foreach ($matches[1] as $code) {
|
||||
$identifiers[] = [
|
||||
'fornitore_id' => (int) $fornitore->id,
|
||||
'code_type' => 'vendor_sku',
|
||||
'code_role' => 'supplier_internal',
|
||||
'code_value' => $code,
|
||||
'source' => 'fattura_elettronica',
|
||||
'source_reference' => null,
|
||||
'payload' => ['raw' => $description],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $identifiers;
|
||||
}
|
||||
|
||||
private function resolveProductByIdentifiers(mixed $identifiers): ?Product
|
||||
{
|
||||
if (! is_array($identifiers)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($identifiers as $identifier) {
|
||||
if (! is_array($identifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$codeType = trim((string) ($identifier['code_type'] ?? ''));
|
||||
$codeValue = trim((string) ($identifier['code_value'] ?? ''));
|
||||
if ($codeType === '' || $codeValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeCode($codeValue);
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = ProductIdentifier::query()
|
||||
->where('code_type', $codeType)
|
||||
->where('normalized_code', $normalized);
|
||||
|
||||
if (array_key_exists('fornitore_id', $identifier)) {
|
||||
$fornitoreId = is_numeric($identifier['fornitore_id']) ? (int) $identifier['fornitore_id'] : null;
|
||||
$query->where('fornitore_id', $fornitoreId);
|
||||
}
|
||||
|
||||
$productId = $query->value('product_id');
|
||||
if (is_numeric($productId)) {
|
||||
return Product::query()->find((int) $productId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hasIdentifierType(array $identifiers, string $type): bool
|
||||
{
|
||||
foreach ($identifiers as $identifier) {
|
||||
if (is_array($identifier) && (string) ($identifier['code_type'] ?? '') === $type) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function buildIdentifierPayload(?int $fornitoreId, string $codeType, string $codeRole, string $codeValue, string $source, ?string $reference): ?array
|
||||
{
|
||||
$codeValue = trim($codeValue);
|
||||
if ($codeValue === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'fornitore_id' => $fornitoreId,
|
||||
'code_type' => $codeType,
|
||||
'code_role' => $codeRole,
|
||||
'code_value' => $codeValue,
|
||||
'source' => $source,
|
||||
'source_reference' => $reference,
|
||||
'payload' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function upsertRemoteMedia(Product $product, string $mediaType, string $url, string $title): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || ! str_starts_with($url, 'http')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$media = ProductMedia::query()->firstOrNew([
|
||||
'product_id' => (int) $product->id,
|
||||
'media_type' => $mediaType,
|
||||
'disk' => 'remote-url',
|
||||
'path' => $url,
|
||||
]);
|
||||
|
||||
$created = ! $media->exists;
|
||||
$media->title = $title;
|
||||
$media->meta = ['remote' => true];
|
||||
$media->save();
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $mapped
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function buildCatalogPrivateSourceLinks(array $mapped): array
|
||||
{
|
||||
return array_filter([
|
||||
'product_page' => trim((string) ($mapped['SCHEDA_PRODOTTO'] ?? '')),
|
||||
'image_link' => trim((string) ($mapped['IMAGE_LINK'] ?? '')),
|
||||
], static fn(string $value): bool => $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $mapped
|
||||
*/
|
||||
private function countPrivateSourceLinks(array $mapped): int
|
||||
{
|
||||
return count($this->buildCatalogPrivateSourceLinks($mapped));
|
||||
}
|
||||
|
||||
private function pruneRemoteSourceMedia(Product $product): int
|
||||
{
|
||||
$query = ProductMedia::query()
|
||||
->where('product_id', (int) $product->id)
|
||||
->where('disk', 'remote-url');
|
||||
|
||||
$count = (clone $query)->count();
|
||||
if ($count > 0) {
|
||||
$query->delete();
|
||||
}
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $mapped
|
||||
*/
|
||||
private function syncInternalCatalogPublication(Product $product, string $sourceFile, array $mapped): void
|
||||
{
|
||||
$meta = is_array($product->meta ?? null) ? $product->meta : [];
|
||||
$catalog = is_array($meta['catalog'] ?? null) ? $meta['catalog'] : [];
|
||||
|
||||
$updatedCatalog = array_replace_recursive($catalog, [
|
||||
'publication_mode' => 'internal_only',
|
||||
'public_reference' => (string) ($product->internal_code ?? ''),
|
||||
'source_file' => $sourceFile,
|
||||
'private_source_links' => $this->buildCatalogPrivateSourceLinks($mapped),
|
||||
]);
|
||||
|
||||
if ($updatedCatalog === $catalog) {
|
||||
return;
|
||||
}
|
||||
|
||||
$meta['catalog'] = $updatedCatalog;
|
||||
$product->meta = $meta;
|
||||
$product->save();
|
||||
}
|
||||
|
||||
private function syncSerialsFromIdentifiers(Product $product, Fornitore $fornitore, array $identifiers, string $reference): void
|
||||
{
|
||||
foreach ($identifiers as $identifier) {
|
||||
if (! is_array($identifier) || (string) ($identifier['code_type'] ?? '') !== 'serial') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$serial = trim((string) ($identifier['code_value'] ?? ''));
|
||||
if ($serial === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = ProductSerial::query()
|
||||
->where('product_id', (int) $product->id)
|
||||
->where('serial_number', $serial)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof ProductSerial) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ProductSerial::query()->create([
|
||||
'fornitore_id' => (int) $fornitore->id,
|
||||
'product_id' => (int) $product->id,
|
||||
'customer_name' => null,
|
||||
'product_model' => $product->name,
|
||||
'product_code' => $product->internal_code,
|
||||
'serial_number' => $serial,
|
||||
'serial_number_2' => null,
|
||||
'date_received' => null,
|
||||
'date_resold' => null,
|
||||
'internal_notes' => 'Seriale acquisito da FE',
|
||||
'source' => 'fattura_elettronica',
|
||||
'source_reference' => $reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function toDecimal(string $value): ?float
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = str_replace('.', '', $value);
|
||||
$value = str_replace(',', '.', $value);
|
||||
|
||||
return is_numeric($value) ? round((float) $value, 2) : null;
|
||||
}
|
||||
|
||||
private function buildCanonicalKey(Fornitore $fornitore, array $payload): string
|
||||
{
|
||||
$name = $this->normalizeText((string) ($payload['name'] ?? ''));
|
||||
$brand = $this->normalizeText((string) ($payload['brand'] ?? ''));
|
||||
$model = $this->normalizeText((string) ($payload['model'] ?? ''));
|
||||
$color = $this->normalizeText((string) ($payload['color_label'] ?? ''));
|
||||
$key = implode('|', array_values(array_filter([$brand, $model, $name, $color])));
|
||||
|
||||
if ($key === '') {
|
||||
$key = 'fornitore:' . (int) $fornitore->id . '|' . $this->normalizeText((string) ($fornitore->ragione_sociale ?? 'prodotto'));
|
||||
}
|
||||
|
||||
return Str::limit($key, 190, '');
|
||||
}
|
||||
|
||||
private function normalizeText(string $value): string
|
||||
{
|
||||
$value = Str::of($value)
|
||||
->ascii()
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9]+/', ' ')
|
||||
->trim()
|
||||
->replace(' pez', '')
|
||||
->replace(' pz', '')
|
||||
->value();
|
||||
|
||||
return preg_replace('/\s+/', '-', $value) ?: '';
|
||||
}
|
||||
|
||||
private function normalizeCode(string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[^A-Z0-9]+/i', '', trim($value)) ?? '');
|
||||
}
|
||||
|
||||
private function resolveUnitMeasure(string $description): ?string
|
||||
{
|
||||
$lower = Str::lower($description);
|
||||
|
||||
return match (true) {
|
||||
Str::contains($lower, [' kg', ' kilo']) => 'kg',
|
||||
Str::contains($lower, [' lt', ' litro']) => 'lt',
|
||||
Str::contains($lower, [' mq', ' metro quadro']) => 'mq',
|
||||
default => 'pz',
|
||||
};
|
||||
}
|
||||
|
||||
private function extractBrandFromText(string $description): ?string
|
||||
{
|
||||
$description = trim($description);
|
||||
if ($description === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+/', $description) ?: [];
|
||||
$first = trim((string) ($parts[0] ?? ''));
|
||||
|
||||
return $first !== '' && mb_strlen($first) >= 3 ? Str::upper($first) : null;
|
||||
}
|
||||
|
||||
private function shouldSkipLine(string $description): bool
|
||||
{
|
||||
if ($description === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$clean = Str::lower($description);
|
||||
|
||||
if (Str::contains($clean, ['sconto', 'arrotond', 'bollo', 'cassa previdenziale'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return mb_strlen($clean) < 4;
|
||||
}
|
||||
|
||||
private function cleanNullable(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
150
app/Services/Catalog/ProductAssetIngestionService.php
Normal file
150
app/Services/Catalog/ProductAssetIngestionService.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
namespace App\Services\Catalog;
|
||||
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductMedia;
|
||||
use App\Support\ArchivioPaths;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductAssetIngestionService
|
||||
{
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public function ingestForFornitore(Fornitore $fornitore, int $limit = 50, bool $dryRun = false): array
|
||||
{
|
||||
$stats = [
|
||||
'products' => 0,
|
||||
'images' => 0,
|
||||
'documents' => 0,
|
||||
'skipped_existing' => 0,
|
||||
'skipped_unsupported' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
|
||||
$products = Product::query()
|
||||
->where('default_fornitore_id', (int) $fornitore->id)
|
||||
->orderBy('id')
|
||||
->limit(max(1, $limit))
|
||||
->get(['id', 'internal_code', 'meta']);
|
||||
|
||||
foreach ($products as $product) {
|
||||
$stats['products']++;
|
||||
$catalog = is_array($product->meta['catalog'] ?? null) ? $product->meta['catalog'] : [];
|
||||
$links = is_array($catalog['private_source_links'] ?? null) ? $catalog['private_source_links'] : [];
|
||||
|
||||
$imageResult = $this->ingestSingleAsset($product, $fornitore, (string) ($links['image_link'] ?? ''), 'image', $dryRun);
|
||||
$stats[$imageResult['bucket']]++;
|
||||
|
||||
$documentResult = $this->ingestSingleAsset($product, $fornitore, (string) ($links['product_page'] ?? ''), 'document', $dryRun);
|
||||
$stats[$documentResult['bucket']]++;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{bucket:string}
|
||||
*/
|
||||
private function ingestSingleAsset(Product $product, Fornitore $fornitore, string $url, string $mediaType, bool $dryRun): array
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || ! str_starts_with($url, 'http')) {
|
||||
return ['bucket' => 'skipped_unsupported'];
|
||||
}
|
||||
|
||||
$existing = ProductMedia::query()
|
||||
->where('product_id', (int) $product->id)
|
||||
->where('media_type', $mediaType)
|
||||
->whereIn('disk', ['local', 'public'])
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return ['bucket' => 'skipped_existing'];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
return ['bucket' => $mediaType === 'image' ? 'images' : 'documents'];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(20)
|
||||
->withHeaders(['User-Agent' => 'NetgesconCatalogBot/1.0'])
|
||||
->get($url);
|
||||
} catch (\Throwable) {
|
||||
return ['bucket' => 'failed'];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return ['bucket' => 'failed'];
|
||||
}
|
||||
|
||||
$mime = strtolower(trim((string) $response->header('Content-Type')));
|
||||
if ($mime !== '' && str_contains($mime, ';')) {
|
||||
$mime = trim((string) Str::before($mime, ';'));
|
||||
}
|
||||
|
||||
if ($mediaType === 'image' && ! str_starts_with($mime, 'image/')) {
|
||||
return ['bucket' => 'skipped_unsupported'];
|
||||
}
|
||||
|
||||
if ($mediaType === 'document' && ! str_contains($mime, 'pdf') && ! str_ends_with(Str::lower(parse_url($url, PHP_URL_PATH) ?: ''), '.pdf')) {
|
||||
return ['bucket' => 'skipped_unsupported'];
|
||||
}
|
||||
|
||||
$extension = $this->guessExtension($url, $mime, $mediaType);
|
||||
$folder = $this->buildMediaFolder($fornitore, $product, $mediaType);
|
||||
$filename = Str::slug((string) ($product->internal_code ?? ('product-' . $product->id))) . '-' . substr(sha1($url), 0, 12) . '.' . $extension;
|
||||
$path = $folder . '/' . $filename;
|
||||
|
||||
Storage::disk('public')->put($path, $response->body());
|
||||
|
||||
ProductMedia::query()->create([
|
||||
'product_id' => (int) $product->id,
|
||||
'media_type' => $mediaType,
|
||||
'disk' => 'public',
|
||||
'path' => $path,
|
||||
'title' => $mediaType === 'image' ? 'Immagine catalogo interna' : 'Scheda prodotto interna',
|
||||
'mime_type' => $mime !== '' ? $mime : null,
|
||||
'size_bytes' => strlen($response->body()),
|
||||
'sort_order' => 0,
|
||||
'meta' => [
|
||||
'source_url' => $url,
|
||||
'source_system' => 'private_catalog_link',
|
||||
'internalized' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
return ['bucket' => $mediaType === 'image' ? 'images' : 'documents'];
|
||||
}
|
||||
|
||||
private function buildMediaFolder(Fornitore $fornitore, Product $product, string $mediaType): string
|
||||
{
|
||||
$base = ArchivioPaths::fornitoreBase($fornitore);
|
||||
if (! is_string($base) || $base === '') {
|
||||
$base = 'catalogo/fornitori/' . ($fornitore->codice_univoco ?: $fornitore->id);
|
||||
}
|
||||
|
||||
return trim('catalogo-interno/' . trim($base, '/') . '/prodotti/' . ($product->internal_code ?: $product->id) . '/' . $mediaType, '/');
|
||||
}
|
||||
|
||||
private function guessExtension(string $url, string $mime, string $mediaType): string
|
||||
{
|
||||
$path = Str::lower((string) (parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if (is_string($ext) && $ext !== '') {
|
||||
return $ext;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
str_contains($mime, 'png') => 'png',
|
||||
str_contains($mime, 'webp') => 'webp',
|
||||
str_contains($mime, 'jpeg'), str_contains($mime, 'jpg') => 'jpg',
|
||||
$mediaType === 'document' => 'pdf',
|
||||
default => 'bin',
|
||||
};
|
||||
}
|
||||
}
|
||||
246
app/Services/Catalog/ProductOfferService.php
Normal file
246
app/Services/Catalog/ProductOfferService.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
namespace App\Services\Catalog;
|
||||
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIdentifier;
|
||||
use App\Models\ProductOffer;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductOfferService
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
public function syncOffer(Product $product, array $payload): ProductOffer
|
||||
{
|
||||
$sourceType = trim((string) ($payload['source_type'] ?? 'internal_supplier'));
|
||||
$fornitoreId = isset($payload['fornitore_id']) && is_numeric($payload['fornitore_id']) ? (int) $payload['fornitore_id'] : null;
|
||||
$externalProductId = $this->cleanNullable((string) ($payload['external_product_id'] ?? ''));
|
||||
$externalSku = $this->cleanNullable((string) ($payload['external_sku'] ?? ''));
|
||||
$offerKey = trim((string) ($payload['offer_key'] ?? ''));
|
||||
|
||||
if ($offerKey === '') {
|
||||
$offerKey = $this->buildOfferKey($product, $sourceType, $fornitoreId, $externalProductId, $externalSku, (string) ($payload['title'] ?? ''));
|
||||
}
|
||||
|
||||
$offer = ProductOffer::query()->firstOrNew(['offer_key' => $offerKey]);
|
||||
$offer->fill([
|
||||
'product_id' => (int) $product->id,
|
||||
'fornitore_id' => $fornitoreId,
|
||||
'source_type' => $sourceType,
|
||||
'source_name' => $this->cleanNullable((string) ($payload['source_name'] ?? '')),
|
||||
'external_product_id' => $externalProductId,
|
||||
'external_sku' => $externalSku,
|
||||
'title' => $this->cleanNullable((string) ($payload['title'] ?? '')),
|
||||
'condition_label' => $this->cleanNullable((string) ($payload['condition_label'] ?? '')),
|
||||
'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'price_amount' => $this->normalizeMoney($payload['price_amount'] ?? null),
|
||||
'price_compare_at' => $this->normalizeMoney($payload['price_compare_at'] ?? null),
|
||||
'shipping_amount' => $this->normalizeMoney($payload['shipping_amount'] ?? null),
|
||||
'availability' => $this->cleanNullable((string) ($payload['availability'] ?? '')),
|
||||
'stock_qty' => is_numeric($payload['stock_qty'] ?? null) ? (int) $payload['stock_qty'] : null,
|
||||
'external_url' => $this->cleanNullable((string) ($payload['external_url'] ?? '')),
|
||||
'referral_url' => $this->cleanNullable((string) ($payload['referral_url'] ?? '')),
|
||||
'referral_code' => $this->cleanNullable((string) ($payload['referral_code'] ?? '')),
|
||||
'is_active' => array_key_exists('is_active', $payload) ? (bool) $payload['is_active'] : true,
|
||||
'is_internal' => (bool) ($payload['is_internal'] ?? false),
|
||||
'last_seen_at' => $payload['last_seen_at'] ?? now(),
|
||||
'meta' => is_array($payload['meta'] ?? null) ? $payload['meta'] : null,
|
||||
]);
|
||||
$offer->save();
|
||||
|
||||
return $offer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
public function syncInternalSupplierOffer(Product $product, Fornitore $fornitore, array $payload): ProductOffer
|
||||
{
|
||||
return $this->syncOffer($product, array_replace_recursive($payload, [
|
||||
'fornitore_id' => (int) $fornitore->id,
|
||||
'source_type' => 'internal_supplier',
|
||||
'source_name' => (string) ($fornitore->ragione_sociale ?? ('Fornitore #' . $fornitore->id)),
|
||||
'is_internal' => true,
|
||||
'is_active' => true,
|
||||
]));
|
||||
}
|
||||
|
||||
public function syncAmazonReferralOffer(Product $product, ?string $associateTag = null, string $locale = 'it'): ?ProductOffer
|
||||
{
|
||||
$link = $this->buildAmazonReferralUrl($product, $associateTag, $locale);
|
||||
if ($link === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$asin = $this->findIdentifierValue($product, ['asin']);
|
||||
$ean = $this->findIdentifierValue($product, ['ean']);
|
||||
$resolution = $asin !== null ? 'asin' : ($ean !== null ? 'ean' : 'search');
|
||||
|
||||
return $this->syncOffer($product, [
|
||||
'source_type' => 'amazon_referral',
|
||||
'source_name' => 'Amazon',
|
||||
'external_product_id' => $asin ?? $ean ?? null,
|
||||
'title' => (string) ($product->name ?? ''),
|
||||
'currency' => 'EUR',
|
||||
'availability' => 'link_referral_only',
|
||||
'external_url' => $this->stripAmazonAssociateTag($link),
|
||||
'referral_url' => $link,
|
||||
'referral_code' => $this->cleanNullable((string) $associateTag),
|
||||
'is_internal' => false,
|
||||
'is_active' => true,
|
||||
'meta' => [
|
||||
'locale' => $locale,
|
||||
'resolution' => $resolution,
|
||||
'generated_from' => [
|
||||
'asin' => $asin,
|
||||
'ean' => $ean,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildAmazonReferralUrl(Product $product, ?string $associateTag = null, string $locale = 'it'): ?string
|
||||
{
|
||||
$domain = match (strtolower($locale)) {
|
||||
'de' => 'www.amazon.de',
|
||||
'fr' => 'www.amazon.fr',
|
||||
'es' => 'www.amazon.es',
|
||||
'uk' => 'www.amazon.co.uk',
|
||||
default => 'www.amazon.it',
|
||||
};
|
||||
|
||||
$asin = $this->findIdentifierValue($product, ['asin']);
|
||||
if ($asin !== null) {
|
||||
return $this->appendQuery('https://' . $domain . '/dp/' . rawurlencode($asin), $associateTag);
|
||||
}
|
||||
|
||||
$ean = $this->findIdentifierValue($product, ['ean']);
|
||||
if ($ean !== null) {
|
||||
return $this->appendQuery('https://' . $domain . '/s?k=' . rawurlencode($ean), $associateTag);
|
||||
}
|
||||
|
||||
$query = $this->buildAmazonSearchQuery($product);
|
||||
|
||||
if ($query === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery('https://' . $domain . '/s?k=' . rawurlencode($query), $associateTag);
|
||||
}
|
||||
|
||||
private function buildOfferKey(Product $product, string $sourceType, ?int $fornitoreId, ?string $externalProductId, ?string $externalSku, string $title): string
|
||||
{
|
||||
$seed = implode('|', [
|
||||
(int) $product->id,
|
||||
$sourceType,
|
||||
$fornitoreId ?? 'null',
|
||||
$externalProductId ?? '',
|
||||
$externalSku ?? '',
|
||||
Str::limit(Str::lower(trim($title)), 80, ''),
|
||||
]);
|
||||
|
||||
return Str::limit($sourceType . ':' . sha1($seed), 190, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $types
|
||||
*/
|
||||
private function findIdentifierValue(Product $product, array $types): ?string
|
||||
{
|
||||
$identifier = ProductIdentifier::query()
|
||||
->where('product_id', (int) $product->id)
|
||||
->whereIn('code_type', $types)
|
||||
->orderByDesc('is_primary')
|
||||
->orderBy('id')
|
||||
->value('code_value');
|
||||
|
||||
$value = trim((string) $identifier);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function appendQuery(string $url, ?string $associateTag = null): string
|
||||
{
|
||||
$tag = $this->cleanNullable((string) $associateTag);
|
||||
if ($tag === null) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
|
||||
return $url . $separator . 'tag=' . rawurlencode($tag);
|
||||
}
|
||||
|
||||
private function buildAmazonSearchQuery(Product $product): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$this->cleanSearchPart((string) ($product->brand ?? '')),
|
||||
$this->cleanSearchPart((string) ($product->model ?? '')),
|
||||
$this->cleanSearchPart((string) ($product->name ?? '')),
|
||||
], static fn(string $value): bool => $value !== '');
|
||||
|
||||
$parts = array_values(array_unique($parts));
|
||||
$query = trim(implode(' ', $parts));
|
||||
|
||||
return Str::limit($query, 120, '');
|
||||
}
|
||||
|
||||
private function cleanSearchPart(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = preg_replace('/\bS\/?N\s*[:#-]?\s*[A-Z0-9._\/-]{4,}\b/iu', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\b(?:INTERNO|CODICE\s+ARTICOLO\s+INTERNO|SKU|ART|COD(?:ICE)?|MPN)\s*[:#-]?\s*[A-Z0-9._\/-]{3,}\b/iu', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\b(?:GRADO\s+[A-Z0-9+]+|WINDOWS\s+\d+(?:\s+PRO)?|RICONDIZIONAT[OA])\b/iu', ' ', $value) ?? $value;
|
||||
$value = str_replace(['//', '|', ',', ';'], ' ', $value);
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private function stripAmazonAssociateTag(string $url): string
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (! is_array($parts)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$query = [];
|
||||
parse_str((string) ($parts['query'] ?? ''), $query);
|
||||
unset($query['tag']);
|
||||
|
||||
$base = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . ($parts['path'] ?? '');
|
||||
if ($query === []) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $base . '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
private function cleanNullable(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function normalizeMoney(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
$value = str_replace('.', '', $value);
|
||||
$value = str_replace(',', '.', $value);
|
||||
}
|
||||
|
||||
return is_numeric($value) ? round((float) $value, 2) : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\FattureElettroniche;
|
||||
|
||||
use App\Models\Amministratore;
|
||||
use App\Models\FeCassettoServiceConfig;
|
||||
use App\Models\Stabile;
|
||||
use App\Models\StgFatturaAde;
|
||||
use App\Services\FattureElettroniche\P7mExtractor;
|
||||
use App\Support\ArchivioPaths;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\FattureElettroniche\P7mExtractor;
|
||||
|
||||
class CassettoFiscaleDownloadService
|
||||
{
|
||||
|
|
@ -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 === '') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -121,10 +122,7 @@ public function parse(string $xml): array
|
|||
$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;
|
||||
}
|
||||
$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,
|
||||
|
|
|
|||
104
app/Support/TecnoRepairMdbReader.php
Normal file
104
app/Support/TecnoRepairMdbReader.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
namespace App\Support;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class TecnoRepairMdbReader
|
||||
{
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function listTables(string $mdbPath): array
|
||||
{
|
||||
$bin = $this->resolveBinary('mdb-tables');
|
||||
$process = new Process([$bin, '-1', $mdbPath]);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Impossibile leggere le tabelle MDB.');
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $process->getOutput()) ?: [])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,string|null>>
|
||||
*/
|
||||
public function exportTable(string $mdbPath, string $table): array
|
||||
{
|
||||
$bin = $this->resolveBinary('mdb-export');
|
||||
$process = new Process([$bin, '-D', '%Y-%m-%d %H:%M:%S', '-q', '^', $mdbPath, $table]);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException(trim($process->getErrorOutput()) ?: ('Impossibile esportare la tabella ' . $table));
|
||||
}
|
||||
|
||||
$csv = $process->getOutput();
|
||||
if (trim($csv) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseCsv($csv);
|
||||
}
|
||||
|
||||
public function resolveBinary(string $name): string
|
||||
{
|
||||
$process = new Process(['sh', '-lc', 'command -v ' . escapeshellarg($name)]);
|
||||
$process->run();
|
||||
|
||||
$path = trim($process->getOutput());
|
||||
if ($path === '') {
|
||||
throw new RuntimeException($name . ' non trovato. Installa mdbtools sul server Day0.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,string|null>>
|
||||
*/
|
||||
private function parseCsv(string $csv): array
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Impossibile creare buffer CSV in memoria.');
|
||||
}
|
||||
|
||||
fwrite($handle, $csv);
|
||||
rewind($handle);
|
||||
|
||||
$headers = fgetcsv($handle, 0, ',', '^');
|
||||
if (! is_array($headers) || $headers === []) {
|
||||
fclose($handle);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$headers = array_map(static fn($value): string => trim((string) $value), $headers);
|
||||
$rows = [];
|
||||
|
||||
while (($line = fgetcsv($handle, 0, ',', '^')) !== false) {
|
||||
if ($line === [null] || $line === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $line[$index] ?? null;
|
||||
$row[$header] = is_string($value) ? trim($value) : $value;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
8
config/catalog.php
Normal file
8
config/catalog.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'amazon' => [
|
||||
'associate_tag' => env('CATALOG_AMAZON_ASSOCIATE_TAG', 'netgescon-21'),
|
||||
'default_locale' => env('CATALOG_AMAZON_LOCALE', 'it'),
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('fornitori', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('fornitori', 'is_tecnorepair_primary')) {
|
||||
$table->boolean('is_tecnorepair_primary')->default(false)->after('aliquota_iva_id');
|
||||
}
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
|
||||
Schema::create('assistenza_tecnorepair_schede_legacy', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('amministratore_id')->nullable()->constrained('amministratori')->nullOnDelete();
|
||||
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
|
||||
$table->unsignedBigInteger('legacy_id')->nullable();
|
||||
$table->unsignedBigInteger('legacy_cliente_id')->nullable();
|
||||
$table->unsignedBigInteger('legacy_centro_ass_id')->nullable();
|
||||
$table->string('legacy_committente_codice')->nullable();
|
||||
$table->string('legacy_numero_scheda')->nullable();
|
||||
$table->string('customer_name')->nullable();
|
||||
$table->string('customer_phone')->nullable();
|
||||
$table->string('customer_phone_alt')->nullable();
|
||||
$table->string('customer_email')->nullable();
|
||||
$table->string('product_model')->nullable();
|
||||
$table->string('product_code')->nullable();
|
||||
$table->string('serial_number')->nullable();
|
||||
$table->string('serial_number_2')->nullable();
|
||||
$table->string('status_code')->nullable();
|
||||
$table->string('status_label')->nullable();
|
||||
$table->string('status_bucket', 24)->default('other')->index();
|
||||
$table->text('defect_reported')->nullable();
|
||||
$table->longText('repair_description')->nullable();
|
||||
$table->longText('communications')->nullable();
|
||||
$table->string('operator_name')->nullable();
|
||||
$table->string('technician_name')->nullable();
|
||||
$table->dateTime('date_received')->nullable();
|
||||
$table->dateTime('ordered_at')->nullable();
|
||||
$table->string('order_number')->nullable();
|
||||
$table->string('rma_code')->nullable();
|
||||
$table->string('pin_code')->nullable();
|
||||
$table->string('unlock_code')->nullable();
|
||||
$table->string('legacy_attachment_path')->nullable();
|
||||
$table->string('imported_from_path')->nullable();
|
||||
$table->timestamp('imported_at')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['amministratore_id', 'legacy_numero_scheda'], 'ass_tecnorepair_admin_numero_idx');
|
||||
$table->unique(['imported_from_path', 'legacy_id'], 'ass_tecnorepair_source_legacy_unique');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('assistenza_tecnorepair_allegati_legacy')) {
|
||||
Schema::create('assistenza_tecnorepair_allegati_legacy', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('scheda_id')->constrained('assistenza_tecnorepair_schede_legacy')->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('legacy_id')->nullable();
|
||||
$table->unsignedBigInteger('legacy_scheda_id')->nullable();
|
||||
$table->unsignedInteger('legacy_attachment_number')->nullable();
|
||||
$table->string('file_name')->nullable();
|
||||
$table->string('file_path')->nullable();
|
||||
$table->string('imported_from_path')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scheda_id', 'legacy_attachment_number'], 'ass_tecnorepair_allegati_scheda_num_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('product_serials')) {
|
||||
Schema::create('product_serials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
|
||||
$table->foreignId('legacy_scheda_id')->nullable()->constrained('assistenza_tecnorepair_schede_legacy')->nullOnDelete();
|
||||
$table->string('customer_name')->nullable();
|
||||
$table->string('product_model')->nullable();
|
||||
$table->string('product_code')->nullable();
|
||||
$table->string('serial_number')->nullable();
|
||||
$table->string('serial_number_2')->nullable();
|
||||
$table->dateTime('date_received')->nullable();
|
||||
$table->dateTime('date_resold')->nullable();
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->string('source')->nullable();
|
||||
$table->string('source_reference')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['legacy_scheda_id'], 'product_serials_legacy_scheda_unique');
|
||||
$table->index(['fornitore_id', 'serial_number'], 'product_serials_vendor_serial_idx');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('product_serials')) {
|
||||
Schema::dropIfExists('product_serials');
|
||||
}
|
||||
|
||||
if (Schema::hasTable('assistenza_tecnorepair_allegati_legacy')) {
|
||||
Schema::dropIfExists('assistenza_tecnorepair_allegati_legacy');
|
||||
}
|
||||
|
||||
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
|
||||
Schema::dropIfExists('assistenza_tecnorepair_schede_legacy');
|
||||
}
|
||||
|
||||
Schema::table('fornitori', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('fornitori', 'is_tecnorepair_primary')) {
|
||||
$table->dropColumn('is_tecnorepair_primary');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('fatture_elettroniche', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('fatture_elettroniche', 'destinatario_piva')) {
|
||||
$table->string('destinatario_piva', 32)->nullable()->after('destinatario_cf');
|
||||
$table->index('destinatario_piva');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('fatture_elettroniche', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('fatture_elettroniche', 'destinatario_piva')) {
|
||||
$table->dropIndex(['destinatario_piva']);
|
||||
$table->dropColumn('destinatario_piva');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('products')) {
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('default_fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
|
||||
$table->string('type', 24)->default('product')->index();
|
||||
$table->string('internal_code')->nullable()->unique();
|
||||
$table->string('canonical_key')->unique();
|
||||
$table->string('name');
|
||||
$table->string('brand')->nullable()->index();
|
||||
$table->string('model')->nullable()->index();
|
||||
$table->string('variant_group')->nullable();
|
||||
$table->string('variant_label')->nullable();
|
||||
$table->string('color_label')->nullable();
|
||||
$table->string('unit_measure', 24)->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('track_serials')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['default_fornitore_id', 'is_active'], 'products_supplier_active_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('product_identifiers')) {
|
||||
Schema::create('product_identifiers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
|
||||
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
|
||||
$table->string('code_type', 32)->index();
|
||||
$table->string('code_role', 32)->nullable()->index();
|
||||
$table->string('code_value');
|
||||
$table->string('normalized_code')->index();
|
||||
$table->string('source', 32)->nullable()->index();
|
||||
$table->string('source_reference')->nullable();
|
||||
$table->boolean('is_primary')->default(false);
|
||||
$table->json('payload')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['fornitore_id', 'code_type', 'normalized_code'], 'product_identifiers_scope_unique');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('product_media')) {
|
||||
Schema::create('product_media', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
|
||||
$table->string('media_type', 24)->default('image')->index();
|
||||
$table->string('disk', 32)->default('local');
|
||||
$table->string('path');
|
||||
$table->string('title')->nullable();
|
||||
$table->string('mime_type', 120)->nullable();
|
||||
$table->unsignedBigInteger('size_bytes')->nullable();
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['product_id', 'media_type', 'sort_order'], 'product_media_lookup_idx');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('product_serials', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('product_serials', 'product_id')) {
|
||||
$table->foreignId('product_id')->nullable()->after('fornitore_id')->constrained('products')->nullOnDelete();
|
||||
$table->index(['product_id', 'serial_number'], 'product_serials_product_serial_idx');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('contabilita_fatture_fornitori_righe', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('contabilita_fatture_fornitori_righe', 'product_id')) {
|
||||
$table->foreignId('product_id')->nullable()->after('fattura_id')->constrained('products')->nullOnDelete();
|
||||
$table->index(['product_id', 'fattura_id'], 'cont_fatture_righe_product_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contabilita_fatture_fornitori_righe', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('contabilita_fatture_fornitori_righe', 'product_id')) {
|
||||
$table->dropConstrainedForeignId('product_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('product_serials', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('product_serials', 'product_id')) {
|
||||
$table->dropConstrainedForeignId('product_id');
|
||||
}
|
||||
});
|
||||
|
||||
if (Schema::hasTable('product_media')) {
|
||||
Schema::dropIfExists('product_media');
|
||||
}
|
||||
|
||||
if (Schema::hasTable('product_identifiers')) {
|
||||
Schema::dropIfExists('product_identifiers');
|
||||
}
|
||||
|
||||
if (Schema::hasTable('products')) {
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('product_offers')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('product_offers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
|
||||
$table->foreignId('fornitore_id')->nullable()->constrained('fornitori')->nullOnDelete();
|
||||
$table->string('offer_key')->unique();
|
||||
$table->string('source_type', 32)->index();
|
||||
$table->string('source_name', 64)->nullable()->index();
|
||||
$table->string('external_product_id')->nullable()->index();
|
||||
$table->string('external_sku')->nullable()->index();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('condition_label', 32)->nullable();
|
||||
$table->string('currency', 8)->default('EUR');
|
||||
$table->decimal('price_amount', 12, 2)->nullable();
|
||||
$table->decimal('price_compare_at', 12, 2)->nullable();
|
||||
$table->decimal('shipping_amount', 12, 2)->nullable();
|
||||
$table->string('availability', 64)->nullable();
|
||||
$table->integer('stock_qty')->nullable();
|
||||
$table->text('external_url')->nullable();
|
||||
$table->text('referral_url')->nullable();
|
||||
$table->string('referral_code', 64)->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_internal')->default(false);
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['product_id', 'is_active', 'price_amount'], 'product_offers_lookup_idx');
|
||||
$table->index(['source_type', 'fornitore_id', 'is_active'], 'product_offers_source_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_offers');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
||||
- `<cod_stabile>/<anno>/singolo_anno.mdb`
|
||||
- tabelle minime da validare:
|
||||
- `condomin`
|
||||
|
|
@ -25,6 +27,7 @@ ### 2. Dati annuali per stabile
|
|||
- `straordinarie`
|
||||
|
||||
### 3. Dati trasversali stabile
|
||||
|
||||
- `<cod_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
|
||||
|
|
|
|||
|
|
@ -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,9 +679,12 @@ ### 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`
|
||||
|
||||
1. flussi tecnici basati su `communication_messages`
|
||||
|
||||
- canale `panasonic_csta`
|
||||
- utili per verifica tecnica e correlazione con routing/intercettazione operatore
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,26 @@
|
|||
<div class="-m-6">
|
||||
<div class="-m-6 space-y-3 p-2">
|
||||
<div class="flex items-center justify-end gap-2 px-4 pt-4">
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700"
|
||||
>
|
||||
Apri in nuova scheda
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="h-[78vh] w-full px-4 pb-4">
|
||||
<object
|
||||
data="{{ $url }}#toolbar=1&navpanes=1&view=FitH"
|
||||
type="application/pdf"
|
||||
class="h-full w-full rounded-lg border bg-white"
|
||||
>
|
||||
<iframe
|
||||
title="PDF"
|
||||
class="h-[75vh] w-full"
|
||||
src="{{ $url }}"
|
||||
class="h-full w-full rounded-lg border"
|
||||
src="{{ $url }}#toolbar=1&navpanes=1&view=FitH"
|
||||
></iframe>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@
|
|||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'fe'">
|
||||
<div class="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
|
||||
<div class="font-semibold">Stabile attivo per archivio FE</div>
|
||||
<div class="mt-1">{{ $this->activeStabileLabel }}</div>
|
||||
<div class="mt-1 text-xs text-sky-700">L'elenco mostra solo le fatture collegate allo stabile attivo selezionato nella topbar.</div>
|
||||
</div>
|
||||
|
||||
{{ $this->table }}
|
||||
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
|
|
@ -51,6 +57,12 @@
|
|||
<x-filament::section>
|
||||
<x-slot name="heading">Scarichi da Cassetto Fiscale</x-slot>
|
||||
|
||||
<div class="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<div class="font-semibold">Destinazione scarico corrente</div>
|
||||
<div class="mt-1">{{ $this->activeStabileLabel }}</div>
|
||||
<div class="mt-1 text-xs text-amber-700">I trimestri scaricati in questa tab vengono associati allo stabile attivo visibile in topbar al momento del click.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm text-gray-500">
|
||||
Verifica trimestri già scaricati (se completo, evita ulteriori scarichi).
|
||||
|
|
|
|||
|
|
@ -115,6 +115,190 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold">Modulo fornitore separato</div>
|
||||
<div class="mt-1 text-xs text-gray-500">Catalogo isolato per questo fornitore tramite codice univoco, con riferimenti pubblici interni e sorgenti esterne archiviate solo nel gestionale.</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-900 px-3 py-2 text-right text-white">
|
||||
<div class="text-[10px] uppercase tracking-[0.18em] text-slate-300">Scope</div>
|
||||
<div class="font-mono text-sm font-semibold">{{ $box['catalogo_modulo']['scope_code'] ?? ($fornitore->codice_univoco ?? ('FORN-' . $fornitore->id)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-7">
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Da FE</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['fe_products'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Da CSV</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['csv_products'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Manuali</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['manual_products'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Solo interni</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['internal_only'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Link sorgente</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['private_links'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Offerte</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['offers'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Amazon link</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['amazon_links'] ?? 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
|
||||
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
|
||||
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa listino CSV</x-filament::button>
|
||||
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Internalizza asset</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Prepara link Amazon</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold">Catalogo operativo interno</div>
|
||||
<div class="mt-1 text-xs text-gray-500">Anagrafica prodotti/servizi del fornitore. I riferimenti esterni importati restano interni al gestionale e la pubblicazione usa il codice interno del catalogo.</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
|
||||
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
|
||||
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa CSV</x-filament::button>
|
||||
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Asset interni</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Amazon</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Prodotti</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['count'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Serializzati</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['serializzati'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Codici agganciati</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['identifiers'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-slate-50 p-3">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Media</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['with_media'] ?? 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full border-collapse border text-xs">
|
||||
<thead>
|
||||
<tr class="bg-slate-100 text-slate-700">
|
||||
<th class="border px-2 py-1.5 text-left">Codice</th>
|
||||
<th class="border px-2 py-1.5 text-left">Prodotto</th>
|
||||
<th class="border px-2 py-1.5 text-left">Marca / modello</th>
|
||||
<th class="border px-2 py-1.5 text-left">Origine</th>
|
||||
<th class="border px-2 py-1.5 text-left">Rif. interno</th>
|
||||
<th class="border px-2 py-1.5 text-left">Codici</th>
|
||||
<th class="border px-2 py-1.5 text-left">Seriali</th>
|
||||
<th class="border px-2 py-1.5 text-left">Offerte</th>
|
||||
<th class="border px-2 py-1.5 text-left">Catalogo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($catalogoProdottiRows as $row)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="border px-2 py-1.5 font-medium">{{ $row['internal_code'] !== '' ? $row['internal_code'] : '-' }}</td>
|
||||
<td class="border px-2 py-1.5">
|
||||
<div>{{ $row['name'] !== '' ? $row['name'] : '-' }}</div>
|
||||
@if($row['color_label'] !== '')
|
||||
<div class="text-[11px] text-slate-500">Colore: {{ $row['color_label'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="border px-2 py-1.5">{{ trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')) ?: '-' }}</td>
|
||||
<td class="border px-2 py-1.5">
|
||||
@if(($row['source'] ?? '') === 'fattura_elettronica')
|
||||
<span class="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-800">FE</span>
|
||||
@elseif(($row['source'] ?? '') === 'ncom_wholesale_csv')
|
||||
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800">CSV</span>
|
||||
@else
|
||||
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700">manuale</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="border px-2 py-1.5">
|
||||
<div class="font-mono">{{ $row['public_reference'] !== '' ? $row['public_reference'] : '-' }}</div>
|
||||
@if(($row['publication_mode'] ?? '') === 'internal_only')
|
||||
<div class="text-[11px] text-slate-500">solo interno</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="border px-2 py-1.5">{{ (int) ($row['identifiers_count'] ?? 0) }}</td>
|
||||
<td class="border px-2 py-1.5">
|
||||
{{ (int) ($row['serials_count'] ?? 0) }}
|
||||
@if($row['track_serials'])
|
||||
<span class="ml-1 rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-800">track</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="border px-2 py-1.5">
|
||||
<div>{{ (int) ($row['offers_count'] ?? 0) }} totali</div>
|
||||
<div class="text-[11px] text-slate-500">
|
||||
@if(($row['own_offer_price'] ?? null) !== null)
|
||||
tuo € {{ number_format((float) $row['own_offer_price'], 2, ',', '.') }}
|
||||
@else
|
||||
tuo n/d
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500">
|
||||
@if(($row['best_competitor_price'] ?? null) !== null)
|
||||
competitor € {{ number_format((float) $row['best_competitor_price'], 2, ',', '.') }}
|
||||
@if(($row['best_competitor_source'] ?? '') !== '')
|
||||
| {{ $row['best_competitor_source'] }}
|
||||
@endif
|
||||
@else
|
||||
competitor n/d
|
||||
@endif
|
||||
</div>
|
||||
@if(($row['amazon_referral_url'] ?? '') !== '')
|
||||
<div class="text-[11px] text-slate-500">Amazon referral pronto</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="border px-2 py-1.5">
|
||||
<div>Link sorgente: {{ (int) ($row['private_links'] ?? 0) }}</div>
|
||||
<div class="text-[11px] text-slate-500">
|
||||
@if(($row['price_wholesale'] ?? null) !== null)
|
||||
€ {{ number_format((float) $row['price_wholesale'], 2, ',', '.') }}
|
||||
@else
|
||||
prezzo n/d
|
||||
@endif
|
||||
@if(($row['stock_quantity'] ?? null) !== null)
|
||||
| stock {{ (int) $row['stock_quantity'] }}
|
||||
@endif
|
||||
</div>
|
||||
@if(($row['categories'] ?? '') !== '')
|
||||
<div class="truncate text-[11px] text-slate-500">{{ $row['categories'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="border px-2 py-3 text-center text-gray-500">Nessun prodotto presente per questo fornitore.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="text-lg font-semibold">Pagamenti</div>
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
|
|
@ -178,6 +362,59 @@ class="w-full rounded-lg border-gray-300 text-sm"
|
|||
$acquaScan = (bool) ($acqua['scan_enabled'] ?? false);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-base font-semibold">TecnoRepair / seriali</div>
|
||||
<div class="mt-1 text-sm text-gray-600">Import MDB in anagrafica fornitore e aggancio automatico di seriali e prodotti assistenza.</div>
|
||||
</div>
|
||||
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('importa_tecnorepair')">
|
||||
Importa TecnoRepair
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<span>Schede</span>
|
||||
<span class="font-semibold">{{ (int) ($box['tecnorepair']['schede'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<span>Aperte / attesa</span>
|
||||
<span class="font-semibold">{{ (int) ($box['tecnorepair']['aperte'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<span>Chiuse</span>
|
||||
<span class="font-semibold">{{ (int) ($box['tecnorepair']['chiuse'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<span>Seriali</span>
|
||||
<span class="font-semibold">{{ (int) ($box['tecnorepair']['seriali'] ?? 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2 text-sm">
|
||||
@forelse($tecnorepairRows as $row)
|
||||
<div class="rounded-md border px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium">{{ $row['legacy_numero'] !== '' ? $row['legacy_numero'] : ('Scheda #' . $row['id']) }}</div>
|
||||
<div class="truncate text-gray-600">{{ $row['product_model'] !== '' ? $row['product_model'] : '-' }}</div>
|
||||
<div class="truncate text-xs text-gray-500">Codice: {{ $row['product_code'] !== '' ? $row['product_code'] : '-' }} | Seriale: {{ $row['serial_number'] !== '' ? $row['serial_number'] : '-' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="rounded px-2 py-0.5 text-[10px] font-semibold {{ ($row['status_bucket'] ?? '') === 'closed' ? 'bg-emerald-100 text-emerald-800' : ((($row['status_bucket'] ?? '') === 'waiting') ? 'bg-amber-100 text-amber-800' : 'bg-rose-100 text-rose-800') }}">
|
||||
{{ $row['status_label'] !== '' ? $row['status_label'] : ($row['status_bucket'] !== '' ? $row['status_bucket'] : 'stato') }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">Allegati: {{ (int) ($row['allegati_count'] ?? 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-sm text-gray-600">Nessuna scheda TecnoRepair collegata a questo fornitore.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="text-base font-semibold">Automazioni / Consumi</div>
|
||||
<div class="mt-1 text-sm text-gray-600">Configura acquisizione dati (PDF) e scansione massiva sulle FE già importate.</div>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@
|
|||
@endif
|
||||
|
||||
@if(!$riga->ticket_id && $riga->stato !== 'chiusa')
|
||||
<div class="flex flex-wrap items-center gap-2 rounded-md border bg-slate-50 px-2 py-1">
|
||||
<input type="text" wire:model.defer="conversioneNote.{{ (int) $riga->id }}" class="w-56 rounded-md border-gray-300 text-xs" placeholder="Nota libera prima del ticket (opzionale)" />
|
||||
<x-filament::button size="sm" color="primary" wire:click="convertiInTicket({{ $riga->id }})">Converti in ticket</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($riga->stato !== 'chiusa')
|
||||
|
|
@ -174,6 +177,10 @@
|
|||
<option value="esterno">Solo numeri esterni</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Linea o gruppo</label>
|
||||
<input type="text" wire:model.live.debounce.300ms="tecnicoLineFilter" class="w-full rounded-md border-gray-300 text-xs" placeholder="Es. 0003, 603, GRP00603" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Direzione</label>
|
||||
<select wire:model.live="tecnicoDirectionFilter" class="w-full rounded-md border-gray-300 text-xs">
|
||||
|
|
@ -183,7 +190,7 @@
|
|||
<option value="internal">Internal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-1">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Ricerca</label>
|
||||
<input type="text" wire:model.live.debounce.300ms="tecnicoSearch" class="w-full rounded-md border-gray-300 text-xs" placeholder="Numero, interno, testo riga, nominativo tecnico" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
<x-filament-panels::page>
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border bg-white p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-slate-900">Archivio assistenza TecnoRepair</div>
|
||||
<div class="text-xs text-slate-500">Elenco filtrabile delle schede legacy importate da MDB e collegate al fornitore operativo, tipicamente NETHOME.</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||
Import comando: <span class="font-mono text-slate-800">{{ $this->importCommandHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<div class="rounded-lg border bg-slate-50 px-3 py-2 text-xs">Totali: <span class="font-semibold">{{ $counters['all'] ?? 0 }}</span></div>
|
||||
<div class="rounded-lg border bg-rose-50 px-3 py-2 text-xs text-rose-800">Aperti: <span class="font-semibold">{{ $counters['open'] ?? 0 }}</span></div>
|
||||
<div class="rounded-lg border bg-amber-50 px-3 py-2 text-xs text-amber-800">In attesa: <span class="font-semibold">{{ $counters['waiting'] ?? 0 }}</span></div>
|
||||
<div class="rounded-lg border bg-emerald-50 px-3 py-2 text-xs text-emerald-800">Chiusi: <span class="font-semibold">{{ $counters['closed'] ?? 0 }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
|
||||
<div class="rounded-xl border bg-white p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input type="text" wire:model.live.debounce.300ms="search" class="min-w-[240px] flex-1 rounded-lg border-gray-300 text-sm" placeholder="Cerca scheda, cliente, seriale, modello, difetto" />
|
||||
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm">
|
||||
<option value="all">Tutti gli stati</option>
|
||||
<option value="open">Aperti</option>
|
||||
<option value="waiting">In attesa</option>
|
||||
<option value="closed">Chiusi</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full border-collapse border text-xs">
|
||||
<thead>
|
||||
<tr class="bg-slate-100 text-slate-700">
|
||||
<th class="border px-2 py-2 text-left">Scheda</th>
|
||||
<th class="border px-2 py-2 text-left">Cliente</th>
|
||||
<th class="border px-2 py-2 text-left">Prodotto</th>
|
||||
<th class="border px-2 py-2 text-left">Seriali</th>
|
||||
<th class="border px-2 py-2 text-left">Stato</th>
|
||||
<th class="border px-2 py-2 text-left">Fornitore</th>
|
||||
<th class="border px-2 py-2 text-left">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($this->rows as $scheda)
|
||||
<tr class="{{ $scheda->row_classes }}">
|
||||
<td class="border px-2 py-2 align-top">
|
||||
<div class="font-medium text-slate-900">{{ $scheda->display_title }}</div>
|
||||
<div class="text-[11px] text-slate-500">Ingresso {{ optional($scheda->date_received)->format('d/m/Y') ?: '-' }}</div>
|
||||
</td>
|
||||
<td class="border px-2 py-2 align-top">
|
||||
<div>{{ $scheda->customer_name ?: '-' }}</div>
|
||||
<div class="text-[11px] text-slate-500">{{ $scheda->customer_phone ?: ($scheda->customer_email ?: 'recapito non presente') }}</div>
|
||||
</td>
|
||||
<td class="border px-2 py-2 align-top">
|
||||
<div>{{ $scheda->product_model ?: '-' }}</div>
|
||||
<div class="text-[11px] text-slate-500">{{ $scheda->product_code ?: 'codice non presente' }}</div>
|
||||
</td>
|
||||
<td class="border px-2 py-2 align-top">{{ $scheda->serial_label }}</td>
|
||||
<td class="border px-2 py-2 align-top">
|
||||
<span class="inline-flex rounded-full border px-2 py-1 text-[11px] font-medium {{ $scheda->status_badge_classes }}">{{ $scheda->status_label ?: 'Stato non indicato' }}</span>
|
||||
</td>
|
||||
<td class="border px-2 py-2 align-top">
|
||||
@if($scheda->fornitore)
|
||||
<div class="font-medium">{{ $scheda->fornitore->ragione_sociale ?: ('Fornitore #' . $scheda->fornitore->id) }}</div>
|
||||
@if($scheda->fornitore->is_tecnorepair_primary)
|
||||
<div class="text-[11px] text-emerald-700">Centro primario</div>
|
||||
@endif
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="border px-2 py-2 align-top">
|
||||
<button type="button" wire:click="apriScheda({{ (int) $scheda->id }})" class="inline-flex items-center rounded-md bg-slate-800 px-2 py-1 text-[11px] font-medium text-white hover:bg-slate-700">Scheda</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="border px-2 py-6 text-center text-sm text-slate-500">Nessuna scheda legacy importata. Usa il comando indicato sopra per caricare l'archivio TecnoRepair.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border bg-white p-4">
|
||||
@php($selected = $this->selectedScheda)
|
||||
@if(! $selected)
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Seleziona una riga dall'elenco per aprire la scheda tecnica legacy con cliente, seriali, allegati e note operative.
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-slate-900">{{ $selected->display_title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">Importata da {{ $selected->imported_from_path ?: 'sorgente sconosciuta' }}</div>
|
||||
</div>
|
||||
<span class="inline-flex rounded-full border px-2 py-1 text-[11px] font-medium {{ $selected->status_badge_classes }}">{{ $selected->status_label ?: 'Stato non indicato' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border bg-slate-50 p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Cliente</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">{{ $selected->customer_name ?: '-' }}</div>
|
||||
<div class="mt-1 text-xs text-slate-600">{{ $selected->customer_phone ?: '-' }}</div>
|
||||
<div class="text-xs text-slate-600">{{ $selected->customer_phone_alt ?: '-' }}</div>
|
||||
<div class="text-xs text-slate-600">{{ $selected->customer_email ?: '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-slate-50 p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Prodotto</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">{{ $selected->product_model ?: '-' }}</div>
|
||||
<div class="mt-1 text-xs text-slate-600">Codice {{ $selected->product_code ?: '-' }}</div>
|
||||
<div class="text-xs text-slate-600">Seriali {{ $selected->serial_label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg border bg-white p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Difetto segnalato</div>
|
||||
<div class="mt-1 whitespace-pre-wrap text-sm text-slate-800">{{ $selected->defect_reported ?: 'Non valorizzato' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg border bg-white p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Riparazione / comunicazioni</div>
|
||||
<div class="mt-1 whitespace-pre-wrap text-sm text-slate-800">{{ $selected->repair_description ?: 'Nessuna descrizione riparazione' }}</div>
|
||||
@if($selected->communications)
|
||||
<div class="mt-3 whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700">{{ $selected->communications }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg border bg-white p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Fornitore e allegati</div>
|
||||
<div class="mt-1 text-sm text-slate-800">
|
||||
@if($selected->fornitore)
|
||||
@php($fornitoreUrl = $this->getFornitoreUrl((int) $selected->fornitore->id))
|
||||
@if($fornitoreUrl)
|
||||
<a href="{{ $fornitoreUrl }}" class="font-medium text-sky-700 hover:text-sky-600">{{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }}</a>
|
||||
@else
|
||||
{{ $selected->fornitore->ragione_sociale ?: ('Fornitore #' . $selected->fornitore->id) }}
|
||||
@endif
|
||||
@else
|
||||
Nessun fornitore collegato
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
@forelse($selected->allegati as $allegato)
|
||||
<div class="rounded-md border bg-slate-50 px-3 py-2 text-xs text-slate-700">
|
||||
<div class="font-medium">{{ $allegato->file_name ?: 'Allegato legacy' }}</div>
|
||||
<div class="text-slate-500">{{ $allegato->file_path ?: 'Percorso non disponibile' }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-xs text-slate-500">Nessun allegato legacy indicizzato.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
|
@ -132,9 +132,71 @@
|
|||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@php($ticketCallContext = $this->ticketCallContext)
|
||||
@if(!empty($ticketCallContext['phone']) || !empty($ticketCallContext['caller_name']) || !empty($ticketCallContext['target_extension']))
|
||||
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">
|
||||
<div class="font-semibold uppercase tracking-wide">Contesto chiamata</div>
|
||||
<div class="mt-2 grid gap-2 md:grid-cols-2">
|
||||
@if(!empty($ticketCallContext['phone']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Numero chiamante</div>
|
||||
<div class="text-sm font-semibold">{{ $ticketCallContext['phone'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['target_extension']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Interno o gruppo chiamato</div>
|
||||
<div class="text-sm font-semibold">{{ $ticketCallContext['target_extension'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['received_at']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Ricevuta alle</div>
|
||||
<div>{{ $ticketCallContext['received_at'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['caller_name']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Contatto associato</div>
|
||||
<div>{{ $ticketCallContext['caller_name'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['caller_profile']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Profilo</div>
|
||||
<div>{{ $ticketCallContext['caller_profile'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['riferimento_stabile']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento stabile</div>
|
||||
<div>{{ $ticketCallContext['riferimento_stabile'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['riferimento_unita']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento unità</div>
|
||||
<div>{{ $ticketCallContext['riferimento_unita'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($ticketCallContext['stabile_label']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Stabile collegato</div>
|
||||
<div>{{ $ticketCallContext['stabile_label'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($ticketCallContext['call_note']))
|
||||
<div class="mt-2 rounded-md bg-white/70 px-3 py-2 text-[11px] text-emerald-800">
|
||||
<div class="font-semibold">Nota operativa importata</div>
|
||||
<div class="mt-1 whitespace-pre-wrap">{{ $ticketCallContext['call_note'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label class="block text-sm md:col-span-2">
|
||||
<span class="mb-1 block font-medium">Titolo</span>
|
||||
<span class="mb-1 block font-medium">Titolo operativo</span>
|
||||
<input type="text" wire:model.defer="newTicketTitolo" class="w-full rounded-lg border-gray-300" placeholder="Es. Guasto ascensore scala B" />
|
||||
</label>
|
||||
|
||||
|
|
@ -174,7 +236,7 @@
|
|||
|
||||
<label class="block text-sm md:col-span-2">
|
||||
<span class="mb-1 block font-medium">Descrizione</span>
|
||||
<textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema..." ></textarea>
|
||||
<textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema senza ripetere i dati tecnici della chiamata..." ></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block text-sm md:col-span-2">
|
||||
|
|
|
|||
|
|
@ -31,5 +31,19 @@
|
|||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mx-3 flex w-full max-w-xl items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 shadow-sm">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-700">CTI live</div>
|
||||
<div class="truncate text-sm font-medium text-slate-900">In attesa di chiamate recenti</div>
|
||||
<div class="truncate text-xs text-slate-600">Il box operativo si apre automaticamente quando arriva una inbound valida.</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<a href="{{ \App\Filament\Pages\Strumenti\PostItGestione::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100">
|
||||
Log chiamate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
10
scripts/ops/windows/bootstrap-netgescon-panasonic-live.cmd
Normal file
10
scripts/ops/windows/bootstrap-netgescon-panasonic-live.cmd
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
@echo off
|
||||
setlocal
|
||||
|
||||
rem Launcher unico Windows per setup/test/status/log/stop del bridge Panasonic live.
|
||||
rem Esempi:
|
||||
rem bootstrap-netgescon-panasonic-live.cmd
|
||||
rem bootstrap-netgescon-panasonic-live.cmd -Action status
|
||||
rem bootstrap-netgescon-panasonic-live.cmd -Action log
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0bootstrap-netgescon-panasonic-live.ps1" %*
|
||||
263
scripts/ops/windows/bootstrap-netgescon-panasonic-live.ps1
Normal file
263
scripts/ops/windows/bootstrap-netgescon-panasonic-live.ps1
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<#
|
||||
Bootstrap operativo unico per il bridge Panasonic live su Windows.
|
||||
|
||||
Comandi supportati:
|
||||
- setup : imposta token, pubblica runtime, testa HTTP, installa task e lo avvia
|
||||
- test : esegue solo lo smoke test HTTP verso staging
|
||||
- status : mostra token, cartelle, stato task e ultime righe log
|
||||
- log : segue il log live in tail -wait
|
||||
- stop : ferma il task schedulato
|
||||
|
||||
Uso rapido:
|
||||
.\bootstrap-netgescon-panasonic-live.ps1
|
||||
.\bootstrap-netgescon-panasonic-live.ps1 -Action status
|
||||
#>
|
||||
|
||||
param(
|
||||
[ValidateSet('setup', 'test', 'status', 'log', 'stop', 'dry-run', 'live-run')]
|
||||
[string]$Action = 'setup',
|
||||
[string]$BaseUrl = 'https://staging.netgescon.it',
|
||||
[string]$Token = 'netgescon-cti-test-20260311',
|
||||
[string]$Extensions = '201-208,601,603,0001,0003',
|
||||
[string]$TaskName = 'NetGescon Panasonic Live Bridge',
|
||||
[string]$RuntimeDir = 'C:\ProgramData\NetGescon\panasonic-live\current',
|
||||
[string]$LogFile = '',
|
||||
[string]$InteropOutputDir = '',
|
||||
[switch]$SkipHttpTest
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('=== {0} ===' -f $Message)
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([string]$Path)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ResolvedLogFile {
|
||||
if (-not [string]::IsNullOrWhiteSpace($LogFile)) {
|
||||
return $LogFile
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
return (Join-Path $env:LOCALAPPDATA 'NetGescon\Logs\netgescon-tapi-dotnet-events-live.log')
|
||||
}
|
||||
|
||||
return 'C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log'
|
||||
}
|
||||
|
||||
function Get-ResolvedInteropDir {
|
||||
if (-not [string]::IsNullOrWhiteSpace($InteropOutputDir)) {
|
||||
return $InteropOutputDir
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
return (Join-Path $env:LOCALAPPDATA 'NetGescon\tapi-interop')
|
||||
}
|
||||
|
||||
return 'C:\ProgramData\NetGescon\tapi-interop'
|
||||
}
|
||||
|
||||
function Get-RepoWindowsOpsRoot {
|
||||
$scriptRoot = $PSScriptRoot
|
||||
$publishScript = Join-Path $scriptRoot 'publish-netgescon-panasonic-runtime.ps1'
|
||||
if (Test-Path $publishScript) {
|
||||
return $scriptRoot
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $RuntimeDir 'publish-netgescon-panasonic-runtime.ps1')) {
|
||||
return $RuntimeDir
|
||||
}
|
||||
|
||||
throw 'Script publish-netgescon-panasonic-runtime.ps1 non trovato. Lancia il bootstrap dalla cartella scripts\ops\windows del repo.'
|
||||
}
|
||||
|
||||
function Publish-Runtime {
|
||||
param([string]$SourceRoot)
|
||||
|
||||
$publishScript = Join-Path $SourceRoot 'publish-netgescon-panasonic-runtime.ps1'
|
||||
if (-not (Test-Path $publishScript)) {
|
||||
throw "Script publish non trovato: $publishScript"
|
||||
}
|
||||
|
||||
Write-Step 'Publish runtime locale'
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $publishScript -TargetRoot $RuntimeDir
|
||||
}
|
||||
|
||||
function Test-HttpBridge {
|
||||
$testScript = Join-Path $RuntimeDir 'test-netgescon-panasonic-api.ps1'
|
||||
if (-not (Test-Path $testScript)) {
|
||||
throw "Script test non trovato: $testScript"
|
||||
}
|
||||
|
||||
Write-Step 'Smoke test HTTP verso staging'
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testScript -BaseUrl $BaseUrl -Token $Token -Extension '205'
|
||||
}
|
||||
|
||||
function Install-Task {
|
||||
$installCmd = Join-Path $RuntimeDir 'install-netgescon-panasonic-live-task.cmd'
|
||||
if (-not (Test-Path $installCmd)) {
|
||||
throw "Launcher task non trovato: $installCmd"
|
||||
}
|
||||
|
||||
Write-Step 'Installazione task'
|
||||
& $installCmd -Mode current-user-logon -BaseUrl $BaseUrl -Extensions $Extensions -LogFile $script:ResolvedLogFile -InteropOutputDir $script:ResolvedInteropDir
|
||||
}
|
||||
|
||||
function Start-BridgeTask {
|
||||
Write-Step 'Avvio task'
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List LastRunTime, LastTaskResult, NextRunTime, TaskName
|
||||
}
|
||||
|
||||
function Stop-BridgeTask {
|
||||
Write-Step 'Stop task'
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List LastRunTime, LastTaskResult, NextRunTime, TaskName
|
||||
}
|
||||
|
||||
function Show-Status {
|
||||
Write-Step 'Stato bridge'
|
||||
Write-Host ('BaseUrl : {0}' -f $BaseUrl)
|
||||
Write-Host ('RuntimeDir : {0}' -f $RuntimeDir)
|
||||
Write-Host ('LogFile : {0}' -f $script:ResolvedLogFile)
|
||||
Write-Host ('InteropDir : {0}' -f $script:ResolvedInteropDir)
|
||||
Write-Host ('Token user : {0}' -f ([Environment]::GetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', 'User')))
|
||||
|
||||
try {
|
||||
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns, TaskName
|
||||
}
|
||||
catch {
|
||||
Write-Host ('Task non trovato: {0}' -f $TaskName)
|
||||
}
|
||||
|
||||
if (Test-Path $script:ResolvedLogFile) {
|
||||
Write-Host ''
|
||||
Write-Host 'Ultime 30 righe log:'
|
||||
Get-Content $script:ResolvedLogFile -Tail 30
|
||||
}
|
||||
else {
|
||||
Write-Host ''
|
||||
Write-Host 'Log non ancora presente.'
|
||||
}
|
||||
}
|
||||
|
||||
function Follow-Log {
|
||||
Write-Step 'Follow log live'
|
||||
Ensure-Directory -Path (Split-Path -Parent $script:ResolvedLogFile)
|
||||
if (-not (Test-Path $script:ResolvedLogFile)) {
|
||||
New-Item -ItemType File -Path $script:ResolvedLogFile -Force | Out-Null
|
||||
}
|
||||
|
||||
Get-Content $script:ResolvedLogFile -Tail 100 -Wait
|
||||
}
|
||||
|
||||
function Start-DryRunWatcher {
|
||||
$watchScript = Join-Path $RuntimeDir 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
|
||||
if (-not (Test-Path $watchScript)) {
|
||||
throw "Watcher non trovato: $watchScript"
|
||||
}
|
||||
|
||||
Write-Step 'Dry-run watcher Panasonic'
|
||||
Write-Host ('Runtime script: {0}' -f $watchScript)
|
||||
Write-Host ('Log file : {0}' -f $script:ResolvedLogFile)
|
||||
Write-Host ('Interop dir : {0}' -f $script:ResolvedInteropDir)
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchScript `
|
||||
-Extensions $Extensions `
|
||||
-BridgeMode 'dry-run' `
|
||||
-LogFile $script:ResolvedLogFile `
|
||||
-InteropOutputDir $script:ResolvedInteropDir `
|
||||
-IncludeDiagnosticEvents
|
||||
}
|
||||
|
||||
function Start-LiveWatcher {
|
||||
$watchScript = Join-Path $RuntimeDir 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
|
||||
if (-not (Test-Path $watchScript)) {
|
||||
throw "Watcher non trovato: $watchScript"
|
||||
}
|
||||
|
||||
Write-Step 'Live watcher Panasonic'
|
||||
Write-Host ('Runtime script: {0}' -f $watchScript)
|
||||
Write-Host ('BaseUrl : {0}' -f $BaseUrl)
|
||||
Write-Host ('Log file : {0}' -f $script:ResolvedLogFile)
|
||||
Write-Host ('Interop dir : {0}' -f $script:ResolvedInteropDir)
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchScript `
|
||||
-Extensions $Extensions `
|
||||
-BridgeMode 'live' `
|
||||
-BaseUrl $BaseUrl `
|
||||
-Token $Token `
|
||||
-LogFile $script:ResolvedLogFile `
|
||||
-InteropOutputDir $script:ResolvedInteropDir
|
||||
}
|
||||
|
||||
$script:ResolvedLogFile = Get-ResolvedLogFile
|
||||
$script:ResolvedInteropDir = Get-ResolvedInteropDir
|
||||
|
||||
Ensure-Directory -Path (Split-Path -Parent $script:ResolvedLogFile)
|
||||
Ensure-Directory -Path $script:ResolvedInteropDir
|
||||
|
||||
switch ($Action) {
|
||||
'setup' {
|
||||
Write-Step 'Impostazione token utente'
|
||||
[Environment]::SetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', $Token, 'User')
|
||||
Write-Host ('Token utente impostato: {0}' -f ([Environment]::GetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', 'User')))
|
||||
|
||||
$sourceRoot = Get-RepoWindowsOpsRoot
|
||||
Publish-Runtime -SourceRoot $sourceRoot
|
||||
|
||||
if (-not $SkipHttpTest.IsPresent) {
|
||||
Test-HttpBridge
|
||||
}
|
||||
|
||||
Install-Task
|
||||
Start-BridgeTask
|
||||
Show-Status
|
||||
break
|
||||
}
|
||||
'test' {
|
||||
Test-HttpBridge
|
||||
break
|
||||
}
|
||||
'status' {
|
||||
Show-Status
|
||||
break
|
||||
}
|
||||
'log' {
|
||||
Follow-Log
|
||||
break
|
||||
}
|
||||
'stop' {
|
||||
Stop-BridgeTask
|
||||
break
|
||||
}
|
||||
'dry-run' {
|
||||
$sourceRoot = Get-RepoWindowsOpsRoot
|
||||
Publish-Runtime -SourceRoot $sourceRoot
|
||||
Start-DryRunWatcher
|
||||
break
|
||||
}
|
||||
'live-run' {
|
||||
[Environment]::SetEnvironmentVariable('NETGESCON_CTI_SHARED_TOKEN', $Token, 'User')
|
||||
$sourceRoot = Get-RepoWindowsOpsRoot
|
||||
Publish-Runtime -SourceRoot $sourceRoot
|
||||
Start-LiveWatcher
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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" %*
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
74
scripts/ops/windows/publish-netgescon-panasonic-runtime.ps1
Normal file
74
scripts/ops/windows/publish-netgescon-panasonic-runtime.ps1
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<#
|
||||
Copia i file runtime Panasonic/TAPI in una cartella locale stabile Windows.
|
||||
|
||||
Uso tipico:
|
||||
1. tieni il repo dove vuoi anche temporaneamente
|
||||
2. lancia questo script
|
||||
3. installa e avvia il bridge dalla cartella runtime locale pubblicata
|
||||
|
||||
Vantaggio:
|
||||
- il task non dipende da S:
|
||||
- se aggiorni il repo, rilanci questo script e riallinei il runtime locale
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$TargetRoot = 'C:\ProgramData\NetGescon\panasonic-live\current',
|
||||
[switch]$IncludeDocs
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Ensure-Directory {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$sourceRoot = $PSScriptRoot
|
||||
if (-not (Test-Path $sourceRoot)) {
|
||||
throw "Cartella sorgente non trovata: $sourceRoot"
|
||||
}
|
||||
|
||||
Ensure-Directory -Path $TargetRoot
|
||||
|
||||
$files = @(
|
||||
'start-netgescon-panasonic-live.ps1',
|
||||
'start-netgescon-panasonic-live.cmd',
|
||||
'install-netgescon-panasonic-live-task.ps1',
|
||||
'install-netgescon-panasonic-live-task.cmd',
|
||||
'watch-netgescon-panasonic-tapi-dotnet-events.ps1',
|
||||
'watch-netgescon-panasonic-tapi-com-events.ps1',
|
||||
'poll-netgescon-panasonic-active-calls.ps1',
|
||||
'get-netgescon-panasonic-tapi-info.ps1',
|
||||
'inspect-netgescon-panasonic-address.ps1',
|
||||
'inspect-netgescon-tapi-interop.ps1',
|
||||
'test-netgescon-panasonic-api.ps1',
|
||||
'test-netgescon-panasonic-register-notifications.ps1'
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
$sourcePath = Join-Path $sourceRoot $file
|
||||
if (Test-Path $sourcePath) {
|
||||
Copy-Item -Path $sourcePath -Destination (Join-Path $TargetRoot $file) -Force
|
||||
Write-Host ("COPIATO {0}" -f $file)
|
||||
}
|
||||
}
|
||||
|
||||
if ($IncludeDocs.IsPresent) {
|
||||
$docsRoot = Split-Path -Path $sourceRoot -Parent | Split-Path -Parent | Split-Path -Parent
|
||||
$docPath = Join-Path $docsRoot 'docs\CTI-WINDOWS-TAPI-PROBE.md'
|
||||
if (Test-Path $docPath) {
|
||||
Copy-Item -Path $docPath -Destination (Join-Path $TargetRoot 'CTI-WINDOWS-TAPI-PROBE.md') -Force
|
||||
Write-Host 'COPIATO CTI-WINDOWS-TAPI-PROBE.md'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('Runtime pubblicato in: {0}' -f $TargetRoot)
|
||||
Write-Host 'Avvio manuale consigliato:'
|
||||
Write-Host (' {0}' -f (Join-Path $TargetRoot 'start-netgescon-panasonic-live.cmd'))
|
||||
Write-Host 'Installazione task consigliata:'
|
||||
Write-Host (' {0} -Mode current-user-logon' -f (Join-Path $TargetRoot 'install-netgescon-panasonic-live-task.cmd'))
|
||||
7
scripts/ops/windows/start-netgescon-panasonic-live.cmd
Normal file
7
scripts/ops/windows/start-netgescon-panasonic-live.cmd
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@echo off
|
||||
setlocal
|
||||
|
||||
rem Wrapper Windows: evita il blocco ExecutionPolicy quando vuoi lanciare il bridge a mano.
|
||||
rem Usa sempre il .ps1 nella stessa cartella del file .cmd.
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-netgescon-panasonic-live.ps1" %*
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ function Convert-ComCollectionToArray {
|
|||
catch {
|
||||
}
|
||||
|
||||
return $items
|
||||
return ,$items
|
||||
}
|
||||
|
||||
function Normalize-ExtensionToken {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,37 +432,11 @@ 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)) {
|
||||
$addressMatch = Find-AddressWatchMatch -Values @($rawDial, $rawName) -WatchList $WatchList
|
||||
if ($null -ne $addressMatch) {
|
||||
return $true
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if ($dial -eq $token -or $name -eq $token) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
|
|
@ -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 = ''
|
||||
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
|
||||
}
|
||||
elseif ($connectedToken -ne '' -and ($WatchList -contains $connectedToken) -and $callerToken -eq '') {
|
||||
$calledExtension = $connectedToken
|
||||
if ($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
|
||||
$addressCategory = Get-TokenCategory -Value $callerIdAddress
|
||||
}
|
||||
elseif ($watchedExtension -eq '' -and $connectedToken -ne '' -and ($WatchList -contains $connectedToken)) {
|
||||
}
|
||||
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 (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
|
||||
|
|
@ -891,6 +1039,9 @@ function Get-CallSnapshot {
|
|||
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
|
||||
|
|
@ -900,9 +1051,9 @@ function Build-IncomingPayload {
|
|||
called_extension = $CallRecord.CalledExtension
|
||||
calling_extension = $CallRecord.CallingExtension
|
||||
event_type = $CallRecord.LastEventType
|
||||
note = 'bridge dry-run da watcher TAPI .NET'
|
||||
note = $bridgeNote
|
||||
called_at = $CallRecord.StartedAt.ToString('o')
|
||||
is_test = $true
|
||||
is_test = $isTest
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -915,6 +1066,9 @@ 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
|
||||
|
|
@ -925,8 +1079,8 @@ function Build-CallEndedPayload {
|
|||
called_extension = $CallRecord.CalledExtension
|
||||
calling_extension = $CallRecord.CallingExtension
|
||||
event_type = $CallRecord.LastEventType
|
||||
note = 'bridge dry-run da watcher TAPI .NET'
|
||||
is_test = $true
|
||||
note = $bridgeNote
|
||||
is_test = $isTest
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1307,7 +1461,7 @@ 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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user