From bfed5dbd7f43a7338e3cdb52c184954292098647 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 09:18:43 +0000 Subject: [PATCH] =?UTF-8?q?ECM=20v2.0=20som=20subsystem:=20sex=20motorer,?= =?UTF-8?q?=20pre-diagnostik=20och=20=C3=A4rendeidentitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- docs/MVP.md | 4 +- docs/moduler/evidensmotor.md | 153 +++++----- services/ai-orkester/server.mjs | 2 +- services/plattform/openapi.yaml | 3 + src/felsokning/DelatArendeVy.tsx | 35 ++- src/felsokning/__tests__/ecm.test.ts | 177 ++++++++---- src/felsokning/ai.ts | 2 + src/felsokning/demo.ts | 9 + src/felsokning/domain.ts | 28 +- src/felsokning/ecm.ts | 328 +++++++++++++++++++--- src/felsokning/projektioner.ts | 40 +++ src/pages/felsokning/ArendeSida.tsx | 317 ++++++++++++++++++++- src/pages/felsokning/NyttArende.tsx | 6 + supabase/functions/felsokning-ai/index.ts | 2 +- 14 files changed, 943 insertions(+), 163 deletions(-) diff --git a/docs/MVP.md b/docs/MVP.md index 0decc5b..f863ee9 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -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, 🟡 80–95 % 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 E0–E6 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å E0–E6, 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`. | diff --git a/docs/moduler/evidensmotor.md b/docs/moduler/evidensmotor.md index 8e7f64e..e30920c 100644 --- a/docs/moduler/evidensmotor.md +++ b/docs/moduler/evidensmotor.md @@ -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 %, 🟡 80–95 %, 🔴 <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. diff --git a/services/ai-orkester/server.mjs b/services/ai-orkester/server.mjs index 739dec5..e6a6049 100644 --- a/services/ai-orkester/server.mjs +++ b/services/ai-orkester/server.mjs @@ -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", ]; diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index 7721fde..ab67b41 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -568,6 +568,9 @@ components: - overlamning - ansvarig_satt - arbetsorder_skannad + - arendetyp_satt + - historik_kontrollerad + - matarstallning - export_skapad - ai_svar - arende_avslutat diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index ffcc516..169bc0d 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -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 ( +
+ {idn.beskrivning &&

{idn.beskrivning}

} +

+ {falt + .filter(([, varde]) => varde) + .map(([etikett, varde]) => ( + + {etikett}: {varde} + + ))} +

+
+ ); +} + export function DelatArendeVy({ arende, nu, @@ -48,6 +76,11 @@ export function DelatArendeVy({ } > + {/* Ä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. */} + +

{notis}

diff --git a/src/felsokning/__tests__/ecm.test.ts b/src/felsokning/__tests__/ecm.test.ts index 125c88b..d2ac8d7 100644 --- a/src/felsokning/__tests__/ecm.test.ts +++ b/src/felsokning/__tests__/ecm.test.ts @@ -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"); }); }); diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts index 6a930f1..ee2792d 100644 --- a/src/felsokning/ai.ts +++ b/src/felsokning/ai.ts @@ -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" }, diff --git a/src/felsokning/demo.ts b/src/felsokning/demo.ts index 1c8a86b..36c9625 100644 --- a/src/felsokning/demo.ts +++ b/src/felsokning/demo.ts @@ -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" }), diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index 08dc1ea..82ba0c1 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -29,12 +29,21 @@ export const TIDKATEGORI_LABEL: Record = { 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": { diff --git a/src/felsokning/ecm.ts b/src/felsokning/ecm.ts index 995b9bf..df34c43 100644 --- a/src/felsokning/ecm.ts +++ b/src/felsokning/ecm.ts @@ -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 = { @@ -29,8 +38,76 @@ export const EVIDENS_LABEL: Record = { 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; + 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> = { + 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(); 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), + }; +} diff --git a/src/felsokning/projektioner.ts b/src/felsokning/projektioner.ts index 26861de..c885cce 100644 --- a/src/felsokning/projektioner.ts +++ b/src/felsokning/projektioner.ts @@ -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"); } diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index a52673e..79776ee 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -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() { } > + {/* Ärendeidentiteten: registreras en gång, alltid synlig — det ska + aldrig råda tvekan om vilket fordon och ärende som avses. */} + + {/* 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]) => ( +
+ {etikett} + {varde} +
+ )); +} + +// Ä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 ? ( + + {etikett && {etikett} } + {varde} + + ) : null; + return ( +
+ {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)} + +
+ ); +} + +// 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(null); + const [foto, setFoto] = useState(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 ( +
+ { + const fil = e.target.files?.[0]; + if (fil) fota(fil); + e.target.value = ""; + }} + /> + {!foto && !laser && ( + filRef.current?.click()}> + 📷 Fotografera instrumentpanelen + + )} + {laser &&

Systemet läser av mätarställningen …

} + {foto && !laser && ( +
+ {demo && ( +

+ Demo-avläsning — bildtolkningen kräver inloggning. Kontrollera värdet. +

+ )} + Instrumentpanel + + { + skicka({ typ: "matarstallning", lage, varde: varde.trim(), dataUrl: foto }); + setFoto(null); + setVarde(""); + }} + > + Spara mätarställning + +
+ )} + skicka({ typ: "matarstallning", lage, varde: "", undantag: orsak })} /> +
+ ); +} + +// 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 ( + + {rader.map((r) => ( +

+ {r.klar ? "✅" : "☐"} {r.rubrik} + {r.varning && ⚠ {r.varning}} +

+ ))} + + {!rad("historik").klar && ( +
+

+ Har fordonets historik kontrollerats? (tidigare arbeten, återkommande fel, TSB, kampanjer) +

+
+ setHistorikVal("ja")}> + Ja — kontrollerad + + setHistorikVal("nej")}> + Nej + +
+ {historikVal && ( + <> + + { + skicka({ typ: "historik_kontrollerad", kontrollerad: historikVal === "ja", kommentar: historikText.trim() || undefined }); + setHistorikVal(""); + setHistorikText(""); + }} + > + Dokumentera + + + )} +
+ )} + + {!rad("matarstallning_in").klar && ( +
+

+ Ingående mätarställning — fotografera instrumentpanelen +

+ +
+ )} + + {!rad("felbeskrivning").klar && ( +
+

+ Är kundens felbeskrivning korrekt återgiven? +

+ {fb &&

”{fb}”

} + skicka({ typ: "kommentar", text: `${MARKOR_FELBESKRIVNING_VERIFIERAD}.` })}> + Stämmer — verifierad + +

+ Ytterligare symptom dokumenteras som separata observationer — blanda dem inte med kundens beskrivning. +

+
+ )} + + {!rad("tidiga_observationer").klar && ( +
+

+ Något ytterligare vid mottagandet? (reparationsspår, modifieringar, skador, läckage, korrosion …) +

+
+ skicka({ typ: "kommentar", text: `${MARKOR_INGA_TIDIGA_OBSERVATIONER}.` })}> + Inga ytterligare observationer + + skicka({ typ: "kommentar", text: `${MARKOR_TIDIGA_OBSERVATIONER_KLARA}.` })} + > + Observationerna är dokumenterade + +
+

+ Dokumentera med foto eller observation i panelen nedan — knappen låses upp när något loggats. +

+
+ )} +
+ ); +} + // 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 ( + <> + + + + + ); + } + return ( <> @@ -422,6 +683,14 @@ function GuideFlik({ + {/* 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") && ( + + + + )} +
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).

- - {b.objekt && ( -

- {b.objekt.beskrivning} · {b.objekt.identifierare} -

- )} - {b.felbeskrivning &&

Felbeskrivning: ”{b.felbeskrivning}”

} + {/* Rapportens första sida: ärendeidentiteten — registrerad en gång, + återanvänd här automatiskt. */} + + {identitetsRader([ + ["Ärende", `#${arende.nummer}`], + ["Arbetsorder", idn.arbetsorder], + ["Claim-/garantinr", idn.claim], + ["Skadenummer", idn.skadenummer], + ["Ärendetyp", typ], + ["Ansvarig tekniker", idn.ansvarig], + ])} + + + {identitetsRader([ + ["Fordon", idn.beskrivning], + ["Regnr", idn.identifierare], + ["VIN", idn.vin], + ["Mätarställning in", matIn], + ["Mätarställning ut", matUt], + ["Kund", idn.kund], + ])} + + + {b.felbeskrivning &&

Felbeskrivning: ”{b.felbeskrivning}”

}

Total arbetstid: {b.totalArbetstid}

{fordelning.length > 0 && (
diff --git a/src/pages/felsokning/NyttArende.tsx b/src/pages/felsokning/NyttArende.tsx index e257a60..2345ed6 100644 --- a/src/pages/felsokning/NyttArende.tsx +++ b/src/pages/felsokning/NyttArende.tsx @@ -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; diff --git a/supabase/functions/felsokning-ai/index.ts b/supabase/functions/felsokning-ai/index.ts index b144635..0a94193 100644 --- a/supabase/functions/felsokning-ai/index.ts +++ b/supabase/functions/felsokning-ai/index.ts @@ -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", ];