From a94c228cabccc36dc765c555d2c34542604466c2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 6 Aug 2026 14:56:48 +0000
Subject: [PATCH] =?UTF-8?q?Garantiregimer,=20f=C3=B6rs=C3=A4kringsvillkor?=
=?UTF-8?q?=20och=20felanm=C3=A4lan=20i=20=C3=A4rendet?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---- Garantier (ALVA-SPEC-040) -----------------------------------------
Skiljelinjen mellan LAGSTADGAT och AVTALAT bär allt annat, och den
förväxlas dagligen åt bägge håll. Överraskande lite av det verkstaden
kallar garanti är lagstadgat:
nybilsgaranti avtalad. Ingen lag i EU eller USA kräver den.
rostskyddsgaranti avtalad. Ingen lag kräver den, någonstans.
lackgaranti avtalad.
drivlinegaranti avtalad.
avgasgaranti LAGSTADGAD i bägge, och den enda som är det.
Att kalla rostskyddet lagstadgat är fel; att kalla avgasgarantin en
tillverkarutfästelse är fel åt andra hållet, och det andra felet kostar
kunden pengar — en katalysator på en sexårig bil offereras rutinmässigt
som en betald reparation när den inte är det.
Varje lagstadgad post bär källa och kontrolldatum: 2019/771 (2 år,
säljaren — inte tillverkaren), MVBER 461/2010 förlängd till 2028,
715/2007 (5 år/100 000 km), 2024/1257 (8 år/160 000 km, batteri 80 %
vid 5 år och 72 % vid 8), CAA § 207 (2/24 000 och 8/80 000 miles),
Magnuson–Moss § 2302(c), CARB (3/50 000 och 7/70 000), ACC II (70 % SoH
vid 8 år/100 000 miles).
Bedömningen skiljer INOM, UTANFÖR och OKLART. Saknas underlag blir det
oklart, aldrig utanför — att påstå att en garanti gått ut när underlaget
inte räcker är det enda felet här som kostar kunden pengar.
---- Försäkring (ALVA-SPEC-041) ----------------------------------------
Modulen avgör ingenting och kan inte avgöra något. Vad som gäller
bestäms av kundens eget försäkringsbrev och villkoren vid tecknandet —
inte av vad ett bolag skriver i dag och inte av vad vi noterat. Testerna
låser att resultatet inte innehåller något fält som liknar ett utfall,
och att ett fordon utanför samtliga noterade gränser ändå inte får ett
nej: registret kan vara ofullständigt och produkten en annan.
Det som står överst är de sju villkoren som avgör en maskinskadefråga.
Ålder och sträcka är två av dem; de fem andra avgör oftare. Registret
med If, Folksam, Trygg-Hansa, Svedea, WaterCircles och Hedvig ligger
under, med källa och avläsningsdatum per post, och märks som föråldrat
efter 180 dagar i stället för att se aktuellt ut.
---- Felanmälan i ärendet (ALVA-PROC-0050) -----------------------------
En anmälningsknapp som bara finns i en portalvy nås aldrig av den som
står med händerna i en bil. Den ligger därför i varje ärende, och tar
med sitt sammanhang självt: ärende, metodik, plattformsversion och
spår-id. Teknikern minns inte plattformsversionen, och att fråga efter
den är att lägga över vårt problem på verkstaden.
Sammanhanget HÄRLEDS och kan inte smugglas: ett regnr i anropet släpps
inte igenom. En supportanmälan är inte ett skäl att flytta
personuppgifter till ett annat system. Bevisat i integrationstestet.
Anmälan är oföränderlig och statusen en projektion av inläggen — samma
skäl som för fakturan: en anmälan vars historia kan skrivas om är inte
ett underlag när någon frågar hur länge felet var känt. Verkstaden
svarar i sitt eget ärende; status sätts av supporten med egen nyckel.
---- Historikkontrollen -------------------------------------------------
Kravet fanns redan: klienten öppnar inte metodiken förrän
pre-diagnostiken är besvarad, och ett nej kräver skriven motivering.
Det var dock aldrig BEVISAT att spärren håller. Genomgången prövar nu
att metodiken inte går att nå medan historikfrågan står obesvarad — i
det enda ögonblick det går att pröva, eftersom svaret därefter redan är
avgivet.
429 enhetstester · 157 integrationskontroller mot riktig Postgres ·
genomgången 4/4 · portalspärren 16/16.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
---
felsokning/app/e2e/genomgang.mjs | 10 +
felsokning/app/e2e/portalsparr.mjs | 3 +
felsokning/app/src/App.tsx | 6 +
felsokning/app/src/alva/komponenter.tsx | 47 +++
.../felsokning/__tests__/forsakring.test.ts | 143 +++++++
.../felsokning/__tests__/garantier.test.ts | 166 ++++++++
.../src/felsokning/__tests__/support.test.ts | 126 ++++++
felsokning/app/src/felsokning/plattform.ts | 43 ++
felsokning/app/src/pages/alva/Forsakring.tsx | 189 +++++++++
felsokning/app/src/pages/alva/Garantier.tsx | 164 ++++++++
felsokning/app/src/pages/alva/Ram.tsx | 3 +
felsokning/app/src/pages/alva/Support.tsx | 157 +++++++
.../app/src/pages/felsokning/ArendeSida.tsx | 106 +++++
felsokning/infra/postgres-init.sql | 48 +++
felsokning/services/gemensam/forsakring.mjs | 227 ++++++++++
felsokning/services/gemensam/garantier.mjs | 396 ++++++++++++++++++
felsokning/services/gemensam/support.mjs | 108 +++++
felsokning/services/plattform/forsakring.mjs | 1 +
felsokning/services/plattform/garantier.mjs | 1 +
.../services/plattform/integrationstest.sh | 58 +++
felsokning/services/plattform/server.mjs | 162 ++++++-
felsokning/services/plattform/support.mjs | 1 +
22 files changed, 2160 insertions(+), 5 deletions(-)
create mode 100644 felsokning/app/src/felsokning/__tests__/forsakring.test.ts
create mode 100644 felsokning/app/src/felsokning/__tests__/garantier.test.ts
create mode 100644 felsokning/app/src/felsokning/__tests__/support.test.ts
create mode 100644 felsokning/app/src/pages/alva/Forsakring.tsx
create mode 100644 felsokning/app/src/pages/alva/Garantier.tsx
create mode 100644 felsokning/app/src/pages/alva/Support.tsx
create mode 100644 felsokning/services/gemensam/forsakring.mjs
create mode 100644 felsokning/services/gemensam/garantier.mjs
create mode 100644 felsokning/services/gemensam/support.mjs
create mode 120000 felsokning/services/plattform/forsakring.mjs
create mode 120000 felsokning/services/plattform/garantier.mjs
create mode 120000 felsokning/services/plattform/support.mjs
diff --git a/felsokning/app/e2e/genomgang.mjs b/felsokning/app/e2e/genomgang.mjs
index 82291eb..3370afc 100644
--- a/felsokning/app/e2e/genomgang.mjs
+++ b/felsokning/app/e2e/genomgang.mjs
@@ -200,6 +200,13 @@ async function kor(sida, fall) {
if (await finns(pre)) {
const ja = btn(/Ja — kontrollerad/i, pre);
if (await finns(ja)) {
+ // Historikkontrollen är en SPÄRR, inte en påminnelse: metodiken
+ // får inte gå att nå förbi den. Det prövas här, i det ögonblick
+ // frågan står obesvarad på skärmen — senare går det inte att
+ // pröva, eftersom svaret då redan är avgivet.
+ if (await finns(panel(/Metodik|Steg \d/i))) {
+ throw new Error("metodiken var nåbar innan fordonets historik besvarats");
+ }
await klicka(ja);
await klicka(btn(/^Dokumentera$/i, pre));
continue;
@@ -474,6 +481,9 @@ try {
"/alva/portal/kunskapskallor",
"/alva/portal/integration",
"/alva/portal/fakturor",
+ "/alva/portal/garantier",
+ "/alva/portal/forsakring",
+ "/alva/portal/support",
]) {
await sida.goto(`${BAS}/#${vag}`, { waitUntil: "networkidle" });
await sida.waitForTimeout(400);
diff --git a/felsokning/app/e2e/portalsparr.mjs b/felsokning/app/e2e/portalsparr.mjs
index 32e0240..46eca1f 100644
--- a/felsokning/app/e2e/portalsparr.mjs
+++ b/felsokning/app/e2e/portalsparr.mjs
@@ -35,6 +35,9 @@ const VYER = [
"/alva/portal/kunskapskallor",
"/alva/portal/integration",
"/alva/portal/fakturor",
+ "/alva/portal/garantier",
+ "/alva/portal/forsakring",
+ "/alva/portal/support",
];
// Nycklarna är plattformsklientens egna (src/felsokning/plattform.ts).
diff --git a/felsokning/app/src/App.tsx b/felsokning/app/src/App.tsx
index e3d12c7..9145cb6 100644
--- a/felsokning/app/src/App.tsx
+++ b/felsokning/app/src/App.tsx
@@ -29,6 +29,9 @@ import AlvaKunskapskallor from "./pages/alva/Kunskapskallor";
import AlvaAnalys from "./pages/alva/Analys";
import AlvaFakturor from "./pages/alva/Fakturor";
import AlvaIntegration from "./pages/alva/Integration";
+import AlvaGarantier from "./pages/alva/Garantier";
+import AlvaForsakring from "./pages/alva/Forsakring";
+import AlvaSupport from "./pages/alva/Support";
import { Portalvakt } from "./pages/alva/Portalvakt";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
@@ -74,6 +77,9 @@ function AppContent() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/felsokning/app/src/alva/komponenter.tsx b/felsokning/app/src/alva/komponenter.tsx
index 85e2f0d..3cfc8ce 100644
--- a/felsokning/app/src/alva/komponenter.tsx
+++ b/felsokning/app/src/alva/komponenter.tsx
@@ -361,6 +361,53 @@ export function Knapp({
* håller markeringen fri från tillstånd och fungerar innan skriptet
* kört.
*/
+/**
+ * Textfält.
+ *
+ * Fanns inte förrän nu: inloggningen kodade sitt eget, och nästa vy som
+ * behövde ett hade blivit det tredje stället där samma ruta ritas för
+ * hand. Tre gånger är där ett designsystem tappas — inte i det stora
+ * beslutet utan i den lilla upprepningen.
+ */
+export function Textfalt({
+ label,
+ varde,
+ andra,
+ platshallare,
+ typ = "text",
+ komplettering,
+}: {
+ label: string;
+ varde: string;
+ andra: (v: string) => void;
+ platshallare?: string;
+ typ?: "text" | "email" | "password";
+ komplettering?: string;
+}) {
+ const id = `f-${label.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
+ return (
+
+ );
+}
+
export function Tabell({ kolumner, rader }: { kolumner: string[]; rader: ReactNode[][] }) {
return (
<>
diff --git a/felsokning/app/src/felsokning/__tests__/forsakring.test.ts b/felsokning/app/src/felsokning/__tests__/forsakring.test.ts
new file mode 100644
index 0000000..6b0ec7e
--- /dev/null
+++ b/felsokning/app/src/felsokning/__tests__/forsakring.test.ts
@@ -0,0 +1,143 @@
+// @vitest-environment node
+// ALVA-SPEC-041 · Försäkringsregistret.
+//
+// Ett enda krav bär hela modulen: den får aldrig uttala sig som om den
+// avgjorde frågan. Testerna nedan låser den egenskapen från flera håll,
+// därför att den kan gå förlorad på flera sätt — ett fält som heter
+// `tackt`, ett tomt `utanfor` som läses som ett nej, en föråldrad post
+// som ser aktuell ut.
+import { describe, expect, it } from "vitest";
+import {
+ AVGORANDE_VILLKOR,
+ BOLAG,
+ FORBEHALL,
+ GILTIGHETSTID_DAGAR,
+ bedom,
+ foraldrad,
+ spann,
+} from "../../../../services/gemensam/forsakring.mjs";
+
+describe("modulen avgör ingenting", () => {
+ it("resultatet innehåller inget fält som liknar ett utfall", () => {
+ const r = bedom({ alderAr: 3, mil: 4000 });
+ for (const förbjudet of ["tackt", "täckt", "covered", "utfall", "beslut", "godkand", "avslag"]) {
+ expect(Object.keys(r), förbjudet).not.toContain(förbjudet);
+ }
+ });
+
+ it("varje svar pekar vidare till kundens eget försäkringsbrev", () => {
+ expect(bedom({ alderAr: 1, mil: 100 }).nastaSteg).toMatch(/own policy/i);
+ expect(bedom({}).nastaSteg).toMatch(/own policy/i);
+ });
+
+ it("ett fordon utanför samtliga noterade gränser får ändå inget nej", () => {
+ // Registret kan vara ofullständigt, produkten en annan, och villkoren
+ // vid tecknandet andra. Att svara nej här vore att påstå mer än vi vet.
+ const r = bedom({ alderAr: 30, mil: 40_000 });
+ expect(r.inom).toEqual([]);
+ expect(r.utanfor.length).toBeGreaterThan(0);
+ expect(r.kvarstaende.length).toBeGreaterThan(0);
+ expect(r.nastaSteg).toBeTruthy();
+ });
+
+ it("förbehållet säger uttryckligen att ALVA inte avgör", () => {
+ expect(FORBEHALL).toMatch(/does not decide/i);
+ expect(FORBEHALL).toMatch(/the insurer decides/i);
+ });
+});
+
+describe("frågorna väger tyngre än gränserna", () => {
+ it("ålder och sträcka är två av sju villkor", () => {
+ expect(AVGORANDE_VILLKOR.length).toBe(7);
+ expect(AVGORANDE_VILLKOR.map((v) => v.id)).toContain("plotsligt");
+ expect(AVGORANDE_VILLKOR.map((v) => v.id)).toContain("underhall");
+ });
+
+ it("de fem som inte är ålder och sträcka återstår alltid", () => {
+ // Även för ett fordon som ligger välinom gränserna. Slitage och
+ // eftersatt underhåll avgör oftare än åldern gör.
+ const r = bedom({ alderAr: 1, mil: 500 });
+ expect(r.kvarstaende.map((v) => v.id)).toEqual(["plotsligt", "komponent", "underhall", "orsak", "sjalvrisk"]);
+ });
+
+ it("varje villkor bär en fråga, inte bara en rubrik", () => {
+ for (const v of AVGORANDE_VILLKOR) {
+ expect(v.fraga, v.id).toMatch(/\?$/);
+ expect(v.text, v.id).toBeTruthy();
+ }
+ });
+});
+
+describe("registret åldras synligt", () => {
+ it("varje bolag har källa och avläsningsdatum", () => {
+ for (const b of BOLAG) {
+ expect(b.kalla, b.namn).toMatch(/^https:\/\//);
+ expect(b.kontrollerad, b.namn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(b.produkter.length, b.namn).toBeGreaterThan(0);
+ }
+ });
+
+ it("en gammal post märks som föråldrad i stället för att tigas ihjäl", () => {
+ const gammal = { kontrollerad: "2020-01-01" };
+ expect(foraldrad(gammal)).toBe(true);
+ expect(foraldrad({ kontrollerad: "2026-08-01" }, new Date("2026-08-06"))).toBe(false);
+ // Precis över gränsen.
+ const nu = new Date("2026-08-06");
+ const strax = new Date(nu.getTime() - (GILTIGHETSTID_DAGAR + 1) * 86_400_000);
+ expect(foraldrad({ kontrollerad: strax.toISOString().slice(0, 10) }, nu)).toBe(true);
+ });
+
+ it("ett trasigt datum räknas som föråldrat, inte som giltigt", () => {
+ expect(foraldrad({ kontrollerad: "" })).toBe(true);
+ expect(foraldrad({ kontrollerad: "i somras" })).toBe(true);
+ });
+
+ it("föråldringen följer med i svaret", () => {
+ const gammalt = [{ ...BOLAG[0], kontrollerad: "2020-01-01" }];
+ const r = bedom({ alderAr: 1, mil: 100 }, gammalt);
+ expect(r.inom.every((p) => p.foraldrad)).toBe(true);
+ });
+});
+
+describe("gränserna läses som alternativa tak", () => {
+ it("ett överskridet mått räcker för att hamna utanför", () => {
+ // If: 10 år / 12 000 mil. Ett år gammal men 14 000 mil är utanför den
+ // produkten trots att åldern håller.
+ const r = bedom({ alderAr: 1, mil: 14_000, bolagId: "if" });
+ expect(r.utanfor.some((p) => p.produkt === "Maskinskadeförsäkring")).toBe(true);
+ // Men den större produkten går till 15 000 mil.
+ expect(r.inom.some((p) => p.produkt === "Stor bilförsäkring")).toBe(true);
+ });
+
+ it("halva underlaget ger oklart, inte inom", () => {
+ const r = bedom({ alderAr: 2, bolagId: "svedea" });
+ expect(r.inom).toEqual([]);
+ expect(r.oklart.length).toBeGreaterThan(0);
+ });
+
+ it("utan uppgifter alls är allting oklart", () => {
+ const r = bedom({});
+ expect(r.inom).toEqual([]);
+ expect(r.utanfor).toEqual([]);
+ expect(r.oklart.length).toBeGreaterThan(0);
+ });
+
+ it("samma bolag kan ha olika gränser för olika produkter", () => {
+ // Poängen med att inte slå ihop bolaget till en siffra.
+ const trygg = BOLAG.find((b) => b.id === "trygghansa");
+ const gränser = new Set(trygg.produkter.map((p) => `${p.maxAlderAr}/${p.maxMil}`));
+ expect(gränser.size).toBeGreaterThan(1);
+ });
+});
+
+describe("spannet beskriver marknaden utan att peka ut ett bolag", () => {
+ it("ger minsta och största noterade gräns", () => {
+ const s = spann();
+ expect(s.alderAr.minsta).toBeLessThan(s.alderAr.storsta);
+ expect(s.mil.minsta).toBeLessThan(s.mil.storsta);
+ });
+
+ it("ett tomt register ger null i stället för ett påhittat spann", () => {
+ expect(spann([])).toBeNull();
+ });
+});
diff --git a/felsokning/app/src/felsokning/__tests__/garantier.test.ts b/felsokning/app/src/felsokning/__tests__/garantier.test.ts
new file mode 100644
index 0000000..d3c2b17
--- /dev/null
+++ b/felsokning/app/src/felsokning/__tests__/garantier.test.ts
@@ -0,0 +1,166 @@
+// @vitest-environment node
+// ALVA-SPEC-040 · Garantiregimerna.
+//
+// Testerna handlar mindre om att siffrorna är rätt — det avgörs av
+// källorna, inte av en assertion — än om två egenskaper som avgör om
+// modulen är farlig att lita på:
+//
+// 1. Att varje lagstadgad regel bär en källa. En regel utan källa är ett
+// påstående, och ett påstående om lag är värre än inget påstående.
+// 2. Att bedömningen aldrig säger "utanför garanti" på ett underlag som
+// inte räcker. Det är det enda felet i den här modulen som kostar
+// kunden pengar.
+import { describe, expect, it } from "vitest";
+import {
+ FORBEHALL,
+ JURISDIKTIONER,
+ KONTROLLERAD,
+ REGIMER,
+ mojligenGallande,
+ regimerFor,
+ skyddadReparation,
+} from "../../../../services/gemensam/garantier.mjs";
+
+describe("varje regel går att spåra till sin källa", () => {
+ it("alla lagstadgade regler har källa och länk", () => {
+ for (const r of REGIMER.filter((x) => x.grund === "lag")) {
+ expect(r.källa, r.id).toBeTruthy();
+ expect(r.källänk, r.id).toMatch(/^https:\/\//);
+ }
+ });
+
+ it("varje regel är antingen lag eller avtal, aldrig något oklart däremellan", () => {
+ for (const r of REGIMER) expect(["lag", "avtal"], r.id).toContain(r.grund);
+ });
+
+ it("kontrolldatumet finns och står i förbehållet", () => {
+ expect(KONTROLLERAD).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(FORBEHALL).toContain(KONTROLLERAD);
+ expect(FORBEHALL).toMatch(/not legal advice/i);
+ });
+});
+
+describe("skiljelinjen mellan lagstadgat och avtalat hålls", () => {
+ // Den här förväxlingen är modulens hela existensberättigande. Skulle
+ // rosten någon gång märkas som lagstadgad vore modulen värre än ingen.
+ it("rostskydd, lack och nybil är AVTALADE — inget av dem har lagstöd", () => {
+ for (const id of ["AVTAL-ROST", "AVTAL-LACK", "AVTAL-NYBIL", "AVTAL-DRIVLINA"]) {
+ expect(REGIMER.find((r) => r.id === id).grund, id).toBe("avtal");
+ }
+ });
+
+ it("avgasgarantin är lagstadgad i bägge jurisdiktionerna", () => {
+ expect(REGIMER.find((r) => r.id === "US-AVGAS-PRESTANDA").grund).toBe("lag");
+ expect(REGIMER.find((r) => r.id === "US-AVGAS-HUVUDDELAR").grund).toBe("lag");
+ expect(REGIMER.find((r) => r.id === "EU-AVGAS-EURO6").grund).toBe("lag");
+ });
+
+ it("avtalade regimer utger sig inte för att ha en lagkälla", () => {
+ for (const r of REGIMER.filter((x) => x.grund === "avtal")) {
+ expect(r.källänk, r.id).toBeNull();
+ expect(r.källa, r.id).toMatch(/manufacturer/i);
+ }
+ });
+
+ it("konsumentköpet pekar på säljaren, inte tillverkaren", () => {
+ // Det vanligaste felet i verkstadsledet: kunden skickas till fel part.
+ const r = REGIMER.find((x) => x.id === "EU-KONFORMITET");
+ expect(r.bärare).toBe("säljaren");
+ expect(r.anmärkning).toMatch(/SELLER, not the manufacturer/);
+ });
+});
+
+describe("jurisdiktionen avgör vad som visas", () => {
+ it("Kalifornien ärver de federala reglerna", () => {
+ const ids = regimerFor("US-CA").map((r) => r.id);
+ // Magnuson–Moss är federal och måste följa med — den är den regel
+ // verkstaden behöver mest, och att tappa den vore tyst skada.
+ expect(ids).toContain("US-MMWA");
+ expect(ids).toContain("US-CA-AVGAS");
+ });
+
+ it("EU visar inte amerikanska regler och tvärtom", () => {
+ expect(regimerFor("EU").map((r) => r.id)).not.toContain("US-MMWA");
+ expect(regimerFor("US").map((r) => r.id)).not.toContain("EU-MVBER");
+ });
+
+ it("avtalade kategorier visas i bägge", () => {
+ for (const j of ["EU", "US"]) {
+ expect(regimerFor(j).map((r) => r.id)).toContain("AVTAL-ROST");
+ }
+ });
+
+ it("varje jurisdiktion i listan ger minst en regim", () => {
+ for (const j of JURISDIKTIONER) expect(regimerFor(j.id).length, j.id).toBeGreaterThan(0);
+ });
+});
+
+describe("skyddet för oberoende reparation lyfts fram", () => {
+ it("EU pekar på MVBER och USA på Magnuson–Moss", () => {
+ expect(skyddadReparation("EU").id).toBe("EU-MVBER");
+ expect(skyddadReparation("US").id).toBe("US-MMWA");
+ expect(skyddadReparation("US-CA").id).toBe("US-MMWA");
+ });
+
+ it("bägge säger att arbete här inte upphäver garantin", () => {
+ expect(skyddadReparation("EU").text).toMatch(/independent workshop/i);
+ expect(skyddadReparation("US").text).toMatch(/may not condition/i);
+ });
+});
+
+describe("bedömningen påstår aldrig mer än underlaget bär", () => {
+ it("ett nytt fordon ligger inom de federala avgasreglerna", () => {
+ const { inom } = mojligenGallande("US", { alderAr: 1, miles: 12_000 });
+ expect(inom.map((r) => r.id)).toContain("US-AVGAS-PRESTANDA");
+ expect(inom.map((r) => r.id)).toContain("US-AVGAS-HUVUDDELAR");
+ });
+
+ it("perioden är 'whichever comes first' — ett överskridet mått räcker", () => {
+ // Två år gammal men 40 000 miles: prestandagarantin är passerad på
+ // sträckan trots att åldern håller.
+ const { utanfor } = mojligenGallande("US", { alderAr: 2, miles: 40_000 });
+ expect(utanfor.map((r) => r.id)).toContain("US-AVGAS-PRESTANDA");
+ });
+
+ it("okänd ålder ger OKLART, aldrig utanför", () => {
+ // Det enda felet här som kostar kunden pengar är att påstå att en
+ // garanti gått ut när underlaget inte räcker.
+ const { utanfor, oklart } = mojligenGallande("US", {});
+ expect(utanfor).toEqual([]);
+ expect(oklart.map((r) => r.id)).toContain("US-AVGAS-HUVUDDELAR");
+ });
+
+ it("bara mätarställning utan ålder räcker inte för att avfärda", () => {
+ const { utanfor, oklart } = mojligenGallande("US", { miles: 10_000 });
+ expect(utanfor).toEqual([]);
+ expect(oklart.length).toBeGreaterThan(0);
+ });
+
+ it("men en passerad sträcka avfärdar även utan ålder", () => {
+ // Här RÄCKER underlaget: 200 000 miles är över varje federal gräns,
+ // oavsett hur gammalt fordonet är.
+ const { utanfor } = mojligenGallande("US", { miles: 200_000 });
+ expect(utanfor.map((r) => r.id)).toContain("US-AVGAS-HUVUDDELAR");
+ });
+
+ it("kilometer och miles räknas om åt bägge håll", () => {
+ // 100 000 miles är ~160 934 km, alltså över Euro 7:s 160 000 km.
+ const { utanfor } = mojligenGallande("EU", { alderAr: 2, miles: 100_000 });
+ expect(utanfor.map((r) => r.id)).toContain("EU-AVGAS-EURO7");
+ // Och 50 000 km är under.
+ const { inom } = mojligenGallande("EU", { alderAr: 2, km: 50_000 });
+ expect(inom.map((r) => r.id)).toContain("EU-AVGAS-EURO7");
+ });
+
+ it("regler utan tidsgräns gäller alltid, även utan uppgifter om fordonet", () => {
+ expect(mojligenGallande("EU", {}).inom.map((r) => r.id)).toContain("EU-MVBER");
+ expect(mojligenGallande("US", {}).inom.map((r) => r.id)).toContain("US-MMWA");
+ });
+
+ it("avtalade regimer bedöms inte alls — deras villkor känner vi inte", () => {
+ const allt = mojligenGallande("EU", { alderAr: 1, km: 1000 });
+ const ids = [...allt.inom, ...allt.utanfor, ...allt.oklart].map((r) => r.id);
+ expect(ids).not.toContain("AVTAL-ROST");
+ expect(ids).not.toContain("AVTAL-NYBIL");
+ });
+});
diff --git a/felsokning/app/src/felsokning/__tests__/support.test.ts b/felsokning/app/src/felsokning/__tests__/support.test.ts
new file mode 100644
index 0000000..32cba6b
--- /dev/null
+++ b/felsokning/app/src/felsokning/__tests__/support.test.ts
@@ -0,0 +1,126 @@
+// @vitest-environment node
+// ALVA-PROC-0050 · Felanmälan.
+//
+// Två egenskaper prövas: att sammanhanget härleds och inte kan smugglas,
+// och att statusen är en projektion. Den första är en dataskyddsfråga —
+// en supportanmälan är inte ett skäl att flytta personuppgifter till ett
+// annat system, och det är precis vad ett fritt sammanhangsfält skulle
+// göra första gången någon klistrar in ett regnr "för tydlighetens skull".
+import { describe, expect, it } from "vitest";
+import {
+ FORBJUDET_I_SAMMANHANG,
+ STATUSAR,
+ TYPER,
+ granskaAnmalan,
+ sammanhang,
+ supportbeteckning,
+ supportstatus,
+} from "../../../../services/gemensam/support.mjs";
+
+describe("sammanhanget härleds och kan inte smugglas", () => {
+ it("bara kända fält följer med", () => {
+ const ut = sammanhang({
+ arendeId: "arende-1",
+ metodikId: "vibration",
+ steg: "4",
+ plattformsversion: "ALVA 1.0",
+ sparId: "abc",
+ });
+ expect(Object.keys(ut).sort()).toEqual(["arende", "metodik", "plattform", "spar", "steg"]);
+ });
+
+ it("identifierande fält släpps aldrig igenom", () => {
+ const smuggel = Object.fromEntries(FORBJUDET_I_SAMMANHANG.map((n: string) => [n, "ABC123"]));
+ const ut = sammanhang({ ...smuggel, arendeId: "arende-1" });
+ for (const n of FORBJUDET_I_SAMMANHANG) expect(Object.keys(ut), n).not.toContain(n);
+ // Och det som SKA följa med gör det fortfarande.
+ expect(ut.arende).toBe("arende-1");
+ });
+
+ it("tomma fält utelämnas i stället för att skickas som tomma strängar", () => {
+ expect(sammanhang({ arendeId: "", metodikId: null })).toEqual({});
+ expect(sammanhang()).toEqual({});
+ });
+
+ it("klientsträngen kapas — en user agent är inte en uppsats", () => {
+ expect(sammanhang({ klient: "x".repeat(500) }).klient.length).toBe(200);
+ });
+});
+
+describe("tröskeln för att anmäla är låg, men inte noll", () => {
+ it("en anmälan med rubrik och beskrivning tas emot", () => {
+ expect(
+ granskaAnmalan({
+ typ: "felanmalan",
+ rubrik: "Mätsteget vägrar komma",
+ beskrivning: "Kontrollen tar inte emot 2,4 men accepterar 2.4. Verkstaden skriver komma.",
+ }),
+ ).toBeNull();
+ });
+
+ it("okänd typ avvisas", () => {
+ expect(granskaAnmalan({ typ: "hittepa", rubrik: "Något", beskrivning: "x".repeat(30) })).toBeTruthy();
+ });
+
+ it("en tom beskrivning avvisas — den som svarar ser inte skärmen", () => {
+ const fel = granskaAnmalan({ typ: "felanmalan", rubrik: "Funkar inte", beskrivning: "" });
+ expect(fel).toMatch(/ser inte din skärm/);
+ });
+
+ it("orimligt långa fält avvisas", () => {
+ expect(granskaAnmalan({ typ: "fraga", rubrik: "x".repeat(300), beskrivning: "y".repeat(30) })).toBeTruthy();
+ expect(granskaAnmalan({ typ: "fraga", rubrik: "Rubrik", beskrivning: "y".repeat(30_000) })).toBeTruthy();
+ });
+
+ it("alla tre typerna går att anmäla", () => {
+ for (const t of TYPER as { id: string }[]) {
+ expect(granskaAnmalan({ typ: t.id, rubrik: "En rubrik", beskrivning: "x".repeat(30) }), t.id).toBeNull();
+ }
+ });
+});
+
+describe("statusen är en projektion, inte ett fält", () => {
+ it("en anmälan utan inlägg är mottagen — utan att något skrivits", () => {
+ expect(supportstatus([])).toBe("mottagen");
+ expect(supportstatus()).toBe("mottagen");
+ });
+
+ it("senaste statusposten gäller", () => {
+ expect(
+ supportstatus([
+ { typ: "status", status: "under_arbete" },
+ { typ: "svar", text: "Vi tittar." },
+ { typ: "status", status: "atgardad" },
+ ]),
+ ).toBe("atgardad");
+ });
+
+ it("en stängd anmälan kan öppnas igen", () => {
+ // Händer när felet visar sig inte vara borta.
+ expect(
+ supportstatus([
+ { typ: "status", status: "stangd" },
+ { typ: "status", status: "under_arbete" },
+ ]),
+ ).toBe("under_arbete");
+ });
+
+ it("okända statusvärden ignoreras i stället för att bli tillstånd", () => {
+ expect(supportstatus([{ typ: "status", status: "hittepa" }])).toBe("mottagen");
+ });
+
+ it("svar ensamma ändrar inte tillståndet", () => {
+ expect(supportstatus([{ typ: "svar", text: "Tack." }])).toBe("mottagen");
+ });
+
+ it("alla fyra tillstånd finns", () => {
+ expect(STATUSAR).toEqual(["mottagen", "under_arbete", "atgardad", "stangd"]);
+ });
+});
+
+describe("beteckningen följer nomenklaturen", () => {
+ it("ALVA-SUP-0001", () => {
+ expect(supportbeteckning(1)).toBe("ALVA-SUP-0001");
+ expect(supportbeteckning(1234)).toBe("ALVA-SUP-1234");
+ });
+});
diff --git a/felsokning/app/src/felsokning/plattform.ts b/felsokning/app/src/felsokning/plattform.ts
index 73342fe..daef20e 100644
--- a/felsokning/app/src/felsokning/plattform.ts
+++ b/felsokning/app/src/felsokning/plattform.ts
@@ -233,6 +233,49 @@ export async function hamtaFakturor(): Promise {
return ((await res.json()) as { fakturor: Faktura[] }).fakturor;
}
+// Supportärenden (ALVA-PROC-0050). Anmälan sker från ärendet där felet
+// uppstod; sammanhanget härleds av servern och skickas inte in.
+export interface Supportinlagg {
+ typ: "svar" | "status";
+ text: string;
+ status: string | null;
+ fran: string;
+ skapad: string;
+}
+
+export interface Supportarende {
+ id: string;
+ beteckning: string;
+ arende_id: string | null;
+ typ: string;
+ rubrik: string;
+ beskrivning: string;
+ sammanhang: Record;
+ status: string;
+ skapad: string;
+ anmald_av: string | null;
+ inlagg: Supportinlagg[];
+}
+
+export async function hamtaSupport(): Promise {
+ const res = await plattformFetch("/api/support");
+ if (!res.ok) throw new Error(`Fel ${res.status}`);
+ return ((await res.json()) as { arenden: Supportarende[] }).arenden;
+}
+
+export async function anmalSupport(anmalan: {
+ typ: string;
+ rubrik: string;
+ beskrivning: string;
+ arendeId?: string;
+ sammanhang?: Record;
+}): Promise<{ beteckning: string }> {
+ const res = await plattformFetch("/api/support", { method: "POST", body: JSON.stringify(anmalan) });
+ const data = (await res.json().catch(() => ({}))) as { beteckning?: string; error?: string };
+ if (!res.ok) throw new Error(data.error ?? `Fel ${res.status}`);
+ return { beteckning: data.beteckning ?? "" };
+}
+
// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
export interface LeverantorsFalt {
diff --git a/felsokning/app/src/pages/alva/Forsakring.tsx b/felsokning/app/src/pages/alva/Forsakring.tsx
new file mode 100644
index 0000000..78cc1b1
--- /dev/null
+++ b/felsokning/app/src/pages/alva/Forsakring.tsx
@@ -0,0 +1,189 @@
+// ALVA-SPEC-041 · Försäkringsvillkor.
+//
+// Vyn får aldrig se ut som ett besked. Den är byggd så att det som står
+// överst är FRÅGORNA — det som faktiskt avgör en maskinskadefråga — och
+// registret därunder, som det underlag det är.
+//
+// Ordningen är inte kosmetisk. Läggs tabellen först läses den som ett
+// svar, och de fem villkor som avgör oftare än åldern gör hamnar under
+// vikningen.
+
+import { useState } from "react";
+import {
+ AVGORANDE_VILLKOR,
+ BOLAG,
+ FORBEHALL,
+ bedom,
+ foraldrad,
+ spann,
+} from "../../../../services/gemensam/forsakring.mjs";
+import { Block, Etikett, FARG, Rubrik, Tabell, Textfalt } from "@/alva/komponenter";
+import { Ram } from "./Ram";
+
+interface Produkt {
+ namn: string;
+ maxAlderAr: number;
+ maxMil: number;
+}
+interface Bolag {
+ id: string;
+ namn: string;
+ marknad: string;
+ produkter: Produkt[];
+ kalla: string;
+ kontrollerad: string;
+}
+interface Villkor {
+ id: string;
+ rubrik: string;
+ fraga: string;
+ text: string;
+}
+
+export default function Forsakring() {
+ const [alder, setAlder] = useState("");
+ const [mil, setMil] = useState("");
+
+ const tal = (v: string) => (v.trim() === "" || Number.isNaN(Number(v)) ? undefined : Number(v));
+ const utfall = bedom({ alderAr: tal(alder), mil: tal(mil) }) as {
+ inom: { bolag: string; produkt: string; foraldrad: boolean }[];
+ utanfor: { bolag: string; produkt: string }[];
+ oklart: { bolag: string; produkt: string }[];
+ nastaSteg: string;
+ };
+ const s = spann() as { alderAr: { minsta: number; storsta: number }; mil: { minsta: number; storsta: number } };
+ const angivet = tal(alder) !== undefined || tal(mil) !== undefined;
+
+ return (
+
+
+ Reasoning aid
+
+ Insurance conditions
+
+
+ ALVA does not decide whether a claim is covered, and cannot. Cover follows the
+ customer’s own policy and the terms in force when it was taken out. What this page gives
+ you is the shape of the question and what the register recorded, so the conversation starts
+ from something better than a guess.
+
+
+
+
+ Age and distance are two of seven. The other five decide more claims than the two that get
+ asked about first.
+
+ Recorded limits range from {s.alderAr.minsta}–{s.alderAr.storsta} years and{" "}
+ {s.mil.minsta.toLocaleString("sv-SE")}–{s.mil.storsta.toLocaleString("sv-SE")} mil, depending
+ on insurer and product tier.
+
+ ) : (
+
+ {[
+ ["Within recorded limits", utfall.inom],
+ ["Beyond recorded limits", utfall.utanfor],
+ ["Cannot be placed on what you entered", utfall.oklart],
+ ].map(([rubrik, lista]) => {
+ const rader = lista as { bolag: string; produkt: string; foraldrad?: boolean }[];
+ return (
+
+ {rubrik as string}
+ {rader.length === 0 ? (
+
+ —
+
+ ) : (
+
+ {rader.map((r) => (
+
+ {r.bolag} · {r.produkt}
+ {r.foraldrad && (
+
+ register entry out of date
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ })}
+ {/* Står även när fordonet ligger utanför varje noterad
+ gräns. Registret kan vara ofullständigt, produkten en
+ annan, och villkoren vid tecknandet andra. */}
+
+ The register covers the insurers read so far and is not a market survey. Smaller insurers
+ and non-Swedish markets have to be added by the organization, with a source and a date —
+ an entry without those two is not usable as a starting point for anything.
+
+
+
+
+
+ {FORBEHALL}
+
+
+
+
+ );
+}
diff --git a/felsokning/app/src/pages/alva/Garantier.tsx b/felsokning/app/src/pages/alva/Garantier.tsx
new file mode 100644
index 0000000..d661b2b
--- /dev/null
+++ b/felsokning/app/src/pages/alva/Garantier.tsx
@@ -0,0 +1,164 @@
+// ALVA-SPEC-040 · Garantiregimer.
+//
+// Vyn har ett jobb och det är inte att vara fullständig: den ska göra
+// skillnaden mellan LAGSTADGAT och AVTALAT omöjlig att missa, och lyfta
+// de två regler som avgör om verkstaden får utföra arbetet alls.
+//
+// Ordningen är därför inte alfabetisk och inte kronologisk. Skyddet för
+// oberoende reparation står först, eftersom det är det som ifrågasätts —
+// ibland av kunden, som blivit tillsagd något annat.
+
+import { useState } from "react";
+import {
+ FORBEHALL,
+ JURISDIKTIONER,
+ KONTROLLERAD,
+ regimerFor,
+ skyddadReparation,
+} from "../../../../services/gemensam/garantier.mjs";
+import { Block, Etikett, FARG, Rubrik, Tabell } from "@/alva/komponenter";
+import { Ram } from "./Ram";
+
+interface Regim {
+ id: string;
+ jurisdiktion: string;
+ grund: "lag" | "avtal";
+ bärare: string;
+ rubrik: string;
+ kortform: string;
+ källa: string;
+ källänk: string | null;
+ text: string;
+ anmärkning: string;
+ avvikelser: { marknad: string; text: string }[];
+}
+
+const BARARE: Record = {
+ säljaren: "Seller",
+ tillverkaren: "Manufacturer",
+};
+
+export default function Garantier() {
+ const [jurisdiktion, setJurisdiktion] = useState("EU");
+ const regimer = regimerFor(jurisdiktion) as Regim[];
+ const lag = regimer.filter((r) => r.grund === "lag");
+ const avtal = regimer.filter((r) => r.grund === "avtal");
+ const skydd = skyddadReparation(jurisdiktion) as Regim | null;
+
+ return (
+
+
+ Regulatory
+
+ Warranty regimes
+
+
+ What the law requires as a floor, and what the manufacturer merely promises. The two are
+ confused daily on the shop floor, and the confusion runs in both directions — a corrosion
+ warranty treated as statutory, an emissions warranty treated as a favour.
+
+
+ {/* Jurisdiktionen först: allt nedanför beror på den, och ett svar
+ för fel marknad är sämre än inget svar. */}
+
+ None of the following has any statutory basis in the EU or the United States. Terms are set
+ by the manufacturer and vary by brand, market and model year, so the ranges below describe
+ what occurs on the market — not what any given brand undertakes. Read the vehicle’s own
+ warranty terms before quoting a period to a customer.
+
+
+ );
+}
diff --git a/felsokning/app/src/pages/alva/Ram.tsx b/felsokning/app/src/pages/alva/Ram.tsx
index f1d7e7d..92e81d8 100644
--- a/felsokning/app/src/pages/alva/Ram.tsx
+++ b/felsokning/app/src/pages/alva/Ram.tsx
@@ -21,6 +21,9 @@ const PORTAL = [
{ till: "/alva/portal/analys", text: "Analysis" },
{ till: "/alva/portal/kunskapskallor", text: "Knowledge sources" },
{ till: "/alva/portal/integration", text: "Integration" },
+ { till: "/alva/portal/garantier", text: "Warranty" },
+ { till: "/alva/portal/forsakring", text: "Insurance" },
+ { till: "/alva/portal/support", text: "Support" },
{ till: "/alva/portal/fakturor", text: "Invoices" },
{ till: "/felsokning", text: "Diagnostics" },
];
diff --git a/felsokning/app/src/pages/alva/Support.tsx b/felsokning/app/src/pages/alva/Support.tsx
new file mode 100644
index 0000000..21d77c4
--- /dev/null
+++ b/felsokning/app/src/pages/alva/Support.tsx
@@ -0,0 +1,157 @@
+// ALVA-PROC-0050 · Supportärenden.
+//
+// Listan över organisationens anmälningar. Själva anmälandet sker inte
+// här utan i ärendet där felet uppstod — se Felanmalan i felsökningsvyn.
+// En anmälningsknapp som bara finns i en portalvy nås aldrig av den som
+// står med händerna i en bil.
+
+import { useEffect, useState } from "react";
+import { STATUSAR, TYPER } from "../../../../services/gemensam/support.mjs";
+import { hamtaSupport, plattformAktiv, type Supportarende } from "@/felsokning/plattform";
+import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
+import { Ram } from "./Ram";
+
+const STATUSAV: Record = {
+ mottagen: "pending",
+ under_arbete: "pending",
+ atgardad: "passed",
+ stangd: "not_applicable",
+};
+
+const STATUSTEXT: Record = {
+ mottagen: "Received",
+ under_arbete: "In progress",
+ atgardad: "Resolved",
+ stangd: "Closed",
+};
+
+// Vad som visas när ingen plattform är konfigurerad. Exemplet är märkt.
+const EXEMPEL: Supportarende[] = [
+ {
+ id: "demo-1",
+ beteckning: "ALVA-SUP-0001",
+ arende_id: "ALVA-CASE-91821",
+ typ: "felanmalan",
+ rubrik: "Measurement step will not accept a value from the pressure tester",
+ beskrivning:
+ "The check asks for a reading in bar. Entering 2,4 is rejected as invalid; entering 2.4 is accepted. The workshop uses a comma.",
+ sammanhang: { metodik: "vibration", steg: "4", plattform: "ALVA 1.0" },
+ status: "atgardad",
+ skapad: "2026-08-04T09:12:00Z",
+ anmald_av: "Anna",
+ inlagg: [],
+ },
+];
+
+export default function Support() {
+ const skarpt = plattformAktiv();
+ const [arenden, setArenden] = useState(null);
+ const [fel, setFel] = useState("");
+
+ useEffect(() => {
+ if (!skarpt) return;
+ let avbruten = false;
+ hamtaSupport()
+ .then((a) => !avbruten && setArenden(a))
+ .catch((orsak) => !avbruten && setFel(orsak instanceof Error ? orsak.message : String(orsak)));
+ return () => {
+ avbruten = true;
+ };
+ }, [skarpt]);
+
+ // Misslyckad hämtning visar felet, aldrig exemplet: data som ser äkta
+ // ut men inte är det är värre än ett synligt fel.
+ const lista = skarpt ? arenden : EXEMPEL;
+
+ return (
+
+
+ Support
+
+ Reported issues
+
+
+ Every case carries a report button. A report filed from the case brings its own context —
+ methodology, step, platform version, trace id — so the first reply can be an answer rather
+ than a question.
+
+
+ {!skarpt && (
+
+ The entry below is an example. Connected to a platform instance this page lists the
+ organization’s own reports.
+
+ )}
+
+
+ {fel ? (
+
+ A report is never edited. Replies and state changes are separate entries, and the state is
+ derived from them — so the record can still answer how long a fault was known about.
+
+ [
+
+ {STATUSTEXT[s] ?? s}
+ ,
+
+ {s === "mottagen"
+ ? "Filed. This is the state a report has the moment it exists — nothing has to be written for it to be true."
+ : s === "under_arbete"
+ ? "Support has taken it on."
+ : s === "atgardad"
+ ? "Fixed, and the fix is out."
+ : "Closed. A closed report can be reopened — which happens when the fault turns out not to be gone."}
+ ,
+ ])}
+ />
+
+
+
+ );
+}
diff --git a/felsokning/app/src/pages/felsokning/ArendeSida.tsx b/felsokning/app/src/pages/felsokning/ArendeSida.tsx
index f4e48f1..d2eb54e 100644
--- a/felsokning/app/src/pages/felsokning/ArendeSida.tsx
+++ b/felsokning/app/src/pages/felsokning/ArendeSida.tsx
@@ -2567,10 +2567,116 @@ function RapportFlik({
Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.
+
>
);
}
+/**
+ * Felanmälan — i ärendet, inte någon annanstans.
+ *
+ * En anmälningsknapp som bara finns i en portalvy nås aldrig av den som
+ * står med händerna i en bil. Den ligger därför i varje ärende, och tar
+ * med sitt sammanhang självt: ärende, metodik, öppet steg och
+ * plattformsversion. Teknikern minns inte plattformsversionen, och att
+ * fråga efter den är att lägga över vårt problem på verkstaden.
+ *
+ * Anmälan lagras aldrig i ärendets händelselogg. Loggen är protokollet
+ * över vad som gjordes med FORDONET; att ett fel i vårt system anmäldes
+ * hör inte dit, och skulle följa med in i en rapport som ska hålla i en
+ * tvist.
+ */
+function Felanmalan({ arende, metodik }: { arende: Arende; metodik: Metodik | null }) {
+ const [oppen, setOppen] = useState(false);
+ const [typ, setTyp] = useState("felanmalan");
+ const [rubrik, setRubrik] = useState("");
+ const [text, setText] = useState("");
+ const [utfall, setUtfall] = useState<{ ok?: string; fel?: string }>({});
+ const [skickar, setSkickar] = useState(false);
+
+ const skicka = async () => {
+ setSkickar(true);
+ setUtfall({});
+ try {
+ const { anmalSupport, plattformAktiv } = await import("@/felsokning/plattform");
+ if (!plattformAktiv()) {
+ setUtfall({ fel: "Ingen plattform är konfigurerad — anmälan kan inte skickas härifrån." });
+ return;
+ }
+ const { beteckning } = await anmalSupport({
+ typ,
+ rubrik,
+ beskrivning: text,
+ arendeId: arende.id,
+ sammanhang: {
+ metodikId: metodik?.id ?? "",
+ plattformsversion: PLATTFORMSVERSION,
+ klient: typeof navigator === "undefined" ? "" : navigator.userAgent,
+ },
+ });
+ setUtfall({ ok: beteckning });
+ setRubrik("");
+ setText("");
+ } catch (orsak) {
+ setUtfall({ fel: orsak instanceof Error ? orsak.message : String(orsak) });
+ } finally {
+ setSkickar(false);
+ }
+ };
+
+ if (!oppen) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ Ärende, metodik och plattformsversion följer med automatiskt. Inget om fordonet eller kunden
+ skickas.
+
+
+ );
+}
+
/**
* Ritningsstämpel.
*
diff --git a/felsokning/infra/postgres-init.sql b/felsokning/infra/postgres-init.sql
index 4db9810..8d1eb4e 100644
--- a/felsokning/infra/postgres-init.sql
+++ b/felsokning/infra/postgres-init.sql
@@ -423,3 +423,51 @@ create trigger personnycklar_skydd
-- krypteringen ger ny chiffertext för samma klartext, så varken raden
-- eller nyttolasten kan jämföras. Avtrycket kan.
alter table felsokning_handelser add column if not exists klientdigest text;
+
+-- ---- Support och felanmälan (ALVA-PROC-0050) ---------------------------
+--
+-- Anmälan är oföränderlig, och det som händer med den är egna poster.
+-- Samma skäl som för fakturan: en anmälan vars historia kan skrivas om är
+-- inte ett underlag när någon senare frågar hur länge felet var känt.
+create table if not exists supportarenden (
+ id uuid primary key default gen_random_uuid(),
+ organisation_id uuid not null references organisationer(id),
+ -- Ärendet anmälan gäller. Null för en anmälan utan ärende — plattformen
+ -- kan gå sönder även när ingen står i ett ärende.
+ arende_id text,
+ nummer bigint not null unique,
+ beteckning text not null unique,
+ typ text not null check (typ in ('felanmalan', 'fraga', 'forbattring')),
+ rubrik text not null,
+ beskrivning text not null,
+ -- Härlett sammanhang: metodik, plattformsversion, spår-id. Aldrig något
+ -- identifierande om fordon eller kund — en supportanmälan är inte ett
+ -- skäl att flytta personuppgifter till ett annat system.
+ sammanhang jsonb not null default '{}'::jsonb,
+ skapad_av uuid references anvandare(id),
+ skapad timestamptz not null default now()
+);
+create index if not exists supportarenden_org_idx on supportarenden (organisation_id, nummer desc);
+create index if not exists supportarenden_arende_idx on supportarenden (arende_id);
+
+drop trigger if exists supportarenden_append_only on supportarenden;
+create trigger supportarenden_append_only
+ before update or delete on supportarenden
+ for each row execute function forbjud_andring();
+
+create table if not exists supportinlagg (
+ id uuid primary key default gen_random_uuid(),
+ support_id uuid not null references supportarenden(id),
+ typ text not null check (typ in ('svar', 'status')),
+ text text not null,
+ status text check (status in ('mottagen', 'under_arbete', 'atgardad', 'stangd')),
+ fran text not null,
+ skapad_av uuid references anvandare(id),
+ skapad timestamptz not null default now()
+);
+create index if not exists supportinlagg_idx on supportinlagg (support_id, skapad);
+
+drop trigger if exists supportinlagg_append_only on supportinlagg;
+create trigger supportinlagg_append_only
+ before update or delete on supportinlagg
+ for each row execute function forbjud_andring();
diff --git a/felsokning/services/gemensam/forsakring.mjs b/felsokning/services/gemensam/forsakring.mjs
new file mode 100644
index 0000000..f615863
--- /dev/null
+++ b/felsokning/services/gemensam/forsakring.mjs
@@ -0,0 +1,227 @@
+// ALVA-SPEC-041 · Försäkringsvillkor som underlag.
+//
+// ---- Varför den här modulen aldrig svarar ja eller nej ------------------
+//
+// Den avgör ingenting. Den kan inte avgöra något, och en version som såg
+// ut att göra det vore skadlig på ett sätt som inte märks förrän någon
+// förlitat sig på den.
+//
+// Skälet är inte försiktighet utan sakförhållandet: vad som gäller
+// bestäms av KUNDENS EGET försäkringsbrev och de villkor som gällde när
+// avtalet tecknades — inte av vad ett bolag skriver på sin webbplats i
+// dag, och inte av vad vi noterat. Samma bolag har flera produkter med
+// olika gränser, gränserna ändras, och ett tecknat avtal följer sina egna
+// villkor även efter att bolaget ändrat dem.
+//
+// Modulen ger därför tre saker och inte ett fjärde:
+//
+// 1. VAD SOM AVGÖR frågan — de villkorstyper som styr maskinskada,
+// oberoende av bolag. Den delen är stabil.
+// 2. VAD REGISTRET SÄGER — noterade gränser per bolag och produkt, med
+// källa och datum. Underlag för ett samtal, inte ett besked.
+// 3. VAD SOM MÅSTE KONTROLLERAS i det enskilda fallet.
+//
+// Det fjärde — ett utfall — finns inte, och `bedom()` returnerar med
+// avsikt inget fält som liknar ett.
+//
+// ---- Registrets hållbarhet ----------------------------------------------
+//
+// Uppgifterna nedan är avlästa från bolagens egna sidor vid det datum som
+// står på varje post. De åldras. En post äldre än GILTIGHETSTID_DAGAR
+// märks som föråldrad i gränssnittet i stället för att tigas ihjäl —
+// en gammal siffra som ser aktuell ut är värre än ingen siffra.
+//
+// Organisationen kan lägga till bolag och produkter. Ett register som
+// bara vi fyller kommer aldrig att täcka de mindre bolagen.
+
+/** En post äldre än så här märks som föråldrad. */
+export const GILTIGHETSTID_DAGAR = 180;
+
+/**
+ * Vad som avgör en maskinskadefråga, oavsett bolag.
+ *
+ * Den här listan är modulens stabila del. Gränserna nedanför ändras;
+ * frågorna gör det inte.
+ */
+export const AVGORANDE_VILLKOR = [
+ {
+ id: "alder",
+ rubrik: "Vehicle age",
+ fraga: "Is the vehicle within the age limit of the policy in force?",
+ text: "Machine damage cover ends at an age limit that differs by insurer and by product tier within the same insurer.",
+ },
+ {
+ id: "stracka",
+ rubrik: "Odometer",
+ fraga: "Is the recorded distance within the limit?",
+ text: "Age and distance are alternative ceilings — whichever is reached first ends the cover. The odometer reading therefore has to be documented, not estimated.",
+ },
+ {
+ id: "plotsligt",
+ rubrik: "Sudden and unforeseen",
+ fraga: "Is the failure sudden and unforeseen, rather than gradual?",
+ text: "Machine damage cover is written for sudden internal failure. Gradual deterioration, wear and ageing are normally excluded — and this is the exclusion most claims turn on.",
+ },
+ {
+ id: "komponent",
+ rubrik: "Component in scope",
+ fraga: "Is the failed component named in the policy's list?",
+ text: "Cover is usually limited to an enumerated list — powertrain, steering, brake and electrical systems are common; wear parts, exhaust and bodywork usually are not.",
+ },
+ {
+ id: "underhall",
+ rubrik: "Maintenance history",
+ fraga: "Has the vehicle been serviced according to the manufacturer's schedule?",
+ text: "Neglected or undocumented servicing is a common ground for reduction or refusal. The service record is evidence, and its absence is also evidence.",
+ },
+ {
+ id: "orsak",
+ rubrik: "Excluded cause",
+ fraga: "Was the failure caused by something the policy excludes?",
+ text: "Wrong fuel, lack of oil or coolant, frost damage, tuning or modification, competition use and consequential damage from an earlier unrepaired fault are typical exclusions.",
+ },
+ {
+ id: "sjalvrisk",
+ rubrik: "Deductible",
+ fraga: "What is the deductible, and does it exceed the repair cost?",
+ text: "Deductibles are often graded by vehicle age. A claim below the deductible is not worth filing, and saying so early is part of the service.",
+ },
+];
+
+/**
+ * Noterade gränser per bolag och produkt.
+ *
+ * `kontrollerad` är avläsningsdatum, inte giltighetsdatum. Ingen post här
+ * uttalar sig om vad som gäller för en enskild kund.
+ */
+export const BOLAG = [
+ {
+ id: "if",
+ namn: "If",
+ marknad: "SE",
+ produkter: [
+ { namn: "Maskinskadeförsäkring", maxAlderAr: 10, maxMil: 12_000 },
+ { namn: "Stor bilförsäkring", maxAlderAr: 10, maxMil: 15_000 },
+ ],
+ kalla: "https://www.if.se/privat/forsakringar/bilforsakring/maskinskadeforsakring",
+ kontrollerad: "2026-08-06",
+ },
+ {
+ id: "folksam",
+ namn: "Folksam",
+ marknad: "SE",
+ produkter: [
+ { namn: "Maskinskadeförsäkring", maxAlderAr: 8, maxMil: 15_000 },
+ { namn: "Bilförsäkring Stor", maxAlderAr: 10, maxMil: 15_000 },
+ ],
+ kalla: "https://www.folksam.se/forsakringar/bilforsakring/maskinskadeforsakring",
+ kontrollerad: "2026-08-06",
+ },
+ {
+ id: "trygghansa",
+ namn: "Trygg-Hansa",
+ marknad: "SE",
+ produkter: [
+ { namn: "Maskinskadeförsäkring", maxAlderAr: 8, maxMil: 12_000 },
+ { namn: "Bilförsäkring stor / märkesförsäkring", maxAlderAr: 10, maxMil: 15_000 },
+ ],
+ kalla: "https://www.trygghansa.se/forsakringar/bilforsakring/maskinskadeforsakring",
+ kontrollerad: "2026-08-06",
+ },
+ {
+ id: "svedea",
+ namn: "Svedea",
+ marknad: "SE",
+ produkter: [{ namn: "Maskinskadeförsäkring", maxAlderAr: 10, maxMil: 15_000 }],
+ kalla: "https://www.svedea.se/bilforsakring/maskinskadeforsakring",
+ kontrollerad: "2026-08-06",
+ },
+ {
+ id: "watercircles",
+ namn: "WaterCircles",
+ marknad: "SE",
+ produkter: [{ namn: "Maskinskadeförsäkring", maxAlderAr: 12, maxMil: 17_000 }],
+ kalla: "https://www.watercircles.se/tips-och-rad/maskinskadeforsakring/10925/",
+ kontrollerad: "2026-08-06",
+ },
+ {
+ id: "hedvig",
+ namn: "Hedvig",
+ marknad: "SE",
+ produkter: [{ namn: "Maskinskadeskydd", maxAlderAr: 8, maxMil: 12_000 }],
+ kalla: "https://www.hedvig.com/se/forsakringar/bilforsakring/maskinskadeforsakring",
+ kontrollerad: "2026-08-06",
+ },
+];
+
+/** Är posten äldre än giltighetstiden? */
+export function foraldrad(post, nu = new Date()) {
+ const d = new Date(post.kontrollerad);
+ if (Number.isNaN(d.getTime())) return true;
+ return (nu - d) / 86_400_000 > GILTIGHETSTID_DAGAR;
+}
+
+/** Bredaste och smalaste noterade gräns — spannet, inte ett svar. */
+export function spann(bolag = BOLAG) {
+ const produkter = bolag.flatMap((b) => b.produkter);
+ if (produkter.length === 0) return null;
+ return {
+ alderAr: { minsta: Math.min(...produkter.map((p) => p.maxAlderAr)), storsta: Math.max(...produkter.map((p) => p.maxAlderAr)) },
+ mil: { minsta: Math.min(...produkter.map((p) => p.maxMil)), storsta: Math.max(...produkter.map((p) => p.maxMil)) },
+ };
+}
+
+/**
+ * Underlag för ett samtal med kunden. INTE ett utfall.
+ *
+ * Returnerar avsiktligt inget fält som säger täckt eller ej. Det som
+ * returneras är: vilka noterade produkter fordonet ligger inom respektive
+ * utanför, vilka frågor som återstår, och det obligatoriska nästa steget
+ * — att läsa kundens eget försäkringsbrev.
+ *
+ * Ett fordon som ligger utanför SAMTLIGA noterade gränser får ändå inget
+ * "nej". Registret kan vara ofullständigt, produkten kan vara en annan,
+ * och villkoren vid tecknandet kan ha varit andra.
+ */
+export function bedom({ alderAr, mil, bolagId } = {}, register = BOLAG, nu = new Date()) {
+ const urval = bolagId ? register.filter((b) => b.id === bolagId) : register;
+
+ const inom = [];
+ const utanfor = [];
+ const oklart = [];
+
+ for (const b of urval) {
+ for (const p of b.produkter) {
+ const post = { bolag: b.namn, produkt: p.namn, gransAr: p.maxAlderAr, gransMil: p.maxMil, kalla: b.kalla, foraldrad: foraldrad(b, nu) };
+ const alderKant = typeof alderAr === "number";
+ const milKant = typeof mil === "number";
+ if (!alderKant && !milKant) {
+ oklart.push(post);
+ continue;
+ }
+ const overAlder = alderKant && alderAr > p.maxAlderAr;
+ const overMil = milKant && mil > p.maxMil;
+ if (overAlder || overMil) utanfor.push(post);
+ else if (alderKant && milKant) inom.push(post);
+ else oklart.push(post);
+ }
+ }
+
+ return {
+ inom,
+ utanfor,
+ oklart,
+ // Frågorna som återstår oavsett vad gränserna säger. Ålder och
+ // sträcka är bara två av sju, och de fem andra avgör oftare.
+ kvarstaende: AVGORANDE_VILLKOR.filter((v) => !["alder", "stracka"].includes(v.id)),
+ nastaSteg:
+ "Read the customer's own policy document and the terms in force when it was taken out. The register is a starting point for the conversation, never the answer.",
+ };
+}
+
+/** Förbehållet. Ska stå i varje vy som visar registret. */
+export const FORBEHALL =
+ "ALVA does not decide whether a claim is covered and cannot do so. Cover is determined by the customer's own " +
+ "policy and the terms in force when it was taken out — not by this register, which records what insurers " +
+ "published on the dates shown. Figures age, insurers differ between their own product tiers, and an existing " +
+ "policy keeps its original terms. Use this to frame the conversation and the questions; the insurer decides.";
diff --git a/felsokning/services/gemensam/garantier.mjs b/felsokning/services/gemensam/garantier.mjs
new file mode 100644
index 0000000..18fde23
--- /dev/null
+++ b/felsokning/services/gemensam/garantier.mjs
@@ -0,0 +1,396 @@
+// ALVA-SPEC-040 · Garantiregimer, EU och USA.
+//
+// ---- Vad den här modulen är, och inte är -------------------------------
+//
+// Den är beslutsstöd för verkstaden, inte juridisk rådgivning. Varje regel
+// bär sin källa och det datum uppgiften kontrollerades, så att den som
+// lutar sig mot den kan gå till originaltexten i stället för att tro på
+// oss. En regel utan källa hör inte hemma här.
+//
+// ---- Den skiljelinje allt annat hänger på ------------------------------
+//
+// LAGSTADGAT och AVTALAT är inte samma sak, och blandas ihop dagligen i
+// verkstadsledet.
+//
+// Lagstadgat Följer av lag eller förordning. Gäller oavsett vad någon
+// skrivit i ett garantihäfte. Går inte att avtala bort mot
+// en konsument.
+//
+// Avtalat Tillverkarens frivilliga löfte. Villkoren är dess egna
+// och varierar mellan märken, marknader och årsmodeller.
+//
+// Det som förvånar är hur lite av det verkstaden dagligen kallar "garanti"
+// som faktiskt är lagstadgat:
+//
+// NYBILSGARANTI avtalad. Ingen lag i EU eller USA kräver den.
+// ROSTSKYDDSGARANTI avtalad. Ingen lag kräver den, någonstans.
+// LACKGARANTI avtalad.
+// DRIVLINEGARANTI avtalad.
+// AVGASGARANTI LAGSTADGAD i bägge jurisdiktionerna, med olika
+// längd, och den är den enda av dem som är det.
+//
+// Att påstå att en rostskyddsgaranti är lagstadgad är alltså fel, och att
+// tro att en avgasgaranti bara är en tillverkarutfästelse är fel åt andra
+// hållet — det andra felet kostar kunden pengar.
+//
+// ---- Vad som INTE finns här --------------------------------------------
+//
+// Inga märkesvillkor. De är tusentals, ändras per årsmodell och marknad,
+// och en föråldrad siffra här vore värre än ingen siffra alls. Modulen
+// säger vad LAGEN kräver som golv och vilka kategorier av avtalade
+// garantier som brukar finnas — inte vad ett visst märke lovar.
+
+/** Datum då uppgifterna nedan senast kontrollerades mot källorna. */
+export const KONTROLLERAD = "2026-08-06";
+
+/**
+ * Regimerna.
+ *
+ * `grund` skiljer lagstadgat från avtalat. `bärare` säger VEM som svarar —
+ * en fråga verkstaden behöver besvara innan den ringer någon: säljaren
+ * (EU:s konsumentköp) är sällan tillverkaren.
+ */
+export const REGIMER = [
+ // ---- EU, lagstadgat ---------------------------------------------------
+ {
+ id: "EU-KONFORMITET",
+ jurisdiktion: "EU",
+ grund: "lag",
+ bärare: "säljaren",
+ rubrik: "Legal guarantee of conformity",
+ kortform: "2 years minimum, seller liable",
+ källa: "Directive (EU) 2019/771, Art. 10–11",
+ källänk: "https://eur-lex.europa.eu/eli/dir/2019/771/oj/eng",
+ period: { år: 2 },
+ text:
+ "The seller is liable for any lack of conformity existing at delivery that becomes apparent within two years. A lack of conformity appearing within one year is presumed to have existed at delivery — the seller must prove otherwise. Member States may set longer periods and may extend the reversed burden of proof to two years.",
+ // Det här är det fel verkstaden gör oftast: skickar kunden till
+ // tillverkaren för något som är säljarens ansvar.
+ anmärkning:
+ "This is a claim against the SELLER, not the manufacturer, and it exists independently of any manufacturer warranty.",
+ avvikelser: [
+ { marknad: "Sverige", text: "Three years (konsumentköplagen 2022:260), reversed burden of proof two years." },
+ { marknad: "Frankrike, Portugal", text: "Reversed burden of proof extended to the full two years." },
+ ],
+ },
+ {
+ id: "EU-MVBER",
+ jurisdiktion: "EU",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "Servicing outside the authorised network does not void the warranty",
+ kortform: "Independent servicing is protected",
+ källa:
+ "Regulation (EU) 461/2010, prolonged to 31 May 2028 by Regulation (EU) 2023/822; Supplementary Guidelines 2010/C 138/05",
+ källänk: "https://eur-lex.europa.eu/eli/reg/2023/822/oj/eng",
+ period: null,
+ text:
+ "A manufacturer may not make its warranty conditional on the vehicle being serviced within the authorised network for work not covered by the warranty, nor on the exclusive use of its own branded parts for such work. Warranty cover therefore survives servicing by an independent workshop, provided the work follows the manufacturer's specification and uses parts of matching quality.",
+ anmärkning:
+ "The rule that matters most to an independent workshop. Document the specification followed and the part quality — that documentation is what makes the protection usable if the claim is later contested.",
+ avvikelser: [],
+ },
+ {
+ id: "EU-AVGAS-EURO6",
+ jurisdiktion: "EU",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "Emissions durability — Euro 5/6",
+ kortform: "5 years / 100 000 km in-service conformity",
+ källa: "Regulation (EC) No 715/2007, Art. 4(2) and Annex VII",
+ källänk: "https://eur-lex.europa.eu/eli/reg/2007/715/oj/eng",
+ period: { år: 5, km: 100_000 },
+ text:
+ "In-service conformity is checked for up to 5 years or 100 000 km, whichever comes first. Durability testing of pollution control devices for type approval covers 160 000 km.",
+ anmärkning:
+ "A durability requirement on the manufacturer's type approval — not a consumer warranty in the US sense. It gives no direct claim to the owner; the corresponding owner-facing cover is contractual.",
+ avvikelser: [],
+ },
+ {
+ id: "EU-AVGAS-EURO7",
+ jurisdiktion: "EU",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "Emissions durability — Euro 7",
+ kortform: "8 years / 160 000 km, plus 25 % additional lifetime",
+ källa: "Regulation (EU) 2024/1257, Art. 5 and Annex IV",
+ källänk: "https://eur-lex.europa.eu/eli/reg/2024/1257/oj/eng",
+ period: { år: 8, km: 160_000 },
+ text:
+ "The main lifetime for M1 and N1 rises from 5 years/100 000 km to 8 years/160 000 km, with an additional lifetime of a further 25 % (to 10 years/200 000 km) governed by durability multipliers.",
+ anmärkning:
+ "Applies to new types from 29 November 2026 and to all new vehicles from 29 November 2027. A vehicle registered before those dates keeps the Euro 6 regime.",
+ avvikelser: [],
+ },
+ {
+ id: "EU-BATTERI-EURO7",
+ jurisdiktion: "EU",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "Battery durability — Euro 7",
+ kortform: "≥80 % at 5 y/100 000 km, ≥72 % at 8 y/160 000 km",
+ källa: "Regulation (EU) 2024/1257, Art. 8 and Annex VII",
+ källänk: "https://eur-lex.europa.eu/eli/reg/2024/1257/oj/eng",
+ period: { år: 8, km: 160_000 },
+ text:
+ "For passenger cars (M1) the battery must retain at least 80 % of its energy storage capability after 5 years or 100 000 km, and not fall below 72 % after 8 years or 160 000 km.",
+ anmärkning:
+ "A type-approval durability requirement. A measured State of Health below the threshold is evidence in a claim, not a claim in itself — and it must be measured with a traceable instrument to carry that weight.",
+ avvikelser: [],
+ },
+
+ // ---- USA, lagstadgat --------------------------------------------------
+ {
+ id: "US-AVGAS-PRESTANDA",
+ jurisdiktion: "US",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "Federal emissions performance warranty",
+ kortform: "2 years / 24 000 miles",
+ källa: "Clean Air Act § 207, 42 U.S.C. § 7541; 40 CFR Part 85 Subpart V",
+ källänk: "https://www.ecfr.gov/current/title-40/chapter-I/subchapter-C/part-85/subpart-V",
+ period: { år: 2, miles: 24_000 },
+ text:
+ "The manufacturer must repair, at no cost, a vehicle that fails an approved emissions test during the first 2 years or 24 000 miles because of a defect in an emission control component.",
+ anmärkning:
+ "Runs from the date of sale to the first owner and follows the vehicle — a later owner inherits the remaining period.",
+ avvikelser: [],
+ },
+ {
+ id: "US-AVGAS-HUVUDDELAR",
+ jurisdiktion: "US",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "Federal warranty — specified major emission control components",
+ kortform: "8 years / 80 000 miles",
+ källa: "Clean Air Act § 207(i); 40 CFR § 85.2103",
+ källänk: "https://www.ecfr.gov/current/title-40/chapter-I/subchapter-C/part-85/subpart-V/section-85.2103",
+ period: { år: 8, miles: 80_000 },
+ text:
+ "Specified major emission control components carry 8 years or 80 000 miles. For light-duty vehicles, light-duty trucks and medium-duty passenger vehicles these include catalytic converters, SCR catalysts and related components, and particulate filters and traps, on both spark-ignition and compression-ignition engines.",
+ anmärkning:
+ "Routinely missed. A catalytic converter on a six-year-old vehicle is frequently quoted to the customer as a paid repair when it is not one.",
+ avvikelser: [],
+ },
+ {
+ id: "US-MMWA",
+ jurisdiktion: "US",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "No tie-in: aftermarket parts and independent service",
+ kortform: "Warranty cannot require branded parts or dealer service",
+ källa: "Magnuson–Moss Warranty Act, 15 U.S.C. § 2302(c)",
+ källänk: "https://www.law.cornell.edu/uscode/text/15/2302",
+ period: null,
+ text:
+ "A warrantor may not condition a written or implied warranty on the consumer using an article or service identified by brand or corporate name, unless it is provided free of charge or the FTC has granted a waiver.",
+ anmärkning:
+ "The US counterpart to the MVBER rule. Denying a claim requires the manufacturer to show that a specific part actually caused the failure — which is a question of evidence, and therefore a question of what the workshop documented.",
+ avvikelser: [],
+ },
+ {
+ id: "US-CA-AVGAS",
+ jurisdiktion: "US-CA",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "California emissions warranty",
+ kortform: "3 y/50 000 mi, high-priced parts 7 y/70 000 mi",
+ källa: "13 CCR §§ 2035–2039 (CARB)",
+ källänk: "https://www.law.cornell.edu/regulations/california/13-CCR-2039",
+ period: { år: 3, miles: 50_000 },
+ text:
+ "Emission-related parts are warranted 3 years or 50 000 miles. A single warranted part above an annually indexed cost threshold is covered 7 years or 70 000 miles. The threshold was USD 770 for model year 2025.",
+ anmärkning:
+ "Applies in California and in the states that have adopted CARB standards — always check which regime the individual vehicle was certified to before quoting the federal figures.",
+ avvikelser: [],
+ },
+ {
+ id: "US-CA-BATTERI",
+ jurisdiktion: "US-CA",
+ grund: "lag",
+ bärare: "tillverkaren",
+ rubrik: "California ZEV battery warranty — Advanced Clean Cars II",
+ kortform: "≥70 % SoH at 8 y/100 000 mi (MY2026–2030)",
+ källa: "Advanced Clean Cars II, 13 CCR § 1962.4 (CARB)",
+ källänk: "https://ww2.arb.ca.gov/resources/fact-sheets/california-vehicle-and-emissions-warranty-periods",
+ period: { år: 8, miles: 100_000 },
+ text:
+ "For model years 2026–2030 the battery must be warranted to retain at least 70 % of certified State of Health for 8 years or 100 000 miles; from model year 2031 the floor rises to 75 %. Propulsion-related parts follow the 3 y/50 000 mi and 7 y/70 000 mi structure.",
+ anmärkning: "Separate from the durability requirement that range be retained over 10 years/150 000 miles.",
+ avvikelser: [],
+ },
+
+ // ---- Avtalade kategorier ----------------------------------------------
+ //
+ // Ingen av dem har lagstöd. De tas med därför att de är vad kunden MENAR
+ // när den säger garanti, och verkstaden måste kunna säga vilket slag av
+ // löfte den talar om. Spannen är vad som förekommer på marknaden, inte
+ // vad något märke utfäster.
+ {
+ id: "AVTAL-NYBIL",
+ jurisdiktion: "EU/US",
+ grund: "avtal",
+ bärare: "tillverkaren",
+ rubrik: "New-vehicle warranty",
+ kortform: "Typically 2–7 years — contractual, not statutory",
+ källa: "Manufacturer's own warranty terms",
+ källänk: null,
+ period: null,
+ text:
+ "The manufacturer's general warranty against defects. Length, mileage cap and exclusions are set by the manufacturer and vary by brand, market and model year.",
+ anmärkning:
+ "No law in the EU or the US requires it. In the EU it sits on top of the statutory conformity right against the seller and cannot reduce it.",
+ avvikelser: [],
+ },
+ {
+ id: "AVTAL-ROST",
+ jurisdiktion: "EU/US",
+ grund: "avtal",
+ bärare: "tillverkaren",
+ rubrik: "Corrosion / anti-perforation warranty",
+ kortform: "Typically 6–12 years — contractual, not statutory",
+ källa: "Manufacturer's own warranty terms",
+ källänk: null,
+ period: null,
+ text:
+ "Cover against rust perforation from the inside out. Usually excludes surface corrosion, stone-chip damage and rust caused by unrepaired accident damage, and is commonly conditioned on documented periodic inspection.",
+ anmärkning:
+ "There is no statutory corrosion warranty anywhere in the EU or the US. The inspection condition is the usual reason a claim is refused — which makes the inspection record the decisive evidence.",
+ avvikelser: [],
+ },
+ {
+ id: "AVTAL-LACK",
+ jurisdiktion: "EU/US",
+ grund: "avtal",
+ bärare: "tillverkaren",
+ rubrik: "Paint warranty",
+ kortform: "Typically 2–3 years — contractual, not statutory",
+ källa: "Manufacturer's own warranty terms",
+ källänk: null,
+ period: null,
+ text: "Cover against paint defects. Normally separate from, and shorter than, the corrosion warranty.",
+ anmärkning: "",
+ avvikelser: [],
+ },
+ {
+ id: "AVTAL-DRIVLINA",
+ jurisdiktion: "EU/US",
+ grund: "avtal",
+ bärare: "tillverkaren",
+ rubrik: "Powertrain warranty",
+ kortform: "Varies — contractual, not statutory",
+ källa: "Manufacturer's own warranty terms",
+ källänk: null,
+ period: null,
+ text:
+ "Engine, transmission and driveline cover, often longer than the general new-vehicle warranty and frequently conditioned on servicing at the prescribed intervals.",
+ anmärkning:
+ "The service-interval condition is legitimate — what is not legitimate is requiring that the service be performed by the authorised network (MVBER; Magnuson–Moss § 2302(c)).",
+ avvikelser: [],
+ },
+ {
+ id: "AVTAL-BATTERI",
+ jurisdiktion: "EU/US",
+ grund: "avtal",
+ bärare: "tillverkaren",
+ rubrik: "Traction battery warranty",
+ kortform: "Commonly 8 years / 160 000 km with a State of Health floor",
+ källa: "Manufacturer's own warranty terms",
+ källänk: null,
+ period: null,
+ text:
+ "Commercial cover for the traction battery, usually expressed as a period plus a retained State of Health, often around 70 %.",
+ anmärkning:
+ "Sits alongside the statutory durability requirements (Euro 7 in the EU, ACC II in California) and is frequently more generous than them.",
+ avvikelser: [],
+ },
+];
+
+/** Jurisdiktioner som går att välja. `US-CA` ärver de federala reglerna. */
+export const JURISDIKTIONER = [
+ { id: "EU", namn: "European Union" },
+ { id: "US", namn: "United States (federal)" },
+ { id: "US-CA", namn: "United States — California / CARB states" },
+];
+
+/**
+ * Regimer som gäller för en jurisdiktion.
+ *
+ * Kalifornien ÄRVER de federala reglerna och lägger sina egna ovanpå.
+ * Att visa enbart de kaliforniska hade utelämnat Magnuson–Moss, som är
+ * den regel verkstaden behöver mest av alla.
+ */
+export function regimerFor(jurisdiktion) {
+ const gäller = (r) => {
+ if (r.jurisdiktion === "EU/US") return true;
+ if (jurisdiktion === "US-CA") return r.jurisdiktion === "US" || r.jurisdiktion === "US-CA";
+ return r.jurisdiktion === jurisdiktion;
+ };
+ return REGIMER.filter(gäller);
+}
+
+/**
+ * Vilka LAGSTADGADE regimer ett visst fordon fortfarande ligger inom.
+ *
+ * Returnerar tre listor i stället för ett filtrerat urval, därför att
+ * "utanför" och "går inte att avgöra" är olika besked och bara det ena
+ * betyder att kunden ska betala. Saknas ålder eller mätarställning
+ * hamnar regimen under `oklart` — aldrig under `utanfor`. Att påstå att
+ * en garanti har gått ut när underlaget inte räcker är precis den sortens
+ * obefogade säkerhet produkten finns för att undvika.
+ *
+ * @param jurisdiktion "EU" | "US" | "US-CA"
+ * @param fordon { alderAr?, km?, miles? }
+ */
+export function mojligenGallande(jurisdiktion, fordon = {}) {
+ const inom = [];
+ const utanfor = [];
+ const oklart = [];
+
+ for (const r of regimerFor(jurisdiktion)) {
+ if (r.grund !== "lag") continue;
+ if (!r.period) {
+ // Regler utan tidsgräns — MVBER, Magnuson–Moss — gäller alltid.
+ inom.push(r);
+ continue;
+ }
+
+ const bedömningar = [];
+ if (typeof r.period.år === "number") {
+ bedömningar.push(typeof fordon.alderAr === "number" ? fordon.alderAr <= r.period.år : null);
+ }
+ if (typeof r.period.km === "number") {
+ const km = fordon.km ?? (typeof fordon.miles === "number" ? fordon.miles * 1.609344 : null);
+ bedömningar.push(typeof km === "number" ? km <= r.period.km : null);
+ }
+ if (typeof r.period.miles === "number") {
+ const miles = fordon.miles ?? (typeof fordon.km === "number" ? fordon.km / 1.609344 : null);
+ bedömningar.push(typeof miles === "number" ? miles <= r.period.miles : null);
+ }
+
+ // Perioderna är "whichever comes first": ett överskridet mått räcker
+ // för att regimen ska vara passerad, oavsett vad det andra säger.
+ if (bedömningar.some((b) => b === false)) utanfor.push(r);
+ else if (bedömningar.some((b) => b === null)) oklart.push(r);
+ else inom.push(r);
+ }
+
+ return { inom, utanfor, oklart };
+}
+
+/**
+ * Det verkstaden behöver få veta oavsett ärende.
+ *
+ * Bägge jurisdiktionerna har en regel som säger samma sak: att arbetet
+ * utförs här upphäver inte garantin. Den är värd att lyfta separat,
+ * eftersom motsatsen ofta påstås — och ibland av kunden själv.
+ */
+export function skyddadReparation(jurisdiktion) {
+ return regimerFor(jurisdiktion).find((r) => r.id === (jurisdiktion === "EU" ? "EU-MVBER" : "US-MMWA")) ?? null;
+}
+
+/** Förbehållet. Står i varje vy som visar regimerna. */
+export const FORBEHALL =
+ "Decision support, not legal advice. Figures are statutory minimums verified against the cited sources on " +
+ `${KONTROLLERAD}; contractual warranties are set by the manufacturer and are not reproduced here. ` +
+ "Verify against the current text of the cited instrument before relying on it in a dispute.";
diff --git a/felsokning/services/gemensam/support.mjs b/felsokning/services/gemensam/support.mjs
new file mode 100644
index 0000000..36fcd31
--- /dev/null
+++ b/felsokning/services/gemensam/support.mjs
@@ -0,0 +1,108 @@
+// ALVA-PROC-0050 · Felanmälan och supportärenden.
+//
+// ---- Varför anmälan sitter i ärendet ------------------------------------
+//
+// En felanmälan som skrivs någon annanstans än där felet uppstod tappar
+// det som gör den användbar. Teknikern minns inte ärendenumret, vet inte
+// vilken metodik som kördes, och kan inte plattformsversionen — så
+// anmälan blir "det funkar inte" och supporten får börja med att fråga
+// tillbaka. Två dagar går innan någon vet vad som hände.
+//
+// Sammanhanget HÄRLEDS därför i stället för att skrivas in: ärende,
+// metodik, plattformsversion, spår-id och vilket steg som var öppet. Det
+// är samma hållning som mot sammanfattningen och fakturan — det som går
+// att härleda ska inte skrivas av.
+//
+// ---- Status är en projektion --------------------------------------------
+//
+// Anmälan är oföränderlig. Det som händer med den — svar, statusbyten —
+// är egna poster, och statusen härleds ur dem. Samma skäl som för
+// fakturan: en anmälan vars historia kan skrivas om är inte ett underlag
+// när någon senare frågar hur länge felet var känt.
+
+/** ALVA-SUP-0001 */
+export const supportbeteckning = (nummer) => `ALVA-SUP-${String(nummer).padStart(4, "0")}`;
+
+/**
+ * Vad anmälan gäller.
+ *
+ * Skilt åt därför att de har olika brådska och olika mottagare. En
+ * felanmälan om systemet mitt i ett ärende blockerar arbete just nu;
+ * ett förbättringsförslag gör det inte, och att blanda dem gör att
+ * bägge behandlas som det senare.
+ */
+export const TYPER = [
+ { id: "felanmalan", rubrik: "Fault report", text: "The platform did something wrong, or stopped working." },
+ { id: "fraga", rubrik: "Question", text: "The methodology or the rules are unclear in this case." },
+ { id: "forbattring", rubrik: "Improvement", text: "Something works, but could work better." },
+];
+
+/**
+ * Tillstånden. Ordningen är enkelriktad — utom att en stängd anmälan kan
+ * öppnas igen, vilket händer när felet visar sig inte vara borta.
+ */
+export const STATUSAR = ["mottagen", "under_arbete", "atgardad", "stangd"];
+
+/**
+ * Statusen härleds ur inläggen, den lagras inte.
+ *
+ * Senaste statusposten gäller. Saknas statusposter är anmälan mottagen —
+ * det är vad den är i det ögonblick den skapas, och ingenting behöver
+ * skrivas för att det ska bli sant.
+ */
+export function supportstatus(inlagg = []) {
+ const senaste = [...inlagg].filter((i) => i?.typ === "status" && STATUSAR.includes(i.status)).pop();
+ return senaste?.status ?? "mottagen";
+}
+
+/**
+ * Granskar en anmälan innan den tas emot.
+ *
+ * Kraven är låga med flit. En tröskel som gör det jobbigt att anmäla ger
+ * färre anmälningar, inte färre fel — och de fel som inte anmäls är de
+ * som lever längst. Men en rubrik som "fel" och en tom beskrivning gör
+ * anmälan obrukbar för den som ska svara, så det finns ett golv.
+ */
+export function granskaAnmalan({ typ, rubrik, beskrivning } = {}) {
+ if (!TYPER.some((t) => t.id === typ)) return "Ange vad anmälan gäller.";
+ if (typeof rubrik !== "string" || rubrik.trim().length < 5) {
+ return "Rubriken behöver säga vad det gäller — minst fem tecken.";
+ }
+ if (rubrik.trim().length > 200) return "Rubriken är för lång (max 200 tecken).";
+ if (typeof beskrivning !== "string" || beskrivning.trim().length < 20) {
+ return "Beskriv vad som hände och vad du väntade dig — minst tjugo tecken. Den som ska svara ser inte din skärm.";
+ }
+ if (beskrivning.length > 20_000) return "Beskrivningen är för lång (max 20 000 tecken).";
+ return null;
+}
+
+/**
+ * Sammanhanget, härlett ur det som redan finns.
+ *
+ * Fälten är medvetet få och tekniska. Ingenting identifierande om
+ * fordonet eller kunden följer med: en supportanmälan är inte ett skäl
+ * att flytta personuppgifter till ett annat system, och den som svarar
+ * behöver dem inte för att läsa ett spår.
+ */
+export function sammanhang({ arendeId, metodikId, steg, plattformsversion, sparId, klient } = {}) {
+ const ut = {};
+ if (arendeId) ut.arende = String(arendeId);
+ if (metodikId) ut.metodik = String(metodikId);
+ if (steg) ut.steg = String(steg);
+ if (plattformsversion) ut.plattform = String(plattformsversion);
+ if (sparId) ut.spar = String(sparId);
+ if (klient) ut.klient = String(klient).slice(0, 200);
+ return ut;
+}
+
+/** Fälten som ALDRIG får följa med en anmälan. Låst av test. */
+export const FORBJUDET_I_SAMMANHANG = [
+ "identifierare",
+ "regnr",
+ "vin",
+ "agare",
+ "kund",
+ "personnummer",
+ "telefon",
+ "epost",
+];
diff --git a/felsokning/services/plattform/forsakring.mjs b/felsokning/services/plattform/forsakring.mjs
new file mode 120000
index 0000000..2a71cd1
--- /dev/null
+++ b/felsokning/services/plattform/forsakring.mjs
@@ -0,0 +1 @@
+../gemensam/forsakring.mjs
\ No newline at end of file
diff --git a/felsokning/services/plattform/garantier.mjs b/felsokning/services/plattform/garantier.mjs
new file mode 120000
index 0000000..39436d9
--- /dev/null
+++ b/felsokning/services/plattform/garantier.mjs
@@ -0,0 +1 @@
+../gemensam/garantier.mjs
\ No newline at end of file
diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh
index 1220c0d..edf70a3 100755
--- a/felsokning/services/plattform/integrationstest.sh
+++ b/felsokning/services/plattform/integrationstest.sh
@@ -848,4 +848,62 @@ sleep 1
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "utan huvudnyckel lamnas inget ut" "$KOD" "500"
+# 19. Support och felanmälan (ALVA-PROC-0050)
+kill "$SERVER_PID" 2>/dev/null || true
+wait "$SERVER_PID" 2>/dev/null || true
+DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
+ JWT_SECRET=integrationshemlighet PORT=$APPPORT SUPPORT_NYCKEL=supportnyckel node server.mjs &
+SERVER_PID=$!
+sleep 1
+
+# Teknikern far anmala. En troskel har ger farre anmalningar, inte farre fel.
+ANMALAN=$(curl -s -X POST "$BAS/api/support" -H "Authorization: Bearer $TOKEN_J" -H 'Content-Type: application/json' \
+ -d '{"typ":"felanmalan","rubrik":"Matsteget vagrar komma","arendeId":"arende-test1",
+ "beskrivning":"Kontrollen tar inte emot 2,4 men accepterar 2.4. Verkstaden skriver komma.",
+ "sammanhang":{"metodikId":"vibration","regnr":"ABC123","plattformsversion":"ALVA 1.0"}}')
+SUP_ID=$(echo "$ANMALAN" | falt .id)
+kontroll "teknikern kan anmala" "$(echo "$ANMALAN" | falt .beteckning)" "ALVA-SUP-0001"
+kontroll "nya anmalningar ar mottagna" "$(echo "$ANMALAN" | falt .status)" "mottagen"
+
+LISTA=$(curl -s "$BAS/api/support" -H "Authorization: Bearer $TOKEN_A")
+kontroll "anmalan syns i organisationen" "$(echo "$LISTA" | falt .arenden.length)" "1"
+kontroll "sammanhanget bar metodiken" "$(echo "$LISTA" | falt '.arenden[0].sammanhang.metodik')" "vibration"
+kontroll "sammanhanget bar sparId" "$(echo "$LISTA" | falt '.arenden[0].sammanhang.spar ? "ja" : "nej"')" "ja"
+# Det viktigaste: ett regnr kan inte smugglas in i en supportanmalan.
+kontroll "identifierande falt slapps inte igenom" \
+ "$(echo "$LISTA" | falt '.arenden[0].sammanhang.regnr ?? "borta"')" "borta"
+
+kontroll "org B ser inga anmalningar" "$(curl -s "$BAS/api/support" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)" "0"
+
+KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/support" -H "Authorization: Bearer $TOKEN_A" \
+ -H 'Content-Type: application/json' -d '{"typ":"felanmalan","rubrik":"fel","beskrivning":"gar ej"}')
+kontroll "for tunn anmalan avvisas" "$KOD" "400"
+
+KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/support" -H "Authorization: Bearer $TOKEN_B" \
+ -H 'Content-Type: application/json' \
+ -d '{"typ":"felanmalan","rubrik":"Nagot om org A","arendeId":"arende-test1","beskrivning":"En anmalan ar inte en vag runt organisationsgransen."}')
+kontroll "anmalan kan inte peka pa annan orgs arende" "$KOD" "404"
+
+# Verkstaden svarar i sitt eget arende; status ar var sida av bordet.
+curl -s -X POST "$BAS/api/support/$SUP_ID/inlagg" -H "Authorization: Bearer $TOKEN_A" \
+ -H 'Content-Type: application/json' -d '{"text":"Hander aven i Chrome."}' >/dev/null
+KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/support/$SUP_ID/inlagg" \
+ -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
+ -d '{"text":"Vi tittar.","status":"under_arbete"}')
+kontroll "verkstaden kan inte satta status" "$KOD" "403"
+
+curl -s -X POST "$BAS/api/support/$SUP_ID/inlagg" -H 'X-Support: supportnyckel' \
+ -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
+ -d '{"text":"Ratta i nasta utgava.","status":"atgardad"}' >/dev/null
+LISTA=$(curl -s "$BAS/api/support" -H "Authorization: Bearer $TOKEN_A")
+kontroll "statusen harleds ur inlaggen" "$(echo "$LISTA" | falt '.arenden[0].status')" "atgardad"
+kontroll "svaret finns kvar bredvid statusbytet" "$(echo "$LISTA" | falt '.arenden[0].inlagg.length')" "2"
+
+if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
+ -qc "update supportarenden set rubrik = 'omskrivet'" 2>/dev/null; then
+ echo "✗ supportarenden gar att skriva om"; exit 1
+else
+ echo "✓ supportarenden gar inte att skriva om"
+fi
+
echo "Integrationstest: allt grönt"
diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs
index 6d555b8..49ab1ed 100644
--- a/felsokning/services/plattform/server.mjs
+++ b/felsokning/services/plattform/server.mjs
@@ -17,6 +17,7 @@
// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät
// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration
// FAKTURERING_NYCKEL utfärdarens nyckel; utan den kan ingen faktura utfärdas
+// SUPPORT_NYCKEL vår egen supports nyckel; krävs för att svara och sätta status
// PERSONNYCKEL_HUVUD 32 byte (hex/base64) — kuverterar personnycklarna så att
// en återställd databasdump inte innehåller läsbara nycklar
// PORT default 8080
@@ -37,6 +38,7 @@ import { oversikt as statistikOversikt } from "./statistik.mjs";
import { KATEGORIER, UTGAENDE, protokollTillHandelser, signeraLeverans } from "./integration.mjs";
import { enrading, sammanfatta } from "./sammanfattning.mjs";
import { fakturabeteckning, fakturastatus, fakturera, granskaPeriod, kreditera } from "./fakturering.mjs";
+import { STATUSAR, granskaAnmalan, sammanhang as byggSammanhang, supportbeteckning, supportstatus } from "./support.mjs";
import {
MASKERAT,
gallringsdatum,
@@ -131,16 +133,28 @@ const ROLLER = ["tekniker", "arbetsledare", "admin"];
// ett ärende utan evidens inte kan avslutas.
const FAKTURERING_NYCKEL = process.env.FAKTURERING_NYCKEL ?? "";
-function arUtfardare(req) {
- if (!FAKTURERING_NYCKEL) return false;
- const given = String(req.headers["x-fakturering"] ?? "");
- const a = Buffer.from(given);
- const b = Buffer.from(FAKTURERING_NYCKEL);
+// Vår egen support. Verkstaden anmäler och svarar i sitt eget ärende;
+// att sätta status är vår sida av bordet, och den skiljs med en egen
+// nyckel av samma skäl som utfärdaren av fakturor gör det.
+const SUPPORT_NYCKEL = process.env.SUPPORT_NYCKEL ?? "";
+
+function nyckelStammer(given, forvantad) {
+ if (!forvantad) return false;
+ const a = Buffer.from(String(given ?? ""));
+ const b = Buffer.from(forvantad);
// Jämförelsen är tidskonstant, och längden jämförs först eftersom
// timingSafeEqual kastar på olika längd.
return a.length === b.length && timingSafeEqual(a, b);
}
+function arUtfardare(req) {
+ return nyckelStammer(req.headers["x-fakturering"], FAKTURERING_NYCKEL);
+}
+
+function arSupport(req) {
+ return nyckelStammer(req.headers["x-support"], SUPPORT_NYCKEL);
+}
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
// Poolens uttömning är den vanligaste orsaken till att allt blir
@@ -668,6 +682,27 @@ async function medFakturanummer(arbete) {
}
}
+// Supportnummer, samma resonemang som fakturanumret: låset serialiserar
+// mot alla repliker, och en sequence hade lämnat luckor vid rollback.
+// Serien är dock inte ett bokföringsunderlag, så luckor vore uthärdliga —
+// det är enhetligheten som motiverar samma lösning, inte kravet.
+async function medSupportnummer(arbete) {
+ const klient = await pool.connect();
+ try {
+ await klient.query("begin");
+ await klient.query("lock table supportarenden in exclusive mode");
+ const rad = await klient.query(`select coalesce(max(nummer), 0) + 1 as nasta from supportarenden`);
+ const resultat = await arbete(klient, Number(rad.rows[0].nasta));
+ await klient.query("commit");
+ return resultat;
+ } catch (fel) {
+ await klient.query("rollback").catch(() => {});
+ throw fel;
+ } finally {
+ klient.release();
+ }
+}
+
// Fakturan med sitt härledda tillstånd. Statusen finns inte i tabellen
// och kan inte finnas där: raden är oföränderlig.
async function fakturamedStatus(id) {
@@ -1301,6 +1336,123 @@ export function skapaServer() {
return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." });
}
+ // ---- Support och felanmälan (ALVA-PROC-0050) -------------------
+ //
+ // ALLA inloggade får anmäla, inklusive teknikern. En tröskel här
+ // ger färre anmälningar, inte färre fel — och de fel som inte
+ // anmäls är de som lever längst.
+ if (vag === "/api/support") {
+ if (req.method === "GET") {
+ const rader = await pool.query(
+ `select s.id, s.beteckning, s.arende_id, s.typ, s.rubrik, s.beskrivning,
+ s.sammanhang, s.skapad, a.namn as anmald_av,
+ coalesce(json_agg(json_build_object(
+ 'typ', i.typ, 'text', i.text, 'status', i.status,
+ 'fran', i.fran, 'skapad', i.skapad) order by i.skapad)
+ filter (where i.id is not null), '[]') as inlagg
+ from supportarenden s
+ left join anvandare a on a.id = s.skapad_av
+ left join supportinlagg i on i.support_id = s.id
+ where s.organisation_id = $1
+ group by s.id, a.namn
+ order by s.nummer desc limit 200`,
+ [anspr.org],
+ );
+ return svara(res, 200, {
+ arenden: rader.rows.map(({ inlagg, ...r }) => ({
+ ...r,
+ inlagg,
+ // Statusen lagras inte, den härleds. Se supportstatus().
+ status: supportstatus(inlagg),
+ })),
+ });
+ }
+
+ if (req.method === "POST") {
+ const kropp = await lasKropp(req);
+ const fel = granskaAnmalan(kropp);
+ if (fel) return svara(res, 400, { error: fel });
+
+ // Ärendet måste vara vårt om det anges. En anmälan är inte en
+ // väg runt organisationsgränsen.
+ if (kropp.arendeId && !(await arendeIOrg(kropp.arendeId, anspr.org))) {
+ return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
+ }
+
+ // Sammanhanget HÄRLEDS. Klienten får skicka tekniska fält, men
+ // byggSammanhang() släpper bara igenom de kända — ett fordon
+ // eller en kund kan inte smugglas in i en supportanmälan.
+ const kontext = byggSammanhang({
+ ...kropp.sammanhang,
+ arendeId: kropp.arendeId,
+ sparId: res.spår.spårId,
+ plattformsversion: kropp.sammanhang?.plattformsversion,
+ });
+
+ const skapat = await medSupportnummer(async (klient, nummer) => {
+ const rad = await klient.query(
+ `insert into supportarenden
+ (organisation_id, arende_id, nummer, beteckning, typ, rubrik, beskrivning, sammanhang, skapad_av)
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ returning id, beteckning, skapad`,
+ [
+ anspr.org,
+ kropp.arendeId ?? null,
+ nummer,
+ supportbeteckning(nummer),
+ kropp.typ,
+ kropp.rubrik.trim(),
+ kropp.beskrivning.trim(),
+ JSON.stringify(kontext),
+ anspr.sub,
+ ],
+ );
+ return rad.rows[0];
+ });
+ logga("info", "supportärende skapat", {
+ beteckning: skapat.beteckning,
+ typ: kropp.typ,
+ organisation: anspr.org,
+ spårId: res.spår.spårId,
+ });
+ return svara(res, 201, { ...skapat, status: "mottagen" });
+ }
+ }
+
+ // Svar och statusbyten. Verkstaden får svara i sitt eget ärende;
+ // status sätts bara av vår support, med egen nyckel.
+ const supportInlagg = vag.match(/^\/api\/support\/([0-9a-fA-F-]{36})\/inlagg$/);
+ if (req.method === "POST" && supportInlagg) {
+ const { text, status } = await lasKropp(req);
+ const agare = await pool.query(
+ `select organisation_id from supportarenden where id = $1`,
+ [supportInlagg[1]],
+ );
+ if (agare.rowCount === 0 || agare.rows[0].organisation_id !== anspr.org) {
+ return svara(res, 404, { error: "Supportärendet är inte tillgängligt." });
+ }
+ if (status !== undefined) {
+ if (!arSupport(req)) return svara(res, 403, { error: "Status sätts av supporten." });
+ if (!STATUSAR.includes(status)) return svara(res, 400, { error: "Okänd status." });
+ }
+ if (typeof text !== "string" || text.trim().length < 2) {
+ return svara(res, 400, { error: "Skriv något." });
+ }
+ await pool.query(
+ `insert into supportinlagg (support_id, typ, text, status, fran, skapad_av)
+ values ($1, $2, $3, $4, $5, $6)`,
+ [
+ supportInlagg[1],
+ status !== undefined ? "status" : "svar",
+ text.trim(),
+ status ?? null,
+ arSupport(req) ? "Support" : anspr.namn,
+ arSupport(req) ? null : anspr.sub,
+ ],
+ );
+ return svara(res, 200, { ok: true });
+ }
+
// Organisationens egna fakturor. Läsning, aldrig mer: kunden ser
// vad den ska betala och varför, men utfärdar inget och bokför
// inget. Kommersiella uppgifter — därför administratör.
diff --git a/felsokning/services/plattform/support.mjs b/felsokning/services/plattform/support.mjs
new file mode 120000
index 0000000..c47b7cd
--- /dev/null
+++ b/felsokning/services/plattform/support.mjs
@@ -0,0 +1 @@
+../gemensam/support.mjs
\ No newline at end of file