Grinden talar organisationens språk — engelska som standard

Kvalitetsgrinden formulerade sina hinder på svenska, hårdkodat. I en
produkt där engelska är standardspråket betyder det att en spärr kan
neka ett avslut på ett språk teknikern inte läser. Det är en spärr utan
väg förbi: teknikern ser att systemet vägrar men inte varför, och den
enda utvägen blir att ringa någon. Det är då regeln slutar vara en regel
och blir ett hinder man lär sig kringgå.

Varje hinder bär nu både nyckel och färdig text. Nyckeln därför att en
klient som byter språk ska kunna rendera om utan att fråga servern;
texten därför att ett hinder som hamnar i en logg eller en PDF måste gå
att läsa utan katalogen. Hindrets id och nyckel är språkoberoende — det
är bara texten som byter.

Språket är organisationens, inte användarens. En verkstad i Tyskland med
en polsk tekniker ska ha ett gemensamt dokumentationsspråk; rapporten
ska inte byta språk beroende på vem som råkade stänga ärendet. Det
lagras i organisationens inställningar, och en felstavad landskod blir
engelska i stället för ett fel — språkinställningen är en etikett och
ska inte hindra någon från att spara sina objekttyper.

SPARRFRAGOR bär nu nycklar i stället för svensk text. Testet som jämför
serverns lista med klientens jämför nycklar, så listorna kan fortfarande
inte glida isär.

Två tester prövade svenska ord i grindens utdata och gick sönder, vilket
var rätt av dem. De prövar nu nyckeln — texten är översatt och byter
språk med organisationen, och att pröva svenska ord hade gjort dem till
tester av vilket språk som råkar vara standard. Ett nytt test prövar det
som faktiskt betyder något: samma id, samma nyckel, olika text.

Integrationstestet följer språket hela vägen — organisationens
inställning, genom servern, ut i det 409-svar som nekar avslutet: engelska
som standard, tyska efter byte, engelska igen vid okänd kod.

