Import supplier customer directory from TecnoRepair legacy

This commit is contained in:
michele 2026-04-15 06:05:10 +00:00
parent f065149831
commit 2810d2c70e
5 changed files with 947 additions and 57 deletions

View File

@ -0,0 +1,441 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use App\Models\AssistenzaTecnorepairScheda;
use App\Models\Fornitore;
use App\Models\FornitoreCliente;
use App\Models\RubricaUniversale;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TecnoRepairImportRubricaClientiCommand extends Command
{
protected $signature = 'tecnorepair:import-rubrica-clienti
{amministratore : ID o codice amministratore}
{--fornitore-id= : ID fornitore da collegare ai clienti}
{--fornitore-term=NETHOME : Ragione sociale o frammento per trovare il fornitore di riferimento}
{--dry-run : Simula import senza scrivere}';
protected $description = 'Materializza la rubrica clienti del fornitore a partire dai record legacy TecnoRepair importati da TClienti.';
public function handle(): int
{
$admin = $this->resolveAdmin((string) $this->argument('amministratore'));
if (! $admin instanceof Amministratore) {
$this->error('Amministratore non trovato.');
return self::FAILURE;
}
$fornitore = $this->resolveFornitore($admin, (string) $this->option('fornitore-id'), (string) $this->option('fornitore-term'));
if (! $fornitore instanceof Fornitore) {
$this->error('Fornitore non trovato per questo amministratore.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$candidates = AssistenzaTecnorepairScheda::query()
->where('amministratore_id', (int) $admin->id)
->where('fornitore_id', (int) $fornitore->id)
->orderByDesc('date_received')
->orderByDesc('id')
->get();
$customers = $this->collectUniqueCustomers($candidates);
$stats = [
'legacy_rows' => $candidates->count(),
'clienti_distinti' => $customers->count(),
'rubrica_create' => 0,
'rubrica_match' => 0,
'rubrica_update' => 0,
'fornitore_rows' => 0,
];
$runner = function () use ($customers, $fornitore, $admin, &$stats): void {
foreach ($customers as $customer) {
$rubrica = $this->resolveOrCreateRubrica($admin, $customer, $stats);
FornitoreCliente::query()->updateOrCreate(
[
'fornitore_id' => (int) $fornitore->id,
'legacy_cliente_id' => $customer['legacy_cliente_id'],
],
[
'amministratore_id' => (int) $admin->id,
'rubrica_id' => $rubrica?->id,
'display_name' => $customer['display_name'],
'phone' => $customer['phone'],
'phone_alt' => $customer['phone_alt'],
'email' => $customer['email'],
'indirizzo' => $customer['indirizzo'],
'cap' => $customer['cap'],
'citta' => $customer['citta'],
'provincia' => $customer['provincia'],
'partita_iva' => $customer['partita_iva'],
'codice_fiscale' => $customer['codice_fiscale'],
'note' => $customer['note'],
'source' => 'tecnorepair_tclienti',
'imported_from_path' => $customer['imported_from_path'],
'imported_at' => now(),
'metadata' => $customer['metadata'],
]
);
$stats['fornitore_rows']++;
}
};
if ($dryRun) {
foreach ($customers as $customer) {
$this->resolveOrCreateRubrica($admin, $customer, $stats, true);
$stats['fornitore_rows']++;
}
} else {
DB::transaction($runner);
}
$this->info('Import rubrica clienti completato.');
$this->table(['Campo', 'Valore'], [
['Amministratore', (string) ($admin->denominazione_studio ?: trim((string) ($admin->nome ?? '') . ' ' . (string) ($admin->cognome ?? '')))],
['Fornitore', (string) ($fornitore->ragione_sociale ?: ('Fornitore #' . $fornitore->id))],
['Dry run', $dryRun ? 'si' : 'no'],
['Schede legacy lette', (string) $stats['legacy_rows']],
['Clienti distinti', (string) $stats['clienti_distinti']],
['Rubrica nuova', (string) $stats['rubrica_create']],
['Rubrica match', (string) $stats['rubrica_match']],
['Rubrica aggiornata', (string) $stats['rubrica_update']],
['Righe fornitore_clienti', (string) $stats['fornitore_rows']],
]);
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 . '%');
})
->orderByDesc('is_tecnorepair_primary')
->orderBy('ragione_sociale')
->first();
}
/**
* @param Collection<int, AssistenzaTecnorepairScheda> $rows
* @return Collection<int, array<string, mixed>>
*/
private function collectUniqueCustomers(Collection $rows): Collection
{
$customers = [];
foreach ($rows as $scheda) {
$cliente = is_array($scheda->metadata['cliente'] ?? null) ? $scheda->metadata['cliente'] : [];
$legacyClienteId = $this->toInt($cliente['ID'] ?? $scheda->legacy_cliente_id);
$displayName = $this->cleanString($cliente['NomeCognome'] ?? $scheda->customer_name);
$phone = $this->cleanString($cliente['NumeroTelefono'] ?? $scheda->customer_phone);
$phoneAlt = $this->cleanString($cliente['TelFisso'] ?? $scheda->customer_phone_alt);
$email = $this->cleanString($cliente['Email'] ?? $scheda->customer_email);
$indirizzo = $this->cleanString($cliente['Indirizzo'] ?? null);
$cap = $this->cleanString($cliente['Cap'] ?? null);
$citta = $this->cleanString($cliente['Citta'] ?? null);
$provincia = $this->cleanString($cliente['Prov'] ?? null);
$partitaIva = $this->cleanString($cliente['PIVA'] ?? null);
$codiceFiscale = $this->cleanString($cliente['CodFis'] ?? null);
$note = $this->cleanString($cliente['Annotazioni'] ?? null);
$identity = $legacyClienteId !== null
? 'legacy-' . $legacyClienteId
: $this->buildIdentityKey($displayName, $email, $phone, $phoneAlt);
if ($identity === '' || ($displayName === null && $phone === null && $email === null)) {
continue;
}
if (! isset($customers[$identity])) {
$customers[$identity] = [
'legacy_cliente_id' => $legacyClienteId,
'display_name' => $displayName,
'phone' => $phone,
'phone_alt' => $phoneAlt,
'email' => $email,
'indirizzo' => $indirizzo,
'cap' => $cap,
'citta' => $citta,
'provincia' => $provincia,
'partita_iva' => $partitaIva,
'codice_fiscale' => $codiceFiscale,
'note' => $note,
'imported_from_path'=> $this->cleanString($scheda->imported_from_path),
'metadata' => [
'cliente' => $cliente,
'ultima_scheda_id' => (int) $scheda->id,
'ultima_scheda_num' => (string) ($scheda->legacy_numero_scheda ?? ''),
],
];
continue;
}
foreach (['display_name', 'phone', 'phone_alt', 'email', 'indirizzo', 'cap', 'citta', 'provincia', 'partita_iva', 'codice_fiscale', 'note', 'imported_from_path'] as $field) {
if (($customers[$identity][$field] ?? null) === null && ${Str::camel($field)} !== null) {
$customers[$identity][$field] = ${Str::camel($field)};
}
}
}
return collect(array_values($customers));
}
/**
* @param array<string, mixed> $customer
* @param array<string, int> $stats
*/
private function resolveOrCreateRubrica(Amministratore $admin, array $customer, array &$stats, bool $dryRun = false): ?RubricaUniversale
{
$query = RubricaUniversale::query()->where('amministratore_id', (int) $admin->id);
$email = $this->cleanString($customer['email'] ?? null);
$phone = $this->normalizePhone($customer['phone'] ?? null);
$phoneAlt = $this->normalizePhone($customer['phone_alt'] ?? null);
$phoneForRubrica = $this->normalizePhoneField($customer['phone'] ?? null);
$phoneAltForRubrica = $this->normalizePhoneField($customer['phone_alt'] ?? null);
$cf = $this->cleanString($customer['codice_fiscale'] ?? null);
$piva = $this->cleanString($customer['partita_iva'] ?? null);
$name = $this->cleanString($customer['display_name'] ?? null);
$provincia = $this->normalizeProvince($customer['provincia'] ?? null);
$rubrica = null;
if ($cf !== null) {
$rubrica = (clone $query)->where('codice_fiscale', $cf)->first();
}
if (! $rubrica && $piva !== null) {
$rubrica = (clone $query)->where('partita_iva', $piva)->first();
}
if (! $rubrica && $email !== null) {
$rubrica = (clone $query)->where('email', $email)->first();
}
if (! $rubrica && $phone !== null) {
$rubrica = (clone $query)->where(function ($phoneQuery) use ($phone): void {
$phoneQuery->whereRaw("REGEXP_REPLACE(COALESCE(telefono_cellulare, ''), '[^0-9]', '') = ?", [$phone])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_ufficio, ''), '[^0-9]', '') = ?", [$phone])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_casa, ''), '[^0-9]', '') = ?", [$phone]);
})->first();
}
if (! $rubrica && $phoneAlt !== null) {
$rubrica = (clone $query)->where(function ($phoneQuery) use ($phoneAlt): void {
$phoneQuery->whereRaw("REGEXP_REPLACE(COALESCE(telefono_cellulare, ''), '[^0-9]', '') = ?", [$phoneAlt])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_ufficio, ''), '[^0-9]', '') = ?", [$phoneAlt])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_casa, ''), '[^0-9]', '') = ?", [$phoneAlt]);
})->first();
}
if (! $rubrica && $name !== null) {
$rubrica = (clone $query)->where(function ($nameQuery) use ($name): void {
$nameQuery->whereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) = ?", [Str::lower($name)])
->orWhereRaw("LOWER(TRIM(COALESCE(ragione_sociale, ''))) = ?", [Str::lower($name)]);
})->first();
}
if ($rubrica instanceof RubricaUniversale) {
$stats['rubrica_match']++;
if (! $dryRun) {
$dirty = false;
foreach ([
'email' => $email,
'telefono_cellulare' => $phoneForRubrica,
'telefono_ufficio' => $phoneAltForRubrica,
'indirizzo' => $this->cleanString($customer['indirizzo'] ?? null),
'cap' => $this->cleanString($customer['cap'] ?? null),
'citta' => $this->cleanString($customer['citta'] ?? null),
'provincia' => $provincia,
'partita_iva' => $piva,
'codice_fiscale' => $cf,
] as $field => $value) {
if ($value !== null && ! filled($rubrica->{$field})) {
$rubrica->{$field} = $value;
$dirty = true;
}
}
if ($dirty) {
$rubrica->save();
$stats['rubrica_update']++;
}
}
return $rubrica;
}
$stats['rubrica_create']++;
if ($dryRun) {
return null;
}
[$nome, $cognome] = $this->splitDisplayName($name);
return RubricaUniversale::query()->create([
'amministratore_id' => (int) $admin->id,
'nome' => $nome,
'cognome' => $cognome,
'tipo_contatto' => 'persona_fisica',
'codice_fiscale' => $cf,
'partita_iva' => $piva,
'indirizzo' => $this->cleanString($customer['indirizzo'] ?? null),
'cap' => $this->cleanString($customer['cap'] ?? null),
'citta' => $this->cleanString($customer['citta'] ?? null),
'provincia' => $provincia,
'telefono_cellulare' => $phoneForRubrica,
'telefono_ufficio' => $phoneAltForRubrica,
'email' => $email,
'note' => $this->buildRubricaNote($customer),
'categoria' => 'cliente',
'stato' => 'attivo',
'data_inserimento' => now(),
'data_ultima_modifica' => now(),
'creato_da' => auth()->id(),
'modificato_da' => auth()->id(),
]);
}
private function splitDisplayName(?string $name): array
{
$name = trim((string) $name);
if ($name === '') {
return ['Cliente', 'TecnoRepair'];
}
$parts = preg_split('/\s+/', $name) ?: [];
if (count($parts) <= 1) {
return [$name, ''];
}
return [trim((string) array_shift($parts)), trim(implode(' ', $parts))];
}
private function buildRubricaNote(array $customer): string
{
$lines = array_filter([
'Import TecnoRepair TClienti per rubrica fornitore.',
isset($customer['legacy_cliente_id']) && $customer['legacy_cliente_id'] !== null ? 'Legacy cliente ID: ' . $customer['legacy_cliente_id'] : null,
$this->cleanString($customer['note'] ?? null),
]);
return implode("\n", $lines);
}
private function buildIdentityKey(?string $displayName, ?string $email, ?string $phone, ?string $phoneAlt): string
{
if ($email !== null) {
return 'email-' . Str::lower($email);
}
if ($phone !== null) {
return 'phone-' . preg_replace('/\D+/', '', $phone);
}
if ($phoneAlt !== null) {
return 'phone-' . preg_replace('/\D+/', '', $phoneAlt);
}
if ($displayName !== null) {
return 'name-' . Str::slug(Str::lower($displayName));
}
return '';
}
private function normalizePhone(mixed $value): ?string
{
$value = preg_replace('/\D+/', '', (string) $value) ?? '';
return $value !== '' ? $value : null;
}
private function normalizePhoneField(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
if (preg_match('/\+?[0-9][0-9\s().\/-]{4,}/', $value, $matches) !== 1) {
return null;
}
$normalized = preg_replace('/[^0-9+]/', '', (string) $matches[0]) ?? '';
$normalized = ltrim($normalized, '+') === '' ? '' : $normalized;
return $normalized !== '' ? Str::limit($normalized, 30, '') : null;
}
private function normalizeProvince(mixed $value): ?string
{
$value = Str::upper(trim((string) $value));
if ($value === '') {
return null;
}
$value = preg_replace('/[^A-Z]/', '', $value) ?? '';
if ($value === '') {
return null;
}
return Str::limit($value, 2, '');
}
private function cleanString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function toInt(mixed $value): ?int
{
if ($value === null || $value === '' || ! is_numeric($value)) {
return null;
}
return (int) $value;
}
}

