diff --git a/docs/MVP.md b/docs/MVP.md index 219acd7..eb53e02 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -16,7 +16,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Direktivets kärna | Status i MVP | | --- | --- | -| Objektidentifiering först | ✅ Manuell inmatning (reg.nr/VIN/serienummer/maskinnummer) med bekräftelsesteg. QR/streckkod/OCR är markerade som kommande. | +| Objektidentifiering först | ✅ **QR-/streckkodsläsning** (`src/felsokning/streckkod.ts`): kameraströmmen läser QR, Code 39/128, Data Matrix och PDF417 via webbläsarens BarcodeDetector. Avläst kod klassificeras innan den används — VIN (17 tecken utan I/O/Q), svenskt regnr (båda serierna) eller serienummer — och identifieraren plockas ut även ur QR-innehåll som URL:er eller `vin=…`-fält; fritext och nakna URL:er avvisas. Saknar webbläsaren API:t (t.ex. iOS/Safari) **fotograferas typskylten** i stället och plattformens bildtolkning läser av den — kameran är gränssnittet oavsett enhet. Manuell inmatning med bekräftelsesteg finns kvar. | | AI-guidad felsökning | ✅ Deterministisk metodikmotor (en fråga i taget, tre metodiker) **plus Claude-orkestern driven av plattformen**: edge-funktionen `felsokning-ai` äger Claude API-nyckeln (serverhemligheten `ANTHROPIC_API_KEY`) och routar per uppgift — handledning i realtid (Sonnet 5), djupgranskning av hela underlaget via knapp i briefen (Opus 5, hög effort), AI-komplettering av överlämningen med risker & osäkerheter (Sonnet 5) och metodikklassificering av felbeskrivningen (Haiku 4.5). Alla svar är schema-bundna och klassificerade enligt AI-reglerna, med automatisk fallback till Anthropics rekommenderade reservmodell vid avböjd förfrågan; modellen som svarade loggas i varje händelse. Kräver inloggad användare; svaren är interna och delas aldrig i kundvyer. I lokalt läge guidar metodiken ensam. | | Arbetslogg | ✅ Append-only händelselogg med tidsstämpel och användare på varje post. Ingenting skrivs över. | | Tidredovisning | ✅ Kategorier (aktiv felsökning, väntetid, provkörning …) via kategoribyten i loggen; paus räknas inte i total tid. Inaktivitetsfråga efter 20 min utan händelser. | @@ -57,4 +57,5 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st ## Medvetna avgränsningar -- QR-/streckkodsläsning, VIN-avkodning mot fordonsdatabaser (utrustningsnivå, återkallelser, TSB:er) och tillverkarintegrationer ingår inte ännu — arbetsorderskanningen ger strukturen de kopplas in i. +- VIN-avkodning mot fordonsdatabaser (utrustningsnivå, återkallelser, TSB:er) och tillverkarintegrationer ingår inte ännu — arbetsorderskanningen och QR-/VIN-avläsningen ger strukturen de kopplas in i. +- Publikt kundgodkännande direkt i delningslänken (kunden klickar själv) kräver en skrivande publik endpoint med egen säkerhetsmodell; i dag registrerar verkstaden kundens besked med kanal och tidpunkt. diff --git a/src/felsokning/__tests__/streckkod.test.ts b/src/felsokning/__tests__/streckkod.test.ts new file mode 100644 index 0000000..8a51472 --- /dev/null +++ b/src/felsokning/__tests__/streckkod.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { tolkaKod } from "../streckkod"; + +describe("avläst kod klassificeras innan den används", () => { + it("känner igen VIN (17 tecken, utan I/O/Q)", () => { + expect(tolkaKod("YV1DZ8256F2123456")).toEqual({ varde: "YV1DZ8256F2123456", typ: "VIN" }); + // Mellanslag och bindestreck i streckkoder normaliseras bort. + expect(tolkaKod(" yv1dz8256f2123456 ")?.typ).toBe("VIN"); + // 16 tecken är inget VIN — men kan vara ett serienummer. + expect(tolkaKod("YV1DZ8256F212345")?.typ).toBe("Serienummer"); + // I/O/Q förekommer aldrig i VIN → klassas som serienummer. + expect(tolkaKod("YV1DZ8256F212345O")?.typ).toBe("Serienummer"); + }); + + it("känner igen svenska registreringsnummer i båda serierna", () => { + expect(tolkaKod("ABC123")).toEqual({ varde: "ABC123", typ: "Regnr" }); + expect(tolkaKod("abc12a")).toEqual({ varde: "ABC12A", typ: "Regnr" }); + expect(tolkaKod("ABC 123")?.typ).toBe("Regnr"); + }); + + it("plockar ut identifieraren ur QR-innehåll med fält eller URL", () => { + expect(tolkaKod("https://verkstad.se/fordon?vin=YV1DZ8256F2123456")).toEqual({ + varde: "YV1DZ8256F2123456", + typ: "VIN", + }); + expect(tolkaKod("REGNR: ABC123")).toEqual({ varde: "ABC123", typ: "Regnr" }); + expect(tolkaKod("vin YV1DZ8256F2123456")?.typ).toBe("VIN"); + }); + + it("tar maskin-/serienummer som de är men avvisar fritext och nakna URL:er", () => { + expect(tolkaKod("SN-4471/A")).toEqual({ varde: "SN4471/A", typ: "Serienummer" }); + expect(tolkaKod("https://exempel.se/nagot-helt-annat")).toBeNull(); + expect(tolkaKod("Kontrollera oljenivån innan start")).toBeNull(); + expect(tolkaKod(" ")).toBeNull(); + expect(tolkaKod("AB")).toBeNull(); + }); +}); diff --git a/src/felsokning/streckkod.ts b/src/felsokning/streckkod.ts new file mode 100644 index 0000000..69cc417 --- /dev/null +++ b/src/felsokning/streckkod.ts @@ -0,0 +1,131 @@ +// QR-, streckkods- och typskyltsläsning för objektidentifiering. +// +// Två vägar, samma resultat: +// 1. Webbläsarens BarcodeDetector (Chrome/Android) läser QR, Code 39/128 +// och Data Matrix direkt ur kameraströmmen — vanligast på typskyltar, +// VIN-etiketter och maskinmärkning. +// 2. Saknas API:t (t.ex. iOS/Safari) fotograferas typskylten i stället +// och plattformens bildtolkning läser av den — visual-first: kameran +// är gränssnittet oavsett vad webbläsaren stöder. +// +// Den avlästa koden klassificeras alltid innan den används, så att rätt +// identifieringsmetod sätts på fordonsobjektet. + +export type KodTyp = "VIN" | "Regnr" | "Serienummer"; + +export interface AvlastKod { + varde: string; + typ: KodTyp; + format?: string; +} + +// VIN: 17 tecken, aldrig I, O eller Q (för att inte förväxlas med 1/0). +// Kontrollsiffran (position 9) valideras medvetet inte — den är +// obligatorisk i Nordamerika men följs inte konsekvent i Europa. +const VIN_MONSTER = /^[A-HJ-NPR-Z0-9]{17}$/; +// Svenskt registreringsnummer: ABC123 eller ABC12A (nyare serie). +const REGNR_MONSTER = /^[A-Z]{3}[0-9]{2}[0-9A-Z]$/; + +// Normaliserar och klassificerar en avläst kod. QR-koder på fordon och +// maskiner innehåller ofta en URL eller nyckel=värde — då plockas +// identifieraren ut ur innehållet. +export function tolkaKod(ratext: string): AvlastKod | null { + const text = ratext.trim(); + if (!text) return null; + + // QR med URL eller fält: leta efter vin/regnr i innehållet. + const vinFalt = text.match(/(?:vin|chassi)[=:/\s]+([A-HJ-NPR-Z0-9]{17})/i); + if (vinFalt) return { varde: vinFalt[1].toUpperCase(), typ: "VIN" }; + const regnrFalt = text.match(/(?:reg(?:nr)?|plate)[=:/\s]+([A-Za-z]{3}\s?[0-9]{2}[0-9A-Za-z])/i); + if (regnrFalt) return { varde: regnrFalt[1].replace(/\s/g, "").toUpperCase(), typ: "Regnr" }; + + const rent = text.replace(/[\s-]/g, "").toUpperCase(); + if (VIN_MONSTER.test(rent)) return { varde: rent, typ: "VIN" }; + if (REGNR_MONSTER.test(rent)) return { varde: rent, typ: "Regnr" }; + + // Allt annat (maskin-/serienummer) tas som det är, men bara om det ser + // ut som en identifierare — inte som fritext eller en hel URL. + if (/^[A-Z0-9._/-]{4,40}$/.test(rent) && !/^HTTPS?:/.test(rent)) { + return { varde: rent, typ: "Serienummer" }; + } + return null; +} + +interface BarcodeDetectorLik { + detect(kalla: CanvasImageSource): Promise<{ rawValue: string; format?: string }[]>; +} + +function detektorKlass(): (new (init?: { formats?: string[] }) => BarcodeDetectorLik) | undefined { + return (globalThis as { BarcodeDetector?: new (init?: { formats?: string[] }) => BarcodeDetectorLik }) + .BarcodeDetector; +} + +export function stodStreckkod(): boolean { + return !!detektorKlass() && !!navigator.mediaDevices?.getUserMedia; +} + +export interface Skanning { + stoppa: () => void; +} + +// Startar kameraströmmen i det angivna video-elementet och anropar +// vidTraff första gången en giltig kod lästs av. Anroparen ansvarar för +// att stoppa skanningen (returvärdet) när panelen stängs. +export async function startaSkanning( + video: HTMLVideoElement, + vidTraff: (kod: AvlastKod) => void, + vidFel?: (meddelande: string) => void, +): Promise { + const Detektor = detektorKlass(); + if (!Detektor) { + vidFel?.("Webbläsaren saknar streckkodsläsning — fotografera typskylten i stället."); + return { stoppa: () => {} }; + } + + // Samlat tillstånd: stoppa() kan då stänga ner även om skanningen + // avbryts innan kameraströmmen hunnit starta. + const lage: { aktiv: boolean; strom?: MediaStream; timer?: ReturnType } = { aktiv: true }; + + const stoppa = () => { + lage.aktiv = false; + if (lage.timer !== undefined) clearInterval(lage.timer); + lage.strom?.getTracks().forEach((spar) => spar.stop()); + }; + + try { + lage.strom = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); + if (!lage.aktiv) { + lage.strom.getTracks().forEach((spar) => spar.stop()); + return { stoppa }; + } + video.srcObject = lage.strom; + // play() väntas medvetet inte in: på vissa enheter resolvar löftet + // först när första bildrutan kommit, och då skulle avläsningen aldrig + // starta. Detektorn får helt enkelt tomma rutor tills strömmen är i gång. + void video.play().catch(() => {}); + } catch { + vidFel?.("Kameran kunde inte startas — kontrollera behörigheten eller fotografera typskylten."); + return { stoppa }; + } + + const detektor = new Detektor({ formats: ["qr_code", "code_128", "code_39", "data_matrix", "pdf417"] }); + // Avläsning ~2,5 ggr/sekund räcker gott och håller nere batteridraget. + lage.timer = setInterval(async () => { + if (!lage.aktiv) return; + try { + const traffar = await detektor.detect(video); + for (const traff of traffar) { + const kod = tolkaKod(traff.rawValue); + if (kod) { + stoppa(); + vidTraff({ ...kod, format: traff.format }); + return; + } + } + } catch { + // Enstaka avläsningsfel är normalt — nästa bildruta får försöka. + } + }, 400); + + return { stoppa }; +} diff --git a/src/pages/felsokning/NyttArende.tsx b/src/pages/felsokning/NyttArende.tsx index 78c1fc7..6f6e33b 100644 --- a/src/pages/felsokning/NyttArende.tsx +++ b/src/pages/felsokning/NyttArende.tsx @@ -3,12 +3,13 @@ import { useNavigate } from "react-router-dom"; import type { Objekt } from "@/felsokning/domain"; import { useFelsokning } from "@/felsokning/store"; import { valjMetodik } from "@/felsokning/metodik"; -import { tolkaArbetsorder, valjMetodikMedAi, type TolkatFalt, type ArbetsorderGrupp } from "@/felsokning/ai"; +import { lasAvInstrument, tolkaArbetsorder, valjMetodikMedAi, type TolkatFalt, type ArbetsorderGrupp } from "@/felsokning/ai"; import { byggDemoTolkning } from "@/felsokning/demo"; import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { skalaNerFoto } from "@/felsokning/format"; import { IkonKamera } from "@/felsokning/ikoner"; +import { startaSkanning, stodStreckkod, tolkaKod, type AvlastKod, type Skanning } from "@/felsokning/streckkod"; // Ärendestart: teknikern är redan känd (inloggning/namn) och ska kunna // starta ett ärende på under 15 sekunder. Primärvägen är att fotografera @@ -38,6 +39,62 @@ export default function NyttArende() { const [fel, setFel] = useState(""); const [startar, setStartar] = useState(false); + // QR/streckkod: läses direkt ur kameraströmmen när webbläsaren stöder + // det, annars fotograferas typskylten och bildtolkningen läser av den. + const [skannarKod, setSkannarKod] = useState(false); + const [kodFel, setKodFel] = useState(""); + const [avlastKod, setAvlastKod] = useState(null); + const videoRef = useRef(null); + const skanningRef = useRef(null); + const typskyltRef = useRef(null); + const [laserTypskylt, setLaserTypskylt] = useState(false); + + useEffect(() => () => skanningRef.current?.stoppa(), []); + + const anvandKod = (kod: AvlastKod) => { + setAvlastKod(kod); + setIdentifierare(kod.varde); + if (inst.identifieringsmetoder.includes(kod.typ)) setMetod(kod.typ); + setSkannarKod(false); + }; + + const oppnaSkanning = async () => { + setKodFel(""); + setSkannarKod(true); + // Videoelementet monteras i samma render — vänta in det. + setTimeout(async () => { + if (!videoRef.current) return; + skanningRef.current = await startaSkanning(videoRef.current, anvandKod, (m) => { + setKodFel(m); + setSkannarKod(false); + }); + }, 50); + }; + + const stangSkanning = () => { + skanningRef.current?.stoppa(); + skanningRef.current = null; + setSkannarKod(false); + }; + + // Fallback: fota typskylten — bildtolkningen läser VIN/regnr ur bilden. + const lasTypskylt = async (fil: File) => { + setLaserTypskylt(true); + setKodFel(""); + const foto = await skalaNerFoto(fil); + try { + const resultat = await lasAvInstrument(foto); + const kandidater = resultat?.tolkning.varden.map((v) => v.varde) ?? []; + const traff = kandidater.map((v) => tolkaKod(v)).find((k): k is AvlastKod => !!k); + if (traff) anvandKod(traff); + else if (!resultat) setKodFel("Avläsning av typskylt kräver inloggning — ange identiteten manuellt."); + else setKodFel("Ingen identifierare kunde läsas ur bilden — försök igen eller ange manuellt."); + } catch { + setKodFel("Avläsningen kunde inte nås — ange identiteten manuellt."); + } + setLaserTypskylt(false); + }; + const [tolkning, setTolkning] = useState<{ foto: string; falt: TolkatFalt[]; modell?: string; demo?: boolean } | null>(null); const [skannar, setSkannar] = useState(false); const [skanningsFel, setSkanningsFel] = useState(""); @@ -198,9 +255,52 @@ export default function NyttArende() { ))}

- QR-kod, streckkod och OCR från typskylt tillkommer i senare version — ange identiteten manuellt. + QR-kod, streckkod och typskylt kan läsas av i nästa steg — eller ange identiteten manuellt.

+ + { + const fil = e.target.files?.[0]; + e.target.value = ""; + if (fil) lasTypskylt(fil); + }} + /> + {skannarKod ? ( +
+
+ ) : ( +
+ {stodStreckkod() && ( + + Skanna QR/streckkod + + )} + typskyltRef.current?.click()}> + {laserTypskylt ? "Läser av …" : "Fotografera typskylt"} + +
+ )} + {avlastKod && ( +

+ Avläst {avlastKod.typ}: {avlastKod.varde} + {avlastKod.format ? ` (${avlastKod.format})` : ""} — kontrollera värdet nedan. +

+ )} + {kodFel &&

{kodFel}

} +