diff --git a/felsokning/app/src/felsokning/Slutsats.tsx b/felsokning/app/src/felsokning/Slutsats.tsx
new file mode 100644
index 0000000..6b16dd9
--- /dev/null
+++ b/felsokning/app/src/felsokning/Slutsats.tsx
@@ -0,0 +1,205 @@
+// ALVA-RULE-200 · Slutsatspanelen.
+//
+// Här möter teknikern kravet, och här avgörs om regeln blir älskad eller
+// hatad. Tre val styr det:
+//
+// Fyra fält, inte ett. En stor ruta ger "klart". Fyra namngivna
+// frågor med olika adressat ger fyra svar,
+// eftersom var och en är konkret nog att
+// besvara utan att först fundera på vad som
+// efterfrågas.
+//
+// Granskning medan man Bristerna visas under fältet direkt, inte som
+// skriver. ett felmeddelande efter Spara. Att bli nekad
+// efter att ha skrivit klart är det som gör
+// obligatoriska fält förhatliga.
+//
+// Hypoteserna listas. Systemet visar vilka misstankar som ligger i
+// loggen och behöver bemötas. Teknikern ska
+// aldrig behöva gissa vad som fattas.
+
+import { useMemo, useState } from "react";
+import type { Arende, Handelse } from "./domain";
+import { granskaSlutsats, obemottaHypoteser } from "../../../services/gemensam/motivering.mjs";
+import { FARG, Etikett, Knapp, Statusmärke } from "@/alva/komponenter";
+
+interface Brist {
+ falt: string;
+ text: string;
+}
+
+const FALT = [
+ {
+ id: "motivering" as const,
+ etikett: "Motivering",
+ fraga: "Varför följer slutsatsen av underlaget?",
+ hjalp: "Knyt mätvärden, foton och kontroller till slutsatsen. Vad i evidensen gör att just detta följer?",
+ rader: 4,
+ },
+ {
+ id: "uteslutet" as const,
+ etikett: "Uteslutna alternativ",
+ fraga: "Vad övervägdes, och varför föll det bort?",
+ hjalp: "En felsökning utan uteslutna alternativ är en gissning som råkade stämma.",
+ rader: 3,
+ },
+ {
+ id: "atgardsval" as const,
+ etikett: "Val av åtgärd",
+ fraga: "Varför denna åtgärd och inte en annan?",
+ hjalp: "Byte eller justering? Hela enheten eller en del? Skälet läses av den som granskar kostnaden.",
+ rader: 2,
+ },
+ {
+ id: "kvarstaende" as const,
+ etikett: "Kvarstående osäkerhet",
+ fraga: "Vad är fortfarande osäkert?",
+ hjalp: 'Får vara "inget" — men det måste sägas aktivt.',
+ rader: 2,
+ },
+];
+
+export function Slutsatspanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
+ const handelser = useMemo(() => arende.handelser.map((p) => p.handelse), [arende]);
+ const befintlig = useMemo(
+ () => [...handelser].reverse().find((h) => h.typ === "slutsats"),
+ [handelser],
+ ) as Extract | undefined;
+
+ const [fastställd, setFastställd] = useState(befintlig?.orsakFastställd !== false);
+ const [utkast, setUtkast] = useState({
+ motivering: befintlig?.motivering ?? "",
+ uteslutet: befintlig?.uteslutet ?? "",
+ atgardsval: befintlig?.atgardsval ?? "",
+ kvarstaende: befintlig?.kvarstaende ?? "",
+ });
+
+ const förslag = { typ: "slutsats" as const, ...utkast, orsakFastställd: fastställd };
+ const brister: Brist[] = useMemo(
+ () => granskaSlutsats(förslag, handelser),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [utkast, fastställd, handelser],
+ );
+ const obemötta: string[] = useMemo(() => obemottaHypoteser(handelser, förslag), [handelser, förslag]);
+
+ const brist = (falt: string) => brister.find((b) => b.falt === falt && !b.text.includes("bemöts inte"));
+ const klar = brister.length === 0;
+
+ const arbeteUtfört = handelser.some((h) => h.typ === "atgard_utford" && h.utford);
+
+ return (
+
+
+
+ Closing statement
+
+
+
+
+ ALVA-RULE-200
+
+
+
+
+
+
+ Underlaget visar vad som mättes. Slutsatsen visar vad som är fel. Det här är raden som säger varför det
+ ena följer av det andra — och den enda en garantihandläggare eller försäkringsbedömare behöver granska.
+
+
+ {/* Den ärliga vägen. Att orsaken inte kunnat fastställas är ett
+ giltigt utfall, och ofta mer användbart än en påhittad orsak.
+ Men varför den inte kunde det är fortfarande ett varför. */}
+
+
+ );
+}
diff --git a/felsokning/app/src/felsokning/__tests__/grind.test.ts b/felsokning/app/src/felsokning/__tests__/grind.test.ts
index 9372768..5dc6138 100644
--- a/felsokning/app/src/felsokning/__tests__/grind.test.ts
+++ b/felsokning/app/src/felsokning/__tests__/grind.test.ts
@@ -5,6 +5,7 @@
// signerad behållare för overifierade påståenden, och kvalitetsgrinden
// ett råd. Går något av testerna sönder har den garantin försvunnit —
// och det syns inte i gränssnittet förrän någon bestrider ett ärende.
+import { readFileSync } from "node:fs";
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";
@@ -23,9 +24,18 @@ function komplettLogg(extra: Record[] = []) {
{ typ: "matarstallning", lage: "utgaende", varde: "14 205" },
{ typ: "reproducering", status: "ja", beskrivning: "Reproducerat vid 88 km/h." },
{ typ: "felorsak", avvikelse: "Obalans", orsaker: ["Normalt slitage"], underlag: ["Mätvärde"], sakerhet: "hog" },
- { typ: "atgard_utford", text: "Balanserade hjulen." },
- { typ: "kundbeslut", utfall: "godkant", kanal: "Telefon" },
- { typ: "kvalitetskontroll", utfall: "borta", beskrivning: "Provkört, symptomet borta." },
+ { typ: "atgard_utford", beskrivning: "Balanserade hjulen.", utford: true },
+ { typ: "kundbeslut", beslut: "godkant", kanal: "Telefon" },
+ { typ: "kvalitetskontroll", resultat: "symptomet_borta", beskrivning: "Provkört, symptomet borta." },
+ {
+ typ: "slutsats",
+ motivering:
+ "Uppmätt obalans 38 g på höger framhjul mot toleransen 5 g, vilket förklarar varför vibrationen " +
+ "uppträder hastighetsberoende kring 88 km/h och försvann efter balansering.",
+ uteslutet: "Kast och bussningar kontrollerades utan anmärkning och uteslöts därför.",
+ kvarstaende: "Inget. Symptomet reproducerades före och uteblev efter åtgärd.",
+ atgardsval: "Balansering valdes framför däckbyte eftersom däcket är oskadat och slitaget inom gräns.",
+ },
{ typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4" },
{ typ: "foto", beskrivning: "Objektet" },
{ typ: "foto", beskrivning: "Typskylt" },
@@ -117,7 +127,7 @@ describe("C-2 · kvalitetsgrinden spärrar på servern", () => {
it("arbete utfört trots avböjt förslag är ett hårt fel, inte en varning", () => {
const logg = komplettLogg().map((h) =>
- h.typ === "kundbeslut" ? { ...h, utfall: "avbojt" } : h,
+ h.typ === "kundbeslut" ? { ...h, beslut: "avbojt" } : h,
);
expect(grinda(logg, GENERISK).map((h) => h.id)).toContain("avbojt_men_utfort");
});
@@ -194,3 +204,44 @@ describe("ärendetypens regelpaket", () => {
expect(grindaArendetyp(logg, trasigt)[0].rubrik).toMatch(/Okänt krav/);
});
});
+
+// Skydd mot den drift som faktiskt inträffade: schemat på servern skrevs
+// mot antagna fältnamn i stället för mot domänmodellen. Följden var värre
+// än ett typfel — grinden såg utförd åtgärd som utebliven, och krävde
+// därför aldrig kundbesked eller kvalitetskontroll. Det syntes inte,
+// eftersom testfixturerna hade samma antagande.
+describe("schemat följer domänmodellen", () => {
+ it("varje obligatoriskt fält i schemat finns i domain.ts", async () => {
+ const { HÄNDELSESCHEMA } = await import("../../../../services/gemensam/handelser.mjs");
+ const domain = readFileSync("src/felsokning/domain.ts", "utf8");
+ const avvikelser: string[] = [];
+
+ for (const [typ, schema] of Object.entries(HÄNDELSESCHEMA as Record)) {
+ // Klipp ut den del av unionen som beskriver typen.
+ const start = domain.indexOf(`typ: "${typ}"`);
+ if (start < 0) {
+ avvikelser.push(`${typ}: saknas i domain.ts`);
+ continue;
+ }
+ const block = domain.slice(start, start + 700);
+ for (const falt of Object.keys(schema)) {
+ // Bilagefälten ärvs via intersektion med Bilaga och står inte i blocket.
+ if (["bilagaId", "bilagaHash", "dataUrl"].includes(falt)) continue;
+ if (!new RegExp(`\\b${falt}\\??:`).test(block)) avvikelser.push(`${typ}.${falt}`);
+ }
+ }
+ expect(avvikelser).toEqual([]);
+ });
+
+ it("demoärendets händelser passerar serverns validering", async () => {
+ // Slutkontrollen: allt klienten faktiskt producerar måste gå igenom.
+ // Ett schema som avvisar riktig trafik är värre än inget schema.
+ const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
+ const { byggDemoArende } = await import("../demo");
+ const avvisade = byggDemoArende(1)
+ .handelser.map((p) => [p.handelse.typ, granskaHändelse(p.handelse)] as const)
+ .filter(([, fel]) => fel !== null)
+ .map(([typ, fel]) => `${typ}: ${fel}`);
+ expect([...new Set(avvisade)]).toEqual([]);
+ });
+});
diff --git a/felsokning/app/src/felsokning/__tests__/slutsats.test.ts b/felsokning/app/src/felsokning/__tests__/slutsats.test.ts
new file mode 100644
index 0000000..94d8b9a
--- /dev/null
+++ b/felsokning/app/src/felsokning/__tests__/slutsats.test.ts
@@ -0,0 +1,180 @@
+// @vitest-environment node
+// ALVA-RULE-200 · Ett ärende stängs aldrig utan ett varför.
+//
+// Regeln är värdelös om den går att uppfylla med "klart". Testerna nedan
+// är därför mest en katalog över hur någon med bråttom faktiskt skriver,
+// och ett krav på att systemet inte accepterar det.
+//
+// Den viktigaste regeln är den sista: en hypotes som dokumenterats och
+// inte blivit slutsatsen måste bemötas. Utan den är en felsökning en
+// gissning som råkade stämma — och det är precis den skillnaden en
+// försäkringsbedömare försöker avgöra.
+import { describe, expect, it } from "vitest";
+import {
+ MINSTA_MOTIVERING,
+ granskaFalt,
+ granskaSlutsats,
+ obemottaHypoteser,
+} from "../../../../services/gemensam/motivering.mjs";
+
+const GILTIG = {
+ typ: "slutsats",
+ motivering:
+ "Uppmätt spänningsfall 2,1 V över reläets slutkontakt vid belastning, mot 0,2 V som är gränsen. " +
+ "Det förklarar varför bränslepumpen inte får full matning och varför felet bara uppträder varm.",
+ uteslutet:
+ "Bränslepumpen själv uteslöts eftersom den drar rätt ström vid direktmatning. Kablaget mättes utan anmärkning.",
+ kvarstaende: "Inget. Symptomet reproducerades och försvann efter bytet.",
+ atgardsval:
+ "Reläet byttes i stället för att rengöras eftersom kontaktytorna är eroderade och rengöring inte återställer dem.",
+};
+
+const ARBETE = [{ typ: "atgard_utford", beskrivning: "Bytte bränslepumpsrelä.", utford: true }];
+
+describe("motiveringen måste vara ett skäl, inte en upprepning", () => {
+ it("avvisar tomt fält", () => {
+ expect(granskaFalt("Motivering", "")).toMatch(/saknas/);
+ expect(granskaFalt("Motivering", " ")).toMatch(/saknas/);
+ });
+
+ it.each([
+ "klart",
+ "OK",
+ "åtgärdat",
+ "fixat",
+ "se ovan",
+ "trasig",
+ "defekt",
+ "sliten",
+ "behöver bytas",
+ "enligt kund",
+ "vet ej",
+ "-",
+ "n/a",
+ ])("avvisar icke-svaret %s", (text) => {
+ expect(granskaFalt("Motivering", text)).toMatch(/är inget skäl/);
+ });
+
+ it("avvisar text som är för kort för att gå att granska", () => {
+ expect(granskaFalt("Motivering", "Reläet var dåligt", { minsta: MINSTA_MOTIVERING })).toMatch(/för kort/);
+ });
+
+ it("avvisar en omformulering av slutsatsen utan orsakssamband", () => {
+ // Vad, men inte varför. Detta är den vanligaste bristen och den
+ // svåraste att fånga — texten ser fullgod ut tills man frågar vad
+ // den egentligen påstår.
+ const fel = granskaFalt(
+ "Motivering",
+ "Reläet till bränslepumpen fungerade inte som det ska och byttes därmed ut mot ett nytt relä.",
+ { minsta: MINSTA_MOTIVERING, kravOrsak: true },
+ );
+ expect(fel).toMatch(/anger vad, men inte varför/);
+ });
+
+ it("godtar ett resonemang med orsakssamband", () => {
+ expect(granskaFalt("Motivering", GILTIG.motivering, { minsta: MINSTA_MOTIVERING, kravOrsak: true })).toBeNull();
+ });
+
+ it("godtar konkreta mätvärden som evidensreferens även utan orsaksord", () => {
+ // Den som skriver "12,4 V vid stift 14" hänvisar till en mätning
+ // utan att säga ordet mätning. Regeln får inte tvinga fram ett
+ // språkbruk som inte är teknikerns.
+ expect(
+ granskaFalt("Motivering", "Stift 14 låg på 12,4 V med tändning på, stift 7 på 0,03 ohm mot jord.", {
+ minsta: MINSTA_MOTIVERING,
+ kravOrsak: true,
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("hela slutsatsen inför avslut", () => {
+ it("saknad slutsats spärrar avslutet", () => {
+ const brister = granskaSlutsats(undefined, []);
+ expect(brister).toHaveLength(1);
+ expect(brister[0].text).toMatch(/kan inte avslutas utan en slutsats/);
+ });
+
+ it("en fullständig slutsats passerar", () => {
+ expect(granskaSlutsats(GILTIG, ARBETE)).toEqual([]);
+ });
+
+ it("kräver val av åtgärd när arbete utförts — men inte annars", () => {
+ const utanVal = { ...GILTIG, atgardsval: undefined };
+ expect(granskaSlutsats(utanVal, ARBETE).map((b) => b.falt)).toContain("atgardsval");
+ // Utan utfört arbete finns inget åtgärdsval att motivera.
+ expect(granskaSlutsats(utanVal, [{ typ: "atgard_utford", beskrivning: "Ingen åtgärd.", utford: false }])).toEqual([]);
+ });
+
+ it("kvarstående osäkerhet får vara inget — men måste sägas aktivt", () => {
+ expect(granskaSlutsats({ ...GILTIG, kvarstaende: "Inget." }, ARBETE)).toEqual([]);
+ expect(granskaSlutsats({ ...GILTIG, kvarstaende: "" }, ARBETE).map((b) => b.falt)).toContain("kvarstaende");
+ });
+
+ it("den ärliga vägen: orsaken kunde inte fastställas, men varför måste stå där", () => {
+ const oklart = {
+ typ: "slutsats",
+ orsakFastställd: false,
+ motivering:
+ "Felet gick inte att reproducera under de förhållanden som rådde, eftersom det enligt kunden " +
+ "bara uppträder vid längre körning i kyla. Ingen avvikelse kunde mätas fram vid undersökningen.",
+ uteslutet: "Kablaget och kontaktdonen kontrollerades utan anmärkning vid genomgång.",
+ kvarstaende: "Orsaken är inte fastställd. Fordonet bör återkomma vid symptom.",
+ };
+ expect(granskaSlutsats(oklart, [])).toEqual([]);
+
+ // Men "vet ej" duger inte som skäl till att man inte vet.
+ expect(granskaSlutsats({ ...oklart, motivering: "vet ej" }, []).map((b) => b.falt)).toContain("motivering");
+ });
+});
+
+describe("dokumenterade hypoteser måste bemötas", () => {
+ const HYPOTES = { typ: "hypotes", text: "Misstänkt glapp i hjullagret höger fram", niva: "medel" };
+
+ it("en obemött hypotes spärrar avslutet", () => {
+ const brister = granskaSlutsats(GILTIG, [...ARBETE, HYPOTES]);
+ expect(brister.map((b) => b.text).join(" ")).toMatch(/finns i loggen men bemöts inte/);
+ });
+
+ it("hypotesen är bemött när uteslutandet nämner den", () => {
+ const bemött = {
+ ...GILTIG,
+ uteslutet:
+ "Hjullagret kontrollerades utan glapp och uteslöts därför. Bränslepumpen drar rätt ström vid direktmatning.",
+ };
+ expect(obemottaHypoteser([...ARBETE, HYPOTES], bemött)).toEqual([]);
+ expect(granskaSlutsats(bemött, [...ARBETE, HYPOTES])).toEqual([]);
+ });
+
+ it("hypotesen är bemött när den blev den fastställda orsaken", () => {
+ const logg = [
+ ...ARBETE,
+ HYPOTES,
+ { typ: "felorsak", avvikelse: "Glapp i hjullagret höger fram, 1,2 mm axiellt.", orsaker: [], underlag: [] },
+ ];
+ expect(obemottaHypoteser(logg, GILTIG)).toEqual([]);
+ });
+
+ it("flera hypoteser rapporteras var för sig", () => {
+ const logg = [
+ ...ARBETE,
+ HYPOTES,
+ { typ: "hypotes", text: "Möjlig obalans i framhjulen", niva: "lag" },
+ ];
+ const brister = granskaSlutsats(GILTIG, logg).filter((b) => b.text.includes("bemöts inte"));
+ expect(brister).toHaveLength(2);
+ });
+
+ it("ett ärende utan hypoteser kräver inget bemötande", () => {
+ expect(obemottaHypoteser(ARBETE, GILTIG)).toEqual([]);
+ });
+});
+
+describe("grinden spärrar avslutet utan slutsats", () => {
+ it("saknad slutsats ger ett hinder med id slutsats_slutsats", async () => {
+ const { grinda } = await import("../../../../services/gemensam/grind.mjs");
+ const { ALLA_METODIKER } = await import("../../../../services/gemensam/metodiker.mjs");
+ const hinder = grinda([], ALLA_METODIKER.at(-1)).map((h) => h.id);
+ expect(hinder).toContain("slutsats_slutsats");
+ });
+});
diff --git a/felsokning/app/src/felsokning/domain.ts b/felsokning/app/src/felsokning/domain.ts
index c90c0e1..0f893ad 100644
--- a/felsokning/app/src/felsokning/domain.ts
+++ b/felsokning/app/src/felsokning/domain.ts
@@ -189,6 +189,30 @@ export type Handelse =
modell: string;
}
// Avslutet signeras av teknikern — slutsatsen får en ansvarig avsändare.
+ /**
+ * ALVA-RULE-200 · Teknikerns varför.
+ *
+ * Fyra frågor med olika adressat, som i dag blandas ihop till en rad i
+ * en arbetsorder — vilket är varför verkstadsprotokoll är svåra att
+ * använda i efterhand. Fälten valideras i
+ * services/gemensam/motivering.mjs och spärrar avslutet.
+ */
+ | {
+ typ: "slutsats";
+ /** Varför följer slutsatsen av evidensen? */
+ motivering: string;
+ /** Vad övervägdes och varför föll det bort? */
+ uteslutet: string;
+ /** Vad är fortfarande osäkert? Får vara "inget" — men aktivt sagt. */
+ kvarstaende: string;
+ /** Varför denna åtgärd och inte en annan? Krävs när arbete utförts. */
+ atgardsval?: string;
+ /**
+ * Falskt när orsaken inte kunnat fastställas. Det är ett giltigt
+ * utfall — men varför den inte kunde det är fortfarande ett varför.
+ */
+ orsakFastställd?: boolean;
+ }
| { typ: "arende_avslutat"; signatur?: string };
export interface LoggPost {
diff --git a/felsokning/app/src/pages/felsokning/ArendeSida.tsx b/felsokning/app/src/pages/felsokning/ArendeSida.tsx
index 53daac4..898bf9b 100644
--- a/felsokning/app/src/pages/felsokning/ArendeSida.tsx
+++ b/felsokning/app/src/pages/felsokning/ArendeSida.tsx
@@ -6,6 +6,7 @@ import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik";
import { fasFor, klaraFaser } from "../../../../services/gemensam/faser.mjs";
import { Fasrad } from "@/alva/komponenter";
+import { Slutsatspanel } from "@/felsokning/Slutsats";
import { arendebeteckning, fasDefinition } from "@/alva/system";
import {
arAvslutat,
@@ -1302,6 +1303,11 @@ function GuideFlik({
+ {/* ALVA-RULE-200. Panelen visas när metodiken är genomförd — det
+ är då varför-frågan har ett underlag att vila på. Att fråga
+ tidigare vore att be om en gissning. */}
+ {steg.klart && }
+
{steg.klart ? (
<>
diff --git a/felsokning/services/gemensam/grind.mjs b/felsokning/services/gemensam/grind.mjs
index f5fe782..d7510b2 100644
--- a/felsokning/services/gemensam/grind.mjs
+++ b/felsokning/services/gemensam/grind.mjs
@@ -15,6 +15,8 @@
// databasåtkomst, ingen tid, inget slumpmässigt — samma logg ger alltid
// samma utfall, vilket är vad som gör en spärr granskbar.
+import { granskaSlutsats } from "./motivering.mjs";
+
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.
@@ -69,7 +71,7 @@ export function grinda(handelser, metodik) {
// Å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);
+ const utfordArbete = atgarder.some((h) => h.utford === true);
krav(
"atgard",
"Åtgärd dokumenterad eller motiverad",
@@ -85,7 +87,7 @@ export function grinda(handelser, metodik) {
krav(
"avbojt_men_utfort",
"Utfört arbete trots avböjt åtgärdsförslag",
- !beslut.some((h) => h.utfall === "avbojt"),
+ !beslut.some((h) => h.beslut === "avbojt"),
"Kunden avböjde förslaget men arbete har dokumenterats som utfört.",
);
@@ -124,6 +126,19 @@ export function grinda(handelser, metodik) {
);
krav("foton", "Foton finns för fotokrävande kontroller", utanFoto.length === 0 || foton >= utanFoto.length);
+ // ALVA-RULE-200 · Slutsatsen. Ett ärende stängs aldrig utan att
+ // teknikern lämnat ett varför — motiveringen som knyter slutsatsen
+ // till underlaget, vad som uteslöts, och vad som kvarstår osäkert.
+ //
+ // Detta är den rad som saknas i varje verkstadsprotokoll: underlaget
+ // säger vad som mättes, slutsatsen vad som är fel, men ingenting
+ // varför det ena medför det andra. Det är precis det steget en
+ // garantihandläggare eller försäkringsbedömare behöver granska.
+ const slutsats = av(handelser, "slutsats").at(-1);
+ for (const brist of granskaSlutsats(slutsats, handelser)) {
+ hinder.push({ id: `slutsats_${brist.falt}`, rubrik: brist.text });
+ }
+
// 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).
diff --git a/felsokning/services/gemensam/handelser.mjs b/felsokning/services/gemensam/handelser.mjs
index ba10392..e60f53d 100644
--- a/felsokning/services/gemensam/handelser.mjs
+++ b/felsokning/services/gemensam/handelser.mjs
@@ -40,18 +40,30 @@ export const HÄNDELSESCHEMA = {
matarstallning: { lage: ["ingaende", "utgaende"], varde: "text" },
historik_kontrollerad: { kontrollerad: "boolean" },
reproducering: { status: ["ja", "delvis", "nej"], beskrivning: "text" },
- felorsak: { avvikelse: "text", orsaker: "lista", underlag: "lista", sakerhet: ["hog", "medel", "lag"] },
- atgardsforslag: { text: "text" },
- kundbeslut: { utfall: ["godkant", "avbojt", "delvis"], kanal: "text" },
- atgard_utford: { text: "text" },
- kvalitetskontroll: { utfall: ["borta", "kvarstar", "delvis", "ej_verifierat"], beskrivning: "text" },
+ felorsak: {
+ avvikelse: "text",
+ orsaker: "lista",
+ underlag: "lista",
+ sakerhet: ["hog", "medel", "lag"],
+ atgard: "text",
+ },
+ atgardsforslag: { beskrivning: "text" },
+ kundbeslut: { beslut: ["godkant", "avbojt", "delvis"], kanal: "text" },
+ atgard_utford: { beskrivning: "text", utford: "boolean" },
+ kvalitetskontroll: {
+ resultat: ["symptomet_borta", "kvarstar", "delvis", "ej_verifierbar"],
+ beskrivning: "text",
+ },
kommentar: { text: "text" },
kategori_byte: { kategori: "text" },
inaktivitet_forklarad: { text: "text", minuter: "tal" },
overlamning: { fran: "text" },
ansvarig_satt: { ansvarig: "text" },
- ai_svar: { rader: "lista" },
+ ai_svar: { rader: "lista", nastaSteg: "text", modell: "text" },
export_skapad: { format: "text", version: "tal" },
+ // ALVA-RULE-200: teknikerns varför. Fälten valideras utöver formen i
+ // services/gemensam/motivering.mjs — här kontrolleras bara att de finns.
+ slutsats: { motivering: "text", uteslutet: "text", kvarstaende: "text" },
arende_avslutat: {},
};
diff --git a/felsokning/services/gemensam/motivering.mjs b/felsokning/services/gemensam/motivering.mjs
new file mode 100644
index 0000000..0420658
--- /dev/null
+++ b/felsokning/services/gemensam/motivering.mjs
@@ -0,0 +1,242 @@
+// ALVA-RULE-200 · Closing statement.
+//
+// Ett ärende stängs aldrig utan att teknikern har lämnat ett varför.
+//
+// Det låter enkelt och är det inte. En obligatorisk fritextruta blir
+// "klart" på tredje ärendet, och då har vi bara gjort dokumentationen
+// långsammare utan att göra den bättre. Ett varför som ska hålla i en
+// garantitvist måste vara strukturerat, kopplat till evidensen, och
+// kvalitetsgranskat mot det som faktiskt skrivs när någon har bråttom.
+//
+// ---- Vad ett varför faktiskt består av -------------------------------
+//
+// Fyra frågor med olika adressat. De blandas i dag ihop till en enda
+// rad i en arbetsorder, och det är därför verkstadsprotokoll är svåra
+// att använda i efterhand:
+//
+// MOTIVERING Varför följer slutsatsen av evidensen?
+// Läses av: nästa tekniker, försäkringsbedömaren.
+// Detta är den rad som saknas i varje system jag sett.
+// Underlaget säger VAD som mättes. Slutsatsen säger VAD
+// som är fel. Ingenting säger varför det ena medför det
+// andra — och det är precis det steget en bedömare
+// behöver granska.
+//
+// UTESLUTET Vad övervägdes och varför föll det bort?
+// Läses av: försäkringsbolaget, garantihandläggaren.
+// En felsökning utan uteslutna alternativ är en gissning
+// som råkade stämma. Härleds delvis ur loggen: en
+// hypotes som dokumenterats och inte blev slutsatsen
+// MÅSTE bemötas.
+//
+// ÅTGÄRDSVAL Varför denna åtgärd och inte en annan?
+// Läses av: kunden, kostnadsgranskaren.
+// Byte eller justering? Varför hela enheten?
+//
+// KVARSTÅENDE Vad är fortfarande osäkert?
+// Läses av: nästa tekniker, flottansvarig.
+// Får vara "inget" — men det måste sägas aktivt.
+//
+// ---- Den ärliga vägen -------------------------------------------------
+//
+// Ibland vet teknikern inte varför. Systemet måste ha en väg för det som
+// inte är en lögn: orsaken kunde inte fastställas, och varför den inte
+// kunde det. Det är också ett varför, och ofta ett mer användbart än en
+// påhittad orsak.
+
+/** Fraser som ser ut som svar men inte är det. */
+const ICKESVAR = [
+ "klart",
+ "ok",
+ "okej",
+ "åtgärdat",
+ "atgardat",
+ "fixat",
+ "se ovan",
+ "se logg",
+ "enligt ovan",
+ "som vanligt",
+ "trasig",
+ "defekt",
+ "sliten",
+ "utsliten",
+ "behöver bytas",
+ "behover bytas",
+ "byttes",
+ "bytt",
+ "enligt kund",
+ "kunden sa",
+ "vet ej",
+ "ingen aning",
+ "diverse",
+ "allmänt slitage",
+ "-",
+ "n/a",
+ "na",
+];
+
+// Ett resonemang innehåller ett därför. Utan orsakssamband är texten en
+// omformulering av slutsatsen, inte ett skäl för den.
+const ORSAKSORD = [
+ "eftersom",
+ "därför",
+ "darfor",
+ "beror",
+ "orsakas",
+ "orsakade",
+ "medför",
+ "medfor",
+ "leder till",
+ "ledde till",
+ "vilket",
+ "på grund av",
+ "pga",
+ "resulterar",
+ "resulterade",
+ "innebär",
+ "innebar",
+ "följaktligen",
+ "foljaktligen",
+ "till följd av",
+ "förklarar",
+ "forklarar",
+ "stämmer överens",
+ "stammer overens",
+ "bekräftas av",
+ "bekraftas av",
+ "visar att",
+ "utesluter",
+];
+
+export const MINSTA_MOTIVERING = 40;
+export const MINSTA_KORT = 15;
+
+const normalisera = (text) => String(text ?? "").trim().toLowerCase().replace(/\s+/g, " ");
+
+/** Är texten ett icke-svar? Jämför mot hela fältet, inte delsträngar. */
+function ärIckesvar(text) {
+ const n = normalisera(text).replace(/[.!]+$/, "");
+ return ICKESVAR.includes(n);
+}
+
+function harOrsakssamband(text) {
+ const n = normalisera(text);
+ return ORSAKSORD.some((ord) => n.includes(ord));
+}
+
+/**
+ * Refererar texten till dokumenterad evidens? Godtar både uttryckliga
+ * ord för underlag och konkreta mätvärden med enhet — den som skriver
+ * "12,4 V vid stift 14" hänvisar till en mätning även utan att säga
+ * ordet "mätning".
+ */
+function refererarEvidens(text) {
+ const n = normalisera(text);
+ const ord = ["mät", "mat", "foto", "bild", "video", "felkod", "avläs", "avlas", "prov", "test", "dokument", "historik", "kontroll"];
+ const värde = /\d+([.,]\d+)?\s*(v|volt|a|ampere|ω|ohm|bar|kpa|mpa|nm|°c|c|mm|km|mil|%|g|hz|rpm|varv)\b/;
+ return ord.some((o) => n.includes(o)) || värde.test(n);
+}
+
+/**
+ * Granskar ett fält i slutsatsen.
+ * @returns null när fältet duger, annars en förklaring till teknikern.
+ */
+export function granskaFalt(namn, text, { minsta = MINSTA_KORT, kravOrsak = false } = {}) {
+ const rå = String(text ?? "").trim();
+ if (!rå) return `${namn} saknas.`;
+ if (ärIckesvar(rå)) {
+ return `${namn}: "${rå}" är inget skäl. Beskriv vad som faktiskt gäller och varför.`;
+ }
+ if (rå.length < minsta) {
+ return `${namn} är för kort (${rå.length} av minst ${minsta} tecken) för att gå att granska i efterhand.`;
+ }
+ if (kravOrsak && !harOrsakssamband(rå) && !refererarEvidens(rå)) {
+ return `${namn} anger vad, men inte varför. Knyt slutsatsen till underlaget — vad i evidensen gör att det här följer?`;
+ }
+ return null;
+}
+
+/**
+ * Hypoteser som dokumenterats men inte blivit slutsatsen.
+ *
+ * Härleds ur loggen: den som skrivit ned en misstanke och sedan landat i
+ * något annat måste säga varför den föll bort. Det är den enskilda regel
+ * som gör underlaget användbart för en försäkringsbedömare — utan den är
+ * en felsökning en gissning som råkade stämma.
+ */
+export function obemottaHypoteser(handelser, slutsats) {
+ const hypoteser = handelser.filter((h) => h?.typ === "hypotes").map((h) => h.text);
+ if (hypoteser.length === 0) return [];
+
+ const uteslutet = normalisera(slutsats?.uteslutet);
+ const orsak = normalisera(
+ [slutsats?.motivering, ...(handelser.filter((h) => h?.typ === "felorsak").map((h) => h.avvikelse) ?? [])].join(" "),
+ );
+
+ // En hypotes är bemött om något meningsbärande ord ur den återkommer i
+ // uteslutandet eller i den fastställda orsaken. Trubbigt, men det
+ // fångar den som skriver "uteslutet: inget" med tre öppna hypoteser i
+ // loggen.
+ return hypoteser.filter((text) => {
+ const ord = normalisera(text)
+ .split(/[^a-zåäöéü0-9]+/)
+ .filter((o) => o.length >= 5);
+ if (ord.length === 0) return false;
+ return !ord.some((o) => uteslutet.includes(o) || orsak.includes(o));
+ });
+}
+
+/**
+ * Granskar hela slutsatsen inför avslut.
+ *
+ * @param slutsats händelsen av typ "slutsats"
+ * @param handelser hela loggen, för de regler som härleds ur den
+ * @returns lista med brister; tom lista betyder godkänd
+ */
+export function granskaSlutsats(slutsats, handelser = []) {
+ const brister = [];
+ const lägg = (fält, text) => text && brister.push({ falt: fält, text });
+
+ if (!slutsats) {
+ return [
+ {
+ falt: "slutsats",
+ text: "Ärendet kan inte avslutas utan en slutsats. Motivera varför slutsatsen följer av underlaget.",
+ },
+ ];
+ }
+
+ const fastställd = slutsats.orsakFastställd !== false;
+
+ if (fastställd) {
+ lägg("motivering", granskaFalt("Motivering", slutsats.motivering, { minsta: MINSTA_MOTIVERING, kravOrsak: true }));
+ } else {
+ // Den ärliga vägen. Att orsaken inte kunnat fastställas är ett
+ // giltigt utfall — men varför den inte kunnat det är fortfarande ett
+ // varför, och det måste stå där.
+ lägg(
+ "motivering",
+ granskaFalt("Skäl till att orsaken inte fastställts", slutsats.motivering, {
+ minsta: MINSTA_MOTIVERING,
+ kravOrsak: true,
+ }),
+ );
+ }
+
+ lägg("uteslutet", granskaFalt("Uteslutna alternativ", slutsats.uteslutet, { minsta: MINSTA_KORT }));
+ lägg("kvarstaende", granskaFalt("Kvarstående osäkerhet", slutsats.kvarstaende, { minsta: 5 }));
+
+ const utfördArbete = handelser.some((h) => h?.typ === "atgard_utford" && h.utford === true);
+ if (utfördArbete) {
+ lägg("atgardsval", granskaFalt("Val av åtgärd", slutsats.atgardsval, { minsta: MINSTA_KORT, kravOrsak: true }));
+ }
+
+ for (const text of obemottaHypoteser(handelser, slutsats)) {
+ brister.push({
+ falt: "uteslutet",
+ text: `Hypotesen "${text}" finns i loggen men bemöts inte. Ange varför den uteslöts, eller varför den kvarstår.`,
+ });
+ }
+
+ return brister;
+}
diff --git a/felsokning/services/plattform/motivering.mjs b/felsokning/services/plattform/motivering.mjs
new file mode 120000
index 0000000..c3210f1
--- /dev/null
+++ b/felsokning/services/plattform/motivering.mjs
@@ -0,0 +1 @@
+../gemensam/motivering.mjs
\ No newline at end of file
diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs
index c685f8b..99b6f12 100644
--- a/felsokning/services/plattform/server.mjs
+++ b/felsokning/services/plattform/server.mjs
@@ -288,6 +288,17 @@ export const DELBART_KUND = [
"atgard_utford",
"kvalitetskontroll",
"export_skapad",
+ // ALVA-RULE-200 · Teknikerns varför delas med kunden.
+ //
+ // Det är den enda raden som besvarar "varför kostade det här vad det
+ // kostade", och den enda en försäkringsbedömare faktiskt behöver.
+ // Att hålla den intern vore att bygga funktionen och sedan gömma den.
+ //
+ // Fältet "uteslutet" nämner hypoteser som förkastats. Det är inte
+ // samma sak som att dela en öppen hypotes: en utesluten misstanke,
+ // redovisad som utesluten, är stärkande — "vi kontrollerade hjullagret
+ // och det var helt" är precis vad kunden betalade för att få veta.
+ "slutsats",
"arende_avslutat",
];