View File

@ -2,7 +2,10 @@
namespace App\Filament\Pages\Fornitore; namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\AssistenzaTecnorepairScheda;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreCliente;
use App\Models\TicketIntervento; use App\Models\TicketIntervento;
use App\Models\User; use App\Models\User;
use BackedEnum; use BackedEnum;
@ -37,9 +40,21 @@ class RubricaClienti extends Page
public string $search = ''; public string $search = '';
public string $activeTab = 'elenco';
public ?int $selectedClienteId = null;
/** @var array<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
public array $rows = []; public array $rows = [];
/** @var array<string, int> */
public array $counters = [
'totali' => 0,
'collegati' => 0,
'con_email' => 0,
'con_cellulare' => 0,
];
/** @var array<string, mixed>|null */ /** @var array<string, mixed>|null */
public ?array $googleWorkspaceStatus = null; public ?array $googleWorkspaceStatus = null;
@ -71,15 +86,151 @@ public function updatedSearch(): void
$this->refreshRows(); $this->refreshRows();
} }
public function selectTab(string $tab): void
{
if (! in_array($tab, ['elenco', 'scheda'], true)) {
return;
}
$this->activeTab = $tab;
}
public function selectCliente(int $clienteId): void
{
$this->selectedClienteId = $clienteId > 0 ? $clienteId : null;
$this->activeTab = 'scheda';
}
public function refreshRows(): void public function refreshRows(): void
{ {
if (! $this->fornitoreId) { if (! $this->fornitoreId) {
$this->rows = []; $this->rows = [];
$this->counters = ['totali' => 0, 'collegati' => 0, 'con_email' => 0, 'con_cellulare' => 0];
return; return;
} }
$term = Str::lower(trim($this->search)); $term = Str::lower(trim($this->search));
$legacyCounts = AssistenzaTecnorepairScheda::query()
->where('fornitore_id', $this->fornitoreId)
->selectRaw('legacy_cliente_id, COUNT(*) as totale_schede, MAX(date_received) as ultima_data')
->groupBy('legacy_cliente_id')
->get()
->mapWithKeys(fn(AssistenzaTecnorepairScheda $row): array => [
(int) ($row->legacy_cliente_id ?? 0) => [
'totale_schede' => (int) ($row->totale_schede ?? 0),
'ultima_data' => optional($row->ultima_data)->format('d/m/Y') ?: '-',
],
])
->all();
$items = FornitoreCliente::query()
->with('rubrica:id,nome,cognome,ragione_sociale,email,telefono_cellulare,telefono_ufficio,categoria')
->where('fornitore_id', $this->fornitoreId)
->when($term !== '', function ($query) use ($term): void {
$like = '%' . $term . '%';
$query->where(function ($inner) use ($like): void {
$inner->where('display_name', 'like', $like)
->orWhere('phone', 'like', $like)
->orWhere('phone_alt', 'like', $like)
->orWhere('email', 'like', $like)
->orWhere('citta', 'like', $like)
->orWhere('indirizzo', 'like', $like);
});
})
->orderByRaw('CASE WHEN rubrica_id IS NULL THEN 1 ELSE 0 END')
->orderBy('display_name')
->limit(500)
->get();
if ($items->isEmpty()) {
$this->rows = $this->buildTicketFallbackRows($term);
$this->counters = [
'totali' => count($this->rows),
'collegati' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['rubrica_id'] ?? null))->count(),
'con_email' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['email'] ?? null))->count(),
'con_cellulare' => (int) collect($this->rows)->filter(fn(array $row): bool => filled($row['phone'] ?? null))->count(),
];
$this->selectedClienteId = null;
return;
}
$this->rows = $items->map(function (FornitoreCliente $cliente) use ($legacyCounts): array {
$legacy = $legacyCounts[(int) ($cliente->legacy_cliente_id ?? 0)] ?? ['totale_schede' => 0, 'ultima_data' => '-'];
return [
'id' => (int) $cliente->id,
'legacy_cliente_id'=> (int) ($cliente->legacy_cliente_id ?? 0),
'display_name' => (string) ($cliente->display_name ?: 'Cliente TecnoRepair'),
'phone' => (string) ($cliente->phone ?? ''),
'phone_alt' => (string) ($cliente->phone_alt ?? ''),
'email' => (string) ($cliente->email ?? ''),
'indirizzo' => (string) ($cliente->indirizzo ?? ''),
'citta' => (string) ($cliente->citta ?? ''),
'provincia' => (string) ($cliente->provincia ?? ''),
'rubrica_id' => (int) ($cliente->rubrica_id ?? 0),
'rubrica_url' => $cliente->rubrica_id ? RubricaUniversaleScheda::getUrl(['record' => (int) $cliente->rubrica_id], panel: 'admin-filament') : null,
'rubrica_label' => (string) ($cliente->rubrica?->categoria ?? ''),
'totale_schede' => (int) ($legacy['totale_schede'] ?? 0),
'ultima_data' => (string) ($legacy['ultima_data'] ?? '-'),
'updated_at' => optional($cliente->updated_at)->format('d/m/Y H:i') ?: '-',
'source' => 'tecnorepair_tclienti',
];
})->all();
$this->counters = [
'totali' => count($this->rows),
'collegati' => (int) collect($this->rows)->filter(fn(array $row): bool => (int) ($row['rubrica_id'] ?? 0) > 0)->count(),
'con_email' => (int) collect($this->rows)->filter(fn(array $row): bool => trim((string) ($row['email'] ?? '')) !== '')->count(),
'con_cellulare' => (int) collect($this->rows)->filter(fn(array $row): bool => trim((string) ($row['phone'] ?? '')) !== '')->count(),
];
if ((int) ($this->selectedClienteId ?? 0) <= 0 && $this->rows !== []) {
$this->selectedClienteId = (int) $this->rows[0]['id'];
}
}
public function getSelectedClienteProperty(): ?FornitoreCliente
{
$selectedId = (int) ($this->selectedClienteId ?? 0);
if ($selectedId <= 0 || ! $this->fornitoreId) {
return null;
}
return FornitoreCliente::query()
->with('rubrica')
->where('fornitore_id', $this->fornitoreId)
->find($selectedId);
}
/** @return array<int, array<string, mixed>> */
public function getSelectedClienteSchedeProperty(): array
{
$cliente = $this->selectedCliente;
if (! $cliente instanceof FornitoreCliente || ! $this->fornitoreId) {
return [];
}
return AssistenzaTecnorepairScheda::query()
->where('fornitore_id', $this->fornitoreId)
->when((int) ($cliente->legacy_cliente_id ?? 0) > 0, fn($query) => $query->where('legacy_cliente_id', (int) $cliente->legacy_cliente_id))
->orderByDesc('date_received')
->orderByDesc('id')
->limit(30)
->get()
->map(fn(AssistenzaTecnorepairScheda $scheda): array => [
'id' => (int) $scheda->id,
'numero' => (string) ($scheda->legacy_numero_scheda ?: ('#' . $scheda->id)),
'prodotto' => (string) ($scheda->product_model ?: '-'),
'codice' => (string) ($scheda->product_code ?: ''),
'seriali' => (string) $scheda->serial_label,
'stato' => (string) ($scheda->status_label ?: '-'),
'status_badge' => (string) $scheda->status_badge_classes,
'ingresso' => optional($scheda->date_received)->format('d/m/Y') ?: '-',
])->all();
$items = TicketIntervento::query() $items = TicketIntervento::query()
->with(['ticket.stabile', 'ticket.soggettoRichiedente', 'ticket.apertoDaUser']) ->with(['ticket.stabile', 'ticket.soggettoRichiedente', 'ticket.apertoDaUser'])
->where('fornitore_id', $this->fornitoreId) ->where('fornitore_id', $this->fornitoreId)
@ -144,6 +295,19 @@ public function getProdottiUrl(): string
return ProdottiCatalogo::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament'); return ProdottiCatalogo::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament');
} }
public function getImportCommandHintProperty(): string
{
$fornitore = (int) ($this->fornitoreId ?? 0) > 0
? Fornitore::query()->select(['id', 'amministratore_id'])->find((int) $this->fornitoreId)
: null;
if (! $fornitore instanceof Fornitore) {
return 'php artisan tecnorepair:import-rubrica-clienti {amministratore} --fornitore-id={id}';
}
return 'php artisan tecnorepair:import-rubrica-clienti ' . (int) ($fornitore->amministratore_id ?? 0) . ' --fornitore-id=' . (int) $fornitore->id;
}
public function getGoogleConnectUrl(): string public function getGoogleConnectUrl(): string
{ {
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
@ -226,6 +390,59 @@ protected function buildRubricaUrlForCurrentUser(int $rubricaId): ?string
return null; return null;
} }
/**
* @return array<int, array<string, mixed>>
*/
private function buildTicketFallbackRows(string $term = ''): array
{
$items = TicketIntervento::query()
->with(['ticket.stabile', 'ticket.soggettoRichiedente', 'ticket.apertoDaUser'])
->where('fornitore_id', $this->fornitoreId)
->latest('updated_at')
->limit(250)
->get();
$grouped = [];
foreach ($items as $intervento) {
$contact = $this->resolveCallerDataForRubrica($intervento);
$key = $contact['identity_key'] !== '' ? $contact['identity_key'] : ('ticket-' . (int) $intervento->ticket_id);
if ($term !== '') {
$haystack = Str::lower(implode(' ', [$contact['contatto'], $contact['telefono'], $contact['email'], $contact['stabile'], $contact['origine']]));
if (! Str::contains($haystack, $term)) {
continue;
}
}
if (! isset($grouped[$key])) {
$grouped[$key] = [
'id' => 0,
'legacy_cliente_id' => 0,
'display_name' => $contact['contatto'],
'phone' => $contact['telefono'],
'phone_alt' => '',
'email' => $contact['email'],
'indirizzo' => '',
'citta' => '',
'provincia' => '',
'rubrica_id' => 0,
'rubrica_url' => $contact['rubrica_url'],
'rubrica_label' => $contact['rubrica_label'],
'totale_schede' => 1,
'ultima_data' => optional($intervento->updated_at)->format('d/m/Y') ?: '-',
'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'source' => 'ticket_fallback',
];
continue;
}
$grouped[$key]['totale_schede']++;
}
return array_values($grouped);
}
/** /**
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */

