Files
alva/felsokning/services/gemensam/statistik.mjs
T
Claude 3ec25dea9c 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
2026-08-05 15:51:30 +00:00

209 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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),
};
}