582 tester, 186 integrationskontroller mot riktig Postgres, genomgång och
portalspärr gröna.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 16:36:53 +00:00
parent 995858174d
commit be10704349
15 changed files with 243 additions and 65 deletions
+65 -55
View File
@@ -16,6 +16,7 @@
// samma utfall, vilket är vad som gör en spärr granskbar.
import { granskaSlutsats } from "./motivering.mjs";
import { STANDARD, t } from "./sprak/index.mjs";
const UNDANTAG_MOTIVERAT = (h) => typeof h.undantag === "string" && h.undantag.trim().length > 0;
@@ -23,8 +24,8 @@ const UNDANTAG_MOTIVERAT = (h) => typeof h.undantag === "string" && h.undantag.t
// 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",
"hogvolt/sakerhet/behorighet": "grind.hogvolt.behorighet",
"hogvolt/sakerhet/avstangt": "grind.hogvolt.spanningslos",
};
/** Händelser av en viss typ. */
@@ -35,22 +36,42 @@ const av = (handelser, typ) => handelser.filter((h) => h.typ === typ);
*
* @returns lista med hinder; tom lista betyder att ärendet får avslutas.
*/
export function grinda(handelser, metodik) {
export function grinda(handelser, metodik, sprak = STANDARD) {
const hinder = [];
const krav = (id, rubrik, ok, detalj) => {
if (!ok) hinder.push(detalj ? { id, rubrik, detalj } : { id, rubrik });
// Hindret bär både nyckeln och den färdiga texten. Nyckeln därför att
// en klient som byter språk ska kunna rendera om utan att fråga
// servern; texten därför att ett hinder som passerar genom en logg
// eller en PDF måste vara läsbart utan katalogen.
const krav = (id, nyckel, ok, detaljNyckel, variabler) => {
if (ok) return;
const post = { id, nyckel, rubrik: t(sprak, nyckel, variabler) };
if (detaljNyckel) {
post.detaljNyckel = detaljNyckel;
post.detalj = t(sprak, detaljNyckel, variabler);
}
hinder.push(post);
};
krav("objekt", "Fordons-/objektidentifiering verifierad", handelser.some((h) => h.typ === "objekt_identifierat"));
// Fritext som kommer ur data, inte ur katalogen — namnen på metodikens
// kontroller. De står på metodikens språk och kan inte översättas här.
const kravMedData = (id, nyckel, ok, detalj) => {
if (ok) return;
const post = { id, nyckel, rubrik: t(sprak, nyckel) };
if (detalj) post.detalj = detalj;
hinder.push(post);
};
krav("objekt", "grind.objekt", 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",
"grind.historik",
Boolean(historik) && (historik.kontrollerad || (historik.kommentar ?? "").trim().length > 0),
historik ? "Nekad historikkontroll kräver en motivering." : "Ingen historikkontroll dokumenterad.",
historik ? "grind.historik.nekad" : "grind.historik.saknas",
);
// Mätarställningen ska vara FOTOGRAFERAD, inte bara inskriven.
@@ -71,9 +92,9 @@ export function grinda(handelser, metodik) {
// som sitter oåtkomligt, en spegling som inte går att fota bort. Men
// undantaget kräver en motivering, av samma skäl som ett nekat
// historiksvar gör det.
for (const [lage, rubrik] of [
["ingaende", "Ingående mätarställning fotograferad"],
["utgaende", "Utgående mätarställning fotograferad"],
for (const [lage, nyckel] of [
["ingaende", "grind.matarstallning.ingaende"],
["utgaende", "grind.matarstallning.utgaende"],
]) {
const poster = av(handelser, "matarstallning").filter((h) => h.lage === lage);
const dokumenterad = poster.length > 0;
@@ -81,50 +102,35 @@ export function grinda(handelser, metodik) {
const motiverat = poster.some((h) => (h.undantag ?? "").trim().length > 0);
krav(
`matarstallning_${lage}`,
rubrik,
nyckel,
dokumenterad && (fotograferad || motiverat),
!dokumenterad
? "Ingen mätarställning dokumenterad."
: "Mätarställningen är inskriven men inte fotograferad. Fotografera mätaren eller ange varför det inte går.",
dokumenterad ? "grind.matarstallning.ej_foto" : "grind.matarstallning.saknas",
);
}
krav(
"reproducering",
"Symptomverifiering: reproducerat eller dokumenterat ej reproducerbart",
handelser.some((h) => h.typ === "reproducering"),
);
krav("reproducering", "grind.reproducering", handelser.some((h) => h.typ === "reproducering"));
krav("felorsak", "Felorsaksanalys dokumenterad", handelser.some((h) => h.typ === "felorsak"));
krav("felorsak", "grind.felorsak", 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.utford === true);
krav(
"atgard",
"Åtgärd dokumenterad eller motiverad",
atgarder.length > 0,
"Varken utförd åtgärd eller skäl till att den uteblev finns dokumenterat.",
);
krav("atgard", "grind.atgard", atgarder.length > 0, "grind.atgard.saknas");
if (utfordArbete) {
const beslut = av(handelser, "kundbeslut");
krav("kundbeslut", "Kundens besked på åtgärdsförslaget registrerat", beslut.length > 0);
krav("kundbeslut", "grind.kundbeslut", 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",
"grind.kundbeslut.avbojt",
!beslut.some((h) => h.beslut === "avbojt"),
"Kunden avböjde förslaget men arbete har dokumenterats som utfört.",
"grind.kundbeslut.avbojt.detalj",
);
krav(
"kvalitetskontroll",
"Kvalitetskontroll genomförd — symptomet verifierat",
handelser.some((h) => h.typ === "kvalitetskontroll"),
);
krav("kvalitetskontroll", "grind.kvalitetskontroll", handelser.some((h) => h.typ === "kvalitetskontroll"));
}
// Metodikens kontroller: varje kontroll ska bära evidens eller ett
@@ -156,18 +162,11 @@ export function grinda(handelser, metodik) {
if (!(h.resultat ?? "").trim()) saknade.push(`${steg.rubrik} · ${kontroll.text} (utan resultat)`);
}
}
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",
foton >= fotokravande.length,
fotokravande.length ? `${fotokravande.length} kontroller kräver foto, ${foton} foton i loggen.` : undefined,
);
kravMedData("metodik_kontroller", "grind.kontroller", saknade.length === 0, saknade.slice(0, 5).join(" · "));
krav("foton", "grind.foton", foton >= fotokravande.length, fotokravande.length ? "grind.foton.detalj" : undefined, {
kontroller: fotokravande.length,
foton,
});
// ALVA-RULE-200 · Slutsatsen. Ett ärende stängs aldrig utan att
// teknikern lämnat ett varför — motiveringen som knyter slutsatsen
@@ -185,22 +184,22 @@ export function grinda(handelser, metodik) {
// 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("/");
for (const [fraga, nyckel] of Object.entries(SPARRFRAGOR)) {
const [metodikId, stegId, frageId] = fraga.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.");
krav(`sparr_${frageId}`, nyckel, svaret?.svar === "Ja", "grind.sparr.ej_uppfyllt");
}
// Evidensnivån måste överstiga E0 — annars avslutas ett ärende utan
// att någonting alls har dokumenterats.
krav(
"evidens",
"Evidensnivå över E0",
"grind.evidens",
handelser.some((h) => ["foto", "video", "matvarde", "observation", "kontroll_utford"].includes(h.typ)),
"Ingen evidens av något slag finns i loggen.",
"grind.evidens.saknas",
);
return hinder;
@@ -210,7 +209,7 @@ export function grinda(handelser, metodik) {
* 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) {
export function grindaArendetyp(handelser, regelpaket, sprak = STANDARD) {
const typ = av(handelser, "arendetyp_satt").at(-1)?.arendetyp;
const regler = regelpaket?.arendetyper?.[typ]?.krav ?? [];
const hinder = [];
@@ -227,10 +226,21 @@ export function grindaArendetyp(handelser, regelpaket) {
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.
const krav = regel.typ ?? regel;
if (!kontroll) {
hinder.push({ id: `arendetyp_${regel.typ ?? regel}`, rubrik: `Okänt krav i regelpaketet: ${regel.typ ?? regel}` });
hinder.push({
id: `arendetyp_${krav}`,
nyckel: "grind.arendetyp.okant",
rubrik: t(sprak, "grind.arendetyp.okant", { krav }),
});
} else if (!kontroll()) {
hinder.push({ id: `arendetyp_${regel.typ ?? regel}`, rubrik: regel.rubrik ?? `Krav för ärendetypen: ${regel.typ ?? regel}` });
// Regelpaketets egen rubrik står på regelpaketets språk och går
// inte att översätta här — den är data från kunden, inte vår text.
hinder.push({
id: `arendetyp_${krav}`,
nyckel: regel.rubrik ? undefined : "grind.arendetyp.krav",
rubrik: regel.rubrik ?? t(sprak, "grind.arendetyp.krav", { krav }),
});
}
}
return hinder;
@@ -46,6 +46,13 @@ export const DE = {
"grind.hogvolt.spanningslos": "Fahrzeug nach Herstellerverfahren spannungsfrei geschaltet",
"grind.regelpaket": "Signatur des Regelpakets stimmt nicht — Abschluss gesperrt.",
"grind.regelpaket.osignerat": "Ein externes Regelpaket wird ohne Signatur verwendet — Abschluss gesperrt.",
"grind.evidens": "Evidenzgrad über E0",
"grind.evidens.saknas": "Es liegt keinerlei Evidenz im Protokoll vor.",
"grind.foton.detalj": "{kontroller} Prüfpunkte erfordern ein Foto, {foton} Fotos im Protokoll.",
"grind.sparr.ej_uppfyllt": "Die Sicherheitsanforderung ist nicht erfüllt.",
"grind.arendetyp.okant": "Unbekannte Anforderung im Regelpaket: {krav}",
"grind.arendetyp.krav": "Anforderung für diese Vorgangsart: {krav}",
// ---- Abschlussfeststellung (ALVA-RULE-200) ------------------------------
"slutsats.rubrik": "Abschlussfeststellung",
@@ -73,6 +73,13 @@ export const EN = {
"grind.hogvolt.spanningslos": "Vehicle de-energised per the manufacturer's procedure",
"grind.regelpaket": "Rule package signature does not match — closing blocked.",
"grind.regelpaket.osignerat": "An external rule package is in use without a signature — closing blocked.",
"grind.evidens": "Evidence above E0",
"grind.evidens.saknas": "No evidence of any kind is present in the log.",
"grind.foton.detalj": "{kontroller} checks require a photograph, {foton} photographs in the log.",
"grind.sparr.ej_uppfyllt": "The safety requirement is not met.",
"grind.arendetyp.okant": "Unknown requirement in the rule package: {krav}",
"grind.arendetyp.krav": "Requirement for this case type: {krav}",
// ---- The closing statement (ALVA-RULE-200) ------------------------------
"slutsats.rubrik": "Closing statement",
@@ -45,6 +45,13 @@ export const ES = {
"grind.hogvolt.spanningslos": "Vehículo sin tensión según el procedimiento del fabricante",
"grind.regelpaket": "La firma del paquete de reglas no coincide — cierre bloqueado.",
"grind.regelpaket.osignerat": "Se utiliza un paquete de reglas externo sin firma — cierre bloqueado.",
"grind.evidens": "Nivel de evidencia superior a E0",
"grind.evidens.saknas": "No hay ninguna evidencia de ningún tipo en el registro.",
"grind.foton.detalj": "{kontroller} controles exigen fotografía, {foton} fotografías en el registro.",
"grind.sparr.ej_uppfyllt": "El requisito de seguridad no se cumple.",
"grind.arendetyp.okant": "Requisito desconocido en el paquete de reglas: {krav}",
"grind.arendetyp.krav": "Requisito para este tipo de expediente: {krav}",
// ---- La conclusión (ALVA-RULE-200) --------------------------------------
"slutsats.rubrik": "Conclusión",
@@ -46,6 +46,13 @@ export const FR = {
"grind.hogvolt.spanningslos": "Véhicule consigné hors tension selon la procédure du constructeur",
"grind.regelpaket": "La signature du paquet de règles ne correspond pas — clôture bloquée.",
"grind.regelpaket.osignerat": "Un paquet de règles externe est utilisé sans signature — clôture bloquée.",
"grind.evidens": "Niveau de preuve supérieur à E0",
"grind.evidens.saknas": "Aucune preuve d'aucune sorte ne figure dans le journal.",
"grind.foton.detalj": "{kontroller} contrôles exigent une photographie, {foton} photographies dans le journal.",
"grind.sparr.ej_uppfyllt": "L'exigence de sécurité n'est pas satisfaite.",
"grind.arendetyp.okant": "Exigence inconnue dans le paquet de règles : {krav}",
"grind.arendetyp.krav": "Exigence pour ce type de dossier : {krav}",
// ---- La conclusion (ALVA-RULE-200) --------------------------------------
"slutsats.rubrik": "Conclusion",
@@ -45,6 +45,13 @@ export const IT = {
"grind.hogvolt.spanningslos": "Veicolo messo fuori tensione secondo la procedura del costruttore",
"grind.regelpaket": "La firma del pacchetto di regole non corrisponde — chiusura bloccata.",
"grind.regelpaket.osignerat": "È in uso un pacchetto di regole esterno privo di firma — chiusura bloccata.",
"grind.evidens": "Livello di evidenza superiore a E0",
"grind.evidens.saknas": "Nel registro non è presente alcuna evidenza.",
"grind.foton.detalj": "{kontroller} controlli richiedono una fotografia, {foton} fotografie nel registro.",
"grind.sparr.ej_uppfyllt": "Il requisito di sicurezza non è soddisfatto.",
"grind.arendetyp.okant": "Requisito sconosciuto nel pacchetto di regole: {krav}",
"grind.arendetyp.krav": "Requisito per questo tipo di pratica: {krav}",
// ---- La conclusione (ALVA-RULE-200) -------------------------------------
"slutsats.rubrik": "Conclusione",
@@ -45,6 +45,13 @@ export const NL = {
"grind.hogvolt.spanningslos": "Voertuig spanningsloos gemaakt volgens de procedure van de fabrikant",
"grind.regelpaket": "Handtekening van het regelpakket komt niet overeen — afsluiten geblokkeerd.",
"grind.regelpaket.osignerat": "Er wordt een extern regelpakket zonder handtekening gebruikt — afsluiten geblokkeerd.",
"grind.evidens": "Bewijsgraad boven E0",
"grind.evidens.saknas": "Er is geen enkel bewijs in het logboek aanwezig.",
"grind.foton.detalj": "{kontroller} controles vereisen een foto, {foton} foto's in het logboek.",
"grind.sparr.ej_uppfyllt": "Aan de veiligheidseis is niet voldaan.",
"grind.arendetyp.okant": "Onbekende eis in het regelpakket: {krav}",
"grind.arendetyp.krav": "Eis voor dit zaaktype: {krav}",
// ---- De eindconclusie (ALVA-RULE-200) -----------------------------------
"slutsats.rubrik": "Eindconclusie",
@@ -47,6 +47,13 @@ export const PL = {
"grind.hogvolt.spanningslos": "Pojazd odłączony od napięcia zgodnie z procedurą producenta",
"grind.regelpaket": "Podpis pakietu reguł nie zgadza się — zamknięcie zablokowane.",
"grind.regelpaket.osignerat": "Używany jest zewnętrzny pakiet reguł bez podpisu — zamknięcie zablokowane.",
"grind.evidens": "Poziom dowodu powyżej E0",
"grind.evidens.saknas": "W dzienniku nie ma żadnego dowodu.",
"grind.foton.detalj": "{kontroller} kontroli wymaga fotografii, {foton} fotografii w dzienniku.",
"grind.sparr.ej_uppfyllt": "Wymóg bezpieczeństwa nie jest spełniony.",
"grind.arendetyp.okant": "Nieznany wymóg w pakiecie reguł: {krav}",
"grind.arendetyp.krav": "Wymóg dla tego rodzaju sprawy: {krav}",
// ---- Wniosek końcowy (ALVA-RULE-200) ------------------------------------
"slutsats.rubrik": "Wniosek końcowy",
@@ -45,6 +45,13 @@ export const PT = {
"grind.regelpaket": "A assinatura do pacote de regras não corresponde — encerramento bloqueado.",
"grind.regelpaket.osignerat":
"Está a ser utilizado um pacote de regras externo sem assinatura — encerramento bloqueado.",
"grind.evidens": "Nível de evidência superior a E0",
"grind.evidens.saknas": "Não existe qualquer evidência no registo.",
"grind.foton.detalj": "{kontroller} controlos exigem fotografia, {foton} fotografias no registo.",
"grind.sparr.ej_uppfyllt": "O requisito de segurança não está cumprido.",
"grind.arendetyp.okant": "Requisito desconhecido no pacote de regras: {krav}",
"grind.arendetyp.krav": "Requisito para este tipo de processo: {krav}",
// ---- A conclusão (ALVA-RULE-200) ----------------------------------------
"slutsats.rubrik": "Conclusão",
@@ -51,6 +51,13 @@ export const RO = {
"grind.regelpaket": "Semnătura pachetului de reguli nu corespunde — închiderea este blocată.",
"grind.regelpaket.osignerat":
"Se utilizează un pachet de reguli extern fără semnătură — închiderea este blocată.",
"grind.evidens": "Nivel de dovadă peste E0",
"grind.evidens.saknas": "În jurnal nu există nicio dovadă.",
"grind.foton.detalj": "{kontroller} controale necesită o fotografie, {foton} fotografii în jurnal.",
"grind.sparr.ej_uppfyllt": "Cerința de siguranță nu este îndeplinită.",
"grind.arendetyp.okant": "Cerință necunoscută în pachetul de reguli: {krav}",
"grind.arendetyp.krav": "Cerință pentru acest tip de caz: {krav}",
// ---- Concluzia finală (ALVA-RULE-200) -----------------------------------
"slutsats.rubrik": "Concluzie finală",
@@ -44,6 +44,13 @@ export const SV = {
"grind.hogvolt.spanningslos": "Fordonet spänningslöst enligt tillverkarens metod",
"grind.regelpaket": "Regelpaketets signatur stämmer inte — avslut spärrat.",
"grind.regelpaket.osignerat": "Ett externt regelpaket används utan signatur — avslut spärrat.",
"grind.evidens": "Evidensnivå över E0",
"grind.evidens.saknas": "Ingen evidens av något slag finns i loggen.",
"grind.foton.detalj": "{kontroller} kontroller kräver foto, {foton} foton i loggen.",
"grind.sparr.ej_uppfyllt": "Säkerhetskravet är inte uppfyllt.",
"grind.arendetyp.okant": "Okänt krav i regelpaketet: {krav}",
"grind.arendetyp.krav": "Krav för ärendetypen: {krav}",
// ---- Slutsatsen (ALVA-RULE-200) -----------------------------------------
"slutsats.rubrik": "Slutsats",