diff --git a/felsokning/app/src/App.tsx b/felsokning/app/src/App.tsx index 1250573..a8304ed 100644 --- a/felsokning/app/src/App.tsx +++ b/felsokning/app/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/felsokning/app/src/felsokning/__tests__/statistik-integration.test.ts b/felsokning/app/src/felsokning/__tests__/statistik-integration.test.ts new file mode 100644 index 0000000..41cdea7 --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/statistik-integration.test.ts @@ -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) => ({ tidpunkt, anvandare: "Anna", handelse }); + +function arende(id: string, ident: string, handelser: [string, Record][]) { + 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([]); + }); +}); diff --git a/felsokning/app/src/pages/alva/Analys.tsx b/felsokning/app/src/pages/alva/Analys.tsx new file mode 100644 index 0000000..e55ce39 --- /dev/null +++ b/felsokning/app/src/pages/alva/Analys.tsx @@ -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 ( +
+ {tal.etikett} +
+ {saknas ? ( + + ) : ( + <> + + {tal.varde} + + {tal.enhet && ( + + {tal.enhet} + + )} + + )} +
+
+ {tal.riktning === "hogre" ? "↑ bättre" : "↓ bättre"} · {tal.underlag} +
+

+ {tal.tolkning} +

+
+ ); +} + +/** 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 ( +
+ {rader.map((r) => ( +
+
+ + {r.etikett} + + + {r.extra ?? r.antal} + +
+
+
+
+
+ ))} +
+ ); +} + +export default function Analys() { + const arenden = useFelsokning((s) => s.arenden); + const [fran, setFran] = useState | 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), + [fran, arenden], + ) as ReturnType; + + 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 ( + +
+
+
+ Operational metrics +
+ Analysis +
+
+ + ALVA-REP-0100 · {data.antal.avslutade}/{data.antal.totalt} closed + +
+ +
+ {tal.map((t) => ( + + ))} +
+ +
+ +

+ Starkaste evidenstyp per ärende. Ett mätvärde räknas som E4 endast med spårbart mätdon. +

+ ) + .filter(([, n]) => n > 0) + .map(([niva, antal]) => ({ etikett: niva, antal }))} + /> +
+ + +

+ Var arbetet ligger, i ALVA-termer. Övervikt i Localization betyder att fel hittas men inte fastställs. +

+ ({ + etikett: f.namn, + antal: data.faser[f.id].antal, + extra: data.faser[f.id].andel === null ? "—" : `${data.faser[f.id].andel} %`, + }))} + /> +
+
+ + +

+ 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. +

+ {data.undantag.length === 0 ? ( + + ) : ( +
+ [ + + {u.steg} + , + u.text, + u.fas, + + {u.undantag}/{u.utforda} + , + + {u.andel} % + , + ])} + /> +
+ )} +
+ + + {data.orsaker.length === 0 ? ( + + ) : ( + ({ etikett: o.kategori, antal: o.antal }))} /> + )} + + + {data.omarbetning.fall.length > 0 && ( + +
+ [ + + {f.identifierare} + , + f.kategorier.join(" · "), + + {f.dagar} d + , + ])} + /> +
+
+ )} +
+
+ ); +} diff --git a/felsokning/app/src/pages/alva/Integration.tsx b/felsokning/app/src/pages/alva/Integration.tsx new file mode 100644 index 0000000..960b012 --- /dev/null +++ b/felsokning/app/src/pages/alva/Integration.tsx @@ -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 = { + 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 ( + +
+ Integration interface +
+ Connected systems +
+

+ 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.beteckning} + , + p.system, + p.kategori, + p.riktning, + , + + {p.anmarkning} + , + ])} + /> +
+

+ A profile at draft 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. +

+
+ + +

+ 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. +

+
+ [ + + {id} + , + text, + ])} + /> +
+
+ + +

+ Readings become evidence, 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. +

+
+{`POST /api/arenden/{arendeId}/protokoll
+Authorization: Bearer 
+
+{
+  "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" }
+}`}
+          
+
+ Add profile + Download OpenAPI +
+
+
+
+ ); +} diff --git a/felsokning/app/src/pages/alva/Ram.tsx b/felsokning/app/src/pages/alva/Ram.tsx index 349037a..54f4019 100644 --- a/felsokning/app/src/pages/alva/Ram.tsx +++ b/felsokning/app/src/pages/alva/Ram.tsx @@ -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" }, ]; diff --git a/felsokning/infra/postgres-init.sql b/felsokning/infra/postgres-init.sql index d46cc5b..d5ab251 100644 --- a/felsokning/infra/postgres-init.sql +++ b/felsokning/infra/postgres-init.sql @@ -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; diff --git a/felsokning/services/gemensam/integration.mjs b/felsokning/services/gemensam/integration.mjs new file mode 100644 index 0000000..1afa92a --- /dev/null +++ b/felsokning/services/gemensam/integration.mjs @@ -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 !== ""); +} diff --git a/felsokning/services/gemensam/sammanfattning.mjs b/felsokning/services/gemensam/sammanfattning.mjs new file mode 100644 index 0000000..eeeb58e --- /dev/null +++ b/felsokning/services/gemensam/sammanfattning.mjs @@ -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 rå = String(text ?? "").trim().replace(/\s+/g, " "); + if (rå.length <= max) return rå; + const brytpunkt = rå.lastIndexOf(" ", max); + return `${rå.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.`; +} diff --git a/felsokning/services/gemensam/statistik.mjs b/felsokning/services/gemensam/statistik.mjs new file mode 100644 index 0000000..ac31139 --- /dev/null +++ b/felsokning/services/gemensam/statistik.mjs @@ -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 E0–E6. 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), + }; +} diff --git a/felsokning/services/plattform/integration.mjs b/felsokning/services/plattform/integration.mjs new file mode 120000 index 0000000..4b0196c --- /dev/null +++ b/felsokning/services/plattform/integration.mjs @@ -0,0 +1 @@ +../gemensam/integration.mjs \ No newline at end of file diff --git a/felsokning/services/plattform/sammanfattning.mjs b/felsokning/services/plattform/sammanfattning.mjs new file mode 120000 index 0000000..17cddd6 --- /dev/null +++ b/felsokning/services/plattform/sammanfattning.mjs @@ -0,0 +1 @@ +../gemensam/sammanfattning.mjs \ No newline at end of file diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 99b6f12..3bc3cdf 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -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 }); } } diff --git a/felsokning/services/plattform/statistik.mjs b/felsokning/services/plattform/statistik.mjs new file mode 120000 index 0000000..85b02ad --- /dev/null +++ b/felsokning/services/plattform/statistik.mjs @@ -0,0 +1 @@ +../gemensam/statistik.mjs \ No newline at end of file