fix(fornitore): unifica pratiche e ticket a 1874 schede live, rubrica multi-fonte a 3716 contatti e progetto centralino push

This commit is contained in:
michele 2026-09-12 10:20:41 +02:00
parent 067be6513a
commit 07cec6ab7a
11 changed files with 646 additions and 101 deletions

View File

@ -215,8 +215,9 @@ private function collectUniqueCustomers(Collection $rows): Collection
}
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);}
$varName = Str::camel($field);
if (($customers[$identity][$field] ?? null) === null && isset($$varName) && $$varName !== null) {
$customers[$identity][$field] = $$varName;
}
}
}

View File

@ -189,6 +189,16 @@ public function resetFiltri(): void
$this->refreshData();
}
public function getFornitoreIds(): array
{
$id = (int) $this->fornitoreId;
if (in_array($id, [236, 359], true) || str_contains(strtoupper((string) $this->fornitoreLabel), 'NETHOME')) {
return [236, 359, 392];
}
return [$id];
}
public function refreshData(): void
{
if (! $this->fornitoreId) {
@ -196,14 +206,10 @@ public function refreshData(): void
return;
}
$fornitoreIds = $this->getFornitoreIds();
$query = AssistenzaTecnorepairScheda::query()
->where(function (Builder $builder) {
$builder->where('fornitore_id', (int) $this->fornitoreId);
// Also include merged suppliers (e.g. NCOMSRL if Nethome)
if ((int) $this->fornitoreId === 236) {
$builder->orWhere('fornitore_id', 392);
}
});
->whereIn('fornitore_id', $fornitoreIds);
// Search filters
if (trim($this->searchMatricola) !== '') {
@ -265,12 +271,7 @@ public function refreshData(): void
// Count totals across dataset
$baseTotalQuery = AssistenzaTecnorepairScheda::query()
->where(function (Builder $builder) {
$builder->where('fornitore_id', (int) $this->fornitoreId);
if ((int) $this->fornitoreId === 236) {
$builder->orWhere('fornitore_id', 392);
}
});
->whereIn('fornitore_id', $fornitoreIds);
$this->totals = [
'totale' => (clone $baseTotalQuery)->count(),
@ -477,7 +478,8 @@ public function sincronizzaMdb(): void
{
try {
$service = app(TecnoRepairArchiveService::class);
$stats = $service->importArchive(13, (int) $this->fornitoreId);
$targetFornitoreId = in_array((int) $this->fornitoreId, [236, 359], true) ? 236 : (int) $this->fornitoreId;
$stats = $service->importArchive(13, $targetFornitoreId);
$msg = sprintf(
'Archivio MDB TecnoRepair sincronizzato: %d schede (%d create, %d agg.), %d ricambi, %d seriali.',
$stats['schede_lette'],

View File

@ -101,6 +101,23 @@ public function selectCliente(int $clienteId): void
$this->activeTab = 'scheda';
}
public string $filterSource = 'all';
public function updatedFilterSource(): void
{
$this->refreshRows();
}
public function getFornitoreIds(): array
{
$id = (int) $this->fornitoreId;
if (in_array($id, [236, 359], true) || str_contains(strtoupper((string) $this->fornitoreLabel), 'NETHOME')) {
return [236, 359, 392];
}
return [$id];
}
public function refreshRows(): void
{
if (! $this->fornitoreId) {
@ -109,10 +126,11 @@ public function refreshRows(): void
return;
}
$fornitoreIds = $this->getFornitoreIds();
$term = Str::lower(trim($this->search));
$legacyCounts = AssistenzaTecnorepairScheda::query()
->where('fornitore_id', $this->fornitoreId)
->whereIn('fornitore_id', $fornitoreIds)
->selectRaw('legacy_cliente_id, COUNT(*) as totale_schede, MAX(date_received) as ultima_data')
->groupBy('legacy_cliente_id')
->get()
@ -124,26 +142,39 @@ public function refreshRows(): void
])
->all();
$items = FornitoreCliente::query()
$query = 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 . '%';
->whereIn('fornitore_id', $fornitoreIds);
$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);
});
})
if ($this->filterSource === 'tecnorepair') {
$query->where('source', 'tecnorepair_tclienti');
} elseif ($this->filterSource === 'contabilita') {
$query->where('source', 'contabilita_mysql');
} elseif ($this->filterSource === 'tickets') {
$query->where('source', 'netgescon_ticket');
}
if ($term !== '') {
$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)
->orWhere('partita_iva', 'like', $like)
->orWhere('codice_fiscale', 'like', $like);
});
}
$items = (clone $query)
->orderByRaw('CASE WHEN rubrica_id IS NULL THEN 1 ELSE 0 END')
->orderBy('display_name')
->limit(200)
->get();
if ($items->isEmpty()) {
if ($items->isEmpty() && $term !== '' && $this->filterSource === 'all') {
$this->rows = $this->buildTicketFallbackRows($term);
$this->counters = [
'totali' => count($this->rows),
@ -175,15 +206,16 @@ public function refreshRows(): void
'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',
'source' => (string) ($cliente->source ?: 'tecnorepair_tclienti'),
];
})->all();
$countQuery = FornitoreCliente::query()->whereIn('fornitore_id', $fornitoreIds);
$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(),
'totali' => (clone $countQuery)->count(),
'collegati' => (clone $countQuery)->whereNotNull('rubrica_id')->count(),
'con_email' => (clone $countQuery)->whereNotNull('email')->where('email', '!=', '')->count(),
'con_cellulare' => (clone $countQuery)->whereNotNull('phone')->where('phone', '!=', '')->count(),
];
if ((int) ($this->selectedClienteId ?? 0) <= 0 && $this->rows !== []) {
@ -191,6 +223,25 @@ public function refreshRows(): void
}
}
public function sincronizzaRubrica(): void
{
try {
$service = app(\App\Services\Fornitore\FornitoreRubricaSyncService::class);
$stats = $service->syncAll(13, (int) $this->fornitoreId);
$msg = sprintf(
'Rubrica sincronizzata: %d TecnoRepair, %d Contabilità, %d Ticket. Totale contatti: %d.',
$stats['tecnorepair'],
$stats['contabilita'],
$stats['tickets'],
$stats['total']
);
\Filament\Notifications\Notification::make()->title($msg)->success()->send();
$this->refreshRows();
} catch (\Throwable $e) {
\Filament\Notifications\Notification::make()->title('Errore sincronizzazione: ' . $e->getMessage())->danger()->send();
}
}
public function getSelectedClienteProperty(): ?FornitoreCliente
{
$selectedId = (int) ($this->selectedClienteId ?? 0);

View File

@ -122,7 +122,7 @@ public function refreshData(): void
return;
}
[$fornitore, $dipendente] = $this->resolveOperatoreContext($this->fornitoreId);
[$fornitore, $dipendente] = $this->resolveOperatoreContext($this->fornitoreId, allowAdminWithoutSupplier: true);
$query = $this->buildBaseQuery($fornitore, $dipendente);
$this->applyStatusFilter($query, $this->status);
@ -167,17 +167,19 @@ public function refreshData(): void
$this->rows = array_slice($rows, 0, 150);
$fornitoreIds = $this->getFornitoreIds($fornitore);
$this->totals = [
'aperti' => (clone $this->buildBaseQuery($fornitore, $dipendente))
->whereNotIn('stato', ['chiuso'])
->count() + AssistenzaTecnorepairScheda::query()
->where('fornitore_id', (int) $fornitore->id)
->whereIn('fornitore_id', $fornitoreIds)
->whereIn('status_bucket', ['open', 'waiting'])
->count(),
'chiusi' => (clone $this->buildBaseQuery($fornitore, $dipendente))
->whereIn('stato', ['chiuso', 'fatturato'])
->count() + AssistenzaTecnorepairScheda::query()
->where('fornitore_id', (int) $fornitore->id)
->whereIn('fornitore_id', $fornitoreIds)
->where('status_bucket', 'closed')
->count(),
'fatturabili' => (clone $this->buildBaseQuery($fornitore, $dipendente))
@ -186,6 +188,16 @@ public function refreshData(): void
];
}
public function getFornitoreIds(?Fornitore $fornitore = null): array
{
$id = $fornitore ? (int) $fornitore->id : (int) $this->fornitoreId;
if (in_array($id, [236, 359], true) || str_contains(strtoupper((string) $this->fornitoreLabel), 'NETHOME')) {
return [236, 359, 392];
}
return [$id];
}
public function setStatus(string $status): void
{
$this->status = $status;
@ -286,9 +298,11 @@ public function closeInterventoModal(): void
protected function buildBaseQuery(Fornitore $fornitore, ?FornitoreDipendente $dipendente): Builder
{
$fornitoreIds = $this->getFornitoreIds($fornitore);
$query = TicketIntervento::query()
->with(['ticket.stabile', 'ticket.unitaImmobiliare', 'ticket.soggettoRichiedente', 'eseguitoDaDipendente'])
->where('fornitore_id', (int) $fornitore->id)
->whereIn('fornitore_id', $fornitoreIds)
->orderByDesc('created_at');
if ($dipendente instanceof FornitoreDipendente) {
@ -321,8 +335,10 @@ protected function applyStatusFilter(Builder $query, string $status): void
*/
protected function buildTecnorepairRows(Fornitore $fornitore): array
{
$fornitoreIds = $this->getFornitoreIds($fornitore);
$query = AssistenzaTecnorepairScheda::query()
->where('fornitore_id', (int) $fornitore->id)
->whereIn('fornitore_id', $fornitoreIds)
->orderByDesc('date_received')
->orderByDesc('updated_at');

View File

@ -0,0 +1,426 @@
<?php
namespace App\Services\Fornitore;
use App\Models\AssistenzaTecnorepairScheda;
use App\Models\Fornitore;
use App\Models\FornitoreCliente;
use App\Models\RubricaUniversale;
use App\Models\TicketIntervento;
use App\Services\Tecnorepair\TecnoRepairArchiveService;
use App\Support\PhoneNumber;
use App\Support\TecnoRepairMdbReader;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class FornitoreRubricaSyncService
{
public function __construct(
private readonly TecnoRepairMdbReader $mdbReader,
private readonly TecnoRepairArchiveService $archiveService,
) {
}
/**
* Synchronize contacts from all three sources:
* 1. TecnoRepair MDB (TClienti)
* 2. Contabilità MySQL arc_nehr (cli + ind)
* 3. NetGescon Tickets / Stabili
*
* @return array{tecnorepair: int, contabilita: int, tickets: int, total: int}
*/
public function syncAll(int $amministratoreId, int $fornitoreId): array
{
$primaryFornitoreId = in_array($fornitoreId, [236, 359], true) ? 236 : $fornitoreId;
$stats = [
'tecnorepair' => 0,
'contabilita' => 0,
'tickets' => 0,
'total' => 0,
];
// 1. Sync TecnoRepair TClienti
try {
$stats['tecnorepair'] = $this->syncTecnoRepair($amministratoreId, $primaryFornitoreId);
} catch (\Throwable $e) {
Log::warning('FornitoreRubricaSyncService: errore sync TecnoRepair: ' . $e->getMessage());
}
// 2. Sync Contabilità MySQL
try {
$stats['contabilita'] = $this->syncContabilita($amministratoreId, $primaryFornitoreId);
} catch (\Throwable $e) {
Log::warning('FornitoreRubricaSyncService: errore sync Contabilità: ' . $e->getMessage());
}
// 3. Sync NetGescon Tickets
try {
$stats['tickets'] = $this->syncNetGesconTickets($amministratoreId, $primaryFornitoreId);
} catch (\Throwable $e) {
Log::warning('FornitoreRubricaSyncService: errore sync Tickets: ' . $e->getMessage());
}
$stats['total'] = FornitoreCliente::query()
->where('fornitore_id', $primaryFornitoreId)
->count();
return $stats;
}
/**
* Import contacts from TecnoRepairDB.mdb TClienti table
*/
public function syncTecnoRepair(int $amministratoreId, int $fornitoreId): int
{
$fornitore = Fornitore::query()->find($fornitoreId);
$mdbPath = $this->archiveService->resolveMdbPath(null, $fornitore);
if (! file_exists($mdbPath) || ! is_readable($mdbPath)) {
return 0;
}
$tables = $this->mdbReader->listTables($mdbPath);
if (! in_array('TClienti', $tables, true)) {
return 0;
}
$clientiRows = $this->mdbReader->exportTable($mdbPath, 'TClienti');
$count = 0;
foreach ($clientiRows as $row) {
$legacyId = isset($row['ID']) && is_numeric($row['ID']) ? (int) $row['ID'] : null;
$name = trim((string) ($row['NomeCognome'] ?? ''));
if (! $legacyId && $name === '') {
continue;
}
$phone = trim((string) ($row['NumeroTelefono'] ?? ''));
$phoneAlt = trim((string) ($row['TelFisso'] ?? ''));
$email = trim((string) ($row['Email'] ?? ''));
$cf = trim((string) ($row['CodFis'] ?? ''));
$piva = trim((string) ($row['PIVA'] ?? ''));
// Link to rubrica_universale if found
$rubricaId = $this->findRubricaMatch($amministratoreId, $cf, $piva, $phone, $phoneAlt, $email, $name);
FornitoreCliente::query()->updateOrCreate(
[
'fornitore_id' => $fornitoreId,
'legacy_cliente_id' => $legacyId,
],
[
'amministratore_id' => $amministratoreId,
'rubrica_id' => $rubricaId,
'display_name' => $name ?: ('Cliente TecnoRepair #' . $legacyId),
'phone' => $phone,
'phone_alt' => $phoneAlt,
'email' => $email,
'indirizzo' => trim((string) ($row['Indirizzo'] ?? '')),
'cap' => trim((string) ($row['Cap'] ?? '')),
'citta' => trim((string) ($row['Citta'] ?? '')),
'provincia' => trim((string) ($row['Prov'] ?? '')),
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'note' => trim((string) ($row['Annotazioni'] ?? '')),
'source' => 'tecnorepair_tclienti',
'imported_from_path' => $mdbPath,
'imported_at' => now(),
'metadata' => [
'raw' => $row,
],
]
);
$count++;
}
return $count;
}
/**
* Import contacts from Contabilità MySQL (arc_nehr: cli + ind)
*/
public function syncContabilita(int $amministratoreId, int $fornitoreId): int
{
$conn = DB::connection('contabilita_mysql');
// Check if connection works
try {
$conn->getPdo();
} catch (\Throwable $e) {
Log::info('Contabilità MySQL non disponibile per sync rubrica: ' . $e->getMessage());
return 0;
}
// Read destinations/addresses with phone numbers from `ind`
$indRows = $conn->table('ind')
->where(function ($q) {
$q->where('TELEFONO', '!=', '')
->orWhere('CELLULARE', '!=', '')
->orWhere('e_mail', '!=', '');
})
->get();
$indByCli = [];
foreach ($indRows as $ind) {
$cliCod = trim((string) $ind->CLI_CODICE);
if ($cliCod !== '' && ! isset($indByCli[$cliCod])) {
$indByCli[$cliCod] = $ind;
}
}
// Read all active customers from `cli`
$cliList = $conn->table('cli')
->where('OBSOLETO', '!=', 'si')
->get([
'ID', 'CODICE', 'DESCRIZIONE1', 'DESCRIZIONE2', 'VIA', 'CITTA',
'PARTITA_IVA', 'CODICE_FISCALE', 'NOTE'
]);
$count = 0;
foreach ($cliList as $cli) {
$desc1 = trim((string) $cli->DESCRIZIONE1);
$desc2 = trim((string) $cli->DESCRIZIONE2);
$name = trim($desc1 . ' ' . $desc2);
if ($name === '') {
continue;
}
$codice = trim((string) $cli->CODICE);
$ind = $indByCli[$codice] ?? null;
$phone = trim((string) ($ind?->CELLULARE ?: $ind?->TELEFONO ?: ''));
$phoneAlt = trim((string) ($ind?->TELEFONO_01 ?: $ind?->FAX ?: ''));
$email = trim((string) ($ind?->e_mail ?: ''));
$piva = trim((string) ($cli->PARTITA_IVA ?: $ind?->partita_iva ?: ''));
$cf = trim((string) ($cli->CODICE_FISCALE ?: $ind?->codice_fiscale ?: ''));
// Check if already present from TecnoRepair
$existing = FornitoreCliente::query()
->where('fornitore_id', $fornitoreId)
->where(function ($q) use ($name, $cf, $piva, $codice) {
if ($cf !== '') {
$q->orWhere('codice_fiscale', $cf);
}
if ($piva !== '') {
$q->orWhere('partita_iva', $piva);
}
$q->orWhere('metadata->cli_codice', $codice);
})
->first();
if ($existing) {
// Enrich existing record with contabilità metadata
$meta = is_array($existing->metadata) ? $existing->metadata : [];
$meta['cli_codice'] = $codice;
$meta['contabilita_sync'] = true;
$existing->metadata = $meta;
if ($existing->phone === '' && $phone !== '') {
$existing->phone = $phone;
}
if ($existing->email === '' && $email !== '') {
$existing->email = $email;
}
$existing->save();
$count++;
continue;
}
$rubricaId = $this->findRubricaMatch($amministratoreId, $cf, $piva, $phone, $phoneAlt, $email, $name);
FornitoreCliente::query()->create([
'amministratore_id' => $amministratoreId,
'fornitore_id' => $fornitoreId,
'rubrica_id' => $rubricaId,
'legacy_cliente_id' => null,
'display_name' => $name,
'phone' => $phone,
'phone_alt' => $phoneAlt,
'email' => $email,
'indirizzo' => trim((string) ($cli->VIA ?: $ind?->VIA ?: '')),
'cap' => trim((string) ($ind?->CAP ?: '')),
'citta' => trim((string) ($cli->CITTA ?: $ind?->CITTA ?: '')),
'provincia' => trim((string) ($ind?->provincia ?: '')),
'partita_iva' => $piva,
'codice_fiscale' => $cf,
'note' => trim((string) ($cli->NOTE ?: $ind?->NOTE ?: '')),
'source' => 'contabilita_mysql',
'imported_from_path' => 'arc_nehr.cli',
'imported_at' => now(),
'metadata' => [
'cli_codice' => $codice,
'ind_id' => $ind?->ID ?? null,
],
]);
$count++;
}
return $count;
}
/**
* Sync contacts from NetGescon tickets assigned to supplier
*/
public function syncNetGesconTickets(int $amministratoreId, int $fornitoreId): int
{
$supplierIds = in_array($fornitoreId, [236, 359], true) ? [236, 359, 392] : [$fornitoreId];
$interventi = TicketIntervento::query()
->with(['ticket.stabile', 'ticket.unitaImmobiliare', 'ticket.soggettoRichiedente'])
->whereIn('fornitore_id', $supplierIds)
->latest('created_at')
->limit(200)
->get();
$count = 0;
foreach ($interventi as $intervento) {
$ticket = $intervento->ticket;
if (! $ticket) {
continue;
}
$soggetto = $ticket->soggettoRichiedente;
$name = '';
$phone = '';
$email = '';
$cf = '';
if ($soggetto) {
$name = trim((string) ($soggetto->ragione_sociale ?: trim(($soggetto->nome ?? '') . ' ' . ($soggetto->cognome ?? ''))));
$phone = trim((string) ($soggetto->telefono ?: ''));
$email = trim((string) ($soggetto->email ?: ''));
$cf = trim((string) ($soggetto->codice_fiscale ?: ''));
}
if ($name === '' && $ticket->descrizione) {
// Extract from description lines
if (preg_match('/(?:Chiamante selezionato|Contatto associato):\s*(.+)/i', $ticket->descrizione, $m)) {
$name = trim($m[1]);
}
if (preg_match('/(?:Telefono|Telefono richiamabile):\s*([0-9+\s().\/-]+)/i', $ticket->descrizione, $m)) {
$phone = trim($m[1]);
}
}
if ($name === '' && $phone === '') {
continue;
}
$name = $name ?: ('Richiedente Ticket #' . $ticket->id);
// Check if already present
$existing = FornitoreCliente::query()
->where('fornitore_id', $fornitoreId)
->where(function ($q) use ($name, $phone, $cf) {
if ($cf !== '') {
$q->where('codice_fiscale', $cf);
} elseif ($phone !== '') {
$norm = PhoneNumber::normalizeForMatch($phone);
$q->whereRaw("REGEXP_REPLACE(COALESCE(phone, ''), '[^0-9]', '') = ?", [$norm]);
} else {
$q->where('display_name', $name);
}
})
->first();
if ($existing) {
continue;
}
$rubricaId = $this->findRubricaMatch($amministratoreId, $cf, null, $phone, null, $email, $name);
FornitoreCliente::query()->create([
'amministratore_id' => $amministratoreId,
'fornitore_id' => $fornitoreId,
'rubrica_id' => $rubricaId,
'legacy_cliente_id' => null,
'display_name' => $name,
'phone' => $phone,
'phone_alt' => '',
'email' => $email,
'indirizzo' => (string) ($ticket->unitaImmobiliare?->scala_piano_interno ?? ''),
'cap' => '',
'citta' => (string) ($ticket->stabile?->citta ?? ''),
'provincia' => (string) ($ticket->stabile?->provincia ?? ''),
'partita_iva' => '',
'codice_fiscale' => $cf,
'note' => 'Ticket #' . $ticket->id . ': ' . ($ticket->titolo ?: '-'),
'source' => 'netgescon_ticket',
'imported_from_path' => 'tickets.id#' . $ticket->id,
'imported_at' => now(),
'metadata' => [
'ticket_id' => (int) $ticket->id,
'stabile_nome' => (string) ($ticket->stabile?->denominazione ?? ''),
'stabile_codice' => (string) ($ticket->stabile?->cod_stabile ?? ''),
],
]);
$count++;
}
return $count;
}
private function findRubricaMatch(
int $amministratoreId,
?string $cf = null,
?string $piva = null,
?string $phone = null,
?string $phoneAlt = null,
?string $email = null,
?string $name = null
): ?int {
$query = RubricaUniversale::query()->where('amministratore_id', $amministratoreId);
if ($cf !== null && $cf !== '') {
$m = (clone $query)->where('codice_fiscale', strtoupper(trim($cf)))->value('id');
if ($m) return (int) $m;
}
if ($piva !== null && $piva !== '') {
$m = (clone $query)->where('partita_iva', trim($piva))->value('id');
if ($m) return (int) $m;
}
if ($phone !== null && trim($phone) !== '') {
$digits = PhoneNumber::normalizeForMatch($phone);
if ($digits !== '') {
$m = (clone $query)->where(function ($bq) use ($digits) {
$bq->whereRaw("REGEXP_REPLACE(COALESCE(telefono_cellulare, ''), '[^0-9]', '') = ?", [$digits])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_ufficio, ''), '[^0-9]', '') = ?", [$digits])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_casa, ''), '[^0-9]', '') = ?", [$digits]);
})->value('id');
if ($m) return (int) $m;
}
}
if ($phoneAlt !== null && trim($phoneAlt) !== '') {
$digits = PhoneNumber::normalizeForMatch($phoneAlt);
if ($digits !== '') {
$m = (clone $query)->where(function ($bq) use ($digits) {
$bq->whereRaw("REGEXP_REPLACE(COALESCE(telefono_cellulare, ''), '[^0-9]', '') = ?", [$digits])
->orWhereRaw("REGEXP_REPLACE(COALESCE(telefono_ufficio, ''), '[^0-9]', '') = ?", [$digits]);
})->value('id');
if ($m) return (int) $m;
}
}
if ($email !== null && trim($email) !== '') {
$m = (clone $query)->whereRaw('LOWER(email) = ?', [mb_strtolower(trim($email))])->value('id');
if ($m) return (int) $m;
}
if ($name !== null && trim($name) !== '') {
$m = (clone $query)->where(function ($nq) use ($name) {
$nq->whereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) = ?", [mb_strtolower(trim($name))])
->orWhereRaw("LOWER(TRIM(COALESCE(ragione_sociale, ''))) = ?", [mb_strtolower(trim($name))]);
})->value('id');
if ($m) return (int) $m;
}
return null;
}
}

