foxygit / RPA-Remisser Log in
commit 62d9a9744ce0ba10d5359ea53a801b6be4b21b3b
Author:     Jens Kristoffersson <jenkr@vgregion.se>
AuthorDate: Wed Sep 9 11:03:17 2026 +0200
Commit:     Jens Kristoffersson <jenkr@vgregion.se>
CommitDate: Wed Sep 9 11:03:17 2026 +0200

    Nya steg (fas 3): stop, read_url-pattern, Salma->ELVIS-skelett

    - rpa/schema.py: nytt steg 'stop' (avbryt med meddelande). read_url far ett
      valfritt 'pattern'-falt (regex-utplock ur URL:en, t.ex. arende-id).
      var_equals/var_not_equals: 'text' inte langre required ("ar variabeln tom?"
      ar ett giltigt villkor).
    - rpa/flow_engine.py: 'stop'-grenen; read_url pattern via ocr.apply_pattern;
      lookup from_var stodjer punktnotation (cfg.elvis.translationTable).
    - scripts/build_salma_flow.py: bygger data/flows/salma-elvis.json (Main + 2
      subgrafer) och delar config.json i data/exports/salma_config.json +
      salma_avsandare.json. Skelettet portar AHK-prototypen: read_url -> extract_text
      -> lookup specialWorkflows/unsupportedGroups -> save_attachment -> ocr_read x4
      -> lookup translationTable -> lookup+prompt+merge_json for avsandarnummer ->
      prompt fields (granska) -> desktop-fyll ELVIS -> call_flow uppdatera_salma.
      TODO-markta noder dar riktiga selectors behovs.
    - docs/salma-elvis.md (ny): vad flodet gor + checklista for live-verifiering.
    - docs/wiki.html + README: stegkort for ocr_read/prompt/lookup/merge_json/
      read_url/stop.
    - 144 pytest + 22 vitest grona.

    config.json / test.ahk (anvandarens kallmaterial, innehaller vardenhet) lamnas
    oincheckade.
