ECM v2.0 som subsystem: sex motorer, pre-diagnostik och ärendeidentitet

Regelmotorn är nu plattformens styrande subsystem — systemet kan inte
skriva en slutsats som ECM inte godkänt.

Sex motorer (src/felsokning/ecm.ts):
- Evidence: evidensposter ur loggen med nivå E0–E6, tekniker och
  deterministisk innehållshash
- Rule: dokumentationskrav + undantagsregeln med obligatorisk orsak
- Compliance: ärendetypen (garanti/goodwill/försäkring/reklamation/
  begagnatgaranti …) styr extra krav — claim, skadenummer, historik,
  miltal, bildbevis
- Validation: "Evidens saknas" i stället för antaganden (orkesterns
  grundprompt + projektioner + grind)
- Completion: utökad kvalitetsgrind som spärrar slutrapporten
- Traceability: spårbarhetspaket (regelversion, grindstatus per
  regel-id, evidensposter med hash) i varje export

Pre-Diagnostic Validation — metodiken låses upp först när:
- fordonshistoriken kontrollerats (eller motiverats: kvalitetsvarning),
  med orsakskedja för tidigare arbeten
- ingående mätarställning fotograferats (bildtolkningen föreslår värdet)
- kundens felbeskrivning verifierats
- tidiga observationer hanterats
Utgående mätarställning fotograferas inför avslut och blir obligatorisk
i grinden när ärendet stängs.

Ärendeidentitet (Case Identity): fordonsobjektet utökat med VIN, miltal,
AO-, claim- och skadenummer (läses ur arbetsordern) — registreras en
gång, synligt i identitetsraden i arbetsytan (med ärendetypsval), låst
panel i Live Share, slutrapportens första sida och exporten.

