diff --git a/apps/platforms/src/lib/longevity/gestionale.ts b/apps/platforms/src/lib/longevity/gestionale.ts index 84abcb5..16b98d4 100644 --- a/apps/platforms/src/lib/longevity/gestionale.ts +++ b/apps/platforms/src/lib/longevity/gestionale.ts @@ -7,12 +7,23 @@ // altro modulo, un'altra chiamata. import type Database from 'better-sqlite3'; import { testAttivi, esisteTest, MODEL_VERSION } from './registro'; -import { apriSessione, registraMisure, type MisuraIn } from './misure'; +import { apriSessione, registraMisure, fuoriRangeDi, type MisuraIn, type Fonte } from './misure'; import { calcolaSessione, salvaScore, leggiScore } from './motore'; import type { CampoQuestionario } from './vista'; export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] }; +/** L'ordine degli assi dai pesi (livello 'asse'), nell'ordine in cui compaiono per la prima + * volta — stesso criterio di `contenitoriDi` in vista.ts. Estratto qui perché lo usano sia + * `campiDelCheckup` sia `misureDellaSessione` (#12): due elenchi di assi che potrebbero + * divergere sarebbero peggio di uno solo. */ +function ordineAssiDaiPesi(db: Database.Database): string[] { + return (db.prepare( + `SELECT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse' + GROUP BY contenitore ORDER BY MIN(rowid)` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); +} + /** * I campi del modulo di check-up alla data indicata: solo i test_id fisici (prefisso `f_`, * per lo stesso motivo per cui `campiDelQuestionario` in vista.ts prende solo `q_*`), @@ -26,10 +37,7 @@ export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] }; export function campiDelCheckup(db: Database.Database, alla: string): GruppoCheckup[] { const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_')); - const ordineAssi = (db.prepare( - `SELECT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse' - GROUP BY contenitore ORDER BY MIN(rowid)` - ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + const ordineAssi = ordineAssiDaiPesi(db); const perAsse = new Map(); for (const v of fisici) { @@ -102,6 +110,163 @@ export function salvaCheckup( return { ok: true, sessioneId, fuoriRange }; } +export type MisuraDiSessione = { + id: number; test_id: string; etichetta: string; + valore_num: number | null; valore_txt: string | null; unita: string | null; + fonte: Fonte; fuori_range: number; verifica: 'confermata' | 'scartata' | null; +}; + +export type SessioneDettaglio = { + id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario'; + operatore: string | null; misure: MisuraDiSessione[]; +}; + +/** + * Il dettaglio di una sessione con le sue misure (#12, pagina di dettaglio e precompilazione + * della correzione): null se la sessione non esiste. Le misure sono ordinate per asse — + * stesso criterio di `campiDelCheckup`, via `ordineAssiDaiPesi` — poi per test_id, così il + * dettaglio di un check-up si legge negli stessi raggruppamenti del modulo che lo ha + * compilato. + */ +export function misureDellaSessione(db: Database.Database, sessioneId: number): SessioneDettaglio | null { + const sess = db.prepare( + `SELECT id, client_code, data, tipo, operatore FROM sessioni WHERE id = ?` + ).get(sessioneId) as + { id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null } + | undefined; + if (!sess) return null; + + const righe = db.prepare( + `SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta, r.asse AS asse, + m.valore_num AS valore_num, m.valore_txt AS valore_txt, m.unita AS unita, + m.fonte AS fonte, m.fuori_range AS fuori_range, m.verifica AS verifica + FROM misure m JOIN registro_test r ON r.test_id = m.test_id + WHERE m.sessione_id = ?` + ).all(sessioneId) as (MisuraDiSessione & { asse: string | null })[]; + + const posizioneAsse = new Map(ordineAssiDaiPesi(db).map((a, i) => [a, i])); + righe.sort((a, b) => { + const pa = posizioneAsse.get(a.asse ?? '') ?? Infinity; + const pb = posizioneAsse.get(b.asse ?? '') ?? Infinity; + return pa - pb || a.test_id.localeCompare(b.test_id); + }); + + const misure: MisuraDiSessione[] = righe.map(({ asse: _asse, ...m }) => m); + return { id: sess.id, client_code: sess.client_code, data: sess.data, tipo: sess.tipo, operatore: sess.operatore, misure }; +} + +/** + * Corregge un check-up già salvato (#12): stesse validazioni di `salvaCheckup`, poi + * un'unica transazione che aggiorna la sessione e le sue misure e ricalcola i punteggi. + * Nessuno storico delle misure: la correzione SOVRASCRIVE (decisione di Adriano, 27/09/2026) + * — una misura cambiata perde la verifica precedente (il trainer deve rivederla sul nuovo + * valore, non su quello vecchio), una tolta dal form si cancella, una nuova si inserisce + * con `registraMisure` (fonte 'manuale': resta l'unico INSERT di `misure`, vedi misure.ts). + * + * Rifiuta PRIMA di scrivere: nessuna misura, un test_id fuori dal registro o non fisico + * (stesso criterio di `salvaCheckup`), una sessione inesistente, o una sessione che non è + * un check-up (un questionario non si corregge da qui). + */ +export function correggiCheckup( + db: Database.Database, + input: { + sessioneId: number; data: string; eta: number | null; + operatore: string; misure: MisuraIn[]; + } +): EsitoCheckup { + if (input.misure.length === 0) { + return { ok: false, errore: 'Nessuna misura da salvare.' }; + } + for (const m of input.misure) { + if (!m.test_id.startsWith('f_')) { + return { ok: false, errore: `Il check-up accetta solo test fisici: ${m.test_id}` }; + } + if (!esisteTest(db, m.test_id)) { + return { ok: false, errore: `Test non riconosciuto: ${m.test_id}` }; + } + } + + const sessione = db.prepare( + `SELECT id, client_code, data, tipo FROM sessioni WHERE id = ?` + ).get(input.sessioneId) as + { id: number; client_code: string; data: string; tipo: 'checkup' | 'questionario' } | undefined; + if (!sessione) { + return { ok: false, errore: `Sessione inesistente: ${input.sessioneId}` }; + } + if (sessione.tipo !== 'checkup') { + return { ok: false, errore: 'Solo un check-up si corregge da qui, non un questionario.' }; + } + + // Solo le misure fisiche (f_*) della sessione entrano nel confronto: di tipo checkup non + // ce ne sono altre, ma per sicurezza (e per lo stesso motivo di salvaCheckup) una misura + // non f_* eventualmente presente non si tocca. + const esistenti = (db.prepare( + `SELECT id, test_id, valore_num, unita FROM misure WHERE sessione_id = ?` + ).all(input.sessioneId) as { id: number; test_id: string; valore_num: number | null; unita: string | null }[]) + .filter((r) => r.test_id.startsWith('f_')); + const esistentiPerTestId = new Map(esistenti.map((r) => [r.test_id, r])); + const nuoviPerTestId = new Map(input.misure.map((m) => [m.test_id, m])); + + const fuoriRange: string[] = []; + const daInserire: MisuraIn[] = []; + + const tx = db.transaction(() => { + db.prepare( + `UPDATE sessioni SET data = ?, eta_alla_data = ?, operatore = ? WHERE id = ?` + ).run(input.data, input.eta ?? null, input.operatore, input.sessioneId); + + const tuttiTestId = new Set([...esistentiPerTestId.keys(), ...nuoviPerTestId.keys()]); + for (const testId of tuttiTestId) { + const vecchia = esistentiPerTestId.get(testId); + const nuova = nuoviPerTestId.get(testId); + + if (vecchia && !nuova) { + db.prepare(`DELETE FROM misure WHERE id = ?`).run(vecchia.id); // tolto dal form + continue; + } + if (!vecchia && nuova) { + daInserire.push(nuova); // nuovo campo compilato: un solo INSERT via registraMisure + continue; + } + if (vecchia && nuova) { + const invariato = (vecchia.valore_num ?? null) === (nuova.valore_num ?? null); + if (invariato) continue; // la riga resta com'è: mantiene verifica/verificata_* + + const fuori = fuoriRangeDi(db, testId, nuova.valore_num); + if (fuori) fuoriRange.push(testId); + db.prepare( + `UPDATE misure SET valore_num = ?, unita = ?, fuori_range = ?, + verifica = NULL, verificata_da = NULL, verificata_at = NULL + WHERE id = ?` + ).run(nuova.valore_num ?? null, nuova.unita ?? null, fuori ? 1 : 0, vecchia.id); + } + } + + if (daInserire.length > 0) { + const { fuoriRange: fuoriNuove } = registraMisure(db, input.sessioneId, 'manuale', daInserire); + fuoriRange.push(...fuoriNuove); + } + + // Ricalcolo: la sessione stessa più ogni sessione del cliente dalla posizione minima fra + // quella vecchia e quella nuova (stesso criterio di verificaMisura, misure.ts) — l'id + // non cambia con una correzione, solo la data può spostare la sessione: il minimo copre + // entrambi i lati. + const dataMin = input.data < sessione.data ? input.data : sessione.data; + const daRicalcolare = db.prepare( + `SELECT id FROM sessioni + WHERE client_code = ? AND (data > ? OR (data = ? AND id >= ?)) + ORDER BY data ASC, id ASC` + ).all(sessione.client_code, dataMin, dataMin, input.sessioneId) as { id: number }[]; + + for (const s of daRicalcolare) { + salvaScore(db, s.id, calcolaSessione(db, s.id)); + } + }); + tx(); + + return { ok: true, sessioneId: input.sessioneId, fuoriRange }; +} + export type SessioneElenco = { id: number; data: string; diff --git a/apps/platforms/src/lib/longevity/misure.ts b/apps/platforms/src/lib/longevity/misure.ts index 0c378e3..0b67a15 100644 --- a/apps/platforms/src/lib/longevity/misure.ts +++ b/apps/platforms/src/lib/longevity/misure.ts @@ -25,6 +25,19 @@ export function apriSessione( return Number(r.lastInsertRowid); } +/** + * True se un valore numerico esce dal range di plausibilità del test (registro_test). + * Unico punto di calcolo: usato da `registraMisure` all'inserimento e da `correggiCheckup` + * (gestionale.ts, #12) alla correzione, così il confronto min/max non vive in due posti. + */ +export function fuoriRangeDi(db: Database.Database, testId: string, valoreNum: number | undefined): boolean { + if (valoreNum === undefined) return false; + const r = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`).get(testId) as + { range_min: number | null; range_max: number | null }; + return (r.range_min !== null && valoreNum < r.range_min) || + (r.range_max !== null && valoreNum > r.range_max); +} + /** * L'unico punto in cui si scrive nella tabella `misure`. * Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una @@ -47,7 +60,6 @@ export function registraMisure( } } - const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`); const ins = db.prepare( `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` @@ -56,17 +68,10 @@ export function registraMisure( const tx = db.transaction(() => { for (const m of misure) { - const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null }; - let fuori = 0; - if (m.valore_num !== undefined) { - if ((r.range_min !== null && m.valore_num < r.range_min) || - (r.range_max !== null && m.valore_num > r.range_max)) { - fuori = 1; - fuoriRange.push(m.test_id); - } - } + const fuori = fuoriRangeDi(db, m.test_id, m.valore_num); + if (fuori) fuoriRange.push(m.test_id); ins.run(sessioneId, sess.client_code, m.test_id, - m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori); + m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori ? 1 : 0); } }); tx(); diff --git a/apps/platforms/tests/longevity/gestionale.test.ts b/apps/platforms/tests/longevity/gestionale.test.ts index bbfb897..fce24ed 100644 --- a/apps/platforms/tests/longevity/gestionale.test.ts +++ b/apps/platforms/tests/longevity/gestionale.test.ts @@ -9,8 +9,9 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; -import { salvaCheckup, sessioniDi } from '../../src/lib/longevity/gestionale'; -import { misureDaVerificare } from '../../src/lib/longevity/misure'; +import { salvaCheckup, sessioniDi, correggiCheckup, misureDellaSessione } from '../../src/lib/longevity/gestionale'; +import { misureDaVerificare, verificaMisura } from '../../src/lib/longevity/misure'; +import { leggiScore } from '../../src/lib/longevity/motore'; import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista'; import { creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo, @@ -170,6 +171,204 @@ describe('sessioniDi', () => { }); }); +describe('misureDellaSessione', () => { + it('null se la sessione non esiste; altrimenti le misure ordinate per asse come campiDelCheckup', () => { + const db = dbPronto(); + expect(misureDellaSessione(db, 999999)).toBeNull(); + + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }, { test_id: 'f_handgrip_kg', valore_num: 45 }], + }); + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + + const dettaglio = misureDellaSessione(db, esito.sessioneId); + expect(dettaglio?.tipo).toBe('checkup'); + expect(dettaglio?.operatore).toBe('nicola'); + // 'Forza & Struttura' precede 'Composizione Corporea' nei pesi (PESI_ASSE, registro.ts): + // stesso ordine che userebbe campiDelCheckup per raggruppare il modulo. + expect(dettaglio?.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg', 'f_peso_kg']); + expect(dettaglio?.misure[1]).toMatchObject({ etichetta: 'Peso corporeo', valore_num: 80, verifica: null }); + }); +}); + +describe('correggiCheckup: sovrascrittura, nessuno storico delle misure (#12)', () => { + it('corregge un valore: misura aggiornata, score della sessione ricalcolato; la successiva viene ricalcolata, la precedente resta intatta', () => { + const db = dbPronto(); + const precedente = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + const target = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-10', eta: 38, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 82 }], + }); + const successiva = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-20', eta: 38, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 83 }], + }); + expect(precedente.ok && target.ok && successiva.ok).toBe(true); + if (!precedente.ok || !target.ok || !successiva.ok) throw new Error('setup fallito'); + + const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n; + const primaPrecedente = contaScore(precedente.sessioneId); + const primaTarget = contaScore(target.sessioneId); + const primaSuccessiva = contaScore(successiva.sessioneId); + + const esito = correggiCheckup(db, { + sessioneId: target.sessioneId, data: '2026-09-10', eta: 38, operatore: 'donata', + misure: [{ test_id: 'f_peso_kg', valore_num: 90 }], + }); + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + expect(esito.sessioneId).toBe(target.sessioneId); + + const riga = db.prepare( + `SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` + ).get(target.sessioneId) as { valore_num: number }; + expect(riga.valore_num).toBe(90); + + const sessioneAggiornata = db.prepare(`SELECT operatore FROM sessioni WHERE id = ?`).get(target.sessioneId) as { operatore: string }; + expect(sessioneAggiornata.operatore).toBe('donata'); // l'operatore diventa chi corregge + + // score ricalcolato (righe nuove in append): target e successiva sì, precedente no. + expect(contaScore(target.sessioneId)).toBeGreaterThan(primaTarget); + expect(contaScore(successiva.sessioneId)).toBeGreaterThan(primaSuccessiva); + expect(contaScore(precedente.sessioneId)).toBe(primaPrecedente); + }); + + it('campo svuotato cancella la misura; campo nuovo la inserisce con fonte manuale', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + + const corretto = correggiCheckup(db, { + sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola', + // f_peso_kg non c'è più (campo svuotato nel form), f_handgrip_kg è nuovo. + misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }], + }); + expect(corretto.ok).toBe(true); + + const righe = db.prepare(`SELECT test_id, fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as + { test_id: string; fonte: string }[]; + expect(righe).toEqual([{ test_id: 'f_handgrip_kg', fonte: 'manuale' }]); + }); + + it('valore invariato conserva la verifica; valore cambiato azzera la verifica e ricalcola fuori_range', () => { + const db = dbPronto(); + const esito = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', + // 500 kg è oltre il range di plausibilità (max 250): fuori range da subito. + misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 45 }], + }); + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + + const misuraPeso = db.prepare( + `SELECT id FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` + ).get(esito.sessioneId) as { id: number }; + const conferma = verificaMisura(db, { misuraId: misuraPeso.id, esito: 'confermata', operatore: 'nicola' }); + expect(conferma.ok).toBe(true); + + // Correzione: f_peso_kg invariato (500), f_handgrip_kg cambiato (45 -> 50). + const corretto = correggiCheckup(db, { + sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 50 }], + }); + expect(corretto.ok).toBe(true); + + const pesoDopo = db.prepare( + `SELECT verifica, fuori_range FROM misure WHERE id = ?` + ).get(misuraPeso.id) as { verifica: string | null; fuori_range: number }; + expect(pesoDopo.verifica).toBe('confermata'); // invariato: la verifica resta + expect(pesoDopo.fuori_range).toBe(1); + + const handgripDopo = db.prepare( + `SELECT valore_num, fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_handgrip_kg'` + ).get(esito.sessioneId) as { valore_num: number; fuori_range: number }; + expect(handgripDopo.valore_num).toBe(50); + expect(handgripDopo.fuori_range).toBe(0); // 50 è nel range (0-100): ricalcolato, non ereditato + }); + + it('cambio data che sposta la sessione dopo un\'altra: entrambe ricalcolate', () => { + const db = dbPronto(); + const a = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + const b = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-15', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }], + }); + expect(a.ok && b.ok).toBe(true); + if (!a.ok || !b.ok) throw new Error('setup fallito'); + + const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n; + const primaA = contaScore(a.sessioneId); + const primaB = contaScore(b.sessioneId); + + // Sposta A dopo B: la posizione minima (min fra vecchia e nuova data di A) resta + // 2026-09-01, quindi la ricalcolata copre sia A sia B. + const esito = correggiCheckup(db, { + sessioneId: a.sessioneId, data: '2026-09-20', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + expect(esito.ok).toBe(true); + + expect(contaScore(a.sessioneId)).toBeGreaterThan(primaA); + expect(contaScore(b.sessioneId)).toBeGreaterThan(primaB); + }); + + it('rifiuti: sessione inesistente, sessione questionario, nessuna misura, test non f_* — nessuna scrittura', () => { + const db = dbPronto(); + + const inesistente = correggiCheckup(db, { + sessioneId: 999999, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + expect(inesistente.ok).toBe(false); + + const q = salvaDalForm(db, { + client_code: 'ISL-0001', data: '2026-09-01', eta: 38, + risposte: { q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1 }, + consensoSanitario: 'Si', liberi: {}, + }); + expect(q.ok).toBe(true); + if (!q.ok) throw new Error('setup questionario fallito'); + const suQuestionario = correggiCheckup(db, { + sessioneId: q.sessioneId, data: '2026-09-01', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + expect(suQuestionario.ok).toBe(false); + + const checkup = salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', + misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], + }); + expect(checkup.ok).toBe(true); + if (!checkup.ok) throw new Error('setup checkup fallito'); + + const nessunaMisura = correggiCheckup(db, { + sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [], + }); + expect(nessunaMisura.ok).toBe(false); + + const nonFisico = correggiCheckup(db, { + sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }], + }); + expect(nonFisico.ok).toBe(false); + + // Nessuna delle chiamate rifiutate ha scritto: la misura del checkup è ancora quella originale. + const rigaFinale = db.prepare( + `SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` + ).get(checkup.sessioneId) as { valore_num: number }; + expect(rigaFinale.valore_num).toBe(80); + }); +}); + describe('anagrafica per il gestionale, versioni parametrizzate (testabili su :memory:, come codicePerUtente accanto a codicePerUtenteLoggato)', () => { it('elencoClientiParam ordina per cognome poi nome, e dice chi ha già un accesso', () => { const id = createIdentityDb(':memory:');