View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FornitoreCliente extends Model
{
use HasFactory;
protected $table = 'fornitore_clienti';
protected $fillable = [
'amministratore_id',
'fornitore_id',
'rubrica_id',
'legacy_cliente_id',
'display_name',
'phone',
'phone_alt',
'email',
'indirizzo',
'cap',
'citta',
'provincia',
'partita_iva',
'codice_fiscale',
'note',
'source',
'imported_from_path',
'imported_at',
'metadata',
];
protected $casts = [
'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 rubrica(): BelongsTo
{
return $this->belongsTo(RubricaUniversale::class, 'rubrica_id');
}
}

View File

@ -0,0 +1,48 @@
<?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('fornitore_clienti')) {
return;
}
Schema::create('fornitore_clienti', function (Blueprint $table): void {
$table->id();
$table->foreignId('amministratore_id')->nullable()->constrained('amministratori')->nullOnDelete();
$table->foreignId('fornitore_id')->constrained('fornitori')->cascadeOnDelete();
$table->foreignId('rubrica_id')->nullable()->constrained('rubrica_universale')->nullOnDelete();
$table->unsignedBigInteger('legacy_cliente_id')->nullable();
$table->string('display_name')->nullable();
$table->string('phone')->nullable();
$table->string('phone_alt')->nullable();
$table->string('email')->nullable();
$table->string('indirizzo')->nullable();
$table->string('cap')->nullable();
$table->string('citta')->nullable();
$table->string('provincia')->nullable();
$table->string('partita_iva')->nullable();
$table->string('codice_fiscale')->nullable();
$table->text('note')->nullable();
$table->string('source')->nullable();
$table->string('imported_from_path')->nullable();
$table->timestamp('imported_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['fornitore_id', 'legacy_cliente_id'], 'fornitore_clienti_fornitore_legacy_unique');
$table->index(['fornitore_id', 'display_name'], 'fornitore_clienti_fornitore_name_idx');
$table->index(['fornitore_id', 'rubrica_id'], 'fornitore_clienti_fornitore_rubrica_idx');
});
}
public function down(): void
{
Schema::dropIfExists('fornitore_clienti');
}
};

