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:
Claude
2026-08-05 15:51:30 +00:00
parent 01189ae42a
commit 3ec25dea9c
13 changed files with 1446 additions and 1 deletions
+4
View File
@@ -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([]);
});
});
+244
View File
@@ -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&rsquo;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>
);
}
+2 -1
View File
@@ -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" },
];
+16
View File
@@ -232,3 +232,19 @@ create table if not exists matdon (
alter table felsokning_arenden add column if not exists identifierare_index text;
create index if not exists felsokning_arenden_ident_idx
on felsokning_arenden (organisation_id, identifierare_index);
-- Utgående integrationer. En prenumeration per mottagare och
-- händelsetyp — ett videooffertsystem bryr sig inte om varje mätvärde.
create table if not exists prenumerationer (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
namn text not null,
url text not null,
hemlighet_krypt text not null,
handelser text[] not null default '{}',
aktiv boolean not null default true,
skapad timestamptz not null default now(),
senast_levererad timestamptz,
senaste_status text
);
create index if not exists prenumerationer_org on prenumerationer (organisation_id) where aktiv;
@@ -0,0 +1,180 @@
// ALVA-SPEC-020 · Integration interface.
//
// Verkstaden har redan system: ett DMS, ett diagnosinstrument som
// producerar protokoll, ett videooffertsystem. ALVA ska koppla in sig i
// dem — inte ersätta dem, och inte kräva att de anpassar sig.
//
// ---- Vad jag INTE bygger ---------------------------------------------
//
// Färdiga klienter mot namngivna leverantörer. Jag känner inte Beonodes
// eller ServiceCams faktiska API:er, och 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, och då är felet dyrare att hitta.
//
// I stället: ett stabilt gränssnitt med PROFILER. En profil beskriver
// vad en kategori av system förväntar sig — fältnamn, format, riktning —
// och valideras mot leverantören innan den märks som `validated`. Tills
// dess står den som `draft`, och det syns i gränssnittet.
//
// Det är samma princip som Knowledge Sources: leverantören är data.
//
// ---- Riktningar --------------------------------------------------------
//
// IN Diagnosprotokoll, arbetsorder, fordonsdata. Blir händelser i
// loggen med källa och tidsstämpel bevarade.
// UT Ärendet, rapporten, slutsatsen, mediet. Levereras som webhook
// eller hämtas via API.
//
// Riktningen är inte symmetrisk. Inkommande data blir evidens och måste
// därför bära sin härkomst hela vägen; utgående data är en projektion
// och kan formas fritt.
/** Kategorier av system. Namnen är generiska med avsikt. */
export const KATEGORIER = {
diagnosprotokoll: {
namn: "Diagnostic protocol",
riktning: "in",
beskrivning: "Fault codes, live data and readouts from a diagnostic instrument.",
// Vad ALVA gör av det: varje avläsning blir evidens, inte en bilaga.
blir: ["matvarde", "observation", "foto"],
},
dms: {
namn: "Dealer management system",
riktning: "bada",
beskrivning: "Work orders in, completed cases and time records out.",
blir: ["arbetsorder_skannad", "objekt_identifierat"],
},
videooffert: {
namn: "Video quotation",
riktning: "bada",
beskrivning: "Video evidence in, the case record and closing statement out.",
blir: ["video", "foto"],
},
fordonsdata: {
namn: "Vehicle data",
riktning: "in",
beskrivning: "Identification, equipment level, service campaigns.",
blir: ["objekt_identifierat"],
},
garanti: {
namn: "Warranty administration",
riktning: "ut",
beskrivning: "Claim substantiation: evidence chain, closing statement, traceability package.",
blir: [],
},
forsakring: {
namn: "Insurance assessment",
riktning: "ut",
beskrivning: "Assessor-facing record: what was checked, what was ruled out, and why.",
blir: [],
},
skadekalkyl: {
namn: "Damage calculation",
riktning: "bada",
beskrivning:
"Damage estimation systems used between workshops and insurers — CABAS is the Nordic standard. " +
"In: damage assessment and vehicle identification. Out: the diagnostic record substantiating that " +
"the damage was investigated rather than assumed.",
blir: ["objekt_identifierat", "observation", "foto"],
// Det ALVA tillför en skadekalkyl är inte fler poster utan
// beviskedjan bakom dem: vad som kontrollerades, vad som uteslöts
// och varför. Det är den enda del av kalkylen som i dag inte går
// att granska i efterhand.
tillfor: ["slutsats", "felorsak", "reproducering"],
},
};
/**
* Profilens mognad. Samma resonemang som konnektorernas livscykel:
* "det finns kod" är inte samma sak som "det fungerar mot leverantören".
*/
export const MOGNAD = ["draft", "tested", "validated"];
/**
* Utgående händelser. En integration prenumererar på det den behöver —
* ett videooffertsystem bryr sig inte om varje mätvärde.
*/
export const UTGAENDE = {
"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.",
};
/**
* Signerar en utgående leverans.
*
* HMAC över tidsstämpel och kropp, i det format nästan alla
* webhook-mottagare redan förstår. Tidsstämpeln ingår i signaturen så
* att en avlyssnad leverans inte går att spela upp igen.
*/
export function signeraLeverans(kropp, hemlighet, tidsstampel, hmac) {
const underlag = `${tidsstampel}.${kropp}`;
return `t=${tidsstampel},v1=${hmac("sha256", hemlighet).update(underlag).digest("hex")}`;
}
/**
* Verifierar en inkommande leverans från oss.
*
* Exponerad så att mottagaren kan använda exakt samma kod som avsändaren
* — de flesta integrationsfel uppstår i glappet mellan två
* implementationer av samma signatur.
*/
export function verifieraLeverans(kropp, huvud, hemlighet, hmac, timingSafeEqual, nu = Date.now()) {
const delar = Object.fromEntries(
String(huvud ?? "")
.split(",")
.map((d) => d.split("=")),
);
if (!delar.t || !delar.v1) return { ok: false, orsak: "Malformed signature header." };
// Fem minuter. Nog för klockglapp, kort nog att en fångad leverans
// inte går att spela upp i morgon.
if (Math.abs(nu - Number(delar.t) * 1000) > 300_000) return { ok: false, orsak: "Timestamp outside tolerance." };
const väntad = hmac("sha256", hemlighet).update(`${delar.t}.${kropp}`).digest("hex");
const a = Buffer.from(delar.v1);
const b = Buffer.from(väntad);
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, orsak: "Signature mismatch." };
return { ok: true };
}
/**
* Normaliserar ett inkommande diagnosprotokoll till händelser.
*
* Profilen beskriver var värdena ligger i leverantörens format; den här
* funktionen gör dem till evidens. Härkomsten bevaras i varje händelse —
* ett värde som kommit utifrån får aldrig se ut som något teknikern
* själv mätt.
*/
export function protokollTillHandelser(protokoll, profil, kalla) {
const ut = [];
const plocka = (objekt, vag) => String(vag ?? "").split(".").reduce((o, n) => o?.[n], objekt);
for (const kod of plocka(protokoll, profil?.felkoder?.vag) ?? []) {
ut.push({
typ: "observation",
text: `${plocka(kod, profil.felkoder.kod) ?? "?"}${plocka(kod, profil.felkoder.text) ?? "utan beskrivning"}`,
kalla,
});
}
for (const värde of plocka(protokoll, profil?.matvarden?.vag) ?? []) {
ut.push({
typ: "matvarde",
beskrivning: String(plocka(värde, profil.matvarden.beskrivning) ?? "Avläst värde"),
varde: String(plocka(värde, profil.matvarden.varde) ?? ""),
enhet: plocka(värde, profil.matvarden.enhet) ?? undefined,
// Instrumentets identitet följer med när leverantören lämnar den.
// Utan den nedgraderas värdet till E1 — ett avläst tal utan känt
// instrument är inte en spårbar mätning (QUALITY-AUDIT M-1).
matdonId: plocka(värde, profil.matvarden.instrumentId) ?? undefined,
matdonBeteckning: plocka(värde, profil.matvarden.instrument) ?? undefined,
kalla,
});
}
return ut.filter((h) => h.typ !== "matvarde" || h.varde !== "");
}
@@ -0,0 +1,150 @@
// ALVA-PROC-0030 · Case summary.
//
// Några få meningar som ger vem som helst en bild av ärendet: kunden i
// telefon, försäkringshandläggaren som öppnar en akt, teknikern som tar
// över efter helgen.
//
// ---- Varför den är härledd och inte genererad -------------------------
//
// Frestelsen är att låta modellen skriva den. Det vore fel här, och
// skälet är inte försiktighet utan funktion:
//
// Den ska gå att lita på. En sammanfattning som en bedömare läser
// blir en del av beslutsunderlaget. Är den
// genererad måste den granskas mot loggen
// varje gång — och då är den inte längre en
// genväg.
//
// Den ska vara stabil. Samma ärende måste ge samma text i dag och
// om två år. En sammanfattning som ändrar sig
// mellan visningar är oanvändbar i en tvist.
//
// Den ska fungera offline. Verkstadsgolvet har dålig täckning.
//
// Sammanfattningen är därför en projektion, precis som briefen och
// rapporten. Den innehåller inget som inte står i loggen, och den säger
// uttryckligen när något saknas i stället för att utelämna det.
//
// Modellen har fortfarande en roll — men i andra änden: den kan föreslå
// en språkligt jämnare formulering som teknikern GODKÄNNER, och då blir
// godkännandet en händelse i loggen. Det som visas här är alltid det
// härledda.
const handelserI = (arende) => (arende.handelser ?? []).map((p) => p.handelse ?? p);
const av = (handelser, typ) => handelser.filter((h) => h?.typ === typ);
const sista = (handelser, typ) => av(handelser, typ).at(-1);
const REPRODUKTION = {
ja: "Symptomet reproducerades.",
delvis: "Symptomet kunde delvis reproduceras.",
nej: "Symptomet kunde inte reproduceras under de förhållanden som rådde vid undersökningen.",
};
const KVALITET = {
symptomet_borta: "Vid kvalitetskontrollen var symptomet borta.",
kvarstar: "Vid kvalitetskontrollen kvarstod symptomet.",
delvis: "Vid kvalitetskontrollen kvarstod symptomet delvis.",
ej_verifierbar: "Kvalitetskontrollen kunde inte verifiera resultatet.",
};
/** Klipper en mening vid en rimlig gräns utan att kapa mitt i ett ord. */
function korta(text, max = 220) {
const = String(text ?? "").trim().replace(/\s+/g, " ");
if (.length <= max) return ;
const brytpunkt = .lastIndexOf(" ", max);
return `${.slice(0, brytpunkt > max * 0.6 ? brytpunkt : max)}`;
}
/**
* Bygger sammanfattningen.
*
* Ordningen är den en läsare faktiskt behöver: vad gällde det, vad sade
* kunden, kunde vi se det, vad blev orsaken, vad gjorde vi, höll det,
* vad är kvar. Varje mening utelämnas när underlaget saknas — utom de
* som handlar om att något saknas, för de är själva poängen.
*
* @returns { meningar[], text, fullstandig, saknas[] }
*/
export function sammanfatta(arende) {
const h = handelserI(arende);
const meningar = [];
const saknas = [];
// 1 · Objektet.
const objekt = sista(h, "objekt_identifierat")?.objekt;
if (objekt) {
const delar = [objekt.beskrivning, objekt.identifierare].filter(Boolean).join(", ");
meningar.push(`Ärendet gäller ${delar || "ett identifierat objekt"}.`);
} else {
saknas.push("objektidentifiering");
meningar.push("Objektet är inte identifierat i loggen.");
}
// 2 · Kundens ord, ordagrant men kortat.
const fel = sista(h, "felbeskrivning")?.text;
if (fel) meningar.push(`Kunden beskrev: ”${korta(fel, 160)}`);
else saknas.push("felbeskrivning");
// 3 · Kunde vi se det? Detta är den mening en bedömare läser först.
const repro = sista(h, "reproducering");
if (repro) meningar.push(REPRODUKTION[repro.status] ?? "Reproduktionen är dokumenterad.");
else saknas.push("symptomverifiering");
// 4 · Orsaken, med teknikerns eget varför när det finns.
const orsak = sista(h, "felorsak");
const slutsats = sista(h, "slutsats");
if (slutsats && slutsats.orsakFastställd === false) {
meningar.push(`Orsaken kunde inte fastställas: ${korta(slutsats.motivering, 200)}`);
} else if (orsak) {
const kategori = (orsak.orsaker ?? []).join(", ");
meningar.push(`Konstaterad avvikelse: ${korta(orsak.avvikelse, 180)}${kategori ? ` Bedömd orsak: ${kategori}.` : ""}`);
if (slutsats?.motivering) meningar.push(`Motivering: ${korta(slutsats.motivering, 220)}`);
} else {
saknas.push("felorsaksanalys");
}
// 5 · Vad som gjordes.
const atgard = sista(h, "atgard_utford");
if (atgard) {
meningar.push(
atgard.utford
? `Utförd åtgärd: ${korta(atgard.beskrivning, 160)}`
: `Ingen åtgärd utfördes: ${korta(atgard.motivering || atgard.beskrivning, 160)}`,
);
} else saknas.push("åtgärd");
// 6 · Höll det?
const kvalitet = sista(h, "kvalitetskontroll");
if (kvalitet) meningar.push(KVALITET[kvalitet.resultat] ?? "Kvalitetskontrollen är dokumenterad.");
// 7 · Vad som är kvar. Utelämnas aldrig när den finns — det är den
// enda meningen som talar om vad läsaren själv behöver göra.
if (slutsats?.kvarstaende) meningar.push(`Kvarstående: ${korta(slutsats.kvarstaende, 160)}`);
return {
version: "ALVA-PROC-0030",
meningar,
text: meningar.join(" "),
/** Sant när inget obligatoriskt underlag saknas. */
fullstandig: saknas.length === 0,
saknas,
};
}
/**
* Kortformen: en enda mening för en lista eller ett API-svar där
* utrymmet är en rad.
*/
export function enrading(arende) {
const h = handelserI(arende);
const objekt = sista(h, "objekt_identifierat")?.objekt;
const slutsats = sista(h, "slutsats");
const orsak = sista(h, "felorsak");
const avslutat = h.some((x) => x?.typ === "arende_avslutat");
const vad = objekt?.identifierare ?? "Oidentifierat objekt";
if (!avslutat) return `${vad} — pågående.`;
if (slutsats?.orsakFastställd === false) return `${vad} — avslutat utan fastställd orsak.`;
if (orsak) return `${vad}${korta(orsak.avvikelse, 90)}`;
return `${vad} — avslutat.`;
}
+208
View File
@@ -0,0 +1,208 @@
// ALVA-REP-0100 · Operational metrics.
//
// Vilka siffror som visas avgör vad organisationen optimerar. Det är
// därför det här är ett produktbeslut och inte ett rapportjobb.
//
// Uteslutna med avsikt:
//
// Ärenden per tekniker Belönar snabbhet över noggrannhet. Den som
// hoppar över kontroller vinner.
// Genomsnittlig ledtid Samma sak, en gång till. Ett svårt fel
// SKA ta längre tid.
// Inloggningar, aktivitet Mäter användning, inte nytta.
//
// Medtagna, och varför var och en är handlingsbar:
//
// VERIFIERINGSGRAD Andel avslut med fastställd orsak. Sjunker den
// gissar organisationen mer. Det här är det enda
// måttet ett försäkringsbolag egentligen bryr sig
// om.
// REPRODUKTIONSGRAD Andel symptom som återskapats. Låg siffra
// förutsäger återkommande fordon — man kan inte
// åtgärda det man inte sett.
// OMARBETNING Samma fordon tillbaka med samma orsakskategori.
// Det dyraste felet i en verkstad, och det enda
// ingen mäter.
// UNDANTAGSFREKVENS Vilka kontroller hoppas över, per steg. Ett
// steg med hög frekvens är antingen fel skrivet
// eller kräver utrustning som saknas — bägge är
// åtgärdbara, ingetdera syns i dag.
// EVIDENSPROFIL Fördelning E0E6. Visar om underlaget vilar på
// mätningar eller på observationer.
// FASFÖRDELNING Var tiden går i ALVA-termer.
//
// Alla funktioner är rena över (ärenden). Ingen databasåtkomst, ingen
// klocka — samma underlag ger samma siffra, vilket är förutsättningen
// för att en kvartalsrapport ska gå att granska i efterhand.
import { fasFor } from "./faser.mjs";
const handelserI = (arende) => (arende.handelser ?? []).map((p) => p.handelse ?? p);
const av = (handelser, typ) => handelser.filter((h) => h?.typ === typ);
const arAvslutat = (arende) => handelserI(arende).some((h) => h?.typ === "arende_avslutat");
/** Andel med en decimal, eller null när underlaget saknas. */
function andel(traffar, av_) {
return av_ === 0 ? null : Math.round((traffar / av_) * 1000) / 10;
}
/**
* Verifieringsgrad — andel avslutade ärenden där orsaken fastställdes.
*
* Att den inte är 100 % är friskt. En organisation som aldrig skriver
* "orsaken kunde inte fastställas" ljuger antingen, eller får bara enkla
* fel. Talet är intressant över tid, inte som absolut nivå.
*/
export function verifieringsgrad(arenden) {
const avslutade = arenden.filter(arAvslutat);
const fastställda = avslutade.filter((a) => {
const s = av(handelserI(a), "slutsats").at(-1);
return s && s.orsakFastställd !== false;
});
return { antal: avslutade.length, fastställda: fastställda.length, andel: andel(fastställda.length, avslutade.length) };
}
/** Reproduktionsgrad — andel ärenden där symptomet faktiskt återskapades. */
export function reproduktionsgrad(arenden) {
const medSvar = arenden.filter((a) => av(handelserI(a), "reproducering").length > 0);
const fördelning = { ja: 0, delvis: 0, nej: 0 };
for (const a of medSvar) {
const r = av(handelserI(a), "reproducering").at(-1);
if (r?.status in fördelning) fördelning[r.status] += 1;
}
return { antal: medSvar.length, ...fördelning, andel: andel(fördelning.ja, medSvar.length) };
}
/**
* Omarbetning — samma fordon tillbaka med samma orsakskategori.
*
* Det dyraste felet i en verkstad och det enda ingen mäter, eftersom det
* kräver att man kopplar ihop två ärenden som ser fristående ut. Här är
* kopplingen gratis: loggen bär fordonets identifierare och orsakens
* kategori.
*
* @param inomDagar fönster; 90 dagar är en rimlig utgångspunkt men
* varje verkstad har sin egen uppfattning.
*/
export function omarbetning(arenden, inomDagar = 90) {
const perFordon = new Map();
for (const a of arenden) {
if (!arAvslutat(a)) continue;
const ident = av(handelserI(a), "objekt_identifierat").at(-1)?.objekt?.identifierare;
if (!ident) continue;
const orsak = av(handelserI(a), "felorsak").at(-1);
const avslut = (a.handelser ?? []).find((p) => (p.handelse ?? p)?.typ === "arende_avslutat")?.tidpunkt;
if (!orsak || !avslut) continue;
if (!perFordon.has(ident)) perFordon.set(ident, []);
perFordon.get(ident).push({ tid: Date.parse(avslut), kategorier: orsak.orsaker ?? [], arende: a.id });
}
const fall = [];
const fönster = inomDagar * 86400000;
for (const [ident, poster] of perFordon) {
poster.sort((a, b) => a.tid - b.tid);
for (let i = 1; i < poster.length; i += 1) {
const föregående = poster[i - 1];
const nu = poster[i];
const delad = nu.kategorier.filter((k) => föregående.kategorier.includes(k));
if (delad.length > 0 && nu.tid - föregående.tid <= fönster) {
fall.push({ identifierare: ident, kategorier: delad, dagar: Math.round((nu.tid - föregående.tid) / 86400000) });
}
}
}
const avslutade = arenden.filter(arAvslutat).length;
return { antal: fall.length, andel: andel(fall.length, avslutade), fall: fall.slice(0, 20) };
}
/**
* Undantagsfrekvens per metodiksteg.
*
* Ett steg som ofta hoppas över ä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, vilket ingen gör i dag.
*/
export function undantagsfrekvens(arenden) {
const per = new Map();
for (const a of arenden) {
for (const h of av(handelserI(a), "kontroll_utford")) {
const nyckel = `${h.stegId}/${h.kontrollId}`;
if (!per.has(nyckel)) per.set(nyckel, { steg: h.stegId, kontroll: h.kontrollId, text: h.text, utforda: 0, undantag: 0 });
const rad = per.get(nyckel);
rad.utforda += 1;
if ((h.undantag ?? "").trim()) rad.undantag += 1;
}
}
return [...per.values()]
.map((r) => ({ ...r, fas: fasFor(r.steg), andel: andel(r.undantag, r.utforda) }))
.filter((r) => r.undantag > 0)
.sort((a, b) => b.andel - a.andel || b.undantag - a.undantag);
}
/** Evidensprofil — fördelning av starkaste evidenstyp per ärende. */
export function evidensprofil(arenden) {
const nivå = { E0: 0, E1: 0, E2: 0, E3: 0, E4: 0, E5: 0, E6: 0 };
for (const a of arenden) {
const h = handelserI(a);
const har = {
E5: av(h, "arbetsorder_skannad").length > 0,
// Ett mätvärde räknas som E4 bara med spårbart mätdon; utan det är
// det teknikerns observation av en siffra (QUALITY-AUDIT M-1).
E4: av(h, "matvarde").some((m) => m.matdonId),
E3: av(h, "video").length > 0,
E2: av(h, "foto").length > 0,
E1: av(h, "observation").length + av(h, "kontroll_utford").length > 0,
};
const källor = ["E2", "E3", "E4", "E5"].filter((n) => har[n]).length;
const högsta = källor >= 2 ? "E6" : (["E5", "E4", "E3", "E2", "E1"].find((n) => har[n]) ?? "E0");
nivå[högsta] += 1;
}
return nivå;
}
/** Fasfördelning — var tiden går, i ALVA-termer. */
export function fasfordelning(arenden) {
const per = { analysis: 0, localization: 0, verification: 0, action: 0 };
for (const a of arenden) {
for (const h of handelserI(a)) {
if (h?.stegId) per[fasFor(h.stegId)] += 1;
}
}
const summa = Object.values(per).reduce((s, v) => s + v, 0);
return Object.fromEntries(
Object.entries(per).map(([fas, antal]) => [fas, { antal, andel: andel(antal, summa) }]),
);
}
/** Orsakskategorier över flottan. */
export function orsakskategorier(arenden) {
const per = new Map();
for (const a of arenden) {
for (const h of av(handelserI(a), "felorsak")) {
for (const k of h.orsaker ?? []) per.set(k, (per.get(k) ?? 0) + 1);
}
}
return [...per.entries()]
.map(([kategori, antal]) => ({ kategori, antal }))
.sort((a, b) => b.antal - a.antal);
}
/**
* Hela underlaget för portalens analysvy och kvartalsrapporten.
*
* En enda funktion, så att skärmen och rapporten aldrig kan visa olika
* siffror för samma period — det är den vanligaste orsaken till att
* ingen litar på rapporten.
*/
export function oversikt(arenden, { omarbetningsfonster = 90 } = {}) {
return {
version: "ALVA-REP-0100",
antal: { totalt: arenden.length, avslutade: arenden.filter(arAvslutat).length },
verifiering: verifieringsgrad(arenden),
reproduktion: reproduktionsgrad(arenden),
omarbetning: omarbetning(arenden, omarbetningsfonster),
undantag: undantagsfrekvens(arenden).slice(0, 10),
evidens: evidensprofil(arenden),
faser: fasfordelning(arenden),
orsaker: orsakskategorier(arenden).slice(0, 12),
};
}
+1
View File
@@ -0,0 +1 @@
../gemensam/integration.mjs
+1
View File
@@ -0,0 +1 @@
../gemensam/sammanfattning.mjs
+188
View File
@@ -30,6 +30,9 @@ import { avsluta, logga, mätvärde, spårFrån, starta, traceparent } from "./o
import { tillPost } from "./handelser.mjs";
import { grinda, grindaArendetyp } from "./grind.mjs";
import { ALLA_METODIKER } from "./metodiker.mjs";
import { oversikt as statistikOversikt } from "./statistik.mjs";
import { KATEGORIER, UTGAENDE, protokollTillHandelser, signeraLeverans } from "./integration.mjs";
import { enrading, sammanfatta } from "./sammanfattning.mjs";
import {
MASKERAT,
gallringsdatum,
@@ -453,6 +456,50 @@ function loggaAtkomst(req, res, { org, anvandare, arende, delningskod }) {
.catch((fel) => logga("fel", "åtkomstlogg misslyckades", { spårId: res.spår.spårId, orsak: fel?.message }));
}
/**
* Levererar en utgående händelse till organisationens prenumeranter.
*
* Leveransen är signerad och sker i bakgrunden: en mottagare som är nere
* får inte hindra teknikern från att arbeta. Utfallet skrivs på
* prenumerationen, så ett trasigt mottagarsystem syns i inställningarna
* i stället för att tyst sluta få data (ALVA-SPEC-021).
*/
function leverera(orgId, handelse, nyttolast) {
pool
.query(
`select id, url, hemlighet_krypt from prenumerationer
where organisation_id = $1 and aktiv and $2 = any(handelser)`,
[orgId, handelse],
)
.then(async ({ rows }) => {
for (const p of rows) {
const kropp = JSON.stringify({ handelse, tid: new Date().toISOString(), ...nyttolast });
const t = Math.floor(Date.now() / 1000);
let status = "ok";
try {
const svar = await fetch(p.url, {
method: "POST",
headers: {
"content-type": "application/json",
"alva-signatur": signeraLeverans(kropp, dekryptera(p.hemlighet_krypt), t, createHmac),
"alva-handelse": handelse,
},
body: kropp,
signal: AbortSignal.timeout(10_000),
});
if (!svar.ok) status = `HTTP ${svar.status}`;
} catch (fel) {
status = fel?.message?.slice(0, 200) ?? "okänt fel";
}
await pool.query(
`update prenumerationer set senast_levererad = now(), senaste_status = $2 where id = $1`,
[p.id, status],
);
}
})
.catch((fel) => logga("fel", "leverans misslyckades", { handelse, orsak: fel?.message }));
}
function kravAuth(req, hemlighet) {
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
@@ -1308,6 +1355,133 @@ export function skapaServer() {
}
// ---- ALVA-REP-0100 · Analysunderlag ------------------------------
//
// En enda källa för både portalens analysvy och kvartalsrapporten,
// så att skärmen och rapporten aldrig visar olika siffror för samma
// period. Det är den vanligaste orsaken till att ingen litar på en
// rapport.
if (req.method === "GET" && vag.startsWith("/api/statistik/oversikt")) {
if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." });
const rader = await pool.query(
`select a.id, a.nummer,
coalesce(json_agg(json_build_object('tidpunkt', h.tidpunkt, 'handelse', h.handelse)
order by h.tidpunkt) filter (where h.id is not null), '[]') as handelser
from felsokning_arenden a
left join felsokning_handelser h on h.arende_id = a.id
where a.organisation_id = $1
group by a.id, a.nummer`,
[anspr.org],
);
const nycklar = await nycklarFor(anspr.org);
const arenden = rader.rows.map((r) => ({
...r,
handelser: r.handelser.map((p) => ({ ...p, handelse: öppnaHändelse(p.handelse, nycklar) })),
}));
return svara(res, 200, statistikOversikt(arenden));
}
// ---- ALVA-SPEC-021 · Prenumerationer -----------------------------
if (vag === "/api/integration/prenumerationer") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
if (req.method === "GET") {
const rader = await pool.query(
`select id, namn, url, handelser, aktiv, senast_levererad, senaste_status
from prenumerationer where organisation_id = $1 order by namn`,
[anspr.org],
);
return svara(res, 200, { prenumerationer: rader.rows });
}
if (req.method === "POST") {
const { namn, url, handelser, hemlighet } = await lasKropp(req);
if (typeof namn !== "string" || !namn.trim() || typeof url !== "string") {
return svara(res, 400, { error: "namn och url krävs." });
}
// Samma SSRF-gräns som leverantörsuppslagen: en prenumeration
// får inte peka in i klustret.
if (await pekarInat(url)) {
return svara(res, 400, { error: "Adressen pekar mot ett internt nät." });
}
if (!Array.isArray(handelser) || handelser.some((h) => !(h in UTGAENDE))) {
return svara(res, 400, { error: "handelser måste vara kända händelsetyper.", kanda: Object.keys(UTGAENDE) });
}
const rad = await pool.query(
`insert into prenumerationer (organisation_id, namn, url, hemlighet_krypt, handelser)
values ($1, $2, $3, $4, $5) returning id`,
[anspr.org, namn.trim(), url, kryptera(String(hemlighet ?? nyKod(32))), handelser],
);
return svara(res, 200, { id: rad.rows[0].id });
}
}
// ---- ALVA-PROC-0030 · Sammanfattning -----------------------------
//
// Härledd, inte genererad. En sammanfattning som en bedömare läser
// blir en del av beslutsunderlaget — är den genererad måste den
// granskas mot loggen varje gång, och då är den ingen genväg.
const sammanfattningVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/sammanfattning$/);
if (req.method === "GET" && sammanfattningVag) {
if (!(await arendeIOrg(sammanfattningVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
const rader = await pool.query(
`select tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[sammanfattningVag[1]],
);
const nycklar = await nycklarFor(anspr.org);
const arende = {
id: sammanfattningVag[1],
handelser: rader.rows.map((r) => ({ ...r, handelse: öppnaHändelse(r.handelse, nycklar) })),
};
loggaAtkomst(req, res, { org: anspr.org, anvandare: anspr.sub, arende: sammanfattningVag[1] });
return svara(res, 200, { ...sammanfatta(arende), enrading: enrading(arende) });
}
// ---- ALVA-SPEC-020 · Integrationsgränssnitt ----------------------
if (req.method === "GET" && vag === "/api/integration/kategorier") {
return svara(res, 200, { kategorier: KATEGORIER, handelser: UTGAENDE });
}
// Inkommande diagnosprotokoll. Blir evidens, inte en bilaga — men
// härkomsten följer med i varje händelse, så ett värde som kommit
// utifrån aldrig ser ut som något teknikern själv mätt.
const protokollVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/protokoll$/);
if (req.method === "POST" && protokollVag) {
if (!(await arendeIOrg(protokollVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
const { protokoll, profil, kalla } = await lasKropp(req);
if (!protokoll || !profil || typeof kalla !== "string" || !kalla.trim()) {
return svara(res, 400, { error: "protokoll, profil och kalla krävs." });
}
const handelser = protokollTillHandelser(protokoll, profil, kalla.trim().slice(0, 120));
if (handelser.length === 0) {
return svara(res, 422, {
error: "Profilen gav inga händelser ur protokollet.",
atgard: "Kontrollera att profilens sökvägar matchar leverantörens format.",
});
}
const nyckel = await personnyckel(anspr.org, protokollVag[1]);
let skrivna = 0;
for (const [i, h] of handelser.entries()) {
const { post, fel } = tillPost(
{ id: `prot-${Date.now()}-${i}`, handelse: h },
anspr,
);
if (fel) continue;
post.handelse = skyddaHändelse(post.handelse, nyckel.id, nyckel.nyckel);
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
[post.id, protokollVag[1], post.tidpunkt, post.anvandare, post.handelse],
);
skrivna += 1;
}
return svara(res, 200, { handelser: skrivna, kalla });
}
// ---- Radering (dataskyddsförordningen art. 17) ------------------
//
// Krypto-shredding: nyckeln förstörs, loggen står kvar. Vad som
@@ -1529,6 +1703,20 @@ export function skapaServer() {
);
if (skrivet.rowCount === 0) res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
}
// Utgående integrationer underrättas efter att loggen skrivits,
// aldrig före: en mottagare ska aldrig kunna se en händelse som
// inte finns i loggen.
for (const p of attSkriva) {
if (p.handelse.typ === "arende_avslutat") {
leverera(anspr.org, "arende.avslutat", { arende: handelserVag[1] });
}
if (p.handelse.typ === "slutsats") {
leverera(anspr.org, "arende.slutsats", { arende: handelserVag[1] });
}
if (p.handelse.typ === "foto" || p.handelse.typ === "video") {
leverera(anspr.org, "media.tillagt", { arende: handelserVag[1], typ: p.handelse.typ });
}
}
return svara(res, 200, { ok: true });
}
}
+1
View File
@@ -0,0 +1 @@
../gemensam/statistik.mjs