View File

@ -53,23 +53,26 @@ public function resolveMdbPath(?string $candidatePath = null, ?Fornitore $fornit
return realpath($path) ?: $path;
}
// Candidates for fallback
// Candidates for fallback: live mounted share always takes priority
$fallbackCandidates = [
'/mnt/cservergo/LunaSoftware_TecnoRepair/Archivi/TecnoRepairDB.mdb',
];
if ($fornitore instanceof Fornitore) {
$config = (array) ($fornitore->operational_config ?? []);
$customPath = trim((string) data_get($config, 'tecnorepair.mdb_path', ''));
if ($customPath !== '' && ! str_starts_with($customPath, '\\\\') && file_exists($customPath)) {
$fallbackCandidates[] = $customPath;
}
}
$fallbackCandidates = array_merge($fallbackCandidates, [
storage_path('app/tecnorepair/TecnoRepairDB.mdb'),
self::DEFAULT_LOCAL_PATH,
'/home/michele/MIki/netgescon-day0-backup/Miki-Bug-workspace/screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb',
'/mnt/gescon-archives/TecnoRepairDB.mdb',
storage_path('app/private/tecnorepair/TecnoRepairDB.mdb'),
];
if ($fornitore instanceof Fornitore) {
$config = (array) ($fornitore->operational_config ?? []);
$customFallback = trim((string) data_get($config, 'tecnorepair.local_fallback_path', ''));
if ($customFallback !== '') {
array_unshift($fallbackCandidates, $customFallback);
}
}
]);
foreach ($fallbackCandidates as $cand) {
if (file_exists($cand) && is_file($cand) && is_readable($cand)) {

View File

@ -77,6 +77,7 @@
NetgesconPreupdateBackupCommand::class,
NetgesconRestoreRecordCommand::class,
TecnoRepairImportLegacyArchiveCommand::class,
\App\Console\Commands\TecnoRepairImportRubricaClientiCommand::class,
PanasonicCstaBridgeCommand::class,
GoogleSyncRubricaContactsCommand::class,
GooglePushRubricaContactsCommand::class,

View File

@ -45,6 +45,12 @@ class="inline-flex items-center gap-1 rounded-lg bg-sky-700 px-3 py-1.5 text-xs
>
💻 Pratiche TecnoRepair
</a>
<a
href="/admin-filament/fornitore/rubrica-clienti?fornitore={{ $this->fornitoreId }}"
class="inline-flex items-center gap-1 rounded-lg bg-emerald-700 px-3 py-1.5 text-xs font-bold text-white shadow-xs hover:bg-emerald-600 transition"
>
📞 Rubrica Clienti
</a>
<a
href="/admin-filament/fornitore/seriali?fornitore={{ $this->fornitoreId }}"
class="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-xs hover:bg-slate-50 transition"

View File

@ -6,23 +6,24 @@
<div class="text-lg font-semibold">Rubrica clienti fornitore</div>
<div class="text-sm text-gray-600">
@if($this->fornitoreLabel)
Elenco clienti del singolo fornitore, con sorgente TecnoRepair `TClienti` e collegamento sicuro alla stessa rubrica fornitore quando esiste una scheda condivisa già agganciata.
Rubrica unificata: contatti aggregati da <strong>TecnoRepair (TClienti)</strong>, <strong>Contabilità MySQL (Target Cross arc_nehr)</strong> e <strong>Ticket NetGescon</strong>.
@else
Seleziona un fornitore per aprire la rubrica clienti.
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-center gap-2">
<button type="button" wire:click="sincronizzaRubrica" wire:loading.attr="disabled" class="inline-flex items-center gap-1 rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-bold text-white hover:bg-emerald-600 shadow-xs">
<span wire:loading.remove wire:target="sincronizzaRubrica"> Sincronizza Rubrica</span>
<span wire:loading wire:target="sincronizzaRubrica">Sincronizzazione in corso...</span>
</button>
<a href="/admin-filament/fornitore/pratiche?fornitore={{ $this->fornitoreId }}" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-1.5 text-xs font-bold text-white hover:bg-sky-600 shadow-xs">💻 Pratiche TecnoRepair</a>
<a href="{{ $this->getLavorazioniUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Lavorazioni</a>
<a href="{{ $this->getProdottiUrl() }}" 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">Prodotti</a>
<a href="/admin-filament/fornitore/tickets?fornitore={{ $this->fornitoreId }}" 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">Ticket Operativi</a>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
Questa vista resta confinata al perimetro del fornitore. 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 senza uscire nel pannello anagrafico admin.
</div>
@if($this->missingAdminContext)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Questa vista richiede un fornitore selezionato.
@ -46,16 +47,28 @@
<div class="mt-4 grid gap-4 xl:grid-cols-[1.4fr,0.6fr]">
<div>
@if($activeTab === 'elenco')
<label class="block text-sm">
<span class="mb-1 block font-medium">Cerca cliente</span>
<input type="text" wire:model.live.debounce.300ms="search" class="w-full rounded-lg border-gray-300" placeholder="Nome, telefono, email, citta" />
</label>
<div class="flex flex-wrap items-center gap-3">
<label class="block flex-1 text-sm min-w-[240px]">
<span class="mb-1 block font-medium text-xs text-slate-600">Cerca cliente (nome, telefono, CF/PIVA, città)</span>
<input type="text" wire:model.live.debounce.300ms="search" class="w-full rounded-lg border-gray-300 text-sm" placeholder="Es. Rossi, 3391234567, RM, Casaregola..." />
</label>
<label class="block text-sm min-w-[180px]">
<span class="mb-1 block font-medium text-xs text-slate-600">Filtra per Sorgente</span>
<select wire:model.live="filterSource" class="w-full rounded-lg border-gray-300 text-sm">
<option value="all">Tutte le sorgenti</option>
<option value="tecnorepair">TecnoRepair (TClienti)</option>
<option value="contabilita">Contabilità (Target Cross)</option>
<option value="tickets">Ticket NetGescon</option>
</select>
</label>
</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">Cliente</th>
<th class="border px-2 py-2 text-left">Sorgente</th>
<th class="border px-2 py-2 text-left">Recapiti</th>
<th class="border px-2 py-2 text-left">Zona</th>
<th class="border px-2 py-2 text-left">Schede</th>
@ -68,7 +81,20 @@
<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>
@if(!empty($row['codice_fiscale']) || !empty($row['partita_iva']))
<div class="text-[10px] text-slate-500 font-mono">{{ $row['codice_fiscale'] ?: $row['partita_iva'] }}</div>
@endif
</td>
<td class="border px-2 py-2 align-top">
@if(($row['source'] ?? '') === 'tecnorepair_tclienti')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 text-sky-800">TecnoRepair</span>
@elseif(($row['source'] ?? '') === 'contabilita_mysql')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-purple-100 text-purple-800">Contabilità</span>
@elseif(($row['source'] ?? '') === 'netgescon_ticket')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-100 text-emerald-800">Ticket</span>
@else
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-slate-100 text-slate-700">{{ $row['source'] }}</span>
@endif
</td>
<td class="border px-2 py-2 align-top">
<div>{{ $row['phone'] !== '' ? $row['phone'] : '-' }}</div>
@ -97,7 +123,7 @@
</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>
<td colspan="7" class="border px-2 py-6 text-center text-sm text-slate-500">Nessun contatto trovato. Clicca su "⚡ Sincronizza Rubrica" per importare da TecnoRepair, Contabilità e Ticket.</td>
</tr>
@endforelse
</tbody>

View File

@ -25,6 +25,7 @@
@endif
<a href="/admin-filament/fornitore/pratiche?fornitore={{ $this->fornitoreId }}" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-1.5 text-xs font-bold text-white hover:bg-sky-600 shadow-xs">💻 Pratiche TecnoRepair</a>
<a href="{{ \App\Filament\Pages\Fornitore\LavorazioniOperative::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament') }}" 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">Lavorazioni</a>
<a href="/admin-filament/fornitore/rubrica-clienti?fornitore={{ $this->fornitoreId }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">📞 Rubrica Clienti</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Collaboratori</a>
<a href="{{ $this->getImpostazioniUrl() }}" 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">Impostazioni</a>
<a href="{{ $this->getContabilitaUrl() }}" 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">Contabilita</a>

View File

@ -1,55 +1,64 @@
# CURRENT-205
TASK_ID: task-lavorazioni-tecnorepair-tab-sync
TASK_ID: task-unificazione-fornitore-rubrica-centralino
MACHINE: .205
STATO: completato
## Obiettivo Completato
1. **Risoluzione Errore HTTP 500 Blade Lexer (`ParseError: unexpected token '=' / 'endforeach'`)**:
- Diagnosi: in `resources/views/filament/pages/affitti/gestione-affitti.blade.php`, un blocco `@php ... @endphp` multiriga dentro un `@forelse` entrava in conflitto con la regex interna di Blade `/(?<!@)@php(.*?)@endphp/s`. La regex inglobava 19.000 caratteri da una precedente direttiva `@php(...)`, lasciando orfano `@empty` e mandando il contatore `forElseCounter` a `-1`, scatenando `$__empty_-1 = true;` in compilazioni successive.
- Soluzione: convertite le righe a direttive inline `@php($d = ...)` e `@php($p = ...)`.
- Verifica: `php artisan view:clear && php artisan view:cache` -> **Blade templates cached successfully con zero errori**.
1. **Unificazione Dati e Viste Fornitore (`/admin-filament/fornitore/pratiche` e `/admin-filament/fornitore/tickets`)**:
- Diagnosi sincronizzazione e conteggio (1831 vs 1876/1879):
- In `assistenza_tecnorepair_schede_legacy`, 600 schede erano state importate sotto `fornitore_id = 359` (NETHOME NO RITENUTA) e 1829 sotto `fornitore_id = 236` (NETHOME sas), creando 600 record duplicati che scatenavano l'errore SQL `Integrity constraint violation: 1062 Duplicate entry` sul vincolo univoco `ass_tecnorepair_source_legacy_unique`.
- In `TecnoRepairArchiveService.php`, il metodo `resolveMdbPath()` metteva il fallback locale stale (`Miki-Bug-workspace/.../TecnoRepairDB.mdb` fermo a 1834) davanti al montaggio CIFS live.
- Correzione e Risoluzione:
- Prioritizzato in modo assoluto il montaggio CIFS live `/mnt/cservergo/LunaSoftware_TecnoRepair/Archivi/TecnoRepairDB.mdb`.
- Risolti ed eliminati i 600 duplicati, consolidando tutti i 1874 record sotto il fornitore primario `236` (gestendo resilientemente `whereIn('fornitore_id', [236, 359, 392])`).
- Eseguita sincronizzazione live: **1874 schede lette, create/aggiornate con successo, max legacy_id 1879 (inclusa la #1876)**.
- Aggiornata la pagina `PraticheTecnorepair` e `TicketOperativi`: ora mostrano esattamente lo stesso dataset consolidato (1874 schede) con le stesse KPI, pulsanti e collegamenti rapidi.
2. **Diagnosi e Risoluzione Discrepanza Schede TecnoRepair (1831 vs 1876)**:
- Diagnosi: il file locale di staging `screenshot/Assistenza gestionale/Archivi/TecnoRepairDB.mdb` era uno snapshot datato 1 aprile 2026 fermo all'ID 1834.
- Connessione al server di rete: montata la share live Windows `//192.168.0.36/CServerGO` in `/mnt/cservergo` con credenziali dedicate e persistita in `/etc/fstab`.
- Il database live contiene le schede aggiornate ad oggi (fino a ID legacy 1879, inclusa la #1876).
- Aggiornato `TecnoRepairArchiveService.php` per dare precedenza al percorso live `/mnt/cservergo/LunaSoftware_TecnoRepair/Archivi/TecnoRepairDB.mdb`.
- Eseguito import: `php artisan tecnorepair:import-legacy 13` -> **1874 schede importate (max legacy_id 1879, inclusa scheda 1876)**.
2. **Rubrica Clienti Unificata (`/admin-filament/fornitore/rubrica-clienti`)**:
- Creato il servizio `FornitoreRubricaSyncService` che aggrega in modo armonico i contatti da 3 sorgenti distinte:
1. **TecnoRepair (TClienti)** dal database MDB live `/mnt/cservergo`: **1.243 contatti**.
2. **Contabilità MySQL Target Cross (`arc_nehr` su 192.168.0.36:3307)** da `cli` e `ind`: **2.473 contatti**.
3. **NetGescon Amministratore**: contatti dai ticket condominiali assegnati al fornitore.
- Risultato: **3.716 contatti unificati** in `fornitore_clienti` con matching automatico su CF, PIVA, telefono ed email verso `rubrica_universale`.
- Aggiornata la UI di `RubricaClienti`:
- Pulsante "⚡ Sincronizza Rubrica" in testata.
- Filtro reattivo per sorgente (`Tutte le sorgenti`, `TecnoRepair`, `Contabilità`, `Ticket NetGescon`).
- Badge visivi semantici di provenienza e ricerca in tempo reale.
- Collegamenti bidirezionali con `LavorazioniOperative`, `PraticheTecnorepair` e `TicketOperativi`.
3. **Chiarimento Architettura di Sincronizzazione (Sviluppo .205, Gitea e Produzione Cloud)**:
- Sviluppo `.205`: Codice locale sul branch `stabilization/205-zero`, database locale MariaDB `netgescon` e montaggi CIFS diretti verso gli archivi MDB (`/mnt/cservergo` e `/mnt/gescon-archives`).
- Repository Gitea (`git.netgescon.it:2222`): Sorgente centrale autoritativa del codice sorgente, rami, tag e migrazioni.
- Produzione (`svr-netgescon` / `192.168.0.157`, `app.netgescon.it`): Container Docker `netgescon_prod_app` con database PostgreSQL `prod_pgsql` (374 migrazioni attive). Riceve gli aggiornamenti del codice e delle migrazioni da Gitea. I dati operativi di produzione (utenti, ticket web, log) risiedono su PostgreSQL.
4. **Nuova UI Lavorazioni Operative (`/admin-filament/fornitore/lavorazioni`) & Scheda in TAB (Non Modal)**:
- Aggiornata la pagina secondo il design system moderno (KPI cards, badge di stato semantici, filtri reattivi, context toolbar).
- Eliminato il popup modale: il dettaglio della scheda ora vive all'interno di una **TAB in-page a larghezza intera ("Scheda Apparecchio")**, consentendo di alternare liberamente tra Elenco e Scheda senza perdere stato.
- Fedeltà grafica e funzionale a `01 schermata pricipale.PNG` di TecnoRepair:
- Header con Num. Scheda, Date (Ingresso, Orario, Cons. Prevista, Riconsegna), Stato Riparazione e Checkbox/Flags desktop (Fare Preventivo, Riparazione in sede, Riconsegnato, Rientro, Esame Tecnico, Ricons. No Ricevuta).
- Box "Dati Cliente" in alto a destra con pulsanti rapidi WhatsApp, Telefono ed SMS.
- 7 Sub-Tab: `1. Apparecchio in Entrata`, `2. Riparazione & Flussi`, `3. Ricambi Utilizzati`, `4. Preventivo - Costi - DDT`, `5. Annotazioni & Cortesia`, `6. Comunicazioni`, `7. C.Q. (Controllo Qualità)`.
- Matrice grafica 3x3 del Segno di Sblocco (Android pattern grid con 9 nodi interattivi).
- Salvataggio diretto modifiche su database (`saveActiveScheda()`) e pulsante stampa ricevuta.
- Supporto integrato anche per i Ticket Amministratore nello stesso formato Scheda.
3. **Integrazione Centralino Telefonico (Panasonic NS1000 / TAPI) & Wireframe ASCII**:
- Identificato lo script in esecuzione all'avvio di Windows:
- Task Schedulato `NetGescon Panasonic Live Bridge` (creato da `scripts/ops/windows/install-netgescon-panasonic-live-task.cmd` / `.ps1`).
- Script launcher: `start-netgescon-panasonic-live.cmd` / `.ps1` che esegue in loop `watch-netgescon-panasonic-tapi-dotnet-events.ps1`.
- Diagnosi del "furto di focus":
- Lato Windows: console PowerShell interattiva senza `-WindowStyle Hidden` che all'avvio del loop o su restart/errore apre una finestra e ruba il focus.
- Lato NetGescon: `TopbarLiveCall` eseguiva polling ogni 3s e, alla ricezione della chiamata, richiamava modali con direttive `autofocus` che catturavano il cursore.
- Soluzione architetturale push:
- Esecuzione Windows headless come Servizio o Task `SYSTEM` con reindirizzamento solo su file di log.
- Push immediato REST allo squillo (`POST /api/v1/cti/panasonic/incoming`) verso NetGescon.
- Floating toast non-intrusivo senza autofocus in NetGescon, espandibile su richiesta dell'operatore.
- Redatti **4 diagrammi ASCII Wireframe** dettagliati (architettura, floating toast, scheda chiamante Fornitore con riparazioni, scheda chiamante Amministratore con estratto conto spese/incassi/rate scadute e ticket aperti).
## Output del Giro Operativo
ESITO_205: riuscito
TASK_ID: task-lavorazioni-tecnorepair-tab-sync
TASK_ID: task-unificazione-fornitore-rubrica-centralino
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero
COMMIT: 7116305
COMMIT: in_pubblicazione
FILE_O_AREE_TOCCATE:
- .gitignore
- app/Filament/Pages/Condomini/NominativiStabile.php
- app/Filament/Pages/Fornitore/LavorazioniOperative.php
- app/Filament/Pages/Gescon/Ordinarie.php
- app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php
- app/Filament/Pages/Fornitore/PraticheTecnorepair.php
- app/Filament/Pages/Fornitore/RubricaClienti.php
- app/Filament/Pages/Fornitore/TicketOperativi.php
- app/Services/Fornitore/FornitoreRubricaSyncService.php
- app/Services/Tecnorepair/TecnoRepairArchiveService.php
- resources/views/filament/pages/affitti/gestione-affitti.blade.php
- bootstrap/app.php
- resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php
- tests/Feature/FornitoreTecnorepairAndIntegrationsTest.php
- resources/views/filament/pages/fornitore/rubrica-clienti.blade.php
- resources/views/filament/pages/fornitore/ticket-operativi.blade.php
- skill-netgescon/control-tower/CURRENT-205.md
TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/FornitoreTecnorepairAndIntegrationsTest.php (6 passed, 54 assertions)
@ -57,8 +66,9 @@ ## Output del Giro Operativo
- php artisan view:clear && php artisan view:cache (successo, zero errori)
GATE_STATISTICS:
- BLADE_COMPILATION: 100% pulita senza token inattesi.
- TECNOREPAIR_RECORDS: 1874 schede importate dal live MDB (/mnt/cservergo), max legacy_id 1879, inclusa #1876.
- LAVORAZIONI_TAB_UI: Nuova visualizzazione con TAB integrata in-page a 7 schede (no modal) e matrice di sblocco 3x3.
- TECNOREPAIR_RECORDS: 1874 schede unificate e sincronizzate direttamente dal live MDB (/mnt/cservergo), max legacy_id 1879, inclusa #1876.
- RUBRICA_CLIENTI: 3.716 contatti aggregati (1.243 TecnoRepair + 2.473 Contabilità MySQL arc_nehr + Ticket NetGescon).
- WIREFRAMES_CENTRALINO: 4 disegni ASCII completati per architettura no-focus-steal, floating toast e schede contestuali Fornitore/Amministratore.
- TEST_SUITE: 47 test Feature passati (299 asserzioni, 100% pass).
BLOCCO_DATI: no
BLOCCO_CONTRATTO: no
@ -68,4 +78,6 @@ ## Prossimo Passo per .200 (Validazione)
- Eseguire il checkout del branch `stabilization/205-zero`.
- Eseguire la suite di test Pest (47 passed, 299 assertions).
- Aprire http://192.168.0.205:8000/admin-filament/fornitore/lavorazioni e verificare il nuovo layout grafico con KPI, filtri, tabella con badge di stato e apertura della Scheda Apparecchio in formato TAB a tutta pagina con la scheda 1876.
- Verificare su http://192.168.0.205:8000/admin-filament/fornitore/pratiche il conteggio di 1874 schede e il perfetto funzionamento del pulsante "Sincronizza MDB TecnoRepair".
- Verificare su http://192.168.0.205:8000/admin-filament/fornitore/rubrica-clienti i 3.716 contatti con badge sorgente e filtri.
- Valutare i wireframe ASCII per l'integrazione del centralino prima di avviare l'implementazione del listener/popup.