Spärra avslut utan slutsats även i klienten (ALVA-RULE-200)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-05 16:20:27 +00:00
parent cd6b2caca5
commit 590fab3a70
2 changed files with 101 additions and 16 deletions
@@ -178,3 +178,50 @@ describe("grinden spärrar avslutet utan slutsats", () => {
expect(hinder).toContain("slutsats_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<string, unknown>[]) =>
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);
});
});
@@ -8,6 +8,7 @@ import { fasFor, klaraFaser } from "../../../../services/gemensam/faser.mjs";
import { Fasrad } from "@/alva/komponenter"; import { Fasrad } from "@/alva/komponenter";
import { Slutsatspanel } from "@/felsokning/Slutsats"; import { Slutsatspanel } from "@/felsokning/Slutsats";
import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs"; import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs";
import { granskaSlutsats } from "../../../../services/gemensam/motivering.mjs";
import { arendebeteckning, fasDefinition } from "@/alva/system"; import { arendebeteckning, fasDefinition } from "@/alva/system";
import { import {
arAvslutat, arAvslutat,
@@ -1252,12 +1253,26 @@ function GuideFlik({
// åtgärd faktiskt utförts — kvalitetskontroll av att symptomet är borta. // åtgärd faktiskt utförts — kvalitetskontroll av att symptomet är borta.
const atgardsposter = atgarder(arende); const atgardsposter = atgarder(arende);
const utfordAtgard = atgardsposter.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford); 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) && !!reproducering(arende) &&
felorsaker(arende).length > 0 && felorsaker(arende).length > 0 &&
atgardsposter.length > 0 && atgardsposter.length > 0 &&
(!utfordAtgard || !!kvalitetskontroll(arende)); (!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) { if (avslutat) {
return ( return (
<Panel rubrik="Felsökningen är avslutad"> <Panel rubrik="Felsökningen är avslutad">
@@ -1304,10 +1319,12 @@ function GuideFlik({
</p> </p>
</div> </div>
{/* ALVA-RULE-200. Panelen visas när metodiken är genomförd — det {/* ALVA-RULE-200. Panelen visas när det finns ett underlag att
är då varför-frågan har ett underlag att vila på. Att fråga vila på — antingen för att metodiken är genomförd eller för att
tidigare vore att be om en gissning. */} kedjan reproducering → felorsak → åtgärd → kvalitetskontroll är
{steg.klart && <Slutsatspanel arende={arende} skicka={skicka} />} 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) && <Slutsatspanel arende={arende} skicka={skicka} />}
<Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}> <Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}>
{steg.klart ? ( {steg.klart ? (
@@ -1320,11 +1337,7 @@ function GuideFlik({
<StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}> <StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
Avsluta felsökning Avsluta felsökning
</StorKnapp> </StorKnapp>
{!kanAvslutas && ( {!kanAvslutas && <Avslutshinder underlagKlart={underlagKlart} brister={slutsatsbrister} />}
<p className="mt-2 text-[12px] font-semibold text-[#9A6700]">
Avslut kräver symptomverifiering, felorsaksanalys, åtgärd och kvalitetskontroll se panelerna nedan.
</p>
)}
</> </>
) : steg.sparr ? ( ) : steg.sparr ? (
// Säkerhetsspärr. Metodiken går inte vidare — svaret är ett // Säkerhetsspärr. Metodiken går inte vidare — svaret är ett
@@ -1397,12 +1410,7 @@ function GuideFlik({
</Panel> </Panel>
)} )}
{!kanAvslutas && ( {!kanAvslutas && <Avslutshinder underlagKlart={underlagKlart} brister={slutsatsbrister} />}
<p className="mb-2 text-[12px] font-semibold text-[#9A6700]">
Avslut kräver hela kedjan: symptomverifiering, felorsaksanalys, dokumenterad åtgärd och vid utförd åtgärd
kvalitetskontroll av att symptomet är borta.
</p>
)}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}> <StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
Lämna över arbete 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 (
<div className="mb-2 border-l-2 border-[#9A6700] bg-[#FFFBF0] px-4 py-4">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-[#9A6700]">
Ärendet kan inte avslutas ännu
</p>
{!underlagKlart && (
<p className="mb-2 text-[13px] leading-[20px] text-[#333333]">
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.
</p>
)}
{brister.length > 0 && (
<ul className="ml-4 list-disc text-[13px] leading-[20px] text-[#333333]">
{brister.map((b) => (
<li key={b.falt}>{b.text}</li>
))}
</ul>
)}
</div>
);
}
function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) { function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
let aktiv: TidKategori = "aktiv_felsokning"; let aktiv: TidKategori = "aktiv_felsokning";
for (const post of arende.handelser) { for (const post of arende.handelser) {