49 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:18:43 +00:00
parent e8cf6db236
commit bfed5dbd7f
14 changed files with 943 additions and 163 deletions
+308 -9
View File
@@ -6,6 +6,7 @@ import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik";
import {
arAvslutat,
arendeidentitet,
brief,
felbeskrivning,
formateraTid,
@@ -35,7 +36,19 @@ import {
type InstrumentTolkning,
} from "@/felsokning/ai";
import { byggDemoInstrument } from "@/felsokning/demo";
import { ECM_VERSION, UNDANTAGSORSAKER, grindGodkand, kvalitetsgrind } from "@/felsokning/ecm";
import {
ARENDETYPER,
ECM_VERSION,
MARKOR_FELBESKRIVNING_VERIFIERAD,
MARKOR_INGA_TIDIGA_OBSERVATIONER,
MARKOR_TIDIGA_OBSERVATIONER_KLARA,
UNDANTAGSORSAKER,
arendetyp,
grindGodkand,
kvalitetsgrind,
preDiagnostik,
sparbarhetspaket,
} from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -127,6 +140,10 @@ export default function ArendeSida() {
</div>
}
>
{/* Ärendeidentiteten: registreras en gång, alltid synlig — det ska
aldrig råda tvekan om vilket fordon och ärende som avses. */}
<IdentitetsRad arende={arende} skicka={skicka} />
{/* Klassisk trekolumnslayout på skrivbord: navigationsträd till
vänster, arbetsyta i mitten, kontextpanel till höger. På smala
skärmar: flikrad + en kolumn. */}
@@ -168,6 +185,238 @@ export default function ArendeSida() {
);
}
// Nyckel/värde-rader för identitetspanelerna (rapport + delade vyer).
function identitetsRader(rader: [string, string | undefined][]) {
return rader
.filter(([, varde]) => varde)
.map(([etikett, varde]) => (
<div key={etikett} className="flex justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span className="text-[#4A5560]">{etikett}</span>
<span className="text-right font-medium">{varde}</span>
</div>
));
}
// Ärendeidentiteten (Case Identity): kompakt rad med fordonsobjektet och
// ärendereferenserna — alltid synlig i arbetsytan. Ärendetypen väljs här
// och styr vilka dokumentationskrav ECM ställer.
function IdentitetsRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const idn = arendeidentitet(arende);
const typ = arendetyp(arende);
const del = (etikett: string, varde?: string) =>
varde ? (
<span className="whitespace-nowrap">
{etikett && <span className="text-[#707070]">{etikett} </span>}
<span className="font-semibold text-[#1A1A1A]">{varde}</span>
</span>
) : null;
return (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 text-[12px] print:hidden">
{del("AO", idn.arbetsorder)}
{del("Claim", idn.claim)}
{del("Skadenr", idn.skadenummer)}
{del("", idn.beskrivning)}
{del("Regnr", idn.identifierare)}
{del("VIN", idn.vin)}
{del("Miltal", idn.miltal)}
{del("Ansvarig", idn.ansvarig)}
<label className="ml-auto flex items-center gap-1">
<span className="text-[#707070]">Ärendetyp</span>
<select
value={typ}
disabled={idn.avslutat}
onChange={(e) => skicka({ typ: "arendetyp_satt", arendetyp: e.target.value })}
className="rounded border border-[#ADADAD] bg-white px-1.5 py-0.5 text-[12px] focus:border-[#00437A] focus:outline-none"
>
{ARENDETYPER.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
</div>
);
}
// Mätarställning in/ut: instrumentpanelen fotograferas, bildtolkningen
// föreslår värdet och teknikern bekräftar. Fotot blir den officiella
// mätarställningen (E2-evidens). Undantag kräver dokumenterad orsak.
function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; skicka: (h: Handelse) => void }) {
const filRef = useRef<HTMLInputElement>(null);
const [foto, setFoto] = useState<string | null>(null);
const [varde, setVarde] = useState("");
const [laser, setLaser] = useState(false);
const [demo, setDemo] = useState(false);
const fota = async (fil: File) => {
setLaser(true);
const dataUrl = await skalaNerFoto(fil);
setFoto(dataUrl);
try {
const res = await lasAvInstrument(dataUrl);
const km = res?.tolkning.varden.find((v) => /mätar|odo|km|mil/i.test(`${v.beskrivning} ${v.enhet ?? ""}`)) ?? res?.tolkning.varden[0];
if (km) setVarde(`${km.varde}${km.enhet ? ` ${km.enhet}` : ""}`);
if (!res) {
setDemo(true);
setVarde(lage === "ingaende" ? "84 320 km" : "84 512 km");
}
} catch {
// Avläsningen kunde inte nås — värdet anges manuellt.
}
setLaser(false);
};
return (
<div>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
if (fil) fota(fil);
e.target.value = "";
}}
/>
{!foto && !laser && (
<StorKnapp variant="sekundar" onClick={() => filRef.current?.click()}>
📷 Fotografera instrumentpanelen
</StorKnapp>
)}
{laser && <p className="animate-pulse py-1 text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av mätarställningen </p>}
{foto && !laser && (
<div className="mt-2">
{demo && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-1.5 text-[11px] font-semibold text-[#9A6700]">
Demo-avläsning bildtolkningen kräver inloggning. Kontrollera värdet.
</p>
)}
<img src={foto} alt="Instrumentpanel" className="mb-2 max-h-40 rounded border border-[#C6C6C6]" />
<TextFalt label={`Mätarställning (${lage === "ingaende" ? "ingående" : "utgående"})`} varde={varde} satt={setVarde} platshallare="T.ex. 84 320 km" />
<StorKnapp
disabled={!varde.trim()}
onClick={() => {
skicka({ typ: "matarstallning", lage, varde: varde.trim(), dataUrl: foto });
setFoto(null);
setVarde("");
}}
>
Spara mätarställning
</StorKnapp>
</div>
)}
<Undantag vidUndantag={(orsak) => skicka({ typ: "matarstallning", lage, varde: "", undantag: orsak })} />
</div>
);
}
// Pre-Diagnostic Validation: ingen felsökning påbörjas förrän
// grundkontrollerna är genomförda — eller dokumenterat motiverade.
function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const rader = preDiagnostik(arende);
const rad = (id: string) => rader.find((r) => r.id === id)!;
const [historikVal, setHistorikVal] = useState<"" | "ja" | "nej">("");
const [historikText, setHistorikText] = useState("");
const fb = felbeskrivning(arende);
const harTidiga = arende.handelser.some((p) => p.handelse.typ === "observation" || p.handelse.typ === "foto");
return (
<Panel rubrik="Pre-diagnostik — innan felsökningen börjar">
{rader.map((r) => (
<p key={r.id} className="py-0.5 text-[13px]">
<span className={r.klar ? "text-[#1E6B34]" : "text-[#8B1A1A]"}>{r.klar ? "✅" : "☐"}</span> {r.rubrik}
{r.varning && <span className="ml-1 text-[11px] font-semibold text-[#9A6700]"> {r.varning}</span>}
</p>
))}
{!rad("historik").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har fordonets historik kontrollerats? (tidigare arbeten, återkommande fel, TSB, kampanjer)
</p>
<div className="mb-2 grid grid-cols-2 gap-2">
<StorKnapp variant={historikVal === "ja" ? "primar" : "sekundar"} onClick={() => setHistorikVal("ja")}>
Ja kontrollerad
</StorKnapp>
<StorKnapp variant={historikVal === "nej" ? "fara" : "sekundar"} onClick={() => setHistorikVal("nej")}>
Nej
</StorKnapp>
</div>
{historikVal && (
<>
<TextFalt
label={historikVal === "ja" ? "Relevanta tidigare arbeten (valfritt — orsakskedja)" : "Orsak till att historiken inte kontrollerats (obligatoriskt)"}
varde={historikText}
satt={setHistorikText}
platshallare={historikVal === "ja" ? "T.ex. Vattenpump bytt för 8 mån sedan — misstänkt läckage samma område" : ""}
rost
/>
<StorKnapp
disabled={historikVal === "nej" && !historikText.trim()}
onClick={() => {
skicka({ typ: "historik_kontrollerad", kontrollerad: historikVal === "ja", kommentar: historikText.trim() || undefined });
setHistorikVal("");
setHistorikText("");
}}
>
Dokumentera
</StorKnapp>
</>
)}
</div>
)}
{!rad("matarstallning_in").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Ingående mätarställning fotografera instrumentpanelen
</p>
<MatarstallningSteg lage="ingaende" skicka={skicka} />
</div>
)}
{!rad("felbeskrivning").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Är kundens felbeskrivning korrekt återgiven?
</p>
{fb && <p className="mb-2 text-[13px]">{fb}</p>}
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_FELBESKRIVNING_VERIFIERAD}.` })}>
Stämmer verifierad
</StorKnapp>
<p className="mt-1 text-[11px] text-[#707070]">
Ytterligare symptom dokumenteras som separata observationer blanda dem inte med kundens beskrivning.
</p>
</div>
)}
{!rad("tidiga_observationer").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Något ytterligare vid mottagandet? (reparationsspår, modifieringar, skador, läckage, korrosion )
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_INGA_TIDIGA_OBSERVATIONER}.` })}>
Inga ytterligare observationer
</StorKnapp>
<StorKnapp
variant="sekundar"
disabled={!harTidiga}
onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_TIDIGA_OBSERVATIONER_KLARA}.` })}
>
Observationerna är dokumenterade
</StorKnapp>
</div>
<p className="mt-1 text-[11px] text-[#707070]">
Dokumentera med foto eller observation i panelen nedan knappen låses upp när något loggats.
</p>
</div>
)}
</Panel>
);
}
// Navigationsträd (vänsterkolumnen): ärendets vyer plus metodikens steg
// med status — som en mappstruktur i ett klassiskt verkstadssystem.
function VyTrad({
@@ -370,6 +619,18 @@ function GuideFlik({
);
}
// Pre-Diagnostic Validation: metodiken öppnas först när grund-
// kontrollerna är genomförda eller dokumenterat motiverade.
if (!preDiagnostik(arende).every((r) => r.klar)) {
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
<PreDiagnostikPanel arende={arende} skicka={skicka} />
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
</>
);
}
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
@@ -422,6 +683,14 @@ function GuideFlik({
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
{/* Utgående mätarställning: obligatorisk för kvalitetsgrinden när
ärendet avslutas — erbjuds så fort metodiken är genomarbetad. */}
{!arende.handelser.some((p) => p.handelse.typ === "matarstallning" && p.handelse.lage === "utgaende") && (
<Panel rubrik="Utgående mätarställning — inför avslut">
<MatarstallningSteg lage="utgaende" skicka={skicka} />
</Panel>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
Lämna över arbete
@@ -1174,6 +1443,17 @@ function RapportFlik({
const b = brief(arende, metodik, nu);
const bilder = foton(arende);
const fordelning = tidsfordelningsRader(arende, nu);
const idn = arendeidentitet(arende);
const typ = arendetyp(arende);
let matIn: string | undefined;
let matUt: string | undefined;
for (const p of arende.handelser) {
const h = p.handelse;
if (h.typ === "matarstallning" && !h.undantag) {
if (h.lage === "ingaende") matIn = h.varde;
else matUt = h.varde;
}
}
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
// och ingår inte i det som delas med kund.
const kundposter = arende.handelser.filter(
@@ -1181,11 +1461,13 @@ function RapportFlik({
);
// Alla exporter bygger på samma händelselogg och versionsmärks:
// version = antal händelser vid exporttillfället.
// version = antal händelser vid exporttillfället. Spårbarhetspaketet
// (ECM-version, evidensposter med hash, grindstatus) följer med.
const exporteraJson = () => {
const version = arende.handelser.length;
const data = {
export: { format: "JSON", version, exporteradAv: anvandare, tidpunkt: new Date().toISOString() },
ecm: sparbarhetspaket(arende, metodik),
arende,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
@@ -1262,13 +1544,30 @@ function RapportFlik({
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
</p>
</Panel>
<Panel rubrik={`Ärende #${arende.nummer}`}>
{b.objekt && (
<p className="text-[14px] font-semibold">
{b.objekt.beskrivning} · {b.objekt.identifierare}
</p>
)}
{b.felbeskrivning && <p className="mt-1 text-[14px]">Felbeskrivning: {b.felbeskrivning}</p>}
{/* Rapportens första sida: ärendeidentiteten — registrerad en gång,
återanvänd här automatiskt. */}
<Panel rubrik="Ärendeinformation">
{identitetsRader([
["Ärende", `#${arende.nummer}`],
["Arbetsorder", idn.arbetsorder],
["Claim-/garantinr", idn.claim],
["Skadenummer", idn.skadenummer],
["Ärendetyp", typ],
["Ansvarig tekniker", idn.ansvarig],
])}
</Panel>
<Panel rubrik="Fordonsinformation">
{identitetsRader([
["Fordon", idn.beskrivning],
["Regnr", idn.identifierare],
["VIN", idn.vin],
["Mätarställning in", matIn],
["Mätarställning ut", matUt],
["Kund", idn.kund],
])}
</Panel>
<Panel rubrik="Sammanfattning">
{b.felbeskrivning && <p className="text-[14px]">Felbeskrivning: {b.felbeskrivning}</p>}
<p className="mt-1 text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && (
<div className="mt-2">
+6
View File
@@ -83,6 +83,12 @@ export default function NyttArende() {
identifieringsmetod: regnr ? "Regnr" : vin ? "VIN" : "Manuell inmatning",
beskrivning: beskr || "Se arbetsorder",
kund: v("kund_namn"),
// Ärendeidentiteten: registreras en gång här, återanvänds i alla vyer.
vin: vin?.toUpperCase(),
miltal: v("fordon_matarstallning"),
arbetsorder: v("ao_nummer"),
claim: v("ao_claim"),
skadenummer: v("ao_skadenummer"),
};
const felText = v("felbeskrivning") ?? "Se skannad arbetsorder";
let metodikId: string | undefined;