04e0a1e8ec
Guidad Felsökning / test-och-bygg (push) Has been cancelled
Guidad Felsökning / tjanster (push) Has been cancelled
Guidad Felsökning / terraform (push) Has been cancelled
Guidad Felsökning / leverantorskedja (push) Has been cancelled
Guidad Felsökning / aterstallning (push) Has been cancelled
Guidad Felsökning / publicera (push) Has been cancelled
Guidad Felsökning / driftsatt (push) Has been cancelled
209 lines
8.7 KiB
JavaScript
209 lines
8.7 KiB
JavaScript
// 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),
|
||
};
|
||
}
|