From 590fab3a70fc187f74eec382f2131b89de9536c5 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 16:20:27 +0000
Subject: [PATCH] =?UTF-8?q?Sp=C3=A4rra=20avslut=20utan=20slutsats=20=C3=A4?=
=?UTF-8?q?ven=20i=20klienten=20(ALVA-RULE-200)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Kvalitetsgrinden på servern krävde en slutsats, men klientens
avslutsknapp gjorde inte det. Ett ärende gick därför att stänga på
skärmen utan ett varför, och teknikern fick beskedet först vid synk —
den sämsta tänkbara tidpunkten, eftersom bilen då har lämnat verkstaden.
Klienten anropar nu granskaSlutsats() — samma funktion som grinden, inte
en klientkopia av regeln — och avslutsknappen är spärrad tills den är
ren. Slutsatspanelen visas så fort underlaget är helt, inte bara när
metodikens sista steg är avbockat, så att frågan kommer när den går att
besvara och inte som ett hinder efteråt.
Hindret säger vad som fattas i stället för att bara konstatera att kraven
inte är uppfyllda: först underlaget, sedan de enskilda bristerna i
slutsatsen.
Fyra tester jämför verdikt mellan klient och grind för samma logg, så att
de två ändarna inte kan glida isär igen.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
---
.../src/felsokning/__tests__/slutsats.test.ts | 47 +++++++++++++
.../app/src/pages/felsokning/ArendeSida.tsx | 70 ++++++++++++++-----
2 files changed, 101 insertions(+), 16 deletions(-)
diff --git a/felsokning/app/src/felsokning/__tests__/slutsats.test.ts b/felsokning/app/src/felsokning/__tests__/slutsats.test.ts
index 94d8b9a..8b4f135 100644
--- a/felsokning/app/src/felsokning/__tests__/slutsats.test.ts
+++ b/felsokning/app/src/felsokning/__tests__/slutsats.test.ts
@@ -178,3 +178,50 @@ describe("grinden spärrar avslutet utan slutsats", () => {
expect(hinder).toContain("slutsats_slutsats");
});
});
+
+// ---- Klient och grind måste ge samma svar --------------------------------
+//
+// Bakgrunden till det här testet är en riktig bugg: kvalitetsgrinden på
+// servern krävde en slutsats, men klientens avslutsknapp gjorde inte det.
+// Demoärendet gick därför att stänga på skärmen utan ett varför, och
+// teknikern fick beskedet först vid synk — vilket är den sämsta tänkbara
+// tidpunkten, eftersom bilen då har lämnat verkstaden.
+//
+// Testet jämför inte texter utan verdikt: samma logg ska ge samma svar på
+// frågan "får detta ärende stängas?" i båda ändar.
+describe("avslutsvillkoret är detsamma i klienten och i grinden", () => {
+ const slutsatsHinder = async (handelser: unknown[]) => {
+ const { grinda } = await import("../../../../services/gemensam/grind.mjs");
+ const { ALLA_METODIKER } = await import("../../../../services/gemensam/metodiker.mjs");
+ return grinda(handelser, ALLA_METODIKER.at(-1))
+ .map((h) => h.id)
+ .filter((id) => id.startsWith("slutsats_"));
+ };
+
+ // Precis det uttryck klienten använder i ArendeSida.kanAvslutas.
+ const klientBrister = (handelser: Record[]) =>
+ granskaSlutsats([...handelser].reverse().find((h) => h.typ === "slutsats"), handelser);
+
+ it("en logg utan slutsats spärras i båda ändar", async () => {
+ expect(klientBrister(ARBETE).length).toBeGreaterThan(0);
+ expect(await slutsatsHinder(ARBETE)).not.toHaveLength(0);
+ });
+
+ it("en logg med fullständig slutsats släpps igenom i båda ändar", async () => {
+ const logg = [...ARBETE, GILTIG];
+ expect(klientBrister(logg)).toEqual([]);
+ expect(await slutsatsHinder(logg)).toEqual([]);
+ });
+
+ it("en slutsats med tunn motivering spärras i båda ändar", async () => {
+ const logg = [...ARBETE, { ...GILTIG, motivering: "Klart." }];
+ expect(klientBrister(logg).length).toBeGreaterThan(0);
+ expect(await slutsatsHinder(logg)).not.toHaveLength(0);
+ });
+
+ it("en obemött hypotes spärras i båda ändar", async () => {
+ const logg = [...ARBETE, { typ: "hypotes", text: "Misstänkt glapp i hjullagret höger fram", niva: "medel" }, GILTIG];
+ expect(klientBrister(logg).length).toBeGreaterThan(0);
+ expect(await slutsatsHinder(logg)).not.toHaveLength(0);
+ });
+});
diff --git a/felsokning/app/src/pages/felsokning/ArendeSida.tsx b/felsokning/app/src/pages/felsokning/ArendeSida.tsx
index 829214e..73b47fc 100644
--- a/felsokning/app/src/pages/felsokning/ArendeSida.tsx
+++ b/felsokning/app/src/pages/felsokning/ArendeSida.tsx
@@ -8,6 +8,7 @@ import { fasFor, klaraFaser } from "../../../../services/gemensam/faser.mjs";
import { Fasrad } from "@/alva/komponenter";
import { Slutsatspanel } from "@/felsokning/Slutsats";
import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs";
+import { granskaSlutsats } from "../../../../services/gemensam/motivering.mjs";
import { arendebeteckning, fasDefinition } from "@/alva/system";
import {
arAvslutat,
@@ -1252,12 +1253,26 @@ function GuideFlik({
// åtgärd faktiskt utförts — kvalitetskontroll av att symptomet är borta.
const atgardsposter = atgarder(arende);
const utfordAtgard = atgardsposter.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford);
- const kanAvslutas =
+
+ // Underlaget: det som gör att en slutsats över huvud taget går att
+ // motivera. Innan detta är på plats vore varför-frågan en begäran om
+ // en gissning.
+ const underlagKlart =
!!reproducering(arende) &&
felorsaker(arende).length > 0 &&
atgardsposter.length > 0 &&
(!utfordAtgard || !!kvalitetskontroll(arende));
+ // ALVA-RULE-200. Samma granskning som kvalitetsgrinden på servern kör
+ // — avsiktligt samma funktion, inte en klientkopia av regeln. En
+ // tekniker ska aldrig få veta vid synk att ärendet inte gick att
+ // stänga; hindret ska synas på skärmen där arbetet utförs.
+ const slutsatsbrister = granskaSlutsats(
+ [...arende.handelser].reverse().find((p) => p.handelse.typ === "slutsats")?.handelse,
+ arende.handelser.map((p) => p.handelse),
+ );
+ const kanAvslutas = underlagKlart && slutsatsbrister.length === 0;
+
if (avslutat) {
return (
@@ -1304,10 +1319,12 @@ 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 && }
+ {/* ALVA-RULE-200. Panelen visas när det finns ett underlag att
+ vila på — antingen för att metodiken är genomförd eller för att
+ kedjan reproducering → felorsak → åtgärd → kvalitetskontroll är
+ hel. Att fråga tidigare vore att be om en gissning; att fråga
+ först vid avslutsknappen vore att neka någon som redan är klar. */}
+ {(steg.klart || underlagKlart) && }
{steg.klart ? (
@@ -1320,11 +1337,7 @@ function GuideFlik({
skicka({ typ: "arende_avslutat", signatur: anvandare })}>
Avsluta felsökning
- {!kanAvslutas && (
-
- Avslut kräver symptomverifiering, felorsaksanalys, åtgärd och kvalitetskontroll — se panelerna nedan.
-
- )}
+ {!kanAvslutas && }
>
) : steg.sparr ? (
// Säkerhetsspärr. Metodiken går inte vidare — svaret är ett
@@ -1397,12 +1410,7 @@ function GuideFlik({
)}
- {!kanAvslutas && (
-
- Avslut kräver hela kedjan: symptomverifiering, felorsaksanalys, dokumenterad åtgärd och — vid utförd åtgärd
- — kvalitetskontroll av att symptomet är borta.
-
- )}
+ {!kanAvslutas && }
setVisaOverlamning(true)}>
Lämna över arbete
@@ -1419,6 +1427,36 @@ function GuideFlik({
);
}
+/**
+ * Varför avslut är spärrat — i klartext, på skärmen, medan arbetet pågår.
+ *
+ * Ett hinder som bara säger "kraven är inte uppfyllda" tvingar teknikern
+ * att leta. Här står vad som fattas, i den ordning det behöver åtgärdas:
+ * först underlaget, sedan varför-frågan.
+ */
+function Avslutshinder({ underlagKlart, brister }: { underlagKlart: boolean; brister: { falt: string; text: string }[] }) {
+ return (
+
+
+ Ärendet kan inte avslutas ännu
+
+ {!underlagKlart && (
+
+ Underlaget är ofullständigt: avslut kräver symptomverifiering, felorsaksanalys, dokumenterad åtgärd och —
+ vid utförd åtgärd — kvalitetskontroll av att symptomet är borta. Se panelerna nedan.
+
+ )}
+ {brister.length > 0 && (
+
+ {brister.map((b) => (
+ - {b.text}
+ ))}
+
+ )}
+
+ );
+}
+
function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
let aktiv: TidKategori = "aktiv_felsokning";
for (const post of arende.handelser) {