---
 README.md                   |  19 ++++
 docs/salma-elvis.md         |  77 ++++++++++++++
 docs/wiki.html              |  62 ++++++++++++
 rpa/flow_engine.py          |  24 ++++-
 rpa/schema.py               |  17 +++-
 scripts/build_salma_flow.py | 242 ++++++++++++++++++++++++++++++++++++++++++++
 tests/test_new_steps.py     |  26 +++++
 tests/test_schema.py        |  11 ++
 8 files changed, 472 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index c8910f8..a81f01d 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,25 @@ python -m rpa.desktop_inspect                  # listar synliga toppfönster
 python -m rpa.desktop_inspect "Anteckningar"   # dumpar kontroll-identifierare
 ```

+### OCR, frågor & uppslag
+
+För arbetsflöden som blandar webb, skrivbord och skannade dokument:
+
+- **`ocr_read`** – OCR (Windows: winocr + pymupdf) av en inskannad PDF, ett
+  fönster, ett skärmområde eller en bildfil → variabel. `pattern` (regex) eller
+  `near` (värdet bredvid en etikett) plockar ut just det du vill ha. Kräver att
+  svenskt OCR-språk är installerat i Windows.
+- **`prompt`** – pausa och visa en ruta: bekräfta (OK/Avbryt), fråga efter ett
+  värde, eller visa variabler redigerbara så användaren kan rätta OCR-fel innan
+  de skrivs vidare.
+- **`lookup`** – slå upp en nyckel (`{grupp}|{spec}`) i en JSON-map (inläst
+  variabel eller fil i `data/exports/`). **`merge_json`** skriver tillbaka en
+  nyckel – för kom-ihåg-tabeller. **`read_url`** läser aktuell URL (+ regex).
+  **`stop`** avbryter med ett meddelande.
+
+Ett komplett exempel som använder allt detta finns i
+[`docs/salma-elvis.md`](docs/salma-elvis.md) (remissregistrering Sälma → ELVIS).
+
 ## AI: bygg och kör flöden med en AI-agent

 Verktyget kan drivas av en AI på två sätt. Båda använder samma stegschema
diff --git a/docs/salma-elvis.md b/docs/salma-elvis.md
new file mode 100644
index 0000000..125f50d
--- /dev/null
+++ b/docs/salma-elvis.md
@@ -0,0 +1,77 @@
+# Sälma → ELVIS: remissregistrering
+
+Skelett-flöde för att registrera remisser från **Sälma** (webbapp i Edge) i
+**ELVIS SU** (Windows-app). Portat från en tidigare AutoHotkey-prototyp.
+
+- **Flöde:** `data/flows/salma-elvis.json` (Main + subgraferna *Uppdatera Sälma*
+  och *Specialhantering*). Ligger i den gitignorerade `data/`-mappen eftersom det
+  refererar riktiga vårdenheter.
+- **Data:** `data/exports/salma_config.json` (översättningstabell, specialfall,
+  fasta värden, fältordning) och `data/exports/salma_avsandare.json`
+  (avsändarnamn → nummer, byggs på automatiskt av flödet).
+
+Regenerera bägge med `config.json` som källa:
+`python scripts/build_salma_flow.py`. **OBS:** det skriver över
+`data/flows/salma-elvis.json` – kör bara om när du medvetet vill börja om från
+skelettet (dina selector-ändringar försvinner då).
+
+## Vad flödet gör
+
+1. **Ärendenummer** ur Edge-adressen (`read_url` + regex).
+2. **Grupp / Specialitet** ur Sälma (`extract_text` – selectors är TODO).
+3. **Specialfall?** `lookup` i `specialWorkflows`. Träff → subgrafen
+   *Specialhantering* (visar instruktion, OCR:ar personnr/datum, stänger ärendet i
+   Sälma – aldrig in i ELVIS).
+4. **Ohanterad grupp?** `lookup` i `unsupportedGroups`. Träff → `stop` med ett
+   tydligt meddelande.
+5. **Remiss-PDF:** öppna Filer-fliken, ladda ner PDF:en (`save_attachment`).
+6. **OCR** (`ocr_read source=pdf`, winocr + sv-SE): personnummer, remissdatum,
+   läkare, avsändare. Inskannade PDF:er OCR:as sida för sida; PDF:er med textlager
+   läses direkt.
+7. **Översättning:** `lookup` i `translationTable` (`{grupp}|{spec}`) → team,
+   orsak, vårdnivå. Fasta värden (typ av vårdbegäran, avvikande avtal, betalarkod)
+   ur `salma_config.json`.
+8. **Avsändarnummer:** `lookup` i `salma_avsandare.json`. Ingen träff →
+   `prompt input` frågar efter numret och `merge_json` sparar det till nästa gång.
+9. **Granska:** `prompt fields` visar alla värden redigerbara – rätta OCR-fel här.
+10. **Fyll ELVIS:** `desktop_connect` + `!nb` + en `{TAB}` + `desktop_press_key`
+    per fält i `fieldOrder` (Ankomstdatum hoppas över, tomma fält bara tabbas
+    förbi). `prompt confirm` innan `Ctrl+S`.
+11. **Uppdatera Sälma:** subgrafen *Uppdatera Sälma* (delas med specialflödet) –
+    Redigera → lås upp personnummer → fyll personnr/datum → sätt Status → Spara.
+
+## Måste verifieras mot en riktig session
+
+Alla noder med `TODO:` i etiketten. I korthet:
+
+| Var | Vad |
+|---|---|
+| Main: "läs Grupp/Specialitet" | CSS-selectors i Sälmas huvudvy. Använd **⚙ → Inspektera fönster** eller `python -m rpa.codegen <sälma-url>`. |
+| Main: "öppna Filer-fliken" + "ladda ner remiss-PDF" | Hur Filer-listan ser ut (en fil? flera?) och selectorn till remiss-länken. |
+| ELVIS-fälten | Att `{TAB}`-ordningen stämmer mot `fieldOrder`, och att dropdowns (Team/Orsak/Typ/Vårdnivå) tar emot inskriven text utan att behöva väljas ur listan. Testa mot en **testremiss**. |
+| Uppdatera Sälma: penn-ikon | `desktop`-selector eller koordinat för penn-ikonen som låser upp personnummer-fältet. Just nu en `prompt` som ber dig klicka manuellt. |
+| Uppdatera Sälma: Status | Om Status är en vanlig `<select>` (inskriven text + Enter räcker) eller en custom-komponent (kräver klick på rätt listrad). `closeValue` ("Stängt") är en gissning. |
+| `remissdatum` | OCR ger `ÅÅÅÅ-MM-DD`; ELVIS kan vilja ha `ÅÅÅÅMMDD`. Rätta i granska-dialogen tills vidare, eller lägg ett litet `run_script` som formaterar om. |
+| `translationTable`-fallbacks | `groupLevelRules` (t.ex. *MEDS Vaskulärmedicin* – dynamisk orsak) och rader med `_note` (Gastro GEA/EUS) hanteras inte automatiskt än – lägg egna `if`/`lookup`-grenar vid behov. |
+
+## OCR-krav
+
+`ocr_read` kräver att **svenskt OCR-språk** är installerat i Windows. Kolla/
+installera (admin-terminal):
+
+```powershell
+Get-WindowsCapability -Online -Name "Language.OCR~~~sv-SE*"
+Add-WindowsCapability -Online -Name "Language.OCR~~~sv-SE~0.0.1.0"
+```
+
+eller Inställningar → Tid och språk → Språk → Svenska → Språkalternativ → *Optisk
+teckenläsning*. Saknas det ger `ocr_read` ett fel med exakt kommandot.
+
+## Testa delarna
+
+- **Uppdatera Sälma / Specialhantering** var för sig: flikens **▶ Kör denna flik**
+  (fyll i inputs i dialogen).
+- **`ocr_read`** isolerat: lägg en engångsnod `ocr_read source=pdf file=... var=t`
+  i ett tomt flöde mot en riktig PDF.
+- **`prompt` / `lookup` / `merge_json`** kräver ingen webbläsare – snabba att
+  prova i ett litet variabel-flöde.
diff --git a/docs/wiki.html b/docs/wiki.html
index 42b951d..b7b269a 100644
--- a/docs/wiki.html
+++ b/docs/wiki.html
@@ -534,8 +534,70 @@ run_script      scripts/merge_and_print.py
         om <code>selector</code>/<code>count</code>.</p>
     </div>

+    <div class="step-card">
+      <h3><code>ocr_read</code> <span class="tag">OCR – läs text ur bild/PDF</span></h3>
+      <ul class="field-list">
+        <li><code>source</code> – <code>pdf</code> (fil i <code>data/downloads/</code>), <code>window</code> (fönstertitel-regex), <code>region</code> (<code>x/y/w/h</code> skärmkoordinater) eller <code>image</code> (fil)</li>
+        <li><code>var</code> – variabeln att spara texten i</li>
+        <li><code>pattern?</code> – regex; namngiven grupp <code>(?P&lt;value&gt;…)</code> annars hela träffen</li>
+        <li><code>near?</code> / <code>near_mode?</code> – returnera värdet <code>same_line</code> (efter en etikett) eller <code>below</code> (raden under)</li>
+        <li><code>lang?</code> – OCR-språk, default <code>sv-SE</code></li>
+      </ul>
+      <p>Windows-only (winocr = Windows.Media.Ocr + pymupdf för PDF). Kräver att
+        OCR-språket är installerat i Windows – annars ett fel med exakt
+        DISM-kommandot. En PDF med textlager läses direkt utan OCR.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>lookup</code> <span class="tag">Slå upp i tabell</span></h3>
+      <ul class="field-list">
+        <li><code>from_var</code> – en variabel som håller en JSON-map (från <code>Läs in variabel</code>); punktnotation <code>cfg.elvis.tabell</code> går bra</li>
+        <li><code>file</code> – …eller en JSON-fil i <code>data/exports/</code></li>
+        <li><code>key</code> – nyckel att slå upp, stödjer <code>{variabel}</code> (t.ex. <code>{grupp}|{spec}</code>)</li>
+        <li><code>var</code> – resultatet (kan vara ett objekt → <code>{var.fält}</code> funkar sen)</li>
+        <li><code>ci?</code> – skiftlägesokänsligt (default på); <code>on_missing?</code> – <code>error</code> / <code>empty</code> / ett defaultvärde</li>
+      </ul>
+    </div>
+
+    <div class="step-card">
+      <h3><code>merge_json</code> <span class="tag">Skriv nyckel till JSON-fil</span></h3>
+      <ul class="field-list">
+        <li><code>file</code> – JSON-fil i <code>data/exports/</code> (skapas om den saknas)</li>
+        <li><code>key</code> / <code>value</code> – <code>data[key] = value</code>, båda stödjer <code>{variabel}</code></li>
+      </ul>
+      <p>För kom-ihåg-tabeller: fråga användaren om ett värde med <code>prompt</code>
+        och spara det här, så finns det nästa körning (läs tillbaka med
+        <code>Läs in variabel</code> + <code>lookup</code>).</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>read_url</code> <span class="tag">Läs aktuell URL</span></h3>
+      <ul class="field-list">
+        <li><code>var</code> – aktuell sidas URL</li>
+        <li><code>pattern?</code> – regex-utplock, t.ex. ett ärende-id ur adressen</li>
+      </ul>
+    </div>
+
     <h3 id="steg-kontrollflode">Kontrollflöde</h3>

+    <div class="step-card">
+      <h3><code>prompt</code> <span class="tag">Fråga användaren (paus)</span></h3>
+      <ul class="field-list">
+        <li><code>mode</code> – <code>confirm</code> (OK/Avbryt; Avbryt stoppar flödet), <code>input</code> (ett textfält → <code>var</code>), <code>fields</code> (visa de angivna variablerna redigerbara)</li>
+        <li><code>message</code> – text, stödjer <code>{variabel}</code></li>
+        <li><code>fields?</code> – komma-separerade variabelnamn (för <code>mode=fields</code>)</li>
+      </ul>
+      <p>Pausar körningen och visar en ruta på skärmen (egen process). <code>fields</code>
+        är till för att låta användaren <strong>rätta OCR-fel</strong> innan värdena
+        skrivs vidare.</p>
+    </div>
+
+    <div class="step-card">
+      <h3><code>stop</code> <span class="tag">Stoppa flödet</span></h3>
+      <ul class="field-list"><li><code>message</code> – felmeddelande, stödjer <code>{variabel}</code></li></ul>
+      <p>Avbryter körningen (t.ex. när ett ärende inte kan hanteras mekaniskt).</p>
+    </div>
+
     <div class="step-card">
       <h3><code>loop</code> <span class="tag">Loop (för varje post)</span></h3>
       <ul class="field-list">
diff --git a/rpa/flow_engine.py b/rpa/flow_engine.py
index d116c3e..05c5513 100644
--- a/rpa/flow_engine.py
+++ b/rpa/flow_engine.py
@@ -60,7 +60,9 @@ Nodtyper (fält per typ):
                                                                   -- inläst variabel eller en fil i exports/)
   merge_json        {file, key, value}                            -- data/exports/<file>[key] = value
                                                                   -- (skapar filen; kom-ihåg-tabeller)
-  read_url          {var}                                         -- aktuell sidas URL -> var
+  read_url          {var, pattern?}                               -- aktuell sidas URL -> var (pattern =
+                                                                  -- regex-utplock, t.ex. ett ärende-id)
+  stop              {message}                                     -- avbryter körningen med ett felmeddelande
   screenshot        {name?}                                     -- sparar skärmdump i data/screenshots
   list              {value, var}                       -- CSS-selector, sparar antal+selector i var
                                                           (samt en textförhandsvisning av de första 3
@@ -174,7 +176,7 @@ _DESKTOP_STEPS = frozenset({
 # separat (dess kropp = egna noder). "if" avgörs av sina villkor.
 _NO_BROWSER_STEPS = frozenset({
     "set_var", "increment", "clear_var", "save_var", "load_var", "run_script", "wait",
-    "ocr_read", "prompt", "lookup", "merge_json",
+    "ocr_read", "prompt", "lookup", "merge_json", "stop",
 })


@@ -807,6 +809,11 @@ def _execute_step(step, t, page, log, vars_, item_locator, download_dir, desktop
         from_var = step.get("from_var")
         if from_var:
             table = vars_.get(from_var)
+            if table is None and "." in from_var:  # cfg.elvis.translationTable
+                root, *rest = from_var.split(".")
+                table = vars_.get(root)
+                for k in rest:
+                    table = table.get(k) if isinstance(table, dict) else None
             src_desc = f"variabeln {from_var!r}"
         else:
             table = json.loads(_safe_export_path(_interpolate(step.get("file", ""), vars_)).read_text(encoding="utf-8"))
@@ -844,12 +851,21 @@ def _execute_step(step, t, page, log, vars_, item_locator, download_dir, desktop
         path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
         log(f"merge_json: {path.name}[{key!r}] = {value!r}")

+    elif t == "stop":
+        raise FlowError(_interpolate(step.get("message") or "Flödet stoppades.", vars_))
+
     elif t == "read_url":
         var_name = step.get("var")
         if not var_name:
             raise FlowError("read_url kräver 'var'.")
-        vars_[var_name] = page.url
-        log(f"read_url -> '{var_name}': {page.url}")
+        url = page.url
+        pattern = step.get("pattern")
+        if pattern:
+            from . import ocr
+
+            url = ocr.apply_pattern(url, pattern)
+        vars_[var_name] = url
+        log(f"read_url -> '{var_name}': {url}")

     elif t == "screenshot":
         name = step.get("name") or "screenshot"
diff --git a/rpa/schema.py b/rpa/schema.py
index e296c8e..4fa1659 100644
--- a/rpa/schema.py
+++ b/rpa/schema.py
@@ -79,7 +79,9 @@ _TIMEOUT_FIELD = {
 # Villkorstyper för "if"-grenar (CONDITION_KINDS / CONDITION_FIELD_DEFS i index.html).
 _VAR_TEXT_FIELDS = [
     {"key": "var", "kind": "text", "required": True, "label_sv": "Variabelnamn", "label_en": "Variable name"},
-    {"key": "text", "kind": "text", "required": True, "label_sv": "Jämför med", "label_en": "Compare with"},
+    # inte required: att jämföra mot tomt ("är variabeln tom?") är ett giltigt villkor
+    {"key": "text", "kind": "text", "label_sv": "Jämför med (tomt = jämför mot tom sträng)",
+     "label_en": "Compare with (empty = compare to empty string)"},
 ]

 CONDITION_KINDS: list[dict] = [
@@ -251,10 +253,12 @@ STEP_TYPES: list[dict] = [
     {
         "type": "read_url", "category": "navigation",
         "label_sv": "Läs aktuell URL", "label_en": "Read current URL",
-        "help": "Sparar den aktuella sidans URL i en variabel (t.ex. för att plocka ut ett id ur adressen med lookup/set_var).",
+        "help": "Sparar den aktuella sidans URL i en variabel. Med 'pattern' plockas bara en regex-träff ut (namngiven grupp (?P<value>…) om den finns, annars hela träffen) – t.ex. ett ärende-id ur adressen.",
         "fields": [
             {"key": "var", "kind": "text", "required": True,
              "label_sv": "Spara som variabel", "label_en": "Save as variable"},
+            {"key": "pattern", "kind": "text",
+             "label_sv": "Regex-utplock (valfritt)", "label_en": "Regex extraction (optional)"},
         ],
         "produces_vars": ["var"],
     },
@@ -546,6 +550,15 @@ STEP_TYPES: list[dict] = [
         "consumes_vars": ["over"],
         "produces_vars": ["item_var", "index_var"],
     },
+    {
+        "type": "stop", "category": "control",
+        "label_sv": "Stoppa flödet", "label_en": "Stop the flow",
+        "help": "Avbryter körningen med ett felmeddelande (t.ex. när ett ärende inte kan hanteras mekaniskt). 'message' stödjer {variabel}.",
+        "fields": [
+            {"key": "message", "kind": "text", "interpolate": True, "default": "Flödet stoppades.",
+             "label_sv": "Meddelande", "label_en": "Message"},
+        ],
+    },
     {
         "type": "call_flow", "category": "control",
         "label_sv": "Anropa subflöde", "label_en": "Call subflow",
diff --git a/scripts/build_salma_flow.py b/scripts/build_salma_flow.py
new file mode 100644
index 0000000..553fbc5
--- /dev/null
+++ b/scripts/build_salma_flow.py
@@ -0,0 +1,242 @@
+"""Bygger skelett-flödet data/flows/salma-elvis.json och delar config.json i
+laddbara delar i data/exports/ (salma_config.json + salma_avsandare.json).
+
+    python scripts/build_salma_flow.py
+
+Kör från projektroten (där config.json ligger). Se docs/salma-elvis.md.
+Idempotent – kör om efter att du ändrat config.json. OBS: skriver över
+data/flows/salma-elvis.json (dina egna selector-ändringar går förlorade –
+kör bara om när du medvetet vill regenerera skelettet).
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+from rpa import config, flows  # noqa: E402
+
+
+def _write_config_parts() -> None:
+    cfg = json.loads((ROOT / "config.json").read_text(encoding="utf-8"))
+    elvis = cfg["elvis"]
+    config.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
+
+    (config.EXPORT_DIR / "salma_config.json").write_text(
+        json.dumps({
+            "translationTable": elvis["translationTable"],
+            "groupLevelRules": elvis["groupLevelRules"],
+            "specialWorkflows": elvis["specialWorkflows"],
+            "unsupportedGroups": elvis["unsupportedGroups"],
+            "fixedValues": elvis["fixedValues"],
+            "skipFields": elvis["skipFields"],
+            "fieldOrder": elvis["fieldOrder"],
+            "betalarkodNote": elvis["betalarkodNote"],
+        }, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    avs = {k: v for k, v in elvis["avsandarregister"].items()
+           if not k.startswith("_") and k != "fallbackNumber"}
+    avs["_fallback"] = elvis["avsandarregister"]["fallbackNumber"]
+    (config.EXPORT_DIR / "salma_avsandare.json").write_text(
+        json.dumps(avs, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+class G:
+    """Liten nod/kant-byggare."""
+
+    def __init__(self) -> None:
+        self.nodes: list[dict] = []
+        self.edges: list[dict] = []
+        self._n = 0
+
+    def add(self, typ, _from=None, _handle="out", label="", **fields):
+        self._n += 1
+        nid = f"{typ[:4]}{self._n}"
+        node = {"id": nid, "type": typ, **fields}
+        if label:
+            node["label"] = label
+        self.nodes.append(node)
+        if _from:
+            self.edge(_from, nid, _handle)
+        return nid
+
+    def edge(self, src, tgt, handle="out"):
+        self.edges.append({"id": f"e{len(self.edges)}", "source": src,
+                           "sourceHandle": handle, "target": tgt, "targetHandle": "in"})
+
+    def result(self, start):
+        return {"nodes": self.nodes, "edges": self.edges, "start": start}
+
+
+def _build_main() -> dict:
+    m = G()
+    start = m.add("prompt", mode="confirm",
+                  message="Stå på ärendets HUVUDVY i Sälma (inte Redigera-läge). Fortsätt?",
+                  label="Startkontroll")
+    c = m.add("read_url", start, var="arendenr", pattern=r"\d{5,8}", label="Ärendenummer ur adressen")
+    c = m.add("extract_text", c, by="css", value="TODO: selector för Grupp", scope="page", var="grupp",
+              label="TODO: läs Grupp i Sälma")
+    c = m.add("extract_text", c, by="css", value="TODO: selector för Specialitet", scope="page", var="spec",
+              label="TODO: läs Specialitet i Sälma")
+    c = m.add("load_var", c, var="cfg", filename="salma_config.json")
+    c = m.add("load_var", c, var="avs", filename="salma_avsandare.json")
+
+    c = m.add("lookup", c, from_var="cfg.specialWorkflows", key="{grupp}|{spec}", var="special",
+              ci=True, on_missing="empty", label="Specialflöde? (ej ELVIS)")
+    ifsp = m.add("if", c, branches=[{"condition": {"kind": "var_not_equals", "var": "special", "text": ""}}],
+                 label="Special vs normal")
+    m.add("call_flow", ifsp, "case0", target="#special",
+          **{"in": [{"name": "arendenr", "value": "{arendenr}"},
+                    {"name": "instruktion", "value": "{special}"}], "out": []},
+          label="Specialhantering")
+
+    c = m.add("lookup", ifsp, "else", from_var="cfg.unsupportedGroups", key="{grupp}", var="unsup",
+              ci=True, on_missing="empty", label="Ohanterad grupp?")
+    ifun = m.add("if", c, branches=[{"condition": {"kind": "var_not_equals", "var": "unsup", "text": ""}}],
+                 label="Stopp om ohanterad")
+    m.add("stop", ifun, "case0", message="Gruppen '{grupp}' hanteras inte av flödet: {unsup}",
+          label="Stanna – manuell hantering")
+
+    c = m.add("click", ifun, "else", by="text", value="Filer", scope="page", on_error="continue",
+              label="TODO: öppna Filer-fliken")
+    c = m.add("save_attachment", c, by="css", value="TODO: selector för remiss-länken", scope="page",
+              filename="remiss_{arendenr}.pdf", var="pdf_paths", label="TODO: ladda ner remiss-PDF")
+    for var, pat, near, lbl in [
+        ("personnummer", r"(19|20)?\d{6}[-+]?\d{4}", None, "OCR: personnummer"),
+        ("remissdatum", r"\d{4}-\d{2}-\d{2}", None, "OCR: remissdatum"),
+        ("lakarnamn", None, "Läkare", "OCR: läkare"),
+        ("avsandarnamn", None, "Avsändare", "OCR: avsändare"),
+    ]:
+        step: dict = {"source": "pdf", "file": "remiss_{arendenr}.pdf", "var": var, "lang": "sv-SE"}
+        if pat:
+            step["pattern"] = pat
+        if near:
+            step["near"] = near
+            step["near_mode"] = "same_line"
+        c = m.add("ocr_read", c, label=lbl, **step)
+
+    c = m.add("lookup", c, from_var="cfg.translationTable", key="{grupp}|{spec}", var="trans",
+              ci=True, on_missing="error", label="Översättningstabell")
+    for var, text in [
+        ("team", "{trans.team}"), ("orsak", "{trans.orsak}"), ("vardniva", "{trans.vardniva}"),
+        ("typAvVardbegaran", "{cfg.fixedValues.typAvVardbegaran}"),
+        ("avvikandeAvtal", "{cfg.fixedValues.avvikandeAvtal}"),
+        ("betalarkod", "{cfg.fixedValues.betalarkod}"),
+        ("internNotering", "{arendenr}"), ("beslutsdatum", "{remissdatum}"),
+    ]:
+        c = m.add("set_var", c, var=var, text=text)
+
+    c = m.add("lookup", c, from_var="avs", key="{avsandarnamn}", var="fran", ci=True, on_missing="empty",
+              label="Slå upp avsändarnummer")
+    ifav = m.add("if", c, branches=[{"condition": {"kind": "var_equals", "var": "fran", "text": ""}}],
+                 label="Nytt avsändarnummer?")
+    p = m.add("prompt", ifav, "case0", mode="input", var="fran",
+              message="Ingen träff i avsändarregistret för '{avsandarnamn}'.\nSlå upp numret i ELVIS "
+                      "(förstoringsglaset vid Från) och skriv in det – lämna tomt för fallback.",
+              label="Fråga efter nummer")
+    p = m.add("merge_json", p, file="salma_avsandare.json", key="{avsandarnamn}", value="{fran}",
+              label="Kom ihåg numret")
+    review = m.add("prompt", mode="fields",
+                   message="Kontrollera alla värden innan de skrivs in i ELVIS. Rätta ev. OCR-fel.",
+                   fields="personnummer, remissdatum, beslutsdatum, team, orsak, typAvVardbegaran, "
+                          "fran, vardniva, avvikandeAvtal, betalarkod, internNotering",
+                   label="Granska innan ELVIS")
+    m.edge(p, review)
+    m.edge(ifav, review, "else")
+
+    c = m.add("desktop_connect", review, title="ELVIS SU", label="Anslut till ELVIS")
+    c = m.add("desktop_press_key", c, keys="{personnummer}{ENTER}{ENTER}", label="Personnummer + Enter x2")
+    c = m.add("desktop_press_key", c, keys="!nb", label="Ny vårdbegäran (Alt+N,B)")
+    fields = [
+        ("beslutsdatum", "{beslutsdatum}"), ("(vårdgaranti – hoppas över)", ""),
+        ("team", "{team}"), ("orsak", "{orsak}"), ("typAvVardbegaran", "{typAvVardbegaran}"),
+        ("(accepterar ej hänvisning)", ""), ("fran", "{fran}"),
+        ("(vårdenhet)", ""), ("(gatuadress)", ""), ("(postadress)", ""), ("(avsändande resurs)", ""),
+        ("internNotering", "{internNotering}"), ("(betalningsförbindelse – hoppas över)", ""),
+        ("vardniva", "{vardniva}"), ("avvikandeAvtal", "{avvikandeAvtal}"), ("betalarkod", "{betalarkod}"),
+    ]
+    for name, val in fields:
+        c = m.add("desktop_press_key", c, keys="{TAB}", label=f"Tab -> {name}")
+        if val:
+            c = m.add("desktop_press_key", c, keys=val, label=f"Skriv {name}")
+    c = m.add("prompt", c, mode="confirm",
+              message="Granska det ifyllda ELVIS-formuläret på skärmen. OK = spara (Ctrl+S), Avbryt = stoppa.",
+              label="Granska ELVIS")
+    c = m.add("desktop_press_key", c, keys="^s", label="Spara ELVIS (Ctrl+S)")
+    c = m.add("desktop_press_key", c, keys="%{LEFT}", label="Tillbaka (Alt+Vänster)")
+    m.add("call_flow", c, target="#uppdatera_salma",
+          **{"in": [{"name": "personnummer", "value": "{personnummer}"},
+                    {"name": "remissdatum", "value": "{remissdatum}"},
+                    {"name": "status", "value": "Tilldelat"}], "out": []},
+          label="Uppdatera Sälma")
+    return m.result(start)
+
+
+def _build_uppdatera_salma() -> dict:
+    s = G()
+    first = s.add("desktop_connect", title="ahk_exe msedge.exe", label="TODO: fönster för Sälma (Edge)")
+    c = s.add("desktop_click", first, by="name", value="Redigera", label="TODO: klicka Redigera")
+    c = s.add("prompt", c, mode="confirm",
+              message="Klicka penn-ikonen bredvid Personnummer i Sälma (fältet är låst), klicka sedan OK.",
+              label="Lås upp personnummer (manuellt tills selector finns)")
+    c = s.add("desktop_type", c, by="name", value="TODO: personnummer-fältet", text="{personnummer}",
+              label="TODO: skriv personnummer")
+    c = s.add("desktop_click", c, by="name", value="Kontrollera personnummer", on_error="continue",
+              label="TODO: Kontrollera personnummer")
+    c = s.add("prompt", c, mode="confirm",
+              message="Bekräfta 'Kontrollera personnummer'-popupen i Sälma, klicka sedan OK.",
+              label="Popup: kontrollera personnummer")
+    c = s.add("desktop_type", c, by="name", value="TODO: remissdatum-fältet", text="{remissdatum}",
+              label="TODO: skriv remissdatum")
+    c = s.add("desktop_type", c, by="name", value="TODO: status-fältet", text="{status}",
+              label="TODO: sätt Status")
+    c = s.add("prompt", c, mode="confirm",
+              message="Kontrollera Personnummer, Remissdatum och Status i Sälma. OK = Spara, Avbryt = stoppa.",
+              label="Granska Sälma")
+    s.add("desktop_click", c, by="name", value="Spara", label="Spara Sälma")
+    return s.result(first)
+
+
+def _build_special() -> dict:
+    sp = G()
+    first = sp.add("prompt", mode="confirm", message="{instruktion}", label="Instruktioner för specialhantering")
+    c = sp.add("click", first, by="text", value="Filer", scope="page", on_error="continue", label="TODO: Filer-fliken")
+    c = sp.add("save_attachment", c, by="css", value="TODO: remiss-länken", scope="page",
+               filename="remiss_{arendenr}.pdf", var="pdf_paths", label="TODO: ladda ner remiss-PDF")
+    c = sp.add("ocr_read", c, source="pdf", file="remiss_{arendenr}.pdf", var="personnummer",
+               pattern=r"(19|20)?\d{6}[-+]?\d{4}", label="OCR: personnummer")
+    c = sp.add("ocr_read", c, source="pdf", file="remiss_{arendenr}.pdf", var="remissdatum",
+               pattern=r"\d{4}-\d{2}-\d{2}", label="OCR: remissdatum")
+    sp.add("call_flow", c, target="#uppdatera_salma",
+           **{"in": [{"name": "personnummer", "value": "{personnummer}"},
+                     {"name": "remissdatum", "value": "{remissdatum}"},
+                     {"name": "status", "value": "Stängt"}], "out": []},
+           label="Uppdatera Sälma (stäng)")
+    return sp.result(first)
+
+
+def main() -> None:
+    _write_config_parts()
+    main_g = _build_main()
+    flow = {
+        "format": "graph",
+        "nodes": main_g["nodes"], "edges": main_g["edges"], "start": main_g["start"],
+        "settings": {}, "inputs": [],
+        "subflows": [
+            {"id": "uppdatera_salma", "name": "Uppdatera Sälma", **_build_uppdatera_salma(),
+             "inputs": [{"name": "personnummer"}, {"name": "remissdatum"},
+                        {"name": "status", "default": "Tilldelat"}]},
+            {"id": "special", "name": "Specialhantering", **_build_special(),
+             "inputs": [{"name": "arendenr"}, {"name": "instruktion"}]},
+        ],
+    }
+    flows.save_flow("salma-elvis", flow, strict=False)
+    print(f"data/flows/salma-elvis.json: {len(main_g['nodes'])} main-noder, 2 subgrafer")
+    print("data/exports/salma_config.json + salma_avsandare.json")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/test_new_steps.py b/tests/test_new_steps.py
index fe1d666..1eef5f5 100644
--- a/tests/test_new_steps.py
+++ b/tests/test_new_steps.py
@@ -53,6 +53,32 @@ def test_lookup_requires_key_and_var():
         run_step({"type": "lookup", "from_var": "cfg", "var": "r"}, {"cfg": TABLE})


+def test_lookup_dotted_from_var_walks_nested_dict():
+    cfg = {"elvis": {"translationTable": TABLE}}
+    v = run_step(
+        {"type": "lookup", "from_var": "cfg.elvis.translationTable", "key": "X|Y", "var": "r"},
+        {"cfg": cfg},
+    )
+    assert v["r"] == "platt"
+
+
+# --- stop ------------------------------------------------------------
+
+def test_stop_raises_interpolated_message():
+    with pytest.raises(FlowError, match="Gruppen 'MEDS Obesitas' hanteras inte"):
+        run_step({"type": "stop", "message": "Gruppen '{g}' hanteras inte"}, {"g": "MEDS Obesitas"})
+
+
+# --- read_url pattern ------------------------------------------------
+
+def test_read_url_pattern_extracts_id():
+    page = type("P", (), {"url": "https://salma.vgregion.se/incident/1269212?x=1"})()
+    v = run_step({"type": "read_url", "var": "nr", "pattern": r"/incident/(?P<value>\d+)"}, {}, page=page)
+    assert v["nr"] == "1269212"
+    v = run_step({"type": "read_url", "var": "nr2", "pattern": r"\d{5,8}"}, {}, page=page)
+    assert v["nr2"] == "1269212"
+
+
 # --- merge_json --------------------------------------------------------

 def test_merge_json_creates_and_updates(tmp_path, monkeypatch):
diff --git a/tests/test_schema.py b/tests/test_schema.py
index ff50f00..1a6e58a 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -136,6 +136,17 @@ def test_ocr_read_source_enum_checked():
     assert any(".source" in e for e in schema.validate_graph({"nodes": [node], "edges": []}, strict=False))


+def test_stop_step_registered():
+    assert "stop" in schema.STEP_TYPE_NAMES
+
+
+def test_var_equals_empty_string_is_valid():
+    """Att jämföra en variabel mot tom sträng ('är den tom?') ska gå igenom."""
+    node = {"id": "f", "type": "if",
+            "branches": [{"condition": {"kind": "var_equals", "var": "x", "text": ""}}]}
+    assert schema.validate_graph({"nodes": [node], "edges": []}, strict=True) == []
+
+
 def test_subflow_graph_is_validated():
     flow = {
         "nodes": [], "edges": [],