From be1070434909c804f8cad43cf0566c6e686efd28 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:36:53 +0000 Subject: [PATCH] =?UTF-8?q?Grinden=20talar=20organisationens=20spr=C3=A5k?= =?UTF-8?q?=20=E2=80=94=20engelska=20som=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- .../src/felsokning/__tests__/grind.test.ts | 22 +++- felsokning/services/gemensam/grind.mjs | 120 ++++++++++-------- felsokning/services/gemensam/sprak/de.mjs | 7 + felsokning/services/gemensam/sprak/en.mjs | 7 + felsokning/services/gemensam/sprak/es.mjs | 7 + felsokning/services/gemensam/sprak/fr.mjs | 7 + felsokning/services/gemensam/sprak/it.mjs | 7 + felsokning/services/gemensam/sprak/nl.mjs | 7 + felsokning/services/gemensam/sprak/pl.mjs | 7 + felsokning/services/gemensam/sprak/pt.mjs | 7 + felsokning/services/gemensam/sprak/ro.mjs | 7 + felsokning/services/gemensam/sprak/sv.mjs | 7 + .../services/plattform/integrationstest.sh | 47 +++++++ felsokning/services/plattform/server.mjs | 48 +++++-- felsokning/services/plattform/sprak | 1 + 15 files changed, 243 insertions(+), 65 deletions(-) create mode 120000 felsokning/services/plattform/sprak diff --git a/felsokning/app/src/felsokning/__tests__/grind.test.ts b/felsokning/app/src/felsokning/__tests__/grind.test.ts index f029e4c..b89e389 100644 --- a/felsokning/app/src/felsokning/__tests__/grind.test.ts +++ b/felsokning/app/src/felsokning/__tests__/grind.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { granskaHändelse, tillPost } from "../../../../services/gemensam/handelser.mjs"; import { SPARRFRAGOR as SPARR_SERVER, grinda, grindaArendetyp } from "../../../../services/gemensam/grind.mjs"; import { ALLA_METODIKER } from "../../../../services/gemensam/metodiker.mjs"; +import { t } from "../../../../services/gemensam/sprak/index.mjs"; import { SPARRFRAGOR } from "../metodik"; const GENERISK = ALLA_METODIKER.at(-1)!; @@ -203,7 +204,8 @@ describe("ärendetypens regelpaket", () => { it("en okänd kravtyp spärrar i stället för att tolkas som uppfylld", () => { const trasigt = { arendetyper: { Garanti: { krav: [{ typ: "hittepa" }] } } }; const logg = [{ typ: "arendetyp_satt", arendetyp: "Garanti" }]; - expect(grindaArendetyp(logg, trasigt)[0].rubrik).toMatch(/Okänt krav/); + expect(grindaArendetyp(logg, trasigt)[0].nyckel).toBe("grind.arendetyp.okant"); + expect(grindaArendetyp(logg, trasigt)[0].rubrik).toContain("hittepa"); }); }); @@ -470,8 +472,24 @@ describe("mätarställningen ska vara fotograferad, inte bara inskriven", () => }); it("hindret säger vad som saknas och vad man gör åt det", () => { + // Prövas mot nyckeln, inte mot texten: texten är översatt och byter + // språk med organisationen. Att pröva svenska ord här hade gjort + // testet till ett test av vilket språk som råkar vara standard. const h = grinda(utanFoto(), GENERISK).find((x) => x.id === "matarstallning_ingaende"); - expect(h.detalj).toMatch(/fotografera/i); + expect(h.detaljNyckel).toBe("grind.matarstallning.ej_foto"); + expect(h.detalj).toBe(t("en", "grind.matarstallning.ej_foto")); + }); + + it("hindret följer organisationens språk", () => { + // Det som faktiskt betyder något i drift: en tysk verkstad ska få + // veta på tyska varför avslutet nekas. Ett hinder ingen förstår är + // en spärr utan väg förbi. + const engelska = grinda(utanFoto(), GENERISK).find((x) => x.id === "matarstallning_ingaende"); + const tyska = grinda(utanFoto(), GENERISK, "de").find((x) => x.id === "matarstallning_ingaende"); + expect(tyska.id).toBe(engelska.id); + expect(tyska.nyckel).toBe(engelska.nyckel); + expect(tyska.rubrik).not.toBe(engelska.rubrik); + expect(tyska.rubrik).toBe(t("de", "grind.matarstallning.ingaende")); }); it("ett dataUrl-foto duger lika bra som en bilaga", () => { diff --git a/felsokning/services/gemensam/grind.mjs b/felsokning/services/gemensam/grind.mjs index f17c3c1..8209d63 100644 --- a/felsokning/services/gemensam/grind.mjs +++ b/felsokning/services/gemensam/grind.mjs @@ -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; diff --git a/felsokning/services/gemensam/sprak/de.mjs b/felsokning/services/gemensam/sprak/de.mjs index 0d1e065..c0098c8 100644 --- a/felsokning/services/gemensam/sprak/de.mjs +++ b/felsokning/services/gemensam/sprak/de.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/en.mjs b/felsokning/services/gemensam/sprak/en.mjs index e0634b5..5e62e5e 100644 --- a/felsokning/services/gemensam/sprak/en.mjs +++ b/felsokning/services/gemensam/sprak/en.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/es.mjs b/felsokning/services/gemensam/sprak/es.mjs index c7e0ba8..7645cfb 100644 --- a/felsokning/services/gemensam/sprak/es.mjs +++ b/felsokning/services/gemensam/sprak/es.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/fr.mjs b/felsokning/services/gemensam/sprak/fr.mjs index 8e7e81b..eb1ce93 100644 --- a/felsokning/services/gemensam/sprak/fr.mjs +++ b/felsokning/services/gemensam/sprak/fr.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/it.mjs b/felsokning/services/gemensam/sprak/it.mjs index 7644b92..db0ce3c 100644 --- a/felsokning/services/gemensam/sprak/it.mjs +++ b/felsokning/services/gemensam/sprak/it.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/nl.mjs b/felsokning/services/gemensam/sprak/nl.mjs index 0c7c511..4cceb15 100644 --- a/felsokning/services/gemensam/sprak/nl.mjs +++ b/felsokning/services/gemensam/sprak/nl.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/pl.mjs b/felsokning/services/gemensam/sprak/pl.mjs index b85a42b..a4d4be0 100644 --- a/felsokning/services/gemensam/sprak/pl.mjs +++ b/felsokning/services/gemensam/sprak/pl.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/pt.mjs b/felsokning/services/gemensam/sprak/pt.mjs index ab92182..635779f 100644 --- a/felsokning/services/gemensam/sprak/pt.mjs +++ b/felsokning/services/gemensam/sprak/pt.mjs @@ -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", diff --git a/felsokning/services/gemensam/sprak/ro.mjs b/felsokning/services/gemensam/sprak/ro.mjs index 79ddfc5..3b3eaed 100644 --- a/felsokning/services/gemensam/sprak/ro.mjs +++ b/felsokning/services/gemensam/sprak/ro.mjs @@ -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ă", diff --git a/felsokning/services/gemensam/sprak/sv.mjs b/felsokning/services/gemensam/sprak/sv.mjs index ba58a3a..95e7cb4 100644 --- a/felsokning/services/gemensam/sprak/sv.mjs +++ b/felsokning/services/gemensam/sprak/sv.mjs @@ -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", diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh index 2b2f583..6110e5a 100755 --- a/felsokning/services/plattform/integrationstest.sh +++ b/felsokning/services/plattform/integrationstest.sh @@ -860,6 +860,53 @@ kontroll "utan huvudnyckel maskeras uppgiften" \ "$(echo "$SVAR" | falt '.handelser[0].handelse.objekt.identifierare')" "[raderat på begäran]" kontroll "men anropet gar fortfarande igenom" "$(echo "$SVAR" | falt .handelser.length)" "1" +# 18b. Grinden talar organisationens språk (ALVA-SPEC-060) +# +# Det som prövas är inte att en översättning finns — det gör testerna i +# app/ — utan att språket följer HELA vägen: organisationens inställning, +# genom servern, ut i det 409-svar som faktiskt nekar ett avslut. +# +# En spärr på fel språk ä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. +curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"id":"arende-sprak","nummer":77,"skapad":"2026-08-06T09:00:00Z"}' >/dev/null +AVSLUT='{"handelser":[{"id":"h-sprak-avslut","tidpunkt":"2026-08-06T09:05:00Z","anvandare":"Anna","handelse":{"typ":"arende_avslutat"}}]}' + +SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-sprak/handelser" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d "$AVSLUT") +kontroll "avslut utan underlag nekas" "$(echo "$SVAR" | falt '.hinder.length > 0')" "true" +kontroll "hindret star pa engelska som standard" \ + "$(echo "$SVAR" | falt '.hinder.find(h=>h.id==="objekt").rubrik')" "Vehicle or object identification verified" +kontroll "hindret bar sin nyckel" \ + "$(echo "$SVAR" | falt '.hinder.find(h=>h.id==="objekt").nyckel')" "grind.objekt" + +# Organisationen byter dokumentationsspråk. +curl -s -X POST "$BAS/api/organisation/installningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' \ + -d '{"objekttyper":["Personbil"],"identifieringsmetoder":["Registreringsnummer"],"sprak":"de"}' >/dev/null + +SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-sprak/handelser" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d "$AVSLUT") +kontroll "hindret foljer organisationens sprak" \ + "$(echo "$SVAR" | falt '.hinder.find(h=>h.id==="objekt").rubrik')" \ + "Identität des Fahrzeugs oder Objekts verifiziert" +# Nyckeln ar oforandrad: identiteten pa hindret ar inte sprakberoende, +# vilket ar hela poangen med att bara texten oversatts. +kontroll "nyckeln ar densamma pa bada spraken" \ + "$(echo "$SVAR" | falt '.hinder.find(h=>h.id==="objekt").nyckel')" "grind.objekt" + +# En felstavad sprakkod far inte hindra nagon fran att spara sina +# objekttyper — sprakinstallningen ar en etikett, inte ett krav. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"objekttyper":["Personbil"],"identifieringsmetoder":["Registreringsnummer"],"sprak":"klingon"}') +kontroll "okand sprakkod avvisas inte" "$KOD" "200" +SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-sprak/handelser" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d "$AVSLUT") +kontroll "okand sprakkod blir engelska" \ + "$(echo "$SVAR" | falt '.hinder.find(h=>h.id==="objekt").rubrik')" "Vehicle or object identification verified" + # 19. Support och felanmälan (ALVA-PROC-0050) kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 76d5c21..6d61f9f 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -41,6 +41,7 @@ import { fakturabeteckning, fakturastatus, fakturera, granskaPeriod, kreditera } import { STATUSAR, granskaAnmalan, sammanhang as byggSammanhang, supportbeteckning, supportstatus } from "./support.mjs"; import { NIVAER, abonnemangstillstand, behorighet, besked, forfallenFakturering, nivaFor } from "./abonnemang.mjs"; import { fakturaPdf } from "./fakturapdf.mjs"; +import { STANDARD, valjSprak, t } from "./sprak/index.mjs"; import { MASKERAT, gallringsdatum, @@ -444,12 +445,35 @@ function nyKod(langd = 16) { return ut.join(""); } +/** + * Organisationens dokumentationsspråk. + * + * Organisationen och inte användaren, därför att grindens hinder hamnar + * i ärendets logg och i rapporten. 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. + * + * Faller tillbaka på engelska, tyst. Att neka ett avslut för att + * språkinställningen inte gick att läsa vore att låta en etikett stoppa + * arbetet. + */ +async function orgSprak(pool, orgId) { + try { + const rader = await pool.query(`select installningar->>'sprak' as sprak from organisationer where id = $1`, [ + orgId, + ]); + return valjSprak({ organisation: rader.rows[0]?.sprak }); + } catch { + return STANDARD; + } +} + /** * Avslutsspärren. Läser hela loggen ur databasen, lägger de inkommande * händelserna ovanpå och utvärderar grinden mot det samlade underlaget — * annars skulle ett avslut i samma anrop som evidensen felaktigt nekas. */ -async function grindHinder(pool, arendeId, nya) { +async function grindHinder(pool, arendeId, nya, sprak = STANDARD) { const rader = await pool.query( `select h.handelse, a.metodik_id from felsokning_handelser h join felsokning_arenden a on a.id = h.arende_id @@ -462,14 +486,17 @@ async function grindHinder(pool, arendeId, nya) { let regelpaket; if (REGELPAKET_STATUS === "ogiltig signatur") { - return [{ id: "regelpaket", rubrik: "Regelpaketets signatur stämmer inte — avslut spärrat." }]; + return [{ id: "regelpaket", nyckel: "grind.regelpaket", rubrik: t(sprak, "grind.regelpaket") }]; } if (REGELPAKET_STATUS === "osignerat externt") { return [ { id: "regelpaket", - rubrik: "Ett externt regelpaket används utan signatur — avslut spärrat.", - detalj: "Sätt ECM_REGLER_NYCKEL och ECM_REGLER_SIGNATUR, eller ta bort ECM_REGLER_FIL.", + nyckel: "grind.regelpaket.osignerat", + rubrik: t(sprak, "grind.regelpaket.osignerat"), + // Driftanvisning till den som satt upp servern, inte text till + // teknikern. Den står därför på engelska och översätts inte. + detalj: "Set ECM_REGLER_NYCKEL and ECM_REGLER_SIGNATUR, or remove ECM_REGLER_FIL.", }, ]; } @@ -477,9 +504,9 @@ async function grindHinder(pool, arendeId, nya) { regelpaket = JSON.parse(ECM_REGLER); } catch { // Ett trasigt regelpaket får aldrig tolkas som "inga extra krav". - return [{ id: "regelpaket", rubrik: "Regelpaketet gick inte att läsa — avslut spärrat." }]; + return [{ id: "regelpaket", nyckel: "grind.regelpaket", rubrik: t(sprak, "grind.regelpaket") }]; } - return [...grinda(handelser, metodik), ...grindaArendetyp(handelser, regelpaket)]; + return [...grinda(handelser, metodik, sprak), ...grindaArendetyp(handelser, regelpaket, sprak)]; } @@ -1893,7 +1920,7 @@ export function skapaServer() { if (req.method === "POST" && vag === "/api/organisation/installningar") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); - const { objekttyper, identifieringsmetoder, ai_tillaten } = await lasKropp(req); + const { objekttyper, identifieringsmetoder, ai_tillaten, sprak } = await lasKropp(req); const giltigLista = (lista) => Array.isArray(lista) && lista.length > 0 && lista.length <= 50 && lista.every((v) => typeof v === "string" && v.length <= 100); @@ -1912,6 +1939,11 @@ export function skapaServer() { // överföringen till modelleverantören kan ändå använda // produkten (QUALITET C-4). ai_tillaten: ai_tillaten !== false, + // Dokumentationsspråket. Ett okänt värde blir engelska i + // stället för ett fel: språket är en etikett, och en + // felstavad landskod ska inte hindra en organisation från + // att spara sina objekttyper. + sprak: valjSprak({ organisation: sprak }), }), ], ); @@ -2459,7 +2491,7 @@ export function skapaServer() { // tidigare bara i webbläsaren, vilket gjorde hela ECM till ett // råd i stället för en spärr (QUALITET C-2). if (attSkriva.some((p) => p.handelse.typ === "arende_avslutat")) { - const hinder = await grindHinder(pool, handelserVag[1], attSkriva); + const hinder = await grindHinder(pool, handelserVag[1], attSkriva, await orgSprak(pool, anspr.org)); if (hinder.length > 0) { return svara(res, 409, { error: "Ärendet kan inte avslutas — kvalitetsgrinden är inte passerad.", diff --git a/felsokning/services/plattform/sprak b/felsokning/services/plattform/sprak new file mode 120000 index 0000000..ae03652 --- /dev/null +++ b/felsokning/services/plattform/sprak @@ -0,0 +1 @@ +../gemensam/sprak \ No newline at end of file