Personbil-etikett i granskningen + ECM Knowledge Library

Arbetsordergranskningens fältgrupp heter nu Personbil (tidigare Fordon),
liksom raden i rapportens fordonsinformation.

ECM Knowledge Library: regelbiblioteket är nu serverdistribuerat —
regler uppdateras i driften utan appändring, precis som rekommenderat:

- Compliance-reglerna är deklarativ data (krav-typ i stället för kod):
  services/plattform/ecm-regler.json serveras på GET /api/ecm/regler,
  i klustret utbytbar via ConfigMap (ECM_REGLER_FIL)
- Klienten hämtar paketet vid sidladdning, cachar det och faller
  tillbaka till inbyggt standardpaket offline; trasiga paket och okända
  krav-typer filtreras (normaliseraRegelpaket)
- Kvalitetsgrinden, orsakskategorierna, evidenskällorna och
  undantagsorsakerna läses ur det aktiva paketet; spårbarhetspaketet
  bär regelpaketets version
- Testat: paritet server/standardpaket, fallback, att ett uppdaterat
  paket ändrar grinden utan appändring; integrationstestet verifierar
  serveringen (57 vitest-tester gröna)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 09:38:44 +00:00
parent ddc11c6ac2
commit a37cc027ae
14 changed files with 328 additions and 68 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. | | Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. |
| Ärendestart via arbetsorder | ✅ Primärvägen när ett ärende startas: fota arbetsorderns framsida — orkesterns dokumenttolkning (Claude Sonnet 5, vision) läser kund-, fordons- och verkstadsuppgifter oavsett layout och sätter konfidens per fält. 🟢 ≥95 % godkänns automatiskt, 🟡 8095 % markeras för genomläsning, 🔴 <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält. Visuell granskning med dokumentet bredvid fälten (klick markerar ungefärlig position), sedan skapas hela ärendet med ett tryck. Tolkningen loggas som organisationsintern händelse (`arbetsorder_skannad`) och delas aldrig i kund-/partnervyer. Manuell inmatning finns kvar som andrahandsväg; i lokalt läge visas en tydligt märkt demo-tolkning. Inloggade användare tillfrågas aldrig om namn — kontot vet redan. | | Ärendestart via arbetsorder | ✅ Primärvägen när ett ärende startas: fota arbetsorderns framsida — orkesterns dokumenttolkning (Claude Sonnet 5, vision) läser kund-, fordons- och verkstadsuppgifter oavsett layout och sätter konfidens per fält. 🟢 ≥95 % godkänns automatiskt, 🟡 8095 % markeras för genomläsning, 🔴 <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält. Visuell granskning med dokumentet bredvid fälten (klick markerar ungefärlig position), sedan skapas hela ärendet med ett tryck. Tolkningen loggas som organisationsintern händelse (`arbetsorder_skannad`) och delas aldrig i kund-/partnervyer. Manuell inmatning finns kvar som andrahandsväg; i lokalt läge visas en tydligt märkt demo-tolkning. Inloggade användare tillfrågas aldrig om namn — kontot vet redan. |
| Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. | | Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. |
| Evidensmotor (ECM) | ✅ Eget subsystem med sex motorer ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, **ECM v2.0**): Evidence (evidensposter med nivå E0E6, tekniker och innehållshash), Rule (dokumentationskrav + undantagsregeln med obligatorisk orsak), Compliance (ärendetypen — garanti/försäkring/reklamation m.fl. — styr extra krav), Validation ("Evidens saknas" i stället för antaganden, kodat i orkesterns grundprompt), Completion (kvalitetsgrind som spärrar slutrapporten) och Traceability (spårbarhetspaket med regelversion + hash i varje export). | | Evidensmotor (ECM) | ✅ Eget subsystem med sex motorer ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, **ECM v2.0**): Evidence (evidensposter med nivå E0E6, tekniker och innehållshash), Rule (dokumentationskrav + undantagsregeln med obligatorisk orsak), Compliance (ärendetypen — garanti/försäkring/reklamation m.fl. — styr extra krav), Validation ("Evidens saknas" i stället för antaganden, kodat i orkesterns grundprompt), Completion (kvalitetsgrind som spärrar slutrapporten) och Traceability (spårbarhetspaket med regelversion + hash i varje export). **ECM Knowledge Library**: compliance-reglerna är deklarativ data som serveras av plattformen (`GET /api/ecm/regler`, utbytbar via ConfigMap) — uppdateras i driften utan appändring; klienten cachar och faller tillbaka till inbyggt standardpaket offline. |
| Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik (Ja med ev. orsakskedja / Nej med obligatorisk orsak → kvalitetsvarning), **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. | | Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik (Ja med ev. orsakskedja / Nej med obligatorisk orsak → kvalitetsvarning), **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. |
| Symptomverifiering (SVP) | ✅ Kundens beskrivning ≠ konstaterat fel: beskrivningen dokumenteras ordagrant, förtydligas via metodikens symptomfrågor (generiska metodiken har SVP-setet när/var/hur) och reproduceras — Ja (hur/förhållanden), Delvis (vad kunde/kunde inte) eller Nej (obligatorisk motivering). Rapportens beviskedja skiljer kundens beskrivning, verifierad observation, felorsaksanalys och rekommenderad åtgärd; formuleringen "kunde inte reproduceras under de förhållanden som rådde" används i stället för "felet konstaterat" (kodat även i orkesterns grundprompt). | | Symptomverifiering (SVP) | ✅ Kundens beskrivning ≠ konstaterat fel: beskrivningen dokumenteras ordagrant, förtydligas via metodikens symptomfrågor (generiska metodiken har SVP-setet när/var/hur) och reproduceras — Ja (hur/förhållanden), Delvis (vad kunde/kunde inte) eller Nej (obligatorisk motivering). Rapportens beviskedja skiljer kundens beskrivning, verifierad observation, felorsaksanalys och rekommenderad åtgärd; formuleringen "kunde inte reproduceras under de förhållanden som rådde" används i stället för "felet konstaterat" (kodat även i orkesterns grundprompt). |
| Felorsaksanalys | ✅ Obligatorisk före avslut: konstaterad avvikelse (kvalitetsregeln avvisar "trasig/defekt/sliten" utan förklaring), orsakskategorier (inkl. Okänd orsak med krav på motivering), minst en evidenskälla som valideras mot loggen, säkerhetsnivå (medel/låg kräver stärkande kontroller) och rekommenderad åtgärd. Avslutsknappen spärrad tills SVP + felorsak finns; kvalitetsgrinden gör båda obligatoriska vid stängning. Eget avsnitt i slutrapporten. | | Felorsaksanalys | ✅ Obligatorisk före avslut: konstaterad avvikelse (kvalitetsregeln avvisar "trasig/defekt/sliten" utan förklaring), orsakskategorier (inkl. Okänd orsak med krav på motivering), minst en evidenskälla som valideras mot loggen, säkerhetsnivå (medel/låg kräver stärkande kontroller) och rekommenderad åtgärd. Avslutsknappen spärrad tills SVP + felorsak finns; kvalitetsgrinden gör båda obligatoriska vid stängning. Eget avsnitt i slutrapporten. |
+10 -4
View File
@@ -48,10 +48,16 @@ väljs i identitetsraden och loggas (`arendetyp_satt`):
| Reklamation | Historik och tidigare försök kontrollerade | | Reklamation | Historik och tidigare försök kontrollerade |
| Begagnatgaranti | Miltal | | Begagnatgaranti | Miltal |
Här ansluter det framtida **ECM Knowledge Library**: serverdistribuerade, **ECM Knowledge Library är implementerat**: reglerna är deklarativa data
versionerade regelpaket (garantivillkor per tillverkare, försäkringsbolagens (krav-typ, inte kod) och distribueras från plattformen via
krav, reklamationslagstiftning, OEM-kontrollpunkter) som laddas dynamiskt `GET /api/ecm/regler` (`services/plattform/ecm-regler.json` — i klustret
utan appändring. utbytbar via ConfigMap och miljövariabeln `ECM_REGLER_FIL`). Klienten
hämtar paketet vid sidladdning, cachar det och faller tillbaka till sitt
inbyggda standardpaket offline; trasiga paket och okända krav-typer
filtreras. Regelpaketets version följer med i varje spårbarhetspaket.
Nya regler — garantivillkor per tillverkare, försäkringsbolagens krav,
reklamationslagstiftning, OEM-kontrollpunkter — läggs till i driften
utan att applikationen byggs om.
### 4. Validation Engine ### 4. Validation Engine
Inga påståenden utan underlag, i tre lager: (a) orkesterns grundprompt — Inga påståenden utan underlag, i tre lager: (a) orkesterns grundprompt —
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
COPY server.mjs openapi.yaml ./ COPY server.mjs openapi.yaml ecm-regler.json ./
ENV NODE_ENV=production PORT=8080 ENV NODE_ENV=production PORT=8080
USER node USER node
+59
View File
@@ -0,0 +1,59 @@
{
"version": "2.0",
"beskrivning": "ECM Knowledge Library — serverdistribuerat regelpaket. Uppdateras utan appändring: redigera filen (eller montera en ConfigMap över den i klustret) och starta om tjänsten. Klienten hämtar paketet vid inloggning och faller tillbaka till sitt inbyggda standardpaket offline.",
"arendetypRegler": {
"Garanti": [
{ "id": "garanti_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Garantiärenden kräver dokumenterad mätarställning." },
{ "id": "garanti_historik", "rubrik": "Servicehistorik kontrollerad", "krav": "historik", "detaljVidBrist": "Garantiärenden kräver kontrollerad servicehistorik." },
{ "id": "garanti_claim", "rubrik": "Claim-/garantinummer registrerat", "krav": "claim", "detaljVidBrist": "Ange claim-/garantinummer (läses ur arbetsordern)." }
],
"Goodwill": [
{ "id": "goodwill_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Goodwillärenden kräver dokumenterad mätarställning." },
{ "id": "goodwill_historik", "rubrik": "Servicehistorik kontrollerad", "krav": "historik", "detaljVidBrist": "Goodwillärenden kräver kontrollerad servicehistorik." }
],
"Försäkring": [
{ "id": "forsakring_skadenummer", "rubrik": "Skadenummer registrerat", "krav": "skadenummer", "detaljVidBrist": "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ "id": "forsakring_bildbevis", "rubrik": "Bildbevis finns", "krav": "foto", "detaljVidBrist": "Försäkringsärenden kräver bilddokumentation." }
],
"Reklamation": [
{ "id": "reklamation_historik", "rubrik": "Historik och tidigare försök kontrollerade", "krav": "historik", "detaljVidBrist": "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." }
],
"Begagnatgaranti": [
{ "id": "begagnat_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Begagnatgaranti kräver dokumenterad mätarställning." }
]
},
"undantagsorsaker": [
"Komponenten är oåtkomlig",
"Fordonet kan inte lyftas säkert",
"Kunden avböjde demontering",
"Dålig sikt/åtkomst",
"Utrustning saknas"
],
"orsakskategorier": [
"Normalt slitage",
"Ålder",
"Körsträcka",
"Materialutmattning",
"Tillverkningsfel",
"Bristande underhåll",
"Felaktig tidigare reparation",
"Yttre påverkan",
"Korrosion",
"Överhettning",
"Förorening",
"Felaktig användning",
"Modifiering",
"Olycka eller skada",
"Okänd orsak"
],
"underlagskallor": [
"Foto",
"Video",
"Mätresultat",
"Diagnosutläsning",
"Tidigare historik",
"Servicehistorik",
"Teknisk dokumentation",
"Direkt observation"
]
}
+5
View File
@@ -166,6 +166,11 @@ curl -s -X POST "$BAS/api/delningar/$PARTNERKOD/aterkalla" -H "Authorization: Be
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$PARTNERKOD") KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$PARTNERKOD")
kontroll "återkallad delning ger 404" "$KOD" "404" kontroll "återkallad delning ger 404" "$KOD" "404"
# 10b. ECM Knowledge Library: regelpaketet serveras till inloggade klienter
REGLER=$(curl -s "$BAS/api/ecm/regler" -H "Authorization: Bearer $TOKEN_J")
kontroll "regelpaketet serveras" "$(echo "$REGLER" | falt .version)" "2.0"
kontroll "regelpaketet innehåller garantiregler" "$(echo "$REGLER" | falt '.arendetypRegler.Garanti.length')" "3"
# 11. API-first: OpenAPI-specen serveras live, utan inloggning # 11. API-first: OpenAPI-specen serveras live, utan inloggning
SPEC=$(curl -s "$BAS/api/openapi.yaml") SPEC=$(curl -s "$BAS/api/openapi.yaml")
case "$SPEC" in case "$SPEC" in
+23
View File
@@ -147,6 +147,29 @@ paths:
"403": { $ref: "#/components/responses/Fel" } "403": { $ref: "#/components/responses/Fel" }
"409": { $ref: "#/components/responses/Fel" } "409": { $ref: "#/components/responses/Fel" }
/api/ecm/regler:
get:
tags: [Drift]
summary: ECM Knowledge Library — aktuellt regelpaket
description: >
Serverdistribuerat, versionerat regelpaket (compliance-regler per
ärendetyp, orsakskategorier, evidenskällor, undantagsorsaker).
Uppdateras i driften utan appändring; klienten cachar och faller
tillbaka till sitt inbyggda standardpaket offline.
responses:
"200":
description: Regelpaketet.
content:
application/json:
schema:
type: object
properties:
version: { type: string }
arendetypRegler: { type: object, additionalProperties: true }
undantagsorsaker: { type: array, items: { type: string } }
orsakskategorier: { type: array, items: { type: string } }
underlagskallor: { type: array, items: { type: string } }
/api/organisation: /api/organisation:
get: get:
tags: [Organisation] tags: [Organisation]
+18
View File
@@ -24,6 +24,15 @@ import pg from "pg";
// API-first: OpenAPI-specen är en versionerad artefakt och serveras live. // API-first: OpenAPI-specen är en versionerad artefakt och serveras live.
const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8"); const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8");
// ECM Knowledge Library: regelpaketet är serverägd konfiguration — inte
// appkod. Uppdateras genom att byta filen (ECM_REGLER_FIL kan peka på en
// ConfigMap-mount i klustret) och starta om tjänsten; klienterna hämtar
// det nya paketet vid nästa inloggning/sidladdning.
const ECM_REGLER = readFileSync(
process.env.ECM_REGLER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "ecm-regler.json"),
"utf8",
);
const PORT = Number(process.env.PORT ?? 8080); const PORT = Number(process.env.PORT ?? 8080);
const MAX_KROPP = 4 * 1024 * 1024; const MAX_KROPP = 4 * 1024 * 1024;
const TOKEN_LIVSTID_S = 12 * 60 * 60; const TOKEN_LIVSTID_S = 12 * 60 * 60;
@@ -266,6 +275,15 @@ export function skapaServer() {
} }
} }
// ECM Knowledge Library: aktuellt regelpaket för inloggade klienter.
if (req.method === "GET" && vag === "/api/ecm/regler") {
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
return res.end(ECM_REGLER);
}
// Organisationens inställningar: vad som visas när ett ärende // Organisationens inställningar: vad som visas när ett ärende
// startas (objekttyper, identifieringsmetoder). Alla inloggade // startas (objekttyper, identifieringsmetoder). Alla inloggade
// läser; endast systemadministratören ändrar. // läser; endast systemadministratören ändrar.
+2 -1
View File
@@ -72,6 +72,7 @@ describe("AI-orkestern", () => {
["/api/auth/registrera", "/api/auth/registrera"], ["/api/auth/registrera", "/api/auth/registrera"],
["/api/auth/logga-in", "/api/auth/logga-in"], ["/api/auth/logga-in", "/api/auth/logga-in"],
["/api/anvandare", "/api/anvandare"], ["/api/anvandare", "/api/anvandare"],
["/api/ecm/regler", "/api/ecm/regler"],
["/api/organisation", "/api/organisation"], ["/api/organisation", "/api/organisation"],
["/api/organisation/installningar", "/api/organisation/installningar"], ["/api/organisation/installningar", "/api/organisation/installningar"],
["/api/arenden", "/api/arenden"], ["/api/arenden", "/api/arenden"],
@@ -151,7 +152,7 @@ describe("arbetsorderskanning", () => {
expect(falt.map((f) => f.id)).toEqual(["fordon_regnr", "fordon_vin"]); expect(falt.map((f) => f.id)).toEqual(["fordon_regnr", "fordon_vin"]);
expect(falt[0].konfidens).toBe(1); expect(falt[0].konfidens).toBe(1);
expect(falt[0].varde).toBe("abc123"); expect(falt[0].varde).toBe("abc123");
expect(falt[0].grupp).toBe("Fordon"); expect(falt[0].grupp).toBe("Personbil");
expect(falt[1].omrade?.x).toBe(0.5); expect(falt[1].omrade?.x).toBe(0.5);
expect(() => normaliseraArbetsorder({})).toThrow(); expect(() => normaliseraArbetsorder({})).toThrow();
}); });
+55
View File
@@ -254,3 +254,58 @@ describe("Felorsaksanalys (Root Cause Analysis)", () => {
expect(orsak?.handelse.typ === "felorsak" && orsak.handelse.underlag.length > 0).toBe(true); expect(orsak?.handelse.typ === "felorsak" && orsak.handelse.underlag.length > 0).toBe(true);
}); });
}); });
describe("ECM Knowledge Library (regelpaket)", () => {
it("serverpaketet och det inbyggda standardpaketet är samma regler", async () => {
const { readFileSync } = await import("node:fs");
const { STANDARD_REGELPAKET, normaliseraRegelpaket } = await import("../ecm");
const server = normaliseraRegelpaket(JSON.parse(readFileSync("services/plattform/ecm-regler.json", "utf8")));
expect(server.version).toBe(STANDARD_REGELPAKET.version);
expect(server.arendetypRegler).toEqual(STANDARD_REGELPAKET.arendetypRegler);
expect(server.orsakskategorier).toEqual(STANDARD_REGELPAKET.orsakskategorier);
});
it("trasiga paket faller tillbaka till standard, okända krav-typer filtreras", async () => {
const { STANDARD_REGELPAKET, normaliseraRegelpaket } = await import("../ecm");
expect(normaliseraRegelpaket(null)).toEqual(STANDARD_REGELPAKET);
const paket = normaliseraRegelpaket({
version: "2.1",
arendetypRegler: {
Garanti: [
{ id: "x", rubrik: "Ny regel", krav: "foto", detaljVidBrist: "Kräver foto." },
{ id: "y", rubrik: "Trasig", krav: "pahittad_kravtyp", detaljVidBrist: "…" },
],
},
});
expect(paket.version).toBe("2.1");
expect(paket.arendetypRegler.Garanti).toHaveLength(1);
expect(paket.undantagsorsaker).toEqual(STANDARD_REGELPAKET.undantagsorsaker);
});
it("ett uppdaterat regelpaket ändrar kvalitetsgrinden utan appändring", async () => {
const { normaliseraRegelpaket } = await import("../ecm");
localStorage.setItem(
"gf-ecm-regelpaket",
JSON.stringify(
normaliseraRegelpaket({
version: "2.1-test",
arendetypRegler: {
"Privat kund": [
{ id: "test_foto", rubrik: "Foto krävs för alla privatkunder", krav: "foto", detaljVidBrist: "Testregel." },
],
},
}),
),
);
try {
const utanFoto = byggArende([OBJEKT]);
const rad = kvalitetsgrind(utanFoto, VIBRATION_METODIK).find((r) => r.id === "test_foto");
expect(rad?.ok).toBe(false);
expect(rad?.rubrik).toContain("Privat kund");
// Spårbarheten bär paketversionen.
expect(sparbarhetspaket(utanFoto, VIBRATION_METODIK).regelpaketVersion).toBe("2.1-test");
} finally {
localStorage.removeItem("gf-ecm-regelpaket");
}
});
});
+10 -10
View File
@@ -101,7 +101,7 @@ export function tolkaAiSvar(data: unknown): AiSvar {
// (OCR + layoutförståelse) och returnerar strukturerade fält med // (OCR + layoutförståelse) och returnerar strukturerade fält med
// konfidens per värde. Bara osäkra fält behöver granskas. // konfidens per värde. Bara osäkra fält behöver granskas.
export type ArbetsorderGrupp = "Kund" | "Fordon" | "Verkstad" | "Ärende"; export type ArbetsorderGrupp = "Kund" | "Personbil" | "Verkstad" | "Ärende";
export interface TolkatFalt { export interface TolkatFalt {
id: string; id: string;
@@ -118,15 +118,15 @@ export const ARBETSORDER_FALT: { id: string; grupp: ArbetsorderGrupp; etikett: s
{ id: "kund_foretag", grupp: "Kund", etikett: "Företag" }, { id: "kund_foretag", grupp: "Kund", etikett: "Företag" },
{ id: "kund_telefon", grupp: "Kund", etikett: "Telefon" }, { id: "kund_telefon", grupp: "Kund", etikett: "Telefon" },
{ id: "kund_epost", grupp: "Kund", etikett: "E-post" }, { id: "kund_epost", grupp: "Kund", etikett: "E-post" },
{ id: "fordon_regnr", grupp: "Fordon", etikett: "Regnr" }, { id: "fordon_regnr", grupp: "Personbil", etikett: "Regnr" },
{ id: "fordon_vin", grupp: "Fordon", etikett: "VIN" }, { id: "fordon_vin", grupp: "Personbil", etikett: "VIN" },
{ id: "fordon_marke", grupp: "Fordon", etikett: "Märke" }, { id: "fordon_marke", grupp: "Personbil", etikett: "Märke" },
{ id: "fordon_modell", grupp: "Fordon", etikett: "Modell" }, { id: "fordon_modell", grupp: "Personbil", etikett: "Modell" },
{ id: "fordon_motor", grupp: "Fordon", etikett: "Motor" }, { id: "fordon_motor", grupp: "Personbil", etikett: "Motor" },
{ id: "fordon_arsmodell", grupp: "Fordon", etikett: "Årsmodell" }, { id: "fordon_arsmodell", grupp: "Personbil", etikett: "Årsmodell" },
{ id: "fordon_matarstallning", grupp: "Fordon", etikett: "Mätarställning" }, { id: "fordon_matarstallning", grupp: "Personbil", etikett: "Mätarställning" },
{ id: "fordon_motorkod", grupp: "Fordon", etikett: "Motorkod" }, { id: "fordon_motorkod", grupp: "Personbil", etikett: "Motorkod" },
{ id: "fordon_vaxellada", grupp: "Fordon", etikett: "Växellåda" }, { id: "fordon_vaxellada", grupp: "Personbil", etikett: "Växellåda" },
{ id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" }, { id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" },
{ id: "ao_claim", grupp: "Verkstad", etikett: "Claim-/garantinr" }, { id: "ao_claim", grupp: "Verkstad", etikett: "Claim-/garantinr" },
{ id: "ao_skadenummer", grupp: "Verkstad", etikett: "Skadenummer" }, { id: "ao_skadenummer", grupp: "Verkstad", etikett: "Skadenummer" },
+6 -6
View File
@@ -151,12 +151,12 @@ export function byggDemoTolkning(): TolkatFalt[] {
return [ return [
{ id: "kund_namn", etikett: "Namn", grupp: "Kund", varde: "Anders Svensson", konfidens: 0.98 }, { id: "kund_namn", etikett: "Namn", grupp: "Kund", varde: "Anders Svensson", konfidens: 0.98 },
{ id: "kund_telefon", etikett: "Telefon", grupp: "Kund", varde: "070-123 45 67", konfidens: 0.91, omrade: { x: 0.08, y: 0.22, bredd: 0.3, hojd: 0.04 } }, { id: "kund_telefon", etikett: "Telefon", grupp: "Kund", varde: "070-123 45 67", konfidens: 0.91, omrade: { x: 0.08, y: 0.22, bredd: 0.3, hojd: 0.04 } },
{ id: "fordon_regnr", etikett: "Regnr", grupp: "Fordon", varde: "ABC123", konfidens: 0.99, omrade: { x: 0.62, y: 0.12, bredd: 0.2, hojd: 0.05 } }, { id: "fordon_regnr", etikett: "Regnr", grupp: "Personbil", varde: "ABC123", konfidens: 0.99, omrade: { x: 0.62, y: 0.12, bredd: 0.2, hojd: 0.05 } },
{ id: "fordon_vin", etikett: "VIN", grupp: "Fordon", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.55, y: 0.19, bredd: 0.38, hojd: 0.04 } }, { id: "fordon_vin", etikett: "VIN", grupp: "Personbil", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.55, y: 0.19, bredd: 0.38, hojd: 0.04 } },
{ id: "fordon_marke", etikett: "Märke", grupp: "Fordon", varde: "Volvo", konfidens: 0.99 }, { id: "fordon_marke", etikett: "Märke", grupp: "Personbil", varde: "Volvo", konfidens: 0.99 },
{ id: "fordon_modell", etikett: "Modell", grupp: "Fordon", varde: "XC60 D4", konfidens: 0.97 }, { id: "fordon_modell", etikett: "Modell", grupp: "Personbil", varde: "XC60 D4", konfidens: 0.97 },
{ id: "fordon_arsmodell", etikett: "Årsmodell", grupp: "Fordon", varde: "2019", konfidens: 0.96 }, { id: "fordon_arsmodell", etikett: "Årsmodell", grupp: "Personbil", varde: "2019", konfidens: 0.96 },
{ id: "fordon_matarstallning", etikett: "Mätarställning", grupp: "Fordon", varde: "8 432 mil", konfidens: 0.87, omrade: { x: 0.62, y: 0.27, bredd: 0.18, hojd: 0.04 } }, { id: "fordon_matarstallning", etikett: "Mätarställning", grupp: "Personbil", varde: "8 432 mil", konfidens: 0.87, omrade: { x: 0.62, y: 0.27, bredd: 0.18, hojd: 0.04 } },
{ id: "ao_nummer", etikett: "Arbetsordernr", grupp: "Verkstad", varde: "AO-2496", konfidens: 0.99 }, { id: "ao_nummer", etikett: "Arbetsordernr", grupp: "Verkstad", varde: "AO-2496", konfidens: 0.99 },
{ id: "ao_serviceradgivare", etikett: "Servicerådgivare", grupp: "Verkstad", varde: "M. Lindqvist", konfidens: 0.82 }, { id: "ao_serviceradgivare", etikett: "Servicerådgivare", grupp: "Verkstad", varde: "M. Lindqvist", konfidens: 0.82 },
{ id: "felbeskrivning", etikett: "Felbeskrivning", grupp: "Ärende", varde: "Kunden upplever vibrationer i ratten vid ca 90 km/h", konfidens: 0.97, omrade: { x: 0.08, y: 0.45, bredd: 0.84, hojd: 0.07 } }, { id: "felbeskrivning", etikett: "Felbeskrivning", grupp: "Ärende", varde: "Kunden upplever vibrationer i ratten vid ca 90 km/h", konfidens: 0.97, omrade: { x: 0.08, y: 0.45, bredd: 0.84, hojd: 0.07 } },
+128 -37
View File
@@ -241,13 +241,31 @@ export function arendetyp(arende: Arende): Arendetyp {
return typ; return typ;
} }
interface ComplianceKrav { // ---- ECM Knowledge Library --------------------------------------------
// Regelpaketet är data, inte kod: reglerna är deklarativa (krav-typ i
// stället för funktioner) och kan därför distribueras från servern
// (GET /api/ecm/regler, filen services/plattform/ecm-regler.json — i
// klustret utbytbar via ConfigMap) och uppdateras utan appändring.
// Klienten cachar senast hämtade paket och faller tillbaka till det
// inbyggda standardpaketet offline/i lokalt läge.
export type ComplianceKravTyp = "miltal" | "historik" | "claim" | "skadenummer" | "foto";
export interface ComplianceRegel {
id: string; id: string;
rubrik: string; rubrik: string;
uppfyllt: (arende: Arende) => boolean; krav: ComplianceKravTyp;
detaljVidBrist: string; detaljVidBrist: string;
} }
export interface RegelPaket {
version: string;
arendetypRegler: Partial<Record<string, ComplianceRegel[]>>;
undantagsorsaker: string[];
orsakskategorier: string[];
underlagskallor: string[];
}
function objektFalt(arende: Arende, falt: "miltal" | "claim" | "skadenummer" | "arbetsorder"): string | undefined { function objektFalt(arende: Arende, falt: "miltal" | "claim" | "skadenummer" | "arbetsorder"): string | undefined {
for (const post of arende.handelser) { for (const post of arende.handelser) {
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt[falt]; if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt[falt];
@@ -255,37 +273,107 @@ function objektFalt(arende: Arende, falt: "miltal" | "claim" | "skadenummer" | "
return undefined; return undefined;
} }
const harMatarstallning = (arende: Arende) => // Krav-typ → verifiering mot händelseloggen. Nya krav-typer läggs till
arende.handelser.some((p) => p.handelse.typ === "matarstallning" && !p.handelse.undantag) || // här; vilka regler som använder dem styrs helt av regelpaketet.
!!objektFalt(arende, "miltal"); const KRAV_KONTROLLER: Record<ComplianceKravTyp, (arende: Arende) => boolean> = {
const harHistorik = (arende: Arende) => miltal: (a) =>
arende.handelser.some((p) => p.handelse.typ === "historik_kontrollerad" && p.handelse.kontrollerad); a.handelser.some((p) => p.handelse.typ === "matarstallning" && !p.handelse.undantag) ||
const harFoto = (arende: Arende) => arende.handelser.some((p) => p.handelse.typ === "foto"); !!objektFalt(a, "miltal"),
historik: (a) => a.handelser.some((p) => p.handelse.typ === "historik_kontrollerad" && p.handelse.kontrollerad),
// Regelpaket per ärendetyp — här ansluter framtida serverdistribuerade claim: (a) => !!objektFalt(a, "claim"),
// regler (garantivillkor per tillverkare, försäkringsbolagens krav …). skadenummer: (a) => !!objektFalt(a, "skadenummer"),
const COMPLIANCE_REGLER: Partial<Record<Arendetyp, ComplianceKrav[]>> = { foto: (a) => a.handelser.some((p) => p.handelse.typ === "foto"),
Garanti: [
{ id: "garanti_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Garantiärenden kräver dokumenterad mätarställning." },
{ id: "garanti_historik", rubrik: "Servicehistorik kontrollerad", uppfyllt: harHistorik, detaljVidBrist: "Garantiärenden kräver kontrollerad servicehistorik." },
{ id: "garanti_claim", rubrik: "Claim-/garantinummer registrerat", uppfyllt: (a) => !!objektFalt(a, "claim"), detaljVidBrist: "Ange claim-/garantinummer (läses ur arbetsordern)." },
],
Goodwill: [
{ id: "goodwill_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Goodwillärenden kräver dokumenterad mätarställning." },
{ id: "goodwill_historik", rubrik: "Servicehistorik kontrollerad", uppfyllt: harHistorik, detaljVidBrist: "Goodwillärenden kräver kontrollerad servicehistorik." },
],
Försäkring: [
{ id: "forsakring_skadenummer", rubrik: "Skadenummer registrerat", uppfyllt: (a) => !!objektFalt(a, "skadenummer"), detaljVidBrist: "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ id: "forsakring_bildbevis", rubrik: "Bildbevis finns", uppfyllt: harFoto, detaljVidBrist: "Försäkringsärenden kräver bilddokumentation." },
],
Reklamation: [
{ id: "reklamation_historik", rubrik: "Historik och tidigare försök kontrollerade", uppfyllt: harHistorik, detaljVidBrist: "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." },
],
Begagnatgaranti: [
{ id: "begagnat_miltal", rubrik: "Miltal dokumenterat", uppfyllt: harMatarstallning, detaljVidBrist: "Begagnatgaranti kräver dokumenterad mätarställning." },
],
}; };
// Inbyggt standardpaket = fallback när servern inte nåtts ännu.
export const STANDARD_REGELPAKET: RegelPaket = {
version: ECM_VERSION,
arendetypRegler: {
Garanti: [
{ id: "garanti_miltal", rubrik: "Miltal dokumenterat", krav: "miltal", detaljVidBrist: "Garantiärenden kräver dokumenterad mätarställning." },
{ id: "garanti_historik", rubrik: "Servicehistorik kontrollerad", krav: "historik", detaljVidBrist: "Garantiärenden kräver kontrollerad servicehistorik." },
{ id: "garanti_claim", rubrik: "Claim-/garantinummer registrerat", krav: "claim", detaljVidBrist: "Ange claim-/garantinummer (läses ur arbetsordern)." },
],
Goodwill: [
{ id: "goodwill_miltal", rubrik: "Miltal dokumenterat", krav: "miltal", detaljVidBrist: "Goodwillärenden kräver dokumenterad mätarställning." },
{ id: "goodwill_historik", rubrik: "Servicehistorik kontrollerad", krav: "historik", detaljVidBrist: "Goodwillärenden kräver kontrollerad servicehistorik." },
],
Försäkring: [
{ id: "forsakring_skadenummer", rubrik: "Skadenummer registrerat", krav: "skadenummer", detaljVidBrist: "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ id: "forsakring_bildbevis", rubrik: "Bildbevis finns", krav: "foto", detaljVidBrist: "Försäkringsärenden kräver bilddokumentation." },
],
Reklamation: [
{ id: "reklamation_historik", rubrik: "Historik och tidigare försök kontrollerade", krav: "historik", detaljVidBrist: "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." },
],
Begagnatgaranti: [
{ id: "begagnat_miltal", rubrik: "Miltal dokumenterat", krav: "miltal", detaljVidBrist: "Begagnatgaranti kräver dokumenterad mätarställning." },
],
},
undantagsorsaker: UNDANTAGSORSAKER,
orsakskategorier: ORSAKSKATEGORIER,
underlagskallor: UNDERLAGSKALLOR,
};
const REGELPAKET_NYCKEL = "gf-ecm-regelpaket";
// Validerar och normaliserar ett paket från servern/cachen — trasiga
// eller ofullständiga paket faller tillbaka till standard per fält.
export function normaliseraRegelpaket(data: unknown): RegelPaket {
const d = (data ?? {}) as Partial<RegelPaket>;
const lista = (varden: unknown, standard: string[]) =>
Array.isArray(varden) && varden.length > 0 && varden.every((v) => typeof v === "string")
? (varden as string[])
: standard;
const regler: RegelPaket["arendetypRegler"] = {};
if (d.arendetypRegler && typeof d.arendetypRegler === "object") {
for (const [typ, rader] of Object.entries(d.arendetypRegler)) {
if (!Array.isArray(rader)) continue;
const giltiga = rader.filter(
(r): r is ComplianceRegel =>
!!r && typeof r.id === "string" && typeof r.rubrik === "string" &&
typeof r.detaljVidBrist === "string" && typeof r.krav === "string" && r.krav in KRAV_KONTROLLER,
);
if (giltiga.length > 0) regler[typ] = giltiga;
}
}
return {
version: typeof d.version === "string" && d.version ? d.version : STANDARD_REGELPAKET.version,
arendetypRegler: Object.keys(regler).length > 0 ? regler : STANDARD_REGELPAKET.arendetypRegler,
undantagsorsaker: lista(d.undantagsorsaker, STANDARD_REGELPAKET.undantagsorsaker),
orsakskategorier: lista(d.orsakskategorier, STANDARD_REGELPAKET.orsakskategorier),
underlagskallor: lista(d.underlagskallor, STANDARD_REGELPAKET.underlagskallor),
};
}
export function aktivtRegelpaket(): RegelPaket {
try {
const rad = localStorage.getItem(REGELPAKET_NYCKEL);
return rad ? normaliseraRegelpaket(JSON.parse(rad)) : STANDARD_REGELPAKET;
} catch {
return STANDARD_REGELPAKET;
}
}
// Hämtar aktuellt regelpaket från plattformen (inloggat läge) och cachar
// det. Anropas vid sidladdning — misslyckas hämtningen gäller cachen
// eller standardpaketet.
export async function hamtaRegelpaket(): Promise<RegelPaket> {
try {
const { plattformAktiv, plattformToken, plattformFetch } = await import("./plattform");
if (plattformAktiv() && plattformToken()) {
const res = await plattformFetch("/api/ecm/regler");
if (res.ok) {
const paket = normaliseraRegelpaket(await res.json());
localStorage.setItem(REGELPAKET_NYCKEL, JSON.stringify(paket));
return paket;
}
}
} catch {
// Nätverksfel: cache/standard gäller.
}
return aktivtRegelpaket();
}
// ---- 4. Validation Engine --------------------------------------------- // ---- 4. Validation Engine ---------------------------------------------
// Kärnprincipen — inga påståenden utan underlag — verkar i tre lager: // Kärnprincipen — inga påståenden utan underlag — verkar i tre lager:
@@ -482,16 +570,18 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
detalj: fotoKravUtanFoto > 0 ? `${fotoKravUtanFoto} fotokrävande kontroll(er) utan bild i loggen.` : undefined, detalj: fotoKravUtanFoto > 0 ? `${fotoKravUtanFoto} fotokrävande kontroll(er) utan bild i loggen.` : undefined,
}); });
// Compliance Engine: ärendetypens regelpaket. // Compliance Engine: ärendetypens regler ur det aktiva regelpaketet
// (serverdistribuerat via ECM Knowledge Library, standard som fallback).
const paket = aktivtRegelpaket();
const typ = arendetyp(arende); const typ = arendetyp(arende);
for (const krav of COMPLIANCE_REGLER[typ] ?? []) { for (const regel of paket.arendetypRegler[typ] ?? []) {
const ok = krav.uppfyllt(arende); const ok = KRAV_KONTROLLER[regel.krav](arende);
rader.push({ rader.push({
id: krav.id, id: regel.id,
rubrik: `${typ}: ${krav.rubrik}`, rubrik: `${typ}: ${regel.rubrik}`,
ok, ok,
kravs: true, kravs: true,
detalj: ok ? undefined : krav.detaljVidBrist, detalj: ok ? undefined : regel.detaljVidBrist,
}); });
} }
@@ -530,6 +620,7 @@ export function grindGodkand(arende: Arende, metodik: Metodik): boolean {
export function sparbarhetspaket(arende: Arende, metodik: Metodik) { export function sparbarhetspaket(arende: Arende, metodik: Metodik) {
return { return {
ecmVersion: ECM_VERSION, ecmVersion: ECM_VERSION,
regelpaketVersion: aktivtRegelpaket().version,
arendetyp: arendetyp(arende), arendetyp: arendetyp(arende),
evidensniva: evidensNiva(arende), evidensniva: evidensNiva(arende),
kvalitetsgrind: kvalitetsgrind(arende, metodik).map(({ id, rubrik, ok, kravs }) => ({ id, rubrik, ok, kravs })), kvalitetsgrind: kvalitetsgrind(arende, metodik).map(({ id, rubrik, ok, kravs }) => ({ id, rubrik, ok, kravs })),
+9 -7
View File
@@ -42,14 +42,13 @@ import {
MARKOR_FELBESKRIVNING_VERIFIERAD, MARKOR_FELBESKRIVNING_VERIFIERAD,
MARKOR_INGA_TIDIGA_OBSERVATIONER, MARKOR_INGA_TIDIGA_OBSERVATIONER,
MARKOR_TIDIGA_OBSERVATIONER_KLARA, MARKOR_TIDIGA_OBSERVATIONER_KLARA,
ORSAKSKATEGORIER, aktivtRegelpaket,
UNDANTAGSORSAKER,
UNDERLAGSKALLOR,
arendetyp, arendetyp,
felorsaker, felorsaker,
granskaAvvikelse, granskaAvvikelse,
grindGodkand, grindGodkand,
kvalitetsgrind, kvalitetsgrind,
hamtaRegelpaket,
preDiagnostik, preDiagnostik,
reproducering, reproducering,
reproduceringsText, reproduceringsText,
@@ -115,6 +114,9 @@ export default function ArendeSida() {
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNu(new Date().toISOString()), 30000); const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
// ECM Knowledge Library: färskt regelpaket från plattformen (cache/
// standardpaket gäller offline).
hamtaRegelpaket();
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
@@ -551,7 +553,7 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
/> />
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">2. Mest sannolik orsak (en eller flera)</p> <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">2. Mest sannolik orsak (en eller flera)</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-3"> <div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-3">
{ORSAKSKATEGORIER.map((o) => ( {aktivtRegelpaket().orsakskategorier.map((o) => (
<button <button
key={o} key={o}
onClick={() => vaxla(orsaker, setOrsaker, o)} onClick={() => vaxla(orsaker, setOrsaker, o)}
@@ -565,7 +567,7 @@ function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handel
</div> </div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">3. Underlag som stöder bedömningen</p> <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">3. Underlag som stöder bedömningen</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-4"> <div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-4">
{UNDERLAGSKALLOR.map((k) => ( {aktivtRegelpaket().underlagskallor.map((k) => (
<button <button
key={k} key={k}
onClick={() => vaxla(underlag, setUnderlag, k)} onClick={() => vaxla(underlag, setUnderlag, k)}
@@ -1103,7 +1105,7 @@ function Undantag({ vidUndantag }: { vidUndantag: (orsak: string) => void }) {
Kontrollen dokumenteras utan underlag ange orsak (obligatoriskt). Detta flaggas i brief och rapport. Kontrollen dokumenteras utan underlag ange orsak (obligatoriskt). Detta flaggas i brief och rapport.
</p> </p>
<div className="mb-2 grid grid-cols-1 gap-1"> <div className="mb-2 grid grid-cols-1 gap-1">
{UNDANTAGSORSAKER.map((val) => ( {aktivtRegelpaket().undantagsorsaker.map((val) => (
<button <button
key={val} key={val}
onClick={() => setOrsak(val)} onClick={() => setOrsak(val)}
@@ -1787,7 +1789,7 @@ function RapportFlik({
</Panel> </Panel>
<Panel rubrik="Fordonsinformation"> <Panel rubrik="Fordonsinformation">
{identitetsRader([ {identitetsRader([
["Fordon", idn.beskrivning], ["Personbil", idn.beskrivning],
["Regnr", idn.identifierare], ["Regnr", idn.identifierare],
["VIN", idn.vin], ["VIN", idn.vin],
["Mätarställning in", matIn], ["Mätarställning in", matIn],
+1 -1
View File
@@ -295,7 +295,7 @@ function GranskaTolkning({
const obekraftade = rader.filter((r) => !r.bekraftad); const obekraftade = rader.filter((r) => !r.bekraftad);
const vald = rader.find((r) => r.id === markerad); const vald = rader.find((r) => r.id === markerad);
const grupper: ArbetsorderGrupp[] = ["Kund", "Fordon", "Verkstad", "Ärende"]; const grupper: ArbetsorderGrupp[] = ["Kund", "Personbil", "Verkstad", "Ärende"];
const uppdatera = (id: string, andring: Partial<(typeof rader)[number]>) => const uppdatera = (id: string, andring: Partial<(typeof rader)[number]>) =>
setRader((r) => r.map((rad) => (rad.id === id ? { ...rad, ...andring } : rad))); setRader((r) => r.map((rad) => (rad.id === id ? { ...rad, ...andring } : rad)));