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<value>…)</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": [],