diff --git a/apps/platforms/src/lib/longevity/gestionale.ts b/apps/platforms/src/lib/longevity/gestionale.ts new file mode 100644 index 0000000..b48371c --- /dev/null +++ b/apps/platforms/src/lib/longevity/gestionale.ts @@ -0,0 +1,158 @@ +// +// 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, type MisuraIn } from './misure'; +import { calcolaSessione, salvaScore, leggiScore } from './motore'; +import type { CampoQuestionario } from './vista'; + +export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] }; + +/** + * 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 = (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 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 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, + }; + }); +} + +export type MisuraFuoriRange = { + test_id: string; + etichetta: string; + valore: number | null; + unita: string | null; + data: string; +}; + +/** Le misure di un cliente marcate fuori dal range di plausibilità del registro + * (`misure.fuori_range = 1`, scritto da `registraMisure`): quelle che oggi non contano per + * nessun calcolo (`misureValideAlla` le esclude) ma restano da mostrare e, un domani, + * confermare (#7). Più recente prima. */ +export function misureFuoriRange(db: Database.Database, code: string): MisuraFuoriRange[] { + return db.prepare( + `SELECT m.test_id AS test_id, r.etichetta AS etichetta, m.valore_num AS valore, + m.unita AS unita, s.data AS data + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + JOIN registro_test r ON r.test_id = m.test_id + WHERE m.client_code = ? AND m.fuori_range = 1 + ORDER BY s.data DESC, s.id DESC, m.id DESC` + ).all(code) as MisuraFuoriRange[]; +}