# 🔄 REFACTORING LOG — Gestionale Ordini Benza

Changelog. Il **refactoring è concluso** (gestionale in produzione su Aruba dall'11/07/2026).
Da qui in poi si registrano solo **fix bug / modifiche** della fase di manutenzione.

📦 **Storia completa del refactor (2024→11/07/2026):** `docs/archive/REFACTORING_LOG_completo.md`

## File correlati
- `PROJECT_INFO.md` — stack, brand, struttura, helper PHP/JS, pattern API
- `DOCUMENTAZIONE_API_JWT.md` — sistema API e JWT
- `SECURITY_AUDIT.md` — audit sicurezza + remediation
- `db_connection.md` — credenziali DB consultazione (SOLO LETTURA)
- `sql/` — migration SQL numerate (eseguite manualmente dall'utente)
- `archive/` — doc storici del refactor (analisi iniziale, preventivo, template CMS, log completo)

---

## MANUTENZIONE (post go-live)

### FEATURE (extra) — "Da installare" con data di installazione (15/10/2026)
Richiesta di Benza: segnare gli ordini con un'installazione da fare e ritrovarli in una scheda dedicata. Il vecchio gestionale non aveva niente di simile: è un lavoro extra (il primo dopo il saldo di ottobre).
- **DB — migration `044_or_ordini_da_installare.sql`:** `or_ordini.da_installare` (TINYINT, default 0) e `data_installazione` (DATE, NULL). Default per non toccare sito_26 e prev_vasche, che creano ordini senza conoscerle. **Va eseguita prima del deploy:** `Ordine::update()` scrive tutte le colonne insieme.
- **Tutto manuale**, a differenza di "Da spedire": nessuno stato tocca questi campi.
- **Scheda ordine:** casella "Da installare" sotto "Da spedire" + campo data. Si salvano da soli come gli altri flag (`salvaFlagOrdine`, nuova `salvaDataInstallazione`). La data compare con la spunta ("Prevista il"); togliendo la spunta **resta salvata e visibile** come data effettiva ("Installato il"). Il campo data manda `change` anche a metà digitazione dell'anno: attesa di 600 ms e anni < 2000 scartati.
- **Lista Ordini:** scheda "Da installare" col suo contatore (`conteggi.php`), indipendente dallo stato come "Da spedire". La colonna Data diventa "Installazione": vuota se non indicata, **grassetto se oggi, rossa se passata**. Ordinata per scadenza: prima le più vicine, in fondo quelle senza data (`Ordine::read()`, `$orderBy`).
- **API:** `read.php`/`read_one.php` restituiscono i due campi; `update.php` li accetta (data validata `Y-m-d`, vuota → NULL) e li scrive nel Log Attività.

### FIX — Fatturazione: di nuovo possibile registrare importi negativi (note di credito) (12/10/2026)
Benza non riusciva a registrare le note di credito: il campo Importo rifiutava i valori sotto zero.
- **Regressione nostra:** il vecchio gestionale (`fatturazione.php`) non aveva nessun minimo, e in produzione ci sono già **58 fatture negative** (la più grossa -101.500 €). Nel rifacimento i due campi avevano preso `min="0"`.
- **L'unico blocco era quello:** `or_fatturazioni.importo` è `decimal(10,2)` con segno, l'API usa `floatval()` senza controlli sul segno, e chi somma le fatture (totali, dashboard, cassa) le tratta già come sottrazioni, visto che i 58 negativi esistono da prima.
- **Fix:** tolto `min="0"` dal campo della nuova fattura e da quello della modifica (`fatturazione.php`).
- **Test sul NAS** (ordine 12842): fattura "TEST-NC" da -250,50 inviata dal form, salvata e mostrata con -305,61 € con IVA; poi eliminata.

### FIX — Ricerca Avanzata, campo "Luogo": con più parole non trovava niente (25/09/2026)
Segnalazione: "il campo Luogo sembra cercare solo nell'indirizzo di fatturazione o solo in quello di spedizione".
- **Verificato che non è così:** la condizione copriva già i dieci campi di `or_clienti` (via, cap, comune, prov, stato e le varianti `s_*`). In produzione, cliente 7874 (fatturazione Poggiomarino 80040, spedizione Avellino 83044): tutte e quattro le ricerche singole restituiscono tutti i suoi 12 ordini. Codice di produzione confrontato via FTP con il repo: identico.
- **Il bug vero, stesso sintomo:** la frase intera veniva cercata dentro **un solo** campo. "Avellino 83044", "Avellino (AV)", "Poggiomarino, 80040" davano **0 risultati**, perché città, CAP e provincia stanno in colonne diverse. Chi scriveva due informazioni insieme vedeva sparire ordini che c'erano.
- **Fix** (`api/objects/ordine.php`): nuovo `filtroLuogo()`, usato sia da `read()` sia da `count()` (prima erano due copie identiche del blocco). Il testo si spezza in parole (spazi, virgole, punti e virgola, parentesi) e **ogni parola deve comparire in almeno uno dei dieci campi**. Una parola sola dà lo stesso risultato di prima. Tetto a 8 parole (ciascuna sono 10 LIKE).
- **Test sul NAS** (cliente 5928, Bologna 40122 / Novellara 42023): `Bologna 40122`, `Novellara 42023`, `Novellara, 42023` passano da 0 a 36 ordini; `Novellara (RE)` 37; `Bologna Novellara` 36 (fatturazione e spedizione mescolate: stesso cliente); `Bologna Milano` resta 0. Parole singole invariate (98 e 38).

### FIX — Il pagamento nasceva con la data dello stato creato prima (24/09/2026)
Benza ha mostrato il problema di persona a Paolo, che però non riusciva a rifarlo: l'ordine **#12746**, pagamento registrato la mattina del 24/09, compariva con la data del "Ricevuto ordine" del 19/09. **Non era il telefono di Davide: il dato sbagliato arrivava davvero al server.**
- **Causa:** il pannello "Avanzate" ha **un solo** campo data (`edit_data_ora`) per tutta la pagina, e resta valorizzato dopo il salvataggio. Lo azzera `setupAvanzateCreazione()`, che però la chiamano solo le modal che usano Avanzate in creazione (Generico, Preventivo, Ricevuto, Spedizione, Problema, Chiudi): **non il Pagamento**, che ha un campo data suo. In `salvaStato()` la data di Avanzate veniva presa ogni volta che il formData non ne portava già una.
- **Perché è emerso solo adesso:** dal fix del 13/08/2026 la modal Pagamento manda la sua data **solo se l'operatore la tocca** (altrimenti lascia al backend l'ora coi secondi). Data non toccata → niente `data_ora` nel formData → `salvaStato` ripescava il valore rimasto in `edit_data_ora`. Prima il Pagamento la mandava sempre e il guard `!formData.has('data_ora')` bastava.
- **La sequenza, dal Log Attività:** 07:09:04 Davide crea il "Ricevuto ordine" retrodatandolo da Avanzate al 19/09 07:08 → 07:18:50 crea il Pagamento senza toccarne la data, che nasce con **19/09 07:08:00** (il primo log di modifica lo dice: `data_ora {da: "2026-09-19 07:08:00"}`) → 07:22-07:24, sette modifiche per rimetterla a posto. Paolo non lo riproduceva perché registrava il pagamento senza aver prima creato uno stato con data personalizzata.
- **Fix** (`salvaStato`, `ordine.js`): la data di Avanzate si usa solo se quel pannello appartiene alla creazione in corso — `edit_cod_stato` vuoto **e** `edit_cod_categoria` uguale alla `cod_categoria` del formData. Copre anche il caso gemello (creare un pagamento subito dopo averne modificato un altro, dove la sola categoria coinciderebbe).
- **Verificato sul NAS** rifacendo la sequenza: generico retrodatato al 19/09 07:08, poi pagamento senza toccare la data → salvato `2026-09-24 09:14:10`, ora vera coi secondi. Stati di prova eliminati dall'interfaccia (mai DELETE SQL: i totali si ricalcolano solo dal percorso API).
- **Dato in produzione:** il pagamento #71448 è stato riportato all'ora reale di creazione (07:18:50) dall'interfaccia, usando il campo secondi di Avanzate.

### FEATURE — Allegati di stato col nome originale, a schermo e nel download (14/09/2026)
Benza: nell'elenco allegati sotto ogni stato il nome era lunghissimo (`ord-13011-stato-71276-2-VenditaaldettaglioVEN-4195-2f479fad.pdf`) e scaricando un file per girarlo a un cliente andava rinominato ogni volta.
- **Due cause.** (1) `getFileName()` in `ordine.js` toglieva il prefisso solo nel formato del vecchio gestionale (`ord-N-stato-N_N_nome`, underscore): il formato di `uploadNomeSicuro()` usa i trattini e ha il suffisso casuale, quindi non prendeva e mostrava tutto. (2) Il nome originale **non veniva salvato**: `or_allegati` aveva solo `file`, e la sanificazione toglie spazi e punti ("Vendita al dettaglio VEN-4195.pdf" → `VenditaaldettaglioVEN-4195`).
- **Migration 043** (`or_allegati.nome_originale` VARCHAR 255, eseguita da Paolo su NAS e Aruba il 14/09): salvato nei tre upload (`stati.php` POST e PUT, `email/save.php`) e portato dietro nella **copia stato**. Il nome su disco resta quello di sempre.
- **Helper condivisi** in `api/config/allegati_nomi.php` (incluso da `funzioni_api.php`): `nomeOriginaleAllegato()` (basename, via caratteri di controllo e virgolette, 255) e `nomeAllegatoVisibile($file, $nome_originale)` (l'originale se c'è; altrimenti il nome su disco senza codici e suffisso, in entrambi i formati). File a parte perché `funzioni_api.php` ridefinisce `my_encrypt`/`my_decrypt` di `includes/helpers.php` e una pagina non può includerlo.
- **Il backend restituisce già `nome`** per ogni allegato (`stati.php` GET): il JS lo usa nelle tre viste (timeline, modal "Allegati ordine", modal di modifica) con `escapeHtml` — il nome originale può contenere di tutto. `getFileName()` aggiornata al formato nuovo come ripiego.
- **`allegato.php`** (nuovo, root, sessione come le pagine): serve il file con `Content-Disposition` col nome pulito (`filename` ASCII + `filename*` UTF-8). PDF, immagini, video e audio **inline** (si aprono nella scheda come prima, e salvando dal viewer il nome proposto è quello pulito); tutto il resto `attachment`. **MIME da mappa fissa per estensione, mai da `finfo`** (segnalazione della revisione automatica: un `.jpg` che contiene HTML sarebbe stato servito come `text/html`), più `nosniff` e `CSP: sandbox`. Sessione chiusa prima del `readfile`. `uploads/allegati/` resta raggiungibile com'era.
- **Zip** (`allegati_zip.php`): nomi puliti; due omonimi diventano `Nome.pdf` e `Nome (2).pdf`.
- **Test sul NAS** (stato di prova poi eliminato dall'interfaccia): upload di due "Fattura n. 12 società (copia).pdf" → a schermo il nome originale in tutte e tre le viste, su disco `ord-12739-stato-70288-1-Fatturan12societcopia-72f2afcf.pdf`, header `inline; filename*=UTF-8''Fattura%20n.%2012%20societ%C3%A0%20(copia).pdf`, PDF aperto nel viewer con CSP sandbox, zip con le due entry deduplicate. Senza sessione `allegato.php` rimanda a `login.php?vai=…`.
- **Per i file già caricati** non c'è modo di recuperare il nome originale: si vedono e si scaricano col nome su disco senza i codici (`VenditaaldettaglioVEN-4195.pdf`).

### FIX — Email con accenti: "societ?" a schermo e testo troncato nello stato (10/09/2026)
Segnalato da Benza su una mail di Gianluca Scaglione (F.lli Di Martino SpA): a schermo si leggeva "per la nostra societ**?**", e salvandola in uno stato dell'ordine **il testo si fermava lì**. Lo stato #71213 (ordine 13000, salvato da Paride alle 16:24) era lungo 159 caratteri e finiva esatto su "per la nostra societ".
- **Causa:** `ImapReader::decodeBody()` scioglieva solo il transfer-encoding (base64 / quoted-printable) ma **non convertiva il charset** della parte MIME. Una mail in ISO-8859-1 o Windows-1252 arrivava coi byte accentati grezzi (`0xE0` per `à`). Gli header (oggetto, mittente) la conversione ce l'avevano già in `decodeMime`, il corpo no.
- **Perché due sintomi diversi:** a schermo il corpo passa da `json_encode(..., JSON_INVALID_UTF8_SUBSTITUTE)` in `api/email/imap.php`, che rimpiazza i byte non validi con `?`. Al salvataggio invece `api/email/save.php` prende `$email['body']` **direttamente dal reader**, senza passare da JSON, e lo scrive in `or_stati.descrizione` su una connessione `utf8mb4`: MySQL taglia la stringa al primo byte non valido, in silenzio.
- **Fix:** nuovo `testoInUtf8($testo, $part)` in `includes/ImapReader.php`, applicato in `extractBody()` a **entrambi** i rami (mail semplice e multipart). Legge il charset dai `parameters` della struttura MIME; se manca o è dichiarato a sproposito ripiega su Latin-1, dove ogni byte è valido, così l'uscita è sempre UTF-8 buono. Un charset sconosciuto fa lanciare `ValueError` a `mb_convert_encoding` su PHP 8 (l'`@` non basta): è dentro un try/catch. **Non** tocca `downloadAttachment()`, che è binario.
- Verificato su 6 casi (latin1 dichiarato, latin1 senza charset, windows-1252 con € e virgolette curve, UTF-8 già buono, charset inventato, charset dichiarato UTF-8 ma in realtà latin1): tutti convertiti giusti. In produzione la mail ora mostra "società F.lli Di Martino SpA" e "fattibilità". Sul NAS non è testabile: manca php-imap.
- **Gli stati già troncati restano troncati**, il testo perduto non è nel DB: vanno risalvati dalla mail originale e cancellato il vecchio.

### FIX — Via l'icona verde dalla checkbox "Problema già risolto" (10/09/2026)
Nella modal "Problema post-vendita" la label della checkbox aveva davanti un `<i class="fas fa-check-circle text-success">`, che finiva incastrato fra la casella e il testo. Rimosso, resta la sola scritta (`ordine.php`).

### FIX — Importi da incassare, grassetto sì e grassetto no (07/09/2026)
Nella lista Ordini due righe con la stessa identica frase ("In attesa pagamento di X €") mostravano l'importo una in grassetto e una no. Non era casuale: `getStatoHtml()` arriva a quella frase da **due strade diverse** e solo una aveva `fw-bold`. Il ramo **cat 5** (ultimo stato "Ricevuto ordine") scriveva `stato-rosso`, il ramo di ripiego per gli ordini senza stati scriveva `stato-rosso fw-bold`. In pagina si contavano 6 importi in grassetto e 5 no.
- Uniformato al **grassetto**, che era già la scelta di tutti gli altri rami ("Da pagare", "Da saldare ancora", "Ordine spedito, da saldare"): l'importo ancora da incassare è il numero che si cerca a colpo d'occhio.
- Corretto anche il ramo **cat 7 con spedizione inviata** ("Ordine spedito, da saldare X"), che aveva la stessa incoerenza, e le due gemelle in **`archivio.js`**: la funzione è duplicata nelle due pagine, toccarne una sola avrebbe spostato il problema invece di chiuderlo.
- Verificato in pagina: gli ordini #11172 e #12850 ora hanno lo stesso stile, e degli 11 stati rossi visibili 10 sono in grassetto. L'unico rimasto senza è "Problema da risolvere!", che non è un importo.
- **Lasciato com'è** il ramo cat 7 senza spedizione ("Ordine da spedire e da saldare X"): lì è la frase intera a essere rossa, non l'importo isolato, e metterla in grassetto cambierebbe il peso della riga. Se si vuole uniformare anche quella, è una scelta grafica da fare a parte.

### FIX — Il login richiesto ogni volta aprendo un link da WhatsApp o dalla posta (07/09/2026)
Benza se ne lamentava da tempo: aprendo un link a una pagina del gestionale da WhatsApp o da un'email, il browser chiedeva l'accesso **ogni volta**, per quanto spuntasse "Ricordami". Non è il telefono e non è un'impostazione sua: era il nostro cookie.
- **Causa:** il cookie di sessione `gest_adm_login_string` era `SameSite=Strict`. Con Strict il browser **non manda il cookie** quando la navigazione arriva da un altro sito, ed è esattamente il caso del click su un link in WhatsApp o nella posta. Il cookie c'era ed era valido, semplicemente non veniva inviato: il gestionale vedeva una richiesta senza sessione e mostrava il login. Da lì in poi la navigazione è interna, quindi tutto funzionava e il problema sembrava capriccioso.
- **Fix:** `SameSite=Lax` (`login-db.php`). Lax manda il cookie sulle navigazioni normali di primo livello (il click su un link), ma continua a **non** mandarlo sulle richieste cross-site che scrivono. La protezione CSRF resta perché tutte le nostre scritture passano da POST/PUT/DELETE con corpo JSON o FormData, mai da una GET.
- **`Ricordami` non c'entrava** ed è rimasto com'era: decide solo se il cookie dura 30 giorni o fino alla chiusura del browser.

### FEATURE — Dopo l'accesso si torna alla pagina che si stava aprendo (07/09/2026)
Prima si finiva sempre sulla lista ordini, anche arrivando da un link a un ordine preciso: bisognava ricercarselo a mano.
- `includes/auth.php` allega la pagina richiesta al dirottamento verso il login (`login.php?vai=<REQUEST_URI>`), **solo per le GET**: una POST non ha senso ripeterla dopo l'accesso.
- `login.php` ne tiene **solo il nome del file e la query**, e accetta la destinazione unicamente se è un `.php` esistente in quella cartella e non è il login stesso. Così nessun link costruito ad arte può spedire l'utente su un altro sito subito dopo l'accesso (open redirect), e un link vecchio a una pagina rimossa non lo lascia su un 404: si ripiega sulla lista ordini.
- Verificato sul NAS senza credenziali, via `curl`: pagina protetta → `login.php?vai=…`; con destinazione valida → `ordine.php?cod=12842`; `https://sito-esterno` → `ordini.php`; `//sito-esterno/x.php` → `ordini.php`; `login.php` (ciclo) → `ordini.php`; nessun parametro → `ordini.php`.

### FIX — La home non scorreva su Chrome Android: html e body erano due scroller (07/09/2026)
**Causa trovata**, con le misure prese dal telefono di Benza (pannello `?diag=scroll26`, poi rimosso). Nel media query mobile c'era `html, body { overflow-x: hidden; max-width: 100vw }`. Il CSS non ammette un asse `hidden` e l'altro `visible`: `overflow-y` diventa **`auto` su tutti e due**, quindi html e body si ritrovano a essere **due contenitori di scroll annidati**. Chrome Android aggancia il gesto verticale al **body**, che è alto quanto tutto il contenuto e non ha niente da scorrere, e la pagina resta immobile. Firefox e Safari passano invece il gesto al documento: da qui il "solo su Chrome".
- **La prova nei dati del suo telefono:** `HTML ox=hidden oy=auto` con **713px** da scorrere, `BODY ox=hidden oy=auto` con `clientHeight === scrollHeight` (niente da scorrere), `scrollingElement: HTML`, e soprattutto **`scrollTo(0,300)` funzionava** (si muoveva da 656 a 401) mentre il dito non otteneva nulla: la pagina era scrollabile, era il gesto a finire nel posto sbagliato. Coerente anche col gesto che Benza aveva scoperto per caso (trascinare prima in giù e poi in su): invertendo la direzione Chrome riassegna il gesto.
- **Fix:** tolto `overflow-x: hidden` e `max-width: 100vw` da `html, body`. La protezione contro lo sforamento orizzontale resta su `#page-content-wrapper`, che ce l'aveva già, e non serviva comunque: il pannello riportava `scrollW === clientW`, **la pagina non sforava di un pixel**.
- **Perché solo la pagina Ordini:** la regola valeva ovunque, ma è lì che il body fa da contenitore diretto del gesto su quasi tutto lo schermo. Altrove il dito cade su elementi che lasciano passare lo scroll al documento.
- `overscroll-behavior: none` su html/body **resta**: non crea scroller, disabilita solo il pull-to-refresh, ed è una scelta voluta.
- **Secondo giro, stessa causa un livello più in basso.** Con html/body sistemati la pagina ancora non si muoveva, e la traccia del gesto ha mostrato la catena sotto il dito: `TD.cell-cliente` (overflow hidden per l'ellissi) → `.table-responsive` → **`#page-content-wrapper` con `oy=auto` e `dY=0`**, cioè un contenitore di scroll che copre tutta la pagina e non ha niente da scorrere. Sempre l'effetto di `overflow-x: hidden` che promuove l'asse verticale ad `auto`.
- **`overflow: clip` invece di `hidden`** su `#page-content-wrapper` e sulle celle troncate della tabella: taglia allo stesso modo, mantiene `text-overflow: ellipsis`, ma **non crea un contenitore di scroll**. Misurato in Chrome: `hidden` → `oy=auto`, `clip` → `oy=visible`. La riga `overflow: hidden` resta prima di `clip` come ripiego per i browser che non lo conoscono (Safari < 16).
- **Regola generale per il progetto:** `overflow: hidden` usato solo per *tagliare* va sostituito con `clip`, altrimenti si semina la pagina di scroller invisibili che su Chrome Android si mangiano il gesto.

### FIX — La home non scorreva su Chrome Android: la tabella si prendeva il gesto (07/09/2026)
Video di Benza e dettagli decisivi: **solo la pagina Ordini**, **solo Chrome su smartphone** (altri browser sullo stesso telefono a posto, altre pagine del gestionale a posto), il touch funziona (i tocchi rispondono) e la tabella si scorre in orizzontale. Niente a che vedere con la rete di sicurezza sui modal messa poco prima.
- **Causa:** Bootstrap dà a `.table-responsive` `overflow-x: auto`, e il CSS promuove da sé `overflow-y` da `visible` ad `auto` (misurato: `overflowY: auto` su tutti quei contenitori). Il contenitore è quindi uno scroller **anche verticale**. Nella pagina Ordini la tabella occupa quasi tutto lo schermo, quindi il dito ci finisce sempre sopra: Chrome aggancia il gesto a quel contenitore, che in verticale non ha niente da scorrere (o un pixel per arrotondamento), e la pagina resta ferma. Gli altri browser passano il gesto alla pagina.
- **Perché solo lì:** è la tabella più larga, l'unica che sfora davvero lo schermo — infatti Benza la scorre in orizzontale. Dove il contenitore non è scrollabile Chrome non lo aggancia e la pagina scorre normalmente.
- **Fix:** `.table-responsive { overflow-y: hidden; }` in `style.css`. Il contenitore scorre solo in orizzontale e il gesto verticale torna alla pagina. **Non era questa la causa del problema di Benza** (i dati dal suo telefono hanno poi indicato html/body, vedi la voce sopra): la modifica resta perché toglie comunque uno scroller verticale inutile sotto il dito, ed è innocua. **Nessun taglio di contenuto:** quei contenitori sono alti quanto la loro tabella (verificato `scrollHeight === clientHeight`).
- **Verificato sul NAS** restringendo il contenitore a 380px per simulare un telefono: 461px di scroll orizzontale ancora disponibili e funzionanti, scroll verticale del contenitore a zero, niente contenuto tagliato.
- **Insidia dei test:** in una finestra Chrome non visibile (`visibilityState: 'hidden'`) `window.scrollTo()` non muove la pagina e le transizioni CSS sono congelate. Sembrava il bug riprodotto sul desktop, ma succedeva anche su `clienti.php`, che il problema non ce l'ha: è l'ambiente di test, non la pagina.

### PERF — Anche la tendina "Affiliazione" cerca lato server (07/09/2026)
Stesso trattamento dato alla tendina cliente del Nuovo Ordine, applicato alla scheda cliente: `clienti.js` scaricava tutta l'anagrafica per la select **Affiliazione**, cioè **9.212 `<option>`** nel DOM di `clienti.php`. Ora la pagina sta in **909 nodi con 1 sola option**.
- **Nessuna modifica all'API**: `clienti/nominativi.php` accetta già `search` e `limit`, e senza parametri continua a restituire l'elenco completo per chiunque altro (vedi la regressione qui sotto).
- **Il valore di questa select è il nominativo**, non il codice cliente: per mostrare l'affiliazione attuale basta creare la sua option, senza chiedere niente al server. Un'affiliazione scritta a mano in passato, che non corrisponde a nessun cliente, resta visibile com'era.
- **Doppioni:** due clienti possono avere lo stesso nominativo e qui il nominativo È l'id della voce, quindi `processResults` li deduplica.
- **Via `loadNominativi()` e `nominativiCache`**: sparisce anche il refresh della cache dopo ogni salvataggio o eliminazione (tre chiamate in meno), perché i nomi arrivano freschi a ogni ricerca.
- **`no-select2` sulla select** in `clienti.php`, altrimenti l'init automatica di `footer.php` sovrascrive la configurazione AJAX (già visto col Nuovo Ordine).
- **Verificato sul NAS sul cliente #9403** (3R Energy, affiliato ACQUAFERT SRL): affiliazione attuale mostrata all'apertura, ricerca "acqua" → 30 risultati, salvataggio **senza toccare niente** che conserva affiliazione e tutti gli altri campi, cambio affiliazione salvato, svuotamento (allowClear) salvato come vuoto, modal "Nuovo Cliente" che parte con la tendina vuota. Cliente riportato al valore originale a fine test. La colonna Affiliazione nell'elenco clienti continua a mostrarsi.

### FIX — Regressione: la tendina "Affiliazione" si fermava a 30 nomi (07/09/2026)
Effetto collaterale della modifica di poche ore prima. `clienti/nominativi.php` è usato da **due** schermate: la tendina cliente del Nuovo Ordine (che ora cerca lato server) e la tendina **Affiliazione** della scheda cliente (`clienti.js`), che invece chiama senza parametri e si aspetta l'anagrafica completa. Avendo messo `limit = 30` come **default dell'endpoint**, la seconda ha smesso di andare oltre la trentesima voce in ordine alfabetico (si fermava a "ABM…"), senza nessun errore a schermo.
- **Il default torna "nessun limite"** (`limit = 0`): il comportamento storico dell'endpoint è di nuovo quello, e chi vuole paginare passa il proprio `limit` — cosa che `initSelectClienti()` già faceva. `LIMIT` finisce nella query solo se richiesto, quindi cambia anche il `bind_param` (3 tipi invece di 4).
- **Lezione:** cambiare il *default* di un endpoint condiviso cambia il comportamento di tutti i chiamanti, anche di quelli che non si stanno toccando. Il parametro nuovo lo chiede chi lo vuole; chi non lo passa deve ritrovare esattamente quello di prima.
- Verificato sul NAS: `nominativi.php` senza parametri torna **9.211** nominativi (ultimo "Zuppa Giorgio") e la tendina Affiliazione ne ha di nuovo 9.212 option; con `search=ross&limit=30` ne torna 30; la ricerca del Nuovo Ordine funziona come prima. Deploy Aruba con opcache reset.

### FIX — Tendina Select2 alta quanto lo schermo (07/09/2026)
`.select2-results__options` aveva `max-height: none`: con molti risultati la tendina usciva dallo schermo e le ultime voci restavano irraggiungibili, perché con la Select2 aperta la pagina sotto non scorre. Ora `max-height: min(400px, 45vh)` + `overflow-y: auto`: la lista scorre da sé e, sapendo quanto è alta, Select2 può ribaltarla sopra il campo quando sotto non ci sta. Verificato sul NAS: contenuto 1.104px dentro una finestra di 400px, scrollabile.

### PERF — La tendina cliente del Nuovo Ordine cerca lato server (07/09/2026)
La pagina Ordini si portava dietro **9.212 `<option>`**, il 90% dei suoi 10.191 nodi, solo per la tendina cliente di una modal che si apre di rado: `clienti/nominativi.php` restituiva l'anagrafica intera e il JS la riversava nella select. Su desktop non si notava, su un telefono meno potente rendeva tutta la pagina scattosa.
- **`Cliente::getNominativi($search, $limit)`**: filtra su `nominativo`/`nominativo2` con `LIKE` e limita a 30 risultati (massimo 100). `ORDER BY (nominativo LIKE 'termine%') DESC` mette prima chi **inizia** col testo digitato: cercando "rossi" ci si aspetta "Rossi Mario" sopra a "Az. Agr. Barossi". Senza termine restituisce i primi 30 in ordine alfabetico.
- **`initSelectClienti()`** (`ordini.js`) configura Select2 in `ajax` con `minimumInputLength: 2` (sotto le due lettere i risultati sarebbero centinaia) e `delay: 250` (niente una chiamata per tasto). Messaggi in italiano: "Scrivi almeno 2 lettere del nome", "Nessun cliente trovato".
- **La select è marcata `no-select2` in `ordini.php`**: l'init automatica di `footer.php` (`$('select:not(.no-select2)').select2()`) la sovrascriveva con una Select2 senza AJAX, che cercava fra le option locali e rispondeva "No results found". Insidia da ricordare per ogni Select2 con configurazione propria.
- **Preselezione cliente**: con la ricerca lato server l'`<option>` non esiste finché non la si cerca, quindi `preselezionaCliente()` chiede il nominativo a `clienti/read_one.php` e crea l'option al volo. Serve al ritorno da "+ nuovo cliente" (`?nuovoOrdine=<cod>`), che prima aspettava con un polling che l'elenco completo fosse in memoria: quel `setInterval` è sparito.
- `form.reset()` non rimuove le option aggiunte via JS: all'apertura la tendina si svuota a mano lasciando l'option vuota su cui Select2 appoggia il placeholder.
- **Verificato sul NAS:** pagina da **10.191 a 979 nodi** (option da 9.253 a 42), ricerca "ross" → 30 risultati con i "Ross…" in testa, selezione manuale e preselezione da `?nuovoOrdine=9` entrambe corrette.

### FIX — Rete di sicurezza contro la pagina che non scorre su mobile (07/09/2026)
Segnalazione di Benza: **la home (la lista Ordini) non scorre dal cellulare**. Non riproducibile sull'iPhone di Paolo, quindi non è un problema di layout ma di **stato che resta appiccicato al `body`**: sia Bootstrap (`modal-open`) sia il menu mobile (`sidebar-open`) bloccano lo scroll con `overflow: hidden`, e se quella classe non viene tolta la pagina resta ferma **senza niente a schermo che lo spieghi**. Nel codice non c'era nessuna pulizia di sicurezza.
- **`sbloccaScrollSeNessunModal()`** (in `footer.php`): a ogni `hidden.bs.modal` toglie `modal-open`, l'`overflow`/`padding-right` inline e i **backdrop orfani**, ma **solo se non è rimasto nessun `.modal.show`** — altrimenti un modal di conferma aperto sopra un altro si sbloccherebbe da sé. Nel flusso normale non cambia niente: rimuove cose che Bootstrap avrebbe già tolto.
- **`pageshow` con `persisted`**: al ritorno dalla **bfcache** (tasto/gesto indietro, tipico da telefono) la pagina viene ripristinata com'era, classi comprese. Lì si ripulisce lo stesso stato e si chiude lo stato mobile della sidebar.
- **`resize` sopra i 992px** → `chiudiSidebarMobile()`: ruotando il telefono in orizzontale la sidebar torna fissa ma `body.sidebar-open` restava, e tornando in verticale la pagina non scorreva più. La chiusura del menu era già ripetuta in tre punti: ora è una funzione sola.
- **Non risolve per forza la segnalazione**, è una rete di sicurezza sulle cause più probabili: si aspettano da Benza telefono/browser usati e se il blocco arriva subito o dopo aver aperto qualcosa (menu, "Nuovo Ordine", un ordine e poi indietro), e se ricaricando si sblocca.
- **Da guardare comunque:** la home carica **9.212 `<option>`** nella tendina clienti del modal "Nuovo Ordine" — il 90% dei nodi della pagina (10.191). Non blocca lo scroll, ma su telefoni meno potenti rende tutto scattoso. Da spostare su ricerca lato server (Select2 in AJAX).
- Nota sui test: con la scheda Chrome **in background** le transizioni CSS sono congelate e i modal restano a `opacity: 0` con l'apertura "mai finita" — non è un bug della pagina, è un artefatto da tenere presente quando si misura da lì.

### FIX — "Ricevuto ordine" ha la precedenza su "Da richiamare" (04/09/2026)
Segnalazione di Benza: un cliente segnato **Da richiamare** che poi ordina restava nella scheda "Da richiamare" invece di passare in **Ordini in corso**. Le due schede si escludono a vicenda su `or_da_richiamare` (`Ordine::count()`/`read()`, case `incorso` e `richiamare`), quindi finché il flag resta a 1 l'ordine non compare fra quelli in corso.
- **Il case 5 di `api/ordini/stati.php` (POST) azzera `or_da_richiamare`.** Salvare "Ricevuto ordine" significa che il cliente ha ordinato: non c'è più niente da richiamare. È il punto in cui passano tutti i "Ricevuto ordine" creati dalla scheda ordine, quindi la regola vale sia dalla modale sia da chiunque chiami l'endpoint.
- **L'azzeramento è fuori dall'`if ($totale_ordine > 0)`**: il flag va tolto anche nel caso limite di un ordine ricevuto a importo zero.
- **La spunta resta modificabile a mano:** se dopo l'ordine c'è comunque da richiamare il cliente, si rimette. Non è un blocco, è una precedenza al momento del salvataggio.
- **`docs/sql/042_backfill_richiamare_ordini_ricevuti.sql`** sistema gli ordini già in quella situazione (flag a 1 con un cat 5 in archivio): sul NAS ne risultava 1, **in produzione zero** (verificato col ponte DB: 6.100 ordini col flag, nessuno con un cat 5 né con totale > 0 — finora la spunta la toglievano a mano). La migration resta come rete di sicurezza; **eseguita su Aruba il 04/09/2026**, 0 righe toccate come previsto.
- Testato sul NAS con l'ordine **12842**: salvato un "Ricevuto ordine" di prova, la spunta si è tolta da sola e le schede sono passate da 56/180 a **57 In corso / 179 Da richiamare**, con l'ordine presente solo nella prima. Ordine poi **riportato allo stato originale** (stato di prova eliminato, spunta rimessa, data 10/07/2026 10:42, totale 0).
- **Deployato su Aruba il 04/09/2026** (`api/ordini/stati.php` + `opcache_reset` col file-ponte, rimosso subito dopo).

### FEATURE — Le 5 caselle della scheda ordine si salvano da sole al click (04/09/2026)
Richiesta di Benza: "quei pulsanti ce li scordiamo tutti sempre e poi ad esempio non salviamo il *Da spedire* e Paride poi non spedisce". Nel vecchio gestionale (`ordine_stati_db.php`) ogni casella faceva la sua UPDATE al click; nel nuovo finivano nel payload del tasto **Salva** e senza quel click restavano indietro nel database.
- **Ripristinato il comportamento vecchio** per le 5 caselle (`Da richiamare`, `Conferma lettura`, `Merce scaricata/pronta`, `Genap`, `Da spedire`): `salvaFlagOrdine()` in `assets/js/pages/ordine.js`, agganciata in `change` alle `.flag-checkbox`. Il tasto Salva resta per **titolo, data, incaricato e peso**, e non manda più i flag.
- **Nessun endpoint nuovo:** `api/ordini/update.php` aggiorna già solo i campi presenti nel JSON (`isset()` campo per campo, il resto lo rilegge da `readOne()`), quindi si manda `cod_ordine` + il solo flag toccato.
- **Se la chiamata fallisce la spunta torna indietro**, perché il database non è cambiato: una casella che resta spuntata dopo un errore è peggio del problema di partenza. Durante la richiesta la casella è `disabled` (niente doppio click) e si conferma con un toast "Da spedire: attivato/disattivato".
- **Niente `loadOrdine()` dopo il salvataggio:** si aggiorna il campo in `ordineCorrente` (stesso pattern di `salvaCondizioni()`), così non si ricarica tutta la scheda a ogni spunta.
- **Il log attività registra una riga per spunta** invece di una per Salva: è più rumoroso ma dice chi ha tolto il "Da spedire" e quando, che è esattamente la domanda da cui nasce la richiesta.


### PERF — I 5 conteggi delle schede Ordini in una richiesta sola (31/08/2026)
`ordini.php` apriva **8 richieste in parallelo** a ogni caricamento, di cui **5 solo per leggere i numeri delle schede** (`ordini/read.php?limit=1`, una per scheda). Con il server sano nessuno se ne accorgeva; nei periodi di lentezza di Aruba si accodavano e l'ultima aspettava tutte le altre. Ora la pagina fa **4 richieste**.
- **`api/ordini/conteggi.php`** (nuovo) chiama lo stesso **`Ordine::count()`** che usa `read.php`, una volta per scheda, dentro la stessa richiesta. **Nessun filtro riscritto:** se cambia la logica di un filtro cambia in un posto solo e i conteggi seguono da sé. La definizione delle schede (stato/tipo/anno) sta in un array in cima al file, da tenere allineato a `loadAllCounts()`.
- Verificato sul NAS confrontando vecchia e nuova strada su tutti e cinque i numeri, a filtri spenti e poi con ricerca, dipendente, ricerca+dipendente e un filtro avanzato: **identici in tutti i casi**. In rete si vedono 2 chiamate `api/ordini/` invece di 6.
- **`archivio.js` ha una sua `loadAllCounts()`** con due sole schede e `anno=precedenti`: lasciata com'era, due richieste non sono il problema. Se un domani si tocca, l'endpoint va esteso con il parametro anno.

### INFRA — I rallentamenti di Aruba sono rientrati il 19/08/2026 (ticket 18965071A)
Seguito della sonda. **Ticket aperto il 17/08/2026** sul servizio `Sql958286` con allegato il PDF delle misure; unica risposta un interlocutorio dopo 14 minuti, poi silenzio. **Aruba non ha mai detto cosa ha fatto**, ma i dati dicono che qualcosa è cambiato fra il 18 e il 19 agosto.
- 17/08: 75 rilevazioni oltre il secondo, `pk` medio **1.316 ms**. 18/08: 79 lente, picco **59,3 s**.
- 19/08: **un solo** episodio, alle 00:32. Dal 20/08 al 31/08: **zero**, `pk` medio **0,4-0,8 ms**, caso peggiore 263 ms su circa 8.600 rilevazioni.
- Quindi il problema **non era nostro** ed è rientrato senza che toccassimo niente. Il 31/08 è partito un sollecito sul ticket per farsi dire la causa, così resta agli atti se ricapita.
- **La sonda resta accesa.** Storico 14 giorni: i dati del 13-16/08 non sono più nel database, restano solo nel PDF. Prima di aprire un ticket nuovo, alzare `SONDA_GIORNI_STORICO` o esportare.
- Tolto `docs/report_aruba_prestazioni.md`, che era la prima bozza con un solo giorno di dati: superato dal PDF e da questa voce.


### FEATURE — Log Attività: casella "Solo l'ultima attività per ordine" (18/08/2026)
Benza vuole usare il log per seguire cosa succede su ogni ordine, ma su un ordine movimentato le voci sono troppe. Chiesta una vista ridotta **senza cambiare come funziona il log**: nessuna modifica a `logAttivita()` né ai dati, solo un modo diverso di leggerli.
- **Casella nei filtri**, memorizzata per utente/browser in `localStorage` come le altre (chiave `log_filtri`, campo `soloUltima`). Così Paolo continua a vedere il log intero e Davide si accende la vista ridotta da solo, senza che uno tolga niente all'altro. "Azzera filtri" la spegne.
- **Raggruppamento lato server, non nel browser.** Deduplicare le 50 righe della pagina corrente darebbe una pagina "una per ordine" solo all'interno di quella pagina, con totale e paginazione sbagliati. `api/log/` accetta `solo_ultima_ordine=1` e tiene `MAX(id_log)` per ordine.
- **La sottoquery ripete gli stessi filtri della lista**: "l'ultima attività" deve essere l'ultima *fra quelle che si stanno guardando*, altrimenti con la sola Creazione spuntata mostrerebbe righe che il filtro esclude.
- **`MAX(id_log)` e non `MAX(data_ora)`:** l'id è univoco e individua una riga sola, mentre due voci possono avere lo stesso secondo. Le due cose coincidono perché `data_ora` la mette il database all'inserimento.
- **Niente window function:** Aruba è su MySQL 5.6, `ROW_NUMBER()` non esiste. Sottoquery derivata + `GROUP BY`, verificata sia con `ONLY_FULL_GROUP_BY` sul NAS sia sul MySQL di produzione (da centinaia di righe a **205 ordini** coi filtri di default).
- **Restano fuori le voci non legate a un ordine** (cliente, dipendente, admin, costi generali): l'ordine di riferimento è la stessa `CASE` già usata per il link alla scheda, e per loro è `NULL`. È scritto nel suggerimento accanto alla casella, perché il totale cala parecchio.
- Il contatore dei risultati dice "di N ordini (ultima attività di ciascuno)": senza quella riga il numero più basso sembra un pezzo di log mancante.


### FEATURE — Fatturazione: solo gli anni che hanno fatture, e importo con IVA (18/08/2026)
Due richieste di Benza sulla stessa pagina.
- **Anni vuoti spariti.** Il filtro era un ciclo PHP fisso dal 2017 a oggi: su un singolo ordine bisognava cliccare anno per anno per scoprire dove fossero le sue fatture. Ora `api/fatturazioni/` restituisce anche `anni`, con gli **stessi filtri della lista meno l'anno**, e i bottoni li disegna `renderAnni()`. Ogni bottone ha il conteggio nel `title`. Senza nessuna fattura si legge "Nessuna fattura registrata" al posto dei bottoni.
- **Si atterra sull'anno giusto.** Dall'ordine si arriva con l'anno corrente nell'URL: se quell'ordine ha fatture solo di altri anni la pagina era vuota e bisognava indovinare. Ora, se l'anno chiesto non è fra quelli disponibili, la pagina passa da sola al più recente che ha fatture e allinea l'URL con `history.replaceState`. Non cicla: dopo il salto l'anno è per forza nella lista.
- **Importo con IVA.** Serve a Benza per spuntare gli incassi in banca, che arrivano ivati. `or_fatturazioni.importo` è l'**imponibile** e l'aliquota **non è sulla fattura ma sull'ordine** (`or_ordini.iva`), quindi la query ora porta anche `o.iva`. **Non si può usare 22 fisso:** in produzione ci sono 94 fatture su ordini al **0%**, 20 al 10% e 2 al 4%.
- **Calcolo lato server** (`importo_ivato` per riga, `totale_ivato` nel totale): il totale somma gli importi già arrotondati riga per riga, altrimenti balla di qualche centesimo rispetto alla somma di quello che si legge a schermo. Colonna "Importo" divisa in **Imponibile** e **Con IVA**, con l'ivato in grassetto perché è quello che si confronta con la banca; nel riquadro verde l'ivato è il numero grande e l'imponibile la riga piccola sotto.
- I `colspan` dei messaggi "nessuna fattura"/errore/spinner vanno tenuti allineati alla colonna in più (7 e 8 con la colonna Cliente).
- Provato sul NAS: ordine 11926 (fatture solo 2025) entra con `anno=2026` e si sposta da solo sul 2025, 14.259,15 imponibile e 17.396,16 ivato; vista globale con tutti gli anni; ordini con IVA 0 mostrano lo stesso importo nelle due colonne; ordine 12851 senza fatture non va in cicli.


### FIX — HTML nel titolo dell'ordine reso invece che mostrato letterale (14/08/2026)
Nella scheda dell'ordine il titolo veniva scritto con `textContent`, quindi un titolo come `x1 OASE76521, <font color='red'>contrassegno € 145,71</font>` compariva con i tag in chiaro. In produzione non è un caso raro e non è nemmeno decorativo: il rosso è quasi sempre l'importo del contrassegno da incassare, cioè l'informazione che deve saltare all'occhio.
- Riusata la funzione già scritta per il titolo degli stati, rinominata da `sanitizeTitoloStato()` a `sanitizeTitoloHtml()` ora che serve a due campi. **Allowlist, non parsing generico:** si escapa tutto e si ripristinano solo `<b>` e `<font color>` col colore validato `[A-Za-z0-9#]{1,20}`. `<script>`, `<img onerror>`, `<a href>` e un `<font>` con attributi in più restano testo letterale.
- Toccati tre punti: `ordine.js` (caricamento della scheda e timeline degli stati) e l'`oninput` in `ordine.php:627`, che aggiorna l'intestazione mentre si scrive nel campo. Il campo di modifica continua a mostrare il testo grezzo coi tag, altrimenti al salvataggio si perderebbero.
- **Non toccate le due stampe** (etichetta pacco e riepilogo): lì il titolo dell'ordine e quello degli stati usano `escapeHtml()`, coerenti fra loro. Se un domani si vuole il rosso anche in stampa, vanno cambiate insieme.

### INFRA — Sonda sulle prestazioni del DB, per avere numeri da mostrare ad Aruba (13/08/2026)
Davide segnala lentezza. La **sentinella richieste lente** (messa il 12/08) ha registrato **44 avvisi in quattro raffiche brevi**: 12:57-13:01, 14:35, 14:48-14:49, 14:54-14:56. Fra una raffica e l'altra tutto normale.
- **Non è una nostra query.** Fra gli endpoint colpiti c'è `admin/read_one`, che legge **una riga per chiave primaria**, con un caso da **33 secondi**. Memoria usata 2 MB. Dentro ogni raffica le durate crescono a scaletta (7,3 → 8,5 → 16,5 → 20,5 → 29,4 s): è il profilo di richieste che si accodano, non di query lente. **La sentinella misura il tempo del server**, quindi la linea del client non c'entra (a differenza dell'episodio del 12/08).
- **Cosa amplifica il problema da parte nostra:** `ordini.php` fa **8 chiamate in parallelo** a ogni apertura, di cui **5 solo per contare** i numeri delle schede (In corso, Preventivi, Da spedire, Da richiamare, archivio: `limit=1`). Con il server sano non si nota (80-120 ms l'una), quando si inceppa si accodano. **Fatto il 31/08/2026**, vedi la voce PERF in cima.
- **`api/monitor/sonda.php` + migration 041 (`or_db_sonda`)**: esegue sempre le **stesse** query e registra quanto ci mette ciascuna. Serve la linea di base: "questa query sta sempre fra 2 e 4 ms" diventa dimostrabile, e la stessa query a 7.654 ms è la prova da portare ad Aruba. Le 4 misure separate dicono **dove** si inceppa: `connessione` (rete PHP↔MySQL), `select1` (puro andata e ritorno, nessun dato né indice), `pk` (una riga per chiave primaria su `or_stati`), `conteggio` (un po' di lavoro vero). Se salgono tutte insieme, il server era fermo.
- **Token in `secrets.php`** (non nel sorgente, come le altre chiavi), 404 senza. Chiamata da cron-job.org **ogni 2 minuti**: le raffiche durano 1-2 minuti, a 5 minuti se ne perderebbe metà. Storico 14 giorni, pulizia fatta dalla sonda stessa una volta su cento.
- **`ignore_user_abort(true)` + `set_time_limit(120)`, e senza non funzionerebbe:** cron-job.org ha un **timeout massimo di 30 secondi** non modificabile. Le raffiche arrivano a 36: la chiamata verrebbe troncata **prima** che la sonda scriva la riga, e resteremmo senza dati proprio nei momenti che interessano. Così PHP arriva in fondo anche se dall'altra parte nessuno ascolta più.
- Prima misura in produzione a server sano: connessione 2 ms, `SELECT 1` 0 ms, pk 1 ms, conteggio 5 ms, totale 7 ms.
- **Nota su cron-job.org:** disabilita da solo un job dopo troppi fallimenti e **non si può impedire**; l'unica cosa attivabile è la notifica via email, che Paolo tiene accesa. Se dopo un periodo lungo di lentezza il monitoraggio risulta spento, va riacceso a mano.

### FEATURE — Pagamenti da registrare in contabilità: pagina, badge e notifica interna (12/08/2026)
Richiesta di Benza in due passi. La segretaria deve riportare ogni incasso sul software di contabilità e finora non aveva modo di sapere quali fossero da fare.
- **Passo 1, notifica interna.** Alla **creazione** di uno stato di Pagamento dal gestionale parte un'email a `preventivi@benza.it` con cliente, ordine, descrizione, importo pagato, totale, residuo, metodo, chi l'ha registrato e il link alla scheda. Solo interna, nessuna copia al cliente. Non parte in modifica: quella è una correzione, la notifica era già partita alla creazione.
  - **I pagamenti dell'e-commerce non la fanno partire e non serve escluderli a mano:** il sito scrive quegli stati con una `INSERT` sua in `sito_26/api/config/gestionale_helper.php` senza passare da `api/ordini/stati.php`, e ha già la sua email al cliente. La separazione è architetturale.
  - **BCC disabilitato per questa email** (`Mailer::disabilitaBcc()`, nuovo): il BCC di cortesia va a `preventivi@benza.it`, cioè lo stesso indirizzo del destinatario, e Benza l'avrebbe ricevuta in doppia copia. Verificato che il metodo agisca sulla singola istanza e non sulle altre.
  - **La data mostrata è quella dello stato, non di oggi:** un pagamento si registra anche con data futura (RiBa, assegni postdatati) e la segretaria deve leggere la scadenza vera.
  - Sta in try/catch dopo il salvataggio: un problema di posta non deve far perdere la registrazione di un incasso. Un fallimento lascia un `warning` nel Log Errori.
- **Passo 2, la coda di lavoro.** Migration **040**: `or_stati.registrato` TINYINT DEFAULT 0 + indice `(cod_categoria, registrato, data_ora)`. Tutto lo storico passa a 1, altrimenti la pagina nascerebbe con **4.503 righe** arretrate (il pagamento più vecchio è del 26/02/2013). Si parte puliti.
- **`pagamenti.php` + `assets/js/pages/pagamenti.js` + `api/pagamenti/index.php`**: una riga per **stato**, non per ordine, quindi un ordine con tre acconti compare tre volte. Ordinamento per data. Ogni riga mostra anche quanto resta da saldare sull'ordine, così si distingue un acconto da un saldo. Interruttore per rivedere i già registrati e rimetterli in coda (errori di clic). Conferma prima di agire, voce nel Log Attività, ordini nel cestino esclusi.
- **Badge rosso in sidebar**, `window.aggiornaBadgePagamenti()` in `common.js`. Agganciato a **`loadStati()`** di `ordine.js` e non al singolo salvataggio: quella funzione gira dopo *ogni* modifica alla timeline (creazione, eliminazione, modifica, spostamento, copia), quindi un aggancio solo copre tutti i casi. Si nasconde quando il conteggio è zero. Endpoint dedicato `?solo_conteggio=1`: gira su ogni pagina, quindi è una `COUNT` indicizzata senza le JOIN della lista.
- **Vista "già registrati" limitata a 200 righe:** alla prima prova scaricava tutto lo storico, **4.475 righe e 1,3 MB**. Serve solo a correggere una smarcatura sbagliata, quindi bastano le recenti; la pagina scrive "Ultimi 200 su 4475" per non far credere che siano solo quelli. Ora 62 KB.
- **`formatCurrency` è definita nel file di pagina** e non in `common.js`: nel progetto non è globale, ogni pagina ha la sua copia (ordine.js, costi.php, cassa.php…). Sono invece globali `showAlert`, `escapeHtml`, `lockBtn`/`unlockBtn` e `AppModal`.
- **Nota su `AppModal.confirm`:** col tipo `success` il titolo di default è "Operazione completata", che su una domanda ancora senza risposta è fuorviante. Serve passare `title` esplicito.
- Provato per intero sul NAS con un pagamento vero: badge da 0 a 1 senza ricaricare, riga in elenco, smarcatura, riga sparita e badge a 0, poi ritrovata fra le registrate e rimessa in coda. Migration eseguita da Paolo su NAS e Aruba.
- **Dicitura sopra la tabella allineata al bottone (17/08/2026):** diceva ancora "Premi la spunta verde" da prima che l'icona diventasse il timbro. Ora le due frasi mostrano l'icona vera (`fa-stamp` da registrare, `fa-undo` per rimettere in coda), con l'azzurro del brand `var(--primary-blue)` sul timbro e non `text-primary`, che è il blu di Bootstrap e non combacia col bottone. Il testo sta in due posti che vanno cambiati insieme: `pagamenti.php` (primo caricamento) e `pagamenti.js::cambiaElenco()` (interruttore), quest'ultimo passato da `textContent` a `innerHTML` per rendere l'icona.

### PERF — Allegati in una query sola + sentinella richieste lente (12/08/2026)
Benza segnala lentezza: su `ordine.php?cod=12647` la chiamata a `api/ordini/stati.php` è durata **12,4 secondi**, mentre tutte le altre della stessa pagina stavano sotto i 110 ms.
- **Rimisurato subito dopo in produzione: 134 ms** sullo stesso ordine (64 stati, 203 KB), e **407 ms** sull'ordine più pesante del database (#4543, 214 stati, 923 KB). Quindi non è un problema strutturale di quella query.
- **Escluso**: indici a posto (`or_stati` ha `cod_ordine+data_ora`, `or_allegati` ha `cod_stato`); **niente sessioni sulle API** (sono a JWT, quindi nessuna coda sul lock del file di sessione, che è la causa classica di "una chiamata lenta e le altre veloci"); nessuna chiamata esterna nella GET (`ecommerce_sync.php` viene incluso ma lavora solo in scrittura); **gzip attivo** (944 KB → 254 KB sul filo). Nell'elenco di Benza le chiamate veloci pesano 2-3 KB e l'unica lenta è anche l'unica grossa: se in quel momento la linea era lenta, è la prima a farlo notare. Resta un'ipotesi.
- **Difetto vero trovato (N+1):** gli allegati venivano letti con **una query per stato**, 215 query su un ordine da 214 stati. Ora una sola con `IN`. Non spiega i 12 secondi, ma è il codice che amplifica qualsiasi rallentamento del DB.
- **Si filtra per `cod_stato`, non per `cod_ordine`**, che sarebbe stato più comodo: in produzione **1.001 allegati hanno un `cod_ordine` incoerente** con quello del loro stato e **72 ce l'hanno a zero** (dati legacy). Filtrare per ordine avrebbe fatto sparire allegati e mostrati altri sull'ordine sbagliato.
- **Verificato che l'output non cambi**, non solo che sia veloce: sul NAS, ordine #4543, 331 allegati su 93 stati con lo **stesso SHA-256** del calcolo fatto in SQL. In produzione, dopo il deploy, 341 allegati su 96 stati, che è esattamente quello che dice il database.
- **Sentinella richieste lente** in `api/config/core.php` (incluso da 101 file): oltre i **3 secondi** lascia un `warning` nel Log Errori con endpoint, durata, query string e picco di memoria. Gira in `register_shutdown_function`, quindi a risposta già inviata, e apre una connessione propria solo quando deve davvero scrivere; tutto dentro try/catch, non può far fallire una richiesta. Provata sul NAS con una richiesta finta da 3,5 s: riga registrata correttamente.
- Deploy Aruba: `api/ordini/stati.php` + `api/config/core.php` (opcache reset).

### FIX/INFRA — TinyMCE self-hosted: via la dipendenza dal cloud e dal suo limite mensile (11/08/2026)
Benza segnala che l'editor non funziona più: **sola lettura**, quota del piano gratuito esaurita. Sul pannello Tiny risultavano **1.013 editor load su 1.000**, mentre lui aveva visto "451" e pensava a un errore di conteggio.
- **Nessun errore di conteggio:** 451 è il ciclo **precedente** (19/06→19/07). Lo storico di Tiny elenca solo i cicli **chiusi**, quello in corso sta solo nella barra. Andamento dei cicli: 39, 59, 62, 66, 63, 144, 230, 451, **1.013**: raddoppia da quando il gestionale è in produzione.
- **Perché si consuma in fretta:** un "editor load" è **ogni inizializzazione** dell'editor, non una pagina vista né un utente ([doc Tiny](https://www.tiny.cloud/docs/tinymce/latest/understanding-editor-loads/)). Qui `initTinyMCE()` viene chiamata all'apertura di ogni modal di stato e **distrugge e ricrea** l'istanza, quindi riaprire la stessa modal riconta. Nel ciclo: **402 stati creati** contro 1.013 load, cioè ~2,5 load per stato salvato fra aperture, annullamenti e modifiche.
- **Scelta: self-hosting** invece di pagare (40 $/1.000 load) o di ottimizzare il riuso dell'istanza (che avrebbe solo spostato il tetto più in là, con questa crescita di 2-3 mesi). Self-hosted **non c'è nessun conteggio**, e funziona anche sul NAS senza internet.
- **`assets/vendor/tinymce/`**: TinyMCE **7.9.3** (stessa major di prima, nessun cambio di comportamento), preso dal pacchetto npm ufficiale, GPL-2.0-or-later. Tenuti solo i file che servono davvero: `tinymce.min.js`, tema silver, model dom, icone, skin oxide + content default, i 3 plugin usati (`lists`, `link`, `table`), la licenza. **15 file, 1,6 MB** invece dei 10 MB del pacchetto completo.
- **Language pack italiano** preso dal download **community** (`download.tiny.cloud/tinymce/community/languagepacks/7/it.zip`) e non dal CDN cloud: quest'ultimo lo serve con l'intestazione "Tiny commercial license", che per un file self-hosted non è la licenza giusta.
- **`ordine.php`**: tag script locale con `?v=assetVersion()` come tutti gli altri asset. Tolti `crossorigin`/`referrerpolicy`, ora il file è di casa.
- **`initTinyMCE()`**: aggiunti `base_url: 'assets/vendor/tinymce'` e `suffix: '.min'`. Servono **proprio per il `?v=`**: senza, TinyMCE ricava la base dall'URL dello script e si porta dietro la query anche su skin, lingua e plugin. Più `license_key: 'gpl'`, che è la dichiarazione prevista dalla 7 per l'uso self-hosted e toglie l'avviso.
- Verificato sul NAS: editor 7.9.3 inizializzato, menu in italiano, i 3 plugin caricati, scrittura e inserimento tabella funzionanti, i 4 file di skin raggiungibili, e soprattutto **zero richieste verso tiny.cloud** (controllate le `performance.getEntriesByType('resource')`: 9 risorse, tutte locali).
- **Restano sul cloud gli altri due progetti**, stessa API key e stesso problema: `prev_vasche` (4 file, TinyMCE 8) e `sito_26/admin/documenti_contenuto.php`. Da convertire allo stesso modo.

### DATI — Migration 039: sconti e condizioni importati dai preventivi esistenti (10/08/2026)
La 038 ha creato i campi ma li ha lasciati vuoti, mentre nel preventivatore c'erano già parecchi preventivi con sconti e condizioni scritti a mano. La 039 li travasa sul cliente con la stessa regola che varrà da qui in avanti: **vale l'ultimo preventivo fatto a quel cliente** (`data_modifica`, altrimenti `data_creazione`; a pari istante l'`id` più alto).
- **Query cross-database**, `or_clienti` senza prefisso e il preventivatore qualificato: **`Sql958286_5`** (il file è già pronto per la produzione; per il NAS c’è il `sed` nell’intestazione). Verificato dal ponte che l'utente unico di Aruba veda entrambi i database.
- **Riga vera, non doppio MAX:** `p.id = (SELECT ... ORDER BY data DESC, id DESC LIMIT 1)`. Il doppio MAX (data e id calcolati a parte) può mescolare colonne di righe diverse, ed è lo stesso errore che stava dietro al bug del filtro pagamento del Report. Vedi memory `lessons_learned`.
- **Due sorgenti.** `preventivi` (tutti e 5 i campi): in produzione 181 righe su **119 clienti**, di cui 91 con sconto1 > 0, 95 con sconto2 > 0, 118 con condizioni scritte. `preventivi_semplici` (solo lo sconto, niente diciture né condizioni): 40 righe, 4 con sconto, che riguardano **2 clienti senza nessun preventivo normale** (7632 e 9622): senza il secondo UPDATE resterebbero a mani vuote. `preventivi_multipli` è **vuota**.
- **Scelte scritte nel file:** le percentuali si importano anche quando valgono **0** (è la regola "vale l'ultimo": chi non ha avuto sconto parte da zero, resta NULL solo chi non ha mai avuto preventivi); le **condizioni di pagamento si sovrascrivono solo se il preventivo ne ha di sue**, altrimenti il cliente si tiene la frase standard della 038; il secondo UPDATE scrive **solo `sconto1_percentuale`**, perché un preventivo semplice non sa niente del secondo sconto.
- **Verificata senza eseguirla:** `EXPLAIN UPDATE` su entrambe le istruzioni sul NAS (sintassi e piano ok, la subquery correlata usa `idx_cod_cliente`) e anteprima in sola lettura sui dati veri di produzione. Nel file ci sono una SELECT di anteprima da lanciare prima e tre verifiche da lanciare dopo.
- **Eseguita in produzione il 10/08/2026.** Esito verificato via ponte: **145 clienti** con il primo sconto valorizzato (119 dai preventivi normali + 26 che hanno solo preventivi semplici), **93** con sconto sopra zero, **119** col secondo sconto, **118** con la dicitura, **18** con condizioni di pagamento diverse da quella standard. Restano NULL 9.113 clienti, cioè quelli che un preventivo non l'hanno mai avuto.
- **Controllo incrociato riga per riga:** confrontando `or_clienti` con l'ultimo preventivo di ogni cliente, **zero discordanze** sulle percentuali. I 3 clienti che hanno **sia** un preventivo normale sia uno semplice più recente (7425, 9588, 9599) sono stati guardati a mano: in tutti e tre il primo sconto valeva già 0 in entrambe le fonti, quindi il secondo UPDATE non ha cambiato niente e non si è creato nessun miscuglio fra righe diverse.
- Nota per il futuro: il secondo UPDATE scatta anche quando il preventivo semplice ha sconto **0**, sovrascrivendo un eventuale primo sconto più vecchio. È la regola "vale l'ultimo" applicata alla lettera. Oggi non ha prodotto effetti, ma se un domani capitasse un caso con valori diversi, il cliente si ritroverebbe primo sconto azzerato e secondo sconto ereditato dal preventivo precedente.

### FIX — "Altri costi" con la coda di decimali (16130,869999999999) (10/08/2026)
Segnalato da Benza sull'ordine **#10465**: nel campo "Altri costi (da tabella)" compariva `16130,869999999999`, cifre che nessuno ha digitato.
- **Non è un problema di salvataggio.** In `or_costi_altri.importo` e in `or_costi.costi_var` le colonne sono `decimal(10,2)` e nel DB il valore è **esattamente `16130.87`** (verificato in produzione via ponte). Il difetto nasce e muore lato lettura.
- **Causa:** `api/costi/altri.php::getCostiAltri()` sommava gli importi con `$totale += floatval(...)`, cioè in **float binario**: 14 valori decimali sommati così danno `16130.869999999999`. Quel numero finiva nel JSON e `costi.php` (riga ~445) lo scriveva **tale e quale** dentro `#costi_var`, che è un `input type="number"` senza formattazione (gli altri importi passano da `formatCurrency` e infatti si vedevano giusti). Riprodotto in `php -r` con i 14 importi reali: prima `16130.869999999999`, dopo `16130.87`.
- **Fix:** `round($totale, 2)` sul valore restituito. Stessa cosa in **`api/dashboard.php`** (`$aggregatoVecchi[$m]`), unico altro accumulo di float nelle API: alimenta le etichette dei grafici, dove la coda si sarebbe vista.
- **Nessun dato da correggere:** salvando, MySQL arrotondava comunque al `decimal(10,2)`. Era solo brutto da vedere.
- Deploy Aruba: `api/costi/altri.php` + `api/dashboard.php` (opcache reset).

### FEATURE — Condizioni commerciali sul cliente: 2 sconti + condizioni di pagamento (10/08/2026)
Richiesta di Benza a cavallo di due progetti. Nel **preventivatore vasche** ogni preventivo ha due sconti in percentuale (ognuno con la sua dicitura) e una textarea con le condizioni di pagamento, che oggi vanno riscritti ogni volta. Devono diventare un dato del **cliente**: il preventivatore li legge come punto di partenza e a preventivo concluso li riscrive con gli ultimi valori usati (vince sempre l'ultimo preventivo, richiesto esplicitamente da Benza). L'anagrafica sta qui, quindi qui nascono i campi. **La parte di `prev_vasche` la fa Paolo**, brief scritto in un `.md` a parte.
- **Migration 038**: 5 colonne su `or_clienti` con gli **stessi nomi** della tabella `preventivi` del preventivatore, così non serve una corrispondenza fra progetti. Percentuali `decimal(5,2)` **NULL** ("mai impostata" ≠ "sconto zero"): sono le prime colonne di `or_clienti` che ammettono NULL, il resto è legacy NOT NULL con stringa vuota. **Non** si portano `sconto1_valore`/`sconto2_valore`: sono l'importo in euro calcolato su quel preventivo, non un dato del cliente.
- **`condizioni_pagamento` è `TEXT`** (serve per gli a capo) e su **MySQL 5.6 una TEXT non può avere una DEFAULT**: la frase standard si scrive con la `UPDATE` dentro la migration (9.211 clienti). Su MariaDB la `DEFAULT` sarebbe passata e si sarebbe piantata in produzione. I clienti nuovi la ricevono da `Cliente::create()`, **unico posto nel codice dove la frase è scritta**.
- **`api/objects/cliente.php`**: 5 proprietà + `percentualeValida()` (null se vuota, altrimenti clamp 0-100: `decimal(5,2)` accetterebbe 999,99) + `updateCommerciali()`. `bind_param` ricontati (create 29, update 30, updateCommerciali 6: placeholder/tipi/variabili verificati con uno script).
- **Endpoint dedicato `api/clienti/commerciali.php`** per la card dentro l'ordine. **Non** si riusa `clienti/update.php`: dalla scheda ordine il resto dell'anagrafica non è caricato e si riscriverebbero nome, indirizzi e recapiti con quanto la pagina si ricorda, cancellando le modifiche di chi ha toccato il cliente nel frattempo. Voce nel Log Attività come entità `cliente`.
- **Rete di sicurezza in `clienti/update.php`**: se il payload non contiene i 5 campi (JS vecchio in cache, chiamata da altrove) si rileggono quelli salvati invece di azzerarli. Un salvataggio dell'anagrafica non deve poter cancellare gli sconti.
- **`ordine.php` + `ordine.js`**: sesta card "Condizioni commerciali" nella terza colonna sotto Importi. Mostra le due percentuali, con la **dicitura al posto dell'etichetta** quando c'è; tutta la card è cliccabile (`role="button"` + Invio/Spazio) e apre la modal coi 5 campi. In cima alla modal è scritto che sono **dati del cliente**, validi per tutti i suoi ordini: la modifica è globale e Benza lo sa, ma l'interfaccia deve dirlo. I 5 valori arrivano già col `readOne()` dell'ordine (JOIN su `or_clienti` già presente), nessuna chiamata in più.
- **`clienti.php` + `clienti.js`**: stessi campi in fondo al form, in sezione staccata da anagrafica/indirizzi. Il campo **Note resta intoccato** (Benza ci prende annotazioni varie, etichetta compresa).
- **Ritocco del 10/08 (sera):** nella card la riga in basso non ripete più "Dati del cliente: valgono per tutti i suoi preventivi" (l'avviso resta dov'è utile, cioè in cima alla modal, nel momento in cui si sta per modificare) e mostra invece le **condizioni di pagamento**, che altrimenti si vedevano solo aprendo. È testo libero e può essere lungo: `-webkit-line-clamp: 2` lo taglia a due righe coi puntini, il testo intero sta nel `title` e nella modal, così la card non cresce. Se il campo è vuoto compare "Condizioni di pagamento non indicate" in corsivo. Verificato sul NAS anche con un testo da 3 righe: contenuto tagliato a 27px su 41, card ferma.
- **Migration eseguita da Paolo su NAS e Aruba.** Deploy Aruba: `clienti.php`, `ordine.php`, `api/objects/cliente.php`, `api/objects/ordine.php`, `api/ordini/read_one.php`, `api/clienti/{create,read_one,update,commerciali}.php` (opcache reset) + `clienti.js`, `ordine.js` (FTP). **In produzione i campi ci sono ma nessuno li scrive ancora dal preventivatore**: finché quella parte non è pronta, i valori restano quelli di partenza.

### FIX — Gestione Costi: modificare un costo variabile non salvava niente (10/08/2026)
Segnalato da Benza: si preme la matita su un costo già esistente, si cambia l'importo, si preme la spunta verde e il valore resta quello di prima.
- **Causa: PUT + FormData**, lo stesso inciampo già visto sulla fatturazione. `costi.php` (`updateCostoAltro`) chiamava `api/costi/altri.php` con `method: 'PUT'` e un `FormData`: **PHP non popola `$_POST` su una PUT multipart**, quindi lato server `id_altro_costo`, `desc_altro_costo` e `importo` arrivavano tutti vuoti e la funzione rispondeva **400 "id_altro_costo obbligatorio"** senza toccare il DB. Il messaggio compariva solo nell'alert in alto a destra.
- **Fix** (pattern del progetto, già in `stati.php` e `fatturazioni/index.php`): il client invia `method: 'POST'` con `_method=PUT` nel FormData, il backend intercetta `$_POST['_method'] === 'PUT'` e instrada su `updateCostoAltro()`. Nessuna modifica alla logica di aggiornamento.
- **Non era un caso isolato:** in produzione `or_log` per `entita='costo_altro'` conta **32 creazioni e 6 eliminazioni, zero modifiche**. Il pulsante non ha mai funzionato dal go-live. Nel Log Errori niente, coerente: la 400 era una risposta JSON pulita, nessuna eccezione da registrare.
- **È l'unica PUT rimasta nel frontend** (`grep "method: 'PUT'"` su tutto il progetto): dopo questo fix il pattern è uniforme ovunque.
- Verificato sul NAS (ordine #12723, costo #106): importo 120 → 125,50 salvato in `or_costi_altri`, `or_costi.costi_var` ricalcolato a 644,50, riepilogo ordine aggiornato e **prima riga "modifica costo_altro" mai comparsa** in `or_log`. Poi rimesso 120 dalla stessa interfaccia.
- Deploy Aruba: `costi.php` + `api/costi/altri.php` (opcache reset).

### FIX — Report: il filtro per metodo di pagamento non trovava quasi nulla (10/08/2026)
Segnalato da Benza: filtrando per **PayPal** il Report dava **zero risultati**, pur essendoci 40 ordini con almeno uno stato pagato in PayPal. Causa: il filtro guardava **solo l'ultimo stato dell'ordine** (join costruito con i due `MAX`), ma un ordine può avere **più stati di pagamento** con metodi diversi (1000 € di ordine, 500 in PayPal e 500 in bonifico = due stati). Se l'ultimo stato non era quello PayPal, l'ordine spariva.
- **`api/report/index.php`**: il filtro diventa un `INNER JOIN` su una sotto-select `DISTINCT cod_ordine` degli stati di categoria 6 col pagamento richiesto, quindi basta **uno qualsiasi** degli stati. Il metodo mostrato in colonna è un `GROUP_CONCAT DISTINCT` di tutti i metodi usati sull'ordine, non più uno solo.
- **Effetto collaterale positivo:** il vecchio join lasciava vuota la colonna **Stato** su **465 ordini**; sostituito con una subquery correlata ordinata per `data_ora DESC, cod_stato DESC` che l'ultimo stato lo trova davvero. Vedi memory `lessons_learned` ("cercare un'informazione sull'ultimo stato è quasi sempre sbagliato").
- Query validate con `prepare` reale e confrontate sul DB di produzione via ponte (40 ordini PayPal, tutti presenti). Deploy Aruba: `api/report/index.php` (opcache reset).

### FIX — Barra di scorrimento orizzontale con le mail incollate larghe (07/08/2026)
Su un ordine la pagina sforava la larghezza del browser. Il contenuto largo (tabella di una mail incollata) non poteva essere compresso perché `#page-content-wrapper` è un **flex item**, e un flex item ha `min-width: auto`: non scende sotto la larghezza del proprio contenuto, quindi spingeva fuori tutto il layout.
- **`assets/css/style.css`**: `min-width: 0` su `#page-content-wrapper`. Vale per **tutte** le pagine del gestionale, non solo per l'ordine segnalato. Vedi memory `lezioni_ui_frontend`.
- Deploy Aruba: `style.css` (FTP).

### FIX — Tabelle delle mail incolonnate, niente blocchi affiancati (06/08/2026)
Stesso ordine del fix precedente, stato "Fattura Carlisle 90125584": alcuni blocchi comparivano **affiancati** invece che uno sotto l'altro. Le mail incollate usano `<table align="left">`, che in HTML legacy equivale a un `float: left`: due tabelle di seguito si affiancano.
- **`ordine.php`** e i due CSS di stampa in `ordine.js`: `.stato-descrizione table { float: none }`. Il `display: flow-root` del fix del 03/08 conteneva i float ma non impediva l'affiancamento.
- Deploy Aruba: `ordine.php` (opcache reset) + `ordine.js` (FTP).

### FIX — Testo che sbordava dalla card dello stato (tabelle in float nelle mail) (03/08/2026)
Segnalato sull'ordine **#12596**, stato "Fattura Carlisle da pagare il 31 Agosto": il testo usciva sotto il bordo della card. Causa: la mail incollata contiene una **`<table>` con `float: left`** alta 308px. Un elemento in float **esce dal flusso**, quindi il contenitore non lo conta nell'altezza: `.stato-descrizione` risultava alta **106px** con 394px di contenuto, e la card 265px contro 536px.
- **Fix**: `display: flow-root` su `.stato-descrizione`, che crea un contesto di formattazione contenendo i float. Preferito a `overflow: auto`, che pure funziona (provato) ma può tirar fuori scrollbar o tagliare il contenuto.
- Applicato in **tre punti**, tutti con la stessa classe: la timeline in `ordine.php` e i CSS delle due **stampe** in `ordine.js` (stato singolo e ordine intero), dove la tabella sarebbe uscita dal riquadro sovrapponendosi al resto.
- Verificato in produzione sullo stato reale (card da 265px a 554px, niente che sborda) e sul NAS con prova e controprova: iniettando una tabella in float il contenitore cresce di 283px e la assorbe, togliendo `flow-root` il difetto ricompare. Nessuna delle 13 descrizioni della pagina sborda più.
- Deploy Aruba: `ordine.php` (opcache reset) + `ordine.js` (FTP).

### UI — Scheda ordine: su mobile le note cliente partono chiuse, dietro "Mostra note" (03/08/2026)
Da **md in su** la card Cliente è `absolute` dentro il suo wrapper, quindi prende l'altezza della card Ordine accanto e le note scorrono dentro (invariato). **Sotto md** le card si impilano e quel vincolo non c'è: con note lunghe la card cresceva a dismisura e Benza doveva scrollare per un pezzo prima di arrivare al resto.
- **`ordine.php`**: le note (`#clienteNote`) nascono con la classe `note-chiuse` e accanto al titolo "Note:" c'è un pulsante **`d-md-none`** (quindi invisibile su desktop). CSS: `@media (max-width: 767.98px) { #clienteNote.note-chiuse { display: none } }`, così da md in su la classe c'è ma non ha effetto e il comportamento resta quello di prima.
- **`assets/js/pages/ordine.js`**: `toggleNoteCliente(btn)` alterna la classe e il testo del pulsante ("Mostra note" / "Nascondi note"); al caricamento di un ordine le note tornano sempre chiuse.
- **Verifica del caso mobile senza poter ridimensionare la finestra** (lo strumento di automazione non cambia la viewport): si carica la stessa pagina in un **iframe largo 400px**, dentro cui le media query si valutano su quella larghezza. Su un cliente con note da 3.675 caratteri (#6909): card alta **3523px** con note aperte contro **348px** chiuse, pulsante visibile, due click che aprono e richiudono. A viewport piena: note visibili, pulsante nascosto, card a 455px come prima.
- Deploy Aruba: `ordine.php` (opcache reset) + `ordine.js` (FTP).

### UI — Log Attività: descrizioni degli stati più corte, "Personalizzato" col suo titolo (03/08/2026)
Le descrizioni ripetevano quanto già scritto nelle colonne **Azione** e **Tipo**: `Creato stato "Spedizione" per ordine #123456` → **`"Spedizione" per ordine #123456`**. Gli **ordini** restano invariati, per scelta di Benza.
- **`api/config/funzioni_api.php`**: nuova `nomeStatoPerLog($cod_categoria, $desc_stato)`. Per le categorie con **titolo libero** (1, 2, 3 e 10 "Personalizzato") usa il **titolo dato allo stato**: nel log le righe erano tutte "Personalizzato" e non si capiva a quale stato si riferissero. La mappa delle categorie era duplicata in due punti del CRUD, ora sta qui.
- **`api/ordini/stati.php`**, tre punti: creazione (`"Nome" per ordine #N`), eliminazione (`"Nome" da ordine #N`), modifica (`"Nome" dell'ordine #N`). **In modifica il log non diceva nemmeno di quale stato si trattasse**: ora lo riporta, letto da `$statoAttuale`.
- **Storico**: il testo è scritto in `or_log.dettagli` al momento dell'azione, quindi le voci vecchie restano lunghe. Su scelta di Paolo si accorciano **in visualizzazione** (`assets/js/pages/log.js`, `accorciaDettagliStato()`): togliendo il prefisso mentre la tabella viene disegnata, **senza toccare il database** (reversibile, nessuna migration). Agisce solo su `entita === 'stato'`. **Anche i vecchi "Personalizzato" mostrano il titolo:** il messaggio salvato non lo conteneva, ma `desc_stato` è sempre stato in `or_stati` e `or_log.id_entita` è il `cod_stato`. L'API del log aveva già il `LEFT JOIN` su `or_stati` per il link all'ordine: aggiunto `s.desc_stato AS titolo_stato` al SELECT e il frontend sostituisce la parola. In produzione riguarda **230 righe su 247**; restano "Personalizzato" le 15 con lo stato eliminato e le 2 senza titolo, correttamente. Le vecchie modifiche diventano `dell'ordine #N`: monco da solo, ma nella riga le colonne Azione e Tipo fanno da soggetto.
- Verificato sul NAS creando uno stato reale (`"Sollecito pagamento fornitore" per ordine #12851 …` in `or_log`) e rileggendo la tabella con tutti e tre i filtri Azione attivi. Deploy Aruba: `stati.php` + `funzioni_api.php` (opcache reset) e `log.js` (FTP).

### COMPATIBILITÀ — PHP 8.4 (NAS) e 8.5 (Aruba): verifica e pulizia funzioni deprecate (30/07/2026)
Aruba è passata a **PHP 8.5.6**, il NAS è fermo a **8.4.14** (nessun aggiornamento disponibile): il codice deve girare su entrambe. Versioni rilevate con `phpversion()` sui due server, non per sentito dire.
**Esito: nessun errore bloccante.** `php -l` con 8.5 su **172 file**: zero errori di sintassi. Confrontato il codice con la lista ufficiale di *Backward Incompatible Changes* e *Deprecated Features* di 8.5 (UPGRADING di php-src): i soli riscontri sono **tre funzioni deprecate**, che funzionano ancora ma emettono `E_DEPRECATED` su 8.5.
Verifica fatta **eseguendo davvero** le funzioni sui due server (file di prova temporaneo, poi rimosso), non solo per lettura:
- **Aruba 8.5.6:** `curl_close()`, `finfo_close()`, `imagedestroy()` → `DEPRECATED` ("has no effect since PHP 8.0" / "objects are freed automatically"). `mysqli` (prepare/bind/execute/fetch), `DateTime`, `number_format`, `json_encode`, cast float→int: **nessun avviso**.
- **NAS 8.4.14:** zero avvisi su tutto.
**Perché sistemarle comunque:** in produzione `display_errors=0` (`api/config/core.php`), quindi oggi sono silenziose. In locale però è `1`: quando il NAS passerà a 8.5, quegli avvisi finirebbero **dentro le risposte JSON** delle API (`imagedestroy` sta proprio nel ridimensionamento foto di `api/ordini/stati.php`) e romperebbero il frontend in sviluppo.
- Rimosse: 3 `curl_close()` in `includes/api.php`, 2 `finfo_close()` in `api/_example/*/upload_foto.php`, 2 `imagedestroy()` in `api/ordini/stati.php`. **Si cancellano e basta**: dal PHP 8.0 quegli handle sono oggetti liberati dal garbage collector, quindi il codice resta valido sia su 8.4 sia su 8.5. Ridimensionamento immagine riprovato senza `imagedestroy` (file scritto, 120x90, JPEG valido).
- **`vendor/mpdf`** usa `curl_close` e `imagedestroy`: avvisi silenziosi in produzione, codice di terze parti che `composer install` sovrascriverebbe. Non toccato.
- **Altri due progetti sullo stesso hosting** (stessi casi, non ancora corretti): `sito_26` 3 `curl_close`; `prev_vasche` 3 `finfo_close` + 3 `curl_close`.

### FIX — Le modal non si chiudono più cliccando fuori (29/07/2026)
Un click nella zona scura chiudeva la modal e faceva **perdere il lavoro in corso** (segnalato da Benza: capitava scrivendo uno stato). Ora il click esterno non chiude più; **ESC e la X restano attivi**, perché sono gesti voluti e non uno scivolone del mouse.
- **`assets/js/common.js`** (blocco in fondo, dentro `DOMContentLoaded`): `bootstrap.Modal.Default.backdrop = 'static'` + `keyboard = true`. Un punto solo invece di 27 modal su 12 file, e vale anche per quelle costruite al volo. Il `DOMContentLoaded` serve perché **Bootstrap è caricato nel footer mentre `common.js` sta nell'head**: prima non esisterebbe ancora.
- Rete di sicurezza per le modal già nel markup (anche quelle aperte via `data-bs-toggle`): `data-bs-backdrop="static"` applicato a `.modal:not([data-bs-backdrop])`, così **non** si tocca chi ha una configurazione propria (`modalAlert` di AppModal, che vuole `keyboard="false"`, e `modalPurge` del cestino).
- **Nota sulla verifica:** premere ESC con lo strumento di automazione **non funziona**, il tasto non arriva alla pagina (spia su `keydown`: zero eventi). Va simulato l'evento **sull'elemento della modal**, non su `document`: Bootstrap ascolta lì (`EventHandler.on(this._element, 'keydown.dismiss.bs.modal')`). Fatto così: ESC chiude, X chiude, Annulla chiude, il click esterno no e il testo digitato resta. Provato anche su `clienti.php` per confermare che valga in tutto il gestionale.
- Deploy Aruba: solo `common.js` (FTP, nessun opcache).

### FEATURE — Etichetta suggerita su tutti i tipi di stato (+ sito e preventivatore) (29/07/2026)
Estende a tutti gli stati quanto già fatto il 28/07 per "Ricevuto Ordine": creando uno stato l'etichetta arriva già scritta, perché Benza la ricopiava a mano sempre uguale. Diciture approvate: **Preventivo**, **Ricevuto Ordine** (già attiva), **Pagamento: € <importo>**, **Spedizione**, **Problema**, **Venduto** / **Non venduto**.
- **`assets/js/pages/ordine.js`**: nuova `impostaEtichettaAuto(idCampo, testo)` con la mappa `etichetteAuto` (idCampo → ultimo testo scritto in automatico). **Se l'utente scrive di suo nel campo, l'etichetta non viene più toccata**: senza questo, su Pagamento e Chiusura, che si aggiornano da soli, si cancellerebbe quanto appena digitato. `azzeraEtichettaAuto()` all'apertura del modal rimette il campo sotto controllo automatico.
- **Pagamento:** `aggiornaEtichettaPagamento()` accoda l'importo mentre lo si digita (valore grezzo, con la virgola al posto del punto) e lo formatta all'uscita dal campo → `Pagamento: € 1.500,50`. **`useGrouping: 'always'`**: con `it-IT` il punto delle migliaia sotto le 5 cifre verrebbe omesso (uscito in verifica: dava `1500,50`). Vedi memory `lessons_learned`.
- **Chiusura ordine:** `aggiornaEtichettaChiusura()` segue il radio Venduto/Non venduto e cambia se si cambia esito prima di salvare.
- Listener agganciati **una volta sola** via `dataset` (`etichettaCollegata`), come per la checkbox "Copia da Titolo".
- **Solo in creazione:** in modifica il campo resta quello salvato (verificato su #278, che ha stati cat 4/6/7/8: campo invariato).
- **Allineati i due progetti che scrivono stati nel gestionale** (stesse diciture, così il riepilogo dell'ordine è uniforme a prescindere da chi ha creato lo stato):
  - `sito_26/api/config/gestionale_helper.php`: cat 5 → `Ricevuto Ordine`; cat 6 → `Pagamento: € ` + `number_format($importo, 2, ',', '.')` (stesso testo del gestionale, confrontato voce per voce). Lo stato cat 10 "contatto" resta senza etichetta, come il Generico nel gestionale.
  - `prev_vasche/api/preventivi/invia_mail.php`: cat 4 → `Preventivo`.
  - In entrambi la `INSERT` ha una colonna in più: **`bind_param` ricontato** (`isids` / `isidis` / `isids`) e le tre query validate con un `prepare` reale sul DB (sintassi ok, segnaposto e tipi combacianti). Colonna `or_stati.etichetta` = `varchar(100)` sia su NAS sia in produzione: nessuna migration.

### FIX — Errori JS dai CDN leggibili nel log (`crossorigin`) + SRI su Select2 (29/07/2026)
Nel Log Errori API comparivano 2 voci `frontend/js` con messaggio **`Script error.`**, posizione `:0:0`, nessuno stack, su `ordine.php?cod=12883` (28/07, 07:32 e 07:35). Non è un bug del nostro codice: è il messaggio opaco che il browser sostituisce all'errore vero quando l'eccezione nasce in uno script **cross-origin** privo di `crossorigin`. jQuery e Bootstrap l'attributo l'avevano già (arrivano con `integrity`), **Select2** (`footer.php`) e **TinyMCE** (`ordine.php`) no: l'errore veniva da uno di quei due, molto probabilmente TinyMCE che su quella pagina carica altri pezzi dal suo CDN.
- Aggiunto `crossorigin="anonymous"` a entrambi: da ora un'eccezione dentro quegli script arriva al logger con messaggio, file e riga veri. Verificato prima che i due CDN rispondano `Access-Control-Allow-Origin: *`, altrimenti l'attributo avrebbe **bloccato** gli script.
- **Select2:** aggiunto anche `integrity` (sha384 calcolato sul file della versione fissata `4.1.0-rc.0`, confrontato con due download indipendenti), allineandolo a jQuery/Bootstrap.
- **TinyMCE: niente `integrity`**, l'URL con la API key è un redirect 307 e serve contenuto variabile; un hash fisso bloccherebbe l'editor al primo aggiornamento del CDN.
- Verificato sul NAS dopo la modifica: Select2, TinyMCE e Bootstrap caricano regolarmente, l'editor si inizializza e scrive (il rischio era che un `integrity` sbagliato bloccasse Select2 in tutto il gestionale). Deploy Aruba: `footer.php` + `ordine.php` (opcache reset).

### FEATURE — Log Attività: i filtri restano memorizzati fra le visite (29/07/2026)
Benza guarda quasi sempre il log con la stessa configurazione e doveva ri-selezionarla ogni volta. Ora tendine e checkbox si ricordano, in `localStorage` (stesso approccio già usato in `email.js` con `email_last_cod_dip`; niente DB, quindi nessuna migration).
- **Salvati:** Tipo, Utente e le checkbox Azione, sotto la chiave `log_filtri` (un solo oggetto JSON).
- **NON salvati (voluto):** i due campi data e la ricerca libera. Un intervallo tipo "dal 5 luglio a oggi" invecchia di giorno in giorno, e ritrovarsi una ricerca attiva dopo giorni farebbe pensare che manchino delle voci.
- **Tendina Utente:** si popola con la prima risposta dell'API, quindi il valore salvato resta in `adminDaRipristinare` e viene usato già nella prima query (`$('#filtro-admin').val() || adminDaRipristinare`); quando le voci arrivano, `popolaDropdownAdmin` seleziona il valore. Se quell'utente non esiste più si torna a "Tutti" e si ricarica, altrimenti resterebbe a video un elenco filtrato su qualcuno che non c'è.
- **Nuovo pulsante "Azzera filtri"** (a destra delle checkbox): riporta ai valori di partenza (Creazione + Eliminazione, resto libero) e sovrascrive il salvataggio. Serve proprio perché i filtri ora sono persistenti.
- `localStorage` è protetto da try/catch: se pieno o negato la pagina funziona lo stesso, solo senza memoria.
- Verificato sul NAS: impostata una configurazione (Tipo=Stato, Utente, solo Modifica) + data e ricerca, ricaricata la pagina → tornano solo tendine e checkbox, con date e ricerca vuote, e i risultati corrispondono davvero ai filtri (6 righe, solo Modifica/Stato); pulsante Azzera → 30 righe con i default.

### FEATURE — Log Attività: filtro Azione a checkbox, "Modifica" spenta di default (29/07/2026)
A Benza non interessano le voci "Modifica" (sono in gran parte i nostri update): erano la maggioranza delle righe e coprivano creazioni ed eliminazioni.
- **`log.php`**: rimossa la tendina "Azione"; al suo posto, su una **seconda riga**, tre checkbox `Creazione` / `Modifica` / `Eliminazione` con **Creazione ed Eliminazione spuntate di default**. I filtri rimasti sulla prima riga si ridistribuiscono (Cerca passa a `col-md-4`, così la riga resta piena).
- **`assets/js/pages/log.js`**: le azioni spuntate viaggiano come `azioni=creazione,eliminazione`. Il parametro è **sempre presente** (anche vuoto) per distinguere "nessuna casella spuntata" da "filtro assente".
- **`api/log/index.php`**: nuovo parametro `azioni` (lista) → `l.azione IN (...)` con **whitelist** (`creazione|modifica|eliminazione`) e placeholder generati sul numero di valori validi; lista vuota → `1 = 0` (nessun risultato). Il vecchio `azione` singolo resta valido se `azioni` non è presente.
- **Bug trovato in verifica (race condition), risolto:** spuntando due caselle di fila partono due chiamate e la più lenta arrivava per ultima, mostrando dati non corrispondenti ai filtri (nel test: spunta "solo Modifica" → in tabella comparivano anche Eliminazione). Aggiunto `logState.reqId` progressivo: le risposte superate da una richiesta più recente vengono ignorate (sia in `success` sia in `error`). Con la vecchia tendina il caso era raro, con le checkbox è normale.
- Verificato sul NAS: default senza Modifica (30 risultati contro 48 totali), tutte e tre spuntate → tutte le azioni, solo Modifica → solo Modifica, nessuna spunta → "Nessun risultato". Deploy Aruba: `log.js` (FTP) + `log.php`/`api/log/index.php` (opcache reset).

### FEATURE — Checkbox "Copia da Titolo" nell'etichetta del Nuovo Stato generico (28/07/2026)
Benza spesso ricopia a mano nell'etichetta lo stesso testo del "Titolo breve". Accanto alla label "Etichetta (opzionale)" del modal **Nuovo Stato Generico** c'è ora una checkbox **"Copia da Titolo"**:
- Spuntandola l'etichetta prende il testo del titolo e **lo segue** anche mentre lo si continua a digitare (listener `input` sul campo titolo, agganciato una volta sola via `dataset.copiaCollegata`). Mentre è attiva l'etichetta è `readOnly`, così è chiaro che è agganciata.
- Togliendo la spunta il testo copiato **resta** e torna modificabile a mano.
- Il titolo arriva a 512 caratteri, l'etichetta a 100: la copia viene **troncata** a `maxlength` (verificato: 300 caratteri → 100).
- **Solo in creazione:** `apriModalGenerico()` serve anche per *modificare* le categorie 1/2/3/10, quindi la checkbox viene nascosta quando `isEdit` ed è sempre azzerata all'apertura (etichetta `readOnly = false`). Nessun altro modal la espone (gli altri tipi di stato non hanno il Titolo libero).
- Verificato sul NAS l'intero flusso: comparsa in creazione, copia alla spunta, sincronia mentre si scrive, testo che resta togliendo la spunta, troncamento, e assenza in modifica. Deploy Aruba: `ordine.js` (FTP) + `ordine.php` (opcache reset).

### UI — Costi/Fatturazione: da mobile via il tasto "Torna all'ordine", "Ordine #N" cliccabile (28/07/2026)
Stessa logica già applicata alla scheda ordine (il breadcrumb sostituisce il tasto):
- **`costi.php`**: `Ordine #N` sotto il titolo non è più uno `<span class="text-muted">` ma un link alla scheda ordine (azzurro brand `#00a1e6`, ereditato dal tema). Il tasto "Torna all'ordine" prende `d-none d-md-inline-block`, quindi sparisce sotto i 768px.
- **`fatturazione.php`**: il titolo era un unico `$titoloPagina` ("Fatturazione Ordine #N"), spezzato nel markup per rendere cliccabile la sola parte `Ordine #N` (`$titoloPagina` resta per il `<title>` in `header.php`). Stesso `d-none d-md-inline-block` sul tasto.
- **Card verde del totale centrata su mobile**: il gruppo `#totaleContainer` + tasto passa a `justify-content-center justify-content-md-start`; da mobile, sparito il tasto, la card resta sola e si centra.
- Verificato sul NAS a 500px: link `ordine.php?cod=12851` corretto, tasto `display:none`, card centrata.

### FIX — Bottoni: testo a capo che usciva dal bordo (tutto il gestionale) + ritocchi mobile (28/07/2026)
**Bug sistemico, non del singolo bottone:** in `assets/css/style.css` le tre regole di uniformità altezze includevano `.btn` con **`height` fissa** (38 / 31 / 48px). Qualunque bottone il cui testo andasse a capo (schermo stretto, etichette lunghe, nomi file) si vedeva la 2ª riga uscire sotto il bordo: "Torna agli Ordini", "Torna all'ordine", "Salva" e "Chiudi Ordine" negli header delle modal, pulsanti allegato, ecc.
- **Fix:** i `.btn` sono stati separati dalle regole dei form control e usano ora **`min-height`** (38/31/48px). I bottoni a riga singola restano identici (misurato: btn 38px = input 38px, btn-sm 31px = input-sm 31px, input-group 38px), quelli con testo a capo crescono.
- **NON aggiungere `display:inline-flex` ai `.btn`** (tentato e scartato): il flex mangia lo spazio fra icona e testo nel markup `<i class="fas fa-print"></i> Stampa`, usato in tutto il gestionale, e le icone si attaccherebbero alla parola. Il `display` resta `inline-block`.
- Semplificato `.stato-allegati .btn` in `ordine.php`: l'override di altezza del 24/07 è ora ridondante, restano solo `white-space/word-break/text-align` specifici dei nomi file.
- `fatturazione.php`: `.table-fatture .btn.btn-sm.py-0` passa da `height: 25px` a `min-height` (stesso bug in piccolo).
- **Mobile, scheda ordine:** nascosto il bottone "Torna agli Ordini" sotto i 768px (`d-none d-md-inline-block`): il breadcrumb "Ordini" lì accanto fa già la stessa cosa.
- **Mobile, fatturazione:** l'header aveva 3 elementi su una riga (titolo + totale + bottone) che non ci stavano → `flex-column flex-md-row`, titolo sopra, totale e bottone sulla riga sotto. I campi **Giorno/Mese/Anno** del form nuova fattura erano uno per riga (`col-md-1` = piena larghezza su mobile) → aggiunto `col-4`, ora stanno sulla stessa riga.
- Verificato sul NAS misurando `scrollHeight > clientHeight` su btn/btn-sm/btn-lg con testo a capo (nessun testo fuori) e le altezze dei bottoni a riga singola (invariate). Deploy Aruba: `style.css` (FTP) + `ordine.php`/`fatturazione.php` (opcache reset).
- **Correzione successiva (stesso giorno):** il primo passaggio aveva sistemato solo le regole generali, ma dentro `@media (max-width: 767.98px)` restava `.form-control, .form-select, .input-group-text, .btn { height: 36px }` → **il bug sopravviveva proprio da mobile**, cioè dove si vede (es. "Torna all'ordine" in `costi.php`). Separati anche lì i `.btn` con `min-height: 36px`. Controllo esaustivo con parser dei blocchi CSS su `assets/**/*.css` + `*.php` alla ricerca di `height` su selettori contenenti `btn`: **zero** occorrenze rimaste. Verificato in pagina a viewport 500px: 8 bottoni su 8 senza testo fuori dal bordo.

### FEATURE — Data dello stato nelle etichette + etichetta default su "Ricevuto Ordine" (28/07/2026)
Due richieste di Benza sulla scheda ordine:
- **Data nelle etichette:** nel riepilogo etichette in cima alla timeline (`renderEtichette`, `#etichetteSummary`) ogni badge mostra ora la data dello stato **tra parentesi** dopo il nome, con font più piccolo: `🏷 Ricevuto Ordine (21/07/2026)`. Nuovo `.etichetta-badge .etichetta-data` in `ordine.php` (`font-size: 0.7rem`, weight 400, opacity .85) contro gli `0.8rem` del nome. **Non** toccato il badge dentro la card dello stato: lì la data è già stampata sotto il titolo (`.stato-data`), ripeterla a pochi millimetri sarebbe ridondante.
- **Nuovo helper `formatDateOnly(dateStr)`** (`ordine.js`): estrae `DD/MM/YYYY` da `YYYY-MM-DD HH:MM:SS` con una regex, **senza** passare da `new Date()`. I datetime del progetto sono naive e la conversione sposterebbe il giorno a seconda del fuso (uno stato delle 00:15 diventerebbe il giorno prima). Vedi memory `lessons_learned` (datetime naive). Ritorna stringa vuota su input nullo/malformato, così il badge non mostra parentesi vuote.
- **Etichetta di default:** aprendo il form "Ricevuto Ordine" **in creazione** il campo etichetta è precompilato con `Ricevuto Ordine` (`apriModalRicevutoOrdine`). In **modifica** resta quella salvata, anche se svuotata a mano (nessun ripristino forzato). L'assegnazione è dopo il `form.reset()`, quindi il valore regge.
- Deploy Aruba: `ordine.js` (FTP) + `ordine.php` (opcache reset).

### BRAND — Favicon del gestionale in verde (27/07/2026)
I tre progetti Benza (gestionale, sito, preventivatore vasche) avevano la **stessa** favicon: Benza le vuole distinte. Il gestionale passa da fondo azzurro `#009EDA` a verde **`#00A650`** (B bianca invariata). Rigenerati tutti i formati in `assets/img/`: `favicon.ico` (3 frame 16/32/48), `favicon-16x16`, `favicon-32x32`, `apple-touch-icon` (180), `android-chrome-192x192`, `android-chrome-512x512`.
- **Metodo (ImageMagick):** non un semplice `-modulate` (abbassando la luminosità globale ingrigisce anche la B). Per ogni file: si estrae l'alpha, si passa in grayscale, `-level <gray_fondo>%,100%` normalizza fondo→0 / bianco→255, poi `+level-colors "#00A650",white` ricolora **solo** il fondo preservando antialiasing e B bianca pura, infine si ri-applica l'alpha con `-compose CopyOpacity`. Ogni file è stato ricolorato al suo posto (design per-dimensione preservato, niente downscale dal 512).
- **`header.php`:** aggiunto il `?v=<?= assetVersion(...) ?>` ai 3 `<link rel="icon">` (mancava, contro la convenzione del progetto) — senza cache busting il browser continuerebbe a servire la favicon azzurra dalla cache.
- Verificato su prod: PNG servito con fondo `#00A650`, markup con `?v=` corretto. Deploy Aruba (6 immagini + `header.php` con opcache reset). Gli originali azzurri restano recuperabili da git.

---

## Voci precedenti

- **Manutenzione 11/07 → 24/07/2026** (66 voci): `archive/REFACTORING_LOG_manutenzione_2026-07.md`
- **Refactor completo 2024 → 11/07/2026**: `archive/REFACTORING_LOG_completo.md`
