longevity: gestionale.ts — check-up manuale, sessioni e misure fuori range
campiDelCheckup raggruppa i test fisici attivi per asse nell'ordine dei pesi (letto dal dato, come contenitoriDi in vista.ts). salvaCheckup segue lo stesso schema di salvaDalForm: valida tutto (misure presenti, solo test f_*, cliente esistente) prima di aprire qualunque cosa, poi un'unica transazione — apertura sessione tipo 'checkup', registraMisure fonte 'manuale', calcolaSessione + salvaScore — perché il congelamento dei punteggi è parte del salvataggio, non un passo lasciato a chi legge dopo. sessioniDi e misureFuoriRange sono le due letture per la scheda cliente del gestionale. Refs #2 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, CampoQuestionario[]>();
|
||||||
|
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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user