View File

@ -6,7 +6,7 @@
<div class="text-lg font-semibold">Rubrica clienti fornitore</div> <div class="text-lg font-semibold">Rubrica clienti fornitore</div>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
@if($this->fornitoreLabel) @if($this->fornitoreLabel)
Contatti emersi dai ticket e dalle lavorazioni di {{ $this->fornitoreLabel }}, agganciati quando possibile all'anagrafica unica. Elenco clienti del singolo fornitore, con sorgente TecnoRepair `TClienti` e collegamento alla scheda condivisa di anagrafica unica quando disponibile.
@else @else
Seleziona un fornitore per aprire la rubrica clienti. Seleziona un fornitore per aprire la rubrica clienti.
@endif @endif
@ -20,7 +20,7 @@
</div> </div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700"> <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
Questa vista resta stretta sui contatti del singolo fornitore, ma il punto di verità anagrafico rimane la scheda dell'anagrafica unica. Da qui puoi filtrare i contatti operativi e saltare direttamente sulla scheda condivisa quando il legame esiste gia. Questa vista resta stretta sul fornitore, ma la scheda definitiva resta quella condivisa di anagrafica unica. Prima importa i clienti con <span class="font-mono text-xs">{{ $this->importCommandHint }}</span>, poi usa questa pagina per lavorare in modo compatto su elenco, recapiti e storico apparecchi.
</div> </div>
@if($this->missingAdminContext) @if($this->missingAdminContext)
@ -28,67 +28,193 @@
Questa vista richiede un fornitore selezionato. Questa vista richiede un fornitore selezionato.
</div> </div>
@else @else
<div class="grid gap-4 xl:grid-cols-[2fr,1fr]"> <div class="rounded-xl border bg-white p-4">
<div class="rounded-xl border bg-white p-4"> <div class="grid gap-3 xl:grid-cols-[1.2fr,0.8fr]">
<label class="block text-sm"> <div class="flex flex-wrap items-center gap-2">
<span class="mb-1 block font-medium">Cerca contatto</span> <button type="button" wire:click="selectTab('elenco')" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'elenco' ? 'bg-slate-800 text-white' : 'bg-white text-slate-700 ring-1 ring-inset ring-slate-300 hover:bg-slate-50' }}">Elenco compatto</button>
<input type="text" wire:model.live.debounce.300ms="search" class="w-full rounded-lg border-gray-300" placeholder="Nome, telefono, email, stabile" /> <button type="button" wire:click="selectTab('scheda')" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'scheda' ? 'bg-slate-800 text-white' : 'bg-white text-slate-700 ring-1 ring-inset ring-slate-300 hover:bg-slate-50' }}">Scheda cliente</button>
</label> </div>
<div class="mt-4 space-y-3"> <div class="grid grid-cols-2 gap-2 xl:grid-cols-4">
@forelse($rows as $row) <div class="rounded-lg border bg-slate-50 px-3 py-2 text-xs">Totali <span class="font-semibold">{{ $counters['totali'] ?? 0 }}</span></div>
<div class="rounded-xl border p-4 text-sm shadow-sm"> <div class="rounded-lg border bg-emerald-50 px-3 py-2 text-xs text-emerald-800">Collegati <span class="font-semibold">{{ $counters['collegati'] ?? 0 }}</span></div>
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="rounded-lg border bg-sky-50 px-3 py-2 text-xs text-sky-800">Con email <span class="font-semibold">{{ $counters['con_email'] ?? 0 }}</span></div>
<div> <div class="rounded-lg border bg-amber-50 px-3 py-2 text-xs text-amber-800">Con cellulare <span class="font-semibold">{{ $counters['con_cellulare'] ?? 0 }}</span></div>
<div class="font-semibold">{{ $row['contatto'] }}</div>
<div class="mt-1 text-xs text-gray-500">Origine: {{ $row['origine'] }} · Interventi: {{ $row['totale_interventi'] }}</div>
@if($row['rubrica_url'])
<div class="mt-1 text-xs text-indigo-700">Anagrafica unica collegata{{ $row['rubrica_label'] !== '' ? ' · ' . $row['rubrica_label'] : '' }}</div>
@endif
<div class="mt-2 text-xs text-gray-500">Stabile: {{ $row['stabile'] }}</div>
<div class="mt-1 text-xs text-gray-500">Ultimo aggiornamento: {{ $row['updated_at'] }}</div>
</div>
<div class="flex flex-wrap gap-2">
@if($row['rubrica_url'])
<a href="{{ $row['rubrica_url'] }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-300 hover:bg-indigo-50">Scheda anagrafica</a>
@endif
@if($row['telefono'] !== '')
<a href="tel:{{ preg_replace('/\s+/', '', (string) $row['telefono']) }}" class="inline-flex items-center rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500">Chiama</a>
@endif
@if($row['email'] !== '')
<a href="mailto:{{ $row['email'] }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Email</a>
@endif
<a href="{{ $row['ultima_scheda_url'] }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Ultima scheda</a>
</div>
</div>
</div>
@empty
<div class="rounded-xl border border-dashed p-6 text-sm text-gray-500">Nessun contatto disponibile per il filtro corrente.</div>
@endforelse
</div> </div>
</div> </div>
<div class="rounded-xl border bg-white p-4"> <div class="mt-4 grid gap-4 xl:grid-cols-[1.4fr,0.6fr]">
<div class="text-sm font-semibold">Google workspace</div> <div>
@if($googleWorkspaceStatus) @if($activeTab === 'elenco')
<div class="mt-3 space-y-2 text-sm"> <label class="block text-sm">
<div><span class="font-medium">Stato:</span> {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}</div> <span class="mb-1 block font-medium">Cerca cliente</span>
<div><span class="font-medium">Account:</span> {{ $googleWorkspaceStatus['email'] !== '' ? $googleWorkspaceStatus['email'] : '-' }}</div> <input type="text" wire:model.live.debounce.300ms="search" class="w-full rounded-lg border-gray-300" placeholder="Nome, telefono, email, citta" />
<div><span class="font-medium">Nome:</span> {{ $googleWorkspaceStatus['name'] !== '' ? $googleWorkspaceStatus['name'] : '-' }}</div> </label>
<div><span class="font-medium">Ultimo collegamento:</span> {{ $googleWorkspaceStatus['connected_at'] !== '' ? $googleWorkspaceStatus['connected_at'] : '-' }}</div>
</div> <div class="mt-4 overflow-x-auto">
<div class="mt-3 flex flex-wrap gap-2"> <table class="min-w-full border-collapse border text-xs">
<a href="{{ $this->getGoogleConnectUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Collega / aggiorna Google</a> <thead>
@if($googleWorkspaceStatus['connected']) <tr class="bg-slate-100 text-slate-700">
<form method="POST" action="{{ $this->getGoogleDisconnectUrl() }}"> <th class="border px-2 py-2 text-left">Cliente</th>
@csrf <th class="border px-2 py-2 text-left">Recapiti</th>
<button type="submit" class="inline-flex items-center rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-rose-500">Scollega</button> <th class="border px-2 py-2 text-left">Zona</th>
</form> <th class="border px-2 py-2 text-left">Schede</th>
<th class="border px-2 py-2 text-left">Rubrica</th>
<th class="border px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($rows as $row)
<tr class="{{ ($selectedClienteId ?? 0) === (int) ($row['id'] ?? 0) && (int) ($row['id'] ?? 0) > 0 ? 'bg-sky-50' : 'bg-white hover:bg-slate-50' }}">
<td class="border px-2 py-2 align-top">
<div class="font-medium text-slate-900">{{ $row['display_name'] }}</div>
<div class="text-[11px] text-slate-500">Fonte {{ $row['source'] === 'tecnorepair_tclienti' ? 'TClienti' : 'ticket' }}</div>
</td>
<td class="border px-2 py-2 align-top">
<div>{{ $row['phone'] !== '' ? $row['phone'] : '-' }}</div>
<div class="text-[11px] text-slate-500">{{ $row['phone_alt'] !== '' ? $row['phone_alt'] : ($row['email'] !== '' ? $row['email'] : 'nessun recapito') }}</div>
</td>
<td class="border px-2 py-2 align-top">
<div>{{ $row['citta'] !== '' ? $row['citta'] : '-' }}</div>
<div class="text-[11px] text-slate-500">{{ $row['indirizzo'] !== '' ? $row['indirizzo'] : '' }}</div>
</td>
<td class="border px-2 py-2 align-top">
<div class="font-medium">{{ $row['totale_schede'] ?? 0 }}</div>
<div class="text-[11px] text-slate-500">Ultima {{ $row['ultima_data'] ?? '-' }}</div>
</td>
<td class="border px-2 py-2 align-top">
@if($row['rubrica_url'])
<a href="{{ $row['rubrica_url'] }}" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-indigo-700 ring-1 ring-inset ring-indigo-300 hover:bg-indigo-50">Scheda condivisa</a>
@else
<span class="text-[11px] text-amber-700">Non collegato</span>
@endif
</td>
<td class="border px-2 py-2 align-top">
@if((int) ($row['id'] ?? 0) > 0)
<button type="button" wire:click="selectCliente({{ (int) $row['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">Apri</button>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-6 text-center text-sm text-slate-500">Nessun cliente disponibile. Importa prima `TClienti` nell'archivio legacy e poi lancia il comando rubrica clienti.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@else
@php
$selected = $this->selectedCliente;
@endphp
@if(! $selected)
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Seleziona un cliente dall'elenco compatto per aprire la scheda fornitore limitata.
</div>
@else
<div class="grid gap-4 xl:grid-cols-[1.1fr,0.9fr]">
<div class="space-y-4">
<div class="rounded-xl border bg-slate-50 p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Scheda cliente</div>
<div class="mt-2 text-lg font-semibold text-slate-900">{{ $selected->display_name ?: 'Cliente TecnoRepair' }}</div>
<div class="mt-1 text-xs text-slate-500">Legacy cliente ID {{ $selected->legacy_cliente_id ?: '-' }} · aggiornato {{ optional($selected->updated_at)->format('d/m/Y H:i') ?: '-' }}</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border bg-white px-3 py-2 text-sm">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Cellulare</div>
<div class="mt-1">{{ $selected->phone ?: '-' }}</div>
</div>
<div class="rounded-lg border bg-white px-3 py-2 text-sm">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Fisso / email</div>
<div class="mt-1">{{ $selected->phone_alt ?: ($selected->email ?: '-') }}</div>
</div>
<div class="rounded-lg border bg-white px-3 py-2 text-sm sm:col-span-2">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Indirizzo</div>
<div class="mt-1">{{ trim(implode(', ', array_filter([$selected->indirizzo, $selected->cap, $selected->citta, $selected->provincia]))) !== '' ? trim(implode(', ', array_filter([$selected->indirizzo, $selected->cap, $selected->citta, $selected->provincia]))) : '-' }}</div>
</div>
<div class="rounded-lg border bg-white px-3 py-2 text-sm">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Codice fiscale</div>
<div class="mt-1">{{ $selected->codice_fiscale ?: '-' }}</div>
</div>
<div class="rounded-lg border bg-white px-3 py-2 text-sm">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">P. IVA</div>
<div class="mt-1">{{ $selected->partita_iva ?: '-' }}</div>
</div>
</div>
@if($selected->rubrica_id)
<div class="mt-4">
<a href="{{ \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => (int) $selected->rubrica_id], panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Apri scheda anagrafica condivisa</a>
</div>
@endif
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Storico apparecchi / seriali</div>
<div class="mt-3 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">Prodotto</th>
<th class="border px-2 py-2 text-left">Seriali</th>
<th class="border px-2 py-2 text-left">Stato</th>
</tr>
</thead>
<tbody>
@forelse($this->selectedClienteSchede as $scheda)
<tr class="bg-white hover:bg-slate-50">
<td class="border px-2 py-2 align-top">
<div class="font-medium">{{ $scheda['numero'] }}</div>
<div class="text-[11px] text-slate-500">{{ $scheda['ingresso'] }}</div>
</td>
<td class="border px-2 py-2 align-top">
<div>{{ $scheda['prodotto'] }}</div>
<div class="text-[11px] text-slate-500">{{ $scheda['codice'] !== '' ? $scheda['codice'] : 'senza codice' }}</div>
</td>
<td class="border px-2 py-2 align-top">{{ $scheda['seriali'] }}</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'] }}">{{ $scheda['stato'] }}</span></td>
</tr>
@empty
<tr>
<td colspan="4" class="border px-2 py-4 text-center text-slate-500">Nessuna scheda legacy collegata a questo cliente.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Google workspace</div>
@if($googleWorkspaceStatus)
<div class="mt-3 space-y-2 text-sm">
<div><span class="font-medium">Stato:</span> {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}</div>
<div><span class="font-medium">Account:</span> {{ $googleWorkspaceStatus['email'] !== '' ? $googleWorkspaceStatus['email'] : '-' }}</div>
<div><span class="font-medium">Nome:</span> {{ $googleWorkspaceStatus['name'] !== '' ? $googleWorkspaceStatus['name'] : '-' }}</div>
<div><span class="font-medium">Ultimo collegamento:</span> {{ $googleWorkspaceStatus['connected_at'] !== '' ? $googleWorkspaceStatus['connected_at'] : '-' }}</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ $this->getGoogleConnectUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Collega / aggiorna Google</a>
@if($googleWorkspaceStatus['connected'])
<form method="POST" action="{{ $this->getGoogleDisconnectUrl() }}">
@csrf
<button type="submit" class="inline-flex items-center rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-rose-500">Scollega</button>
</form>
@endif
</div>
@else
<div class="mt-3 text-sm text-gray-500">Fornitore senza amministratore collegato: stato Google non disponibile.</div>
@endif
</div>
<div class="rounded-xl border bg-slate-50 p-4 text-sm text-slate-700">
Il modello dati prodotti e seriali è già separato: un prodotto unico può avere più seriali collegati. Nella fase successiva si può rifinire la UI della scheda intervento in stile TecnoRepair e riusare la pipeline FE lato fornitore senza auto-import.
</div>
</div>
</div>
@endif @endif
</div> </div>
<div class="mt-3 text-xs text-gray-500">Questo blocco prepara l'uso fornitore di rubrica, calendario e Drive sullo stesso backend Google gia presente nel progetto.</div>
@else
<div class="mt-3 text-sm text-gray-500">Fornitore senza amministratore collegato: stato Google non disponibile.</div>
@endif @endif
</div> </div>
</div> </div>