Files
alva/felsokning/services/gemensam/grind.mjs
T
Claude e73decd3f5 Revisionen: C-1, C-2, M-2, M-3, m-1 och m-2 åtgärdade
De fyra fynd som underminerade bevisvärdet sitter i samma kodväg och
åtgärdas därför tillsammans.

C-1 · Härkomsten sätts av servern. anvandare kommer ur den verifierade
token och tidpunkt ur serverns klocka. Klientens tidsstämpel kastas inte
— vid offline-arbete är den det enda som finns — utan bevaras som
registrerad_tidpunkt bredvid mottagningstiden, så glappet blir synligt i
stället för osynligt.

C-2 · Kvalitetsgrinden flyttad till services/gemensam/grind.mjs och
utvärderas nu på servern vid arende_avslutat. Ett avslut som inte
passerar får 409 med de faktiska hindren. Metodikdatan flyttades till
services/gemensam/metodiker.mjs och klientens metodiker.ts är en typad
återexport, så det finns fortfarande exakt en sanning.

M-2 · Högvoltsspärren är ett hinder. Ett nekande svar på behörighets-
eller spänningsfrihetsfrågan stoppar metodiken i stället för att räknas
som besvarad, och spärrar avslutet på servern. Ett obesvarat
säkerhetskrav spärrar också — tystnad är inte ett ja. Reglerna ligger i
data så nästa farliga metodik bara behöver en rad.

M-3 · Händelser valideras mot schema före skrivning. Loggen är
append-only, så en felaktig post kan aldrig rättas — kontrollen måste
ske innan, efteråt är det för sent för alltid. Okänd typ avvisas.

m-1 · Delningskoden använder förkastningsurval i stället för modulo.
m-2 · Id-kollisioner räknas i stället för att passera tyst.

21 nya tester låser varje regel. Ett skräddarsytt test kräver dessutom
att varje typ i händelseschemat är klassificerad i delningslistan — en
ny typ kan alltså varken läcka eller tappas bort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-05 13:32:00 +00:00

189 lines
7.5 KiB
JavaScript

