ALVA: analysvy, sammanfattning och integrationsgränssnitt
---- Statistiken är ett produktbeslut ----------------------------------
Vilka siffror som visas avgör vad organisationen optimerar, så tre mått
är uteslutna med avsikt och låsta av test: ärenden per tekniker,
genomsnittlig ledtid och aktivitet. Alla tre belönar den som hoppar över
kontroller, och ett svårt fel SKA ta längre tid.
Medtagna, var och en handlingsbar:
VERIFIERINGSGRAD Andel avslut med fastställd orsak. Det enda mått
ett försäkringsbolag egentligen bryr sig om. Att
den inte är 100 % är friskt.
REPRODUKTIONSGRAD Låg siffra förutsäger återkommande fordon — man kan
inte åtgärda det man inte sett.
OMARBETNING Samma fordon tillbaka med samma orsakskategori inom
ett fönster. Det dyraste felet i en verkstad och det
enda ingen mäter, eftersom det kräver att två
ärenden kopplas ihop. Här är kopplingen gratis.
UNDANTAGSFREKVENS Vilka kontroller som hoppas över, per steg och fas.
Ett steg högt i listan är antingen felskrivet eller
kräver utrustning som saknas — bägge åtgärdbara.
Ett mått utan underlag visas som NOT APPLICABLE, aldrig som noll.
Skillnaden avgör om någon fattar beslut på en siffra som inte finns.
---- Sammanfattningen är härledd, inte genererad -----------------------
Frestelsen är att låta modellen skriva den. Det vore fel av tre skäl:
den blir en del av ett beslutsunderlag och måste därför gå att lita på
utan att granskas mot loggen varje gång; samma ärende måste ge samma
text om två år; och verkstadsgolvet har dålig täckning.
Den är alltså en projektion som briefen och rapporten. Den innehåller
inget som inte står i loggen och säger uttryckligen när något saknas i
stället för att utelämna det. Ett test kräver att den aldrig skriver
"felet konstaterat" när orsaken inte fastställts.
---- Integration utan påhittade endpoints ------------------------------
Jag känner inte Beonodes, ServiceCams eller CABAS faktiska gränssnitt.
En uppfunnen endpoint som ser färdig ut är sämre än en tom: den ser ut
att fungera tills någon försöker.
Därför ett profildrivet gränssnitt. En profil beskriver vad en KATEGORI
av system förväntar sig, och märks validated först efter att den körts
mot leverantören. Tills dess står den som draft, och det syns i
gränssnittet — en integrationslista där allt ser färdigt ut är den
snabbaste vägen till ett misslyckat införande, eftersom verkstaden
planerar efter den.
Kategorier: diagnosprotokoll, DMS, videooffert, fordonsdata,
skadekalkyl (CABAS är nordisk standard), garanti och försäkring.
Skadekalkyl går åt båda håll. Det ALVA tillför en kalkyl är inte fler
poster utan beviskedjan bakom dem: vad som kontrollerades, vad som
uteslöts och varför. Det är den enda del av en kalkyl som i dag inte går
att granska i efterhand.
Inkommande protokoll blir evidens, inte bilagor, med härkomsten bevarad
i varje post — ett värde som kommit utifrån får aldrig se ut som något
teknikern själv mätt. Saknas instrumentets identitet nedgraderas värdet
till E1 enligt samma regel som gäller manuella mätningar.
Utgående leveranser signeras med HMAC över tidsstämpel och kropp;
tidsstämpeln ligger inne i signaturen så en fångad leverans inte går att
spela upp i morgon. Verifieringsfunktionen exporteras så mottagaren kan
använda exakt samma kod — de flesta integrationsfel uppstår i glappet
mellan två implementationer av samma signatur.
Prenumerationer går genom samma SSRF-gräns som leverantörsuppslagen, och
leverans sker efter att loggen skrivits: en mottagare ska aldrig kunna
se en händelse som inte finns i loggen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -26,6 +26,8 @@ import AlvaAnsokan from "./pages/alva/Ansokan";
|
||||
import AlvaLoggaIn from "./pages/alva/LoggaIn";
|
||||
import AlvaPortal from "./pages/alva/Portal";
|
||||
import AlvaKunskapskallor from "./pages/alva/Kunskapskallor";
|
||||
import AlvaAnalys from "./pages/alva/Analys";
|
||||
import AlvaIntegration from "./pages/alva/Integration";
|
||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -64,6 +66,8 @@ function AppContent() {
|
||||
<Route path="/alva/logga-in" element={<AlvaLoggaIn />} />
|
||||
<Route path="/alva/portal" element={<AlvaPortal />} />
|
||||
<Route path="/alva/portal/kunskapskallor" element={<AlvaKunskapskallor />} />
|
||||
<Route path="/alva/portal/analys" element={<AlvaAnalys />} />
|
||||
<Route path="/alva/portal/integration" element={<AlvaIntegration />} />
|
||||
<Route path="/felsokning" element={<Arendelista />} />
|
||||
<Route path="/felsokning/nytt" element={<NyttArende />} />
|
||||
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment node
|
||||
// Statistik, sammanfattning och integrationsgränssnitt.
|
||||
//
|
||||
// Nyckeltal är ett produktbeslut: det som mäts är det som optimeras.
|
||||
// Testerna låser därför inte bara att räkningen stämmer, utan att de mått
|
||||
// som medvetet uteslutits inte smyger tillbaka.
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evidensprofil,
|
||||
omarbetning,
|
||||
oversikt,
|
||||
reproduktionsgrad,
|
||||
undantagsfrekvens,
|
||||
verifieringsgrad,
|
||||
} from "../../../../services/gemensam/statistik.mjs";
|
||||
import { enrading, sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs";
|
||||
import {
|
||||
KATEGORIER,
|
||||
UTGAENDE,
|
||||
protokollTillHandelser,
|
||||
signeraLeverans,
|
||||
verifieraLeverans,
|
||||
} from "../../../../services/gemensam/integration.mjs";
|
||||
|
||||
const post = (tidpunkt: string, handelse: Record<string, unknown>) => ({ tidpunkt, anvandare: "Anna", handelse });
|
||||
|
||||
function arende(id: string, ident: string, handelser: [string, Record<string, unknown>][]) {
|
||||
return { id, nummer: 1, handelser: handelser.map(([t, h]) => post(t, h)) };
|
||||
}
|
||||
|
||||
const AVSLUTAT_MED_ORSAK = arende("a1", "ABC123", [
|
||||
["2026-01-01T08:00:00Z", { typ: "objekt_identifierat", objekt: { identifierare: "ABC123", beskrivning: "Volvo XC60" } }],
|
||||
["2026-01-01T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h." }],
|
||||
["2026-01-01T09:00:00Z", { typ: "reproducering", status: "ja", beskrivning: "Reproducerat vid 88 km/h." }],
|
||||
["2026-01-01T09:30:00Z", { typ: "matvarde", beskrivning: "Obalans", varde: "38", enhet: "g", matdonId: "m1" }],
|
||||
["2026-01-01T10:00:00Z", { typ: "felorsak", avvikelse: "Obalans 38 g höger fram.", orsaker: ["Normalt slitage"], underlag: ["Mätresultat"], sakerhet: "hog", atgard: "Balansering" }],
|
||||
["2026-01-01T10:30:00Z", { typ: "atgard_utford", beskrivning: "Balanserade hjulen.", utford: true }],
|
||||
["2026-01-01T10:45:00Z", { typ: "kvalitetskontroll", resultat: "symptomet_borta", beskrivning: "Provkört." }],
|
||||
["2026-01-01T11:00:00Z", { typ: "slutsats", motivering: "Obalansen förklarar vibrationen eftersom den är hastighetsberoende.", uteslutet: "Kast uteslöts.", kvarstaende: "Inget.", atgardsval: "Balansering eftersom däcket är helt." }],
|
||||
["2026-01-01T11:05:00Z", { typ: "arende_avslutat", signatur: "Anna" }],
|
||||
]);
|
||||
|
||||
const AVSLUTAT_UTAN_ORSAK = arende("a2", "DEF456", [
|
||||
["2026-02-01T08:00:00Z", { typ: "objekt_identifierat", objekt: { identifierare: "DEF456" } }],
|
||||
["2026-02-01T09:00:00Z", { typ: "reproducering", status: "nej", beskrivning: "Kunde inte återskapas." }],
|
||||
["2026-02-01T10:00:00Z", { typ: "slutsats", orsakFastställd: false, motivering: "Felet uppträder bara i kyla eftersom kunden kör långt.", uteslutet: "Kablage kontrollerat.", kvarstaende: "Orsaken ej fastställd." }],
|
||||
["2026-02-01T10:05:00Z", { typ: "arende_avslutat" }],
|
||||
]);
|
||||
|
||||
describe("verifieringsgrad", () => {
|
||||
it("räknar avslut med fastställd orsak", () => {
|
||||
const v = verifieringsgrad([AVSLUTAT_MED_ORSAK, AVSLUTAT_UTAN_ORSAK]);
|
||||
expect(v).toEqual({ antal: 2, fastställda: 1, andel: 50 });
|
||||
});
|
||||
|
||||
it("returnerar null i stället för noll när underlag saknas", () => {
|
||||
// Ett mått utan underlag ska visas som saknat, inte som noll.
|
||||
// Skillnaden avgör om någon fattar beslut på en siffra som inte finns.
|
||||
expect(verifieringsgrad([]).andel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reproduktionsgrad", () => {
|
||||
it("skiljer ja, delvis och nej", () => {
|
||||
const r = reproduktionsgrad([AVSLUTAT_MED_ORSAK, AVSLUTAT_UTAN_ORSAK]);
|
||||
expect(r.ja).toBe(1);
|
||||
expect(r.nej).toBe(1);
|
||||
expect(r.andel).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("omarbetning", () => {
|
||||
const forsta = arende("b1", "GHI789", [
|
||||
["2026-01-01T10:00:00Z", { typ: "objekt_identifierat", objekt: { identifierare: "GHI789" } }],
|
||||
["2026-01-01T10:00:00Z", { typ: "felorsak", avvikelse: "x", orsaker: ["Korrosion"], underlag: [] }],
|
||||
["2026-01-01T11:00:00Z", { typ: "arende_avslutat" }],
|
||||
]);
|
||||
const andra = arende("b2", "GHI789", [
|
||||
["2026-02-01T10:00:00Z", { typ: "objekt_identifierat", objekt: { identifierare: "GHI789" } }],
|
||||
["2026-02-01T10:00:00Z", { typ: "felorsak", avvikelse: "y", orsaker: ["Korrosion"], underlag: [] }],
|
||||
["2026-02-01T11:00:00Z", { typ: "arende_avslutat" }],
|
||||
]);
|
||||
|
||||
it("hittar samma fordon tillbaka med samma orsakskategori", () => {
|
||||
const o = omarbetning([forsta, andra]);
|
||||
expect(o.antal).toBe(1);
|
||||
expect(o.fall[0].identifierare).toBe("GHI789");
|
||||
expect(o.fall[0].kategorier).toEqual(["Korrosion"]);
|
||||
expect(o.fall[0].dagar).toBe(31);
|
||||
});
|
||||
|
||||
it("räknar inte återbesök utanför fönstret", () => {
|
||||
expect(omarbetning([forsta, andra], 14).antal).toBe(0);
|
||||
});
|
||||
|
||||
it("räknar inte olika orsakskategorier som omarbetning", () => {
|
||||
const annanOrsak = {
|
||||
...andra,
|
||||
handelser: andra.handelser.map((p) =>
|
||||
p.handelse.typ === "felorsak" ? post(p.tidpunkt, { ...p.handelse, orsaker: ["Yttre påverkan"] }) : p,
|
||||
),
|
||||
};
|
||||
expect(omarbetning([forsta, annanOrsak]).antal).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evidensprofil", () => {
|
||||
it("kräver spårbart mätdon för E4", () => {
|
||||
const utan = arende("c1", "X", [
|
||||
["2026-01-01T10:00:00Z", { typ: "matvarde", beskrivning: "U", varde: "12" }],
|
||||
]);
|
||||
// Utan mätdon är det teknikerns observation av en siffra.
|
||||
expect(evidensprofil([utan]).E4).toBe(0);
|
||||
expect(evidensprofil([AVSLUTAT_MED_ORSAK]).E4).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undantagsfrekvens", () => {
|
||||
it("rangordnar de kontroller som oftast hoppas över", () => {
|
||||
const a = arende("d1", "X", [
|
||||
["2026-01-01T10:00:00Z", { typ: "kontroll_utford", stegId: "matningar", kontrollId: "k1", text: "Mät spänningsfall", undantag: "Utrustning saknas" }],
|
||||
["2026-01-01T10:01:00Z", { typ: "kontroll_utford", stegId: "visuell", kontrollId: "k2", text: "Fotografera", resultat: "Gjort" }],
|
||||
]);
|
||||
const rader = undantagsfrekvens([a]);
|
||||
expect(rader).toHaveLength(1);
|
||||
expect(rader[0].kontroll).toBe("k1");
|
||||
expect(rader[0].andel).toBe(100);
|
||||
// Fasen följer med, så friktionen går att läsa i ALVA-termer.
|
||||
expect(rader[0].fas).toBe("verification");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nyckeltalen är ett produktbeslut", () => {
|
||||
it("översikten innehåller inga hastighetsmått", () => {
|
||||
// Ärenden per tekniker och genomsnittlig ledtid belönar den som
|
||||
// hoppar över kontroller. De ska inte smyga tillbaka.
|
||||
const nycklar = Object.keys(oversikt([AVSLUTAT_MED_ORSAK]));
|
||||
for (const förbjuden of ["arendenPerTekniker", "ledtid", "snittid", "aktivitet", "inloggningar"]) {
|
||||
expect(nycklar, förbjuden).not.toContain(förbjuden);
|
||||
}
|
||||
});
|
||||
|
||||
it("bär sin egen version så en rapport går att härleda i efterhand", () => {
|
||||
expect(oversikt([]).version).toBe("ALVA-REP-0100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sammanfattningen är härledd, inte genererad", () => {
|
||||
it("ger samma text varje gång för samma ärende", () => {
|
||||
const a = sammanfatta(AVSLUTAT_MED_ORSAK);
|
||||
const b = sammanfatta(AVSLUTAT_MED_ORSAK);
|
||||
expect(a.text).toBe(b.text);
|
||||
});
|
||||
|
||||
it("innehåller objekt, kundens ord, reproduktion, orsak och åtgärd", () => {
|
||||
const s = sammanfatta(AVSLUTAT_MED_ORSAK);
|
||||
expect(s.text).toContain("Volvo XC60");
|
||||
expect(s.text).toContain("88 km/h");
|
||||
expect(s.text).toContain("Symptomet reproducerades");
|
||||
expect(s.text).toContain("Obalans 38 g");
|
||||
expect(s.text).toContain("Balanserade hjulen");
|
||||
expect(s.fullstandig).toBe(true);
|
||||
});
|
||||
|
||||
it("säger uttryckligen när något saknas i stället för att utelämna det", () => {
|
||||
const tomt = arende("e1", "X", []);
|
||||
const s = sammanfatta(tomt);
|
||||
expect(s.fullstandig).toBe(false);
|
||||
expect(s.saknas).toContain("felbeskrivning");
|
||||
expect(s.text).toContain("Objektet är inte identifierat");
|
||||
});
|
||||
|
||||
it("skriver inte felet konstaterat när orsaken inte fastställts", () => {
|
||||
const s = sammanfatta(AVSLUTAT_UTAN_ORSAK);
|
||||
expect(s.text).toContain("Orsaken kunde inte fastställas");
|
||||
expect(s.text).toContain("kunde inte reproduceras under de förhållanden");
|
||||
expect(s.text).not.toMatch(/konstaterat fel|felet konstaterat/i);
|
||||
});
|
||||
|
||||
it("enradingen duger för en lista", () => {
|
||||
expect(enrading(AVSLUTAT_MED_ORSAK)).toContain("ABC123");
|
||||
expect(enrading(AVSLUTAT_UTAN_ORSAK)).toContain("utan fastställd orsak");
|
||||
expect(enrading(arende("f1", "X", []))).toContain("pågående");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integrationsgränssnittet", () => {
|
||||
it("täcker de systemkategorier en verkstad faktiskt har", () => {
|
||||
for (const k of ["diagnosprotokoll", "dms", "videooffert", "skadekalkyl", "garanti", "forsakring"]) {
|
||||
expect(Object.keys(KATEGORIER), k).toContain(k);
|
||||
}
|
||||
// Skadekalkyl går åt båda håll: bedömningen in, beviskedjan ut.
|
||||
expect(KATEGORIER.skadekalkyl.riktning).toBe("bada");
|
||||
expect(KATEGORIER.skadekalkyl.tillfor).toContain("slutsats");
|
||||
});
|
||||
|
||||
it("de utgående händelserna är namngivna och beskrivna", () => {
|
||||
expect(Object.keys(UTGAENDE)).toContain("arende.slutsats");
|
||||
for (const [namn, text] of Object.entries(UTGAENDE)) {
|
||||
expect(namn, namn).toMatch(/^[a-z_]+\.[a-z_]+$/);
|
||||
expect(text, namn).toMatch(/\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("signaturen går att verifiera med samma kod hos mottagaren", () => {
|
||||
const kropp = JSON.stringify({ handelse: "arende.avslutat", arende: "ALVA-CASE-00007" });
|
||||
const t = Math.floor(Date.parse("2026-08-05T12:00:00Z") / 1000);
|
||||
const huvud = signeraLeverans(kropp, "hemlighet", t, createHmac);
|
||||
const nu = t * 1000;
|
||||
|
||||
expect(verifieraLeverans(kropp, huvud, "hemlighet", createHmac, timingSafeEqual, nu).ok).toBe(true);
|
||||
expect(verifieraLeverans(kropp, huvud, "fel-hemlighet", createHmac, timingSafeEqual, nu).ok).toBe(false);
|
||||
expect(verifieraLeverans(`${kropp} `, huvud, "hemlighet", createHmac, timingSafeEqual, nu).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("en fångad leverans går inte att spela upp i morgon", () => {
|
||||
const kropp = "{}";
|
||||
const t = Math.floor(Date.parse("2026-08-05T12:00:00Z") / 1000);
|
||||
const huvud = signeraLeverans(kropp, "h", t, createHmac);
|
||||
const imorgon = (t + 86400) * 1000;
|
||||
const svar = verifieraLeverans(kropp, huvud, "h", createHmac, timingSafeEqual, imorgon);
|
||||
expect(svar.ok).toBe(false);
|
||||
expect(svar.orsak).toMatch(/Timestamp/);
|
||||
});
|
||||
|
||||
it("ett diagnosprotokoll blir evidens med bevarad härkomst", () => {
|
||||
const handelser = protokollTillHandelser(
|
||||
{
|
||||
dtcs: [{ code: "P0301", description: "Cylinder 1 misfire" }],
|
||||
liveData: [{ name: "Batterispänning", value: "12,4", unit: "V", toolSerial: "VCDS-99" }],
|
||||
},
|
||||
{
|
||||
felkoder: { vag: "dtcs", kod: "code", text: "description" },
|
||||
matvarden: { vag: "liveData", beskrivning: "name", varde: "value", enhet: "unit", instrumentId: "toolSerial" },
|
||||
},
|
||||
"Diagnosinstrument, plats 3",
|
||||
);
|
||||
|
||||
expect(handelser).toHaveLength(2);
|
||||
expect(handelser[0]).toMatchObject({ typ: "observation", kalla: "Diagnosinstrument, plats 3" });
|
||||
expect(handelser[0].text).toContain("P0301");
|
||||
// Instrumentets identitet följer med — utan den vore värdet E1.
|
||||
expect(handelser[1]).toMatchObject({ typ: "matvarde", varde: "12,4", matdonId: "VCDS-99" });
|
||||
// Härkomsten finns på varje post, så ett inläst värde aldrig ser ut
|
||||
// som något teknikern själv mätt.
|
||||
expect(handelser.every((h) => h.kalla)).toBe(true);
|
||||
});
|
||||
|
||||
it("tomma värden släpps inte igenom som mätningar", () => {
|
||||
const handelser = protokollTillHandelser(
|
||||
{ liveData: [{ name: "Tomt", value: null }] },
|
||||
{ matvarden: { vag: "liveData", beskrivning: "name", varde: "value" } },
|
||||
"x",
|
||||
);
|
||||
expect(handelser).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
// ALVA-REP-0100 · Analysvy.
|
||||
//
|
||||
// En instrumentpanel, inte en rapport. Skillnaden är att en
|
||||
// instrumentpanel svarar på "är något fel" innan den svarar på "hur går
|
||||
// det" — och att ett värde som saknar underlag visas som saknat i
|
||||
// stället för som noll.
|
||||
//
|
||||
// Diagrammen är staplar av ren CSS, ingen graf-bibliotek och ingen
|
||||
// animation. Ett stapeldiagram med tio värden behöver inte 90 kB
|
||||
// JavaScript, och ett värde som rör sig när sidan laddas är svårare att
|
||||
// läsa av än ett som står stilla.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Block, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
|
||||
import { FASER } from "@/alva/system";
|
||||
import { plattformAktiv } from "@/felsokning/plattform";
|
||||
import { useFelsokning } from "@/felsokning/store";
|
||||
import { oversikt } from "../../../../services/gemensam/statistik.mjs";
|
||||
import { Ram } from "./Ram";
|
||||
|
||||
interface Nyckeltal {
|
||||
etikett: string;
|
||||
varde: number | null;
|
||||
enhet?: string;
|
||||
underlag: string;
|
||||
/** Åt vilket håll är bättre? Styr inget färgval — bara läsningen. */
|
||||
riktning: "hogre" | "lagre";
|
||||
tolkning: string;
|
||||
}
|
||||
|
||||
function Tal({ tal }: { tal: Nyckeltal }) {
|
||||
const saknas = tal.varde === null;
|
||||
return (
|
||||
<div className="bg-white p-6">
|
||||
<Etikett>{tal.etikett}</Etikett>
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
{saknas ? (
|
||||
<Statusmärke status="not_applicable" />
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="text-[40px] font-semibold leading-none tabular-nums"
|
||||
style={{ color: FARG.graphite }}
|
||||
>
|
||||
{tal.varde}
|
||||
</span>
|
||||
{tal.enhet && (
|
||||
<span className="text-[16px]" style={{ color: FARG.steel }}>
|
||||
{tal.enhet}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] uppercase tracking-[0.1em]" style={{ color: FARG.steel }}>
|
||||
{tal.riktning === "hogre" ? "↑ bättre" : "↓ bättre"} · {tal.underlag}
|
||||
</div>
|
||||
<p className="mt-4 border-t pt-4 text-[12px] leading-[18px]" style={{ borderColor: FARG.lightSteel, color: FARG.steel }}>
|
||||
{tal.tolkning}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vågrätt stapeldiagram. Etikett, stapel, tal — i den ordningen. */
|
||||
function Staplar({ rader }: { rader: { etikett: string; antal: number; extra?: string }[] }) {
|
||||
const hogsta = Math.max(1, ...rader.map((r) => r.antal));
|
||||
return (
|
||||
<div>
|
||||
{rader.map((r) => (
|
||||
<div key={r.etikett} className="border-t py-2" style={{ borderColor: FARG.lightSteel }}>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<span className="text-[13px]" style={{ color: FARG.graphite }}>
|
||||
{r.etikett}
|
||||
</span>
|
||||
<span className="font-mono text-[12px] tabular-nums" style={{ color: FARG.steel }}>
|
||||
{r.extra ?? r.antal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2" style={{ background: FARG.background }}>
|
||||
<div className="h-2" style={{ width: `${(r.antal / hogsta) * 100}%`, background: FARG.blue }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Analys() {
|
||||
const arenden = useFelsokning((s) => s.arenden);
|
||||
const [fran, setFran] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
// I inloggat läge äger servern siffrorna — samma modul, samma resultat.
|
||||
// Utan inloggning räknas de lokalt så vyn går att visa i demo.
|
||||
useEffect(() => {
|
||||
if (!plattformAktiv()) return;
|
||||
void fetch("/api/statistik/oversikt")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then(setFran)
|
||||
.catch(() => setFran(null));
|
||||
}, []);
|
||||
|
||||
const data = useMemo(
|
||||
() => fran ?? (oversikt(Object.values(arenden)) as Record<string, never>),
|
||||
[fran, arenden],
|
||||
) as ReturnType<typeof oversikt>;
|
||||
|
||||
const tal: Nyckeltal[] = [
|
||||
{
|
||||
etikett: "Verification rate",
|
||||
varde: data.verifiering.andel,
|
||||
enhet: "%",
|
||||
underlag: `${data.verifiering.fastställda} av ${data.verifiering.antal} avslut`,
|
||||
riktning: "hogre",
|
||||
tolkning:
|
||||
"Andel avslut med fastställd orsak. Att den inte är 100 % är friskt — en organisation som aldrig skriver “orsaken kunde inte fastställas” får antingen bara enkla fel, eller skriver inte sanningen.",
|
||||
},
|
||||
{
|
||||
etikett: "Reproduction rate",
|
||||
varde: data.reproduktion.andel,
|
||||
enhet: "%",
|
||||
underlag: `${data.reproduktion.ja} av ${data.reproduktion.antal} symptom`,
|
||||
riktning: "hogre",
|
||||
tolkning:
|
||||
"Andel symptom som återskapats. Låg siffra förutsäger återkommande fordon: man kan inte åtgärda det man inte sett.",
|
||||
},
|
||||
{
|
||||
etikett: "Rework",
|
||||
varde: data.omarbetning.andel,
|
||||
enhet: "%",
|
||||
underlag: `${data.omarbetning.antal} fall inom 90 dagar`,
|
||||
riktning: "lagre",
|
||||
tolkning:
|
||||
"Samma fordon tillbaka med samma orsakskategori. Det dyraste felet i en verkstad, och det enda ingen mäter — det kräver att två ärenden kopplas ihop.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Ram portal>
|
||||
<div className="mx-auto max-w-[1040px] px-6 py-12">
|
||||
<div className="mb-8 flex flex-wrap items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<Etikett>Operational metrics</Etikett>
|
||||
<div className="mt-2">
|
||||
<Rubrik niva={1}>Analysis</Rubrik>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[11px]" style={{ color: FARG.steel }}>
|
||||
ALVA-REP-0100 · {data.antal.avslutade}/{data.antal.totalt} closed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-px md:grid-cols-3" style={{ background: FARG.lightSteel, border: `1px solid ${FARG.lightSteel}` }}>
|
||||
{tal.map((t) => (
|
||||
<Tal key={t.etikett} tal={t} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Block rubrik="Evidence profile" beteckning="ALVA-SPEC-004">
|
||||
<p className="mb-2 text-[12px] leading-[18px]" style={{ color: FARG.steel }}>
|
||||
Starkaste evidenstyp per ärende. Ett mätvärde räknas som E4 endast med spårbart mätdon.
|
||||
</p>
|
||||
<Staplar
|
||||
rader={Object.entries(data.evidens as Record<string, number>)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([niva, antal]) => ({ etikett: niva, antal }))}
|
||||
/>
|
||||
</Block>
|
||||
|
||||
<Block rubrik="Phase distribution" beteckning="ALVA-ES-0001">
|
||||
<p className="mb-2 text-[12px] leading-[18px]" style={{ color: FARG.steel }}>
|
||||
Var arbetet ligger, i ALVA-termer. Övervikt i Localization betyder att fel hittas men inte fastställs.
|
||||
</p>
|
||||
<Staplar
|
||||
rader={FASER.map((f) => ({
|
||||
etikett: f.namn,
|
||||
antal: data.faser[f.id].antal,
|
||||
extra: data.faser[f.id].andel === null ? "—" : `${data.faser[f.id].andel} %`,
|
||||
}))}
|
||||
/>
|
||||
</Block>
|
||||
</div>
|
||||
|
||||
<Block rubrik="Procedure friction" beteckning="ALVA-RULE-120">
|
||||
<p className="mb-4 text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
|
||||
Kontroller som oftast hoppas över, med dokumenterat undantag. Ett steg högt upp i listan är antingen
|
||||
felskrivet eller kräver utrustning verkstaden inte har. Bägge går att åtgärda — men bara om man vet
|
||||
vilket steg det gäller.
|
||||
</p>
|
||||
{data.undantag.length === 0 ? (
|
||||
<Statusmärke status="not_applicable" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Tabell
|
||||
kolumner={["Step", "Check", "Phase", "Exempted", "Rate"]}
|
||||
rader={data.undantag.map((u) => [
|
||||
<span key="s" className="font-mono text-[11px]">
|
||||
{u.steg}
|
||||
</span>,
|
||||
u.text,
|
||||
u.fas,
|
||||
<span key="a" className="tabular-nums">
|
||||
{u.undantag}/{u.utforda}
|
||||
</span>,
|
||||
<span key="p" className="tabular-nums">
|
||||
{u.andel} %
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
<Block rubrik="Fault categories" beteckning="ALVA-DOC-002">
|
||||
{data.orsaker.length === 0 ? (
|
||||
<Statusmärke status="not_applicable" />
|
||||
) : (
|
||||
<Staplar rader={data.orsaker.map((o) => ({ etikett: o.kategori, antal: o.antal }))} />
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{data.omarbetning.fall.length > 0 && (
|
||||
<Block rubrik="Rework detail" beteckning="ALVA-REP-0101">
|
||||
<div className="overflow-x-auto">
|
||||
<Tabell
|
||||
kolumner={["Object", "Shared cause", "Interval"]}
|
||||
rader={data.omarbetning.fall.map((f) => [
|
||||
<span key="o" className="font-mono text-[11px]">
|
||||
{f.identifierare}
|
||||
</span>,
|
||||
f.kategorier.join(" · "),
|
||||
<span key="d" className="tabular-nums">
|
||||
{f.dagar} d
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</Block>
|
||||
)}
|
||||
</div>
|
||||
</Ram>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// ALVA-SPEC-020 · Integrationsvyn.
|
||||
//
|
||||
// Sidan säger uttryckligen vad som är validerat mot en leverantör och
|
||||
// vad som inte är det. Det är ovanligt och det är avsiktligt: en
|
||||
// integrationslista där allt ser färdigt ut är den snabbaste vägen till
|
||||
// ett misslyckat införande, eftersom verkstaden planerar efter den.
|
||||
|
||||
import { Block, Etikett, FARG, Knapp, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
|
||||
import type { Status } from "@/alva/sprak";
|
||||
import { Ram } from "./Ram";
|
||||
|
||||
type Mognad = "draft" | "tested" | "validated";
|
||||
|
||||
const STATUSAV: Record<Mognad, Status> = {
|
||||
draft: "pending",
|
||||
tested: "in_progress",
|
||||
validated: "passed",
|
||||
};
|
||||
|
||||
interface Profil {
|
||||
beteckning: string;
|
||||
system: string;
|
||||
kategori: string;
|
||||
riktning: "In" | "Out" | "Bidirectional";
|
||||
mognad: Mognad;
|
||||
anmarkning: string;
|
||||
}
|
||||
|
||||
// Profilerna beskriver KATEGORIER av system. Namnen nedan är exempel på
|
||||
// vad respektive kategori brukar heta i en svensk verkstad — inte
|
||||
// färdiga klienter. En profil märks `validated` först efter att den
|
||||
// körts mot leverantörens faktiska gränssnitt.
|
||||
const PROFILER: Profil[] = [
|
||||
{
|
||||
beteckning: "ALVA-SRC-020",
|
||||
system: "Generic diagnostic protocol (JSON)",
|
||||
kategori: "Diagnostic protocol",
|
||||
riktning: "In",
|
||||
mognad: "tested",
|
||||
anmarkning: "Profile-driven mapping. Fault codes and live data become evidence with source attribution.",
|
||||
},
|
||||
{
|
||||
beteckning: "ALVA-SRC-021",
|
||||
system: "OEM diagnostic tool export",
|
||||
kategori: "Diagnostic protocol",
|
||||
riktning: "In",
|
||||
mognad: "draft",
|
||||
anmarkning: "Field paths not yet confirmed against a vendor export.",
|
||||
},
|
||||
{
|
||||
beteckning: "ALVA-SRC-030",
|
||||
system: "Dealer management system",
|
||||
kategori: "DMS",
|
||||
riktning: "Bidirectional",
|
||||
mognad: "draft",
|
||||
anmarkning: "Work orders in, closed cases and time records out. Awaiting a vendor test account.",
|
||||
},
|
||||
{
|
||||
beteckning: "ALVA-SRC-040",
|
||||
system: "Video quotation platform",
|
||||
kategori: "Video quotation",
|
||||
riktning: "Bidirectional",
|
||||
mognad: "draft",
|
||||
anmarkning:
|
||||
"Video evidence in, case record and closing statement out. Vendor interface not yet examined — profile is a placeholder, not an implementation.",
|
||||
},
|
||||
{
|
||||
beteckning: "ALVA-SRC-045",
|
||||
system: "Damage calculation (CABAS and equivalents)",
|
||||
kategori: "Damage calculation",
|
||||
riktning: "Bidirectional",
|
||||
mognad: "draft",
|
||||
anmarkning:
|
||||
"Damage assessment in, diagnostic record out. What ALVA adds to an estimate is not more line items but " +
|
||||
"the chain behind them: what was checked, what was ruled out, and why. Vendor interface not yet examined.",
|
||||
},
|
||||
{
|
||||
beteckning: "ALVA-SRC-050",
|
||||
system: "Warranty administration",
|
||||
kategori: "Warranty",
|
||||
riktning: "Out",
|
||||
mognad: "draft",
|
||||
anmarkning: "Traceability package: evidence chain, closing statement, rule-pack version.",
|
||||
},
|
||||
{
|
||||
beteckning: "ALVA-SRC-060",
|
||||
system: "Insurance assessment",
|
||||
kategori: "Insurance",
|
||||
riktning: "Out",
|
||||
mognad: "draft",
|
||||
anmarkning: "Assessor-facing record: what was checked, what was ruled out, and why.",
|
||||
},
|
||||
];
|
||||
|
||||
const HANDELSER = [
|
||||
["arende.skapat", "A case was opened."],
|
||||
["arende.fas", "The case entered a new ALVA phase."],
|
||||
["arende.slutsats", "A closing statement was recorded."],
|
||||
["arende.avslutat", "The case was closed and passed the quality gate."],
|
||||
["media.tillagt", "Photo or video evidence was added."],
|
||||
["atgardsforslag.lamnat", "A repair proposal was issued to the customer."],
|
||||
["kundbeslut.registrerat", "The customer's decision was recorded."],
|
||||
];
|
||||
|
||||
export default function Integration() {
|
||||
return (
|
||||
<Ram portal>
|
||||
<div className="mx-auto max-w-[1040px] px-6 py-12">
|
||||
<Etikett>Integration interface</Etikett>
|
||||
<div className="mt-2 mb-2">
|
||||
<Rubrik niva={1}>Connected systems</Rubrik>
|
||||
</div>
|
||||
<p className="mb-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
|
||||
ALVA connects to the systems the workshop already runs. The interface is profile-driven: a profile
|
||||
describes what a category of system expects, and is marked validated only after it has been run against
|
||||
the vendor’s actual interface.
|
||||
</p>
|
||||
|
||||
<Block rubrik="Profiles" beteckning="ALVA-SPEC-020">
|
||||
<div className="overflow-x-auto">
|
||||
<Tabell
|
||||
kolumner={["Reference", "System", "Category", "Direction", "Maturity", "Note"]}
|
||||
rader={PROFILER.map((p) => [
|
||||
<span key="r" className="font-mono text-[11px]">
|
||||
{p.beteckning}
|
||||
</span>,
|
||||
p.system,
|
||||
p.kategori,
|
||||
p.riktning,
|
||||
<Statusmärke key="m" status={STATUSAV[p.mognad]} />,
|
||||
<span key="a" className="text-[12px]" style={{ color: FARG.steel }}>
|
||||
{p.anmarkning}
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-6 border-t pt-4 text-[12px] leading-[18px]" style={{ borderColor: FARG.lightSteel, color: FARG.steel }}>
|
||||
A profile at <strong>draft</strong> has an interface but has not been run against the vendor. Listing
|
||||
it as ready would be the fastest route to a failed rollout, because the workshop plans around it.
|
||||
</p>
|
||||
</Block>
|
||||
|
||||
<Block rubrik="Outbound events" beteckning="ALVA-SPEC-021">
|
||||
<p className="mb-4 text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
|
||||
An integration subscribes to what it needs. Deliveries are signed with HMAC over timestamp and body;
|
||||
the timestamp is inside the signature so a captured delivery cannot be replayed.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<Tabell
|
||||
kolumner={["Event", "Meaning"]}
|
||||
rader={HANDELSER.map(([id, text]) => [
|
||||
<span key="e" className="font-mono text-[11px]">
|
||||
{id}
|
||||
</span>,
|
||||
text,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block rubrik="Inbound: diagnostic protocol" beteckning="ALVA-PROC-0020">
|
||||
<p className="mb-4 text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
|
||||
Readings become <strong>evidence</strong>, not an attachment. Provenance travels with every entry, so
|
||||
a value that arrived from outside never looks like something the technician measured. Where the
|
||||
instrument identity is supplied it is retained — without it the value is graded E1 rather than E4.
|
||||
</p>
|
||||
<pre
|
||||
className="overflow-x-auto border p-4 font-mono text-[12px] leading-[20px]"
|
||||
style={{ borderColor: FARG.lightSteel, background: FARG.background, color: FARG.graphite }}
|
||||
>
|
||||
{`POST /api/arenden/{arendeId}/protokoll
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"kalla": "Diagnostic tool, bay 3",
|
||||
"profil": {
|
||||
"felkoder": { "vag": "dtcs", "kod": "code", "text": "description" },
|
||||
"matvarden": { "vag": "liveData", "beskrivning": "name",
|
||||
"varde": "value", "enhet": "unit",
|
||||
"instrumentId": "toolSerial" }
|
||||
},
|
||||
"protokoll": { "...": "vendor payload, unchanged" }
|
||||
}`}
|
||||
</pre>
|
||||
<div className="mt-6 flex flex-wrap gap-4">
|
||||
<Knapp>Add profile</Knapp>
|
||||
<Knapp variant="sekundar">Download OpenAPI</Knapp>
|
||||
</div>
|
||||
</Block>
|
||||
</div>
|
||||
</Ram>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,9 @@ const PUBLIKT = [
|
||||
|
||||
const PORTAL = [
|
||||
{ till: "/alva/portal", text: "Dashboard" },
|
||||
{ till: "/alva/portal/analys", text: "Analysis" },
|
||||
{ till: "/alva/portal/kunskapskallor", text: "Knowledge sources" },
|
||||
{ till: "/alva/portal/dokument", text: "Document library" },
|
||||
{ till: "/alva/portal/integration", text: "Integration" },
|
||||
{ till: "/felsokning", text: "Diagnostics" },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user