// // Il gestionale del trainer (#2, unità 2a): il modulo del check-up manuale e le letture // sulle sessioni di un cliente. Solo il database clinico (vedi db.ts) — mai quello // anagrafico, e mai le due parole riservate a quel confine insieme in un file che non sia // anagrafica.ts o db.ts (tests/longevity/anagrafica.test.ts lo verifica): un cliente qui è // solo un client_code, mai un nome. Chi ha bisogno del nome lo chiede ad anagrafica.ts, un // altro modulo, un'altra chiamata. import type Database from 'better-sqlite3'; import { testAttivi, esisteTest, MODEL_VERSION } from './registro'; 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_*`), * raggruppati per asse — la sezione del modulo cartaceo che il trainer compila — * nell'ordine in cui l'asse compare nei pesi (`pesi`, livello 'asse'): stesso criterio di * `contenitoriDi` in vista.ts, letto dal dato invece di un elenco duplicato qui. Un asse * fisico che non fosse fra i contenitori pesati (non dovrebbe succedere: FISICI in * registro.ts usa solo assi che PESI_ASSE dichiara) chiude in coda, nell'ordine in cui * compare per la prima volta nel registro. */ export function campiDelCheckup(db: Database.Database, alla: string): GruppoCheckup[] { const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_')); const ordineAssi = ordineAssiDaiPesi(db); const perAsse = new Map(); for (const v of fisici) { const asse = v.asse ?? '(senza asse)'; const campo: CampoQuestionario = { id: v.test_id, etichetta: v.etichetta, tipo: v.tipo_valore, unita: v.unita, min: v.range_min, max: v.range_max, sottoDominio: v.sotto_dominio, }; if (!perAsse.has(asse)) perAsse.set(asse, []); perAsse.get(asse)!.push(campo); } const ordine = ordineAssi.filter((a) => perAsse.has(a)); for (const a of perAsse.keys()) if (!ordine.includes(a)) ordine.push(a); return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! })); } export type EsitoCheckup = | { ok: true; sessioneId: number; fuoriRange: string[] } | { ok: false; errore: string }; /** * Salva un check-up manuale del trainer: stesso schema di `salvaDalForm` (vista.ts) — * validare TUTTO prima di scrivere qualunque cosa, poi un'unica transazione (apertura * sessione, misure, calcolo e congelamento dei punteggi) perché il congelamento è parte del * salvataggio, non un passo lasciato a chi legge dopo (lo stesso difetto già corretto una * volta in questo modulo, vedi il commento su `salvaDalForm`). * * Rifiuta PRIMA di aprire la sessione, quindi senza lasciare righe orfane in `sessioni`: * nessuna misura, un test_id fuori dal registro, o un test_id che non è un test fisico * (`f_*` — un `q_*` qui è un errore del chiamante, il check-up non è il questionario), o un * client_code che non ha un soggetto in longevity. */ export function salvaCheckup( db: Database.Database, input: { client_code: string; 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 soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.client_code); if (!soggetto) { return { ok: false, errore: `Cliente inesistente: ${input.client_code}` }; } const tx = db.transaction(() => { const sessioneId = apriSessione(db, { client_code: input.client_code, data: input.data, tipo: 'checkup', eta_alla_data: input.eta ?? undefined, operatore: input.operatore, }); const { fuoriRange } = registraMisure(db, sessioneId, 'manuale', input.misure); // Il calcolo e il congelamento sono parte del salvataggio: vedi salvaDalForm (vista.ts). salvaScore(db, sessioneId, calcolaSessione(db, sessioneId)); return { sessioneId, fuoriRange }; }); const { sessioneId, fuoriRange } = tx(); 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; tipo: 'checkup' | 'questionario'; operatore: string | null; numeroMisure: number; /** La Fitness Age congelata di quella sessione, `null` se insufficiente o non calcolata — * mai un ricalcolo: legge `leggiScore`, come `refertoDi` in vista.ts. */ fitnessAge: number | null; }; /** Le sessioni di un cliente, più recente prima (data DESC, a parità id DESC — stesso * criterio di `misureValideAlla` e `leggiScore` nel motore). */ export function sessioniDi(db: Database.Database, code: string): SessioneElenco[] { const righe = db.prepare( `SELECT s.id AS id, s.data AS data, s.tipo AS tipo, s.operatore AS operatore, (SELECT COUNT(*) FROM misure m WHERE m.sessione_id = s.id) AS numero_misure FROM sessioni s WHERE s.client_code = ? ORDER BY s.data DESC, s.id DESC` ).all(code) as { id: number; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null; numero_misure: number }[]; return righe.map((r) => { const punteggi = leggiScore(db, r.id); const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null; return { id: r.id, data: r.data, tipo: r.tipo, operatore: r.operatore, numeroMisure: r.numero_misure, fitnessAge, }; }); }