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.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- Initial open-source release prep.
|
- Initial open-source release prep.
|
||||||
- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea.
|
- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea.
|
||||||
- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`.
|
- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`.
|
||||||
|
- Hardened Panasonic Windows live bridge scripts, runtime publish flow, and TAPI diagnostics for local-path deployment and aggregated Panasonic addresses.
|
||||||
|
- Added live-call CRM improvements in Ticket Mobile and Post-it Gestione: cleaner call context, operator note before conversion, clearer SMDR/CSTA labels, and line/group filtering.
|
||||||
|
- Extended PBX day0 preset coverage to include main/admin lines `0001` and `0003`, with aligned studio watchlist for broader staging monitoring.
|
||||||
|
- Documented current CTI staging status and Windows restart procedure so the deployment trail remains in-repo even if the active chat is interrupted.
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-17
|
## [0.1.0] - 2026-01-17
|
||||||
|
|
||||||
- Docker production compose and update workflow.
|
- Docker production compose and update workflow.
|
||||||
- Filament documentation baseline.
|
- Filament documentation baseline.
|
||||||
|
|
|
||||||
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
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
|
|
@ -25,7 +24,7 @@ public function handle(): int
|
||||||
|
|
||||||
$apply = (bool) $this->option('apply');
|
$apply = (bool) $this->option('apply');
|
||||||
$limit = max(1, min((int) $this->option('limit'), 50000));
|
$limit = max(1, min((int) $this->option('limit'), 50000));
|
||||||
$ids = array_values(array_filter(array_map(fn($value) => is_numeric($value) ? (int) $value : 0, (array) $this->option('id'))));
|
$ids = array_values(array_filter(array_map(fn($value) => is_numeric($value) ? (int) $value : 0, (array) $this->option('id'))));
|
||||||
|
|
||||||
$query = RubricaUniversale::query()
|
$query = RubricaUniversale::query()
|
||||||
->where('note', 'like', '%Dati anagrafici legacy:%')
|
->where('note', 'like', '%Dati anagrafici legacy:%')
|
||||||
|
|
@ -43,11 +42,11 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$processed = 0;
|
$processed = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
|
|
||||||
foreach ($rows as $rubrica) {
|
foreach ($rows as $rubrica) {
|
||||||
$processed++;
|
$processed++;
|
||||||
$legacy = $this->extractLegacyIdentityFromNote($rubrica->note);
|
$legacy = $this->extractLegacyIdentityFromNote($rubrica->note);
|
||||||
$changes = [];
|
$changes = [];
|
||||||
|
|
||||||
if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) {
|
if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) {
|
||||||
|
|
@ -94,20 +93,20 @@ private function extractLegacyIdentityFromNote(mixed $note): array
|
||||||
$raw = trim((string) ($note ?? ''));
|
$raw = trim((string) ($note ?? ''));
|
||||||
if ($raw === '') {
|
if ($raw === '') {
|
||||||
return [
|
return [
|
||||||
'note_clean' => null,
|
'note_clean' => null,
|
||||||
'titolo_id' => null,
|
'titolo_id' => null,
|
||||||
'sesso' => null,
|
'sesso' => null,
|
||||||
'data_nascita' => null,
|
'data_nascita' => null,
|
||||||
'luogo_nascita' => null,
|
'luogo_nascita' => null,
|
||||||
'provincia_nascita' => null,
|
'provincia_nascita' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$parsed = [
|
$parsed = [
|
||||||
'titolo_id' => null,
|
'titolo_id' => null,
|
||||||
'sesso' => null,
|
'sesso' => null,
|
||||||
'data_nascita' => null,
|
'data_nascita' => null,
|
||||||
'luogo_nascita' => null,
|
'luogo_nascita' => null,
|
||||||
'provincia_nascita' => null,
|
'provincia_nascita' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -122,11 +121,11 @@ private function extractLegacyIdentityFromNote(mixed $note): array
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:')));
|
$payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:')));
|
||||||
$bits = preg_split('/\s*\|\s*/', $payload) ?: [];
|
$bits = preg_split('/\s*\|\s*/', $payload) ?: [];
|
||||||
foreach ($bits as $bit) {
|
foreach ($bits as $bit) {
|
||||||
[$key, $value] = array_pad(explode(':', $bit, 2), 2, null);
|
[$key, $value] = array_pad(explode(':', $bit, 2), 2, null);
|
||||||
$key = mb_strtolower(trim((string) $key));
|
$key = mb_strtolower(trim((string) $key));
|
||||||
$value = trim((string) $value);
|
$value = trim((string) $value);
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +181,7 @@ private function normalizeLegacyDateValue(mixed $value): ?string
|
||||||
|
|
||||||
private function normalizeBirthDateYear(\DateTime $date): ?string
|
private function normalizeBirthDateYear(\DateTime $date): ?string
|
||||||
{
|
{
|
||||||
$year = (int) $date->format('Y');
|
$year = (int) $date->format('Y');
|
||||||
$currentYear = (int) now()->format('Y');
|
$currentYear = (int) now()->format('Y');
|
||||||
|
|
||||||
if ($year > $currentYear + 1) {
|
if ($year > $currentYear + 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Persona;
|
use App\Models\Persona;
|
||||||
|
|
@ -74,12 +73,12 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'processed' => 0,
|
'processed' => 0,
|
||||||
'skipped' => 0,
|
'skipped' => 0,
|
||||||
'persona_created' => 0,
|
'persona_created' => 0,
|
||||||
'persona_updated' => 0,
|
'persona_updated' => 0,
|
||||||
'rel_created' => 0,
|
'rel_created' => 0,
|
||||||
'rel_updated' => 0,
|
'rel_updated' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
|
@ -118,14 +117,14 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$relationPayload = [
|
$relationPayload = [
|
||||||
'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null),
|
'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null),
|
||||||
'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01',
|
'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01',
|
||||||
'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null),
|
'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null),
|
||||||
'attivo' => $this->isRelationActive($row->data_fine ?? null),
|
'attivo' => $this->isRelationActive($row->data_fine ?? null),
|
||||||
'riceve_comunicazioni' => true,
|
'riceve_comunicazioni' => true,
|
||||||
'riceve_convocazioni' => $ruoloRate === 'C',
|
'riceve_convocazioni' => $ruoloRate === 'C',
|
||||||
'vota_assemblea' => $ruoloRate === 'C',
|
'vota_assemblea' => $ruoloRate === 'C',
|
||||||
'note_relazione' => $this->buildRelationNote($row, $tipoRelazione),
|
'note_relazione' => $this->buildRelationNote($row, $tipoRelazione),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) {
|
if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) {
|
||||||
|
|
@ -147,20 +146,20 @@ public function handle(): int
|
||||||
$stats['rel_updated']++;
|
$stats['rel_updated']++;
|
||||||
} else {
|
} else {
|
||||||
DB::table('persone_unita_relazioni')->insert([
|
DB::table('persone_unita_relazioni')->insert([
|
||||||
'persona_id' => (int) $persona->id,
|
'persona_id' => (int) $persona->id,
|
||||||
'unita_id' => (int) $row->unita_immobiliare_id,
|
'unita_id' => (int) $row->unita_immobiliare_id,
|
||||||
'tipo_relazione' => $tipoRelazione,
|
'tipo_relazione' => $tipoRelazione,
|
||||||
'quota_relazione' => $relationPayload['quota_relazione'],
|
'quota_relazione' => $relationPayload['quota_relazione'],
|
||||||
'data_inizio' => $relationPayload['data_inizio'],
|
'data_inizio' => $relationPayload['data_inizio'],
|
||||||
'data_fine' => $relationPayload['data_fine'],
|
'data_fine' => $relationPayload['data_fine'],
|
||||||
'attivo' => $relationPayload['attivo'],
|
'attivo' => $relationPayload['attivo'],
|
||||||
'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'],
|
'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'],
|
||||||
'riceve_convocazioni' => $relationPayload['riceve_convocazioni'],
|
'riceve_convocazioni' => $relationPayload['riceve_convocazioni'],
|
||||||
'vota_assemblea' => $relationPayload['vota_assemblea'],
|
'vota_assemblea' => $relationPayload['vota_assemblea'],
|
||||||
'note_relazione' => $relationPayload['note_relazione'],
|
'note_relazione' => $relationPayload['note_relazione'],
|
||||||
'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null,
|
'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
$stats['rel_created']++;
|
$stats['rel_created']++;
|
||||||
}
|
}
|
||||||
|
|
@ -185,10 +184,10 @@ private function findMatchingRubrica(object $row, string $tipoRelazione): ?Rubri
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino';
|
$role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino';
|
||||||
$codCond = trim((string) ($row->legacy_cond_id ?? ''));
|
$codCond = trim((string) ($row->legacy_cond_id ?? ''));
|
||||||
$codStabile = trim((string) ($row->codice_stabile ?? ''));
|
$codStabile = trim((string) ($row->codice_stabile ?? ''));
|
||||||
$adminId = (int) ($row->amministratore_id ?? 0);
|
$adminId = (int) ($row->amministratore_id ?? 0);
|
||||||
|
|
||||||
if ($codCond !== '' && $codStabile !== '') {
|
if ($codCond !== '' && $codStabile !== '') {
|
||||||
$stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')])));
|
$stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')])));
|
||||||
|
|
@ -228,7 +227,7 @@ private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica):
|
||||||
{
|
{
|
||||||
if ($rubrica) {
|
if ($rubrica) {
|
||||||
$ragione = $this->sanitizeString($rubrica->ragione_sociale, 255);
|
$ragione = $this->sanitizeString($rubrica->ragione_sociale, 255);
|
||||||
$nome = $this->sanitizeString($rubrica->nome, 100);
|
$nome = $this->sanitizeString($rubrica->nome, 100);
|
||||||
$cognome = $this->sanitizeString($rubrica->cognome, 100);
|
$cognome = $this->sanitizeString($rubrica->cognome, 100);
|
||||||
} else {
|
} else {
|
||||||
[$cognome, $nome, $ragione] = $this->splitNomeCognomeOrRagione((string) ($row->nominativo ?? ''));
|
[$cognome, $nome, $ragione] = $this->splitNomeCognomeOrRagione((string) ($row->nominativo ?? ''));
|
||||||
|
|
@ -245,23 +244,23 @@ private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica):
|
||||||
}
|
}
|
||||||
|
|
||||||
$rawFiscal = $rubrica?->codice_fiscale ?: $rubrica?->partita_iva;
|
$rawFiscal = $rubrica?->codice_fiscale ?: $rubrica?->partita_iva;
|
||||||
$fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null);
|
$fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tipologia' => $ragione ? 'giuridica' : 'fisica',
|
'tipologia' => $ragione ? 'giuridica' : 'fisica',
|
||||||
'nome' => $nome,
|
'nome' => $nome,
|
||||||
'cognome' => $cognome,
|
'cognome' => $cognome,
|
||||||
'ragione_sociale' => $ragione,
|
'ragione_sociale' => $ragione,
|
||||||
'codice_fiscale' => $fiscal['cf'],
|
'codice_fiscale' => $fiscal['cf'],
|
||||||
'partita_iva' => $fiscal['piva'],
|
'partita_iva' => $fiscal['piva'],
|
||||||
'email_principale' => $this->sanitizeEmail($rubrica?->email),
|
'email_principale' => $this->sanitizeEmail($rubrica?->email),
|
||||||
'email_pec' => $this->sanitizeEmail($rubrica?->pec),
|
'email_pec' => $this->sanitizeEmail($rubrica?->pec),
|
||||||
'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio),
|
'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio),
|
||||||
'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio),
|
'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio),
|
||||||
'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255),
|
'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255),
|
||||||
'note' => $this->buildPersonaNote($row, $rubrica),
|
'note' => $this->buildPersonaNote($row, $rubrica),
|
||||||
'attivo' => true,
|
'attivo' => true,
|
||||||
'name_key' => $this->normalizeNameKey($nome, $cognome),
|
'name_key' => $this->normalizeNameKey($nome, $cognome),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,19 +318,19 @@ private function resolvePersonaMatch(array $payload): ?object
|
||||||
private function createPersonaFromPayload(array $payload): ?Persona
|
private function createPersonaFromPayload(array $payload): ?Persona
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'tipologia' => $payload['tipologia'] ?? 'fisica',
|
'tipologia' => $payload['tipologia'] ?? 'fisica',
|
||||||
'cognome' => $payload['cognome'],
|
'cognome' => $payload['cognome'],
|
||||||
'nome' => $payload['nome'],
|
'nome' => $payload['nome'],
|
||||||
'ragione_sociale' => $payload['ragione_sociale'],
|
'ragione_sociale' => $payload['ragione_sociale'],
|
||||||
'codice_fiscale' => $payload['codice_fiscale'],
|
'codice_fiscale' => $payload['codice_fiscale'],
|
||||||
'partita_iva' => $payload['partita_iva'],
|
'partita_iva' => $payload['partita_iva'],
|
||||||
'residenza_via' => $payload['residenza_via'],
|
'residenza_via' => $payload['residenza_via'],
|
||||||
'telefono_principale' => $payload['telefono_principale'],
|
'telefono_principale' => $payload['telefono_principale'],
|
||||||
'telefono_secondario' => $payload['telefono_secondario'],
|
'telefono_secondario' => $payload['telefono_secondario'],
|
||||||
'email_principale' => $payload['email_principale'],
|
'email_principale' => $payload['email_principale'],
|
||||||
'email_pec' => $payload['email_pec'],
|
'email_pec' => $payload['email_pec'],
|
||||||
'note' => $payload['note'],
|
'note' => $payload['note'],
|
||||||
'attivo' => $payload['attivo'] ?? true,
|
'attivo' => $payload['attivo'] ?? true,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($data['telefono_principale'] && DB::table('persone')->where('telefono_principale', $data['telefono_principale'])->exists()) {
|
if ($data['telefono_principale'] && DB::table('persone')->where('telefono_principale', $data['telefono_principale'])->exists()) {
|
||||||
|
|
@ -559,7 +558,7 @@ private function splitNomeCognomeOrRagione(?string $raw): array
|
||||||
|
|
||||||
$parts = array_values(array_filter(preg_split('/\s+/', $value) ?: []));
|
$parts = array_values(array_filter(preg_split('/\s+/', $value) ?: []));
|
||||||
if (count($parts) >= 2) {
|
if (count($parts) >= 2) {
|
||||||
$nome = array_pop($parts);
|
$nome = array_pop($parts);
|
||||||
$cognome = implode(' ', $parts);
|
$cognome = implode(' ', $parts);
|
||||||
|
|
||||||
return [mb_substr($cognome, 0, 100), mb_substr($nome, 0, 100), null];
|
return [mb_substr($cognome, 0, 100), mb_substr($nome, 0, 100), null];
|
||||||
|
|
@ -635,11 +634,11 @@ private function normalizeRoleCustom(string $value): ?string
|
||||||
$value = trim($value, '_');
|
$value = trim($value, '_');
|
||||||
|
|
||||||
return match ($value) {
|
return match ($value) {
|
||||||
'comproprietario', 'co_proprietario' => 'comproprietario',
|
'comproprietario', 'co_proprietario' => 'comproprietario',
|
||||||
'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario',
|
'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario',
|
||||||
'usufruttuario', 'usufrutto' => 'usufruttuario',
|
'usufruttuario', 'usufrutto' => 'usufruttuario',
|
||||||
'titolare' => 'titolare',
|
'titolare' => 'titolare',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use App\Models\Persona;
|
use App\Models\Persona;
|
||||||
use App\Models\PersonaUnitaRelazione;
|
use App\Models\PersonaUnitaRelazione;
|
||||||
use App\Models\RubricaRuolo;
|
use App\Models\RubricaRuolo;
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class GesconSyncRubricaRuoli extends Command
|
class GesconSyncRubricaRuoli extends Command
|
||||||
{
|
{
|
||||||
|
|
@ -23,17 +21,22 @@ class GesconSyncRubricaRuoli extends Command
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
if (!Schema::hasTable('rubrica_universale') || !Schema::hasTable('rubrica_ruoli')) {
|
if (! Schema::hasTable('rubrica_universale') || ! Schema::hasTable('rubrica_ruoli')) {
|
||||||
$this->error('Tabelle rubrica_universale/rubrica_ruoli non disponibili.');
|
$this->error('Tabelle rubrica_universale/rubrica_ruoli non disponibili.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$apply = (bool)$this->option('apply');
|
$apply = (bool) $this->option('apply');
|
||||||
$createMissing = (bool) $this->option('create-missing');
|
$createMissing = (bool) $this->option('create-missing');
|
||||||
$stabileFilter = $this->option('stabile');
|
$stabileFilter = $this->option('stabile');
|
||||||
$limit = (int)$this->option('limit');
|
$limit = (int) $this->option('limit');
|
||||||
if ($limit < 100) $limit = 100;
|
if ($limit < 100) {
|
||||||
if ($limit > 20000) $limit = 20000;
|
$limit = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit > 20000) {
|
||||||
|
$limit = 20000;
|
||||||
|
}
|
||||||
|
|
||||||
$query = PersonaUnitaRelazione::with(['persona', 'unitaImmobiliare.stabile'])
|
$query = PersonaUnitaRelazione::with(['persona', 'unitaImmobiliare.stabile'])
|
||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
|
|
@ -55,38 +58,38 @@ public function handle(): int
|
||||||
foreach ($chunk as $relazione) {
|
foreach ($chunk as $relazione) {
|
||||||
$processed++;
|
$processed++;
|
||||||
$persona = $relazione->persona;
|
$persona = $relazione->persona;
|
||||||
$unita = $relazione->unitaImmobiliare;
|
$unita = $relazione->unitaImmobiliare;
|
||||||
$stabile = $unita?->stabile;
|
$stabile = $unita?->stabile;
|
||||||
if (!$persona || !$unita || !$stabile) {
|
if (! $persona || ! $unita || ! $stabile) {
|
||||||
$skipped++;
|
$skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rubrica = $this->trovaRubricaPerPersona($persona, $stabile->amministratore_id ?? null, $apply && $createMissing);
|
$rubrica = $this->trovaRubricaPerPersona($persona, $stabile->amministratore_id ?? null, $apply && $createMissing);
|
||||||
if (!$rubrica) {
|
if (! $rubrica) {
|
||||||
$skipped++;
|
$skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ruoloStandard = $this->mapRuolo($relazione->tipo_relazione);
|
$ruoloStandard = $this->mapRuolo($relazione->tipo_relazione);
|
||||||
$match = [
|
$match = [
|
||||||
'rubrica_id' => $rubrica->id,
|
'rubrica_id' => $rubrica->id,
|
||||||
'stabile_id' => $stabile->id,
|
'stabile_id' => $stabile->id,
|
||||||
'unita_immobiliare_id' => $unita->id,
|
'unita_immobiliare_id' => $unita->id,
|
||||||
'ruolo_standard' => $ruoloStandard,
|
'ruolo_standard' => $ruoloStandard,
|
||||||
];
|
];
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'is_attivo' => $relazione->isAttiva(),
|
'is_attivo' => $relazione->isAttiva(),
|
||||||
'is_preferito' => $relazione->isAttiva(),
|
'is_preferito' => $relazione->isAttiva(),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'percentuale_possesso' => $relazione->quota_relazione,
|
'percentuale_possesso' => $relazione->quota_relazione,
|
||||||
'tipo_relazione_orig' => $relazione->tipo_relazione,
|
'tipo_relazione_orig' => $relazione->tipo_relazione,
|
||||||
'persona_id_orig' => $persona->id,
|
'persona_id_orig' => $persona->id,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!$apply) {
|
if (! $apply) {
|
||||||
RubricaRuolo::where($match)->exists() ? $updated++ : $inserted++;
|
RubricaRuolo::where($match)->exists() ? $updated++ : $inserted++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +105,7 @@ public function handle(): int
|
||||||
|
|
||||||
protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = null, bool $createIfMissing = false): ?RubricaUniversale
|
protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = null, bool $createIfMissing = false): ?RubricaUniversale
|
||||||
{
|
{
|
||||||
if (!$this->personaHasRubricaIdentity($persona)) {
|
if (! $this->personaHasRubricaIdentity($persona)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,13 +114,19 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
|
||||||
$cf = trim((string) ($persona->codice_fiscale ?? ''));
|
$cf = trim((string) ($persona->codice_fiscale ?? ''));
|
||||||
if ($cf) {
|
if ($cf) {
|
||||||
$found = (clone $query)->where('codice_fiscale', $cf)->first();
|
$found = (clone $query)->where('codice_fiscale', $cf)->first();
|
||||||
if ($found) return $found;
|
if ($found) {
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$piva = trim((string) ($persona->partita_iva ?? ''));
|
$piva = trim((string) ($persona->partita_iva ?? ''));
|
||||||
if ($piva) {
|
if ($piva) {
|
||||||
$found = (clone $query)->where('partita_iva', $piva)->first();
|
$found = (clone $query)->where('partita_iva', $piva)->first();
|
||||||
if ($found) return $found;
|
if ($found) {
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->collectPersonaEmails($persona) as $email) {
|
foreach ($this->collectPersonaEmails($persona) as $email) {
|
||||||
|
|
@ -148,7 +157,7 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$nome = trim((string) ($persona->nome ?? ''));
|
$nome = trim((string) ($persona->nome ?? ''));
|
||||||
$cognome = trim((string) ($persona->cognome ?? ''));
|
$cognome = trim((string) ($persona->cognome ?? ''));
|
||||||
if ($nome !== '' && $cognome !== '') {
|
if ($nome !== '' && $cognome !== '') {
|
||||||
$found = (clone $query)
|
$found = (clone $query)
|
||||||
|
|
@ -160,29 +169,29 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$createIfMissing) {
|
if (! $createIfMissing) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tipo = $persona->tipologia === 'giuridica' ? 'persona_giuridica' : 'persona_fisica';
|
$tipo = $persona->tipologia === 'giuridica' ? 'persona_giuridica' : 'persona_fisica';
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
return RubricaUniversale::query()->create([
|
return RubricaUniversale::query()->create([
|
||||||
'amministratore_id' => $amministratoreId ? (int) $amministratoreId : null,
|
'amministratore_id' => $amministratoreId ? (int) $amministratoreId : null,
|
||||||
'nome' => $tipo === 'persona_fisica' ? ($persona->nome ?? null) : null,
|
'nome' => $tipo === 'persona_fisica' ? ($persona->nome ?? null) : null,
|
||||||
'cognome' => $tipo === 'persona_fisica' ? ($persona->cognome ?? null) : null,
|
'cognome' => $tipo === 'persona_fisica' ? ($persona->cognome ?? null) : null,
|
||||||
'ragione_sociale' => $tipo === 'persona_giuridica' ? ($persona->ragione_sociale ?? $persona->nome_completo) : null,
|
'ragione_sociale' => $tipo === 'persona_giuridica' ? ($persona->ragione_sociale ?? $persona->nome_completo) : null,
|
||||||
'tipo_contatto' => $tipo,
|
'tipo_contatto' => $tipo,
|
||||||
'categoria' => 'condomino',
|
'categoria' => 'condomino',
|
||||||
'stato' => 'attivo',
|
'stato' => 'attivo',
|
||||||
'codice_fiscale' => $cf ?: null,
|
'codice_fiscale' => $cf ?: null,
|
||||||
'partita_iva' => $persona->partita_iva ?: null,
|
'partita_iva' => $persona->partita_iva ?: null,
|
||||||
'email' => $persona->email_principale ?? null,
|
'email' => $persona->email_principale ?? null,
|
||||||
'pec' => $persona->email_pec ?? null,
|
'pec' => $persona->email_pec ?? null,
|
||||||
'telefono_ufficio' => $persona->telefono_principale ?? null,
|
'telefono_ufficio' => $persona->telefono_principale ?? null,
|
||||||
'telefono_cellulare' => $persona->telefono_secondario ?? null,
|
'telefono_cellulare' => $persona->telefono_secondario ?? null,
|
||||||
'note' => $persona->note ?? null,
|
'note' => $persona->note ?? null,
|
||||||
'data_inserimento' => $now,
|
'data_inserimento' => $now,
|
||||||
'data_ultima_modifica' => $now,
|
'data_ultima_modifica' => $now,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -265,11 +274,11 @@ private function personaHasRubricaIdentity(Persona $persona): bool
|
||||||
|
|
||||||
protected function mapRuolo(?string $tipo): string
|
protected function mapRuolo(?string $tipo): string
|
||||||
{
|
{
|
||||||
$tipo = strtolower((string)$tipo);
|
$tipo = strtolower((string) $tipo);
|
||||||
return match ($tipo) {
|
return match ($tipo) {
|
||||||
'inquilino' => 'inquilino',
|
'inquilino' => 'inquilino',
|
||||||
'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino',
|
'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino',
|
||||||
default => 'condomino',
|
default => 'condomino',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,7 +109,7 @@ private function stepRelazioni(?int $limit = null): int
|
||||||
{
|
{
|
||||||
$params = [
|
$params = [
|
||||||
'--stabile' => (string) $this->option('stabile'),
|
'--stabile' => (string) $this->option('stabile'),
|
||||||
'--limit' => $limit ?? 10000,
|
'--limit' => $limit ?? 10000,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $this->option('dry-run')) {
|
if (! $this->option('dry-run')) {
|
||||||
|
|
@ -5114,7 +5114,7 @@ private function buildComproprietariHistoryPayloads(array $historyRows, ?string
|
||||||
$sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey;
|
$sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey;
|
||||||
$sameRight = $snapshotRightKey === $currentRightKey;
|
$sameRight = $snapshotRightKey === $currentRightKey;
|
||||||
$samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null
|
$samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null
|
||||||
|| abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005;
|
|| abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005;
|
||||||
|
|
||||||
if ($sameName && $sameRight && $samePercent) {
|
if ($sameName && $sameRight && $samePercent) {
|
||||||
$current['data_inizio'] = $snapshotStart ?? $current['data_inizio'];
|
$current['data_inizio'] = $snapshotStart ?? $current['data_inizio'];
|
||||||
|
|
@ -5150,7 +5150,7 @@ private function upsertUnitaNominativiFromLegacy(int $unitaId, int $stabileId, ?
|
||||||
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'C', $legacyCondId),
|
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'C', $legacyCondId),
|
||||||
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'I', $legacyCondId)
|
$this->buildRoleHistoryPayloads($historyRowsCondomin, 'I', $legacyCondId)
|
||||||
);
|
);
|
||||||
$payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId);
|
$payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId);
|
||||||
|
|
||||||
DB::table('unita_immobiliare_nominativi')
|
DB::table('unita_immobiliare_nominativi')
|
||||||
->where('unita_immobiliare_id', $unitaId)
|
->where('unita_immobiliare_id', $unitaId)
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ public function handle(): int
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$this->hasLegacyDescrizioneColumn = Schema::hasColumn('fornitori', 'legacy_descrizione');
|
$this->hasLegacyDescrizioneColumn = Schema::hasColumn('fornitori', 'legacy_descrizione');
|
||||||
$filterIds = collect((array) $this->option('fornitore-id'))
|
$filterIds = collect((array) $this->option('fornitore-id'))
|
||||||
->map(fn($v) => (int) $v)
|
->map(fn($v) => (int) $v)
|
||||||
->filter(fn(int $v): bool => $v > 0)
|
->filter(fn(int $v): bool => $v > 0)
|
||||||
->values()
|
->values()
|
||||||
|
|
@ -78,7 +78,7 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$legacyDescrizione = $this->buildLegacyDescription($legacy);
|
$legacyDescrizione = $this->buildLegacyDescription($legacy);
|
||||||
$incoming = $this->extractTags($legacyDescrizione);
|
$incoming = $this->extractTags($legacyDescrizione);
|
||||||
if (count($incoming) === 0) {
|
if (count($incoming) === 0) {
|
||||||
$skipped++;
|
$skipped++;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -88,8 +88,8 @@ public function handle(): int
|
||||||
$merged = array_values(array_unique(array_merge($current, $incoming)));
|
$merged = array_values(array_unique(array_merge($current, $incoming)));
|
||||||
sort($merged, SORT_NATURAL | SORT_FLAG_CASE);
|
sort($merged, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
|
||||||
$newTagsValue = implode(', ', $merged);
|
$newTagsValue = implode(', ', $merged);
|
||||||
$changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue
|
$changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue
|
||||||
|| ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione;
|
|| ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione;
|
||||||
|
|
||||||
if (! $changed) {
|
if (! $changed) {
|
||||||
|
|
@ -121,37 +121,37 @@ private function resolveLegacySource(): ?array
|
||||||
{
|
{
|
||||||
$sources = [
|
$sources = [
|
||||||
[
|
[
|
||||||
'table' => 'mdb_fornitori',
|
'table' => 'mdb_fornitori',
|
||||||
'id_column' => 'id_fornitore',
|
'id_column' => 'id_fornitore',
|
||||||
'code_column' => 'cod_forn',
|
'code_column' => 'cod_forn',
|
||||||
'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'],
|
'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'],
|
||||||
'piva_column' => 'p_iva',
|
'piva_column' => 'p_iva',
|
||||||
'cf_column' => 'cod_fisc',
|
'cf_column' => 'cod_fisc',
|
||||||
'description_column' => 'descrizione',
|
'description_column' => 'descrizione',
|
||||||
'notes_column' => 'note',
|
'notes_column' => 'note',
|
||||||
'text_columns' => ['descrizione', 'note'],
|
'text_columns' => ['descrizione', 'note'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'table' => 'fornitori',
|
'table' => 'fornitori',
|
||||||
'id_column' => 'id',
|
'id_column' => 'id',
|
||||||
'code_column' => 'cod_forn',
|
'code_column' => 'cod_forn',
|
||||||
'name_columns' => ['ragione_sociale', 'cognome', 'nome'],
|
'name_columns' => ['ragione_sociale', 'cognome', 'nome'],
|
||||||
'piva_column' => 'partita_iva',
|
'piva_column' => 'partita_iva',
|
||||||
'cf_column' => 'codice_fiscale',
|
'cf_column' => 'codice_fiscale',
|
||||||
'description_column' => 'descrizione',
|
'description_column' => 'descrizione',
|
||||||
'notes_column' => 'note',
|
'notes_column' => 'note',
|
||||||
'text_columns' => ['descrizione', 'note'],
|
'text_columns' => ['descrizione', 'note'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'table' => 'fornitori_gescon',
|
'table' => 'fornitori_gescon',
|
||||||
'id_column' => 'id',
|
'id_column' => 'id',
|
||||||
'code_column' => 'cod_forn',
|
'code_column' => 'cod_forn',
|
||||||
'name_columns' => ['ragione_sociale'],
|
'name_columns' => ['ragione_sociale'],
|
||||||
'piva_column' => 'partita_iva',
|
'piva_column' => 'partita_iva',
|
||||||
'cf_column' => 'codice_fiscale',
|
'cf_column' => 'codice_fiscale',
|
||||||
'description_column' => 'descr',
|
'description_column' => 'descr',
|
||||||
'notes_column' => 'note',
|
'notes_column' => 'note',
|
||||||
'text_columns' => ['descr', 'note'],
|
'text_columns' => ['descr', 'note'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ private function resolveLegacySource(): ?array
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$source['text_columns'] = $availableTextColumns;
|
$source['text_columns'] = $availableTextColumns;
|
||||||
$source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true)
|
$source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true)
|
||||||
? $source['description_column']
|
? $source['description_column']
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -332,51 +332,51 @@ private function extractKeywordTags(string $input): array
|
||||||
}
|
}
|
||||||
|
|
||||||
$keywords = [
|
$keywords = [
|
||||||
'idr' => 'idraulico',
|
'idr' => 'idraulico',
|
||||||
'termoidraul' => 'termoidraulico',
|
'termoidraul' => 'termoidraulico',
|
||||||
'elettric' => 'elettricista',
|
'elettric' => 'elettricista',
|
||||||
'energia elet' => 'energia elettrica',
|
'energia elet' => 'energia elettrica',
|
||||||
'ascens' => 'ascensorista',
|
'ascens' => 'ascensorista',
|
||||||
'puliz' => 'pulizie',
|
'puliz' => 'pulizie',
|
||||||
'giardin' => 'giardiniere',
|
'giardin' => 'giardiniere',
|
||||||
'spurgh' => 'spurgo',
|
'spurgh' => 'spurgo',
|
||||||
'edil' => 'edile',
|
'edil' => 'edile',
|
||||||
'murator' => 'edile',
|
'murator' => 'edile',
|
||||||
'fabbro' => 'fabbro',
|
'fabbro' => 'fabbro',
|
||||||
'ferrament' => 'ferramenta',
|
'ferrament' => 'ferramenta',
|
||||||
'serrament' => 'serramenti',
|
'serrament' => 'serramenti',
|
||||||
'portier' => 'portierato',
|
'portier' => 'portierato',
|
||||||
'citofon' => 'citofonia',
|
'citofon' => 'citofonia',
|
||||||
'videosorv' => 'videosorveglianza',
|
'videosorv' => 'videosorveglianza',
|
||||||
'sicurezz' => 'sicurezza',
|
'sicurezz' => 'sicurezza',
|
||||||
'antincend' => 'antincendio',
|
'antincend' => 'antincendio',
|
||||||
'estintor' => 'antincendio',
|
'estintor' => 'antincendio',
|
||||||
'calda' => 'caldaia',
|
'calda' => 'caldaia',
|
||||||
'riscald' => 'riscaldamento',
|
'riscald' => 'riscaldamento',
|
||||||
'condizion' => 'condizionamento',
|
'condizion' => 'condizionamento',
|
||||||
'gas' => 'gas',
|
'gas' => 'gas',
|
||||||
'acqua' => 'acqua',
|
'acqua' => 'acqua',
|
||||||
'fogna' => 'fognatura',
|
'fogna' => 'fognatura',
|
||||||
'antenna' => 'antenna',
|
'antenna' => 'antenna',
|
||||||
'telecom' => 'telefonia',
|
'telecom' => 'telefonia',
|
||||||
'pec' => 'pec',
|
'pec' => 'pec',
|
||||||
'aruba' => 'pec',
|
'aruba' => 'pec',
|
||||||
'posta' => 'postali',
|
'posta' => 'postali',
|
||||||
'raccomand' => 'postali',
|
'raccomand' => 'postali',
|
||||||
'spediz' => 'spedizioni',
|
'spediz' => 'spedizioni',
|
||||||
'infest' => 'disinfestazione',
|
'infest' => 'disinfestazione',
|
||||||
'amminist' => 'amministrazione',
|
'amminist' => 'amministrazione',
|
||||||
'assicur' => 'assicurazione',
|
'assicur' => 'assicurazione',
|
||||||
'avvocat' => 'legale',
|
'avvocat' => 'legale',
|
||||||
'legale' => 'legale',
|
'legale' => 'legale',
|
||||||
'commercial' => 'commercialista',
|
'commercial' => 'commercialista',
|
||||||
'geometr' => 'geometra',
|
'geometr' => 'geometra',
|
||||||
'privacy' => 'privacy',
|
'privacy' => 'privacy',
|
||||||
'timbri' => 'timbri',
|
'timbri' => 'timbri',
|
||||||
'targhe' => 'targhe',
|
'targhe' => 'targhe',
|
||||||
'paghe' => 'paghe contributi',
|
'paghe' => 'paghe contributi',
|
||||||
'contribut' => 'paghe contributi',
|
'contribut' => 'paghe contributi',
|
||||||
'utenz' => 'utenze',
|
'utenz' => 'utenze',
|
||||||
];
|
];
|
||||||
|
|
||||||
$tags = [];
|
$tags = [];
|
||||||
|
|
@ -409,18 +409,18 @@ private function canonicalizeTag(string $raw): ?string
|
||||||
}
|
}
|
||||||
|
|
||||||
$map = [
|
$map = [
|
||||||
'idr' => 'idraulico',
|
'idr' => 'idraulico',
|
||||||
'idraul' => 'idraulico',
|
'idraul' => 'idraulico',
|
||||||
'elett' => 'elettricista',
|
'elett' => 'elettricista',
|
||||||
'elettric' => 'elettricista',
|
'elettric' => 'elettricista',
|
||||||
'ascens' => 'ascensorista',
|
'ascens' => 'ascensorista',
|
||||||
'puliz' => 'pulizie',
|
'puliz' => 'pulizie',
|
||||||
'giardin' => 'giardiniere',
|
'giardin' => 'giardiniere',
|
||||||
'assicur' => 'assicurazione',
|
'assicur' => 'assicurazione',
|
||||||
'manut' => 'manutenzione',
|
'manut' => 'manutenzione',
|
||||||
'murator' => 'edile',
|
'murator' => 'edile',
|
||||||
'privacy' => 'privacy',
|
'privacy' => 'privacy',
|
||||||
'ferrament'=> 'ferramenta',
|
'ferrament' => 'ferramenta',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($map as $needle => $canonical) {
|
foreach ($map as $needle => $canonical) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Amministratore;
|
use App\Models\Amministratore;
|
||||||
|
|
@ -10,15 +9,18 @@ class NetgesconApplyPbxPresetCommand extends Command
|
||||||
protected $signature = 'netgescon:pbx-apply-day0-preset
|
protected $signature = 'netgescon:pbx-apply-day0-preset
|
||||||
{admin : ID o codice amministratore}
|
{admin : ID o codice amministratore}
|
||||||
{--apply : Salva il preset sull\'amministratore}
|
{--apply : Salva il preset sull\'amministratore}
|
||||||
|
{--main-line=0001 : Linea principale studio}
|
||||||
|
{--main-number=0639731100 : Numero esterno linea principale}
|
||||||
{--day-group=601 : Gruppo risposta giorno}
|
{--day-group=601 : Gruppo risposta giorno}
|
||||||
{--night-group=603 : Gruppo risposta notte}
|
{--night-group=603 : Gruppo risposta notte}
|
||||||
{--day-fallback=201 : Interno fallback giorno}
|
{--day-fallback=201 : Interno fallback giorno}
|
||||||
{--night-fallback=206 : Interno fallback notte}
|
{--night-fallback=206 : Interno fallback notte}
|
||||||
{--admin-line=0003 : Linea/DID amministratore}
|
{--admin-line=0003 : Linea/DID amministratore}
|
||||||
|
{--admin-number=0688812703 : Numero esterno linea amministratore}
|
||||||
{--admin-target= : Interno target amministratore, se diverso dall\'attuale}
|
{--admin-target= : Interno target amministratore, se diverso dall\'attuale}
|
||||||
{--watch=201,206,601,603,0003 : Interni/linee monitorati CSV}';
|
{--watch=0001,0003,201,206,601,603 : Interni/linee monitorati CSV}';
|
||||||
|
|
||||||
protected $description = 'Applica o mostra il preset PBX Day0 con gruppi 601/603, fallback 201/206 e linea amministratore 0003.';
|
protected $description = 'Applica o mostra il preset PBX Day0 con linee 0001/0003, gruppi 601/603 e fallback 201/206.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +39,10 @@ public function handle(): int
|
||||||
$nightGroup = $this->digits((string) $this->option('night-group'));
|
$nightGroup = $this->digits((string) $this->option('night-group'));
|
||||||
$dayFallback = $this->digits((string) $this->option('day-fallback'));
|
$dayFallback = $this->digits((string) $this->option('day-fallback'));
|
||||||
$nightFallback = $this->digits((string) $this->option('night-fallback'));
|
$nightFallback = $this->digits((string) $this->option('night-fallback'));
|
||||||
|
$mainLine = $this->digits((string) $this->option('main-line'));
|
||||||
|
$mainNumber = $this->digits((string) $this->option('main-number'));
|
||||||
$adminLine = $this->digits((string) $this->option('admin-line'));
|
$adminLine = $this->digits((string) $this->option('admin-line'));
|
||||||
|
$adminNumber = $this->digits((string) $this->option('admin-number'));
|
||||||
$adminTarget = $this->digits((string) $this->option('admin-target'));
|
$adminTarget = $this->digits((string) $this->option('admin-target'));
|
||||||
|
|
||||||
if ($adminTarget === '') {
|
if ($adminTarget === '') {
|
||||||
|
|
@ -48,6 +53,13 @@ public function handle(): int
|
||||||
$centralino['interno_amministratore'] = $adminTarget;
|
$centralino['interno_amministratore'] = $adminTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($mainNumber !== '') {
|
||||||
|
$centralino['numero_principale'] = $mainNumber;
|
||||||
|
}
|
||||||
|
if ($adminNumber !== '') {
|
||||||
|
$centralino['numero_backup'] = $adminNumber;
|
||||||
|
}
|
||||||
|
|
||||||
$centralino['interno_gruppo_giorno'] = $dayGroup;
|
$centralino['interno_gruppo_giorno'] = $dayGroup;
|
||||||
$centralino['interno_gruppo_notte'] = $nightGroup;
|
$centralino['interno_gruppo_notte'] = $nightGroup;
|
||||||
|
|
||||||
|
|
@ -57,8 +69,8 @@ public function handle(): int
|
||||||
|
|
||||||
$watch = preg_split('/\s*,\s*/', (string) $this->option('watch'), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
$watch = preg_split('/\s*,\s*/', (string) $this->option('watch'), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
$watch = array_values(array_unique(array_filter(array_map(
|
$watch = array_values(array_unique(array_filter(array_map(
|
||||||
fn (string $value): string => $this->digits($value),
|
fn(string $value): string => $this->digits($value),
|
||||||
array_merge($watch, [$dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget])
|
array_merge($watch, [$mainLine, $dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget])
|
||||||
))));
|
))));
|
||||||
|
|
||||||
$pbx['watch_extensions'] = implode(',', $watch);
|
$pbx['watch_extensions'] = implode(',', $watch);
|
||||||
|
|
@ -75,12 +87,23 @@ public function handle(): int
|
||||||
'mode' => 'notte',
|
'mode' => 'notte',
|
||||||
'target_extension' => $nightFallback,
|
'target_extension' => $nightFallback,
|
||||||
],
|
],
|
||||||
], fn (array $row): bool => ($row['extension'] ?? '') !== ''));
|
], fn(array $row): bool => ($row['extension'] ?? '') !== ''));
|
||||||
|
|
||||||
$incomingLines = [];
|
$incomingLines = [];
|
||||||
|
if ($mainLine !== '') {
|
||||||
|
$incomingLines[] = [
|
||||||
|
'number' => $mainLine,
|
||||||
|
'external_number' => $mainNumber,
|
||||||
|
'label' => 'Linea principale studio',
|
||||||
|
'route_group' => $dayGroup,
|
||||||
|
'target_extension' => $dayFallback,
|
||||||
|
'notes' => 'Linea esterna 0001 del centralino con instradamento gruppi 601/603 e fallback operativo.',
|
||||||
|
];
|
||||||
|
}
|
||||||
if ($adminLine !== '') {
|
if ($adminLine !== '') {
|
||||||
$incomingLines[] = [
|
$incomingLines[] = [
|
||||||
'number' => $adminLine,
|
'number' => $adminLine,
|
||||||
|
'external_number' => $adminNumber,
|
||||||
'label' => 'Linea amministratore',
|
'label' => 'Linea amministratore',
|
||||||
'route_group' => '',
|
'route_group' => '',
|
||||||
'target_extension' => $adminTarget,
|
'target_extension' => $adminTarget,
|
||||||
|
|
@ -122,7 +145,7 @@ private function resolveAdmin(string $value): ?Amministratore
|
||||||
}
|
}
|
||||||
|
|
||||||
return Amministratore::query()
|
return Amministratore::query()
|
||||||
->when(is_numeric($value), fn ($query) => $query->orWhere('id', (int) $value))
|
->when(is_numeric($value), fn($query) => $query->orWhere('id', (int) $value))
|
||||||
->orWhere('codice_amministratore', $value)
|
->orWhere('codice_amministratore', $value)
|
||||||
->orWhere('codice_univoco', $value)
|
->orWhere('codice_univoco', $value)
|
||||||
->first();
|
->first();
|
||||||
|
|
|
||||||
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;
|
namespace App\Filament\Auth;
|
||||||
|
|
||||||
use App\Filament\Pages\Fornitore\TicketOperativi;
|
use App\Filament\Pages\Fornitore\TicketOperativi;
|
||||||
|
use App\Filament\Pages\Gescon\FornitoreScheda;
|
||||||
|
use App\Models\Fornitore;
|
||||||
|
use App\Models\FornitoreDipendente;
|
||||||
|
use App\Models\User;
|
||||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||||
use Filament\Auth\Pages\Login as BaseLogin;
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
|
@ -18,13 +22,46 @@ protected function getRedirectUrl(): string
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($user instanceof \App\Models\User && $user->hasRole('fornitore')) {
|
if ($user instanceof User && $user->hasRole('fornitore')) {
|
||||||
return TicketOperativi::getUrl(panel: 'admin-filament');
|
return $this->resolveSupplierRedirectUrl($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '/admin-filament';
|
return '/admin-filament';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveSupplierRedirectUrl(User $user): string
|
||||||
|
{
|
||||||
|
$email = mb_strtolower(trim((string) $user->email));
|
||||||
|
if ($email !== '') {
|
||||||
|
$fornitore = Fornitore::query()
|
||||||
|
->whereRaw('LOWER(email) = ?', [$email])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($fornitore instanceof Fornitore) {
|
||||||
|
return FornitoreScheda::getUrl(['record' => (int) $fornitore->id], panel: 'admin-filament');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dipendente = FornitoreDipendente::query()
|
||||||
|
->where('attivo', true)
|
||||||
|
->where(function ($query) use ($user, $email): void {
|
||||||
|
$query->where('user_id', (int) $user->id);
|
||||||
|
|
||||||
|
if ($email !== '') {
|
||||||
|
$query->orWhereRaw('LOWER(email) = ?', [$email]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($dipendente instanceof FornitoreDipendente) {
|
||||||
|
return FornitoreScheda::getUrl(['record' => (int) $dipendente->fornitore_id], panel: 'admin-filament');
|
||||||
|
}
|
||||||
|
|
||||||
|
return TicketOperativi::getUrl(panel: 'admin-filament');
|
||||||
|
}
|
||||||
|
|
||||||
public function authenticate(): ?LoginResponse
|
public function authenticate(): ?LoginResponse
|
||||||
{
|
{
|
||||||
$response = parent::authenticate();
|
$response = parent::authenticate();
|
||||||
|
|
|
||||||
|
|
@ -1094,14 +1094,29 @@ private function needsCompleta(): bool
|
||||||
|
|
||||||
private function isFatturaCompatibleWithCurrentStabileCf(): bool
|
private function isFatturaCompatibleWithCurrentStabileCf(): bool
|
||||||
{
|
{
|
||||||
$stabileCf = $this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale);
|
$candidateIds = array_filter([
|
||||||
$destCf = $this->normalizeMatchValue($this->fattura->destinatario_cf);
|
$this->normalizeMatchValue($this->fattura->stabile?->codice_fiscale),
|
||||||
|
$this->normalizeMatchValue($this->fattura->stabile?->cod_fisc_amministratore),
|
||||||
|
$this->normalizeMatchValue($this->fattura->stabile?->amministratore?->codice_fiscale_studio),
|
||||||
|
$this->normalizeMatchValue($this->fattura->stabile?->amministratore?->partita_iva),
|
||||||
|
]);
|
||||||
|
|
||||||
if ($stabileCf === '' || $destCf === '') {
|
$destIds = array_filter([
|
||||||
|
$this->normalizeMatchValue($this->fattura->destinatario_cf),
|
||||||
|
$this->normalizeMatchValue($this->fattura->destinatario_piva ?? null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($candidateIds === [] || $destIds === []) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $stabileCf === $destCf;
|
foreach ($destIds as $destId) {
|
||||||
|
if (in_array($destId, $candidateIds, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeMatchValue(?string $value): string
|
private function normalizeMatchValue(?string $value): string
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\FileUpload;
|
use Filament\Forms\Components\FileUpload;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
|
@ -44,6 +45,27 @@ class FattureElettronicheP7mRicevute extends Page implements HasTable
|
||||||
|
|
||||||
public array $quarterDownloads = [];
|
public array $quarterDownloads = [];
|
||||||
|
|
||||||
|
public function getActiveStabileLabelProperty(): string
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return 'Stabile non selezionato';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stabile = StabileContext::getActiveStabile($user);
|
||||||
|
if (! $stabile instanceof Stabile) {
|
||||||
|
return 'Stabile non selezionato';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array_values(array_filter([
|
||||||
|
trim((string) ($stabile->cod_stabile ?? '')),
|
||||||
|
trim((string) ($stabile->codice_stabile ?? '')),
|
||||||
|
trim((string) ($stabile->denominazione ?? '')),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile->id);
|
||||||
|
}
|
||||||
|
|
||||||
private function safeUtf8(string $value): string
|
private function safeUtf8(string $value): string
|
||||||
{
|
{
|
||||||
$value = preg_replace('/^\xEF\xBB\xBF/', '', $value) ?? $value;
|
$value = preg_replace('/^\xEF\xBB\xBF/', '', $value) ?? $value;
|
||||||
|
|
@ -578,6 +600,10 @@ protected function getHeaderActions(): array
|
||||||
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin', 'amministratore']);
|
return $u instanceof User && $u->hasAnyRole(['super-admin', 'admin', 'amministratore']);
|
||||||
})
|
})
|
||||||
->form([
|
->form([
|
||||||
|
Placeholder::make('stabile_attivo_info')
|
||||||
|
->label('Stabile di destinazione')
|
||||||
|
->content(fn(): string => $this->getActiveStabileLabelProperty()),
|
||||||
|
|
||||||
Select::make('anno')
|
Select::make('anno')
|
||||||
->label('Anno')
|
->label('Anno')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
|
|
@ -1368,8 +1394,9 @@ private function inboxBaseForStabile(int $stabileId, string $ym): string
|
||||||
|
|
||||||
private function resolveStabileIdFromParsed(array $parsed): ?int
|
private function resolveStabileIdFromParsed(array $parsed): ?int
|
||||||
{
|
{
|
||||||
$cf = $parsed['destinatario_cf'] ?? null;
|
$cf = isset($parsed['destinatario_cf']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_cf']))) : null;
|
||||||
$codice = $parsed['codice_destinatario'] ?? null;
|
$piva = isset($parsed['destinatario_piva']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['destinatario_piva']))) : null;
|
||||||
|
$codice = isset($parsed['codice_destinatario']) ? strtoupper(str_replace(' ', '', trim((string) $parsed['codice_destinatario']))) : null;
|
||||||
$pec = $parsed['pec_destinatario'] ?? null;
|
$pec = $parsed['pec_destinatario'] ?? null;
|
||||||
|
|
||||||
if ($cf) {
|
if ($cf) {
|
||||||
|
|
@ -1408,6 +1435,18 @@ private function resolveStabileIdFromParsed(array $parsed): ?int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($piva) {
|
||||||
|
$match = Stabile::query()
|
||||||
|
->select('stabili.id')
|
||||||
|
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
|
||||||
|
->where('stabili.attivo', true)
|
||||||
|
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$piva])
|
||||||
|
->get();
|
||||||
|
if ($match->count() === 1) {
|
||||||
|
return (int) $match->first()->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1455,6 +1494,35 @@ public function table(Table $table): Table
|
||||||
->defaultPaginationPageOption(50)
|
->defaultPaginationPageOption(50)
|
||||||
->defaultSort('data_fattura', 'desc')
|
->defaultSort('data_fattura', 'desc')
|
||||||
->filters([
|
->filters([
|
||||||
|
SelectFilter::make('anno')
|
||||||
|
->label('Anno fattura')
|
||||||
|
->options(function (): array {
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeStabileId = StabileContext::resolveActiveStabileId($user);
|
||||||
|
if (! $activeStabileId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return FatturaElettronica::query()
|
||||||
|
->where('stabile_id', $activeStabileId)
|
||||||
|
->whereNotNull('data_fattura')
|
||||||
|
->selectRaw('YEAR(data_fattura) as anno')
|
||||||
|
->distinct()
|
||||||
|
->orderByDesc('anno')
|
||||||
|
->pluck('anno', 'anno')
|
||||||
|
->mapWithKeys(fn($anno) => [(string) $anno => (string) $anno])
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$anno = is_numeric($data['value'] ?? null) ? (int) $data['value'] : 0;
|
||||||
|
|
||||||
|
return $anno > 0 ? $query->whereYear('data_fattura', $anno) : $query;
|
||||||
|
}),
|
||||||
|
|
||||||
SelectFilter::make('ordine')
|
SelectFilter::make('ordine')
|
||||||
->label('Ordine')
|
->label('Ordine')
|
||||||
->options([
|
->options([
|
||||||
|
|
@ -1514,7 +1582,8 @@ public function table(Table $table): Table
|
||||||
->wrap(),
|
->wrap(),
|
||||||
|
|
||||||
TextColumn::make('destinatario_cf')
|
TextColumn::make('destinatario_cf')
|
||||||
->label('CF destinatario')
|
->label('Identificativo destinatario')
|
||||||
|
->formatStateUsing(fn(FatturaElettronica $record): string => trim((string) ($record->destinatario_cf ?: $record->destinatario_piva ?: '—')))
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
namespace App\Filament\Pages\Gescon;
|
namespace App\Filament\Pages\Gescon;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
|
||||||
use App\Models\Documento;
|
use App\Models\Documento;
|
||||||
use App\Models\FatturaElettronica;
|
use App\Models\FatturaElettronica;
|
||||||
use App\Models\Fornitore;
|
use App\Models\Fornitore;
|
||||||
use App\Models\FornitoreDipendente;
|
use App\Models\FornitoreDipendente;
|
||||||
use App\Models\FornitoreStabileImpostazione;
|
use App\Models\FornitoreStabileImpostazione;
|
||||||
|
use App\Models\Product;
|
||||||
use App\Models\RegistroRitenuteAcconto;
|
use App\Models\RegistroRitenuteAcconto;
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
use App\Models\Stabile;
|
use App\Models\Stabile;
|
||||||
|
|
@ -15,6 +17,9 @@
|
||||||
use App\Models\VoceSpesa;
|
use App\Models\VoceSpesa;
|
||||||
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
|
use App\Modules\Contabilita\Models\FatturaFornitore as ContabilitaFatturaFornitore;
|
||||||
use App\Modules\Contabilita\Models\PianoConti;
|
use App\Modules\Contabilita\Models\PianoConti;
|
||||||
|
use App\Services\Catalog\FornitoreProductCatalogService;
|
||||||
|
use App\Services\Catalog\ProductAssetIngestionService;
|
||||||
|
use App\Services\Catalog\ProductOfferService;
|
||||||
use App\Services\Consumi\AcquaPdfTextParser;
|
use App\Services\Consumi\AcquaPdfTextParser;
|
||||||
use App\Services\Consumi\ConsumiAcquaIngestionService;
|
use App\Services\Consumi\ConsumiAcquaIngestionService;
|
||||||
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
|
use App\Services\Consumi\ConsumiAcquaTariffeIngestionService;
|
||||||
|
|
@ -37,6 +42,8 @@
|
||||||
|
|
||||||
class FornitoreScheda extends Page
|
class FornitoreScheda extends Page
|
||||||
{
|
{
|
||||||
|
use ResolvesOperatoreContext;
|
||||||
|
|
||||||
protected static ?string $title = 'Scheda fornitore';
|
protected static ?string $title = 'Scheda fornitore';
|
||||||
|
|
||||||
protected static ?string $slug = 'anagrafica/fornitori/{record}';
|
protected static ?string $slug = 'anagrafica/fornitori/{record}';
|
||||||
|
|
@ -65,6 +72,12 @@ class FornitoreScheda extends Page
|
||||||
/** @var array<int, array<string, mixed>> */
|
/** @var array<int, array<string, mixed>> */
|
||||||
public array $raVersateRows = [];
|
public array $raVersateRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array<string, mixed>> */
|
||||||
|
public array $catalogoProdottiRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array<string, mixed>> */
|
||||||
|
public array $tecnorepairRows = [];
|
||||||
|
|
||||||
public string $tagsInput = '';
|
public string $tagsInput = '';
|
||||||
|
|
||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
|
|
@ -105,59 +118,64 @@ public function mount(int | string $record): void
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
|
if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore'])) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->fornitore = Fornitore::query()->with(['rubrica', 'amministratore'])->findOrFail((int) $record);
|
if ($user->hasRole('fornitore') && ! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
|
||||||
|
[$resolvedFornitore] = $this->resolveOperatoreContext((int) $record);
|
||||||
|
$this->fornitore = $resolvedFornitore->loadMissing(['rubrica', 'amministratore']);
|
||||||
|
} else {
|
||||||
|
$this->fornitore = Fornitore::query()->with(['rubrica', 'amministratore'])->findOrFail((int) $record);
|
||||||
|
|
||||||
// Tenant guard: impedisce accesso cross-amministratore via URL.
|
// Tenant guard: impedisce accesso cross-amministratore via URL.
|
||||||
if (! $user->hasAnyRole(['super-admin', 'admin'])) {
|
if (! $user->hasAnyRole(['super-admin', 'admin'])) {
|
||||||
$allowedAdminIds = [];
|
$allowedAdminIds = [];
|
||||||
|
|
||||||
$activeStabile = StabileContext::getActiveStabile($user);
|
$activeStabile = StabileContext::getActiveStabile($user);
|
||||||
$activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0);
|
$activeAdminId = (int) ($activeStabile?->amministratore_id ?: 0);
|
||||||
if ($activeAdminId > 0) {
|
if ($activeAdminId > 0) {
|
||||||
$allowedAdminIds[] = $activeAdminId;
|
$allowedAdminIds[] = $activeAdminId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$userAdminId = (int) ($user->amministratore?->id ?: 0);
|
$userAdminId = (int) ($user->amministratore?->id ?: 0);
|
||||||
if ($userAdminId > 0) {
|
if ($userAdminId > 0) {
|
||||||
$allowedAdminIds[] = $userAdminId;
|
$allowedAdminIds[] = $userAdminId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0);
|
$ownerAdminId = (int) ($user->amministratoreOwner?->id ?: 0);
|
||||||
if ($ownerAdminId > 0) {
|
if ($ownerAdminId > 0) {
|
||||||
$allowedAdminIds[] = $ownerAdminId;
|
$allowedAdminIds[] = $ownerAdminId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collaboratori: includi anche gli amministratori degli stabili assegnati.
|
// Collaboratori: includi anche gli amministratori degli stabili assegnati.
|
||||||
try {
|
try {
|
||||||
$assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all();
|
$assigned = $user->stabiliAssegnati()->pluck('amministratore_id')->all();
|
||||||
foreach ($assigned as $aid) {
|
foreach ($assigned as $aid) {
|
||||||
$aid = (int) $aid;
|
$aid = (int) $aid;
|
||||||
if ($aid > 0) {
|
if ($aid > 0) {
|
||||||
$allowedAdminIds[] = $aid;
|
$allowedAdminIds[] = $aid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
} catch (\Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0)));
|
$allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0)));
|
||||||
$fornitoreAdminIds = array_values(array_unique(array_filter([
|
$fornitoreAdminIds = array_values(array_unique(array_filter([
|
||||||
(int) ($this->fornitore->amministratore_id ?? 0),
|
(int) ($this->fornitore->amministratore_id ?? 0),
|
||||||
(int) ($this->fornitore->rubrica?->amministratore_id ?? 0),
|
(int) ($this->fornitore->rubrica?->amministratore_id ?? 0),
|
||||||
], fn($v) => (int) $v > 0)));
|
], fn($v) => (int) $v > 0)));
|
||||||
|
|
||||||
if ($fornitoreAdminIds === []) {
|
if ($fornitoreAdminIds === []) {
|
||||||
if (! $user->hasAnyRole(['admin', 'amministratore'])) {
|
if (! $user->hasAnyRole(['admin', 'amministratore'])) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0;
|
$hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0;
|
||||||
if (! $hasIntersection) {
|
if (! $hasIntersection) {
|
||||||
abort(404);
|
abort(404);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +184,7 @@ public function mount(int | string $record): void
|
||||||
$this->tagsInput = (string) ($this->fornitore->tags ?? '');
|
$this->tagsInput = (string) ($this->fornitore->tags ?? '');
|
||||||
$this->loadTagSuggestions();
|
$this->loadTagSuggestions();
|
||||||
$this->refreshDipendentiRows();
|
$this->refreshDipendentiRows();
|
||||||
|
$this->refreshCatalogRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function creaDipendenteFornitore(): void
|
public function creaDipendenteFornitore(): void
|
||||||
|
|
@ -448,6 +467,138 @@ private function refreshDipendentiRows(): void
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function refreshCatalogRows(): void
|
||||||
|
{
|
||||||
|
$this->catalogoProdottiRows = [];
|
||||||
|
$this->tecnorepairRows = [];
|
||||||
|
|
||||||
|
if (Schema::hasTable('products')) {
|
||||||
|
$query = Product::query()
|
||||||
|
->withCount(['identifiers', 'serials', 'media'])
|
||||||
|
->where('default_fornitore_id', (int) $this->fornitore->id)
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(20);
|
||||||
|
|
||||||
|
if (Schema::hasTable('product_offers')) {
|
||||||
|
$query->with(['offers' => function ($offerQuery): void {
|
||||||
|
$offerQuery->where('is_active', true)->orderBy('price_amount');
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->catalogoProdottiRows = $query
|
||||||
|
->get(['id', 'internal_code', 'name', 'brand', 'model', 'color_label', 'track_serials', 'meta'])
|
||||||
|
->map(function (Product $product): array {
|
||||||
|
$offers = Schema::hasTable('product_offers') ? ($product->offers ?? collect()) : collect();
|
||||||
|
$internalOffer = $offers->first(fn($offer) => (bool) ($offer->is_internal ?? false));
|
||||||
|
$bestCompetitor = $offers
|
||||||
|
->filter(fn($offer) => ! (bool) ($offer->is_internal ?? false) && filled($offer->price_amount))
|
||||||
|
->sortBy('price_amount')
|
||||||
|
->first();
|
||||||
|
$amazonOffer = $offers->first(fn($offer) => (string) ($offer->source_type ?? '') === 'amazon_referral');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $product->id,
|
||||||
|
'internal_code' => (string) ($product->internal_code ?? ''),
|
||||||
|
'name' => (string) ($product->name ?? ''),
|
||||||
|
'brand' => (string) ($product->brand ?? ''),
|
||||||
|
'model' => (string) ($product->model ?? ''),
|
||||||
|
'color_label' => (string) ($product->color_label ?? ''),
|
||||||
|
'track_serials' => (bool) $product->track_serials,
|
||||||
|
'identifiers_count' => (int) ($product->identifiers_count ?? 0),
|
||||||
|
'serials_count' => (int) ($product->serials_count ?? 0),
|
||||||
|
'media_count' => (int) ($product->media_count ?? 0),
|
||||||
|
'source' => (string) (($product->meta['source'] ?? 'manual') ?: 'manual'),
|
||||||
|
'publication_mode' => (string) (($product->meta['catalog']['publication_mode'] ?? '') ?: ''),
|
||||||
|
'public_reference' => (string) (($product->meta['catalog']['public_reference'] ?? '') ?: ($product->internal_code ?? '')),
|
||||||
|
'private_links' => count(array_filter(is_array($product->meta['catalog']['private_source_links'] ?? null) ? $product->meta['catalog']['private_source_links'] : [], fn($value) => filled($value))),
|
||||||
|
'categories' => (string) (($product->meta['catalog']['categories'] ?? '') ?: ''),
|
||||||
|
'stock_quantity' => is_numeric($product->meta['stock']['quantity'] ?? null) ? (int) $product->meta['stock']['quantity'] : null,
|
||||||
|
'price_wholesale' => is_numeric($product->meta['pricing']['wholesale'] ?? null) ? (float) $product->meta['pricing']['wholesale'] : null,
|
||||||
|
'offers_count' => $offers->count(),
|
||||||
|
'own_offer_price' => $internalOffer && filled($internalOffer->price_amount) ? (float) $internalOffer->price_amount : null,
|
||||||
|
'best_competitor_price' => $bestCompetitor && filled($bestCompetitor->price_amount) ? (float) $bestCompetitor->price_amount : null,
|
||||||
|
'best_competitor_source' => $bestCompetitor ? (string) ($bestCompetitor->source_name ?? $bestCompetitor->source_type ?? '') : '',
|
||||||
|
'amazon_referral_url' => $amazonOffer ? (string) ($amazonOffer->referral_url ?? '') : '',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
|
||||||
|
$this->tecnorepairRows = $this->fornitore->tecnorepairSchede()
|
||||||
|
->withCount('allegati')
|
||||||
|
->orderByRaw("CASE status_bucket WHEN 'open' THEN 0 WHEN 'waiting' THEN 1 WHEN 'closed' THEN 2 ELSE 3 END")
|
||||||
|
->orderByDesc('date_received')
|
||||||
|
->limit(12)
|
||||||
|
->get(['id', 'legacy_numero_scheda', 'product_model', 'product_code', 'serial_number', 'status_bucket', 'status_label'])
|
||||||
|
->map(fn($scheda) => [
|
||||||
|
'id' => (int) $scheda->id,
|
||||||
|
'legacy_numero' => (string) ($scheda->legacy_numero_scheda ?? ''),
|
||||||
|
'product_model' => (string) ($scheda->product_model ?? ''),
|
||||||
|
'product_code' => (string) ($scheda->product_code ?? ''),
|
||||||
|
'serial_number' => (string) ($scheda->serial_number ?? ''),
|
||||||
|
'status_bucket' => (string) ($scheda->status_bucket ?? ''),
|
||||||
|
'status_label' => (string) ($scheda->status_label ?? ''),
|
||||||
|
'allegati_count' => (int) ($scheda->allegati_count ?? 0),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFornitoreAdminCode(): ?string
|
||||||
|
{
|
||||||
|
$admin = $this->fornitore->amministratore;
|
||||||
|
if (! $admin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = trim((string) ($admin->codice_amministratore ?? ''));
|
||||||
|
|
||||||
|
return $code !== '' ? $code : (string) $admin->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function suggestWholesaleCsvPath(): string
|
||||||
|
{
|
||||||
|
$basePath = base_path('Miki-Bug-workspace/Fornitori/Listini da importare');
|
||||||
|
if (! is_dir($basePath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = Str::of((string) ($this->fornitore->ragione_sociale ?? ''))
|
||||||
|
->ascii()
|
||||||
|
->lower()
|
||||||
|
->replaceMatches('/[^a-z0-9]+/', ' ')
|
||||||
|
->trim()
|
||||||
|
->value();
|
||||||
|
|
||||||
|
$candidates = glob($basePath . '/*/wholesale.csv') ?: [];
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$segment = Str::of((string) basename(dirname($candidate)))
|
||||||
|
->ascii()
|
||||||
|
->lower()
|
||||||
|
->replaceMatches('/[^a-z0-9]+/', ' ')
|
||||||
|
->trim()
|
||||||
|
->value();
|
||||||
|
|
||||||
|
if ($segment !== '' && $normalizedName !== '' && Str::contains($normalizedName, $segment)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($candidates[0] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshOperationalBoxes(): void
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user instanceof User) {
|
||||||
|
$this->hydrateBoxData($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fornitore->refresh();
|
||||||
|
$this->refreshCatalogRows();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|
@ -521,6 +672,28 @@ private function hydrateBoxData(User $user): void
|
||||||
'voce_spesa_default_id' => null,
|
'voce_spesa_default_id' => null,
|
||||||
'conto_costo_default_id' => null,
|
'conto_costo_default_id' => null,
|
||||||
],
|
],
|
||||||
|
'catalogo_modulo' => [
|
||||||
|
'scope_code' => null,
|
||||||
|
'fe_products' => 0,
|
||||||
|
'csv_products' => 0,
|
||||||
|
'manual_products' => 0,
|
||||||
|
'internal_only' => 0,
|
||||||
|
'private_links' => 0,
|
||||||
|
'offers' => 0,
|
||||||
|
'amazon_links' => 0,
|
||||||
|
],
|
||||||
|
'prodotti' => [
|
||||||
|
'count' => 0,
|
||||||
|
'serializzati' => 0,
|
||||||
|
'identifiers' => 0,
|
||||||
|
'with_media' => 0,
|
||||||
|
],
|
||||||
|
'tecnorepair' => [
|
||||||
|
'schede' => 0,
|
||||||
|
'aperte' => 0,
|
||||||
|
'chiuse' => 0,
|
||||||
|
'seriali' => 0,
|
||||||
|
],
|
||||||
'ade' => ['count' => 0, 'lordo' => 0.0],
|
'ade' => ['count' => 0, 'lordo' => 0.0],
|
||||||
'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0],
|
'fe' => ['count' => 0, 'totale' => 0.0, 'contabilizzate' => 0],
|
||||||
'contabilita' => [
|
'contabilita' => [
|
||||||
|
|
@ -550,6 +723,54 @@ private function hydrateBoxData(User $user): void
|
||||||
|
|
||||||
$this->box['stabile_id'] = $activeStabileId;
|
$this->box['stabile_id'] = $activeStabileId;
|
||||||
|
|
||||||
|
if (Schema::hasTable('products')) {
|
||||||
|
$productsBase = Product::query()->where('default_fornitore_id', (int) $this->fornitore->id);
|
||||||
|
$catalogProducts = (clone $productsBase)->get(['id', 'meta']);
|
||||||
|
|
||||||
|
$this->box['prodotti']['count'] = (int) (clone $productsBase)->count();
|
||||||
|
$this->box['prodotti']['serializzati'] = (int) (clone $productsBase)->where('track_serials', true)->count();
|
||||||
|
$this->box['prodotti']['with_media'] = (int) (clone $productsBase)->whereHas('media')->count();
|
||||||
|
$this->box['prodotti']['identifiers'] = (int) (clone $productsBase)->withCount('identifiers')->get()->sum('identifiers_count');
|
||||||
|
|
||||||
|
$scopeCode = trim((string) ($this->fornitore->codice_univoco ?? ''));
|
||||||
|
$this->box['catalogo_modulo']['scope_code'] = $scopeCode !== '' ? $scopeCode : ('FORN-' . str_pad((string) $this->fornitore->id, 6, '0', STR_PAD_LEFT));
|
||||||
|
|
||||||
|
foreach ($catalogProducts as $product) {
|
||||||
|
$meta = is_array($product->meta ?? null) ? $product->meta : [];
|
||||||
|
$source = (string) ($meta['source'] ?? 'manual');
|
||||||
|
$catalog = is_array($meta['catalog'] ?? null) ? $meta['catalog'] : [];
|
||||||
|
$privateLinks = is_array($catalog['private_source_links'] ?? null) ? $catalog['private_source_links'] : [];
|
||||||
|
|
||||||
|
if ($source === 'fattura_elettronica') {
|
||||||
|
$this->box['catalogo_modulo']['fe_products']++;
|
||||||
|
} elseif ($source === 'ncom_wholesale_csv') {
|
||||||
|
$this->box['catalogo_modulo']['csv_products']++;
|
||||||
|
} else {
|
||||||
|
$this->box['catalogo_modulo']['manual_products']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) ($catalog['publication_mode'] ?? '') === 'internal_only') {
|
||||||
|
$this->box['catalogo_modulo']['internal_only']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->box['catalogo_modulo']['private_links'] += count(array_filter($privateLinks, fn($value) => filled($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('product_offers')) {
|
||||||
|
$offersBase = $this->fornitore->productOffers()->where('is_active', true);
|
||||||
|
$this->box['catalogo_modulo']['offers'] = (int) (clone $offersBase)->count();
|
||||||
|
$this->box['catalogo_modulo']['amazon_links'] = (int) (clone $offersBase)->where('source_type', 'amazon_referral')->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('assistenza_tecnorepair_schede_legacy')) {
|
||||||
|
$stats = $this->fornitore->tecnorepair_stats;
|
||||||
|
$this->box['tecnorepair']['schede'] = (int) ($stats['total'] ?? 0);
|
||||||
|
$this->box['tecnorepair']['aperte'] = (int) ($stats['open'] ?? 0);
|
||||||
|
$this->box['tecnorepair']['chiuse'] = (int) ($stats['closed'] ?? 0);
|
||||||
|
$this->box['tecnorepair']['seriali'] = (int) ($stats['serials'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Feature flags & defaults (per stabile) + fallback (per fornitore)
|
// Feature flags & defaults (per stabile) + fallback (per fornitore)
|
||||||
$settings = null;
|
$settings = null;
|
||||||
if (Schema::hasTable('fornitore_stabile_impostazioni')) {
|
if (Schema::hasTable('fornitore_stabile_impostazioni')) {
|
||||||
|
|
@ -770,6 +991,266 @@ protected function getHeaderActions(): array
|
||||||
->icon('heroicon-o-tag')
|
->icon('heroicon-o-tag')
|
||||||
->action(fn() => $this->importLegacyTags()),
|
->action(fn() => $this->importLegacyTags()),
|
||||||
|
|
||||||
|
Action::make('nuovo_prodotto')
|
||||||
|
->label('Nuovo prodotto')
|
||||||
|
->icon('heroicon-o-cube')
|
||||||
|
->visible(fn(): bool => Schema::hasTable('products'))
|
||||||
|
->form([
|
||||||
|
TextInput::make('name')->label('Nome prodotto')->required()->maxLength(255),
|
||||||
|
TextInput::make('brand')->label('Marca')->maxLength(255),
|
||||||
|
TextInput::make('model')->label('Modello')->maxLength(255),
|
||||||
|
TextInput::make('color_label')->label('Colore')->maxLength(255),
|
||||||
|
TextInput::make('variant_label')->label('Variante')->maxLength(255),
|
||||||
|
Toggle::make('track_serials')->label('Gestione seriali')->default(false),
|
||||||
|
TextInput::make('vendor_sku')->label('Codice fornitore')->maxLength(255),
|
||||||
|
TextInput::make('ean_code')->label('EAN / GTIN')->maxLength(32),
|
||||||
|
TextInput::make('qr_code')->label('QR / payload')->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$service = app(FornitoreProductCatalogService::class);
|
||||||
|
$resolved = $service->resolveOrCreateProduct($this->fornitore, [
|
||||||
|
'name' => (string) ($data['name'] ?? ''),
|
||||||
|
'brand' => (string) ($data['brand'] ?? ''),
|
||||||
|
'model' => (string) ($data['model'] ?? ''),
|
||||||
|
'color_label' => (string) ($data['color_label'] ?? ''),
|
||||||
|
'variant_label' => (string) ($data['variant_label'] ?? ''),
|
||||||
|
'track_serials' => (bool) ($data['track_serials'] ?? false),
|
||||||
|
'type' => 'product',
|
||||||
|
'unit_measure' => 'pz',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (filled($data['vendor_sku'] ?? null)) {
|
||||||
|
$service->upsertIdentifier($resolved['product'], [
|
||||||
|
'fornitore_id' => (int) $this->fornitore->id,
|
||||||
|
'code_type' => 'vendor_sku',
|
||||||
|
'code_role' => 'supplier',
|
||||||
|
'code_value' => (string) $data['vendor_sku'],
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($data['ean_code'] ?? null)) {
|
||||||
|
$service->upsertIdentifier($resolved['product'], [
|
||||||
|
'fornitore_id' => null,
|
||||||
|
'code_type' => 'ean',
|
||||||
|
'code_role' => 'barcode',
|
||||||
|
'code_value' => (string) $data['ean_code'],
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($data['qr_code'] ?? null)) {
|
||||||
|
$service->upsertIdentifier($resolved['product'], [
|
||||||
|
'fornitore_id' => null,
|
||||||
|
'code_type' => 'qr',
|
||||||
|
'code_role' => 'barcode',
|
||||||
|
'code_value' => (string) $data['qr_code'],
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshOperationalBoxes();
|
||||||
|
Notification::make()->title('Prodotto salvato')->success()->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('estrai_prodotti_fe')
|
||||||
|
->label('Estrai prodotti da FE')
|
||||||
|
->icon('heroicon-o-document-magnifying-glass')
|
||||||
|
->visible(fn(): bool => Schema::hasTable('products'))
|
||||||
|
->form([
|
||||||
|
TextInput::make('limit')->label('Numero FE da analizzare')->numeric()->default(40)->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
Notification::make()->title('Utente non valido')->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
||||||
|
if (! $stabileId) {
|
||||||
|
Notification::make()->title('Seleziona uno stabile attivo')->warning()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = app(FornitoreProductCatalogService::class)->extractFromFattureElettroniche(
|
||||||
|
$this->fornitore,
|
||||||
|
(int) $stabileId,
|
||||||
|
max(1, (int) ($data['limit'] ?? 40)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshOperationalBoxes();
|
||||||
|
Notification::make()
|
||||||
|
->title('Estrazione prodotti completata')
|
||||||
|
->body('FE: ' . $stats['fatture'] . ' | righe: ' . $stats['lines'] . ' | nuovi prodotti: ' . $stats['products'] . ' | codici: ' . $stats['identifiers'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('importa_listino_csv')
|
||||||
|
->label('Importa listino CSV')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->visible(fn(): bool => Schema::hasTable('products'))
|
||||||
|
->form([
|
||||||
|
TextInput::make('csv_path')
|
||||||
|
->label('Percorso CSV listino')
|
||||||
|
->default(fn(): string => $this->suggestWholesaleCsvPath())
|
||||||
|
->required(),
|
||||||
|
TextInput::make('limit')->label('Limite righe')->numeric()->default(0),
|
||||||
|
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
try {
|
||||||
|
$stats = app(FornitoreProductCatalogService::class)->importWholesaleCsv(
|
||||||
|
$this->fornitore,
|
||||||
|
(string) ($data['csv_path'] ?? ''),
|
||||||
|
max(0, (int) ($data['limit'] ?? 0)),
|
||||||
|
(bool) ($data['dry_run'] ?? false),
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()->title('Import listino fallito')->body($e->getMessage())->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshOperationalBoxes();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title((bool) ($data['dry_run'] ?? false) ? 'Simulazione listino completata' : 'Import listino completato')
|
||||||
|
->body(
|
||||||
|
'righe: ' . $stats['rows']
|
||||||
|
. ' | nuovi: ' . $stats['products']
|
||||||
|
. ' | aggiornati: ' . $stats['updated']
|
||||||
|
. ' | codici: ' . $stats['identifiers']
|
||||||
|
. ' | offerte: ' . ($stats['offers'] ?? 0)
|
||||||
|
. ' | link sorgente interni: ' . ($stats['private_links'] ?? 0)
|
||||||
|
. ' | media remoti rimossi: ' . ($stats['remote_media_pruned'] ?? 0)
|
||||||
|
)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('scarica_asset_catalogo')
|
||||||
|
->label('Internalizza asset')
|
||||||
|
->icon('heroicon-o-photo')
|
||||||
|
->visible(fn(): bool => Schema::hasTable('products'))
|
||||||
|
->form([
|
||||||
|
TextInput::make('limit')->label('Limite prodotti')->numeric()->default(50)->required(),
|
||||||
|
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$stats = app(ProductAssetIngestionService::class)->ingestForFornitore(
|
||||||
|
$this->fornitore,
|
||||||
|
max(1, (int) ($data['limit'] ?? 50)),
|
||||||
|
(bool) ($data['dry_run'] ?? false),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->refreshOperationalBoxes();
|
||||||
|
Notification::make()
|
||||||
|
->title((bool) ($data['dry_run'] ?? false) ? 'Simulazione asset completata' : 'Asset catalogo internalizzati')
|
||||||
|
->body(
|
||||||
|
'prodotti: ' . $stats['products']
|
||||||
|
. ' | immagini: ' . $stats['images']
|
||||||
|
. ' | documenti: ' . $stats['documents']
|
||||||
|
. ' | esistenti: ' . $stats['skipped_existing']
|
||||||
|
. ' | non supportati: ' . $stats['skipped_unsupported']
|
||||||
|
. ' | errori: ' . $stats['failed']
|
||||||
|
)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('genera_referral_amazon')
|
||||||
|
->label('Prepara link Amazon')
|
||||||
|
->icon('heroicon-o-shopping-bag')
|
||||||
|
->visible(fn(): bool => Schema::hasTable('products') && Schema::hasTable('product_offers'))
|
||||||
|
->form([
|
||||||
|
TextInput::make('associate_tag')->label('Referral tag Amazon')->default((string) config('catalog.amazon.associate_tag', ''))->maxLength(64),
|
||||||
|
Select::make('locale')->label('Marketplace')->options([
|
||||||
|
'it' => 'Amazon IT',
|
||||||
|
'de' => 'Amazon DE',
|
||||||
|
'fr' => 'Amazon FR',
|
||||||
|
'es' => 'Amazon ES',
|
||||||
|
'uk' => 'Amazon UK',
|
||||||
|
])->default((string) config('catalog.amazon.default_locale', 'it'))->required(),
|
||||||
|
TextInput::make('limit')->label('Limite prodotti')->numeric()->default(200)->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$service = app(ProductOfferService::class);
|
||||||
|
$products = Product::query()
|
||||||
|
->where('default_fornitore_id', (int) $this->fornitore->id)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(max(1, (int) ($data['limit'] ?? 200)))
|
||||||
|
->get(['id', 'name', 'brand', 'model']);
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$offer = $service->syncAmazonReferralOffer($product, (string) ($data['associate_tag'] ?? ''), (string) ($data['locale'] ?? 'it'));
|
||||||
|
if ($offer !== null) {
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshOperationalBoxes();
|
||||||
|
Notification::make()
|
||||||
|
->title('Link Amazon preparati')
|
||||||
|
->body('prodotti elaborati: ' . $products->count() . ' | link attivi: ' . $created)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('importa_tecnorepair')
|
||||||
|
->label('Importa TecnoRepair')
|
||||||
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
|
->form([
|
||||||
|
TextInput::make('mdb_path')
|
||||||
|
->label('Percorso MDB TecnoRepair')
|
||||||
|
->default('/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('limit')->label('Limite schede')->numeric()->default(0),
|
||||||
|
Toggle::make('dry_run')->label('Solo simulazione')->default(false),
|
||||||
|
Toggle::make('force_primary')->label('Marca come centro principale')->default(true),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$adminCode = $this->resolveFornitoreAdminCode();
|
||||||
|
if ($adminCode === null) {
|
||||||
|
Notification::make()->title('Amministratore fornitore non trovato')->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'amministratore' => $adminCode,
|
||||||
|
'--mdb' => (string) ($data['mdb_path'] ?? ''),
|
||||||
|
'--fornitore-id' => (int) $this->fornitore->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$limit = max(0, (int) ($data['limit'] ?? 0));
|
||||||
|
if ($limit > 0) {
|
||||||
|
$params['--limit'] = $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) ($data['dry_run'] ?? false)) {
|
||||||
|
$params['--dry-run'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) ($data['force_primary'] ?? true)) {
|
||||||
|
$params['--force-primary'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Artisan::call('tecnorepair:import-legacy', $params);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()->title('Import TecnoRepair fallito')->body($e->getMessage())->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshOperationalBoxes();
|
||||||
|
Notification::make()
|
||||||
|
->title('Import TecnoRepair completato')
|
||||||
|
->body(trim(Artisan::output()) ?: 'Operazione completata.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
Action::make('impostazioni_fe')
|
Action::make('impostazioni_fe')
|
||||||
->label('Impostazioni FE')
|
->label('Impostazioni FE')
|
||||||
->icon('heroicon-o-adjustments-horizontal')
|
->icon('heroicon-o-adjustments-horizontal')
|
||||||
|
|
|
||||||
|
|
@ -1395,8 +1395,8 @@ public function canRequestClickToCall(): bool
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
&& (bool) ($user->pbx_click_to_call_enabled ?? false)
|
&& (bool) ($user->pbx_click_to_call_enabled ?? false)
|
||||||
&& trim((string) ($user->pbx_extension ?? '')) !== '';
|
&& trim((string) ($user->pbx_extension ?? '')) !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function richiediClickToCallRubrica(string $field): void
|
public function richiediClickToCallRubrica(string $field): void
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,16 @@ class PostItGestione extends Page
|
||||||
|
|
||||||
public string $tecnicoSearch = '';
|
public string $tecnicoSearch = '';
|
||||||
|
|
||||||
|
public string $tecnicoLineFilter = '';
|
||||||
|
|
||||||
public string $tecnicoScopeFilter = 'tutti';
|
public string $tecnicoScopeFilter = 'tutti';
|
||||||
|
|
||||||
/** @var array<int,string> */
|
/** @var array<int,string> */
|
||||||
public array $riaperturaNote = [];
|
public array $riaperturaNote = [];
|
||||||
|
|
||||||
|
/** @var array<int,string> */
|
||||||
|
public array $conversioneNote = [];
|
||||||
|
|
||||||
/** @var array<string, int|null> */
|
/** @var array<string, int|null> */
|
||||||
private array $rubricaPhoneCache = [];
|
private array $rubricaPhoneCache = [];
|
||||||
|
|
||||||
|
|
@ -57,7 +62,7 @@ class PostItGestione extends Page
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$focus = (int) request()->query('focus_post_it', 0);
|
$focus = (int) request()->query('focus_post_it', 0);
|
||||||
$this->focusPostItId = $focus > 0 ? $focus : null;
|
$this->focusPostItId = $focus > 0 ? $focus : null;
|
||||||
|
|
||||||
if (request()->query('tab') === 'storico') {
|
if (request()->query('tab') === 'storico') {
|
||||||
|
|
@ -104,7 +109,7 @@ public function convertiInTicket(int $postItId): void
|
||||||
'stabile_id' => $postIt->stabile_id,
|
'stabile_id' => $postIt->stabile_id,
|
||||||
'aperto_da_user_id' => Auth::id(),
|
'aperto_da_user_id' => Auth::id(),
|
||||||
'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')),
|
'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')),
|
||||||
'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"),
|
'descrizione' => $this->buildTicketDescriptionFromPostIt($postItId, $postIt),
|
||||||
'data_apertura' => now(),
|
'data_apertura' => now(),
|
||||||
'stato' => 'Aperto',
|
'stato' => 'Aperto',
|
||||||
'priorita' => $postIt->priorita,
|
'priorita' => $postIt->priorita,
|
||||||
|
|
@ -119,6 +124,8 @@ public function convertiInTicket(int $postItId): void
|
||||||
->body("Ticket #{$ticket->id} creato dal Post-it #{$postIt->id}")
|
->body("Ticket #{$ticket->id} creato dal Post-it #{$postIt->id}")
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
unset($this->conversioneNote[$postItId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function chiudiPostIt(int $postItId): void
|
public function chiudiPostIt(int $postItId): void
|
||||||
|
|
@ -270,6 +277,11 @@ public function getChiamateTecnicheProperty()
|
||||||
$items = $items->filter(fn(CommunicationMessage $message): bool => $this->getTecnicoScope($message) === $this->tecnicoScopeFilter)->values();
|
$items = $items->filter(fn(CommunicationMessage $message): bool => $this->getTecnicoScope($message) === $this->tecnicoScopeFilter)->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$lineFilter = trim($this->tecnicoLineFilter);
|
||||||
|
if ($lineFilter !== '') {
|
||||||
|
$items = $items->filter(fn(CommunicationMessage $message): bool => $this->matchesTecnicoLineFilter($message, $lineFilter))->values();
|
||||||
|
}
|
||||||
|
|
||||||
return $items;
|
return $items;
|
||||||
} catch (QueryException) {
|
} catch (QueryException) {
|
||||||
return collect();
|
return collect();
|
||||||
|
|
@ -323,18 +335,45 @@ public function creaPostItDaMessaggio(int $messageId): void
|
||||||
$sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR';
|
$sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR';
|
||||||
$smdr = (array) data_get($message->metadata, 'smdr', []);
|
$smdr = (array) data_get($message->metadata, 'smdr', []);
|
||||||
$line = trim((string) ($message->message_text ?? ''));
|
$line = trim((string) ($message->message_text ?? ''));
|
||||||
|
$phone = (string) ($message->phone_number ?: ($smdr['dial_number'] ?? ''));
|
||||||
|
$nominativo = $this->getTecnicoNominativo($message);
|
||||||
|
$lineLabel = $this->getTecnicoLineaLabel($message);
|
||||||
|
$direction = match (strtolower(trim((string) $message->direction))) {
|
||||||
|
'inbound' => 'Chiamata ricevuta',
|
||||||
|
'outbound' => 'Chiamata effettuata',
|
||||||
|
'internal' => 'Chiamata interna',
|
||||||
|
default => 'Evento telefonico',
|
||||||
|
};
|
||||||
|
|
||||||
|
$oggetto = $direction;
|
||||||
|
if ($lineLabel !== '-') {
|
||||||
|
$oggetto .= (string) $message->channel === 'smdr'
|
||||||
|
? ' - linea ' . $lineLabel
|
||||||
|
: ' - ' . $lineLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notaParts = [];
|
||||||
|
if ($phone !== '') {
|
||||||
|
$notaParts[] = 'Numero: ' . $phone;
|
||||||
|
}
|
||||||
|
if ($nominativo) {
|
||||||
|
$notaParts[] = 'Contatto: ' . $nominativo;
|
||||||
|
}
|
||||||
|
if ($line !== '') {
|
||||||
|
$notaParts[] = 'Dettaglio sorgente: ' . $line;
|
||||||
|
}
|
||||||
|
|
||||||
$postIt = ChiamataPostIt::query()->create([
|
$postIt = ChiamataPostIt::query()->create([
|
||||||
'stabile_id' => $message->stabile_id,
|
'stabile_id' => $message->stabile_id,
|
||||||
'creato_da_user_id' => Auth::id(),
|
'creato_da_user_id' => Auth::id(),
|
||||||
'telefono' => $message->phone_number,
|
'telefono' => $message->phone_number,
|
||||||
'nome_chiamante' => $sourceLabel . ' ' . strtoupper((string) $message->direction),
|
'nome_chiamante' => $nominativo ?: ($phone !== '' ? $phone : ($sourceLabel . ' ' . strtoupper((string) $message->direction))),
|
||||||
'direzione' => $this->normalizePostItDirection((string) $message->direction),
|
'direzione' => $this->normalizePostItDirection((string) $message->direction),
|
||||||
'origine' => $sourceKey . '_table',
|
'origine' => $sourceKey . '_table',
|
||||||
'origine_id' => (string) $message->id,
|
'origine_id' => (string) $message->id,
|
||||||
'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null,
|
'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null,
|
||||||
'oggetto' => 'Chiamata da pannello tecnico ' . $sourceLabel,
|
'oggetto' => $oggetto,
|
||||||
'nota' => $line !== '' ? $line : ('Evento ' . $sourceLabel . ' senza testo riga'),
|
'nota' => $notaParts !== [] ? implode("\n", $notaParts) : ('Evento ' . $sourceLabel . ' senza dettagli operativi'),
|
||||||
'priorita' => 'Media',
|
'priorita' => 'Media',
|
||||||
'stato' => 'post_it',
|
'stato' => 'post_it',
|
||||||
'chiamata_il' => $message->received_at ?? now(),
|
'chiamata_il' => $message->received_at ?? now(),
|
||||||
|
|
@ -436,6 +475,49 @@ public function getTecnicoLineaLabel(CommunicationMessage $message): string
|
||||||
return $parts !== [] ? implode(' / ', $parts) : '-';
|
return $parts !== [] ? implode(' / ', $parts) : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function matchesTecnicoLineFilter(CommunicationMessage $message, string $filter): bool
|
||||||
|
{
|
||||||
|
$needle = mb_strtolower(trim($filter));
|
||||||
|
if ($needle === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$haystacks = [
|
||||||
|
mb_strtolower($this->getTecnicoLineaLabel($message)),
|
||||||
|
mb_strtolower((string) ($message->target_extension ?? '')),
|
||||||
|
mb_strtolower((string) data_get($message->metadata, 'smdr.co', '')),
|
||||||
|
mb_strtolower((string) data_get($message->metadata, 'provider_call_id', '')),
|
||||||
|
mb_strtolower((string) ($message->message_text ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($haystacks as $haystack) {
|
||||||
|
if ($haystack !== '' && str_contains($haystack, $needle)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTicketDescriptionFromPostIt(int $postItId, ChiamataPostIt $postIt): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
$nota = trim((string) ($postIt->nota ?? ''));
|
||||||
|
if ($nota !== '') {
|
||||||
|
$parts[] = $nota;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conversioneNote = trim((string) ($this->conversioneNote[$postItId] ?? ''));
|
||||||
|
if ($conversioneNote !== '') {
|
||||||
|
$parts[] = 'Nota operatore prima della conversione:' . "\n" . $conversioneNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = 'Riferimento chiamata: #' . $postIt->id;
|
||||||
|
|
||||||
|
return implode("\n\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
public function richiediClickToCallDaMessaggio(int $messageId): void
|
public function richiediClickToCallDaMessaggio(int $messageId): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
|
||||||
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 $newTicketDescrizione = null;
|
||||||
|
|
||||||
|
public ?string $newTicketCallNote = null;
|
||||||
|
|
||||||
public ?string $newTicketLuogo = null;
|
public ?string $newTicketLuogo = null;
|
||||||
|
|
||||||
public string $newTicketPriorita = 'Media';
|
public string $newTicketPriorita = 'Media';
|
||||||
|
|
@ -207,8 +209,8 @@ public function creaPostItDaChiamataInIngresso(): void
|
||||||
'direzione' => 'in_arrivo',
|
'direzione' => 'in_arrivo',
|
||||||
'origine' => 'smdr_live_banner',
|
'origine' => 'smdr_live_banner',
|
||||||
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
|
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
|
||||||
'oggetto' => 'Richiesta telefonica da valutare',
|
'oggetto' => 'Chiamata ricevuta da gestire',
|
||||||
'nota' => $line !== '' ? $line : ('Chiamata in ingresso da ' . $phone),
|
'nota' => $this->buildLiveCallPostItNote($phone, $line),
|
||||||
'priorita' => 'Media',
|
'priorita' => 'Media',
|
||||||
'stato' => 'post_it',
|
'stato' => 'post_it',
|
||||||
'chiamata_il' => now(),
|
'chiamata_il' => now(),
|
||||||
|
|
@ -574,18 +576,9 @@ public function creaTicketRapido(): void
|
||||||
$descrizione = trim((string) $this->newTicketDescrizione);
|
$descrizione = trim((string) $this->newTicketDescrizione);
|
||||||
if ($caller) {
|
if ($caller) {
|
||||||
$telefono = $caller->telefono_cellulare ?: ($caller->telefono_ufficio ?: $caller->telefono_casa);
|
$telefono = $caller->telefono_cellulare ?: ($caller->telefono_ufficio ?: $caller->telefono_casa);
|
||||||
$descrizione .= "\n\nChiamante selezionato: " . ($caller->nome_completo ?: $caller->ragione_sociale ?: ('Rubrica #' . $caller->id));
|
$descrizione .= "\n\nContatto associato: " . ($caller->nome_completo ?: $caller->ragione_sociale ?: ('Rubrica #' . $caller->id));
|
||||||
if ($telefono) {
|
if ($telefono) {
|
||||||
$descrizione .= "\nTelefono: {$telefono}";
|
$descrizione .= "\nTelefono richiamabile: {$telefono}";
|
||||||
}
|
|
||||||
if ($caller->tipo_utenza_call) {
|
|
||||||
$descrizione .= "\nProfilo: {$caller->tipo_utenza_call}";
|
|
||||||
}
|
|
||||||
if ($caller->riferimento_stabile) {
|
|
||||||
$descrizione .= "\nRif. stabile: {$caller->riferimento_stabile}";
|
|
||||||
}
|
|
||||||
if ($caller->riferimento_unita) {
|
|
||||||
$descrizione .= "\nRif. unità: {$caller->riferimento_unita}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,6 +617,7 @@ public function creaTicketRapido(): void
|
||||||
|
|
||||||
$this->newTicketTitolo = null;
|
$this->newTicketTitolo = null;
|
||||||
$this->newTicketDescrizione = null;
|
$this->newTicketDescrizione = null;
|
||||||
|
$this->newTicketCallNote = null;
|
||||||
$this->newTicketLuogo = null;
|
$this->newTicketLuogo = null;
|
||||||
$this->newTicketPriorita = 'Media';
|
$this->newTicketPriorita = 'Media';
|
||||||
$this->newTicketCategoriaId = null;
|
$this->newTicketCategoriaId = null;
|
||||||
|
|
@ -928,6 +922,7 @@ private function prefillTicketFromLiveCall(string $phone, string $line): void
|
||||||
{
|
{
|
||||||
$this->callerSearch = $phone;
|
$this->callerSearch = $phone;
|
||||||
$this->searchCaller();
|
$this->searchCaller();
|
||||||
|
$this->newTicketCallNote = trim($line) !== '' ? trim($line) : null;
|
||||||
|
|
||||||
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
|
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
|
||||||
if ($rubricaId > 0) {
|
if ($rubricaId > 0) {
|
||||||
|
|
@ -940,14 +935,68 @@ private function prefillTicketFromLiveCall(string $phone, string $line): void
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blank($this->newTicketTitolo)) {
|
if (blank($this->newTicketTitolo)) {
|
||||||
$this->newTicketTitolo = 'Chiamata in ingresso ' . $phone;
|
$this->newTicketTitolo = 'Segnalazione telefonica in arrivo';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blank($this->newTicketDescrizione)) {
|
if (blank($this->newTicketDescrizione)) {
|
||||||
$this->newTicketDescrizione = 'Segnalazione aperta durante chiamata in ingresso da ' . $phone . ".\n" . $line;
|
$this->newTicketDescrizione = 'Segnalazione aperta durante chiamata ricevuta dal numero ' . $phone . '.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{phone:?string,target_extension:?string,received_at:?string,caller_name:?string,caller_profile:?string,riferimento_stabile:?string,riferimento_unita:?string,stabile_label:?string,call_note:?string}
|
||||||
|
*/
|
||||||
|
public function getTicketCallContextProperty(): array
|
||||||
|
{
|
||||||
|
$caller = $this->selectedCallerId
|
||||||
|
? RubricaUniversale::query()->find($this->selectedCallerId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$phone = null;
|
||||||
|
if (! empty($this->liveIncomingCall['phone'])) {
|
||||||
|
$phone = (string) $this->liveIncomingCall['phone'];
|
||||||
|
} else {
|
||||||
|
$digits = $this->normalizePhoneDigits((string) $this->callerSearch);
|
||||||
|
$phone = $digits !== '' ? $digits : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'phone' => $phone,
|
||||||
|
'target_extension' => ! empty($this->liveIncomingCall['target_extension']) ? (string) $this->liveIncomingCall['target_extension'] : null,
|
||||||
|
'received_at' => ! empty($this->liveIncomingCall['received_at']) ? (string) $this->liveIncomingCall['received_at'] : null,
|
||||||
|
'caller_name' => $caller ? (string) ($caller->nome_completo ?: $caller->ragione_sociale ?: '') : (! empty($this->liveIncomingCall['rubrica_nome']) ? (string) $this->liveIncomingCall['rubrica_nome'] : null),
|
||||||
|
'caller_profile' => $caller ? ((string) ($caller->tipo_utenza_call ?: '') ?: null) : null,
|
||||||
|
'riferimento_stabile' => $caller ? ((string) ($caller->riferimento_stabile ?: '') ?: null) : null,
|
||||||
|
'riferimento_unita' => $caller ? ((string) ($caller->riferimento_unita ?: '') ?: null) : null,
|
||||||
|
'stabile_label' => $caller ? $this->getCallerStabileLabel((int) $caller->id) : null,
|
||||||
|
'call_note' => $this->newTicketCallNote,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLiveCallPostItNote(string $phone, string $line): string
|
||||||
|
{
|
||||||
|
$parts = [
|
||||||
|
'Chiamata ricevuta dal numero ' . $phone,
|
||||||
|
];
|
||||||
|
|
||||||
|
$targetExtension = trim((string) ($this->liveIncomingCall['target_extension'] ?? ''));
|
||||||
|
if ($targetExtension !== '') {
|
||||||
|
$parts[] = 'Interno/gruppo chiamato: ' . $targetExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
$callerName = trim((string) ($this->liveIncomingCall['rubrica_nome'] ?? ''));
|
||||||
|
if ($callerName !== '') {
|
||||||
|
$parts[] = 'Contatto riconosciuto: ' . $callerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanLine = trim($line);
|
||||||
|
if ($cleanLine !== '') {
|
||||||
|
$parts[] = 'Nota operativa: ' . $cleanLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizePhoneDigits(string $value): string
|
private function normalizePhoneDigits(string $value): string
|
||||||
{
|
{
|
||||||
return preg_replace('/\D+/', '', $value) ?: '';
|
return preg_replace('/\D+/', '', $value) ?: '';
|
||||||
|
|
@ -993,14 +1042,14 @@ private function selectCallerRepresentative(Collection $group, int $selectedCall
|
||||||
$representative = $selected instanceof RubricaUniversale
|
$representative = $selected instanceof RubricaUniversale
|
||||||
? $selected
|
? $selected
|
||||||
: $group
|
: $group
|
||||||
->sortByDesc(fn(RubricaUniversale $match): array => [
|
->sortByDesc(fn(RubricaUniversale $match): array=> [
|
||||||
(int) (! empty($match->amministratore_id)),
|
(int) (! empty($match->amministratore_id)),
|
||||||
(int) (! empty($match->riferimento_stabile)),
|
(int) (! empty($match->riferimento_stabile)),
|
||||||
(int) (! empty($match->riferimento_unita)),
|
(int) (! empty($match->riferimento_unita)),
|
||||||
(int) (($match->categoria ?? '') === 'condomino'),
|
(int) (($match->categoria ?? '') === 'condomino'),
|
||||||
-1 * (int) $match->id,
|
-1 * (int) $match->id,
|
||||||
])
|
])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$duplicateCategories = $group
|
$duplicateCategories = $group
|
||||||
->pluck('categoria')
|
->pluck('categoria')
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ protected function getHeaderActions(): array
|
||||||
->columnSpan(3),
|
->columnSpan(3),
|
||||||
Select::make('persona_id')
|
Select::make('persona_id')
|
||||||
->label('Persona collegata')
|
->label('Persona collegata')
|
||||||
->options(fn(): array => $this->getPersonaRecapitoOptions())
|
->options(fn(): array=> $this->getPersonaRecapitoOptions())
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->native(false)
|
->native(false)
|
||||||
|
|
@ -353,7 +353,7 @@ protected function getHeaderActions(): array
|
||||||
])
|
])
|
||||||
->columns(12),
|
->columns(12),
|
||||||
])
|
])
|
||||||
->fillForm(fn(): array => ['recapiti' => $this->getRecapitiServizioFormRows()])
|
->fillForm(fn(): array=> ['recapiti' => $this->getRecapitiServizioFormRows()])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$this->saveRecapitiServizioOverrides($data['recapiti'] ?? []);
|
$this->saveRecapitiServizioOverrides($data['recapiti'] ?? []);
|
||||||
$this->loadUnita();
|
$this->loadUnita();
|
||||||
|
|
@ -806,7 +806,7 @@ public function hydrateNominativiStorici(): void
|
||||||
$percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%';
|
$percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
$details = [];
|
$details = [];
|
||||||
$dirittoLabel = trim((string) ($payload['diritto_label'] ?? ''));
|
$dirittoLabel = trim((string) ($payload['diritto_label'] ?? ''));
|
||||||
if ($dirittoLabel !== '') {
|
if ($dirittoLabel !== '') {
|
||||||
$details[] = 'Diritto: ' . $dirittoLabel;
|
$details[] = 'Diritto: ' . $dirittoLabel;
|
||||||
|
|
@ -871,8 +871,8 @@ private function formatNominativoStoricoRuolo(string $ruolo, string $fonte, arra
|
||||||
}
|
}
|
||||||
|
|
||||||
return match (strtoupper($ruolo)) {
|
return match (strtoupper($ruolo)) {
|
||||||
'I' => 'Inquilino',
|
'I' => 'Inquilino',
|
||||||
'C' => 'Condomino',
|
'C' => 'Condomino',
|
||||||
default => $ruolo !== '' ? $ruolo : '—',
|
default => $ruolo !== '' ? $ruolo : '—',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Models\RubricaUniversale;
|
use App\Models\RubricaUniversale;
|
||||||
use App\Services\Cti\PbxRoutingService;
|
use App\Services\Cti\PbxRoutingService;
|
||||||
use App\Support\PhoneNumber;
|
use App\Support\PhoneNumber;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
@ -52,7 +53,8 @@ public function incoming(Request $request): JsonResponse
|
||||||
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
||||||
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
|
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
|
||||||
|
|
||||||
$postIt = null;
|
$postIt = null;
|
||||||
|
$calledAt = $this->normalizePayloadDateTime($payload['called_at'] ?? null);
|
||||||
if (Schema::hasTable('chiamate_post_it')) {
|
if (Schema::hasTable('chiamate_post_it')) {
|
||||||
$postIt = ChiamataPostIt::query()->create([
|
$postIt = ChiamataPostIt::query()->create([
|
||||||
'stabile_id' => $routing['stabile_id'],
|
'stabile_id' => $routing['stabile_id'],
|
||||||
|
|
@ -69,7 +71,7 @@ public function incoming(Request $request): JsonResponse
|
||||||
'nota' => $this->buildNote($payload),
|
'nota' => $this->buildNote($payload),
|
||||||
'priorita' => 'Media',
|
'priorita' => 'Media',
|
||||||
'stato' => 'post_it',
|
'stato' => 'post_it',
|
||||||
'chiamata_il' => $payload['called_at'] ?? now(),
|
'chiamata_il' => $calledAt ?? now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,95 +175,116 @@ public function callEnded(Request $request): JsonResponse
|
||||||
'is_test' => $isTest,
|
'is_test' => $isTest,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
|
try {
|
||||||
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
|
||||||
$postIt = $this->findOpenPostIt(
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
||||||
(string) ($payload['event_id'] ?? ''),
|
$endedAt = $this->normalizePayloadDateTime($payload['ended_at'] ?? null);
|
||||||
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '')
|
$postIt = $this->findOpenPostIt(
|
||||||
);
|
(string) ($payload['event_id'] ?? ''),
|
||||||
|
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
$created = false;
|
$created = false;
|
||||||
if (! $postIt && Schema::hasTable('chiamate_post_it')) {
|
if (! $postIt && Schema::hasTable('chiamate_post_it')) {
|
||||||
$created = true;
|
$created = true;
|
||||||
$rubrica = $this->findRubricaByPhone($normalized);
|
$rubrica = $this->findRubricaByPhone($normalized);
|
||||||
|
|
||||||
$postIt = ChiamataPostIt::query()->create([
|
$postIt = ChiamataPostIt::query()->create([
|
||||||
'stabile_id' => null,
|
'stabile_id' => null,
|
||||||
'rubrica_id' => $rubrica?->id,
|
'rubrica_id' => $rubrica?->id,
|
||||||
'ticket_id' => null,
|
'ticket_id' => null,
|
||||||
'creato_da_user_id' => null,
|
'creato_da_user_id' => null,
|
||||||
'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''),
|
'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''),
|
||||||
'nome_chiamante' => $rubrica?->nome_completo,
|
'nome_chiamante' => $rubrica?->nome_completo,
|
||||||
'direzione' => $direction['post_it'],
|
'direzione' => $direction['post_it'],
|
||||||
'esito' => $payload['outcome'] ?? null,
|
'esito' => $payload['outcome'] ?? null,
|
||||||
'origine' => 'panasonic_ns1000',
|
'origine' => 'panasonic_ns1000',
|
||||||
'origine_id' => $payload['event_id'] ?? null,
|
'origine_id' => $payload['event_id'] ?? null,
|
||||||
'oggetto' => 'Chiamata terminata (evento senza incoming)',
|
'oggetto' => 'Chiamata terminata (evento senza incoming)',
|
||||||
'nota' => $this->buildNote($payload),
|
'nota' => $this->buildNote($payload),
|
||||||
'priorita' => 'Media',
|
'priorita' => 'Media',
|
||||||
'stato' => 'post_it',
|
'stato' => 'post_it',
|
||||||
'chiamata_il' => $payload['ended_at'] ?? now(),
|
'chiamata_il' => $endedAt ?? now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $postIt) {
|
if (! $postIt) {
|
||||||
$this->logTrace('call-ended.not-found', $request, $payload, [
|
$this->logTrace('call-ended.not-found', $request, $payload, [
|
||||||
|
'trace_id' => $traceId,
|
||||||
|
'classification' => $classification,
|
||||||
|
'is_test' => $isTest,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'No matching Post-it and table unavailable',
|
||||||
|
'trace_id' => $traceId,
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$postIt->esito = $payload['outcome'] ?? $postIt->esito;
|
||||||
|
$postIt->stato = 'chiusa';
|
||||||
|
|
||||||
|
if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) {
|
||||||
|
$postIt->durata_secondi = $payload['duration_seconds'] ?? $postIt->durata_secondi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) {
|
||||||
|
$postIt->chiusa_il = $endedAt ?? now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload['note'])) {
|
||||||
|
$existing = trim((string) ($postIt->nota ?? ''));
|
||||||
|
$suffix = 'Note chiusura PBX: ' . $payload['note'];
|
||||||
|
$postIt->nota = $existing !== '' ? ($existing . "\n" . $suffix) : $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
$postIt->save();
|
||||||
|
|
||||||
|
$message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id);
|
||||||
|
|
||||||
|
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
|
||||||
|
|
||||||
|
$this->logTrace('call-ended.closed', $request, $payload, [
|
||||||
'trace_id' => $traceId,
|
'trace_id' => $traceId,
|
||||||
|
'post_it_id' => $postIt->id,
|
||||||
|
'message_id' => $message?->id,
|
||||||
|
'created_new' => $created,
|
||||||
'classification' => $classification,
|
'classification' => $classification,
|
||||||
'is_test' => $isTest,
|
'is_test' => $isTest,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'source' => 'panasonic_ns1000',
|
||||||
|
'trace_id' => $traceId,
|
||||||
|
'closed' => true,
|
||||||
|
'created_new' => $created,
|
||||||
|
'post_it_id' => $postIt->id,
|
||||||
|
'communication_message_id' => $message?->id,
|
||||||
|
'stato' => $postIt->stato,
|
||||||
|
'esito' => $postIt->esito,
|
||||||
|
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('CTI Panasonic call-ended failed', [
|
||||||
|
'trace_id' => $traceId,
|
||||||
|
'event_id' => (string) ($payload['event_id'] ?? ''),
|
||||||
|
'called_extension' => (string) ($payload['called_extension'] ?? ''),
|
||||||
|
'calling_extension' => (string) ($payload['calling_extension'] ?? ''),
|
||||||
|
'direction' => (string) ($payload['direction'] ?? ''),
|
||||||
|
'phone' => (string) ($payload['phone'] ?? ''),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'exception' => get_class($e),
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'No matching Post-it and table unavailable',
|
'message' => 'Call ended processing failed',
|
||||||
'trace_id' => $traceId,
|
'trace_id' => $traceId,
|
||||||
], 404);
|
'error' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$postIt->esito = $payload['outcome'] ?? $postIt->esito;
|
|
||||||
$postIt->stato = 'chiusa';
|
|
||||||
|
|
||||||
if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) {
|
|
||||||
$postIt->durata_secondi = $payload['duration_seconds'] ?? $postIt->durata_secondi;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) {
|
|
||||||
$postIt->chiusa_il = $payload['ended_at'] ?? now();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($payload['note'])) {
|
|
||||||
$existing = trim((string) ($postIt->nota ?? ''));
|
|
||||||
$suffix = 'Note chiusura PBX: ' . $payload['note'];
|
|
||||||
$postIt->nota = $existing !== '' ? ($existing . "\n" . $suffix) : $suffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
$postIt->save();
|
|
||||||
|
|
||||||
$message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id);
|
|
||||||
|
|
||||||
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
|
|
||||||
|
|
||||||
$this->logTrace('call-ended.closed', $request, $payload, [
|
|
||||||
'trace_id' => $traceId,
|
|
||||||
'post_it_id' => $postIt->id,
|
|
||||||
'message_id' => $message?->id,
|
|
||||||
'created_new' => $created,
|
|
||||||
'classification' => $classification,
|
|
||||||
'is_test' => $isTest,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'ok' => true,
|
|
||||||
'source' => 'panasonic_ns1000',
|
|
||||||
'trace_id' => $traceId,
|
|
||||||
'closed' => true,
|
|
||||||
'created_new' => $created,
|
|
||||||
'post_it_id' => $postIt->id,
|
|
||||||
'communication_message_id' => $message?->id,
|
|
||||||
'stato' => $postIt->stato,
|
|
||||||
'esito' => $postIt->esito,
|
|
||||||
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pendingClickToCall(Request $request): JsonResponse
|
public function pendingClickToCall(Request $request): JsonResponse
|
||||||
|
|
@ -640,7 +663,7 @@ private function storeIncomingCommunicationMessage(
|
||||||
'message_text' => $this->buildNote($payload),
|
'message_text' => $this->buildNote($payload),
|
||||||
'attachments' => null,
|
'attachments' => null,
|
||||||
'status' => 'received',
|
'status' => 'received',
|
||||||
'received_at' => $payload['called_at'] ?? now(),
|
'received_at' => $this->normalizePayloadDateTime($payload['called_at'] ?? null) ?? now(),
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'provider' => 'panasonic_ns1000',
|
'provider' => 'panasonic_ns1000',
|
||||||
'amministratore_id' => $routing['amministratore_id'],
|
'amministratore_id' => $routing['amministratore_id'],
|
||||||
|
|
@ -701,7 +724,7 @@ private function closeCommunicationMessage(array $payload, string $normalizedPho
|
||||||
|
|
||||||
if ($message) {
|
if ($message) {
|
||||||
$metadata = is_array($message->metadata) ? $message->metadata : [];
|
$metadata = is_array($message->metadata) ? $message->metadata : [];
|
||||||
$metadata['ended_at'] = ($payload['ended_at'] ?? now())->toDateTimeString();
|
$metadata['ended_at'] = ($this->normalizePayloadDateTime($payload['ended_at'] ?? null) ?? now())->toDateTimeString();
|
||||||
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
|
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
|
||||||
$metadata['outcome'] = $payload['outcome'] ?? null;
|
$metadata['outcome'] = $payload['outcome'] ?? null;
|
||||||
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
|
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
|
||||||
|
|
@ -714,7 +737,7 @@ private function closeCommunicationMessage(array $payload, string $normalizedPho
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata = is_array($message->metadata) ? $message->metadata : [];
|
$metadata = is_array($message->metadata) ? $message->metadata : [];
|
||||||
$metadata['ended_at'] = ($payload['ended_at'] ?? now())->toDateTimeString();
|
$metadata['ended_at'] = ($this->normalizePayloadDateTime($payload['ended_at'] ?? null) ?? now())->toDateTimeString();
|
||||||
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
|
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
|
||||||
$metadata['outcome'] = $payload['outcome'] ?? null;
|
$metadata['outcome'] = $payload['outcome'] ?? null;
|
||||||
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
|
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
|
||||||
|
|
@ -799,6 +822,28 @@ private function mergeMetadata(?array $existing, array $newData): array
|
||||||
return $base;
|
return $base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizePayloadDateTime(mixed $value): ?CarbonInterface
|
||||||
|
{
|
||||||
|
if ($value instanceof CarbonInterface) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return now()->setTimestamp($value->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim((string) ($value ?? ''));
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return now()->parse($raw);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
|
@ -16,12 +15,16 @@ public function handle(Request $request, Closure $next): Response
|
||||||
|
|
||||||
// Keep Filament panel and non-admin paths untouched.
|
// Keep Filament panel and non-admin paths untouched.
|
||||||
$isLegacyAdminPath = $request->is('admin') || $request->is('admin/*');
|
$isLegacyAdminPath = $request->is('admin') || $request->is('admin/*');
|
||||||
$isFilamentPath = $request->is('admin-filament') || $request->is('admin-filament/*');
|
$isFilamentPath = $request->is('admin-filament') || $request->is('admin-filament/*');
|
||||||
|
|
||||||
if (! $isLegacyAdminPath || $isFilamentPath) {
|
if (! $isLegacyAdminPath || $isFilamentPath) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->shouldBypassRedirect($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect only navigational requests. Non-GET calls are left as-is.
|
// Redirect only navigational requests. Non-GET calls are left as-is.
|
||||||
if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) {
|
if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|
@ -29,4 +32,25 @@ public function handle(Request $request, Closure $next): Response
|
||||||
|
|
||||||
return redirect('/admin-filament');
|
return redirect('/admin-filament');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldBypassRedirect(Request $request): bool
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
'admin/fatture-elettroniche/*/download-xml',
|
||||||
|
'admin/fatture-elettroniche/*/download-pdf',
|
||||||
|
'admin/tickets/attachments/*/view',
|
||||||
|
'admin/insurance-policies/*/assets/*',
|
||||||
|
'admin/documenti/*/download',
|
||||||
|
'admin/documenti-collegati/*/download',
|
||||||
|
'admin/contabilita-gescon/documenti/*/download',
|
||||||
|
'admin/filament/import-inbox/*/download',
|
||||||
|
'admin/filament/tickets/attachments/*/view',
|
||||||
|
] as $pattern) {
|
||||||
|
if ($request->is($pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ public function createPostIt(): void
|
||||||
|
|
||||||
$this->redirect(
|
$this->redirect(
|
||||||
PostItGestione::getUrl([
|
PostItGestione::getUrl([
|
||||||
'tab' => 'storico',
|
'tab' => 'storico',
|
||||||
'focus_post_it' => (int) $postIt->id,
|
'focus_post_it' => (int) $postIt->id,
|
||||||
], panel: 'admin-filament'),
|
], panel: 'admin-filament'),
|
||||||
navigate: true,
|
navigate: true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,25 @@ class Amministratore extends Model
|
||||||
'cartella_dati',
|
'cartella_dati',
|
||||||
'database_attivo',
|
'database_attivo',
|
||||||
'abilita_import_stabili',
|
'abilita_import_stabili',
|
||||||
|
'fe_cassetto_enabled',
|
||||||
|
'fe_cassetto_expires_at',
|
||||||
|
'fe_cassetto_base_url',
|
||||||
|
'fe_cassetto_password_script',
|
||||||
|
'fe_cassetto_username',
|
||||||
|
'fe_cassetto_password',
|
||||||
|
'fe_cassetto_pin',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'abilita_import_stabili' => 'boolean',
|
'abilita_import_stabili' => 'boolean',
|
||||||
'impostazioni' => 'array',
|
'fe_cassetto_enabled' => 'boolean',
|
||||||
'provisioned_at' => 'datetime',
|
'fe_cassetto_expires_at' => 'datetime',
|
||||||
|
'fe_cassetto_password_script' => 'encrypted',
|
||||||
|
'fe_cassetto_username' => 'encrypted',
|
||||||
|
'fe_cassetto_password' => 'encrypted',
|
||||||
|
'fe_cassetto_pin' => 'encrypted',
|
||||||
|
'impostazioni' => 'array',
|
||||||
|
'provisioned_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -36,6 +35,7 @@ class FatturaElettronica extends Model
|
||||||
'codice_destinatario',
|
'codice_destinatario',
|
||||||
'pec_destinatario',
|
'pec_destinatario',
|
||||||
'destinatario_cf',
|
'destinatario_cf',
|
||||||
|
'destinatario_piva',
|
||||||
'destinatario_denominazione',
|
'destinatario_denominazione',
|
||||||
'stato',
|
'stato',
|
||||||
'xml_content',
|
'xml_content',
|
||||||
|
|
@ -51,11 +51,11 @@ class FatturaElettronica extends Model
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'data_fattura' => 'date',
|
'data_fattura' => 'date',
|
||||||
'data_scadenza' => 'date',
|
'data_scadenza' => 'date',
|
||||||
'imponibile' => 'decimal:2',
|
'imponibile' => 'decimal:2',
|
||||||
'iva' => 'decimal:2',
|
'iva' => 'decimal:2',
|
||||||
'totale' => 'decimal:2',
|
'totale' => 'decimal:2',
|
||||||
'consumo_valore' => 'decimal:3',
|
'consumo_valore' => 'decimal:3',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use App\Support\ArchivioPaths;
|
use App\Support\ArchivioPaths;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -48,14 +49,16 @@ class Fornitore extends Model
|
||||||
'regime_fiscale_id',
|
'regime_fiscale_id',
|
||||||
'tipologia_cassa_id',
|
'tipologia_cassa_id',
|
||||||
'aliquota_iva_id',
|
'aliquota_iva_id',
|
||||||
|
'is_tecnorepair_primary',
|
||||||
'old_id',
|
'old_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
'escludi_righe_fe' => 'boolean',
|
'escludi_righe_fe' => 'boolean',
|
||||||
'fe_features' => 'array',
|
'fe_features' => 'array',
|
||||||
|
'is_tecnorepair_primary' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -168,6 +171,52 @@ public function dipendenti()
|
||||||
return $this->hasMany(FornitoreDipendente::class, 'fornitore_id', 'id');
|
return $this->hasMany(FornitoreDipendente::class, 'fornitore_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tecnorepairSchede(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AssistenzaTecnorepairScheda::class, 'fornitore_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function productSerials(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductSerial::class, 'fornitore_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function products(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Product::class, 'default_fornitore_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function productOffers(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductOffer::class, 'fornitore_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsTecnoRepairCenterAttribute(): bool
|
||||||
|
{
|
||||||
|
if ((bool) ($this->is_tecnorepair_primary ?? false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = Str::upper(trim((string) ($this->ragione_sociale ?? '')));
|
||||||
|
|
||||||
|
return $label !== '' && Str::contains($label, 'NETHOME');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,int>
|
||||||
|
*/
|
||||||
|
public function getTecnoRepairStatsAttribute(): array
|
||||||
|
{
|
||||||
|
$base = $this->tecnorepairSchede();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (int) (clone $base)->count(),
|
||||||
|
'open' => (int) (clone $base)->whereIn('status_bucket', ['open', 'waiting'])->count(),
|
||||||
|
'closed' => (int) (clone $base)->where('status_bucket', 'closed')->count(),
|
||||||
|
'serials' => (int) $this->productSerials()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accessor per l'indirizzo completo
|
* Accessor per l'indirizzo completo
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -24,18 +23,18 @@ class PersonaUnitaRelazione extends Model
|
||||||
'riceve_comunicazioni',
|
'riceve_comunicazioni',
|
||||||
'riceve_convocazioni',
|
'riceve_convocazioni',
|
||||||
'vota_assemblea',
|
'vota_assemblea',
|
||||||
'note_relazione'
|
'note_relazione',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'data_inizio' => 'date',
|
'data_inizio' => 'date',
|
||||||
'data_fine' => 'date',
|
'data_fine' => 'date',
|
||||||
'quota_relazione' => 'decimal:2',
|
'quota_relazione' => 'decimal:2',
|
||||||
'ruolo_rate' => 'string',
|
'ruolo_rate' => 'string',
|
||||||
'attivo' => 'boolean',
|
'attivo' => 'boolean',
|
||||||
'riceve_comunicazioni' => 'boolean',
|
'riceve_comunicazioni' => 'boolean',
|
||||||
'riceve_convocazioni' => 'boolean',
|
'riceve_convocazioni' => 'boolean',
|
||||||
'vota_assemblea' => 'boolean'
|
'vota_assemblea' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,7 +108,7 @@ public function getDescrizioneRelazioneAttribute()
|
||||||
*/
|
*/
|
||||||
public function getStatoRelazioneAttribute()
|
public function getStatoRelazioneAttribute()
|
||||||
{
|
{
|
||||||
if (!$this->attivo) {
|
if (! $this->attivo) {
|
||||||
return 'Non Attiva';
|
return 'Non Attiva';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
76
app/Models/Product.php
Normal file
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 = [
|
protected $fillable = [
|
||||||
'fattura_id',
|
'fattura_id',
|
||||||
|
'product_id',
|
||||||
'riga',
|
'riga',
|
||||||
'descrizione',
|
'descrizione',
|
||||||
'imponibile_euro',
|
'imponibile_euro',
|
||||||
|
|
@ -39,6 +40,11 @@ public function fattura(): BelongsTo
|
||||||
return $this->belongsTo(FatturaFornitore::class, 'fattura_id');
|
return $this->belongsTo(FatturaFornitore::class, 'fattura_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Product::class, 'product_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function contoCosto(): BelongsTo
|
public function contoCosto(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(PianoConti::class, 'conto_costo_id');
|
return $this->belongsTo(PianoConti::class, 'conto_costo_id');
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,15 @@
|
||||||
use App\Filament\Pages\Contabilita\ContoMastrino;
|
use App\Filament\Pages\Contabilita\ContoMastrino;
|
||||||
use App\Filament\Pages\Contabilita\PrimaNotaDettaglio;
|
use App\Filament\Pages\Contabilita\PrimaNotaDettaglio;
|
||||||
use App\Filament\Pages\Contabilita\PrimaNotaModifica;
|
use App\Filament\Pages\Contabilita\PrimaNotaModifica;
|
||||||
|
use App\Filament\Pages\Fornitore\TicketOperativi;
|
||||||
|
use App\Filament\Pages\Gescon\FornitoreScheda;
|
||||||
use App\Filament\Pages\Gescon\RubricaUniversaleArchivio;
|
use App\Filament\Pages\Gescon\RubricaUniversaleArchivio;
|
||||||
use App\Filament\Pages\Supporto\SupportoDashboard;
|
use App\Filament\Pages\Supporto\SupportoDashboard;
|
||||||
use App\Filament\Pages\Supporto\TicketMobile;
|
use App\Filament\Pages\Supporto\TicketMobile;
|
||||||
use App\Filament\Widgets\GoogleScadenziarioOverview;
|
use App\Filament\Widgets\GoogleScadenziarioOverview;
|
||||||
|
use App\Models\Fornitore;
|
||||||
|
use App\Models\FornitoreDipendente;
|
||||||
|
use App\Models\User;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
|
|
@ -88,6 +93,15 @@ public function panel(Panel $panel): Panel
|
||||||
return $user instanceof \App\Models\User
|
return $user instanceof \App\Models\User
|
||||||
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
|
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
|
||||||
}),
|
}),
|
||||||
|
NavigationItem::make('Catalogo Fornitore')
|
||||||
|
->group('Fornitore')
|
||||||
|
->icon('heroicon-o-cube')
|
||||||
|
->url(fn() => $this->resolveSupplierCatalogUrl())
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
return $user instanceof User && $user->hasRole('fornitore');
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages')
|
||||||
|
|
@ -117,6 +131,46 @@ public function panel(Panel $panel): Panel
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveSupplierCatalogUrl(): string
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return '/admin-filament';
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = mb_strtolower(trim((string) $user->email));
|
||||||
|
|
||||||
|
if ($email !== '') {
|
||||||
|
$fornitore = Fornitore::query()
|
||||||
|
->whereRaw('LOWER(email) = ?', [$email])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($fornitore instanceof Fornitore) {
|
||||||
|
return FornitoreScheda::getUrl(['record' => (int) $fornitore->id], panel: 'admin-filament');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dipendente = FornitoreDipendente::query()
|
||||||
|
->where('attivo', true)
|
||||||
|
->where(function ($query) use ($user, $email): void {
|
||||||
|
$query->where('user_id', (int) $user->id);
|
||||||
|
|
||||||
|
if ($email !== '') {
|
||||||
|
$query->orWhereRaw('LOWER(email) = ?', [$email]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($dipendente instanceof FornitoreDipendente) {
|
||||||
|
return FornitoreScheda::getUrl(['record' => (int) $dipendente->fornitore_id], panel: 'admin-filament');
|
||||||
|
}
|
||||||
|
|
||||||
|
return TicketOperativi::getUrl(panel: 'admin-filament');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
<?php
|
||||||
|
|
||||||
namespace App\Services\FattureElettroniche;
|
namespace App\Services\FattureElettroniche;
|
||||||
|
|
||||||
use App\Models\Amministratore;
|
use App\Models\Amministratore;
|
||||||
use App\Models\FeCassettoServiceConfig;
|
use App\Models\FeCassettoServiceConfig;
|
||||||
use App\Models\Stabile;
|
use App\Models\Stabile;
|
||||||
use App\Models\StgFatturaAde;
|
use App\Models\StgFatturaAde;
|
||||||
|
use App\Services\FattureElettroniche\P7mExtractor;
|
||||||
use App\Support\ArchivioPaths;
|
use App\Support\ArchivioPaths;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use App\Services\FattureElettroniche\P7mExtractor;
|
|
||||||
|
|
||||||
class CassettoFiscaleDownloadService
|
class CassettoFiscaleDownloadService
|
||||||
{
|
{
|
||||||
|
|
@ -38,7 +37,7 @@ public function downloadAndImport(
|
||||||
$adminLabel = 'ID-' . (int) $amministratore->id;
|
$adminLabel = 'ID-' . (int) $amministratore->id;
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Servizio non abilitato per amministratore ' . $adminLabel . ' (amm_id=' . (int) $amministratore->id . ', stabile_id=' . (int) $stabile->id . ')',
|
'message' => 'Servizio non abilitato per amministratore ' . $adminLabel . ' (amm_id=' . (int) $amministratore->id . ', stabile_id=' . (int) $stabile->id . ')',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -48,10 +47,10 @@ public function downloadAndImport(
|
||||||
$expires = $amministratore->fe_cassetto_expires_at;
|
$expires = $amministratore->fe_cassetto_expires_at;
|
||||||
if ($expires instanceof \DateTimeInterface) {
|
if ($expires instanceof \DateTimeInterface) {
|
||||||
$today = new \DateTimeImmutable('today');
|
$today = new \DateTimeImmutable('today');
|
||||||
$exp = new \DateTimeImmutable($expires->format('Y-m-d'));
|
$exp = new \DateTimeImmutable($expires->format('Y-m-d'));
|
||||||
if ($exp < $today) {
|
if ($exp < $today) {
|
||||||
return [
|
return [
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Servizio scaduto al ' . $exp->format('d/m/Y') . ' (amm_id=' . (int) $amministratore->id . ')',
|
'message' => 'Servizio scaduto al ' . $exp->format('d/m/Y') . ' (amm_id=' . (int) $amministratore->id . ')',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -62,19 +61,19 @@ public function downloadAndImport(
|
||||||
}
|
}
|
||||||
|
|
||||||
$cfg = FeCassettoServiceConfig::query()->first();
|
$cfg = FeCassettoServiceConfig::query()->first();
|
||||||
if (! $cfg) {
|
if (! $cfg && ! $amministratore->fe_cassetto_base_url && ! $amministratore->fe_cassetto_username) {
|
||||||
return ['status' => 'error', 'message' => 'Configurazione servizio Cassetto Fiscale mancante (Super Admin)'];
|
return ['status' => 'error', 'message' => 'Configurazione servizio Cassetto Fiscale mancante (Super Admin)'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = trim((string) ($cfg->base_url ?? ''));
|
$baseUrl = trim((string) ($amministratore->fe_cassetto_base_url ?: ($cfg->base_url ?? '')));
|
||||||
if ($baseUrl === '') {
|
if ($baseUrl === '') {
|
||||||
$baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php';
|
$baseUrl = 'https://thenetworksolution.it/FattureCorrispettivi/ScaricaFatture/CassettoFiscale.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
$passwordScript = (string) ($cfg->password_script ?? '');
|
$passwordScript = trim((string) ($amministratore->fe_cassetto_password_script ?: ($cfg?->password_script ?? '')));
|
||||||
$username = (string) ($cfg->username ?? '');
|
$username = trim((string) ($amministratore->fe_cassetto_username ?: ($cfg?->username ?? '')));
|
||||||
$password = (string) ($cfg->password ?? '');
|
$password = trim((string) ($amministratore->fe_cassetto_password ?: ($cfg?->password ?? '')));
|
||||||
$pin = (string) ($cfg->pin ?? '');
|
$pin = trim((string) ($amministratore->fe_cassetto_pin ?: ($cfg?->pin ?? '')));
|
||||||
|
|
||||||
// passwordScript: nel servizio esterno viene usato come "token"/licenza.
|
// passwordScript: nel servizio esterno viene usato come "token"/licenza.
|
||||||
if ($passwordScript === '') {
|
if ($passwordScript === '') {
|
||||||
|
|
@ -86,7 +85,7 @@ public function downloadAndImport(
|
||||||
}
|
}
|
||||||
|
|
||||||
$dalStr = $dal->format('d-m-Y');
|
$dalStr = $dal->format('d-m-Y');
|
||||||
$alStr = $al->format('d-m-Y');
|
$alStr = $al->format('d-m-Y');
|
||||||
|
|
||||||
$requestHash = hash('sha256', implode('|', [
|
$requestHash = hash('sha256', implode('|', [
|
||||||
'cf=' . $cf,
|
'cf=' . $cf,
|
||||||
|
|
@ -110,34 +109,34 @@ public function downloadAndImport(
|
||||||
|
|
||||||
if ($existingOk && $allowSkip) {
|
if ($existingOk && $allowSkip) {
|
||||||
return [
|
return [
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'message' => 'Download già eseguito per lo stesso periodo (log #' . (int) $existingOk->id . ')',
|
'message' => 'Download già eseguito per lo stesso periodo (log #' . (int) $existingOk->id . ')',
|
||||||
'log_id' => (int) $existingOk->id,
|
'log_id' => (int) $existingOk->id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$logId = (int) DB::table('fe_cassetto_download_logs')->insertGetId([
|
$logId = (int) DB::table('fe_cassetto_download_logs')->insertGetId([
|
||||||
'amministratore_id' => (int) $amministratore->id,
|
'amministratore_id' => (int) $amministratore->id,
|
||||||
'stabile_id' => (int) $stabile->id,
|
'stabile_id' => (int) $stabile->id,
|
||||||
'codice_fiscale_soggetto' => $cf,
|
'codice_fiscale_soggetto' => $cf,
|
||||||
'dal' => $dal->format('Y-m-d'),
|
'dal' => $dal->format('Y-m-d'),
|
||||||
'al' => $al->format('Y-m-d'),
|
'al' => $al->format('Y-m-d'),
|
||||||
'metadati' => $metadati ? 1 : 0,
|
'metadati' => $metadati ? 1 : 0,
|
||||||
'request_hash' => $requestHash,
|
'request_hash' => $requestHash,
|
||||||
'status' => 'started',
|
'status' => 'started',
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$query = [
|
$query = [
|
||||||
'passwordScript' => $passwordScript,
|
'passwordScript' => $passwordScript,
|
||||||
'usernameEntratel' => $username,
|
'usernameEntratel' => $username,
|
||||||
'passwordEntratel' => $password,
|
'passwordEntratel' => $password,
|
||||||
'pinEntratel' => $pin,
|
'pinEntratel' => $pin,
|
||||||
'cf' => $cf,
|
'cf' => $cf,
|
||||||
'dal' => $dalStr,
|
'dal' => $dalStr,
|
||||||
'al' => $alStr,
|
'al' => $alStr,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($metadati) {
|
if ($metadati) {
|
||||||
|
|
@ -168,8 +167,8 @@ public function downloadAndImport(
|
||||||
$msg .= ' | preview: ' . $preview;
|
$msg .= ' | preview: ' . $preview;
|
||||||
}
|
}
|
||||||
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => $msg,
|
'message' => $msg,
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
return ['status' => 'error', 'message' => $msg, 'log_id' => $logId];
|
return ['status' => 'error', 'message' => $msg, 'log_id' => $logId];
|
||||||
|
|
@ -186,22 +185,22 @@ public function downloadAndImport(
|
||||||
throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview);
|
throw new \RuntimeException('Risposta non ZIP (preview): ' . $preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
$zipSha = hash('sha256', $bytes);
|
$zipSha = hash('sha256', $bytes);
|
||||||
$ym = now()->format('Y-m');
|
$ym = now()->format('Y-m');
|
||||||
|
|
||||||
$adminBase = ArchivioPaths::adminBase($amministratore);
|
$adminBase = ArchivioPaths::adminBase($amministratore);
|
||||||
$stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id);
|
$stabileFolder = ArchivioPaths::stabileFolderName($stabile, $amministratore) ?: ('ID-' . (int) $stabile->id);
|
||||||
$stabileBase = $adminBase . '/stabili/' . $stabileFolder;
|
$stabileBase = $adminBase . '/stabili/' . $stabileFolder;
|
||||||
$basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym;
|
$basePath = $stabileBase . '/fatture-ricevute/downloads/' . $ym;
|
||||||
|
|
||||||
$uuid = (string) Str::uuid();
|
$uuid = (string) Str::uuid();
|
||||||
$zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip';
|
$zipPath = $basePath . '/' . $uuid . '-' . $dalStr . '_' . $alStr . '.zip';
|
||||||
Storage::disk('local')->put($zipPath, $bytes);
|
Storage::disk('local')->put($zipPath, $bytes);
|
||||||
|
|
||||||
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
||||||
'zip_sha256' => $zipSha,
|
'zip_sha256' => $zipSha,
|
||||||
'zip_bytes' => strlen($bytes),
|
'zip_bytes' => strlen($bytes),
|
||||||
'status' => 'downloaded',
|
'status' => 'downloaded',
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -223,12 +222,12 @@ public function downloadAndImport(
|
||||||
$zip->extractTo($tmpFsPath);
|
$zip->extractTo($tmpFsPath);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
$riepilogo = $this->findRiepilogoJson($tmpFsPath);
|
$riepilogo = $this->findRiepilogoJson($tmpFsPath);
|
||||||
$riepilogoStoredPath = null;
|
$riepilogoStoredPath = null;
|
||||||
$riepilogoWarnings = [];
|
$riepilogoWarnings = [];
|
||||||
|
|
||||||
if ($riepilogo !== null) {
|
if ($riepilogo !== null) {
|
||||||
$riepilogoFilename = $riepilogo['filename'];
|
$riepilogoFilename = $riepilogo['filename'];
|
||||||
$riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename;
|
$riepilogoStoredPath = $basePath . '/' . $uuid . '-' . $riepilogoFilename;
|
||||||
Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']);
|
Storage::disk('local')->put($riepilogoStoredPath, $riepilogo['contents']);
|
||||||
|
|
||||||
|
|
@ -244,14 +243,14 @@ public function downloadAndImport(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = $this->collectFeFiles($tmpFsPath);
|
$files = $this->collectFeFiles($tmpFsPath);
|
||||||
$filesTotal = count($files);
|
$filesTotal = count($files);
|
||||||
|
|
||||||
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
|
$importer = new FatturaElettronicaImporter(new FatturaElettronicaXmlParser());
|
||||||
$extractor = app(P7mExtractor::class);
|
$extractor = app(P7mExtractor::class);
|
||||||
$imported = 0;
|
$imported = 0;
|
||||||
$duplicates = 0;
|
$duplicates = 0;
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
|
|
||||||
$errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m');
|
$errorsDir = $stabileBase . '/logs/fe-import-errors/' . now()->format('Y/m');
|
||||||
Storage::disk('local')->makeDirectory($errorsDir);
|
Storage::disk('local')->makeDirectory($errorsDir);
|
||||||
|
|
@ -263,7 +262,7 @@ public function downloadAndImport(
|
||||||
|
|
||||||
foreach ($files as $filePath) {
|
foreach ($files as $filePath) {
|
||||||
$filename = basename($filePath);
|
$filename = basename($filePath);
|
||||||
$lower = strtolower($filename);
|
$lower = strtolower($filename);
|
||||||
|
|
||||||
$bytes = @file_get_contents($filePath);
|
$bytes = @file_get_contents($filePath);
|
||||||
if (! is_string($bytes) || trim($bytes) === '') {
|
if (! is_string($bytes) || trim($bytes) === '') {
|
||||||
|
|
@ -271,12 +270,12 @@ public function downloadAndImport(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$unique = (string) Str::uuid();
|
$unique = (string) Str::uuid();
|
||||||
$storedP7mPath = null;
|
$storedP7mPath = null;
|
||||||
$storedXmlPath = null;
|
$storedXmlPath = null;
|
||||||
|
|
||||||
$isP7m = str_ends_with($lower, '.p7m');
|
$isP7m = str_ends_with($lower, '.p7m');
|
||||||
$xml = $isP7m
|
$xml = $isP7m
|
||||||
? null
|
? null
|
||||||
: $bytes;
|
: $bytes;
|
||||||
|
|
||||||
|
|
@ -296,8 +295,8 @@ public function downloadAndImport(
|
||||||
$errors++;
|
$errors++;
|
||||||
try {
|
try {
|
||||||
Storage::disk('local')->append($errorsLogPath, json_encode([
|
Storage::disk('local')->append($errorsLogPath, json_encode([
|
||||||
'type' => 'empty_xml',
|
'type' => 'empty_xml',
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'stored_p7m_path' => $storedP7mPath,
|
'stored_p7m_path' => $storedP7mPath,
|
||||||
'stored_xml_path' => $storedXmlPath,
|
'stored_xml_path' => $storedXmlPath,
|
||||||
], JSON_UNESCAPED_SLASHES));
|
], JSON_UNESCAPED_SLASHES));
|
||||||
|
|
@ -315,16 +314,16 @@ public function downloadAndImport(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $importer->importXml($xml, 0, $xmlFilename, [
|
$result = $importer->importXml($xml, 0, $xmlFilename, [
|
||||||
'xml_path' => $storedXmlPath,
|
'xml_path' => $storedXmlPath,
|
||||||
'p7m_path' => $storedP7mPath,
|
'p7m_path' => $storedP7mPath,
|
||||||
'nome_file_p7m' => $isP7m ? $filename : null,
|
'nome_file_p7m' => $isP7m ? $filename : null,
|
||||||
'force_stabile_id' => (int) $stabile->id,
|
'force_stabile_id' => (int) $stabile->id,
|
||||||
'allow_fallback_stabile' => false,
|
'allow_fallback_stabile' => false,
|
||||||
'force_update' => (bool) ($opts['force_update'] ?? false),
|
'force_update' => (bool) ($opts['force_update'] ?? false),
|
||||||
'auto_repair_duplicate' => true,
|
'auto_repair_duplicate' => true,
|
||||||
'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'),
|
'importa_righe' => (string) ($opts['importa_righe'] ?? 'auto'),
|
||||||
'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true),
|
'create_fornitore_if_missing' => (bool) ($opts['create_fornitore_if_missing'] ?? true),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$status = (string) ($result['status'] ?? 'error');
|
$status = (string) ($result['status'] ?? 'error');
|
||||||
|
|
@ -336,12 +335,12 @@ public function downloadAndImport(
|
||||||
$errors++;
|
$errors++;
|
||||||
try {
|
try {
|
||||||
Storage::disk('local')->append($errorsLogPath, json_encode([
|
Storage::disk('local')->append($errorsLogPath, json_encode([
|
||||||
'type' => 'import_error',
|
'type' => 'import_error',
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'xml_filename' => $xmlFilename,
|
'xml_filename' => $xmlFilename,
|
||||||
'stored_p7m_path' => $storedP7mPath,
|
'stored_p7m_path' => $storedP7mPath,
|
||||||
'stored_xml_path' => $storedXmlPath,
|
'stored_xml_path' => $storedXmlPath,
|
||||||
'result' => $result,
|
'result' => $result,
|
||||||
], JSON_UNESCAPED_SLASHES));
|
], JSON_UNESCAPED_SLASHES));
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
}
|
}
|
||||||
|
|
@ -350,19 +349,19 @@ public function downloadAndImport(
|
||||||
$errors++;
|
$errors++;
|
||||||
try {
|
try {
|
||||||
Storage::disk('local')->append($errorsLogPath, json_encode([
|
Storage::disk('local')->append($errorsLogPath, json_encode([
|
||||||
'type' => 'exception',
|
'type' => 'exception',
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'xml_filename' => $xmlFilename,
|
'xml_filename' => $xmlFilename,
|
||||||
'stored_p7m_path' => $storedP7mPath,
|
'stored_p7m_path' => $storedP7mPath,
|
||||||
'stored_xml_path' => $storedXmlPath,
|
'stored_xml_path' => $storedXmlPath,
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
], JSON_UNESCAPED_SLASHES));
|
], JSON_UNESCAPED_SLASHES));
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$messageParts = [];
|
$messageParts = [];
|
||||||
$messageParts[] = 'zip=' . $zipPath;
|
$messageParts[] = 'zip=' . $zipPath;
|
||||||
if ($riepilogo !== null) {
|
if ($riepilogo !== null) {
|
||||||
$tot = $riepilogo['data']['totaleFatture'] ?? null;
|
$tot = $riepilogo['data']['totaleFatture'] ?? null;
|
||||||
|
|
@ -379,26 +378,26 @@ public function downloadAndImport(
|
||||||
|
|
||||||
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
||||||
'files_total' => $filesTotal,
|
'files_total' => $filesTotal,
|
||||||
'imported' => $imported,
|
'imported' => $imported,
|
||||||
'duplicates' => $duplicates,
|
'duplicates' => $duplicates,
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null,
|
'message' => ! empty($messageParts) ? implode("\n", $messageParts) : null,
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'log_id' => $logId,
|
'log_id' => $logId,
|
||||||
'files_total' => $filesTotal,
|
'files_total' => $filesTotal,
|
||||||
'imported' => $imported,
|
'imported' => $imported,
|
||||||
'duplicates' => $duplicates,
|
'duplicates' => $duplicates,
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
DB::table('fe_cassetto_download_logs')->where('id', $logId)->update([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -423,7 +422,7 @@ private function collectFeFiles(string $rootDir): array
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = $file->getFilename();
|
$name = $file->getFilename();
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
|
|
||||||
// File di supporto del portale (non sono fatture elettroniche).
|
// File di supporto del portale (non sono fatture elettroniche).
|
||||||
|
|
@ -475,7 +474,7 @@ private function findRiepilogoJson(string $rootDir): ?array
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = $file->getFilename();
|
$name = $file->getFilename();
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
if (! str_ends_with($lower, '.json')) {
|
if (! str_ends_with($lower, '.json')) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -498,7 +497,7 @@ private function findRiepilogoJson(string $rootDir): ?array
|
||||||
return [
|
return [
|
||||||
'filename' => $name,
|
'filename' => $name,
|
||||||
'contents' => $contents,
|
'contents' => $contents,
|
||||||
'data' => $decoded,
|
'data' => $decoded,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,32 +522,32 @@ private function upsertAdeFromRiepilogo(Amministratore $amministratore, Stabile
|
||||||
}
|
}
|
||||||
|
|
||||||
$date = null;
|
$date = null;
|
||||||
$d = trim((string) ($row['dataFattura'] ?? ''));
|
$d = trim((string) ($row['dataFattura'] ?? ''));
|
||||||
if ($d !== '') {
|
if ($d !== '') {
|
||||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null;
|
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $d) ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0'));
|
$imponibile = $this->parseAdeMoney((string) ($row['imponibile'] ?? '0'));
|
||||||
$imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0'));
|
$imposta = $this->parseAdeMoney((string) ($row['imposta'] ?? '0'));
|
||||||
|
|
||||||
StgFatturaAde::query()->updateOrCreate(
|
StgFatturaAde::query()->updateOrCreate(
|
||||||
[
|
[
|
||||||
'amministratore_id' => (int) $amministratore->id,
|
'amministratore_id' => (int) $amministratore->id,
|
||||||
'stabile_id' => (int) $stabile->id,
|
'stabile_id' => (int) $stabile->id,
|
||||||
'sdi_file' => $sdi,
|
'sdi_file' => $sdi,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'cartella_legacy' => null,
|
'cartella_legacy' => null,
|
||||||
'tipo_documento' => (string) ($row['tipoDocumento'] ?? null),
|
'tipo_documento' => (string) ($row['tipoDocumento'] ?? null),
|
||||||
'numero_documento' => (string) ($row['numeroFattura'] ?? null),
|
'numero_documento' => (string) ($row['numeroFattura'] ?? null),
|
||||||
'data_emissione' => $date ? $date->format('Y-m-d') : null,
|
'data_emissione' => $date ? $date->format('Y-m-d') : null,
|
||||||
'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))),
|
'identificativo_fornitore' => strtoupper(trim((string) (($row['pivaEmittente'] ?? null) ?? ($row['cfFornitore'] ?? null) ?? ''))),
|
||||||
'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null),
|
'denominazione_fornitore' => (string) ($row['denomFornitore'] ?? null),
|
||||||
'imponibile' => $imponibile,
|
'imponibile' => $imponibile,
|
||||||
'imposta' => $imposta,
|
'imposta' => $imposta,
|
||||||
'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null),
|
'stato_visualizzazione' => (string) (($row['fileDownload']['statoFile'] ?? null) ?? ($row['testoFattureVisualizzate'] ?? null) ?? null),
|
||||||
'source_file' => $sourceFile,
|
'source_file' => $sourceFile,
|
||||||
'raw' => $row,
|
'raw' => $row,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
use App\Models\Stabile;
|
use App\Models\Stabile;
|
||||||
use App\Modules\Contabilita\Models\FatturaFornitore;
|
use App\Modules\Contabilita\Models\FatturaFornitore;
|
||||||
use App\Modules\Contabilita\Models\RegolaPrimaNota;
|
use App\Modules\Contabilita\Models\RegolaPrimaNota;
|
||||||
|
use App\Services\Catalog\FornitoreProductCatalogService;
|
||||||
use App\Support\ArchivioPaths;
|
use App\Support\ArchivioPaths;
|
||||||
use DOMDocument;
|
use DOMDocument;
|
||||||
use DOMXPath;
|
use DOMXPath;
|
||||||
|
|
@ -113,10 +114,13 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$forcedStabileId = (int) ($extra['force_stabile_id'] ?? 0);
|
$preferredStabileId = (int) ($extra['force_stabile_id'] ?? 0);
|
||||||
$stabileId = $forcedStabileId > 0
|
$stabileRouting = $this->resolveStabileRoutingFromXml(
|
||||||
? $forcedStabileId
|
$data,
|
||||||
: $this->resolveStabileIdFromXml($data, $fallbackStabileId, ! empty($extra['allow_fallback_stabile']));
|
$preferredStabileId > 0 ? $preferredStabileId : $fallbackStabileId,
|
||||||
|
! empty($extra['allow_fallback_stabile'])
|
||||||
|
);
|
||||||
|
$stabileId = (int) $stabileRouting['stabile_id'];
|
||||||
|
|
||||||
// Dedup “logico” anche se l'XML differisce (formattazione ecc.)
|
// Dedup “logico” anche se l'XML differisce (formattazione ecc.)
|
||||||
// Nota: evitiamo dedup “logico” se i campi sono placeholder (es. numero = ND) oppure se P.IVA/CF non è disponibile.
|
// Nota: evitiamo dedup “logico” se i campi sono placeholder (es. numero = ND) oppure se P.IVA/CF non è disponibile.
|
||||||
|
|
@ -230,6 +234,7 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
|
||||||
'codice_destinatario' => $data['codice_destinatario'] ?? null,
|
'codice_destinatario' => $data['codice_destinatario'] ?? null,
|
||||||
'pec_destinatario' => $data['pec_destinatario'] ?? null,
|
'pec_destinatario' => $data['pec_destinatario'] ?? null,
|
||||||
'destinatario_cf' => $data['destinatario_cf'] ?? null,
|
'destinatario_cf' => $data['destinatario_cf'] ?? null,
|
||||||
|
'destinatario_piva' => $data['destinatario_piva'] ?? null,
|
||||||
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? null,
|
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? null,
|
||||||
'stato' => 'ricevuta',
|
'stato' => 'ricevuta',
|
||||||
'xml_content' => $xml,
|
'xml_content' => $xml,
|
||||||
|
|
@ -351,6 +356,17 @@ public function importXml(string $xml, int $fallbackStabileId, ?string $original
|
||||||
|
|
||||||
$this->postProcess($created, $xml, $extra);
|
$this->postProcess($created, $xml, $extra);
|
||||||
|
|
||||||
|
if ($fornitoreId && Schema::hasTable('products')) {
|
||||||
|
try {
|
||||||
|
$fornitore = Fornitore::query()->find((int) $fornitoreId);
|
||||||
|
if ($fornitore instanceof Fornitore) {
|
||||||
|
app(FornitoreProductCatalogService::class)->extractFromFattureElettroniche($fornitore, (int) $stabileId, 1);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Il catalogo prodotti non deve interrompere l'import della FE.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ['status' => 'imported', 'id' => (int) $created->id, 'contabilita_fattura_id' => $contabilitaFatturaId];
|
return ['status' => 'imported', 'id' => (int) $created->id, 'contabilita_fattura_id' => $contabilitaFatturaId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1230,51 +1246,163 @@ private function xpathFirstString(DOMXPath $xpath, string $expr, \DOMNode $conte
|
||||||
|
|
||||||
private function resolveStabileIdFromXml(array $parsed, int $fallbackStabileId, bool $allowFallback): int
|
private function resolveStabileIdFromXml(array $parsed, int $fallbackStabileId, bool $allowFallback): int
|
||||||
{
|
{
|
||||||
$cf = $parsed['destinatario_cf'] ?? null;
|
return (int) $this->resolveStabileRoutingFromXml($parsed, $fallbackStabileId, $allowFallback)['stabile_id'];
|
||||||
$codice = $parsed['codice_destinatario'] ?? null;
|
}
|
||||||
$pec = $parsed['pec_destinatario'] ?? null;
|
|
||||||
|
|
||||||
if ($cf) {
|
/**
|
||||||
|
* @return array{stabile_id:int, source:string}
|
||||||
|
*/
|
||||||
|
private function resolveStabileRoutingFromXml(array $parsed, int $preferredStabileId, bool $allowPreferredFallback): array
|
||||||
|
{
|
||||||
|
$directMatch = $this->matchUniqueStabileIdFromParsedIdentifiers($parsed);
|
||||||
|
if ($directMatch !== null) {
|
||||||
|
return $directMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($preferredStabileId > 0 && $this->stabileMatchesParsedRecipientContext($preferredStabileId, $parsed)) {
|
||||||
|
return [
|
||||||
|
'stabile_id' => $preferredStabileId,
|
||||||
|
'source' => 'preferred_context',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allowPreferredFallback && $preferredStabileId > 0) {
|
||||||
|
return [
|
||||||
|
'stabile_id' => $preferredStabileId,
|
||||||
|
'source' => 'preferred_fallback',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Impossibile determinare lo stabile dalla FE (identificativi destinatario non trovati o non compatibili)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{stabile_id:int, source:string}|null
|
||||||
|
*/
|
||||||
|
private function matchUniqueStabileIdFromParsedIdentifiers(array $parsed): ?array
|
||||||
|
{
|
||||||
|
$destCf = $this->normalizeId(isset($parsed['destinatario_cf']) ? (string) $parsed['destinatario_cf'] : '');
|
||||||
|
$destPiva = $this->normalizeId(isset($parsed['destinatario_piva']) ? (string) $parsed['destinatario_piva'] : '');
|
||||||
|
$codice = $this->normalizeId(isset($parsed['codice_destinatario']) ? (string) $parsed['codice_destinatario'] : '');
|
||||||
|
$pec = strtolower(trim((string) ($parsed['pec_destinatario'] ?? '')));
|
||||||
|
|
||||||
|
if ($destCf !== '') {
|
||||||
$match = Stabile::query()
|
$match = Stabile::query()
|
||||||
->whereNotNull('codice_fiscale')
|
->whereNotNull('codice_fiscale')
|
||||||
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$cf])
|
->whereRaw("REPLACE(UPPER(codice_fiscale), ' ', '') = ?", [$destCf])
|
||||||
->where('attivo', true)
|
->where('attivo', true)
|
||||||
->get();
|
->get();
|
||||||
if ($match->count() === 1) {
|
if ($match->count() === 1) {
|
||||||
return (int) $match->first()->id;
|
return [
|
||||||
|
'stabile_id' => (int) $match->first()->id,
|
||||||
|
'source' => 'destinatario_cf',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($codice) {
|
if ($codice !== '') {
|
||||||
$match = Stabile::query()
|
$match = Stabile::query()
|
||||||
->whereNotNull('codice_destinatario_sdi')
|
->whereNotNull('codice_destinatario_sdi')
|
||||||
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
|
->whereRaw("REPLACE(UPPER(codice_destinatario_sdi), ' ', '') = ?", [$codice])
|
||||||
->where('attivo', true)
|
->where('attivo', true)
|
||||||
->get();
|
->get();
|
||||||
if ($match->count() === 1) {
|
if ($match->count() === 1) {
|
||||||
return (int) $match->first()->id;
|
return [
|
||||||
|
'stabile_id' => (int) $match->first()->id,
|
||||||
|
'source' => 'codice_destinatario',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pec) {
|
if ($pec !== '') {
|
||||||
$pec = trim(strtolower((string) $pec));
|
|
||||||
$match = Stabile::query()
|
$match = Stabile::query()
|
||||||
->where(function ($q) use ($pec) {
|
->where(function ($q) use ($pec) {
|
||||||
$q->whereRaw('LOWER(pec_condominio) = ?', [$pec])
|
$q->whereRaw('LOWER(pec_condominio) = ?', [$pec]);
|
||||||
->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
|
|
||||||
|
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
|
||||||
|
$q->orWhereRaw('LOWER(pec_amministratore) = ?', [$pec]);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
->where('attivo', true)
|
->where('attivo', true)
|
||||||
->get();
|
->get();
|
||||||
if ($match->count() === 1) {
|
if ($match->count() === 1) {
|
||||||
return (int) $match->first()->id;
|
return [
|
||||||
|
'stabile_id' => (int) $match->first()->id,
|
||||||
|
'source' => 'pec_destinatario',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($allowFallback && $fallbackStabileId > 0) {
|
if ($destPiva !== '') {
|
||||||
return $fallbackStabileId;
|
$match = Stabile::query()
|
||||||
|
->select('stabili.id')
|
||||||
|
->join('amministratori', 'amministratori.id', '=', 'stabili.amministratore_id')
|
||||||
|
->where('stabili.attivo', true)
|
||||||
|
->whereRaw("REPLACE(UPPER(amministratori.partita_iva), ' ', '') = ?", [$destPiva])
|
||||||
|
->get();
|
||||||
|
if ($match->count() === 1) {
|
||||||
|
return [
|
||||||
|
'stabile_id' => (int) $match->first()->id,
|
||||||
|
'source' => 'destinatario_piva_amministratore',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new \RuntimeException('Impossibile determinare lo stabile dalla FE (CF/SDI/PEC non trovati o non univoci)');
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stabileMatchesParsedRecipientContext(int $stabileId, array $parsed): bool
|
||||||
|
{
|
||||||
|
$stabileColumns = ['id', 'amministratore_id', 'codice_fiscale', 'cod_fisc_amministratore', 'codice_destinatario_sdi', 'pec_condominio'];
|
||||||
|
if (Schema::hasColumn('stabili', 'pec_amministratore')) {
|
||||||
|
$stabileColumns[] = 'pec_amministratore';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stabile = Stabile::query()
|
||||||
|
->with('amministratore:id,partita_iva,codice_fiscale_studio')
|
||||||
|
->select($stabileColumns)
|
||||||
|
->find($stabileId);
|
||||||
|
|
||||||
|
if (! $stabile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$destCf = $this->normalizeId(isset($parsed['destinatario_cf']) ? (string) $parsed['destinatario_cf'] : '');
|
||||||
|
$destPiva = $this->normalizeId(isset($parsed['destinatario_piva']) ? (string) $parsed['destinatario_piva'] : '');
|
||||||
|
$codice = $this->normalizeId(isset($parsed['codice_destinatario']) ? (string) $parsed['codice_destinatario'] : '');
|
||||||
|
$pec = strtolower(trim((string) ($parsed['pec_destinatario'] ?? '')));
|
||||||
|
|
||||||
|
$candidateIds = array_filter([
|
||||||
|
$this->normalizeId((string) ($stabile->codice_fiscale ?? '')),
|
||||||
|
$this->normalizeId((string) ($stabile->cod_fisc_amministratore ?? '')),
|
||||||
|
$this->normalizeId((string) ($stabile->amministratore?->codice_fiscale_studio ?? '')),
|
||||||
|
$this->normalizeId((string) ($stabile->amministratore?->partita_iva ?? '')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($destCf !== '' && in_array($destCf, $candidateIds, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($destPiva !== '' && in_array($destPiva, $candidateIds, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($codice !== '' && $this->normalizeId((string) ($stabile->codice_destinatario_sdi ?? '')) === $codice) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pec !== '') {
|
||||||
|
$candidatePec = array_filter([
|
||||||
|
strtolower(trim((string) ($stabile->pec_condominio ?? ''))),
|
||||||
|
strtolower(trim((string) ($stabile->pec_amministratore ?? ''))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (in_array($pec, $candidatePec, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shouldImportRighe(?int $fornitoreId, array $extra): bool
|
private function shouldImportRighe(?int $fornitoreId, array $extra): bool
|
||||||
|
|
@ -1348,6 +1476,7 @@ private function updateExisting(FatturaElettronica $existing, string $xml, ?stri
|
||||||
'codice_destinatario' => $data['codice_destinatario'] ?? $existing->codice_destinatario,
|
'codice_destinatario' => $data['codice_destinatario'] ?? $existing->codice_destinatario,
|
||||||
'pec_destinatario' => $data['pec_destinatario'] ?? $existing->pec_destinatario,
|
'pec_destinatario' => $data['pec_destinatario'] ?? $existing->pec_destinatario,
|
||||||
'destinatario_cf' => $data['destinatario_cf'] ?? $existing->destinatario_cf,
|
'destinatario_cf' => $data['destinatario_cf'] ?? $existing->destinatario_cf,
|
||||||
|
'destinatario_piva' => $data['destinatario_piva'] ?? $existing->destinatario_piva,
|
||||||
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? $existing->destinatario_denominazione,
|
'destinatario_denominazione' => $data['destinatario_denominazione'] ?? $existing->destinatario_denominazione,
|
||||||
'xml_content' => $xml,
|
'xml_content' => $xml,
|
||||||
'nome_file_xml' => $originalFilename ?: $existing->nome_file_xml,
|
'nome_file_xml' => $originalFilename ?: $existing->nome_file_xml,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class FatturaElettronicaXmlParser
|
||||||
* codice_destinatario: string|null,
|
* codice_destinatario: string|null,
|
||||||
* pec_destinatario: string|null,
|
* pec_destinatario: string|null,
|
||||||
* destinatario_cf: string|null,
|
* destinatario_cf: string|null,
|
||||||
|
* destinatario_piva: string|null,
|
||||||
* destinatario_denominazione: string|null,
|
* destinatario_denominazione: string|null,
|
||||||
* pagamento_modalita: string|null,
|
* pagamento_modalita: string|null,
|
||||||
* pagamento_iban: string|null,
|
* pagamento_iban: string|null,
|
||||||
|
|
@ -120,11 +121,8 @@ public function parse(string $xml): array
|
||||||
$codiceDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='CodiceDestinatario']");
|
$codiceDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='CodiceDestinatario']");
|
||||||
$pecDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='PECDestinatario']");
|
$pecDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='PECDestinatario']");
|
||||||
|
|
||||||
$destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']");
|
$destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']");
|
||||||
$destinatarioId = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']");
|
$destinatarioPiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']");
|
||||||
if (! $destinatarioCf) {
|
|
||||||
$destinatarioCf = $destinatarioId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$destDenominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']");
|
$destDenominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']");
|
||||||
$destNome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']");
|
$destNome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']");
|
||||||
|
|
@ -187,6 +185,7 @@ public function parse(string $xml): array
|
||||||
'codice_destinatario' => $codiceDest ?: null,
|
'codice_destinatario' => $codiceDest ?: null,
|
||||||
'pec_destinatario' => $pecDest ?: null,
|
'pec_destinatario' => $pecDest ?: null,
|
||||||
'destinatario_cf' => $this->normalizeId($destinatarioCf),
|
'destinatario_cf' => $this->normalizeId($destinatarioCf),
|
||||||
|
'destinatario_piva' => $this->normalizeId($destinatarioPiva),
|
||||||
'destinatario_denominazione' => $destDenominazione ?: null,
|
'destinatario_denominazione' => $destDenominazione ?: null,
|
||||||
|
|
||||||
'pagamento_modalita' => $modalitaPagamento ?: null,
|
'pagamento_modalita' => $modalitaPagamento ?: null,
|
||||||
|
|
|
||||||
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\CtiSyncPostItFromStagingCommand;
|
||||||
use App\Console\Commands\DedupeRateEmesseCommand;
|
use App\Console\Commands\DedupeRateEmesseCommand;
|
||||||
use App\Console\Commands\FeCassettoImportLocal;
|
use App\Console\Commands\FeCassettoImportLocal;
|
||||||
|
use App\Console\Commands\FeCassettoImportSoggettoCommand;
|
||||||
use App\Console\Commands\GesconCollegaFornitoreStabili;
|
use App\Console\Commands\GesconCollegaFornitoreStabili;
|
||||||
use App\Console\Commands\GesconImportAlignCommand;
|
use App\Console\Commands\GesconImportAlignCommand;
|
||||||
use App\Console\Commands\GesconImportBanche;
|
use App\Console\Commands\GesconImportBanche;
|
||||||
|
|
@ -17,21 +18,23 @@
|
||||||
use App\Console\Commands\GoogleSyncFornitoriRubricaCommand;
|
use App\Console\Commands\GoogleSyncFornitoriRubricaCommand;
|
||||||
use App\Console\Commands\GoogleSyncRubricaContactsCommand;
|
use App\Console\Commands\GoogleSyncRubricaContactsCommand;
|
||||||
use App\Console\Commands\GoogleSyncTicketCalendarCommand;
|
use App\Console\Commands\GoogleSyncTicketCalendarCommand;
|
||||||
|
use App\Console\Commands\ImportFornitoreWholesaleCsvCommand;
|
||||||
use App\Console\Commands\ImportGesconAcquaLegacyCommand;
|
use App\Console\Commands\ImportGesconAcquaLegacyCommand;
|
||||||
use App\Console\Commands\ImportGesconF24LegacyCommand;
|
use App\Console\Commands\ImportGesconF24LegacyCommand;
|
||||||
use App\Console\Commands\IstatSetIndiceCommand;
|
use App\Console\Commands\IstatSetIndiceCommand;
|
||||||
use App\Console\Commands\IstatSyncIndiciCommand;
|
use App\Console\Commands\IstatSyncIndiciCommand;
|
||||||
|
use App\Console\Commands\NetgesconApplyPbxPresetCommand;
|
||||||
use App\Console\Commands\NetgesconArchiveRegistrySyncCommand;
|
use App\Console\Commands\NetgesconArchiveRegistrySyncCommand;
|
||||||
use App\Console\Commands\NetgesconDevelopmentSnapshotCommand;
|
use App\Console\Commands\NetgesconDevelopmentSnapshotCommand;
|
||||||
use App\Console\Commands\NetgesconDistributionPullCommand;
|
use App\Console\Commands\NetgesconDistributionPullCommand;
|
||||||
use App\Console\Commands\NetgesconGitSyncCommand;
|
use App\Console\Commands\NetgesconGitSyncCommand;
|
||||||
use App\Console\Commands\NetgesconApplyPbxPresetCommand;
|
|
||||||
use App\Console\Commands\NetgesconPreupdateBackupCommand;
|
use App\Console\Commands\NetgesconPreupdateBackupCommand;
|
||||||
use App\Console\Commands\NetgesconQaUnitaNominativiCommand;
|
use App\Console\Commands\NetgesconQaUnitaNominativiCommand;
|
||||||
use App\Console\Commands\NetgesconRestoreRecordCommand;
|
use App\Console\Commands\NetgesconRestoreRecordCommand;
|
||||||
use App\Console\Commands\PanasonicCstaBridgeCommand;
|
use App\Console\Commands\PanasonicCstaBridgeCommand;
|
||||||
use App\Console\Commands\StabiliTransferCommand;
|
use App\Console\Commands\StabiliTransferCommand;
|
||||||
use App\Console\Commands\SyncSoggettiToPersone;
|
use App\Console\Commands\SyncSoggettiToPersone;
|
||||||
|
use App\Console\Commands\TecnoRepairImportLegacyArchiveCommand;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -56,9 +59,11 @@
|
||||||
GesconQaContatori::class,
|
GesconQaContatori::class,
|
||||||
GesconQaEmissione::class,
|
GesconQaEmissione::class,
|
||||||
FeCassettoImportLocal::class,
|
FeCassettoImportLocal::class,
|
||||||
|
FeCassettoImportSoggettoCommand::class,
|
||||||
IstatSyncIndiciCommand::class,
|
IstatSyncIndiciCommand::class,
|
||||||
IstatSetIndiceCommand::class,
|
IstatSetIndiceCommand::class,
|
||||||
ImportGesconAcquaLegacyCommand::class,
|
ImportGesconAcquaLegacyCommand::class,
|
||||||
|
ImportFornitoreWholesaleCsvCommand::class,
|
||||||
GesconImportAlignCommand::class,
|
GesconImportAlignCommand::class,
|
||||||
ImportGesconF24LegacyCommand::class,
|
ImportGesconF24LegacyCommand::class,
|
||||||
NetgesconQaUnitaNominativiCommand::class,
|
NetgesconQaUnitaNominativiCommand::class,
|
||||||
|
|
@ -69,6 +74,7 @@
|
||||||
NetgesconGitSyncCommand::class,
|
NetgesconGitSyncCommand::class,
|
||||||
NetgesconPreupdateBackupCommand::class,
|
NetgesconPreupdateBackupCommand::class,
|
||||||
NetgesconRestoreRecordCommand::class,
|
NetgesconRestoreRecordCommand::class,
|
||||||
|
TecnoRepairImportLegacyArchiveCommand::class,
|
||||||
PanasonicCstaBridgeCommand::class,
|
PanasonicCstaBridgeCommand::class,
|
||||||
GoogleSyncRubricaContactsCommand::class,
|
GoogleSyncRubricaContactsCommand::class,
|
||||||
GooglePushRubricaContactsCommand::class,
|
GooglePushRubricaContactsCommand::class,
|
||||||
|
|
|
||||||
8
config/catalog.php
Normal file
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
|
## Sorgenti da considerare
|
||||||
|
|
||||||
### 1. Metadati stabili
|
### 1. Metadati stabili
|
||||||
|
|
||||||
- `dbc/Stabili.mdb`
|
- `dbc/Stabili.mdb`
|
||||||
- campi chiave attesi:
|
- campi chiave attesi:
|
||||||
- `cod_stabile`
|
- `cod_stabile`
|
||||||
|
|
@ -14,6 +15,7 @@ ### 1. Metadati stabili
|
||||||
- campo CF stabile/condominio valorizzato
|
- campo CF stabile/condominio valorizzato
|
||||||
|
|
||||||
### 2. Dati annuali per stabile
|
### 2. Dati annuali per stabile
|
||||||
|
|
||||||
- `<cod_stabile>/<anno>/singolo_anno.mdb`
|
- `<cod_stabile>/<anno>/singolo_anno.mdb`
|
||||||
- tabelle minime da validare:
|
- tabelle minime da validare:
|
||||||
- `condomin`
|
- `condomin`
|
||||||
|
|
@ -25,6 +27,7 @@ ### 2. Dati annuali per stabile
|
||||||
- `straordinarie`
|
- `straordinarie`
|
||||||
|
|
||||||
### 3. Dati trasversali stabile
|
### 3. Dati trasversali stabile
|
||||||
|
|
||||||
- `<cod_stabile>/generale_stabile.mdb`
|
- `<cod_stabile>/generale_stabile.mdb`
|
||||||
- utile per verifiche aggiuntive su rate emesse, casse e dati generali
|
- utile per verifiche aggiuntive su rate emesse, casse e dati generali
|
||||||
|
|
||||||
|
|
@ -49,14 +52,17 @@ ## Finestra temporale smoke test
|
||||||
Per il primo passaggio operativo non importare tutto lo storico.
|
Per il primo passaggio operativo non importare tutto lo storico.
|
||||||
|
|
||||||
### Import base
|
### Import base
|
||||||
|
|
||||||
- anno corrente legacy piu recente disponibile
|
- anno corrente legacy piu recente disponibile
|
||||||
- anno precedente
|
- anno precedente
|
||||||
|
|
||||||
### Import esteso solo per straordinarie
|
### Import esteso solo per straordinarie
|
||||||
|
|
||||||
- includere fino a 5 anni indietro
|
- includere fino a 5 anni indietro
|
||||||
- fermarsi prima se non esistono MDB annuali o non esistono righe utili nelle tabelle straordinarie
|
- fermarsi prima se non esistono MDB annuali o non esistono righe utili nelle tabelle straordinarie
|
||||||
|
|
||||||
### Storico oltre la finestra base
|
### Storico oltre la finestra base
|
||||||
|
|
||||||
- da importare solo su richiesta o quando serve per quadrature, estratti conto storici o audit mirati
|
- da importare solo su richiesta o quando serve per quadrature, estratti conto storici o audit mirati
|
||||||
|
|
||||||
## Copertura minima richiesta per rendere operativo il sistema
|
## Copertura minima richiesta per rendere operativo il sistema
|
||||||
|
|
@ -83,22 +89,26 @@ ## Smoke Test pratico per stabile
|
||||||
Per ogni stabile filtrato da `Stabili.mdb` eseguire questa checklist.
|
Per ogni stabile filtrato da `Stabili.mdb` eseguire questa checklist.
|
||||||
|
|
||||||
### A. Controllo metadati
|
### A. Controllo metadati
|
||||||
|
|
||||||
- `cod_stabile` valorizzato
|
- `cod_stabile` valorizzato
|
||||||
- `nome_directory` valorizzato
|
- `nome_directory` valorizzato
|
||||||
- CF stabile valorizzato
|
- CF stabile valorizzato
|
||||||
- cartella legacy esistente
|
- cartella legacy esistente
|
||||||
|
|
||||||
### B. Controllo annualita
|
### B. Controllo annualita
|
||||||
|
|
||||||
- individuato ultimo anno disponibile
|
- individuato ultimo anno disponibile
|
||||||
- individuato anno precedente disponibile
|
- individuato anno precedente disponibile
|
||||||
- individuate eventuali annualita straordinarie fino a -5 anni
|
- individuate eventuali annualita straordinarie fino a -5 anni
|
||||||
|
|
||||||
### C. Controllo file
|
### C. Controllo file
|
||||||
|
|
||||||
- esiste `singolo_anno.mdb` per ogni anno scelto
|
- esiste `singolo_anno.mdb` per ogni anno scelto
|
||||||
- se presente, esiste `generale_stabile.mdb`
|
- se presente, esiste `generale_stabile.mdb`
|
||||||
- `Stabili.mdb` leggibile senza errori
|
- `Stabili.mdb` leggibile senza errori
|
||||||
|
|
||||||
### D. Controllo tabelle minime
|
### D. Controllo tabelle minime
|
||||||
|
|
||||||
- `condomin` leggibile
|
- `condomin` leggibile
|
||||||
- `operazioni` leggibile
|
- `operazioni` leggibile
|
||||||
- `rate` leggibile
|
- `rate` leggibile
|
||||||
|
|
@ -108,6 +118,7 @@ ### D. Controllo tabelle minime
|
||||||
- `straordinarie` leggibile o assente in modo coerente
|
- `straordinarie` leggibile o assente in modo coerente
|
||||||
|
|
||||||
### E. QA dominio post-import
|
### E. QA dominio post-import
|
||||||
|
|
||||||
- stabile creato/aggiornato correttamente
|
- stabile creato/aggiornato correttamente
|
||||||
- unita importate
|
- unita importate
|
||||||
- rubrica/nominativi deduplicati
|
- rubrica/nominativi deduplicati
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,79 @@ ### Caso B: trovi TAPI ma zero indirizzi
|
||||||
3. IP PBX e porta `33333`
|
3. IP PBX e porta `33333`
|
||||||
4. licenza CTI lato centralino
|
4. licenza CTI lato centralino
|
||||||
|
|
||||||
|
### Caso B2: Panasonic esiste nel registry ma `Telephony Providers` e zero
|
||||||
|
|
||||||
|
Se il report di diagnostica mostra contemporaneamente:
|
||||||
|
|
||||||
|
- chiavi Panasonic presenti sotto `HKLM\SOFTWARE\Panasonic\KX_TDA_TSP`
|
||||||
|
- `Telephony providers x64: 0`
|
||||||
|
- `Telephony providers wow64: 0`
|
||||||
|
- nessun provider Panasonic tra i provider TAPI registrati
|
||||||
|
|
||||||
|
allora il problema non e il bridge NetGescon.
|
||||||
|
|
||||||
|
Il problema e che il TSP Panasonic e installato o configurato nei suoi dati locali, ma non e registrato correttamente dentro il sottosistema Telephony di Windows.
|
||||||
|
|
||||||
|
In pratica Windows vede TAPI, ma non vede il provider Panasonic come provider utilizzabile.
|
||||||
|
|
||||||
|
In questo stato:
|
||||||
|
|
||||||
|
1. gli script NetGescon possono partire
|
||||||
|
2. il bridge HTTP puo funzionare
|
||||||
|
3. ma TAPI continuera a esporre zero linee reali o un solo address vuoto
|
||||||
|
|
||||||
|
La correzione va fatta su Windows:
|
||||||
|
|
||||||
|
1. aprire `telephon.cpl`
|
||||||
|
2. verificare nella sezione provider che Panasonic sia realmente presente
|
||||||
|
3. se assente, reinstallare o registrare di nuovo il `KX-TDA TSP`
|
||||||
|
4. verificare compatibilita 32 bit / 64 bit del TSP sulla macchina
|
||||||
|
5. solo dopo ripetere `get-netgescon-panasonic-tapi-info.ps1`
|
||||||
|
|
||||||
|
## Checklist operativa rapida
|
||||||
|
|
||||||
|
Usa questa checklist quando il bridge NetGescon parte ma il provider TAPI non espone linee reali.
|
||||||
|
|
||||||
|
### Stato gia confermato su questa macchina
|
||||||
|
|
||||||
|
1. il task `NetGescon Panasonic Live Bridge` parte ed e in stato `Running`
|
||||||
|
2. il watcher .NET TAPI carica l'assembly e si sottoscrive agli eventi
|
||||||
|
3. il bridge HTTP NetGescon non e il blocco principale
|
||||||
|
4. TAPI vede solo `1` address vuoto
|
||||||
|
5. `Telephony providers x64 = 0`
|
||||||
|
6. `Telephony providers wow64 = 0`
|
||||||
|
7. il registry Panasonic esiste sotto `HKLM\SOFTWARE\Panasonic\KX_TDA_TSP`
|
||||||
|
|
||||||
|
### Cosa manca davvero
|
||||||
|
|
||||||
|
1. il provider Panasonic deve comparire dentro `telephon.cpl`
|
||||||
|
2. il provider Panasonic deve essere registrato nei `Telephony Providers` di Windows
|
||||||
|
3. il TSP deve essere coerente con l'architettura della macchina `32/64 bit`
|
||||||
|
4. la connessione del TSP deve puntare al PBX corretto e non restare su profilo `USB:PC`
|
||||||
|
5. dopo la correzione, `get-netgescon-panasonic-tapi-info.ps1` deve mostrare address reali con `AddressName` o `DialableAddress`
|
||||||
|
|
||||||
|
### Sequenza pratica consigliata
|
||||||
|
|
||||||
|
1. apri `telephon.cpl`
|
||||||
|
2. se Panasonic non compare tra i provider, reinstalla `KX-TDA TSP` come amministratore
|
||||||
|
3. se Panasonic compare, apri la configurazione del provider e verifica:
|
||||||
|
|
||||||
|
- IP PBX `192.168.0.101`
|
||||||
|
- porta `33333`
|
||||||
|
- nessun trasporto bloccato su `USB:PC` se il collegamento e IP
|
||||||
|
|
||||||
|
4. verifica che il centralino abbia licenza CTI attiva lato TSP/CSTA
|
||||||
|
2. salva la configurazione e riavvia il tool Panasonic se richiesto
|
||||||
|
3. rilancia `get-netgescon-panasonic-tapi-info.ps1`
|
||||||
|
4. conferma che compaiano interni reali come `201`, `205`, `206`
|
||||||
|
|
||||||
|
### Solo se Panasonic non compare ancora
|
||||||
|
|
||||||
|
1. controlla in `Program Files` se i file `.tsp` Panasonic sono presenti
|
||||||
|
2. controlla se l'installer Panasonic usato e la versione giusta per il sistema operativo
|
||||||
|
3. esegui reinstallazione pulita del TSP
|
||||||
|
4. riapri `telephon.cpl` e verifica di nuovo i provider
|
||||||
|
|
||||||
### Caso C: gli script API falliscono con `401 Unauthorized`
|
### Caso C: gli script API falliscono con `401 Unauthorized`
|
||||||
|
|
||||||
Significa che il token non coincide con quello configurato in NetGescon.
|
Significa che il token non coincide con quello configurato in NetGescon.
|
||||||
|
|
@ -247,6 +320,119 @@ ## Passo 7: ascolto eventi COM reali
|
||||||
.\scripts\ops\windows\watch-netgescon-panasonic-tapi-com-events.ps1 -Extensions "201,205,206" -LogFile .\netgescon-tapi-com-events.log
|
.\scripts\ops\windows\watch-netgescon-panasonic-tapi-com-events.ps1 -Extensions "201,205,206" -LogFile .\netgescon-tapi-com-events.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ripartenza domani mattina
|
||||||
|
|
||||||
|
Questa e la situazione gia confermata e non va rimessa in discussione salvo nuovi errori evidenti.
|
||||||
|
|
||||||
|
### Stato validato
|
||||||
|
|
||||||
|
1. Panasonic TAPI espone linee reali.
|
||||||
|
2. Il watcher .NET registra correttamente `GRP00603`.
|
||||||
|
3. `RegisterCallNotifications` funziona su `603`.
|
||||||
|
4. Il bridge `incoming` in modalita `live` arriva a staging e crea Post-it e Communication Message.
|
||||||
|
5. Il bug residuo e solo `call-ended` verso staging.
|
||||||
|
|
||||||
|
### Comando rapido di test live
|
||||||
|
|
||||||
|
Da Windows, nel repo:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\NetGescon\netgescon-day0\scripts\ops\windows
|
||||||
|
.\bootstrap-netgescon-panasonic-live.cmd -Action live-run -Extensions "603"
|
||||||
|
```
|
||||||
|
|
||||||
|
In un'altra finestra:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 120 -Wait
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cosa deve essere considerato corretto
|
||||||
|
|
||||||
|
Se compaiono righe come queste, la parte Windows e ok:
|
||||||
|
|
||||||
|
1. `REGISTER OK mode=monitor+owner`
|
||||||
|
2. `BRIDGE LIVE OK endpoint=incoming`
|
||||||
|
3. `BRIDGE MARK answered`
|
||||||
|
|
||||||
|
### Bug residuo da chiudere
|
||||||
|
|
||||||
|
Se compare ancora:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BRIDGE LIVE KO endpoint=call-ended error=Richiesta annullata: Chiusura imprevista della connessione.
|
||||||
|
```
|
||||||
|
|
||||||
|
allora il problema non e piu Panasonic Windows.
|
||||||
|
|
||||||
|
Il problema e sul lato staging, in uno di questi livelli:
|
||||||
|
|
||||||
|
1. codice Laravel deployato non allineato
|
||||||
|
2. PHP-FPM / web server che chiude la richiesta `call-ended`
|
||||||
|
3. eccezione backend prima della risposta JSON
|
||||||
|
|
||||||
|
### Verifica server minima
|
||||||
|
|
||||||
|
Sul server staging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "call-ended\|CTI Panasonic call-ended failed" storage/logs/laravel.log | tail -n 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Se non esce nulla di nuovo mentre `incoming` continua a funzionare, controlla anche i log del web server o di PHP-FPM all'orario della chiamata.
|
||||||
|
|
||||||
|
### Strategia pratica consigliata
|
||||||
|
|
||||||
|
Per non bloccare il lavoro:
|
||||||
|
|
||||||
|
1. usa TAPI live per `incoming`
|
||||||
|
2. usa SMDR per la conferma durata / chiusura chiamata finche `call-ended` non e chiuso lato staging
|
||||||
|
3. affronta il bug `call-ended` come task separato lato server
|
||||||
|
|
||||||
|
## Stato applicativo da portare su staging
|
||||||
|
|
||||||
|
Questo e il pacchetto minimo gia pronto lato applicazione e documentazione.
|
||||||
|
|
||||||
|
### Ticket Mobile
|
||||||
|
|
||||||
|
1. il contesto chiamata viene mostrato sopra il titolo ticket
|
||||||
|
2. numero chiamante, interno o gruppo chiamato, riferimenti stabile/unita e nota operativa non vengono piu mischiati nel testo libero del problema
|
||||||
|
3. il titolo precompilato resta operativo e la descrizione non viene appesantita da dettagli tecnici PBX
|
||||||
|
|
||||||
|
### Post-it Gestione
|
||||||
|
|
||||||
|
1. prima della conversione in ticket c'e una nota libera operatore
|
||||||
|
2. le chiamate tecniche SMDR e CSTA usano etichette piu chiare come `Chiamata ricevuta`, `Chiamata effettuata`, `Chiamata interna`
|
||||||
|
3. il filtro tecnico permette di cercare per linea o gruppo, per esempio `0003`, `603`, `GRP00603`
|
||||||
|
|
||||||
|
### Watchlist pratica corrente
|
||||||
|
|
||||||
|
Per il preset Day0 la watchlist operativa da considerare e questa:
|
||||||
|
|
||||||
|
1. `0001`
|
||||||
|
2. `0003`
|
||||||
|
3. `201`
|
||||||
|
4. `206`
|
||||||
|
5. `601`
|
||||||
|
6. `603`
|
||||||
|
|
||||||
|
Lato applicazione, la risoluzione estensioni osservate e gia compatibile con:
|
||||||
|
|
||||||
|
1. `watch_extensions`
|
||||||
|
2. `response_groups`
|
||||||
|
3. `incoming_lines`
|
||||||
|
|
||||||
|
### Punto aperto che non deve bloccare il deploy
|
||||||
|
|
||||||
|
Se `incoming` continua a funzionare ma `call-ended` continua a chiudere la connessione, il deploy staging va comunque avanti per validare:
|
||||||
|
|
||||||
|
1. banner live operatore
|
||||||
|
2. Ticket Mobile aggiornato
|
||||||
|
3. Post-it Gestione aggiornato
|
||||||
|
4. filtri linea e conversione con nota operatore
|
||||||
|
|
||||||
|
In questo scenario la chiusura chiamata resta temporaneamente coperta da SMDR.
|
||||||
|
|
||||||
### Cosa deve succedere nell'ispezione interop
|
### Cosa deve succedere nell'ispezione interop
|
||||||
|
|
||||||
All'avvio vedrai le righe di setup e `REGISTER OK`.
|
All'avvio vedrai le righe di setup e `REGISTER OK`.
|
||||||
|
|
@ -375,6 +561,18 @@ ### 10.1 Avvio live manuale
|
||||||
.\scripts\ops\windows\start-netgescon-panasonic-live.ps1 -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log"
|
.\scripts\ops\windows\start-netgescon-panasonic-live.ps1 -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "C:\ProgramData\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Se PowerShell blocca gli script con `ExecutionPolicy`, usa il wrapper `.cmd` oppure lancia esplicitamente PowerShell in bypass solo per il processo corrente:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\ops\windows\start-netgescon-panasonic-live.ps1 -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
oppure:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\ops\windows\start-netgescon-panasonic-live.cmd -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log"
|
||||||
|
```
|
||||||
|
|
||||||
### 10.2 Installazione come task automatico all'avvio macchina con account SYSTEM
|
### 10.2 Installazione come task automatico all'avvio macchina con account SYSTEM
|
||||||
|
|
||||||
In PowerShell amministratore:
|
In PowerShell amministratore:
|
||||||
|
|
@ -399,9 +597,34 @@ ### 10.3 Installazione come task al logon utente corrente
|
||||||
.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop"
|
.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.4 Sequenza rapida in 6 comandi per il tuo caso `S:\`
|
### 10.4 Sequenza rapida per il tuo caso `S:\`
|
||||||
|
|
||||||
Se il repo e davvero montato in `S:\` e non hai privilegi amministrativi, usa questi 6 comandi esatti nell'ordine indicato.
|
Attenzione: `S:\` in Windows di solito e un drive mappato di sessione.
|
||||||
|
Questo significa che:
|
||||||
|
|
||||||
|
- puo essere visibile in Esplora file ma non in PowerShell elevata
|
||||||
|
- puo essere visibile nel tuo utente ma non nei task eseguiti come `SYSTEM`
|
||||||
|
- non va considerato affidabile per il bridge live automatico all'avvio macchina
|
||||||
|
|
||||||
|
Se `S:` non esiste nel prompt PowerShell, non usare `S:\` per installare o avviare il task.
|
||||||
|
Usa invece una di queste due strade:
|
||||||
|
|
||||||
|
1. meglio: copia il repo o almeno `scripts\ops\windows` in un percorso locale, per esempio `C:\NetGescon` oppure `C:\ProgramData\NetGescon\repo`
|
||||||
|
2. in alternativa: usa il percorso UNC reale della share invece del drive mappato, se il task gira con un utente che ha accesso alla share
|
||||||
|
|
||||||
|
Procedura piu robusta consigliata:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-ExecutionPolicy -Scope Process Bypass -Force
|
||||||
|
cd C:\NetGescon\netgescon-day0\scripts\ops\windows
|
||||||
|
.\publish-netgescon-panasonic-runtime.ps1
|
||||||
|
cd C:\ProgramData\NetGescon\panasonic-live\current
|
||||||
|
.\install-netgescon-panasonic-live-task.cmd -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop"
|
||||||
|
```
|
||||||
|
|
||||||
|
Con questo approccio, quando aggiorni il repo ti basta rilanciare `publish-netgescon-panasonic-runtime.ps1` e poi reinstallare il task se hai cambiato i launcher.
|
||||||
|
|
||||||
|
Solo se `S:` esiste davvero nella stessa sessione PowerShell puoi usare i comandi sotto.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-ExecutionPolicy -Scope Process Bypass -Force
|
Set-ExecutionPolicy -Scope Process Bypass -Force
|
||||||
|
|
@ -456,11 +679,14 @@ ### 11.4 Dove vedere le nuove chiamate su staging
|
||||||
Quando l'invio live va a buon fine, le nuove chiamate Panasonic compaiono in due punti applicativi:
|
Quando l'invio live va a buon fine, le nuove chiamate Panasonic compaiono in due punti applicativi:
|
||||||
|
|
||||||
1. `Strumenti -> Post-it Gestione`
|
1. `Strumenti -> Post-it Gestione`
|
||||||
- legge i record `chiamate_post_it`
|
|
||||||
- e il punto principale per vedere le chiamate create dal bridge
|
- legge i record `chiamate_post_it`
|
||||||
2. flussi tecnici basati su `communication_messages`
|
- e il punto principale per vedere le chiamate create dal bridge
|
||||||
- canale `panasonic_csta`
|
|
||||||
- utili per verifica tecnica e correlazione con routing/intercettazione operatore
|
1. flussi tecnici basati su `communication_messages`
|
||||||
|
|
||||||
|
- canale `panasonic_csta`
|
||||||
|
- utili per verifica tecnica e correlazione con routing/intercettazione operatore
|
||||||
|
|
||||||
In aggiunta, il banner operatore mobile usa anche `panasonic_csta`, quindi una chiamata instradata a un interno monitorato puo comparire anche lato operatore senza passare solo da SMDR.
|
In aggiunta, il banner operatore mobile usa anche `panasonic_csta`, quindi una chiamata instradata a un interno monitorato puo comparire anche lato operatore senza passare solo da SMDR.
|
||||||
|
|
||||||
|
|
@ -469,9 +695,9 @@ ### 11.5 Procedura rapida di ripartenza se staging smette di ricevere chiamate
|
||||||
Sul PC Windows:
|
Sul PC Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd S:\
|
cd C:\ProgramData\NetGescon\panasonic-live\current
|
||||||
Stop-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" -ErrorAction SilentlyContinue
|
Stop-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge" -ErrorAction SilentlyContinue
|
||||||
.\scripts\ops\windows\install-netgescon-panasonic-live-task.ps1 -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop"
|
.\install-netgescon-panasonic-live-task.cmd -Mode current-user-logon -BaseUrl "https://staging.netgescon.it" -Extensions "201-208,601,603,0001,0003" -LogFile "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -InteropOutputDir "$env:LOCALAPPDATA\NetGescon\tapi-interop"
|
||||||
Start-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge"
|
Start-ScheduledTask -TaskName "NetGescon Panasonic Live Bridge"
|
||||||
Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait
|
Get-Content "$env:LOCALAPPDATA\NetGescon\Logs\netgescon-tapi-dotnet-events-live.log" -Tail 100 -Wait
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,28 @@ ## Prossimo step pragmatico
|
||||||
3. collegare `TClienti` e `TFornitori` alla rubrica/fornitori NetGescon con matching su telefono, email, CF/PIVA
|
3. collegare `TClienti` e `TFornitori` alla rubrica/fornitori NetGescon con matching su telefono, email, CF/PIVA
|
||||||
4. esporre in UI la scheda riparazione collegata a cliente, fornitore e ticket/intervento
|
4. esporre in UI la scheda riparazione collegata a cliente, fornitore e ticket/intervento
|
||||||
|
|
||||||
|
## Primo slice disponibile in Day0
|
||||||
|
|
||||||
|
- Tabelle staging create:
|
||||||
|
- `assistenza_tecnorepair_schede_legacy`
|
||||||
|
- `assistenza_tecnorepair_allegati_legacy`
|
||||||
|
- `product_serials`
|
||||||
|
- Nuovo flag fornitore: `fornitori.is_tecnorepair_primary`
|
||||||
|
- Nuovo comando import:
|
||||||
|
- `php artisan tecnorepair:import-legacy {amministratore} --force-primary`
|
||||||
|
- opzionale: `--fornitore-id=` oppure `--fornitore-term=NETHOME`
|
||||||
|
- Nuova pagina Filament:
|
||||||
|
- `Supporto > Assistenza Legacy`
|
||||||
|
- elenco filtrabile con colori per stato, dettaglio scheda, seriali e allegati legacy
|
||||||
|
|
||||||
|
### Note operative del comando
|
||||||
|
|
||||||
|
- default MDB:
|
||||||
|
- `/home/michele/netgescon/netgescon-day0/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb`
|
||||||
|
- import collega di default le schede al primo fornitore dello stesso amministratore che matcha `NETHOME`
|
||||||
|
- con `--force-primary` il fornitore collegato viene marcato come centro TecnoRepair principale
|
||||||
|
- l'import resta idempotente sul vincolo `imported_from_path + legacy_id`
|
||||||
|
|
||||||
## Decisione corrente
|
## Decisione corrente
|
||||||
|
|
||||||
- Si: NetGescon puo leggere l'MDB subito.
|
- Si: NetGescon puo leggere l'MDB subito.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,26 @@
|
||||||
<div class="-m-6">
|
<div class="-m-6 space-y-3 p-2">
|
||||||
<iframe
|
<div class="flex items-center justify-end gap-2 px-4 pt-4">
|
||||||
title="PDF"
|
<a
|
||||||
class="h-[75vh] w-full"
|
href="{{ $url }}"
|
||||||
src="{{ $url }}"
|
target="_blank"
|
||||||
></iframe>
|
rel="noopener"
|
||||||
|
class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Apri in nuova scheda
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-[78vh] w-full px-4 pb-4">
|
||||||
|
<object
|
||||||
|
data="{{ $url }}#toolbar=1&navpanes=1&view=FitH"
|
||||||
|
type="application/pdf"
|
||||||
|
class="h-full w-full rounded-lg border bg-white"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
title="PDF"
|
||||||
|
class="h-full w-full rounded-lg border"
|
||||||
|
src="{{ $url }}#toolbar=1&navpanes=1&view=FitH"
|
||||||
|
></iframe>
|
||||||
|
</object>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@
|
||||||
</x-filament::tabs>
|
</x-filament::tabs>
|
||||||
|
|
||||||
<div x-show="tab === 'fe'">
|
<div x-show="tab === 'fe'">
|
||||||
|
<div class="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
|
||||||
|
<div class="font-semibold">Stabile attivo per archivio FE</div>
|
||||||
|
<div class="mt-1">{{ $this->activeStabileLabel }}</div>
|
||||||
|
<div class="mt-1 text-xs text-sky-700">L'elenco mostra solo le fatture collegate allo stabile attivo selezionato nella topbar.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
|
|
||||||
<div class="mt-2 text-sm text-gray-500">
|
<div class="mt-2 text-sm text-gray-500">
|
||||||
|
|
@ -51,6 +57,12 @@
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">Scarichi da Cassetto Fiscale</x-slot>
|
<x-slot name="heading">Scarichi da Cassetto Fiscale</x-slot>
|
||||||
|
|
||||||
|
<div class="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
<div class="font-semibold">Destinazione scarico corrente</div>
|
||||||
|
<div class="mt-1">{{ $this->activeStabileLabel }}</div>
|
||||||
|
<div class="mt-1 text-xs text-amber-700">I trimestri scaricati in questa tab vengono associati allo stabile attivo visibile in topbar al momento del click.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
Verifica trimestri già scaricati (se completo, evita ulteriori scarichi).
|
Verifica trimestri già scaricati (se completo, evita ulteriori scarichi).
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,190 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold">Modulo fornitore separato</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">Catalogo isolato per questo fornitore tramite codice univoco, con riferimenti pubblici interni e sorgenti esterne archiviate solo nel gestionale.</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-900 px-3 py-2 text-right text-white">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.18em] text-slate-300">Scope</div>
|
||||||
|
<div class="font-mono text-sm font-semibold">{{ $box['catalogo_modulo']['scope_code'] ?? ($fornitore->codice_univoco ?? ('FORN-' . $fornitore->id)) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-7">
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Da FE</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['fe_products'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Da CSV</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['csv_products'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Manuali</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['manual_products'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Solo interni</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['internal_only'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Link sorgente</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['private_links'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Offerte</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['offers'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Amazon link</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['catalogo_modulo']['amazon_links'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa listino CSV</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Internalizza asset</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Prepara link Amazon</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold">Catalogo operativo interno</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">Anagrafica prodotti/servizi del fornitore. I riferimenti esterni importati restano interni al gestionale e la pubblicazione usa il codice interno del catalogo.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa CSV</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Asset interni</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Amazon</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Prodotti</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['count'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Serializzati</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['serializzati'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Codici agganciati</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['identifiers'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-slate-50 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Media</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold text-slate-900">{{ (int) ($box['prodotti']['with_media'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="min-w-full border-collapse border text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-100 text-slate-700">
|
||||||
|
<th class="border px-2 py-1.5 text-left">Codice</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Prodotto</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Marca / modello</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Origine</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Rif. interno</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Codici</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Seriali</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Offerte</th>
|
||||||
|
<th class="border px-2 py-1.5 text-left">Catalogo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($catalogoProdottiRows as $row)
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="border px-2 py-1.5 font-medium">{{ $row['internal_code'] !== '' ? $row['internal_code'] : '-' }}</td>
|
||||||
|
<td class="border px-2 py-1.5">
|
||||||
|
<div>{{ $row['name'] !== '' ? $row['name'] : '-' }}</div>
|
||||||
|
@if($row['color_label'] !== '')
|
||||||
|
<div class="text-[11px] text-slate-500">Colore: {{ $row['color_label'] }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="border px-2 py-1.5">{{ trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')) ?: '-' }}</td>
|
||||||
|
<td class="border px-2 py-1.5">
|
||||||
|
@if(($row['source'] ?? '') === 'fattura_elettronica')
|
||||||
|
<span class="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-800">FE</span>
|
||||||
|
@elseif(($row['source'] ?? '') === 'ncom_wholesale_csv')
|
||||||
|
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800">CSV</span>
|
||||||
|
@else
|
||||||
|
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700">manuale</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="border px-2 py-1.5">
|
||||||
|
<div class="font-mono">{{ $row['public_reference'] !== '' ? $row['public_reference'] : '-' }}</div>
|
||||||
|
@if(($row['publication_mode'] ?? '') === 'internal_only')
|
||||||
|
<div class="text-[11px] text-slate-500">solo interno</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="border px-2 py-1.5">{{ (int) ($row['identifiers_count'] ?? 0) }}</td>
|
||||||
|
<td class="border px-2 py-1.5">
|
||||||
|
{{ (int) ($row['serials_count'] ?? 0) }}
|
||||||
|
@if($row['track_serials'])
|
||||||
|
<span class="ml-1 rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-800">track</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="border px-2 py-1.5">
|
||||||
|
<div>{{ (int) ($row['offers_count'] ?? 0) }} totali</div>
|
||||||
|
<div class="text-[11px] text-slate-500">
|
||||||
|
@if(($row['own_offer_price'] ?? null) !== null)
|
||||||
|
tuo € {{ number_format((float) $row['own_offer_price'], 2, ',', '.') }}
|
||||||
|
@else
|
||||||
|
tuo n/d
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-slate-500">
|
||||||
|
@if(($row['best_competitor_price'] ?? null) !== null)
|
||||||
|
competitor € {{ number_format((float) $row['best_competitor_price'], 2, ',', '.') }}
|
||||||
|
@if(($row['best_competitor_source'] ?? '') !== '')
|
||||||
|
| {{ $row['best_competitor_source'] }}
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
competitor n/d
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if(($row['amazon_referral_url'] ?? '') !== '')
|
||||||
|
<div class="text-[11px] text-slate-500">Amazon referral pronto</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="border px-2 py-1.5">
|
||||||
|
<div>Link sorgente: {{ (int) ($row['private_links'] ?? 0) }}</div>
|
||||||
|
<div class="text-[11px] text-slate-500">
|
||||||
|
@if(($row['price_wholesale'] ?? null) !== null)
|
||||||
|
€ {{ number_format((float) $row['price_wholesale'], 2, ',', '.') }}
|
||||||
|
@else
|
||||||
|
prezzo n/d
|
||||||
|
@endif
|
||||||
|
@if(($row['stock_quantity'] ?? null) !== null)
|
||||||
|
| stock {{ (int) $row['stock_quantity'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if(($row['categories'] ?? '') !== '')
|
||||||
|
<div class="truncate text-[11px] text-slate-500">{{ $row['categories'] }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="border px-2 py-3 text-center text-gray-500">Nessun prodotto presente per questo fornitore.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<div class="text-lg font-semibold">Pagamenti</div>
|
<div class="text-lg font-semibold">Pagamenti</div>
|
||||||
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
|
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
|
|
@ -178,6 +362,59 @@ class="w-full rounded-lg border-gray-300 text-sm"
|
||||||
$acquaScan = (bool) ($acqua['scan_enabled'] ?? false);
|
$acquaScan = (bool) ($acqua['scan_enabled'] ?? false);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold">TecnoRepair / seriali</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600">Import MDB in anagrafica fornitore e aggancio automatico di seriali e prodotti assistenza.</div>
|
||||||
|
</div>
|
||||||
|
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('importa_tecnorepair')">
|
||||||
|
Importa TecnoRepair
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||||
|
<span>Schede</span>
|
||||||
|
<span class="font-semibold">{{ (int) ($box['tecnorepair']['schede'] ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||||
|
<span>Aperte / attesa</span>
|
||||||
|
<span class="font-semibold">{{ (int) ($box['tecnorepair']['aperte'] ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||||
|
<span>Chiuse</span>
|
||||||
|
<span class="font-semibold">{{ (int) ($box['tecnorepair']['chiuse'] ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||||
|
<span>Seriali</span>
|
||||||
|
<span class="font-semibold">{{ (int) ($box['tecnorepair']['seriali'] ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2 text-sm">
|
||||||
|
@forelse($tecnorepairRows as $row)
|
||||||
|
<div class="rounded-md border px-3 py-2">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium">{{ $row['legacy_numero'] !== '' ? $row['legacy_numero'] : ('Scheda #' . $row['id']) }}</div>
|
||||||
|
<div class="truncate text-gray-600">{{ $row['product_model'] !== '' ? $row['product_model'] : '-' }}</div>
|
||||||
|
<div class="truncate text-xs text-gray-500">Codice: {{ $row['product_code'] !== '' ? $row['product_code'] : '-' }} | Seriale: {{ $row['serial_number'] !== '' ? $row['serial_number'] : '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="rounded px-2 py-0.5 text-[10px] font-semibold {{ ($row['status_bucket'] ?? '') === 'closed' ? 'bg-emerald-100 text-emerald-800' : ((($row['status_bucket'] ?? '') === 'waiting') ? 'bg-amber-100 text-amber-800' : 'bg-rose-100 text-rose-800') }}">
|
||||||
|
{{ $row['status_label'] !== '' ? $row['status_label'] : ($row['status_bucket'] !== '' ? $row['status_bucket'] : 'stato') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">Allegati: {{ (int) ($row['allegati_count'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-sm text-gray-600">Nessuna scheda TecnoRepair collegata a questo fornitore.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<div class="text-base font-semibold">Automazioni / Consumi</div>
|
<div class="text-base font-semibold">Automazioni / Consumi</div>
|
||||||
<div class="mt-1 text-sm text-gray-600">Configura acquisizione dati (PDF) e scansione massiva sulle FE già importate.</div>
|
<div class="mt-1 text-sm text-gray-600">Configura acquisizione dati (PDF) e scansione massiva sulle FE già importate.</div>
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,10 @@
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(!$riga->ticket_id && $riga->stato !== 'chiusa')
|
@if(!$riga->ticket_id && $riga->stato !== 'chiusa')
|
||||||
<x-filament::button size="sm" color="primary" wire:click="convertiInTicket({{ $riga->id }})">Converti in ticket</x-filament::button>
|
<div class="flex flex-wrap items-center gap-2 rounded-md border bg-slate-50 px-2 py-1">
|
||||||
|
<input type="text" wire:model.defer="conversioneNote.{{ (int) $riga->id }}" class="w-56 rounded-md border-gray-300 text-xs" placeholder="Nota libera prima del ticket (opzionale)" />
|
||||||
|
<x-filament::button size="sm" color="primary" wire:click="convertiInTicket({{ $riga->id }})">Converti in ticket</x-filament::button>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($riga->stato !== 'chiusa')
|
@if($riga->stato !== 'chiusa')
|
||||||
|
|
@ -174,6 +177,10 @@
|
||||||
<option value="esterno">Solo numeri esterni</option>
|
<option value="esterno">Solo numeri esterni</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Linea o gruppo</label>
|
||||||
|
<input type="text" wire:model.live.debounce.300ms="tecnicoLineFilter" class="w-full rounded-md border-gray-300 text-xs" placeholder="Es. 0003, 603, GRP00603" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Direzione</label>
|
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Direzione</label>
|
||||||
<select wire:model.live="tecnicoDirectionFilter" class="w-full rounded-md border-gray-300 text-xs">
|
<select wire:model.live="tecnicoDirectionFilter" class="w-full rounded-md border-gray-300 text-xs">
|
||||||
|
|
@ -183,7 +190,7 @@
|
||||||
<option value="internal">Internal</option>
|
<option value="internal">Internal</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-1">
|
<div class="md:col-span-2">
|
||||||
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Ricerca</label>
|
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-600">Ricerca</label>
|
||||||
<input type="text" wire:model.live.debounce.300ms="tecnicoSearch" class="w-full rounded-md border-gray-300 text-xs" placeholder="Numero, interno, testo riga, nominativo tecnico" />
|
<input type="text" wire:model.live.debounce.300ms="tecnicoSearch" class="w-full rounded-md border-gray-300 text-xs" placeholder="Numero, interno, testo riga, nominativo tecnico" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@php($ticketCallContext = $this->ticketCallContext)
|
||||||
|
@if(!empty($ticketCallContext['phone']) || !empty($ticketCallContext['caller_name']) || !empty($ticketCallContext['target_extension']))
|
||||||
|
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">
|
||||||
|
<div class="font-semibold uppercase tracking-wide">Contesto chiamata</div>
|
||||||
|
<div class="mt-2 grid gap-2 md:grid-cols-2">
|
||||||
|
@if(!empty($ticketCallContext['phone']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Numero chiamante</div>
|
||||||
|
<div class="text-sm font-semibold">{{ $ticketCallContext['phone'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['target_extension']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Interno o gruppo chiamato</div>
|
||||||
|
<div class="text-sm font-semibold">{{ $ticketCallContext['target_extension'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['received_at']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Ricevuta alle</div>
|
||||||
|
<div>{{ $ticketCallContext['received_at'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['caller_name']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Contatto associato</div>
|
||||||
|
<div>{{ $ticketCallContext['caller_name'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['caller_profile']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Profilo</div>
|
||||||
|
<div>{{ $ticketCallContext['caller_profile'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['riferimento_stabile']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento stabile</div>
|
||||||
|
<div>{{ $ticketCallContext['riferimento_stabile'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['riferimento_unita']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Riferimento unità</div>
|
||||||
|
<div>{{ $ticketCallContext['riferimento_unita'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($ticketCallContext['stabile_label']))
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-emerald-700">Stabile collegato</div>
|
||||||
|
<div>{{ $ticketCallContext['stabile_label'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if(!empty($ticketCallContext['call_note']))
|
||||||
|
<div class="mt-2 rounded-md bg-white/70 px-3 py-2 text-[11px] text-emerald-800">
|
||||||
|
<div class="font-semibold">Nota operativa importata</div>
|
||||||
|
<div class="mt-1 whitespace-pre-wrap">{{ $ticketCallContext['call_note'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
<label class="block text-sm md:col-span-2">
|
<label class="block text-sm md:col-span-2">
|
||||||
<span class="mb-1 block font-medium">Titolo</span>
|
<span class="mb-1 block font-medium">Titolo operativo</span>
|
||||||
<input type="text" wire:model.defer="newTicketTitolo" class="w-full rounded-lg border-gray-300" placeholder="Es. Guasto ascensore scala B" />
|
<input type="text" wire:model.defer="newTicketTitolo" class="w-full rounded-lg border-gray-300" placeholder="Es. Guasto ascensore scala B" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -174,7 +236,7 @@
|
||||||
|
|
||||||
<label class="block text-sm md:col-span-2">
|
<label class="block text-sm md:col-span-2">
|
||||||
<span class="mb-1 block font-medium">Descrizione</span>
|
<span class="mb-1 block font-medium">Descrizione</span>
|
||||||
<textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema..." ></textarea>
|
<textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema senza ripetere i dati tecnici della chiamata..." ></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block text-sm md:col-span-2">
|
<label class="block text-sm md:col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,19 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mx-3 flex w-full max-w-xl items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 shadow-sm">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-700">CTI live</div>
|
||||||
|
<div class="truncate text-sm font-medium text-slate-900">In attesa di chiamate recenti</div>
|
||||||
|
<div class="truncate text-xs text-slate-600">Il box operativo si apre automaticamente quando arriva una inbound valida.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0">
|
||||||
|
<a href="{{ \App\Filament\Pages\Strumenti\PostItGestione::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100">
|
||||||
|
Log chiamate
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
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 {
|
catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return ,$items
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-AddressSnapshot {
|
function Get-AddressSnapshot {
|
||||||
|
|
@ -88,7 +88,7 @@ Write-Host "[2/5] Inizializzo TAPI..."
|
||||||
$tapi.Initialize()
|
$tapi.Initialize()
|
||||||
|
|
||||||
Write-Host "[3/5] Leggo le informazioni generali..."
|
Write-Host "[3/5] Leggo le informazioni generali..."
|
||||||
$addressesRaw = Convert-ComCollectionToArray -Collection $tapi.Addresses
|
$addressesRaw = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
|
||||||
$addressSnapshots = @()
|
$addressSnapshots = @()
|
||||||
foreach ($address in $addressesRaw) {
|
foreach ($address in $addressesRaw) {
|
||||||
$addressSnapshots += Get-AddressSnapshot -Address $address -FilterValue $Filter
|
$addressSnapshots += Get-AddressSnapshot -Address $address -FilterValue $Filter
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,13 @@ function Convert-ComCollectionToArray {
|
||||||
catch {
|
catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return ,$items
|
||||||
}
|
}
|
||||||
|
|
||||||
$tapi = New-Object -ComObject TAPI.TAPI
|
$tapi = New-Object -ComObject TAPI.TAPI
|
||||||
$tapi.Initialize()
|
$tapi.Initialize()
|
||||||
|
|
||||||
$addresses = Convert-ComCollectionToArray -Collection $tapi.Addresses
|
$addresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
|
||||||
$target = $addresses | Where-Object {
|
$target = $addresses | Where-Object {
|
||||||
($_.DialableAddress -as [string]) -eq $Extension -or
|
($_.DialableAddress -as [string]) -eq $Extension -or
|
||||||
($_.AddressName -as [string]) -match [regex]::Escape($Extension)
|
($_.AddressName -as [string]) -match [regex]::Escape($Extension)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
param(
|
||||||
[string]$TaskName = 'NetGescon Panasonic Live Bridge',
|
[string]$TaskName = 'NetGescon Panasonic Live Bridge',
|
||||||
[string]$BaseUrl = 'https://staging.netgescon.it',
|
[string]$BaseUrl = 'https://staging.netgescon.it',
|
||||||
|
|
@ -13,6 +25,31 @@ param(
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Ensure-ParentDirectory {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = Split-Path -Path $Path -Parent
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path $parent)) {
|
||||||
|
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Directory {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Test-IsAdministrator {
|
function Test-IsAdministrator {
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
|
@ -33,6 +70,9 @@ if ([string]::IsNullOrWhiteSpace($InteropOutputDir)) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ensure-ParentDirectory -Path $LogFile
|
||||||
|
Ensure-Directory -Path $InteropOutputDir
|
||||||
|
|
||||||
$argumentList = @(
|
$argumentList = @(
|
||||||
'-NoProfile'
|
'-NoProfile'
|
||||||
'-ExecutionPolicy', 'Bypass'
|
'-ExecutionPolicy', 'Bypass'
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ function Convert-ComCollectionToArray {
|
||||||
catch {
|
catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return ,$items
|
||||||
}
|
}
|
||||||
|
|
||||||
function Normalize-ExtensionToken {
|
function Normalize-ExtensionToken {
|
||||||
|
|
@ -180,7 +180,7 @@ $tapi = New-Object -ComObject TAPI.TAPI
|
||||||
$tapi.Initialize()
|
$tapi.Initialize()
|
||||||
$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate
|
$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate
|
||||||
|
|
||||||
$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses
|
$allAddresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
|
||||||
$addresses = @($allAddresses | Where-Object {
|
$addresses = @($allAddresses | Where-Object {
|
||||||
Should-WatchAddress -Address $_ -WatchList $watchList
|
Should-WatchAddress -Address $_ -WatchList $watchList
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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(
|
param(
|
||||||
[string]$BaseUrl = 'https://staging.netgescon.it',
|
[string]$BaseUrl = 'https://staging.netgescon.it',
|
||||||
[string]$Extensions = '201-208,601,603,0001,0003',
|
[string]$Extensions = '201-208,601,603,0001,0003',
|
||||||
|
|
@ -11,6 +24,31 @@ param(
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Ensure-ParentDirectory {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = Split-Path -Path $Path -Parent
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path $parent)) {
|
||||||
|
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Directory {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$scriptPath = Join-Path $PSScriptRoot 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
|
$scriptPath = Join-Path $PSScriptRoot 'watch-netgescon-panasonic-tapi-dotnet-events.ps1'
|
||||||
if (-not (Test-Path $scriptPath)) {
|
if (-not (Test-Path $scriptPath)) {
|
||||||
throw "Watcher non trovato: $scriptPath"
|
throw "Watcher non trovato: $scriptPath"
|
||||||
|
|
@ -40,7 +78,11 @@ elseif (-not [System.IO.Path]::IsPathRooted($InteropOutputDir)) {
|
||||||
$InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir
|
$InteropOutputDir = Join-Path $PSScriptRoot $InteropOutputDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ensure-ParentDirectory -Path $LogFile
|
||||||
|
Ensure-Directory -Path $InteropOutputDir
|
||||||
|
|
||||||
while ($true) {
|
while ($true) {
|
||||||
|
# Ciclo supervisore: il watcher gestisce la logica TAPI, questo launcher lo riavvia se cade.
|
||||||
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
Write-Host "$timestamp | AVVIO bridge live Panasonic -> NetGescon"
|
Write-Host "$timestamp | AVVIO bridge live Panasonic -> NetGescon"
|
||||||
|
|
||||||
|
|
@ -65,5 +107,6 @@ while ($true) {
|
||||||
Write-Host "$timestamp | ERRORE bridge live: $($_.Exception.Message)"
|
Write-Host "$timestamp | ERRORE bridge live: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Evita restart-loop aggressivo in caso di errore ripetuto.
|
||||||
Start-Sleep -Seconds ([Math]::Max(1, $RestartDelaySeconds))
|
Start-Sleep -Seconds ([Math]::Max(1, $RestartDelaySeconds))
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ function Convert-ComCollectionToArray {
|
||||||
catch {
|
catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return ,$items
|
||||||
}
|
}
|
||||||
|
|
||||||
function Normalize-ExtensionToken {
|
function Normalize-ExtensionToken {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ function Convert-ComCollectionToArray {
|
||||||
catch {
|
catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return ,$items
|
||||||
}
|
}
|
||||||
|
|
||||||
function Normalize-ExtensionToken {
|
function Normalize-ExtensionToken {
|
||||||
|
|
@ -124,7 +124,7 @@ $tapi = New-Object -ComObject TAPI.TAPI
|
||||||
$tapi.Initialize()
|
$tapi.Initialize()
|
||||||
$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate
|
$tapi.EventFilter = $TapiEventFilterCallNotification + $TapiEventFilterCallState + $TapiEventFilterCallMedia + $TapiEventFilterCallHub + $TapiEventFilterCallInfoChange + $TapiEventFilterPrivate
|
||||||
|
|
||||||
$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses
|
$allAddresses = @(Convert-ComCollectionToArray -Collection $tapi.Addresses)
|
||||||
$addresses = @($allAddresses | Where-Object {
|
$addresses = @($allAddresses | Where-Object {
|
||||||
Should-WatchAddress -Address $_ -WatchList $watchList
|
Should-WatchAddress -Address $_ -WatchList $watchList
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -97,13 +97,13 @@ function Convert-ToSafeString {
|
||||||
|
|
||||||
function Get-FirstNonEmptyProperty {
|
function Get-FirstNonEmptyProperty {
|
||||||
param(
|
param(
|
||||||
$Object,
|
$Object,
|
||||||
[Parameter(Mandatory = $true)] [string[]]$Names
|
[Parameter(Mandatory = $true)] [string[]]$Names
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($null -eq $Object) {
|
if ($null -eq $Object) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($name in $Names) {
|
foreach ($name in $Names) {
|
||||||
$value = Get-SafeProperty -Object $Object -Name $name
|
$value = Get-SafeProperty -Object $Object -Name $name
|
||||||
|
|
@ -172,7 +172,7 @@ function Parse-WatchList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return @($watchItems)
|
return @($watchItems.ToArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
function Is-WatchedExtensionValue {
|
function Is-WatchedExtensionValue {
|
||||||
|
|
@ -186,7 +186,13 @@ function Is-WatchedExtensionValue {
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
return $WatchList -contains $token
|
foreach ($watch in $WatchList) {
|
||||||
|
if ((Normalize-ExtensionToken -Value $watch) -eq $token) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
function Select-ExternalPhoneCandidate {
|
function Select-ExternalPhoneCandidate {
|
||||||
|
|
@ -229,7 +235,32 @@ function Convert-ComCollectionToArray {
|
||||||
catch {
|
catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items
|
return , $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flatten-ObjectArray {
|
||||||
|
param([object[]]$Items)
|
||||||
|
|
||||||
|
$flattened = @()
|
||||||
|
foreach ($item in $Items) {
|
||||||
|
if ($null -eq $item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($item -is [System.Array]) {
|
||||||
|
foreach ($nested in $item) {
|
||||||
|
if ($null -ne $nested) {
|
||||||
|
$flattened += $nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$flattened += $item
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($flattened)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Normalize-ExtensionToken {
|
function Normalize-ExtensionToken {
|
||||||
|
|
@ -251,6 +282,113 @@ function Normalize-ExtensionToken {
|
||||||
return $trimmed
|
return $trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-TokenCategory {
|
||||||
|
param([string]$Value)
|
||||||
|
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = ([string]$Value).Trim()
|
||||||
|
if ($trimmed -eq '') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
$upper = $trimmed.ToUpperInvariant()
|
||||||
|
if ($upper -match '^EXT\d+$') {
|
||||||
|
return 'EXT'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($upper -match '^GRP\d+$') {
|
||||||
|
return 'GRP'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($upper -match '^CO\d+$') {
|
||||||
|
return 'CO'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($upper -eq 'SYSTEM') {
|
||||||
|
return 'SYSTEM'
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = Normalize-ExtensionToken -Value $trimmed
|
||||||
|
if ($normalized -eq '') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($trimmed -match '^0\d+$') {
|
||||||
|
return 'CO'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalized -match '^6\d\d$') {
|
||||||
|
return 'GRP'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalized -match '^\d+$') {
|
||||||
|
return 'EXT'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AddressTokenInfos {
|
||||||
|
param([string[]]$Values)
|
||||||
|
|
||||||
|
$tokens = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
foreach ($value in $Values) {
|
||||||
|
$text = Convert-ToSafeString -Value $value
|
||||||
|
if ($text -eq '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($piece in @([regex]::Split($text.Trim(), '[\s,;\|]+') | Where-Object { $_ -ne '' })) {
|
||||||
|
$tokens.Add([pscustomobject]@{
|
||||||
|
Raw = $piece
|
||||||
|
Normalized = Normalize-ExtensionToken -Value $piece
|
||||||
|
Category = Get-TokenCategory -Value $piece
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($tokens.ToArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-AddressWatchMatch {
|
||||||
|
param(
|
||||||
|
[string[]]$Values,
|
||||||
|
[Parameter(Mandatory = $true)] [string[]]$WatchList
|
||||||
|
)
|
||||||
|
|
||||||
|
$tokenInfos = @(Get-AddressTokenInfos -Values $Values)
|
||||||
|
foreach ($tokenInfo in $tokenInfos) {
|
||||||
|
if ($null -eq $tokenInfo -or $tokenInfo.Normalized -eq '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($watch in $WatchList) {
|
||||||
|
$watchNormalized = Normalize-ExtensionToken -Value $watch
|
||||||
|
if ($watchNormalized -eq '' -or $watchNormalized -ne $tokenInfo.Normalized) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = $tokenInfo.Category
|
||||||
|
if ($category -eq '' -or $category -eq 'SYSTEM') {
|
||||||
|
$category = Get-TokenCategory -Value $watch
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Raw = $tokenInfo.Raw
|
||||||
|
Normalized = $tokenInfo.Normalized
|
||||||
|
Category = $category
|
||||||
|
WatchValue = [string]$watch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
function Get-AddressCategory {
|
function Get-AddressCategory {
|
||||||
param([string]$AddressName)
|
param([string]$AddressName)
|
||||||
|
|
||||||
|
|
@ -294,35 +432,9 @@ function Should-WatchAddress {
|
||||||
|
|
||||||
$rawDial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress')
|
$rawDial = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'DialableAddress')
|
||||||
$rawName = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName')
|
$rawName = Convert-ToSafeString -Value (Get-SafeProperty -Object $Address -Name 'AddressName')
|
||||||
$dial = Normalize-ExtensionToken -Value $rawDial
|
$addressMatch = Find-AddressWatchMatch -Values @($rawDial, $rawName) -WatchList $WatchList
|
||||||
$name = Normalize-ExtensionToken -Value $rawName
|
if ($null -ne $addressMatch) {
|
||||||
$category = Get-AddressCategory -AddressName $rawName
|
return $true
|
||||||
|
|
||||||
foreach ($watch in $WatchList) {
|
|
||||||
$watchRaw = Convert-ToSafeString -Value $watch
|
|
||||||
$token = Normalize-ExtensionToken -Value $watchRaw
|
|
||||||
if ($token -eq '') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($watchRaw -match '^0\d+$') {
|
|
||||||
if ($category -ne 'CO') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
$watchInt = Convert-ToNullableInt -Value $watchRaw
|
|
||||||
$nameInt = Convert-ToNullableInt -Value $rawName
|
|
||||||
$dialInt = Convert-ToNullableInt -Value $rawDial
|
|
||||||
if (($null -ne $watchInt -and $null -ne $nameInt -and $watchInt -eq $nameInt) -or ($null -ne $watchInt -and $null -ne $dialInt -and $watchInt -eq $dialInt)) {
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dial -eq $token -or $name -eq $token) {
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $false
|
return $false
|
||||||
|
|
@ -468,7 +580,7 @@ function Find-BridgeCallMatchBySignature {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
EventId = [string]$eventId
|
EventId = [string]$eventId
|
||||||
CallRecord = $callRecord
|
CallRecord = $callRecord
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -529,16 +641,16 @@ function Try-RegisterCallNotifications {
|
||||||
try {
|
try {
|
||||||
$cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0)
|
$cookie = $Tapi.RegisterCallNotifications($Address, $true, $true, $TapiMediaTypeAudio, 0)
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Ok = $true
|
Ok = $true
|
||||||
Cookie = $cookie
|
Cookie = $cookie
|
||||||
Error = ''
|
Error = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Ok = $false
|
Ok = $false
|
||||||
Cookie = $null
|
Cookie = $null
|
||||||
Error = $_.Exception.Message
|
Error = $_.Exception.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -681,7 +793,7 @@ function Describe-Call {
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts = @()
|
$parts = @()
|
||||||
foreach ($name in @('CallState', 'Privilege', 'MediaType', 'CallerIDAddress', 'CalledIDAddress', 'ConnectedIDAddress', 'CallerIDName', 'CalledIDName', 'ConnectedIDName')) {
|
foreach ($name in @('CallState', 'Privilege', 'MediaType', 'CallerIDAddress', 'CalledIDAddress', 'ConnectedIDAddress', 'CallerIDName', 'CalledIDName', 'ConnectedIDName')) {
|
||||||
$value = Get-SafeProperty -Object $Call -Name $name
|
$value = Get-SafeProperty -Object $Call -Name $name
|
||||||
$valueString = Convert-ToSafeString -Value $value
|
$valueString = Convert-ToSafeString -Value $value
|
||||||
if ($null -ne $value -and $valueString -ne '') {
|
if ($null -ne $value -and $valueString -ne '') {
|
||||||
|
|
@ -808,11 +920,25 @@ function Get-CallSnapshot {
|
||||||
|
|
||||||
$addressName = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'AddressName')
|
$addressName = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'AddressName')
|
||||||
$dialableAddress = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'DialableAddress')
|
$dialableAddress = Convert-ToSafeString -Value (Get-SafeProperty -Object $address -Name 'DialableAddress')
|
||||||
|
$addressMatch = Find-AddressWatchMatch -Values @($dialableAddress, $addressName) -WatchList $WatchList
|
||||||
$addressCategory = Get-AddressCategory -AddressName $addressName
|
$addressCategory = Get-AddressCategory -AddressName $addressName
|
||||||
$watchedExtension = Normalize-ExtensionToken -Value $dialableAddress
|
$watchedExtension = ''
|
||||||
|
if ($null -ne $addressMatch) {
|
||||||
|
$watchedExtension = $addressMatch.Normalized
|
||||||
|
if ($addressMatch.Category -ne '') {
|
||||||
|
$addressCategory = $addressMatch.Category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($watchedExtension -eq '') {
|
||||||
|
$watchedExtension = Normalize-ExtensionToken -Value $dialableAddress
|
||||||
|
}
|
||||||
if ($watchedExtension -eq '') {
|
if ($watchedExtension -eq '') {
|
||||||
$watchedExtension = Normalize-ExtensionToken -Value $addressName
|
$watchedExtension = Normalize-ExtensionToken -Value $addressName
|
||||||
}
|
}
|
||||||
|
if (($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') -and $watchedExtension -ne '') {
|
||||||
|
$addressCategory = Get-TokenCategory -Value $watchedExtension
|
||||||
|
}
|
||||||
|
|
||||||
$callState = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'State')
|
$callState = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'State')
|
||||||
$cause = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'Cause')
|
$cause = Convert-ToSafeString -Value (Get-SafeProperty -Object $Payload -Name 'Cause')
|
||||||
|
|
@ -850,17 +976,39 @@ function Get-CallSnapshot {
|
||||||
$callingExtension = ''
|
$callingExtension = ''
|
||||||
$calledExtension = $watchedExtension
|
$calledExtension = $watchedExtension
|
||||||
|
|
||||||
if ($callerToken -ne '' -and ($WatchList -contains $callerToken) -and $calledIdAddress -ne '' -and -not ($WatchList -contains $calledToken)) {
|
if (Is-WatchedExtensionValue -Value $calledToken -WatchList $WatchList) {
|
||||||
|
$calledExtension = $calledToken
|
||||||
|
if ($watchedExtension -eq '' -or $addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
|
||||||
|
$watchedExtension = $calledToken
|
||||||
|
$addressCategory = Get-TokenCategory -Value $calledIdAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($callerToken -ne '' -and (Is-WatchedExtensionValue -Value $callerToken -WatchList $WatchList) -and $calledIdAddress -ne '' -and -not (Is-WatchedExtensionValue -Value $calledToken -WatchList $WatchList)) {
|
||||||
$direction = 'outbound'
|
$direction = 'outbound'
|
||||||
$externalPhone = Select-ExternalPhoneCandidate -Candidates @($calledIdAddress, $connectedIdAddress, $callerIdAddress) -WatchList $WatchList
|
$externalPhone = Select-ExternalPhoneCandidate -Candidates @($calledIdAddress, $connectedIdAddress, $callerIdAddress) -WatchList $WatchList
|
||||||
$callingExtension = $callerToken
|
$callingExtension = $callerToken
|
||||||
$calledExtension = $watchedExtension
|
$calledExtension = $watchedExtension
|
||||||
|
if ($watchedExtension -eq '') {
|
||||||
|
$watchedExtension = $callerToken
|
||||||
|
}
|
||||||
|
if ($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
|
||||||
|
$addressCategory = Get-TokenCategory -Value $callerIdAddress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
elseif ($connectedToken -ne '' -and ($WatchList -contains $connectedToken) -and $callerToken -eq '') {
|
elseif ($connectedToken -ne '' -and (Is-WatchedExtensionValue -Value $connectedToken -WatchList $WatchList) -and $callerToken -eq '') {
|
||||||
$calledExtension = $connectedToken
|
$calledExtension = $connectedToken
|
||||||
|
if ($watchedExtension -eq '' -or $addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
|
||||||
|
$watchedExtension = $connectedToken
|
||||||
|
$addressCategory = Get-TokenCategory -Value $connectedIdAddress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
elseif ($watchedExtension -eq '' -and $connectedToken -ne '' -and ($WatchList -contains $connectedToken)) {
|
elseif ($watchedExtension -eq '' -and $connectedToken -ne '' -and (Is-WatchedExtensionValue -Value $connectedToken -WatchList $WatchList)) {
|
||||||
$calledExtension = $connectedToken
|
$calledExtension = $connectedToken
|
||||||
|
$watchedExtension = $connectedToken
|
||||||
|
if ($addressCategory -eq '' -or $addressCategory -eq 'SYSTEM') {
|
||||||
|
$addressCategory = Get-TokenCategory -Value $connectedIdAddress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$eventId = Get-CallIdentity -Call $call
|
$eventId = Get-CallIdentity -Call $call
|
||||||
|
|
@ -869,40 +1017,43 @@ function Get-CallSnapshot {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
EventType = $EventType
|
EventType = $EventType
|
||||||
EventId = $eventId
|
EventId = $eventId
|
||||||
CallState = $callState
|
CallState = $callState
|
||||||
Cause = $cause
|
Cause = $cause
|
||||||
AddressName = $addressName
|
AddressName = $addressName
|
||||||
AddressCategory = $addressCategory
|
AddressCategory = $addressCategory
|
||||||
WatchedExtension = $watchedExtension
|
WatchedExtension = $watchedExtension
|
||||||
CalledExtension = $calledExtension
|
CalledExtension = $calledExtension
|
||||||
CallingExtension = $callingExtension
|
CallingExtension = $callingExtension
|
||||||
Direction = $direction
|
Direction = $direction
|
||||||
ExternalPhone = $externalPhone
|
ExternalPhone = $externalPhone
|
||||||
RawCallerIdAddress = $rawCallerIdAddress
|
RawCallerIdAddress = $rawCallerIdAddress
|
||||||
RawCalledIdAddress = $rawCalledIdAddress
|
RawCalledIdAddress = $rawCalledIdAddress
|
||||||
RawConnectedIdAddress = $rawConnectedIdAddress
|
RawConnectedIdAddress = $rawConnectedIdAddress
|
||||||
CallerName = $callerName
|
CallerName = $callerName
|
||||||
CallObject = $call
|
CallObject = $call
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Build-IncomingPayload {
|
function Build-IncomingPayload {
|
||||||
param([Parameter(Mandatory = $true)] $CallRecord)
|
param([Parameter(Mandatory = $true)] $CallRecord)
|
||||||
|
|
||||||
|
$isTest = $script:BridgeMode -ne 'live'
|
||||||
|
$bridgeNote = if ($isTest) { 'bridge dry-run da watcher TAPI .NET' } else { 'bridge live da watcher TAPI .NET' }
|
||||||
|
|
||||||
return [ordered]@{
|
return [ordered]@{
|
||||||
phone = $CallRecord.ExternalPhone
|
phone = $CallRecord.ExternalPhone
|
||||||
name = $CallRecord.CallerName
|
name = $CallRecord.CallerName
|
||||||
direction = $CallRecord.Direction
|
direction = $CallRecord.Direction
|
||||||
outcome = $null
|
outcome = $null
|
||||||
event_id = $CallRecord.EventId
|
event_id = $CallRecord.EventId
|
||||||
called_extension = $CallRecord.CalledExtension
|
called_extension = $CallRecord.CalledExtension
|
||||||
calling_extension = $CallRecord.CallingExtension
|
calling_extension = $CallRecord.CallingExtension
|
||||||
event_type = $CallRecord.LastEventType
|
event_type = $CallRecord.LastEventType
|
||||||
note = 'bridge dry-run da watcher TAPI .NET'
|
note = $bridgeNote
|
||||||
called_at = $CallRecord.StartedAt.ToString('o')
|
called_at = $CallRecord.StartedAt.ToString('o')
|
||||||
is_test = $true
|
is_test = $isTest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -915,18 +1066,21 @@ function Build-CallEndedPayload {
|
||||||
$outcome = 'risposta'
|
$outcome = 'risposta'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isTest = $script:BridgeMode -ne 'live'
|
||||||
|
$bridgeNote = if ($isTest) { 'bridge dry-run da watcher TAPI .NET' } else { 'bridge live da watcher TAPI .NET' }
|
||||||
|
|
||||||
return [ordered]@{
|
return [ordered]@{
|
||||||
phone = $CallRecord.ExternalPhone
|
phone = $CallRecord.ExternalPhone
|
||||||
event_id = $CallRecord.EventId
|
event_id = $CallRecord.EventId
|
||||||
outcome = $outcome
|
outcome = $outcome
|
||||||
duration_seconds = $durationSeconds
|
duration_seconds = $durationSeconds
|
||||||
ended_at = $CallRecord.EndedAt.ToString('o')
|
ended_at = $CallRecord.EndedAt.ToString('o')
|
||||||
direction = $CallRecord.Direction
|
direction = $CallRecord.Direction
|
||||||
called_extension = $CallRecord.CalledExtension
|
called_extension = $CallRecord.CalledExtension
|
||||||
calling_extension = $CallRecord.CallingExtension
|
calling_extension = $CallRecord.CallingExtension
|
||||||
event_type = $CallRecord.LastEventType
|
event_type = $CallRecord.LastEventType
|
||||||
note = 'bridge dry-run da watcher TAPI .NET'
|
note = $bridgeNote
|
||||||
is_test = $true
|
is_test = $isTest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -999,17 +1153,17 @@ function Register-BridgeIncoming {
|
||||||
}
|
}
|
||||||
|
|
||||||
$callRecord = [ordered]@{
|
$callRecord = [ordered]@{
|
||||||
EventId = $resolvedEventId
|
EventId = $resolvedEventId
|
||||||
StartedAt = Get-Date
|
StartedAt = Get-Date
|
||||||
EndedAt = $null
|
EndedAt = $null
|
||||||
LastEventType = $Snapshot.EventType
|
LastEventType = $Snapshot.EventType
|
||||||
CalledExtension = $Snapshot.CalledExtension
|
CalledExtension = $Snapshot.CalledExtension
|
||||||
CallingExtension = $Snapshot.CallingExtension
|
CallingExtension = $Snapshot.CallingExtension
|
||||||
Direction = $Snapshot.Direction
|
Direction = $Snapshot.Direction
|
||||||
ExternalPhone = $Snapshot.ExternalPhone
|
ExternalPhone = $Snapshot.ExternalPhone
|
||||||
CallerName = $Snapshot.CallerName
|
CallerName = $Snapshot.CallerName
|
||||||
Answered = $false
|
Answered = $false
|
||||||
IncomingSent = $false
|
IncomingSent = $false
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) {
|
if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) {
|
||||||
|
|
@ -1021,7 +1175,7 @@ function Register-BridgeIncoming {
|
||||||
if ($callRecord.ExternalPhone -eq '') {
|
if ($callRecord.ExternalPhone -eq '') {
|
||||||
Write-LogLine ("BRIDGE SKIP endpoint=incoming reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension)
|
Write-LogLine ("BRIDGE SKIP endpoint=incoming reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension)
|
||||||
Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress)
|
Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress)
|
||||||
Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject))
|
Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1071,17 +1225,17 @@ function Register-BridgeStateTransition {
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$callRecord = [ordered]@{
|
$callRecord = [ordered]@{
|
||||||
EventId = $resolvedEventId
|
EventId = $resolvedEventId
|
||||||
StartedAt = Get-Date
|
StartedAt = Get-Date
|
||||||
EndedAt = $null
|
EndedAt = $null
|
||||||
LastEventType = $Snapshot.EventType
|
LastEventType = $Snapshot.EventType
|
||||||
CalledExtension = $Snapshot.CalledExtension
|
CalledExtension = $Snapshot.CalledExtension
|
||||||
CallingExtension = $Snapshot.CallingExtension
|
CallingExtension = $Snapshot.CallingExtension
|
||||||
Direction = $Snapshot.Direction
|
Direction = $Snapshot.Direction
|
||||||
ExternalPhone = $Snapshot.ExternalPhone
|
ExternalPhone = $Snapshot.ExternalPhone
|
||||||
CallerName = $Snapshot.CallerName
|
CallerName = $Snapshot.CallerName
|
||||||
Answered = $false
|
Answered = $false
|
||||||
IncomingSent = $false
|
IncomingSent = $false
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) {
|
if ($Snapshot.EventId -ne '' -and $Snapshot.EventId -ne $resolvedEventId) {
|
||||||
|
|
@ -1131,7 +1285,7 @@ function Register-BridgeStateTransition {
|
||||||
if ($callRecord.ExternalPhone -eq '') {
|
if ($callRecord.ExternalPhone -eq '') {
|
||||||
Write-LogLine ("BRIDGE SKIP endpoint=call-ended reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension)
|
Write-LogLine ("BRIDGE SKIP endpoint=call-ended reason=phone-missing event_id={0} called_extension={1}" -f $callRecord.EventId, $callRecord.CalledExtension)
|
||||||
Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress)
|
Write-LogLine ("BRIDGE DEBUG event_id={0} rawCaller={1} rawCalled={2} rawConnected={3}" -f $callRecord.EventId, $Snapshot.RawCallerIdAddress, $Snapshot.RawCalledIdAddress, $Snapshot.RawConnectedIdAddress)
|
||||||
Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject))
|
Write-LogLine ("BRIDGE DEBUG event_id={0} {1}" -f $callRecord.EventId, (Get-PhoneDebugSummary -Call $Snapshot.CallObject))
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Invoke-BridgeRequest -Endpoint 'call-ended' -Payload (Build-CallEndedPayload -CallRecord $callRecord)
|
Invoke-BridgeRequest -Endpoint 'call-ended' -Payload (Build-CallEndedPayload -CallRecord $callRecord)
|
||||||
|
|
@ -1222,20 +1376,20 @@ function Try-SubscribeDotNetEvent {
|
||||||
Write-LogLine ("DOTNET CHECK label={0} type={1} events={2}" -f $Label, $InputObject.GetType().FullName, ($events -join ','))
|
Write-LogLine ("DOTNET CHECK label={0} type={1} events={2}" -f $Label, $InputObject.GetType().FullName, ($events -join ','))
|
||||||
if (-not ($events -contains 'Event')) {
|
if (-not ($events -contains 'Event')) {
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Ok = $false
|
Ok = $false
|
||||||
Error = 'evento .NET Event non esposto'
|
Error = 'evento .NET Event non esposto'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Register-ObjectEvent -InputObject $InputObject -EventName Event -SourceIdentifier $SourceIdentifier | Out-Null
|
Register-ObjectEvent -InputObject $InputObject -EventName Event -SourceIdentifier $SourceIdentifier | Out-Null
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Ok = $true
|
Ok = $true
|
||||||
Error = ''
|
Error = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
Ok = $false
|
Ok = $false
|
||||||
Error = $_.Exception.Message
|
Error = $_.Exception.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1307,10 +1461,10 @@ if ($script:IncludeDiagnosticEvents) {
|
||||||
}
|
}
|
||||||
$tapi.EventFilter = $eventFilter
|
$tapi.EventFilter = $eventFilter
|
||||||
|
|
||||||
$allAddresses = Convert-ComCollectionToArray -Collection $tapi.Addresses
|
$allAddresses = @(Flatten-ObjectArray -Items @(Convert-ComCollectionToArray -Collection $tapi.Addresses))
|
||||||
$addresses = @($allAddresses | Where-Object {
|
$addresses = @($allAddresses | Where-Object {
|
||||||
Should-WatchAddress -Address $_ -WatchList $watchList
|
Should-WatchAddress -Address $_ -WatchList $watchList
|
||||||
})
|
})
|
||||||
|
|
||||||
Write-LogLine "=== AVVIO WATCH DOTNET TAPI ==="
|
Write-LogLine "=== AVVIO WATCH DOTNET TAPI ==="
|
||||||
Write-LogLine ("Assembly interop: {0}" -f $assembly.FullName)
|
Write-LogLine ("Assembly interop: {0}" -f $assembly.FullName)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user