longevity: correggiCheckup e misureDellaSessione, sovrascrittura misure

Aggiunge la correzione di un check-up gia' salvato: aggiorna sessione e
misure (cambiate/tolte/nuove), azzera la verifica sulle misure cambiate,
ricalcola i punteggi della sessione e delle successive. Estratto
fuoriRangeDi in misure.ts per non duplicare il calcolo min/max.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
This commit is contained in:
2026-09-27 22:09:15 +02:00
parent 880c928f43
commit 19035e67b0
3 changed files with 387 additions and 18 deletions
+170 -5
View File
@@ -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<string, CampoQuestionario[]>();
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;