Felorsaksanalys och symptomverifiering (SVP) som obligatoriska grindar

Skillnaden mellan VAD som är fel och VARFÖR felet uppstått är nu kodad:
ett ärende kan aldrig avslutas med enbart "komponent defekt, byt
komponent", och en kundbeskrivning blir aldrig ett konstaterat fel utan
verifiering.

Symptom Verification Protocol:
- Ny händelse reproducering (ja/delvis/nej): ja kräver hur/förhållanden,
  delvis vad som kunde respektive inte kunde återskapas, nej kräver
  motivering
- Generiska metodiken utökad med SVP-frågorna var/hur
- Rapportens beviskedja skiljer kundens beskrivning, verifierad
  observation, felorsaksanalys och rekommenderad åtgärd; "kunde inte
  reproduceras under de förhållanden som rådde" i stället för "felet
  konstaterat" — kodat även i orkesterns grundprompt

Felorsaksanalys (Root Cause Analysis):
- Ny händelse felorsak: avvikelse + orsakskategorier + underlag +
  säkerhetsnivå + rekommenderad åtgärd
- Kvalitetsregeln avvisar generella formuleringar ("trasig", "defekt",
  "sliten", "behöver bytas") utan förklaring
- Valda evidenskällor valideras mot loggen — "Foto" godtas bara om ett
  foto faktiskt finns
- Okänd orsak kräver motivering; medel/låg säkerhet kräver vilka
  ytterligare kontroller som stärker bedömningen
- Avslutsknappen spärrad tills SVP + felorsak dokumenterats;
  kvalitetsgrinden gör båda obligatoriska vid stängning

