QR-/streckkodsläsning med typskyltsfallback

Sista posten i "objektidentifiering först" — och den sista avgränsningen
från MVP:ns första version.

- Kameraströmmen läser QR, Code 39/128, Data Matrix och PDF417 via
  webbläsarens BarcodeDetector; träffen fyller identifieraren och sätter
  rätt identifieringsmetod automatiskt
- Avläst kod klassificeras alltid innan den används: VIN (17 tecken utan
  I/O/Q), svenskt regnr (båda serierna) eller serienummer. Identifieraren
  plockas ut även ur QR-innehåll som URL:er och vin=/regnr=-fält, medan
  fritext och nakna URL:er avvisas
- Saknar webbläsaren API:t (iOS/Safari) fotograferas typskylten i stället
  och plattformens bildtolkning läser av den — kameran är gränssnittet
  oavsett enhet
- Kameraströmmen stoppas alltid: vid träff, avbryt och avmontering.
  play() väntas medvetet inte in — på enheter där löftet resolvar först
  vid första bildrutan skulle avläsningen annars aldrig starta (fångat
  av klicktestet)

73 vitest-tester (nya: klassificeringen i alla varianter) och ett
klicktest som kör båda vägarna — med och utan streckkods-API.

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 10:47:46 +00:00
parent 5428684cca
commit e22e0262e4
4 changed files with 273 additions and 4 deletions
+3 -2
View File
@@ -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.
@@ -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();
});
});
+131
View File
@@ -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<Skanning> {
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<typeof setInterval> } = { 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 };
}
+102 -2
View File
@@ -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<AvlastKod | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const skanningRef = useRef<Skanning | null>(null);
const typskyltRef = useRef<HTMLInputElement>(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() {
))}
</div>
<p className="mt-3 text-[12px] text-[#707070]">
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.
</p>
</Panel>
<Panel rubrik="Läs av identiteten">
<input
ref={typskyltRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
e.target.value = "";
if (fil) lasTypskylt(fil);
}}
/>
{skannarKod ? (
<div>
<video ref={videoRef} playsInline muted className="mb-2 w-full rounded border border-[#C6C6C6]" />
<p className="mb-2 text-[12px] text-[#707070]">
Rikta kameran mot QR-koden, streckkoden eller VIN-etiketten.
</p>
<StorKnapp variant="sekundar" onClick={stangSkanning}>
Avbryt skanning
</StorKnapp>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{stodStreckkod() && (
<StorKnapp variant="sekundar" onClick={oppnaSkanning}>
Skanna QR/streckkod
</StorKnapp>
)}
<StorKnapp variant="sekundar" disabled={laserTypskylt} onClick={() => typskyltRef.current?.click()}>
<IkonKamera /> {laserTypskylt ? "Läser av …" : "Fotografera typskylt"}
</StorKnapp>
</div>
)}
{avlastKod && (
<p className="mt-2 text-[12px] font-semibold text-[#1E6B34]">
Avläst {avlastKod.typ}: {avlastKod.varde}
{avlastKod.format ? ` (${avlastKod.format})` : ""} kontrollera värdet nedan.
</p>
)}
{kodFel && <p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">{kodFel}</p>}
</Panel>
<Panel rubrik="Objektets identitet">
<TextFalt label={metod} varde={identifierare} satt={setIdentifierare} platshallare="T.ex. ABC123" />
<TextFalt label="Beskrivning (märke, modell, år)" varde={beskrivning} satt={setBeskrivning} platshallare="T.ex. Volvo XC60 D4 2019" />