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
+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" />