// Kvalitetsgrinden på serversidan.
//
// Revisionen (docs/QUALITY-AUDIT.md, C-2) fann att grinden bara fanns i
// webbläsaren. Ett ärende kunde därmed avslutas utan evidens genom ett
// anrop till API:t, och rapporten presenterade det som komplett — trots
// att dokumentationen påstod att systemet aldrig kan skriva en slutsats
// ECM inte godkänt.
//
// Modulen utvärderar de obligatoriska raderna direkt mot händelseloggen.
// Klientens ecm.ts har fler rader (råd, rekommendationer, härledd
// evidensnivå per påstående) och fortsätter vara vägledningen i
// gränssnittet. Det som blockerar ett avslut bor här.
//
// Avsiktligt: reglerna är rena funktioner av (händelser, metodik). Ingen
// databasåtkomst, ingen tid, inget slumpmässigt — samma logg ger alltid
// samma utfall, vilket är vad som gör en spärr granskbar.
const UNDANTAG_MOTIVERAT = (h) => typeof h.undantag === "string" && h.undantag.trim().length > 0;
// Frågor som måste besvaras jakande för att arbetet ska få avslutas.
// Speglar SPARRFRAGOR i app/src/felsokning/metodik.ts; ett test jämför
// listorna så de inte kan glida isär.
export const SPARRFRAGOR = {
"hogvolt/sakerhet/behorighet": "Behörighet för högvoltsarbete bekräftad",
"hogvolt/sakerhet/avstangt": "Fordonet spänningslöst enligt tillverkarens rutin",
};
/** Händelser av en viss typ. */
const av = (handelser, typ) => handelser.filter((h) => h.typ === typ);
/**
* Utvärderar avslutsgrinden.
*
* @returns lista med hinder; tom lista betyder att ärendet får avslutas.
*/
export function grinda(handelser, metodik) {
const hinder = [];
const krav = (id, rubrik, ok, detalj) => {
if (!ok) hinder.push(detalj ? { id, rubrik, detalj } : { id, rubrik });
};
krav("objekt", "Fordons-/objektidentifiering verifierad", handelser.some((h) => h.typ === "objekt_identifierat"));
// Historiken får besvaras nekande, men då krävs en motivering — annars
// vore "Nej" ett sätt att hoppa över kontrollen utan att det syns.
const historik = av(handelser, "historik_kontrollerad").at(-1);
krav(
"historik",
"Fordonshistorik kontrollerad eller motiverad",
Boolean(historik) && (historik.kontrollerad || (historik.kommentar ?? "").trim().length > 0),
historik ? "Nekad historikkontroll kräver en motivering." : "Ingen historikkontroll dokumenterad.",
);
for (const [lage, rubrik] of [
["ingaende", "Ingående mätarställning dokumenterad"],
["utgaende", "Utgående mätarställning dokumenterad"],
]) {
krav(`matarstallning_${lage}`, rubrik, av(handelser, "matarstallning").some((h) => h.lage === lage));
}
krav(
"reproducering",
"Symptomverifiering: reproducerat eller dokumenterat ej reproducerbart",
handelser.some((h) => h.typ === "reproducering"),
);
krav("felorsak", "Felorsaksanalys dokumenterad", handelser.some((h) => h.typ === "felorsak"));
// Åtgärdskedjan. En utebliven åtgärd är ett giltigt utfall — men bara
// när skälet står i loggen.
const atgarder = av(handelser, "atgard_utford");
const utfordArbete = atgarder.some((h) => (h.text ?? "").trim().length > 0);
krav(
"atgard",
"Åtgärd dokumenterad eller motiverad",
atgarder.length > 0,
"Varken utförd åtgärd eller skäl till att den uteblev finns dokumenterat.",
);
if (utfordArbete) {
const beslut = av(handelser, "kundbeslut");
krav("kundbeslut", "Kundens besked på åtgärdsförslaget registrerat", beslut.length > 0);
// Hårt fel, inte en varning: arbete utfört trots att kunden avböjt.
krav(
"avbojt_men_utfort",
"Utfört arbete trots avböjt åtgärdsförslag",
!beslut.some((h) => h.utfall === "avbojt"),
"Kunden avböjde förslaget men arbete har dokumenterats som utfört.",
);
krav(
"kvalitetskontroll",
"Kvalitetskontroll genomförd — symptomet verifierat",
handelser.some((h) => h.typ === "kvalitetskontroll"),
);
}
// Metodikens kontroller: varje kontroll ska bära evidens eller ett
// dokumenterat undantag. Det är den rad som gör metodiken bindande.
const utforda = new Map();
for (const h of av(handelser, "kontroll_utford")) utforda.set(`${h.stegId}/${h.kontrollId}`, h);
const saknade = [];
const utanFoto = [];
const foton = av(handelser, "foto").length;
for (const steg of metodik?.steg ?? []) {
for (const kontroll of steg.kontroller ?? []) {
const h = utforda.get(`${steg.id}/${kontroll.id}`);
if (!h) {
saknade.push(`${steg.rubrik} · ${kontroll.text}`);
continue;
}
if (UNDANTAG_MOTIVERAT(h)) continue;
if (!(h.resultat ?? "").trim()) saknade.push(`${steg.rubrik} · ${kontroll.text} (utan resultat)`);
else if (kontroll.krav === "foto") utanFoto.push(kontroll.text);
}
}
krav(
"metodik_kontroller",
"Metodikens kontroller: evidens eller dokumenterat undantag",
saknade.length === 0,
saknade.slice(0, 5).join(" · "),
);
krav("foton", "Foton finns för fotokrävande kontroller", utanFoto.length === 0 || foton >= utanFoto.length);
// Säkerhetsspärrar. Ett nekande svar på en spärrfråga får aldrig
// passera som "besvarad" — se SPARRFRAGOR i klientens metodik.ts.
// Klientens spärr är vägledning; den här är hindret (QUALITET M-2).
for (const [nyckel, text] of Object.entries(SPARRFRAGOR)) {
const [metodikId, stegId, frageId] = nyckel.split("/");
if (metodik?.id !== metodikId) continue;
const svaret = av(handelser, "fraga_besvarad").findLast(
(h) => h.stegId === stegId && h.frageId === frageId,
);
krav(`sparr_${frageId}`, text, svaret?.svar === "Ja", "Säkerhetskravet är inte uppfyllt.");
}
// Evidensnivån måste överstiga E0 — annars avslutas ett ärende utan
// att någonting alls har dokumenterats.
krav(
"evidens",
"Evidensnivå över E0",
handelser.some((h) => ["foto", "video", "matvarde", "observation", "kontroll_utford"].includes(h.typ)),
"Ingen evidens av något slag finns i loggen.",
);
return hinder;
}
/**
* Extra krav som följer av ärendetypen. Reglerna kommer från regelpaketet
* (ECM Knowledge Library) och är alltså data, inte kod.
*/
export function grindaArendetyp(handelser, regelpaket) {
const typ = av(handelser, "arendetyp_satt").at(-1)?.arendetyp;
const regler = regelpaket?.arendetyper?.[typ]?.krav ?? [];
const hinder = [];
const uppfyllt = {
miltal: () => av(handelser, "matarstallning").length > 0,
historik: () => av(handelser, "historik_kontrollerad").some((h) => h.kontrollerad),
foto: () => av(handelser, "foto").length > 0,
claim: () => harReferens(handelser, ["ao_claim", "claim"]),
skadenummer: () => harReferens(handelser, ["ao_skadenummer", "skadenummer"]),
};
for (const regel of regler) {
const kontroll = uppfyllt[regel.typ ?? regel];
// En okänd kravtyp får aldrig tolkas som uppfylld. Ett regelpaket som
// inte går att utvärdera ska stoppa avslutet, inte släppa igenom det.
if (!kontroll) {
hinder.push({ id: `arendetyp_${regel.typ ?? regel}`, rubrik: `Okänt krav i regelpaketet: ${regel.typ ?? regel}` });
} else if (!kontroll()) {
hinder.push({ id: `arendetyp_${regel.typ ?? regel}`, rubrik: regel.rubrik ?? `Krav för ärendetypen: ${regel.typ ?? regel}` });
}
}
return hinder;
}
function harReferens(handelser, nycklar) {
for (const h of av(handelser, "arbetsorder_skannad")) {
for (const f of h.falt ?? []) {
if (nycklar.includes(f.id) && String(f.varde ?? "").trim()) return true;
}
}
return false;
}