Demoärendet bär hela beviskedjan; 54 vitest-tester, integrationstest
och OpenAPI-validering gröna.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 09:26:29 +00:00
parent bfed5dbd7f
commit ddc11c6ac2
11 changed files with 553 additions and 6 deletions
+2
View File
@@ -36,6 +36,8 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. | | Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. |
| Evidensmotor (ECM) | ✅ Eget subsystem med sex motorer ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, **ECM v2.0**): Evidence (evidensposter med nivå E0E6, tekniker och innehållshash), Rule (dokumentationskrav + undantagsregeln med obligatorisk orsak), Compliance (ärendetypen — garanti/försäkring/reklamation m.fl. — styr extra krav), Validation ("Evidens saknas" i stället för antaganden, kodat i orkesterns grundprompt), Completion (kvalitetsgrind som spärrar slutrapporten) och Traceability (spårbarhetspaket med regelversion + hash i varje export). | | Evidensmotor (ECM) | ✅ Eget subsystem med sex motorer ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, **ECM v2.0**): Evidence (evidensposter med nivå E0E6, tekniker och innehållshash), Rule (dokumentationskrav + undantagsregeln med obligatorisk orsak), Compliance (ärendetypen — garanti/försäkring/reklamation m.fl. — styr extra krav), Validation ("Evidens saknas" i stället för antaganden, kodat i orkesterns grundprompt), Completion (kvalitetsgrind som spärrar slutrapporten) och Traceability (spårbarhetspaket med regelversion + hash i varje export). |
| Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik (Ja med ev. orsakskedja / Nej med obligatorisk orsak → kvalitetsvarning), **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. | | Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik (Ja med ev. orsakskedja / Nej med obligatorisk orsak → kvalitetsvarning), **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. |
| Symptomverifiering (SVP) | ✅ Kundens beskrivning ≠ konstaterat fel: beskrivningen dokumenteras ordagrant, förtydligas via metodikens symptomfrågor (generiska metodiken har SVP-setet när/var/hur) och reproduceras — Ja (hur/förhållanden), Delvis (vad kunde/kunde inte) eller Nej (obligatorisk motivering). Rapportens beviskedja skiljer kundens beskrivning, verifierad observation, felorsaksanalys och rekommenderad åtgärd; formuleringen "kunde inte reproduceras under de förhållanden som rådde" används i stället för "felet konstaterat" (kodat även i orkesterns grundprompt). |
| Felorsaksanalys | ✅ Obligatorisk före avslut: konstaterad avvikelse (kvalitetsregeln avvisar "trasig/defekt/sliten" utan förklaring), orsakskategorier (inkl. Okänd orsak med krav på motivering), minst en evidenskälla som valideras mot loggen, säkerhetsnivå (medel/låg kräver stärkande kontroller) och rekommenderad åtgärd. Avslutsknappen spärrad tills SVP + felorsak finns; kvalitetsgrinden gör båda obligatoriska vid stängning. Eget avsnitt i slutrapporten. |
| Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. | | Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. |
| Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. | | Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. |
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. |
+43
View File
@@ -104,6 +104,49 @@ dokumenterat motiverade — metodiken låses upp först därefter:
**Utgående mätarställning** fotograferas inför avslut och blir **Utgående mätarställning** fotograferas inför avslut och blir
obligatorisk i grinden när ärendet stängs. Rapporten visar in/ut. obligatorisk i grinden när ärendet stängs. Rapporten visar in/ut.
## Symptom Verification Protocol (SVP)
Ett fel diagnostiseras aldrig direkt från en vag kundbeskrivning.
Kedjan är alltid: **dokumenterats → förtydligats → reproducerats eller
dokumenterats som ej reproducerbart.**
- Kundens beskrivning registreras ordagrant vid ärendestart och
verifieras i pre-diagnostiken; nya symptom blir separata observationer.
- Förtydligandet sker genom metodikens symptomfrågor (när/var/hur/
förhållanden/frekvens — generiska metodiken har hela SVP-frågesetet).
- **Reproducering** (Ja/Delvis/Nej) dokumenteras innan avslut: Ja kräver
hur och under vilka förhållanden; Delvis vad som kunde respektive inte
kunde återskapas; Nej kräver motivering. Systemet skriver aldrig
"felet konstaterat" utan reproducering eller annan verifiering — i
stället: *"Kundens beskrivning kunde inte reproduceras under de
förhållanden som rådde vid undersökningen."* (kodat även i orkesterns
grundprompt).
- Rapportens beviskedja skiljer alltid: kundens beskrivning →
verifierad observation → felorsaksanalys → rekommenderad åtgärd.
## Felorsaksanalys (Root Cause Analysis)
Ett ärende avslutas aldrig med enbart "komponent defekt, byt komponent".
Varje konstaterat fel kräver fyra obligatoriska svar:
1. **Konstaterad avvikelse** — kvalitetsregeln avvisar generella
formuleringar ("trasig", "defekt", "sliten", "behöver bytas") utan
förklaring.
2. **Mest sannolik orsak** — en eller flera kategorier (normalt slitage,
materialutmattning, tillverkningsfel, bristande underhåll, felaktig
tidigare reparation, yttre påverkan, korrosion, överhettning,
modifiering … samt *Okänd orsak*, som kräver motivering).
3. **Underlag** — minst en evidenskälla, och källan valideras mot
loggen: "Foto" godtas bara om ett foto faktiskt finns.
4. **Säkerhetsnivå** — hög/medel/låg; vid medel/låg krävs vilka
ytterligare kontroller som skulle stärka bedömningen.
Avslutsknappen är spärrad tills SVP + felorsaksanalys är dokumenterade,
och kvalitetsgrinden gör båda obligatoriska när ärendet stängs. Efter
tusentals ärenden ger orsakskategorierna dessutom flottdata: vilka
komponenter fallerar av slitage, vilka efter tidigare reparationer,
vilka tyder på konstruktionsproblem.
## Ärendeidentitet (Case Identity & Vehicle Context) ## Ärendeidentitet (Case Identity & Vehicle Context)
Fordonsobjektet är den röda tråden: identiteten registreras **en gång** Fordonsobjektet är den röda tråden: identiteten registreras **en gång**
+1
View File
@@ -33,6 +33,7 @@ Absoluta regler:
- "rekommendation": nästa verifierbara kontroll eller mätning. - "rekommendation": nästa verifierbara kontroll eller mätning.
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras. - Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
- Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen. - Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen.
- Skriv aldrig "felet konstaterat" om symptomet varken reproducerats eller verifierats med dokumentation — skriv då "Kundens beskrivning kunde inte reproduceras under de förhållanden som rådde vid undersökningen."
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`; - Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
const SVARS_SCHEMA = { const SVARS_SCHEMA = {
+2
View File
@@ -571,6 +571,8 @@ components:
- arendetyp_satt - arendetyp_satt
- historik_kontrollerad - historik_kontrollerad
- matarstallning - matarstallning
- reproducering
- felorsak
- export_skapad - export_skapad
- ai_svar - ai_svar
- arende_avslutat - arende_avslutat
+65
View File
@@ -189,3 +189,68 @@ describe("Traceability Engine + ärendeidentitet", () => {
expect(evidensNiva(demo)).toBe("E6"); expect(evidensNiva(demo)).toBe("E6");
}); });
}); });
describe("Felorsaksanalys (Root Cause Analysis)", () => {
it("kvalitetsregeln avvisar generella formuleringar utan förklaring", async () => {
const { granskaAvvikelse } = await import("../ecm");
for (const daligt of ["Trasig.", "defekt", "Sliten", "Behöver bytas", "kort"]) {
expect(granskaAvvikelse(daligt), daligt).not.toBeNull();
}
expect(
granskaAvvikelse("Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse."),
).toBeNull();
});
it("valda evidenskällor måste finnas i loggen", async () => {
const { underlagFinns } = await import("../ecm");
const utanFoto = byggArende([OBJEKT, { typ: "matvarde", beskrivning: "U", varde: "12" }]);
expect(underlagFinns(utanFoto, "Foto")).toBe(false);
expect(underlagFinns(utanFoto, "Mätresultat")).toBe(true);
expect(underlagFinns(utanFoto, "Servicehistorik")).toBe(false);
const medHistorik = byggArende([{ typ: "historik_kontrollerad", kontrollerad: true }]);
expect(underlagFinns(medHistorik, "Servicehistorik")).toBe(true);
});
it("avslut kräver reproducering och felorsak — grinden blir obligatorisk vid stängning", () => {
const stangdUtan = byggArende([OBJEKT, ...PREDIAG, { typ: "arende_avslutat" }]);
const rader = kvalitetsgrind(stangdUtan, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "svp")?.kravs).toBe(true);
expect(rader.find((r) => r.id === "svp")?.ok).toBe(false);
expect(rader.find((r) => r.id === "felorsak")?.ok).toBe(false);
const stangdMed = byggArende([
OBJEKT,
...PREDIAG,
{ typ: "reproducering", status: "nej", beskrivning: "Felet uppträdde inte vid provkörning; fordonet var varmt." },
{
typ: "felorsak",
avvikelse: "Ingen avvikelse kunde konstateras vid undersökningen trots systematisk genomgång.",
orsaker: ["Okänd orsak"],
underlag: ["Direkt observation"],
sakerhet: "lag",
atgard: "Återkom vid kall motor så att symptomet kan reproduceras.",
motivering: "Symptomet kunde inte reproduceras under rådande förhållanden.",
},
{ typ: "observation", text: "Systematisk genomgång utan anmärkning" },
{ typ: "arende_avslutat" },
]);
const rader2 = kvalitetsgrind(stangdMed, VIBRATION_METODIK);
expect(rader2.find((r) => r.id === "svp")?.ok).toBe(true);
expect(rader2.find((r) => r.id === "svp")?.detalj).toContain("kunde inte reproduceras");
expect(rader2.find((r) => r.id === "felorsak")?.ok).toBe(true);
});
it("rapportformuleringen skiljer reproducerat från ej reproducerat", async () => {
const { reproduceringsText } = await import("../ecm");
expect(reproduceringsText("ja")).toContain("reproducerades");
expect(reproduceringsText("nej")).toContain("kunde inte reproduceras under de förhållanden");
});
it("demoärendet bär hela beviskedjan: reproducering + felorsak med underlag", () => {
const demo = byggDemoArende(1);
const repro = demo.handelser.find((p) => p.handelse.typ === "reproducering");
expect(repro).toBeDefined();
const orsak = demo.handelser.find((p) => p.handelse.typ === "felorsak");
expect(orsak?.handelse.typ === "felorsak" && orsak.handelse.underlag.length > 0).toBe(true);
});
});
+14
View File
@@ -105,6 +105,20 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
post(82, { typ: "matvarde", beskrivning: "Obalans höger framhjul", varde: "38", enhet: "g" }), post(82, { typ: "matvarde", beskrivning: "Obalans höger framhjul", varde: "38", enhet: "g" }),
post(83, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "balansering", text: "Kontrollera hjulbalansering", resultat: "38 g obalans höger fram — en balansvikt saknas. Övriga hjul inom 5 g." }), post(83, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "balansering", text: "Kontrollera hjulbalansering", resultat: "38 g obalans höger fram — en balansvikt saknas. Övriga hjul inom 5 g." }),
post(85, { typ: "observation", text: "Spår efter lossnad klistervikt på höger framfälgs insida" }), post(85, { typ: "observation", text: "Spår efter lossnad klistervikt på höger framfälgs insida" }),
post(85, {
typ: "reproducering",
status: "ja",
beskrivning: "Reproducerad vid provkörning på plan väg: vibrationen återskapades tre gånger mellan 92 och 108 km/h, tydligast i ratten.",
}),
post(86, {
typ: "felorsak",
avvikelse: "Framhjulen uppvisar obalans (38 g höger fram). Ingen deformation av fälgar eller däck kunde konstateras.",
orsaker: ["Yttre påverkan"],
underlag: ["Mätresultat", "Foto", "Direkt observation"],
sakerhet: "medel",
atgard: "Balansera framhjulen och genomför ny provkörning i 80100 km/h.",
ytterligareKontroller: "Ny provkörning efter balansering bekräftar att symptomet är åtgärdat.",
}),
// Exempel på AI-handledning, märkt "demo" — riktiga svar kräver API-nyckel. // Exempel på AI-handledning, märkt "demo" — riktiga svar kräver API-nyckel.
post(86, { post(86, {
typ: "ai_svar", typ: "ai_svar",
+25
View File
@@ -80,6 +80,23 @@ export type Handelse =
| { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string } | { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string }
// Officiell mätarställning in/ut, normalt med foto av instrumentpanelen. // Officiell mätarställning in/ut, normalt med foto av instrumentpanelen.
| { typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; dataUrl?: string; undantag?: string } | { typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; dataUrl?: string; undantag?: string }
// Symptomverifiering (SVP): kundens beskrivning är inte ett konstaterat
// fel förrän den reproducerats — eller dokumenterats som ej
// reproducerbar med motivering.
| { typ: "reproducering"; status: "ja" | "delvis" | "nej"; beskrivning: string }
// Felorsaksanalys: varje konstaterat fel kräver avvikelse, bedömd
// grundorsak, underlag och säkerhetsnivå — eller motivering till
// varför orsaken inte kunnat fastställas.
| {
typ: "felorsak";
avvikelse: string;
orsaker: string[];
underlag: string[];
sakerhet: Tillforlitlighet;
atgard: string;
motivering?: string;
ytterligareKontroller?: string;
}
| { typ: "export_skapad"; format: string; version: number } | { typ: "export_skapad"; format: string; version: number }
| { | {
typ: "ai_svar"; typ: "ai_svar";
@@ -162,6 +179,14 @@ export function handelseRubrik(post: LoggPost): string {
case "matarstallning": case "matarstallning":
if (h.undantag) return `Mätarställning (${h.lage === "ingaende" ? "in" : "ut"}) kunde inte dokumenteras: ${h.undantag}`; if (h.undantag) return `Mätarställning (${h.lage === "ingaende" ? "in" : "ut"}) kunde inte dokumenteras: ${h.undantag}`;
return `Mätarställning ${h.lage === "ingaende" ? "in" : "ut"}: ${h.varde}`; return `Mätarställning ${h.lage === "ingaende" ? "in" : "ut"}: ${h.varde}`;
case "reproducering":
return h.status === "ja"
? `Symptomet reproducerat: ${h.beskrivning}`
: h.status === "delvis"
? `Symptomet delvis reproducerat: ${h.beskrivning}`
: `Symptomet kunde inte reproduceras — ${h.beskrivning}`;
case "felorsak":
return `Felorsak (${TILLFORLITLIGHET_LABEL[h.sakerhet]}): ${h.avvikelse}${h.orsaker.join(", ")}`;
case "export_skapad": case "export_skapad":
return `Export skapad: ${h.format}, version ${h.version}`; return `Export skapad: ${h.format}, version ${h.version}`;
case "ai_svar": { case "ai_svar": {
+119
View File
@@ -121,6 +121,98 @@ export const MARKOR_FELBESKRIVNING_VERIFIERAD = "Kundens felbeskrivning verifier
export const MARKOR_INGA_TIDIGA_OBSERVATIONER = "Inga ytterligare observationer vid mottagandet"; export const MARKOR_INGA_TIDIGA_OBSERVATIONER = "Inga ytterligare observationer vid mottagandet";
export const MARKOR_TIDIGA_OBSERVATIONER_KLARA = "Tidiga observationer vid mottagandet dokumenterade"; export const MARKOR_TIDIGA_OBSERVATIONER_KLARA = "Tidiga observationer vid mottagandet dokumenterade";
// -- Felorsaksanalys (Root Cause Analysis) --
// Ett ärende avslutas aldrig med enbart "komponent trasig, byt komponent":
// varje konstaterat fel kräver avvikelse, bedömd grundorsak, underlag och
// säkerhetsnivå — eller en motivering till varför orsaken inte fastställts.
export const ORSAKSKATEGORIER = [
"Normalt slitage",
"Ålder",
"Körsträcka",
"Materialutmattning",
"Tillverkningsfel",
"Bristande underhåll",
"Felaktig tidigare reparation",
"Yttre påverkan",
"Korrosion",
"Överhettning",
"Förorening",
"Felaktig användning",
"Modifiering",
"Olycka eller skada",
"Okänd orsak",
];
export const UNDERLAGSKALLOR = [
"Foto",
"Video",
"Mätresultat",
"Diagnosutläsning",
"Tidigare historik",
"Servicehistorik",
"Teknisk dokumentation",
"Direkt observation",
];
// Kvalitetsregeln: generella formuleringar utan förklaring avvisas.
const GENERISKA_FRASER = /^(trasig|defekt|sliten|utsliten|kass|död|behöver bytas|byt(es)?( ut)?|fungerar inte|går sönder|sönder)[.!]?$/i;
export function granskaAvvikelse(text: string): string | null {
const ren = text.trim();
if (ren.length < 20 || GENERISKA_FRASER.test(ren)) {
return "Felorsak saknas. Beskriv den konstaterade avvikelsen — inte bara komponenten. Exempel: ”Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse.”";
}
return null;
}
// Underlaget måste finnas: en vald evidenskälla godtas bara om loggen
// faktiskt innehåller den sortens evidens.
export function underlagFinns(arende: Arende, kalla: string): boolean {
const h = arende.handelser.map((p) => p.handelse);
switch (kalla) {
case "Foto":
return h.some((x) => x.typ === "foto");
case "Mätresultat":
return h.some((x) => x.typ === "matvarde");
case "Diagnosutläsning":
return h.some((x) => x.typ === "foto" && /instrument|diagnos/i.test(x.beskrivning));
case "Tidigare historik":
case "Servicehistorik":
return h.some((x) => x.typ === "historik_kontrollerad" && x.kontrollerad);
case "Direkt observation":
return h.some((x) => x.typ === "observation" || x.typ === "kontroll_utford");
default:
// Video/teknisk dokumentation: kan ännu inte verifieras maskinellt.
return true;
}
}
export function felorsaker(arende: Arende) {
return arende.handelser.filter((p) => p.handelse.typ === "felorsak");
}
// -- Symptom Verification Protocol (SVP) --
// Kundens beskrivning → förtydligande (metodikens symptomfrågor) →
// reproducering (eller dokumenterat ej reproducerbar). Rapporten skiljer
// alltid kundens upplevelse från teknikerns verifierade observationer.
export function reproducering(arende: Arende) {
let senaste: { status: "ja" | "delvis" | "nej"; beskrivning: string } | undefined;
for (const post of arende.handelser) {
if (post.handelse.typ === "reproducering") senaste = post.handelse;
}
return senaste;
}
// Rapportformuleringen styrs av verifieringsläget — aldrig "felet
// konstaterat" utan reproducering eller annan dokumenterad verifiering.
export function reproduceringsText(status: "ja" | "delvis" | "nej"): string {
if (status === "ja") return "Symptomet reproducerades vid undersökningen.";
if (status === "delvis") return "Symptomet kunde delvis reproduceras vid undersökningen.";
return "Kundens beskrivning kunde inte reproduceras under de förhållanden som rådde vid undersökningen.";
}
// ---- 3. Compliance Engine --------------------------------------------- // ---- 3. Compliance Engine ---------------------------------------------
// Ärendetypen styr vilka dokumentationskrav som gäller utöver metodiken. // Ärendetypen styr vilka dokumentationskrav som gäller utöver metodiken.
@@ -315,6 +407,33 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: felb.klar ? undefined : "Bekräfta att kundens beskrivning är korrekt återgiven.", detalj: felb.klar ? undefined : "Bekräfta att kundens beskrivning är korrekt återgiven.",
}); });
// SVP: symptomet reproducerat eller dokumenterat ej reproducerbart —
// obligatoriskt innan ärendet avslutas.
const repro = reproducering(arende);
rader.push({
id: "svp",
rubrik: "Symptomverifiering: reproducerat eller dokumenterat ej reproducerbart",
ok: !!repro,
kravs: avslutat,
detalj: repro
? reproduceringsText(repro.status)
: "Dokumentera reproduceringen (Ja/Delvis/Nej med motivering) innan ärendet avslutas.",
});
// Felorsaksanalys: minst en dokumenterad felorsak (eller motiverad
// okänd orsak) krävs för att avsluta — aldrig bara komponent + åtgärd.
const orsaker = felorsaker(arende);
rader.push({
id: "felorsak",
rubrik: "Felorsaksanalys dokumenterad",
ok: orsaker.length > 0,
kravs: avslutat,
detalj:
orsaker.length > 0
? undefined
: "Beskriv varför felet uppstått — eller ange varför orsaken inte kunnat fastställas.",
});
// Utgående mätarställning: obligatorisk först när ärendet avslutas. // Utgående mätarställning: obligatorisk först när ärendet avslutas.
const matUt = handelser.some((h) => h.typ === "matarstallning" && h.lage === "utgaende"); const matUt = handelser.some((h) => h.typ === "matarstallning" && h.lage === "utgaende");
rader.push({ rader.push({
+12
View File
@@ -121,6 +121,18 @@ export const GENERISK_METODIK: Metodik = {
svarstyp: "val", svarstyp: "val",
val: ["Alltid", "Ibland", "Endast under drift", "Endast vid start"], val: ["Alltid", "Ibland", "Endast under drift", "Endast vid start"],
}, },
{
id: "var",
text: "Var upplevs felet?",
svarstyp: "val",
val: ["Fram", "Bak", "Höger", "Vänster", "Motor/drivkälla", "Kupé/förarplats", "Okänt"],
},
{
id: "hur",
text: "Hur upplevs felet?",
svarstyp: "val",
val: ["Skakar/vibrerar", "Missljud (knack/gnissel/vinande)", "Rycker", "Tappar kraft", "Stannar helt", "Annat"],
},
{ id: "forlopp", text: "Beskriv förloppet när felet uppstår", svarstyp: "text" }, { id: "forlopp", text: "Beskriv förloppet när felet uppstår", svarstyp: "text" },
{ id: "forandring", text: "Har något förändrats nyligen (service, reparation, miljö)?", svarstyp: "text" }, { id: "forandring", text: "Har något förändrats nyligen (service, reparation, miljö)?", svarstyp: "text" },
], ],
+269 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import type { Arende, Handelse, TidKategori } from "@/felsokning/domain"; import type { Arende, Handelse, TidKategori, Tillforlitlighet } from "@/felsokning/domain";
import { TIDKATEGORI_LABEL, handelseRubrik } from "@/felsokning/domain"; import { TIDKATEGORI_LABEL, TILLFORLITLIGHET_LABEL, handelseRubrik } from "@/felsokning/domain";
import type { Metodik, NastaSteg } from "@/felsokning/metodik"; import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik"; import { nastaSteg } from "@/felsokning/metodik";
import { import {
@@ -42,12 +42,19 @@ import {
MARKOR_FELBESKRIVNING_VERIFIERAD, MARKOR_FELBESKRIVNING_VERIFIERAD,
MARKOR_INGA_TIDIGA_OBSERVATIONER, MARKOR_INGA_TIDIGA_OBSERVATIONER,
MARKOR_TIDIGA_OBSERVATIONER_KLARA, MARKOR_TIDIGA_OBSERVATIONER_KLARA,
ORSAKSKATEGORIER,
UNDANTAGSORSAKER, UNDANTAGSORSAKER,
UNDERLAGSKALLOR,
arendetyp, arendetyp,
felorsaker,
granskaAvvikelse,
grindGodkand, grindGodkand,
kvalitetsgrind, kvalitetsgrind,
preDiagnostik, preDiagnostik,
reproducering,
reproduceringsText,
sparbarhetspaket, sparbarhetspaket,
underlagFinns,
} from "@/felsokning/ecm"; } from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format"; import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -417,6 +424,204 @@ function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Ha
); );
} }
// Symptom Verification Protocol: kundens beskrivning blir aldrig ett
// konstaterat fel förrän den reproducerats — eller dokumenterats som ej
// reproducerbar med obligatorisk motivering.
function ReproduceringPanel({ skicka }: { skicka: (h: Handelse) => void }) {
const [status, setStatus] = useState<"" | "ja" | "delvis" | "nej">("");
const [text, setText] = useState("");
const etikett =
status === "ja"
? "Hur reproducerades felet? (förhållanden, hastighet, temperatur, belastning …)"
: status === "delvis"
? "Vad kunde respektive kunde inte reproduceras?"
: "Motivering (obligatorisk) — varför kunde felet inte reproduceras?";
return (
<Panel rubrik="Symptomverifiering — reproducering">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har kundens fel kunnat reproduceras?
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{(["ja", "delvis", "nej"] as const).map((val) => (
<StorKnapp key={val} variant={status === val ? "primar" : "sekundar"} onClick={() => setStatus(val)}>
{val === "ja" ? "Ja" : val === "delvis" ? "Delvis" : "Nej"}
</StorKnapp>
))}
</div>
{status && (
<>
<TextFalt label={etikett} varde={text} satt={setText} flerRad rost
platshallare={status === "ja" ? "T.ex. Reproducerad tre gånger vid 92108 km/h på plan väg, varm motor" : ""} />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
skicka({ typ: "reproducering", status, beskrivning: text.trim() });
setStatus("");
setText("");
}}
>
Dokumentera reproducering
</StorKnapp>
</>
)}
</Panel>
);
}
// Felorsaksanalys (Root Cause Analysis): vad är avvikelsen, varför har
// den uppstått, vilket underlag stöder bedömningen och hur säker är den.
// Kvalitetsregeln avvisar generella formuleringar ("trasig", "defekt")
// och evidenskällor som inte finns i loggen.
function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const dokumenterade = felorsaker(arende);
const [oppen, setOppen] = useState(false);
const [avvikelse, setAvvikelse] = useState("");
const [orsaker, setOrsaker] = useState<string[]>([]);
const [underlag, setUnderlag] = useState<string[]>([]);
const [sakerhet, setSakerhet] = useState<Tillforlitlighet>("hog");
const [atgard, setAtgard] = useState("");
const [motivering, setMotivering] = useState("");
const [ytterligare, setYtterligare] = useState("");
const [fel, setFel] = useState("");
const vaxla = (lista: string[], satt: (v: string[]) => void, val: string) =>
satt(lista.includes(val) ? lista.filter((v) => v !== val) : [...lista, val]);
const spara = () => {
const avvikelseFel = granskaAvvikelse(avvikelse);
if (avvikelseFel) return setFel(avvikelseFel);
if (orsaker.length === 0) return setFel("Välj minst en orsakskategori.");
if (underlag.length === 0) return setFel("Koppla minst en evidenskälla till bedömningen.");
const saknat = underlag.find((k) => !underlagFinns(arende, k));
if (saknat) return setFel(`Underlaget ”${saknat}” finns inte i ärendets logg — dokumentera det först eller välj en annan källa.`);
if (orsaker.includes("Okänd orsak") && !motivering.trim())
return setFel("Okänd orsak kräver en motivering till varför orsaken inte kunnat fastställas.");
if (sakerhet !== "hog" && !ytterligare.trim())
return setFel("Vid medel/låg säkerhet: ange vilka ytterligare kontroller som skulle stärka bedömningen.");
if (!atgard.trim()) return setFel("Ange rekommenderad åtgärd.");
skicka({
typ: "felorsak",
avvikelse: avvikelse.trim(),
orsaker,
underlag,
sakerhet,
atgard: atgard.trim(),
motivering: motivering.trim() || undefined,
ytterligareKontroller: ytterligare.trim() || undefined,
});
setOppen(false);
setAvvikelse("");
setOrsaker([]);
setUnderlag([]);
setSakerhet("hog");
setAtgard("");
setMotivering("");
setYtterligare("");
setFel("");
};
return (
<Panel rubrik="Felorsaksanalys — obligatorisk före avslut">
{dokumenterade.map((p) => {
const h = p.handelse;
if (h.typ !== "felorsak") return null;
return (
<div key={p.id} className="mb-2 border-b border-[#EBEBEB] pb-2 text-[13px] last:border-0">
<p className="font-semibold">{h.avvikelse}</p>
<p className="text-[#4A5560]">
Orsak: {h.orsaker.join(", ")} · Underlag: {h.underlag.join(", ")} · Säkerhet: {TILLFORLITLIGHET_LABEL[h.sakerhet]}
</p>
<p>Åtgärd: {h.atgard}</p>
</div>
);
})}
{!oppen ? (
<StorKnapp variant="sekundar" onClick={() => setOppen(true)}>
+ Dokumentera felorsak
</StorKnapp>
) : (
<div>
<TextFalt
label="1. Konstaterad avvikelse (inte bara komponenten)"
varde={avvikelse}
satt={setAvvikelse}
flerRad
rost
platshallare="T.ex. Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse."
/>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">2. Mest sannolik orsak (en eller flera)</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-3">
{ORSAKSKATEGORIER.map((o) => (
<button
key={o}
onClick={() => vaxla(orsaker, setOrsaker, o)}
className={`min-h-8 rounded border px-1.5 text-[12px] font-medium ${
orsaker.includes(o) ? "border-[#00437A] bg-[#D6E4F2] text-[#00437A]" : "border-[#C6C6C6] bg-white text-[#333333]"
}`}
>
{o}
</button>
))}
</div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">3. Underlag som stöder bedömningen</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-4">
{UNDERLAGSKALLOR.map((k) => (
<button
key={k}
onClick={() => vaxla(underlag, setUnderlag, k)}
className={`min-h-8 rounded border px-1.5 text-[12px] font-medium ${
underlag.includes(k) ? "border-[#00437A] bg-[#D6E4F2] text-[#00437A]" : "border-[#C6C6C6] bg-white text-[#333333]"
}`}
>
{k}
</button>
))}
</div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">4. Säkerhet i bedömningen</p>
<div className="mb-3 grid grid-cols-3 gap-2">
{(["hog", "medel", "lag"] as const).map((n) => (
<button
key={n}
onClick={() => setSakerhet(n)}
className={`min-h-9 rounded border text-[12px] font-semibold ${
sakerhet === n ? "border-[#00437A] bg-[#00437A] text-white" : "border-[#ADADAD] bg-white text-[#333333]"
}`}
>
{TILLFORLITLIGHET_LABEL[n]}
</button>
))}
</div>
{sakerhet !== "hog" && (
<TextFalt
label="Vilka ytterligare kontroller skulle stärka bedömningen?"
varde={ytterligare}
satt={setYtterligare}
rost
/>
)}
{orsaker.includes("Okänd orsak") && (
<TextFalt
label="Motivering — varför kunde orsaken inte fastställas?"
varde={motivering}
satt={setMotivering}
flerRad
rost
/>
)}
<TextFalt label="Rekommenderad åtgärd" varde={atgard} satt={setAtgard} rost platshallare="T.ex. Balansera framhjulen och genomför ny provkörning." />
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Avbryt
</StorKnapp>
<StorKnapp onClick={spara}>Spara felorsak</StorKnapp>
</div>
</div>
)}
</Panel>
);
}
// Navigationsträd (vänsterkolumnen): ärendets vyer plus metodikens steg // Navigationsträd (vänsterkolumnen): ärendets vyer plus metodikens steg
// med status — som en mappstruktur i ett klassiskt verkstadssystem. // med status — som en mappstruktur i ett klassiskt verkstadssystem.
function VyTrad({ function VyTrad({
@@ -609,6 +814,9 @@ function GuideFlik({
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar"); const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
// Avslut kräver SVP (reproducering dokumenterad) + felorsaksanalys.
const kanAvslutas = !!reproducering(arende) && felorsaker(arende).length > 0;
if (avslutat) { if (avslutat) {
return ( return (
<Panel rubrik="Felsökningen är avslutad"> <Panel rubrik="Felsökningen är avslutad">
@@ -642,9 +850,14 @@ function GuideFlik({
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
med rekommenderade nästa steg. med rekommenderade nästa steg.
</p> </p>
<StorKnapp variant="fara" onClick={() => skicka({ typ: "arende_avslutat" })}> <StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat" })}>
Avsluta felsökning Avsluta felsökning
</StorKnapp> </StorKnapp>
{!kanAvslutas && (
<p className="mt-2 text-[12px] font-semibold text-[#9A6700]">
Avslut kräver dokumenterad symptomverifiering och felorsaksanalys se panelerna nedan.
</p>
)}
</> </>
) : steg.fraga ? ( ) : steg.fraga ? (
<FrageKort key={`${steg.steg.id}/${steg.fraga.id}`} steg={steg} skicka={skicka} /> <FrageKort key={`${steg.steg.id}/${steg.fraga.id}`} steg={steg} skicka={skicka} />
@@ -683,6 +896,15 @@ function GuideFlik({
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} /> <SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
{/* SVP: symptomet ska reproduceras — eller dokumenteras som ej
reproducerbart med motivering — innan ärendet kan avslutas. */}
{!reproducering(arende) && <ReproduceringPanel skicka={skicka} />}
{/* Felorsaksanalys: aldrig bara "komponent trasig, byt komponent" —
varje konstaterat fel kräver avvikelse, orsak, underlag och
säkerhetsnivå. Obligatorisk för avslut. */}
<FelorsaksPanel arende={arende} skicka={skicka} />
{/* Utgående mätarställning: obligatorisk för kvalitetsgrinden när {/* Utgående mätarställning: obligatorisk för kvalitetsgrinden när
ärendet avslutas — erbjuds så fort metodiken är genomarbetad. */} ärendet avslutas — erbjuds så fort metodiken är genomarbetad. */}
{!arende.handelser.some((p) => p.handelse.typ === "matarstallning" && p.handelse.lage === "utgaende") && ( {!arende.handelser.some((p) => p.handelse.typ === "matarstallning" && p.handelse.lage === "utgaende") && (
@@ -691,11 +913,16 @@ function GuideFlik({
</Panel> </Panel>
)} )}
{!kanAvslutas && (
<p className="mb-2 text-[12px] font-semibold text-[#9A6700]">
Avslut kräver dokumenterad symptomverifiering och felorsaksanalys annars är slutsatsen inte spårbar.
</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
</StorKnapp> </StorKnapp>
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "arende_avslutat" })}> <StorKnapp variant="sekundar" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat" })}>
Avsluta felsökning Avsluta felsökning
</StorKnapp> </StorKnapp>
</div> </div>
@@ -1445,6 +1672,8 @@ function RapportFlik({
const fordelning = tidsfordelningsRader(arende, nu); const fordelning = tidsfordelningsRader(arende, nu);
const idn = arendeidentitet(arende); const idn = arendeidentitet(arende);
const typ = arendetyp(arende); const typ = arendetyp(arende);
const repro = reproducering(arende);
const orsakerLista = felorsaker(arende);
let matIn: string | undefined; let matIn: string | undefined;
let matUt: string | undefined; let matUt: string | undefined;
for (const p of arende.handelser) { for (const p of arende.handelser) {
@@ -1566,9 +1795,43 @@ function RapportFlik({
["Kund", idn.kund], ["Kund", idn.kund],
])} ])}
</Panel> </Panel>
{/* Beviskedjan: kundens upplevelse, teknikerns verifiering och den
tekniska slutsatsen hålls strikt åtskilda och spårbara. */}
<Panel rubrik="Kundens beskrivning">
<p className="text-[14px]">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Verifierad observation">
{repro ? (
<>
<p className="text-[14px] font-semibold">{reproduceringsText(repro.status)}</p>
<p className="text-[13px] text-[#4A5560]">{repro.beskrivning}</p>
</>
) : (
<p className="text-[14px] text-[#4A5560]">Reproducering ej dokumenterad ännu.</p>
)}
</Panel>
<Panel rubrik="Felorsaksanalys">
{orsakerLista.length === 0 && <p className="text-[14px] text-[#4A5560]">Felorsaksanalys ej dokumenterad ännu.</p>}
{orsakerLista.map((p) => {
const h = p.handelse;
if (h.typ !== "felorsak") return null;
return (
<div key={p.id} className="mb-3 border-b border-[#EBEBEB] pb-2 last:mb-0 last:border-0">
<p className="text-[14px] font-semibold">{h.avvikelse}</p>
{identitetsRader([
["Bedömd grundorsak", h.orsaker.join(", ")],
["Underlag för bedömningen", h.underlag.join(", ")],
["Säkerhetsnivå", TILLFORLITLIGHET_LABEL[h.sakerhet]],
["Rekommenderad åtgärd", h.atgard],
["Motivering", h.motivering],
["Stärkande kontroller", h.ytterligareKontroller],
])}
</div>
);
})}
</Panel>
<Panel rubrik="Sammanfattning"> <Panel rubrik="Sammanfattning">
{b.felbeskrivning && <p className="text-[14px]">Felbeskrivning: {b.felbeskrivning}</p>} <p className="text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
<p className="mt-1 text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && ( {fordelning.length > 0 && (
<div className="mt-2"> <div className="mt-2">
{fordelning.map((r) => ( {fordelning.map((r) => (
@@ -32,6 +32,7 @@ Absoluta regler:
- "rekommendation": nästa verifierbara kontroll eller mätning. - "rekommendation": nästa verifierbara kontroll eller mätning.
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras. - Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
- Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen. - Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen.
- Skriv aldrig "felet konstaterat" om symptomet varken reproducerats eller verifierats med dokumentation — skriv då "Kundens beskrivning kunde inte reproduceras under de förhållanden som rådde vid undersökningen."
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`; - Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
const SVARS_SCHEMA = { const SVARS_SCHEMA = {