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
+3 -1
View File
@@ -34,7 +34,9 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. |
| Ärendestart via arbetsorder | ✅ Primärvägen när ett ärende startas: fota arbetsorderns framsida — orkesterns dokumenttolkning (Claude Sonnet 5, vision) läser kund-, fordons- och verkstadsuppgifter oavsett layout och sätter konfidens per fält. 🟢 ≥95 % godkänns automatiskt, 🟡 8095 % markeras för genomläsning, 🔴 <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält. Visuell granskning med dokumentet bredvid fälten (klick markerar ungefärlig position), sedan skapas hela ärendet med ett tryck. Tolkningen loggas som organisationsintern händelse (`arbetsorder_skannad`) och delas aldrig i kund-/partnervyer. Manuell inmatning finns kvar som andrahandsväg; i lokalt läge visas en tydligt märkt demo-tolkning. Inloggade användare tillfrågas aldrig om namn — kontot vet redan. |
| 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) | ✅ Versionshanterat regelbibliotek ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, ECM v1.0): evidensnivåer E0E6 härledda ur loggen, fullbordansregeln *evidens eller dokumenterat undantag med obligatorisk orsak* ("Underlag kan inte tas fram" i guiden, flaggas ⚠ i brief/rapport), och **kvalitetsgrind före slutrapport** — utskrift spärrad tills objektidentifiering, kontroller, fotokrav och evidensnivå är gröna. Regeln "skriv aldrig OK/kontrollerad/inga fel utan evidens — skriv Evidens saknas" är kodad i orkesterns grundprompt. |
| 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. |
| Ä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. |
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. |
| Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. |
+86 -67
View File
@@ -1,22 +1,22 @@
# Modul: Evidensmotorn (ECM — Evidence & Compliance Matrix)
**Version: ECM v1.0** · Regelbiblioteket är versionshanterat och skilt från
applikationslogiken (`src/felsokning/ecm.ts`). Vyerna anropar bara motorns
rena funktioner — regler kan uppdateras utan att applikationen byggs om.
**Version: ECM v2.0** · ECM är ett eget subsystem — inte en tabell i
databasen — och motorn som styr hela plattformen: den avgör vilken
dokumentation som krävs, när dokumentation saknas, vilken bevisnivå som
uppnåtts, vilka regler som gäller och om ett ärende kan avslutas.
**Systemet kan aldrig skriva en slutsats som ECM inte har godkänt.**
## Grundprincip
Regelbiblioteket är versionshanterat och skilt från applikationslogiken
(`src/felsokning/ecm.ts`); vyerna anropar bara motorns rena funktioner.
**Inget underlag = ingen slutsats.**
## De sex motorerna
Systemet får aldrig anta att en kontroll är utförd eller påstå att
dokumentation finns om den inte faktiskt är insamlad. Varje påstående i
diagnos, brief och slutrapport ska kunna härledas till minst en
evidenspost i händelseloggen. Systemet skriver aldrig "OK",
"kontrollerad", "inga fel" eller "åtgärdad" utan evidens — i stället:
**"Evidens saknas."** Regeln är kodad i orkesterns grundprompt och kan
inte kringgås från klienten.
## Evidensnivåer
### 1. Evidence Engine
Katalogiserar all bevisning ur händelseloggen. Varje evidenspost får
id, tidpunkt, tekniker, kategori, evidensnivå, sammanfattning och
**innehållshash** — samma post ger alltid samma hash, och den
append-only-låsta loggen (databastriggers) gör varje ändringsförsök
omöjligt.
| Nivå | Typ | Bevisvärde |
| --- | --- | --- |
@@ -25,70 +25,98 @@ inte kringgås från klienten.
| E2 | Foto | Medel |
| E3 | Video | Högt |
| E4 | Mätvärde | Högt |
| E5 | Diagnosdata/dokument (t.ex. skannad arbetsorder) | Mycket högt |
| E5 | Diagnosdata/dokument | Mycket högt |
| E6 | Flera oberoende källor | Högsta |
Ärendets nivå härleds ur loggens faktiska innehåll (`evidensNiva`) och
visas i kvalitetsgrinden.
### 2. Rule Engine
Dokumentationskraven: metodikens `krav`-fält per kontroll plus de
automatiska reglerna — *kan det fotograferas → begär foto; låter det →
video med ljud; rör det sig → video; mäts det → mätvärde; visar en
display informationen → fota displayen; finns ett dokument → fota
dokumentet.* Undantagsorsakerna ("Underlag kan inte tas fram") ligger
här.
## Fullbordansregler
### 3. Compliance Engine
Ärendetypen styr vilka regler som gäller utöver metodiken. Ärendetyp
väljs i identitetsraden och loggas (`arendetyp_satt`):
En kontrollpunkt kan bara slutföras när något av följande är sant:
| Ärendetyp | Extra krav (v2.0) |
| --- | --- |
| Garanti | Miltal dokumenterat · servicehistorik kontrollerad · claim-/garantinummer |
| Goodwill | Miltal · servicehistorik |
| Försäkring | Skadenummer · bildbevis |
| Reklamation | Historik och tidigare försök kontrollerade |
| Begagnatgaranti | Miltal |
1. **Krävd evidens är insamlad** — foto för det synliga, mätvärde för det
som mäts, kommentar där inget bättre är möjligt (metodikens
`krav`-fält per kontroll).
2. **Teknikern dokumenterar ett undantag**: *"Underlag kan inte tas
fram"* med **obligatorisk orsak** (komponenten oåtkomlig, fordonet kan
inte lyftas säkert, kunden avböjde demontering, dålig sikt, utrustning
saknas — eller fri text). Undantaget loggas i händelseloggen och
flaggas ⚠ i brief, överlämning och rapport — det redovisas aldrig som
"utförd".
Här ansluter det framtida **ECM Knowledge Library**: serverdistribuerade,
versionerade regelpaket (garantivillkor per tillverkare, försäkringsbolagens
krav, reklamationslagstiftning, OEM-kontrollpunkter) som laddas dynamiskt
utan appändring.
## Kvalitetsgrind före slutrapport
### 4. Validation Engine
Inga påståenden utan underlag, i tre lager: (a) orkesterns grundprompt —
aldrig "OK/kontrollerad/inga fel/åtgärdad" utan evidens, i stället
"Evidens saknas" plus begäran om rätt underlag; (b) projektionerna —
hypoteser kan aldrig bli konstaterade fel; (c) kvalitetsgrinden nedan.
Slutrapporten kan inte genereras förrän grinden är godkänd
(`kvalitetsgrind`/`grindGodkand`):
### 5. Completion Engine
Kvalitetsgrinden före slutrapport/avslut — utskriften är spärrad tills
alla obligatoriska rader är gröna:
| Kontroll | Krav |
| --- | --- |
| Fordons-/objektidentifiering verifierad | Obligatorisk |
| Arbetsorder inläst | Rekommenderas |
| Fordonshistorik kontrollerad eller motiverad | Obligatorisk |
| Ingående mätarställning dokumenterad | Obligatorisk |
| Kundens felbeskrivning verifierad | Rekommenderas |
| Utgående mätarställning | Obligatorisk vid avslut |
| Metodikens kontroller: evidens eller dokumenterat undantag | Obligatorisk |
| Foton finns för fotokrävande kontroller | Obligatorisk |
| Hypoteser redovisas som ej verifierade | Informativ (alltid sant per konstruktion) |
| Foton för fotokrävande kontroller | Obligatorisk |
| Ärendetypens compliance-krav | Obligatoriska |
| Evidensnivå över E0 | Obligatorisk |
Utskriftsknappen är spärrad tills varje obligatorisk rad är grön; varje
röd rad visar exakt vad som saknas.
### 6. Traceability Engine
Varje export bär ett spårbarhetspaket: ECM-version, ärendetyp,
evidensnivå, grindstatus per regel-id och samtliga evidensposter med
hash. Tillsammans med loggen kan varje slutsats härledas: vilken bild →
vilken mätning → vilken tekniker → vilken regel → vilken regelverksversion
→ när.
## Visual-first: kameran är integrationslagret
## Pre-Diagnostic Validation
Plattformen prioriterar visuell insamling framför systemintegrationer.
När information redan visas på en skärm, ett instrument, en utskrift
eller en etikett fotograferas den — bildtolkningen extraherar, validerar
och strukturerar informationen automatiskt. Ingen specialintegration
behövs mot Bosch, TEXA, Autel, Launch, Hella Gutmann m.fl. så länge en
människa kan läsa informationen.
Ingen felsökning påbörjas förrän grundkontrollerna är genomförda eller
dokumenterat motiverade — metodiken låses upp först därefter:
- **Arbetsorder** → ärendestartens dokumenttolkning (fält + konfidens).
- **Instrument/diagnosskärmar** → `📷 Instrument` i Dokumentera-panelen:
foto → typidentifiering (multimeter, diagnosdator, batteritestare,
mätarkluster, manometer …) → värden med enhet och konfidens → teknikern
bekräftar → **originalbilden loggas alltid tillsammans med de
strukturerade värdena** — strukturerad data ersätter aldrig
originalevidensen.
1. **Fordonshistorik** — kontrollerad (tidigare arbeten, återkommande
fel, TSB, kampanjer; relevanta samband kan noteras som orsakskedja)
eller Nej med obligatorisk orsak → kvalitetsvarning.
2. **Ingående mätarställning** — instrumentpanelen fotograferas;
bildtolkningen föreslår värdet, teknikern bekräftar. Fotot blir den
officiella ingående mätarställningen.
3. **Kundens felbeskrivning verifierad** — ytterligare symptom
dokumenteras som separata observationer, aldrig hopblandade med
kundens beskrivning.
4. **Tidiga observationer** — reparationsspår, modifieringar, skador,
läckage m.m. dokumenteras med foto/observation, eller kvitteras
"inga ytterligare".
Värden med hög säkerhet godkänns automatiskt; osäkra markeras för
granskning — samma konfidensmodell som arbetsorderskanningen
(🟢 ≥95 %, 🟡 8095 %, 🔴 <80 %).
**Utgående mätarställning** fotograferas inför avslut och blir
obligatorisk i grinden när ärendet stängs. Rapporten visar in/ut.
## Spårbarhet
## Ärendeidentitet (Case Identity & Vehicle Context)
Varje evidenspost är en händelse i den append-only-loggen med tidpunkt,
tekniker, ärende och innehåll — omöjlig att ändra i efterhand (triggers i
databasen). Det ger varje slutsats juridiskt spårbart underlag för kund,
försäkringsbolag eller domstol.
Fordonsobjektet är den röda tråden: identiteten registreras **en gång**
(normalt via arbetsorderskanningen, som nu även läser claim-/garantinummer
och skadenummer) och återanvänds sedan överallt:
- **Identitetsrad i arbetsytan** — AO, claim, skadenummer, fordon, regnr,
VIN, miltal, ansvarig tekniker + ärendetypsval.
- **Live Share** — låst panel överst med fordon, referenser och status,
härledd ur det nivåfiltrerade underlaget.
- **Slutrapportens första sida** — Ärendeinformation + Fordonsinformation
automatiskt.
- **Exporten** — identitet + spårbarhetspaket i varje JSON.
## Terminologi
@@ -96,13 +124,4 @@ Produkten beskrivs aldrig som en "AI-app" utan som ett **evidensbaserat
diagnossystem** / **intelligent beslutsstöd**. I användargränssnitt och
dokument används *systemet, analysen, bedömningen, tolkningen,
bildtolkningen, beslutsstödet, regelmotorn* — inte "AI", om det inte är
tekniskt nödvändigt (t.ex. i arkitekturdokumentation om modellorkestern).
## Kommande (regelbibliotekets väg framåt)
- Områdesregler per komponent (däck: fyra bilder + DOT + dimension;
bromsar: närbild per ok; motorljud: video med ljud …) som
serverdistribuerade, versionerade regelpaket.
- Video- och ljudevidens (E3) med analys.
- Garanti-/försäkrings-/reklamationsprofiler med egna obligatoriska
fält i kvalitetsgrinden.
tekniskt nödvändigt.
+1 -1
View File
@@ -72,7 +72,7 @@ const ARBETSORDER_FALT_ID = [
"fordon_regnr", "fordon_vin", "fordon_marke", "fordon_modell",
"fordon_motor", "fordon_arsmodell", "fordon_matarstallning",
"fordon_motorkod", "fordon_vaxellada",
"ao_nummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"ao_nummer", "ao_claim", "ao_skadenummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"felbeskrivning",
];
+3
View File
@@ -568,6 +568,9 @@ components:
- overlamning
- ansvarig_satt
- arbetsorder_skannad
- arendetyp_satt
- historik_kontrollerad
- matarstallning
- export_skapad
- ai_svar
- arende_avslutat
+34 -1
View File
@@ -6,11 +6,39 @@
import type { Arende } from "./domain";
import { handelseRubrik } from "./domain";
import { arAvslutat, brief, foton, tidsfordelningsRader } from "./projektioner";
import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader } from "./projektioner";
import { metodikForArende } from "./store";
import { tidDatum, tidKlockslag } from "./format";
import { FelsokningSkal, Panel } from "./ui";
function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) {
const idn = arendeidentitet(arende);
const falt: [string, string | undefined][] = [
["Arbetsorder", idn.arbetsorder],
["Claim", idn.claim],
["Skadenr", idn.skadenummer],
["Regnr", idn.identifierare],
["VIN", idn.vin],
["Miltal", idn.miltal],
["Ansvarig tekniker", idn.ansvarig],
["Status", avslutat ? "Avslutat" : "Felsökning pågår"],
];
return (
<div className="sticky top-11 z-10 mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 shadow-[0_1px_3px_rgba(0,0,0,0.1)] print:static">
{idn.beskrivning && <p className="text-[13px] font-semibold">{idn.beskrivning}</p>}
<p className="flex flex-wrap gap-x-4 gap-y-0.5 text-[12px]">
{falt
.filter(([, varde]) => varde)
.map(([etikett, varde]) => (
<span key={etikett} className="whitespace-nowrap">
<span className="text-[#707070]">{etikett}:</span> <span className="font-medium">{varde}</span>
</span>
))}
</p>
</div>
);
}
export function DelatArendeVy({
arende,
nu,
@@ -48,6 +76,11 @@ export function DelatArendeVy({
</span>
}
>
{/* Ärendeidentiteten: låst överst — mottagaren (kund, försäkrings-
handläggare, kollega) ska aldrig tveka om vilket fordon och
ärende vyn avser. Härledd ur det nivåfiltrerade underlaget. */}
<IdentitetsPanel arende={arende} avslutat={avslutat} />
<p className="mb-4 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#4A5560] print:hidden">
{notis}
</p>
+130 -47
View File
@@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest";
import type { Arende, Handelse } from "../domain";
import { nyLoggPost } from "../domain";
import { VIBRATION_METODIK } from "../metodik";
import { evidensNiva, grindGodkand, kvalitetsgrind } from "../ecm";
import {
arendetyp,
evidensNiva,
evidensposter,
grindGodkand,
kvalitetsgrind,
preDiagnostik,
sparbarhetspaket,
} from "../ecm";
import { arendeidentitet } from "../projektioner";
import { byggDemoArende } from "../demo";
function byggArende(handelser: Handelse[]): Arende {
@@ -16,10 +25,28 @@ function byggArende(handelser: Handelse[]): Arende {
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: { typ: "Personbil", identifierare: "ABC123", identifieringsmetod: "Regnr", beskrivning: "Volvo XC60" },
objekt: {
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60",
vin: "YV1DZ8256F2123456",
miltal: "8 432 mil",
arbetsorder: "AO-2496",
claim: "G-2026-00481",
skadenummer: "SK-77812",
},
};
describe("evidensnivåer", () => {
// Grundkontroller som gör pre-diagnostiken klar.
const PREDIAG: Handelse[] = [
{ typ: "historik_kontrollerad", kontrollerad: true },
{ typ: "matarstallning", lage: "ingaende", varde: "84 320 km" },
{ typ: "kommentar", text: "Kundens felbeskrivning verifierad vid mottagandet." },
{ typ: "kommentar", text: "Inga ytterligare observationer vid mottagandet." },
];
describe("Evidence Engine", () => {
it("härleder nivån ur loggens faktiska innehåll", () => {
expect(evidensNiva(byggArende([]))).toBe("E0");
expect(evidensNiva(byggArende([{ typ: "observation", text: "x" }]))).toBe("E1");
@@ -35,25 +62,62 @@ describe("evidensnivåer", () => {
),
).toBe("E6");
});
it("katalogiserar evidensposter med tekniker, nivå och innehållshash", () => {
const arende = byggArende([
{ typ: "foto", beskrivning: "Höger framhjul", dataUrl: "data:x" },
{ typ: "matvarde", beskrivning: "Obalans", varde: "38", enhet: "g" },
]);
const poster = evidensposter(arende);
expect(poster).toHaveLength(2);
expect(poster[0].niva).toBe("E2");
expect(poster[1].sammanfattning).toBe("Obalans = 38 g");
expect(poster[0].tekniker).toBe("Anna");
expect(poster[0].hash).toMatch(/^[0-9a-f]{8}$/);
// Samma innehåll → samma hash (deterministisk spårbarhet).
expect(evidensposter(arende)[0].hash).toBe(poster[0].hash);
});
});
describe("kvalitetsgrind före slutrapport", () => {
describe("Pre-Diagnostic Validation", () => {
it("ingen felsökning utan grundkontroller — allt härleds ur loggen", () => {
const tomt = byggArende([OBJEKT]);
expect(preDiagnostik(tomt).every((r) => r.klar)).toBe(false);
const klart = byggArende([OBJEKT, ...PREDIAG]);
expect(preDiagnostik(klart).every((r) => r.klar)).toBe(true);
});
it("ej kontrollerad historik godtas med orsak men flaggas som kvalitetsvarning", () => {
const arende = byggArende([
OBJEKT,
{ typ: "historik_kontrollerad", kontrollerad: false, kommentar: "Historiksystemet otillgängligt" },
]);
const rad = preDiagnostik(arende).find((r) => r.id === "historik")!;
expect(rad.klar).toBe(true);
expect(rad.varning).toContain("Historiksystemet otillgängligt");
});
});
describe("Completion Engine (kvalitetsgrind)", () => {
it("utan evidens är grinden stängd — inga påståenden utan underlag", () => {
const tomt = byggArende([]);
expect(grindGodkand(tomt, VIBRATION_METODIK)).toBe(false);
const rader = kvalitetsgrind(tomt, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "objekt")?.ok).toBe(false);
expect(rader.find((r) => r.id === "kontroller")?.ok).toBe(false);
expect(rader.find((r) => r.id === "kontroller")?.detalj).toContain("Evidens saknas");
});
it("ett dokumenterat undantag räknas som hanterat men flaggas med orsak", () => {
// Alla kontroller får undantag med orsak — grinden öppnas, men
// undantagen redovisas i detaljraden (aldrig som "utförd").
const undantag: Handelse[] = [OBJEKT, { typ: "foto", beskrivning: "Översikt", dataUrl: "data:" }, { typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", enhet: "bar" }];
it("dokumenterade undantag öppnar grinden men redovisas med orsak", () => {
const handelser: Handelse[] = [
OBJEKT,
...PREDIAG,
{ typ: "foto", beskrivning: "Översikt", dataUrl: "data:" },
{ typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", enhet: "bar" },
];
for (const steg of VIBRATION_METODIK.steg) {
for (const kontroll of steg.kontroller ?? []) {
undantag.push({
handelser.push({
typ: "kontroll_utford",
stegId: steg.id,
kontrollId: kontroll.id,
@@ -62,47 +126,66 @@ describe("kvalitetsgrind före slutrapport", () => {
});
}
}
const arende = byggArende(undantag);
const arende = byggArende(handelser);
expect(grindGodkand(arende, VIBRATION_METODIK)).toBe(true);
const rad = kvalitetsgrind(arende, VIBRATION_METODIK).find((r) => r.id === "kontroller");
expect(rad?.ok).toBe(true);
expect(rad?.detalj).toContain("Undantag med orsak");
expect(rad?.detalj).toContain("Kunden avböjde demontering");
});
it("demoärendet blockeras tills alla kontroller har evidens eller undantag", () => {
const demo = byggDemoArende(1);
expect(grindGodkand(demo, VIBRATION_METODIK)).toBe(false);
const komplett: Arende = {
...demo,
handelser: [...demo.handelser],
};
const hanterade = new Set(
demo.handelser
.filter((p) => p.handelse.typ === "kontroll_utford")
.map((p) => {
const h = p.handelse;
return h.typ === "kontroll_utford" ? `${h.stegId}/${h.kontrollId}` : "";
}),
);
for (const steg of VIBRATION_METODIK.steg) {
for (const kontroll of steg.kontroller ?? []) {
if (!hanterade.has(`${steg.id}/${kontroll.id}`)) {
komplett.handelser.push(
nyLoggPost("Johan", {
typ: "kontroll_utford",
stegId: steg.id,
kontrollId: kontroll.id,
text: kontroll.text,
undantag: "Utrustning saknas",
}),
);
}
}
}
expect(grindGodkand(komplett, VIBRATION_METODIK)).toBe(true);
// Demoärendet har foto + mätvärden + flera källor → högsta evidensnivån.
expect(evidensNiva(komplett)).toBe("E6");
it("utgående mätarställning blir obligatorisk när ärendet avslutas", () => {
const bas: Handelse[] = [OBJEKT, ...PREDIAG];
const oppen = byggArende(bas);
expect(kvalitetsgrind(oppen, VIBRATION_METODIK).find((r) => r.id === "matarstallning_ut")?.kravs).toBe(false);
const stangd = byggArende([...bas, { typ: "arende_avslutat" }]);
expect(kvalitetsgrind(stangd, VIBRATION_METODIK).find((r) => r.id === "matarstallning_ut")?.kravs).toBe(true);
});
});
describe("Compliance Engine", () => {
it("ärendetypen styr dokumentationskraven", () => {
const privat = byggArende([OBJEKT]);
expect(arendetyp(privat)).toBe("Privat kund");
expect(kvalitetsgrind(privat, VIBRATION_METODIK).some((r) => r.id.startsWith("garanti_"))).toBe(false);
const garanti = byggArende([OBJEKT, { typ: "arendetyp_satt", arendetyp: "Garanti" }]);
const rader = kvalitetsgrind(garanti, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "garanti_claim")?.ok).toBe(true); // claim finns i objektet
expect(rader.find((r) => r.id === "garanti_historik")?.ok).toBe(false); // historik ej kontrollerad
});
it("försäkringsärenden kräver skadenummer och bildbevis", () => {
const utanBild = byggArende([OBJEKT, { typ: "arendetyp_satt", arendetyp: "Försäkring" }]);
const rader = kvalitetsgrind(utanBild, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "forsakring_skadenummer")?.ok).toBe(true);
expect(rader.find((r) => r.id === "forsakring_bildbevis")?.ok).toBe(false);
});
});
describe("Traceability Engine + ärendeidentitet", () => {
it("spårbarhetspaketet innehåller version, grindstatus och evidensposter", () => {
const arende = byggArende([OBJEKT, { typ: "foto", beskrivning: "x", dataUrl: "data:" }]);
const paket = sparbarhetspaket(arende, VIBRATION_METODIK);
expect(paket.ecmVersion).toBe("2.0");
expect(paket.arendetyp).toBe("Privat kund");
expect(paket.evidensposter).toHaveLength(1);
expect(paket.kvalitetsgrind.some((r) => r.id === "objekt" && r.ok)).toBe(true);
});
it("ärendeidentiteten registreras en gång och återanvänds överallt", () => {
const arende = byggArende([OBJEKT, { typ: "matarstallning", lage: "ingaende", varde: "84 320 km" }]);
const idn = arendeidentitet(arende);
expect(idn.arbetsorder).toBe("AO-2496");
expect(idn.claim).toBe("G-2026-00481");
expect(idn.skadenummer).toBe("SK-77812");
expect(idn.vin).toBe("YV1DZ8256F2123456");
// Den fotograferade mätarställningen ersätter arbetsorderns uppgift.
expect(idn.miltal).toBe("84 320 km");
});
it("demoärendet passerar pre-diagnostiken men blockeras av okontrollerade metodiksteg", () => {
const demo = byggDemoArende(1);
expect(preDiagnostik(demo).every((r) => r.klar)).toBe(true);
expect(grindGodkand(demo, VIBRATION_METODIK)).toBe(false);
expect(evidensNiva(demo)).toBe("E6");
});
});
+2
View File
@@ -128,6 +128,8 @@ export const ARBETSORDER_FALT: { id: string; grupp: ArbetsorderGrupp; etikett: s
{ id: "fordon_motorkod", grupp: "Fordon", etikett: "Motorkod" },
{ id: "fordon_vaxellada", grupp: "Fordon", etikett: "Växellåda" },
{ id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" },
{ id: "ao_claim", grupp: "Verkstad", etikett: "Claim-/garantinr" },
{ id: "ao_skadenummer", grupp: "Verkstad", etikett: "Skadenummer" },
{ id: "ao_referens", grupp: "Verkstad", etikett: "Intern referens" },
{ id: "ao_serviceradgivare", grupp: "Verkstad", etikett: "Servicerådgivare" },
{ id: "ao_bokningsdatum", grupp: "Verkstad", etikett: "Bokningsdatum" },
+9
View File
@@ -63,9 +63,18 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
vin: "YV1DZ8256F2123456",
miltal: "8 432 mil",
arbetsorder: "AO-2496",
},
}),
post(2, { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h. Symptomet uppträder endast under körning." }),
// Pre-diagnostiken: historik, ingående mätarställning, verifierad
// felbeskrivning och tidiga observationer — innan metodiken börjar.
post(3, { typ: "historik_kontrollerad", kontrollerad: true, kommentar: "Däck roterade vid service för 2 månader sedan" }),
post(3, { typ: "matarstallning", lage: "ingaende", varde: "84 320 km", dataUrl: demoFoto("Instrumentpanel 84 320 km") }),
post(4, { typ: "kommentar", text: "Kundens felbeskrivning verifierad vid mottagandet." }),
post(4, { typ: "kommentar", text: "Inga ytterligare observationer vid mottagandet." }),
post(4, { typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "Är vibrationen hastighetsberoende?", svar: "Ja" }),
post(5, { typ: "fraga_besvarad", stegId: "symptom", frageId: "var", fraga: "Var känns vibrationen?", svar: "I ratten" }),
post(6, { typ: "fraga_besvarad", stegId: "symptom", frageId: "nar", fraga: "När uppstår vibrationen?", svar: "Vid jämn fart" }),
+27 -1
View File
@@ -29,12 +29,21 @@ export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
paus: "Paus",
};
// Fordonsobjektet är den röda tråden genom hela ärendet: identiteten
// registreras en gång och återanvänds i felsökning, Live Share, rapport
// och export. Ärendereferenserna (AO/claim/skadenummer) hör till objektet
// så att de följer med i alla vyer utan att kunduppgifter läcker.
export interface Objekt {
typ: string; // t.ex. "Fordon", "Industrimaskin"
typ: string; // t.ex. "Personbil", "Industrimaskin"
identifierare: string; // reg.nr, VIN, serienummer …
identifieringsmetod: string; // "Regnr", "VIN", "Serienummer", "Manuell inmatning"
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
kund?: string;
vin?: string;
miltal?: string;
arbetsorder?: string;
claim?: string;
skadenummer?: string;
}
// Ett fält tolkat ur en skannad arbetsorder, med AI:ns läs-säkerhet.
@@ -63,6 +72,14 @@ export type Handelse =
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
| { typ: "overlamning"; fran: string; till?: string }
| { typ: "ansvarig_satt"; ansvarig: string }
// Ärendetypen styr vilka dokumentationskrav ECM ställer (garanti,
// försäkring, reklamation …).
| { typ: "arendetyp_satt"; arendetyp: string }
// Pre-diagnostik: fordonshistoriken kontrollerad — eller motiverat
// varför inte (kvalitetsvarning).
| { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string }
// Officiell mätarställning in/ut, normalt med foto av instrumentpanelen.
| { typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; dataUrl?: string; undantag?: string }
| { typ: "export_skapad"; format: string; version: number }
| {
typ: "ai_svar";
@@ -136,6 +153,15 @@ export function handelseRubrik(post: LoggPost): string {
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
case "ansvarig_satt":
return `Ansvarig tekniker: ${h.ansvarig}`;
case "arendetyp_satt":
return `Ärendetyp: ${h.arendetyp}`;
case "historik_kontrollerad":
return h.kontrollerad
? `Fordonshistorik kontrollerad${h.kommentar ? `: ${h.kommentar}` : ""}`
: `Fordonshistorik EJ kontrollerad — orsak: ${h.kommentar ?? "saknas"}`;
case "matarstallning":
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}`;
case "export_skapad":
return `Export skapad: ${h.format}, version ${h.version}`;
case "ai_svar": {
+293 -35
View File
@@ -1,22 +1,31 @@
// Evidence & Compliance Matrix (ECM) — plattformens regelmotor.
//
// Grundprincip: varje påstående måste kunna härledas till evidens i
// händelseloggen. Systemet får aldrig skriva "kontrollerad", "OK" eller
// "inga fel" utan underlag — utan evidens skrivs "Evidens saknas".
// ECM är ett eget subsystem, inte en tabell i databasen: den avgör vilken
// dokumentation som krävs, när dokumentation saknas, vilken bevisnivå som
// uppnåtts, vilka regler som gäller (per ärendetyp) och om ett ärende kan
// avslutas. Systemet kan aldrig skriva en slutsats som ECM inte godkänt.
//
// Regelbiblioteket är versionshanterat och skilt från applikationslogiken:
// evidensnivåer, krav och kvalitetsgrind ändras här (och i kommande
// versioner via serverdistribuerade regler) utan att vyerna byggs om.
// Vyerna anropar bara de rena funktionerna nedan.
// Sex motorer:
// 1. Evidence Engine — katalogiserar all bevisning ur loggen
// 2. Rule Engine — dokumentationskraven (metodik + automatiska regler)
// 3. Compliance Engine — vilka regler som gäller per ärendetyp
// 4. Validation Engine — inga påståenden utan underlag ("Evidens saknas")
// 5. Completion Engine — kvalitetsgrind: får ärendet avslutas/rapporteras?
// 6. Traceability Engine — varje evidenspost hash:as och versionsmärks
//
// Regelbiblioteket är versionshanterat och skilt från applikationslogiken;
// vyerna anropar bara de rena funktionerna nedan. Nästa steg är server-
// distribuerade regelpaket (garantivillkor per tillverkare, försäkrings-
// krav, reklamationsregler) som laddas dynamiskt utan appändring.
import type { Arende } from "./domain";
import type { Arende, LoggPost } from "./domain";
import type { Metodik } from "./metodik";
export const ECM_VERSION = "1.0";
export const ECM_VERSION = "2.0";
// ---- 1. Evidence Engine -----------------------------------------------
// Evidensnivåer: bevisvärdet för ett påstående, härlett ur loggen.
// E0 inget underlag · E1 teknikerns observation · E2 foto · E3 video
// E4 mätvärde · E5 diagnosdata/dokument · E6 flera oberoende källor
export type EvidensNiva = "E0" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
export const EVIDENS_LABEL: Record<EvidensNiva, string> = {
@@ -29,8 +38,76 @@ export const EVIDENS_LABEL: Record<EvidensNiva, string> = {
E6: "E6 · Flera oberoende källor",
};
// Godkända orsaker när underlag inte kan tas fram. Fri text tillåts
// också — men en orsak är alltid obligatorisk.
export interface Evidenspost {
id: string;
tidpunkt: string;
tekniker: string;
kategori: string;
niva: Exclude<EvidensNiva, "E0" | "E6">;
sammanfattning: string;
// Innehållshash för spårbarhet: samma post ger alltid samma hash,
// varje ändringsförsök syns (loggen är dessutom append-only i DB:n).
hash: string;
}
// FNV-1a — snabb, deterministisk innehållshash (spårbarhet, inte krypto;
// manipulationsskyddet ligger i databasens append-only-triggers).
export function innehallsHash(innehall: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < innehall.length; i++) {
h ^= innehall.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
// Katalogiserar varje evidenspost i loggen med nivå, kategori och hash.
export function evidensposter(arende: Arende): Evidenspost[] {
const poster: Evidenspost[] = [];
const lagg = (post: LoggPost, kategori: string, niva: Evidenspost["niva"], sammanfattning: string) =>
poster.push({
id: post.id,
tidpunkt: post.tidpunkt,
tekniker: post.anvandare,
kategori,
niva,
sammanfattning,
hash: innehallsHash(JSON.stringify(post.handelse)),
});
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning);
if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`);
if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`);
if (h.typ === "observation") lagg(post, "observation", "E1", h.text);
if (h.typ === "kontroll_utford" && !h.undantag) lagg(post, "kontroll", "E1", h.text);
}
return poster;
}
// Ärendets samlade evidensnivå: starkaste kombination loggen innehåller.
export function evidensNiva(arende: Arende): EvidensNiva {
const poster = evidensposter(arende);
const har = (n: string) => poster.some((p) => p.niva === n);
const kallor = [har("E2"), har("E4"), har("E5")].filter(Boolean).length;
if (kallor >= 2) return "E6";
if (har("E5")) return "E5";
if (har("E4")) return "E4";
if (har("E2")) return "E2";
if (har("E1")) return "E1";
return "E0";
}
// ---- 2. Rule Engine ---------------------------------------------------
// Automatiska regler (kodade i orkesterns grundprompt och i metodikens
// krav-fält): kan det fotograferas → begär foto; låter det → video med
// ljud; rör det sig → video; mäts det → mätvärde; visar en display
// informationen → fota displayen; finns ett dokument → fota dokumentet.
// Godkända orsaker när underlag inte kan tas fram (fri text tillåts
// också — men en orsak är alltid obligatorisk).
export const UNDANTAGSORSAKER = [
"Komponenten är oåtkomlig",
"Fordonet kan inte lyftas säkert",
@@ -39,29 +116,142 @@ export const UNDANTAGSORSAKER = [
"Utrustning saknas",
];
// Evidensnivå för ärendet som helhet: den starkaste kombination som
// loggen faktiskt innehåller.
export function evidensNiva(arende: Arende): EvidensNiva {
let foto = false;
let matvarde = false;
let dokument = false;
let observation = false;
// Markörtexter för pre-diagnostikens kvitteringar (loggas som kommentar).
export const MARKOR_FELBESKRIVNING_VERIFIERAD = "Kundens felbeskrivning verifierad vid mottagandet";
export const MARKOR_INGA_TIDIGA_OBSERVATIONER = "Inga ytterligare observationer vid mottagandet";
export const MARKOR_TIDIGA_OBSERVATIONER_KLARA = "Tidiga observationer vid mottagandet dokumenterade";
// ---- 3. Compliance Engine ---------------------------------------------
// Ärendetypen styr vilka dokumentationskrav som gäller utöver metodiken.
export const ARENDETYPER = [
"Privat kund",
"Företagskund",
"Garanti",
"Goodwill",
"Försäkring",
"Reklamation",
"Begagnatgaranti",
"Intern kvalitetskontroll",
"Teknisk utredning",
] as const;
export type Arendetyp = (typeof ARENDETYPER)[number];
export function arendetyp(arende: Arende): Arendetyp {
let typ: Arendetyp = "Privat kund";
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "foto") foto = true;
if (h.typ === "matvarde") matvarde = true;
if (h.typ === "arbetsorder_skannad") dokument = true;
if (h.typ === "observation" || h.typ === "kontroll_utford") observation = true;
if (h.typ === "arendetyp_satt" && (ARENDETYPER as readonly string[]).includes(h.arendetyp)) {
typ = h.arendetyp as Arendetyp;
}
}
const kallor = [foto, matvarde, dokument].filter(Boolean).length;
if (kallor >= 2) return "E6";
if (dokument) return "E5";
if (matvarde) return "E4";
if (foto) return "E2";
if (observation) return "E1";
return "E0";
return typ;
}
interface ComplianceKrav {
id: string;
rubrik: string;
uppfyllt: (arende: Arende) => boolean;
detaljVidBrist: string;
}
function objektFalt(arende: Arende, falt: "miltal" | "claim" | "skadenummer" | "arbetsorder"): string | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt[falt];
}
return undefined;
}
const harMatarstallning = (arende: Arende) =>
arende.handelser.some((p) => p.handelse.typ === "matarstallning" && !p.handelse.undantag) ||
!!objektFalt(arende, "miltal");
const harHistorik = (arende: Arende) =>
arende.handelser.some((p) => p.handelse.typ === "historik_kontrollerad" && p.handelse.kontrollerad);
const harFoto = (arende: Arende) => arende.handelser.some((p) => p.handelse.typ === "foto");
// Regelpaket per ärendetyp — här ansluter framtida serverdistribuerade
// regler (garantivillkor per tillverkare, försäkringsbolagens krav …).
const COMPLIANCE_REGLER: Partial<Record<Arendetyp, ComplianceKrav[]>> = {
Garanti: [
{ id: "garanti_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Garantiärenden kräver dokumenterad mätarställning." },
{ id: "garanti_historik", rubrik: "Servicehistorik kontrollerad", uppfyllt: harHistorik, detaljVidBrist: "Garantiärenden kräver kontrollerad servicehistorik." },
{ id: "garanti_claim", rubrik: "Claim-/garantinummer registrerat", uppfyllt: (a) => !!objektFalt(a, "claim"), detaljVidBrist: "Ange claim-/garantinummer (läses ur arbetsordern)." },
],
Goodwill: [
{ id: "goodwill_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Goodwillärenden kräver dokumenterad mätarställning." },
{ id: "goodwill_historik", rubrik: "Servicehistorik kontrollerad", uppfyllt: harHistorik, detaljVidBrist: "Goodwillärenden kräver kontrollerad servicehistorik." },
],
Försäkring: [
{ id: "forsakring_skadenummer", rubrik: "Skadenummer registrerat", uppfyllt: (a) => !!objektFalt(a, "skadenummer"), detaljVidBrist: "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ id: "forsakring_bildbevis", rubrik: "Bildbevis finns", uppfyllt: harFoto, detaljVidBrist: "Försäkringsärenden kräver bilddokumentation." },
],
Reklamation: [
{ id: "reklamation_historik", rubrik: "Historik och tidigare försök kontrollerade", uppfyllt: harHistorik, detaljVidBrist: "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." },
],
Begagnatgaranti: [
{ id: "begagnat_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Begagnatgaranti kräver dokumenterad mätarställning." },
],
};
// ---- 4. Validation Engine ---------------------------------------------
// Kärnprincipen — inga påståenden utan underlag — verkar i tre lager:
// (a) orkesterns grundprompt: aldrig "OK/kontrollerad/inga fel" utan
// evidens; skriv "Evidens saknas" och begär rätt underlag,
// (b) projektionerna: hypoteser kan aldrig bli konstaterade fel,
// (c) kvalitetsgrinden nedan: rapport/avslut blockeras tills varje
// obligatoriskt påstående har evidens eller dokumenterat undantag.
// ---- Pre-Diagnostic Validation ----------------------------------------
export interface PreDiagRad {
id: "historik" | "matarstallning_in" | "felbeskrivning" | "tidiga_observationer";
rubrik: string;
klar: boolean;
varning?: string;
}
// Ingen felsökning påbörjas förrän grundkontrollerna är genomförda —
// eller dokumenterat motiverade. Allt härleds ur loggen.
export function preDiagnostik(arende: Arende): PreDiagRad[] {
let historik: PreDiagRad = { id: "historik", rubrik: "Fordonshistorik kontrollerad", klar: false };
let matarstallning = false;
let felbeskrivningVerifierad = false;
let tidiga = false;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "historik_kontrollerad") {
historik = {
id: "historik",
rubrik: "Fordonshistorik kontrollerad",
klar: true,
varning: h.kontrollerad ? undefined : `Ej kontrollerad — orsak: ${h.kommentar ?? "saknas"}`,
};
}
if (h.typ === "matarstallning" && h.lage === "ingaende") matarstallning = true;
if (h.typ === "kommentar" && h.text.startsWith(MARKOR_FELBESKRIVNING_VERIFIERAD)) felbeskrivningVerifierad = true;
if (
h.typ === "kommentar" &&
(h.text.startsWith(MARKOR_INGA_TIDIGA_OBSERVATIONER) || h.text.startsWith(MARKOR_TIDIGA_OBSERVATIONER_KLARA))
) {
tidiga = true;
}
}
return [
historik,
{ id: "matarstallning_in", rubrik: "Ingående mätarställning dokumenterad", klar: matarstallning },
{ id: "felbeskrivning", rubrik: "Kundens felbeskrivning verifierad", klar: felbeskrivningVerifierad },
{ id: "tidiga_observationer", rubrik: "Tidiga observationer hanterade", klar: tidiga },
];
}
export function preDiagnostikKlar(arende: Arende): boolean {
return preDiagnostik(arende).every((rad) => rad.klar);
}
// ---- 5. Completion Engine ---------------------------------------------
export interface GrindRad {
id: string;
rubrik: string;
@@ -71,12 +261,13 @@ export interface GrindRad {
detalj?: string;
}
// Kvalitetsgrind före slutrapport: varje rad är en verifierbar kontroll
// mot händelseloggen. Rapporten kan inte genereras förrän alla
// obligatoriska rader är gröna — evidens eller dokumenterat undantag.
// Kvalitetsgrind före slutrapport/avslut. Varje rad är en verifierbar
// regel mot händelseloggen; regel-id och ECM-version följer med i
// exporten (Traceability Engine).
export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
const rader: GrindRad[] = [];
const handelser = arende.handelser.map((p) => p.handelse);
const avslutat = handelser.some((h) => h.typ === "arende_avslutat");
const objektFinns = handelser.some((h) => h.typ === "objekt_identifierat");
rader.push({
@@ -87,7 +278,7 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: objektFinns ? undefined : "Evidens saknas — identifiera objektet.",
});
const arbetsorder = handelser.some((h) => h.typ === "arbetsorder_skannad");
const arbetsorder = handelser.some((h) => h.typ === "arbetsorder_skannad") || !!objektFalt(arende, "arbetsorder");
rader.push({
id: "arbetsorder",
rubrik: "Arbetsorder inläst",
@@ -96,6 +287,44 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: arbetsorder ? undefined : "Ärendet startades utan skannad arbetsorder.",
});
// Pre-diagnostiken ingår i grinden: historik + ingående mätarställning
// är obligatoriska (dokumenterade eller motiverade).
const pre = preDiagnostik(arende);
const historik = pre.find((r) => r.id === "historik")!;
rader.push({
id: "historik",
rubrik: "Fordonshistorik kontrollerad eller motiverad",
ok: historik.klar,
kravs: true,
detalj: historik.klar ? historik.varning : "Kontrollera historiken eller dokumentera varför det inte gått.",
});
const matIn = pre.find((r) => r.id === "matarstallning_in")!;
rader.push({
id: "matarstallning_in",
rubrik: "Ingående mätarställning dokumenterad",
ok: matIn.klar,
kravs: true,
detalj: matIn.klar ? undefined : "Fotografera instrumentpanelen (eller dokumentera undantag).",
});
const felb = pre.find((r) => r.id === "felbeskrivning")!;
rader.push({
id: "felbeskrivning_verifierad",
rubrik: "Kundens felbeskrivning verifierad",
ok: felb.klar,
kravs: false,
detalj: felb.klar ? undefined : "Bekräfta att kundens beskrivning är korrekt återgiven.",
});
// Utgående mätarställning: obligatorisk först när ärendet avslutas.
const matUt = handelser.some((h) => h.typ === "matarstallning" && h.lage === "utgaende");
rader.push({
id: "matarstallning_ut",
rubrik: "Utgående mätarställning dokumenterad",
ok: matUt,
kravs: avslutat,
detalj: matUt ? undefined : "Fotografera instrumentpanelen när arbetet är klart.",
});
// Metodikens kontroller: evidens eller dokumenterat undantag per kontroll.
const hanterade = new Map<string, { undantag?: string }>();
for (const h of handelser) {
@@ -134,6 +363,19 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: fotoKravUtanFoto > 0 ? `${fotoKravUtanFoto} fotokrävande kontroll(er) utan bild i loggen.` : undefined,
});
// Compliance Engine: ärendetypens regelpaket.
const typ = arendetyp(arende);
for (const krav of COMPLIANCE_REGLER[typ] ?? []) {
const ok = krav.uppfyllt(arende);
rader.push({
id: krav.id,
rubrik: `${typ}: ${krav.rubrik}`,
ok,
kravs: true,
detalj: ok ? undefined : krav.detaljVidBrist,
});
}
const hypoteser = handelser.filter((h) => h.typ === "hypotes").length;
rader.push({
id: "hypoteser",
@@ -159,3 +401,19 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
export function grindGodkand(arende: Arende, metodik: Metodik): boolean {
return kvalitetsgrind(arende, metodik).every((rad) => rad.ok || !rad.kravs);
}
// ---- 6. Traceability Engine -------------------------------------------
// Spårbarhetspaketet som följer med varje export: regelverkets version,
// ärendets evidensnivå och samtliga evidensposter med innehållshash.
// Tillsammans med den append-only-loggen kan varje slutsats härledas:
// vilken bild, vilken mätning, vilken tekniker, vilken regel, när.
export function sparbarhetspaket(arende: Arende, metodik: Metodik) {
return {
ecmVersion: ECM_VERSION,
arendetyp: arendetyp(arende),
evidensniva: evidensNiva(arende),
kvalitetsgrind: kvalitetsgrind(arende, metodik).map(({ id, rubrik, ok, kravs }) => ({ id, rubrik, ok, kravs })),
evidensposter: evidensposter(arende),
};
}
+40
View File
@@ -33,6 +33,46 @@ export function ansvarig(arende: Arende): string | undefined {
return namn;
}
// Ärendeidentiteten (Case Identity): registreras en gång och återanvänds
// i felsökningsvyn, Live Share, slutrapporten och exporten. Fordons-
// objektet är den röda tråden — det ska alltid vara omedelbart tydligt
// vilket fordon och vilket ärende informationen avser.
export interface Arendeidentitet {
nummer: number;
arbetsorder?: string;
claim?: string;
skadenummer?: string;
identifierare?: string;
vin?: string;
beskrivning?: string;
miltal?: string;
kund?: string;
ansvarig?: string;
avslutat: boolean;
}
export function arendeidentitet(arende: Arende): Arendeidentitet {
const o = objekt(arende);
let miltal = o?.miltal;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "matarstallning" && h.lage === "ingaende" && !h.undantag) miltal = h.varde;
}
return {
nummer: arende.nummer,
arbetsorder: o?.arbetsorder,
claim: o?.claim,
skadenummer: o?.skadenummer,
identifierare: o?.identifierare,
vin: o?.vin,
beskrivning: o?.beskrivning,
miltal,
kund: o?.kund,
ansvarig: ansvarig(arende),
avslutat: arAvslutat(arende),
};
}
export function arAvslutat(arende: Arende): boolean {
return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat");
}
+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;
+1 -1
View File
@@ -71,7 +71,7 @@ const ARBETSORDER_FALT_ID = [
"fordon_regnr", "fordon_vin", "fordon_marke", "fordon_modell",
"fordon_motor", "fordon_arsmodell", "fordon_matarstallning",
"fordon_motorkod", "fordon_vaxellada",
"ao_nummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"ao_nummer", "ao_claim", "ao_skadenummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"felbeskrivning",
];