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:
+3
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user