#!/usr/bin/env python3
"""Build the offline, multi-page Lunis report from reviewed local JSON.
Run: python endbericht/tools/build.py
Only writes inside endbericht/. No accounts, network, publication or raw data.
"""
from pathlib import Path
from html import escape as e
from collections import Counter
import json,csv,io,hashlib
from icons import icon,ICONS
import graphics
R=Path(__file__).resolve().parents[1]
def load(name):return json.loads((R/'data'/name).read_text(encoding='utf-8'))
NAV=load('navigation.json');F=load('funktionsmatrix.json');M=load('modelle.json');MARKET=load('markt.json');SOURCES=load('quellen.json');NOTES=load('gespraechsnotizen.json');DEMOS=load('demos.json');MEDIA=load('media.json')
NI=['grid','check-circle','clock','route','layers','bolt','wallet','chart','search','chat','shield','down']
STATUS={'belegt':('Belegt','check-circle'),'vorhanden':('Bestand vorhanden','package'),'demo':('Nur Demo','monitor'),'teilweise':('Teilweise','partial'),'blockiert':('Blockiert','cross'),'offen':('Nicht geprüft','circle')}
BI={'Lieferbestand':'package','Erfassung':'scan','Fachlogik':'layers','Sicherheit':'lock','Integration':'link','Betrieb':'server','Tests':'check-circle','Konzepte':'cube','Kaufmännisch':'wallet','Berichtswebsite':'monitor'}
META={
'index':('Lunis Ist-Zeit · Entwicklungsbericht','Ein Überblick für Geschäftsführung und IT.'),
'funktion':('Was funktioniert. Was noch nicht.','Bestand, Demo, Test und Produktivbetrieb werden getrennt bewertet.'),
'leistung':('40 Stunden. Nachvollziehbar aufgeteilt.','Die Leistungsbasis mit Stunden, Rechenwert und klarer Abgrenzung zur Maschinenlaufzeit.'),
'ablauf':('So ist der Arbeitsstand entstanden.','Vom Kundenbedarf über den Bau bis zur Prüfung. Historischer Ablauf und nächster Pilot bleiben getrennt.'),
'technik':('Zwei technische Wege. Ein Fachablauf.','Zeiten erfassen, prüfen und buchen: mit Odoo oder als eigenständige Anwendung.'),
'oneshot':('Schnell bauen. Danach prüfen.','Ein Auftrag kann eine bedienbare Oberfläche erzeugen. Das ist noch keine vollständige Anwendung oder Abnahme.'),
'kaufmaennisch':('Kosten verstehen. Nutzen prüfen.','Was der Entwicklungsstand rechnerisch kostet – und welche Daten für eine Investitionsentscheidung noch fehlen.'),
'markt':('Der Markt. Nicht die Marketingzahl.','Tischlerbranche, Wettbewerb und vorhandener Datenumfang – mit getrennten Jahren und Quellen.'),
'research':('Aus Unterlagen werden Entscheidungen.','Wie Aussagen geprüft, Texte aufbereitet und Markenentwürfe vom Lieferstand getrennt werden.'),
 'transkripte':('Gespräche, auf das Wesentliche gekürzt.','Anonymisierte Zusammenfassungen zum Lesen, Kopieren und Exportieren. Keine Rohtranskripte.'),
'qs':('Die Belege. Und ihre Grenzen.','Historische Prüfungen, aktuelle Paketprüfung und offene Freigaben an einem Ort.'),
'export':('Alles Wichtige zum Mitnehmen.','PDFs, Tabellen, Grafiken, Gesprächsnotizen und die Skripte für Bau und Prüfung.')}
PAGES={}
def badge(st,label=None):
 label0,ic=STATUS[st];return f'<span class="badge {st}">{icon(ic)}{e(label or label0)}</span>'
def src(*ids):return '<div class="source">'+icon('link')+'Quelle: '+ ' · '.join(f'<a href="qs.html#{x.lower()}">{e(x)}</a>' for x in ids)+'</div>'
def btn(label,href='',ic='arrow',cls='',attrs=''):
 return f'<a class="btn {cls}" href="{e(href,quote=True)}" {attrs}>{icon(ic)}<span>{e(label)}</span></a>' if href else f'<button class="btn {cls}" type="button" {attrs}>{icon(ic)}<span>{e(label)}</span></button>'
def section(id,title,body,lead='',kicker=''):
 return f'<section class="section" id="{id}"><div class="section-head">'+(f'<div><div class="eyebrow">{e(kicker)}</div><h2>{title}</h2></div>' if kicker else f'<h2>{title}</h2>')+(f'<p>{lead}</p>' if lead else '')+f'</div><div class="reveal">{body}</div></section>'
def note(text,warn=False):return '<div class="note '+('warning' if warn else '')+'">'+icon('warning' if warn else 'info')+'<div>'+text+'</div></div>'
def details(title,body):return f'<details class="details"><summary>{icon("book")}{e(title)}</summary><div class="details-body">{body}</div></details>'
def card(ic,title,body,cls='',foot=''):
 return f'<article class="card {cls}"><div class="card-top"><span class="icon-tile {"red" if cls=="pink" else ""}">{icon(ic)}</span></div><h3>{title}</h3><p>{body}</p>{foot}</article>'
def kpis(items):
 return '<div class="kpis">'+''.join(f'<article class="kpi {cls}"><div class="label">{icon(ic)}{e(label)}</div><div class="value">{val}</div><p class="hint">{hint}</p></article>' for ic,label,val,hint,cls in items)+'</div>'
def count(n,dec=0):return f'<span data-count="{n}" data-decimals="{dec}">{format(n,f",.{dec}f").replace(",","X").replace(".",",").replace("X",".")}</span>'
def figure(name,title,desc,source='S01'):
 return f'<figure class="figure-card"><div class="figure-head"><div><h3>{title}</h3><small>{e(desc)}</small></div>{btn("SVG",f"assets/graphics/{name}.svg","down","small", "download")}</div><img class="graphic" src="assets/graphics/{name}.svg" alt="{e(title+": "+desc,quote=True)}" loading="lazy"><figcaption>{e(desc)}{src(source)}</figcaption></figure>'
def table(head,rows,cls='',id=''):
 return f'<div class="table-scroll {cls}" role="region" tabindex="0" aria-label="{e(head[0])} – Datentabelle"><table'+(f' id="{id}"' if id else '')+'><thead><tr>'+''.join('<th scope="col">'+h+'</th>' for h in head)+'</tr></thead><tbody>'+''.join('<tr'+(' class="total"' if isinstance(r,tuple) else '')+'>'+''.join('<td>'+str(c)+'</td>' for c in (r[0] if isinstance(r,tuple) else r))+'</tr>' for r in rows)+'</tbody></table></div>'
def bullets(items,ic='check'):
 return '<ul>'+''.join('<li>'+icon(ic)+'<span>'+s+'</span></li>' for s in items)+'</ul>'
def summary_verdict():
 return '<div class="verdict-grid">'+card('package','Vorhanden', 'Zwei Lösungswege und dokumentierte Prüfungen.', foot=bullets(['7 Odoo-18-Fachmodule','Eigenständiger Anwendungsweg','29 historische Test-PASS']))+card('partial','Teilweise', 'Die Fachlogik ist angelegt; die Prüfung ist nicht überall abgeschlossen.', 'pink',bullets(['Erfassen, prüfen und buchen','Kolonnen und Verteilung','PIN-Sperre integriert prüfen'],'partial'))+card('warning','Noch nicht freigegeben','Die entscheidenden Betriebsnachweise fehlen.',foot=bullets(['Realer Scanner bis zur Buchung','Odoo-19-Kompatibilität','Produktivbetrieb beim Kunden'],'circle'))+'</div>'+src('S01','S02','S03','S07')
def photo(name,title,desc,tag='Historischer Bildstand'):
 return f'<figure class="image-card"><button type="button" data-image="assets/media/{name}" data-caption="{e(title+" · "+desc,quote=True)}" aria-label="{e(title)} vergrößern"><img src="assets/media/{name}" alt="{e(title)}" loading="lazy"></button><figcaption><span class="badge">{icon("image")}{e(tag)}</span><h3>{e(title)}</h3><p>{desc}</p></figcaption></figure>'
def matrix():
 options='<option value="alle">Alle Stände</option>'+''.join(f'<option value="{k}">{v[0]}</option>' for k,v in STATUS.items())
 filters=f'<div class="filters"><label class="search-field">{icon("search")}<span class="sr-only">Funktion suchen</span><input id="matrix-search" type="search" placeholder="Funktion suchen …"></label><label><span class="sr-only">Nach Status filtern</span><select id="matrix-status">{options}</select></label>{btn("Gefilterte CSV",ic="down",attrs="data-export=matrix-csv")}{btn("JSON",ic="code",attrs="data-export=matrix-json")}<span class="filter-meta" id="matrix-count">{len(F)} Einträge</span></div>'
 rows=''
 for f in F:
  rows+=f'<tr data-status="{f["status"]}" data-feature="{f["id"]}"><td><div class="feature-icon">{icon(BI[f["bereich"]])}<div>{e(f["funktion"])}<small>{e(f["id"]+" · "+f["bereich"])}</small></div></div></td><td>{badge(f["status"])}</td><td>{e(f["vorhanden"])}<small>{e(f["beleggrenze"])}</small>{src(f["quelle"])}</td><td>{e(f["naechster_schritt"])}</td></tr>'
 return filters+f'<div class="table-scroll" tabindex="0" role="region" aria-label="Funktionsmatrix"><table class="matrix-table" id="feature-matrix"><thead><tr><th scope="col">Funktion</th><th scope="col">Stand</th><th scope="col">Was der Beleg aussagt</th><th scope="col">Was als Nächstes nötig ist</th></tr></thead><tbody>{rows}</tbody></table><div id="matrix-empty" class="empty-state" hidden>Keine passende Funktion. Filter zurücksetzen oder anders suchen.</div></div>'
def demo_stage(kind='produkt'):
 selected=[d for d in DEMOS if (d['id'] in ['lunis-workspace','lunis-pwa','brand-studio']) == (kind=='produkt')]
 if not selected:return note('Keine passende Demo im Paket.')
 first=selected[0];tabs=''.join(btn(d['titel'],ic='monitor' if kind=='produkt' else 'cube',attrs=f'role="tab" aria-selected="{str(i==0).lower()}" aria-controls="demo-panel-{kind}" data-demo="{d["id"]}"') for i,d in enumerate(selected))
 return f'<div class="demo-tabs" role="tablist" aria-label="Demos auswählen">{tabs}</div><div class="demo-stage" id="demo-panel-{kind}" role="tabpanel"><div class="demo-toolbar"><div class="demo-title">{icon("monitor")}<div><span data-demo-title>{e(first["titel"])}</span><small>Lokaler Prototyp · kein laufendes Odoo</small></div></div><div class="button-row">{btn("Vollbild",ic="expand",cls="small",attrs="data-fullscreen")}{btn("Öffnen",first["datei"],"arrow-up","small",'data-demo-open target="_blank" rel="noopener"')}{btn("HTML",first["datei"],"down","small",'data-demo-download download')}</div></div><iframe src="{first["datei"]}" title="{e(first["titel"],quote=True)} – lokaler Prototyp" sandbox="allow-scripts allow-downloads" allow="fullscreen" loading="lazy" referrerpolicy="no-referrer"></iframe><div class="print-demo">{icon("monitor")} Die interaktive Demo liegt im Offline-HTML-Paket. Ein PDF enthält keine ausführbare Anwendung.<br><strong data-demo-print>{e(first["titel"])}</strong></div><div class="demo-origin"><strong>Herkunft S11:</strong> bereitgestellter HTML-Prototyp, für diesen Bericht redigiert. <span data-demo-detail>{e(first["anpassung"])}</span> <strong>Browserprüfung hier blockiert.</strong></div></div>'
# 01 – overview
hero=f'<section class="hero" id="ueberblick"><div><div class="eyebrow">Lunis ist Zeit · 21. September 2026</div><h1>Was gebaut wurde.<br><span class="accent">Was noch fehlt.</span></h1><p>Zwei technische Wege für die Zeiterfassung. Ein vorgegebener Rahmen von 40 Stunden. Die Ergebnisse, Kosten und nächsten Entscheidungen auf einen Blick.</p><div class="button-row">{btn("5-Minuten-Tour","index.html?tour=kurz&step=0","play","primary")}{btn("Was funktioniert?","funktion.html","check-circle")}</div></div><div class="hero-scene" aria-label="Schematischer Ablauf: Erfassen, prüfen und buchen"><div class="orbital"></div><div class="orbital second"></div><img class="hero-tile" src="brand/lunis-kachel.svg" alt="Lunis-Flammenkachel"><div class="float-card a">{icon("scan")}<div>Erfassen<small>Handy · Büro · Scanner</small></div></div><div class="float-card b">{icon("check-circle")}<div>Prüfen<small>Fehler und Zuordnung</small></div></div><div class="float-card c">{icon("database")}<div>Buchen<small>Nachvollziehbarer Stand</small></div></div><span class="scene-caption">Zielablauf · kein Funktionsnachweis</span></div></section>'
hero+=kpis([('clock','Leistungsrahmen',count(40)+' <small>h</small>','Nutzervorgabe, keine Zeitmessung.',''),('layers','Lösungswege',count(2),'Odoo und eigenständige Anwendung.',''),('wallet','Rechenwert netto',count(6000)+' <small>€</small>','40 × 150 €; keine Rechnung.',''),('flag','Nächste Entscheidung','Pilot','Begrenzte Erprobung statt Produktivfreigabe.','accent-card')])
executive='<div class="split"><article class="card"><div class="card-top"><span class="icon-tile">'+icon('file')+'</span><span class="card-number">FÜNF SÄTZE</span></div><div class="summary-five">'+''.join('<p>'+x+'</p>' for x in [
'Für Lunis Ist-Zeit sind zwei technische Wege dokumentiert: Odoo 18 Community und eine eigenständige Anwendung.',
'Dazu gehören sieben Odoo-Fachmodule, Bedienoberflächen und begleitende Konzeptdateien.',
'Der vorgegebene Leistungsrahmen beträgt 40 Stunden; 35 Stunden sind direkt aufgelistet und rund fünf Stunden geschätzt zugeordnet.',
'Die Rechnung 40 × 150 Euro ergibt 6.000 Euro netto, aber weder eine Rechnung noch eine Zahlung.',
'Vor dem Kundeneinsatz sind unter anderem Version, Scannerablauf, Sicherheitsprüfung und Betriebsabnahme zu klären.'])+'</div>'+src('S01','S02','S03')+'</article><article class="card decision"><div class="card-top"><span class="icon-tile red">'+icon('flag')+'</span>'+badge('offen','Entscheidung offen')+'</div><h3>Eine Variante für den Pilot wählen.</h3><p>Beide Wege im Dauerbetrieb parallel weiterzuführen würde Prüfung und Pflege verdoppeln. Der nächste Auftrag braucht einen begrenzten Umfang und klare Abnahmekriterien.</p><div class="decision-list">'+''.join('<div>'+icon(i)+'<span>'+t+'</span></div>' for i,t in [('layers','Odoo oder eigenständigen Weg auswählen.'),('users','Verantwortliche für Betrieb und Abnahme festlegen.'),('scan','Einen realen Erfassungsweg vollständig testen.')])+'</div><div class="button-row">'+btn('Entscheidungsgrundlage','kaufmaennisch.html','arrow','primary')+'</div></article></div>'
PAGES['index']=hero+section('kern','Der Stand in fünf Sätzen.',executive)+section('kurzmatrix','Drei Antworten statt einer Fertig-Meldung.',summary_verdict())+section('zielablauf','Der Ablauf, den der Kunde braucht.',figure('fachablauf','Erfassen → prüfen → buchen','Fachliches Zielbild. Die vollständige Geräte-zu-Buchung-Abnahme fehlt.','S03'))+section('lesewege','Der passende Einstieg.', '<div class="grid3">'+''.join(f'<a class="card card-nav" href="{href}"><div class="card-top"><span class="icon-tile">{icon(ic)}</span>{icon("arrow")}</div><h3>{title}</h3><p>{desc}</p></a>' for ic,title,desc,href in [('play','5 Minuten','Ergebnis, Funktionsstand, Kosten und Entscheidung.','index.html?tour=kurz&step=0'),('layers','30 Minuten','Architektur, Nachweise, Grenzen und Pilotprüfung.','technik.html?tour=tief&step=0'),('down','Direkt exportieren','PDFs, Funktionsmatrix, Grafiken und Skripte.','export.html')])+'</div>')
# 02 – functional truth
PAGES['funktion']=section('befund','Vorhanden ist nicht gleich abgenommen.',summary_verdict())+note('<strong>So lesen Sie die Tabelle:</strong> „Bestand vorhanden“ belegt Dateien oder Module. „Nur Demo“ beschreibt einen Prototyp. „Belegt“ gilt nur für den angegebenen Nachweis. „Nicht geprüft“ bedeutet nicht automatisch „funktioniert nicht“.')+section('funktionsmatrix','Die Funktionsmatrix.',matrix(),'Jede Zeile nennt den Stand, die Beleggrenze und den nächsten Prüfschritt.')+section('bewertung','Bewertung für die nächste Entscheidung.',table(['Bereich','Einordnung','Konsequenz'],[
['Bedienung zeigen',badge('demo','Mit Prototypen möglich'),'Für Gespräche und Anforderungen verwenden; nicht als Produktivsystem vorführen.'],
['Technischer Bestand',badge('vorhanden'),'Weiterentwicklung ist möglich; Installation und Zusammenspiel neu prüfen.'],
['Nachweise',badge('teilweise'),'Historische Tests sind nützlich, aber kein heutiger Gesamt-PASS.'],
['Kundeneinsatz',badge('offen','Keine Freigabe belegt'),'Erst den begrenzten Pilot durchführen und abnehmen.'],
['Wirtschaftlicher Nutzen',badge('offen','Noch nicht gemessen'),'Im Pilot Zeitgewinn, Nutzung und Betriebskosten erfassen.']]),'Redaktionelle Einordnung auf Basis der dokumentierten Belege. Keine erfundene Punktzahl.')
# 03 – effort
hours=[['Vorarbeit','19.01.2026','Beratung','4,0'],['Vorarbeit','12.05.2026','Schulung','1,5'],['Vorarbeit','08.07.2026','Technischer Aufbau','1,5'],['Vorarbeit','03.08.2026','Infrastrukturarchitektur','2,5'],['September','15.–18.09.2026','Auswertung und Anforderungen','6,0'],['September','19.09.2026','Architektur und Prüfstand','4,5'],['September','19.–20.09.2026','Auftragspapiere und Steuerung','7,0'],['September','20.09.2026','Gegenprüfung und Bildabnahme','5,0'],['September','20.09.2026','Dokumentation und Übergabe','3,0'],['Schätzung','Ergänzende Zuordnung','Noch separat zu bestätigen','ca. 5,0'],(['Gesamter Berichtsrahmen','','35,0 direkt + ca. 5,0 geschätzt','40,0 h'],)]
PAGES['leistung']=kpis([('list','Direkt aufgelistet',count(35)+' <small>h</small>','Keine unabhängige neue Zeitmessung.',''),('partial','Geschätzte Zuordnung','ca. '+count(5)+' <small>h</small>','Freigabe der Zuordnung noch offen.',''),('calculator','Stundensatz',count(150)+' <small>€</small>','Vorgegebener Rechenansatz netto.',''),('wallet','Gesamter Rechenwert',count(6000)+' <small>€</small>','Keine Rechnung oder Zahlung.','accent-card')])+section('stundenbild','Wo die 40 Stunden herkommen.','<div class="grid2">'+figure('stunden','9,5 + 25,5 + ca. 5,0 Stunden','Der Rahmen enthält auch frühere Vorleistungen.','S01')+card('clock','Nicht „40 gemessene Stunden in einer Woche“.','Die Unterlagen reichen von Vorleistungen vor September bis zur aktiven Umsetzung im September. Die zusätzliche Zuordnung von rund fünf Stunden bleibt als Schätzung sichtbar.',foot=details('Erstgespräch und Zeitgrenzen','Das Erstgespräch vom 26.11.2025 ist im Nachweis mit 0,0 Stunden ohne Berechnung geführt. 9,5 Stunden Vorleistung plus 25,5 Stunden im September ergeben 35,0 direkt aufgelistete Stunden.'))+'</div>')+section('leistungsblatt','Leistungsblatt.',table(['Abschnitt','Zeitraum','Arbeitspaket','Stunden'],hours)+src('S01'))+section('agenten','Maschinenintervalle separat lesen.','<div class="grid2">'+figure('zeitfenster','Beobachtete Aufruf-Zeitfenster','Etwa 38,0 Stunden als Summe; 16,4 Stunden ohne Überlappung.','S01')+card('server','Keine Addition zu den 40 Stunden.','22 technische Intervalle sind dokumentiert, darunter drei Proben. Die Intervalle können Wartezeit und Pausen enthalten. Aktive CPU-/GPU-Zeit und vollständige Tokenkosten wurden nicht nachgewiesen.',foot=src('S01'))+'</div>')+note('Die frühere Schätzung von 30–60 Euro Werkzeugkosten ist nicht durch eine vollständige kontospezifische Abrechnung bestätigt. Sie wird nicht als tatsächlicher Gesamtpreis verwendet.')
# 04 – chronology / tours
chron=[('24.07.2026','Bedarf und Erfassungswege','Mitarbeiter, Büro, Profi und externe Erfassung; Barcode-/Scanner-Konzept und Odoo-Bezug.','Fachliche Vorarbeit'),('30.07. / 03.08.2026','Betrieb und Zielversion','Das Kunden-Zielbild nennt Odoo 19. Der spätere Bestand verwendet 17/18.','Versionsentscheidung offen'),('14.–15.09.2026','Rohzeit, Rollen und Unterlagen','Prüfschritt vor der Buchung; Bestandsmasken, Markenunterlagen und Scannerformate.','Anforderungen und Material'),('15.–16.09.2026','Erste sichtbare Oberflächen','PWA-Zwischenstände und Brand-Studio als Gesprächsgrundlage.','Prototypen, keine Freigabe'),('19.–20.09.2026','Bauen, aufteilen und prüfen','Architektur, Modulzuschnitt, Aufträge, Gegenprüfung und Übergabe.','Aktive Entwicklungs- und Prüfphase')]
PAGES['ablauf']=section('bauweg','Fünf Arbeitsschritte.',figure('bauablauf','Von der Anforderung zum geprüften Arbeitsstand','Arbeitsmethode; keine neue Zeitmessung.','S01'))+section('chronologie','Was wann passiert ist.','<div class="timeline">'+''.join(f'<article class="timeline-item"><div class="date">{date}</div><h3>{title}</h3><p>{desc}</p><span class="duration">{dur}</span></article>' for date,title,desc,dur in chron)+'</div>'+src('S01','S03'))+section('laufzeiten','Wie lange die technischen Läufe dauerten.',figure('wellen','Sechs historische Phasen','Beobachtete Zeitfenster in UTC am 20.09.2026; inklusive Pausen.','S01')+details('Die genauen Fenster und ihre Grenze',table(['Phase','UTC','Einordnung'],[['Wave 0','02:08–03:26','Erste Läufe, Freigabe-Stillstand'],['Wave 1','03:36–04:34','Parallele Aufträge; Peak sechs gleichzeitige Jobs'],['Integration','04:38–06:11','Zusammenführen, kein neues Einzelprodukt'],['Wave 2 / Fix','06:15–07:55 / 07:11–07:33','Folge- und Reparaturläufe'],['Wave 3a','08:59–11:17','Spätere Build-Läufe'],['Proben / Wave 4','11:35–20:12','Enthält die Bridge-Pause 13:49–19:20']])) )+section('touren','Ein geführter Rundgang.','<div class="grid2">'+card('play','Geschäftsführung · etwa 5 Minuten','Ergebnis → Funktionsstand → Aufwand → Kosten → Risiko → Entscheidung. Die Dauer ist eine Leseempfehlung, keine gemessene Arbeitszeit.',foot='<div class="button-row" style="margin-top:20px">'+btn('Tour beginnen','index.html?tour=kurz&step=0','play','primary')+'</div>')+card('layers','IT-Leitung · etwa 30 Minuten','Fachablauf → Module → Demos → Nachweise → offene Fehler → Pilotprüfung. Jeder Schritt führt zu einer echten Unterseite.',foot='<div class="button-row" style="margin-top:20px">'+btn('Technik-Tour','technik.html?tour=tief&step=0','play','primary')+'</div>')+'</div>')+section('pilotplan','Vorschlag: 14 Tage bis zur Pilotentscheidung.',table(['Zeitraum','Arbeitsauftrag','Erwarteter Nachweis'],[['Tag 1–2','Version, Umfang und Verantwortliche festlegen','Freigegebene Pilot-Prüfliste'],['Tag 3–5','Umgebung, Installation, Upgrade und Sicherheit prüfen','Protokolle; keine verdeckten Fehler'],['Tag 6–8','Scanner und Rollenfälle von Rohzeit bis Buchung testen','Nachvollziehbare Fachfälle, auch Duplikate und Fehler'],['Tag 9–11','Mit einer begrenzten Gruppe erproben','Nutzungs- und Zeitmessung, Fehlerliste'],['Tag 12–14','Ergebnisse, Kosten und Restarbeiten bewerten','Go/No-Go oder eingegrenzter Folgeauftrag']])+note('<strong>Planvorschlag, keine Terminzusage:</strong> Der Start hängt von Gerätezugang, Testumgebung, Verantwortlichen und der Freigabe ab.'))
# 05 – technology
mods=[('lunis_istzeit','clock','Zeiterfassung','Zentrale fachliche Basis.'),('attendance_crew_capture','users','Kolonnen','Mehrere Personen gemeinsam erfassen.'),('attendance_segment_ledger','list','Buchungsjournal','Zeitabschnitte nachvollziehbar führen.'),('attendance_percent_allocation','pie','Prozentverteilung','Zeiten auf Projekte verteilen.'),('attendance_reporting','chart','Auswertungen','Geprüfte Daten sichtbar machen.'),('attendance_terminal_kiosk','scan','Terminal','Anmeldung und Erfassung am Kiosk.'),('attendance_external_link','link','Externe Erfassung','Externe Zugänge fachlich anbinden.')]
A=[('A1','users','Mehrere Erfassungswege','Masken und Implementierungen vorhanden; vollständige Rollenabnahme offen.'),('A2','scan','Barcode / Opticon','Formate und technische Anteile vorhanden; realer Gerätedurchlauf offen.'),('A3','link','Odoo-Anbindung','Sieben Module für 17/18; Zielbild Odoo 19 noch abstimmen.'),('A4','image','Lunis-Marke','Kachel-Logo und aktuelle Farben verwendet; alte Regionalvariante nicht übernehmen.'),('A5','database','Alt-System verstehen','Struktur und Aggregate ausgewertet; keine vollständige Datenmigration.'),('A6','check-circle','Ein nutzbares Ergebnis','Anwendungsbestand und Prototypen vorhanden; keine pauschale Produktivfreigabe.')]
PAGES['technik']=section('architektur','Odoo oder eigenständiger Betrieb.',figure('architektur','Zwei Wege für denselben fachlichen Bedarf','Der Bestand ist dokumentiert. Installation und Betrieb sind gesondert abzunehmen.','S03'))+section('anforderungen','Was der Kunde braucht.','<div class="grid3">'+''.join(card(ic,f'{id} · {title}',desc,foot=src('S03')) for id,ic,title,desc in A)+'</div>')+section('module','Die sieben Odoo-18-Module.',table(['Modul','Wofür es da ist','Nachweis'],[[f'<div class="feature-icon">{icon(ic)}<div>{title}<small><code>{name}</code></small></div></div>',desc,'Modul/Manifest dokumentiert; Integration nicht neu abgenommen.'] for name,ic,title,desc in mods])+src('S02')+details('Technische Tiefe und Grenzen','<p>Die Aufteilung trennt Erfassung, Journal, Verteilung, Auswertung und Zugänge. Die Modulmanifeste sind mit LGPL-3 dokumentiert. Eine Lizenzdatei wurde ergänzt; der Testserver wurde auf Loopback begrenzt.</p><p>PIN-Hashing, Fehlversuch-Sperre und ein wirksamer Verkaufssatz sind als Härtungsarbeit dokumentiert. Für die PIN-Prüfung im laufenden Odoo fehlt der Nachweis. Ein Odoo-ORM-Test scheiterte an der Namensauflösung der Datenbank.</p><p>Frühere Geheimnisse in der Git-Historie sind ein eigener Sicherheitsfall. Dieser Bericht veröffentlicht weder diese Werte noch behauptet er ihre Bereinigung.</p>'))+section('demos','Bedienwege selbst ansehen.',demo_stage('produkt'),'Lokale, redigierte HTML-Prototypen. Die Odoo-Instanz ist nicht in der Website enthalten.')+section('demo-regeln','Was eine Demo aussagt.','<div class="grid3">'+card('eye','Sie zeigt Bedienung.','Ansichten, Formulare und Beispielabläufe lassen sich besprechen. Testdaten sind synthetisch.')+card('offline','Sie bleibt lokal.','In den adaptierten Beispielen ist die Netzwerkverbindung gesperrt. Der Demo-Speicher bleibt nur bis zum Neuladen.')+card('shield','Sie bestätigt keine Abnahme.','Keine produktive Datenbank, kein echter Scanner und keine Kundenfreigabe werden durch den iframe nachgewiesen.')+'</div>')
# 06 – one shots
PAGES['oneshot']=section('kapazitaet','Was ein One-Shot leisten kann.','<div class="grid3">'+card('monitor','Oberfläche aufbauen','Navigation, Formulare und erste Interaktionen können in einem Auftrag entstehen.')+card('cube','Abläufe anschaulich machen','CAD-, Stücklisten- und Kalkulationskonzepte eignen sich für frühe Abstimmung.')+card('check-circle','Prüfung vorbereiten','Tests, Prüffälle und Belegstruktur können mitgebaut werden. Der Testlauf bleibt ein eigener Nachweis.')+'</div>'+src('S02','S11'))+section('bilanz','Die historische Bilanz, ohne Schönrechnen.','<div class="grid2">'+figure('one-shot-bilanz','24 Pakete – nicht 24 Erfolge','4 strenge PASS, 7 Marken ohne PASS-Beleg, 13 unvollständig oder offen.','S01')+card('branch','Auftrag, Lauf und Ergebnis trennen.','22 Bridge-Intervalle und drei Proben sind Ausführungsmetadaten. Sie zählen nicht als 22 fertige Produkte. Spätere A–F-Aufträge und diese Berichtsüberarbeitung gehören nicht rückwirkend zum historischen Ergebnis.',foot=details('Was „schneller“ hier bedeutet','Es gibt sichtbare Bau-Ergebnisse in kurzer Abfolge. Eine genaue Beschleunigung gegenüber einer klassischen Umsetzung ist aber nicht gemessen: gleicher Umfang, gleiche Qualität, gleiche Abnahme und genaue aktive Zeiten sind nicht vollständig belegt. Deshalb wird kein „x-mal schneller“-Faktor behauptet.'))+'</div>')+section('konzepte','Große Konzeptansichten.',demo_stage('konzept'),'Masswerk und die CAD-Studie sind eigene Konzepte. Keine offiziellen Herstellerprogramme.')+section('grenzen','Sichtbare Grenzen.',table(['Beispiel','Im Offline-Paket','Nicht damit bewiesen'],[['Masswerk',badge('demo'),'Vollständiger produktiver CAD-/ERP-Kern oder CNC-Freigabe.'],['CAD-Studie',badge('demo'),'Originalsoftware des referenzierten Herstellers.'],['FALZ / FALZ-02',badge('offen','Dateien fehlen'),'Eine Ersatzdatei wird nicht als das Original ausgegeben.'],['Profi-Einzelprototyp',badge('offen','Original fehlt'),'Die Arbeitsbereich-Demo ersetzt keinen Originalnachweis.'],['Brand-Studio',badge('demo','Berichtsadaption'),'Ein vollständiger Markenprozess oder freigegebene Werbeaussagen.']])+src('S11'))+section('naechster-bau','Ein guter nächster Bauauftrag.', '<div class="code-block"><div class="copy-header"><h3>Prüfbarer Einzelauftrag · neue Vorlage</h3>'+btn('Kopieren',ic='copy',cls='small',attrs='data-copy="oneshot-prompt"')+'</div><pre id="oneshot-prompt">Ziel: Einen definierten Erfassungsweg vollständig prüfen.\nEingabe: anonymisierte Beispieldaten, festgelegte Odoo-Version, ein Testgerät.\nErgebnis: Erfassung → Rohzeit → Prüfung → Freigabe → Buchung.\nFehlerfälle: doppelte Übertragung, Unterbrechung, fehlende Berechtigung, Korrektur.\nBeleg: Testprotokoll mit Revision, erwartetem und tatsächlichem Ergebnis.\nGrenze: Keine Veröffentlichung, kein produktiver Kundendatensatz.\nErfolg erst behaupten, wenn jeder vereinbarte Prüffall bestanden ist.</pre></div>')
# 07 – commercial
ranges=[('people','Personen',1,100,25,1,''),('minutes','Gewonnene Minuten / Person / Tag',0,30,5,1,' min'),('days','Arbeitstage / Monat',1,31,20,1,''),('adoption','Tatsächlich nutzbarer Anteil',0,100,75,5,' %'),('rate','Bewertung pro gewonnener Stunde',0,150,35,1,' €'),('running','Laufende Modellkosten / Monat',0,1500,150,25,' €'),('initial','Einmaliger Modellwert',0,50000,6000,500,' €')]
controls=''.join(f'<div class="range-row"><label for="roi-{key}"><span class="range-label">{e(label)}</span><input type="range" id="roi-{key}" name="{key}" min="{lo}" max="{hi}" value="{value}" step="{step}" data-roi></label><output for="roi-{key}" id="out-{key}">{value}{suf}</output></div>' for key,label,lo,hi,value,step,suf in ranges)
calc=f'<article class="card calculator" id="roi-calculator"><div class="card-top"><span class="icon-tile">{icon("sliders")}</span><span class="badge">{icon("info")}Annahmen veränderbar</span></div><h3>Wieviel Kapazität wäre das?</h3>{controls}<div class="calc-results"><div class="calc-result"><strong id="roi-hours">31,25 h</strong><span>Nutzbare Zeit pro Monat</span></div><div class="calc-result focus"><strong id="roi-net">943,75 €</strong><span>Bewertete Kapazität minus Modellkosten</span></div></div><p id="roi-payback">Rechnerischer Ausgleich nach etwa 6,4 Monaten.</p><div class="button-row" style="margin-top:18px">{btn("Szenario JSON",ic="code",attrs="data-export=roi-json")}{btn("Szenario CSV",ic="down",attrs="data-export=roi-csv")}{btn("Zurücksetzen",ic="refresh",attrs="data-roi-reset")}</div>{src("M02")}</article>'
PAGES['kaufmaennisch']=section('kosten','Der Rechenrahmen.',kpis([('clock','Vorgegebener Rahmen','40 <small>h</small>','35 gelistet + ca. 5 geschätzt.',''),('calculator','Rechenansatz','150 <small>€/h</small>','Netto; keine Kostenmessung.',''),('wallet','Leistungswert','6.000 <small>€</small>','Keine Rechnung oder Zahlung.','accent-card'),('server','Vollständige Betriebskosten','Offen','Kein vollständiges TCO-Angebot.','')])+note('<strong>6.000 Euro sind nicht automatisch die Kosten eines fertigen Produkts.</strong> Zielsystemprüfung, Geräteabnahme, Betrieb, Pflege und offene Restarbeiten sind gesondert zu klären.')+src('S01'))+section('klassisch','Was eine klassische Umsetzung kosten könnte.','<div class="grid2">'+figure('klassisch-modell','224–432 Stunden als Rechenmodell','Mit 150 €/h: 33.600–64.800 € netto. Kein Marktangebot, kein gemessener Vergleich.','M01')+card('calculator','Ein Vergleich der Aufgaben, nicht der Fertigstellung.','Das Modell verteilt typische Arbeitsschritte auf eine angenommene Stundenbandbreite. Der 40-Stunden-Bestand hat offene Abnahmepunkte. Deshalb ist die Differenz kein belegter Preisvorteil für dieselbe fertige Leistung.',foot=details('Annahmen und Abgrenzung','Das Modell wurde für die Erklärung neu angelegt. Es ist weder eine Anbieterrechnung noch ein Kostenvoranschlag. Der genaue Folgeaufwand ist offen. Zur Entscheidung sind vergleichbare Leistungsumfänge und konkrete Angebote erforderlich.'))+'</div>')+section('betriebswahl','Zwei Betriebsoptionen.',table(['Option','Was sie bietet','Was dafür noch zu klären ist'],[['Odoo Community im Eigenbetrieb','Vorhandene Module und gemeinsames ERP-Datenmodell.','Version, Hosting, Sicherung, Updates, Rollen, Support und Lizenzprüfung.'],['Eigenständige Anwendung / PWA','Schmaler eigener Fachablauf, ohne Odoo-Oberfläche.','Mehrbenutzerbetrieb, Datenbankbetrieb, Schnittstellen, Offline-Konflikte und Wartung.']])+note('<strong>Preisoptionen für den nächsten Auftrag:</strong> abgegrenzter Pilot, Umsetzung nach freigegebener Prüfliste oder laufende Wartung. Hier sind keine verbindlichen Pauschalpreise, Hostingpreise oder Lizenzangebote erfunden.'))+section('roi','Zeitgewinn rechnen. Nicht versprechen.','<div class="grid2">'+calc+'<div class="stack">'+figure('roi-modell','Drei reine Beispielszenarien','Grau: 3 min. Rot: 5 min. Navy: 7 min. Die Grafik zeigt feste Ausgangsannahmen.','M02')+card('info','Kapazität ist nicht automatisch Geld auf dem Konto.','Gesparte Eingabezeit kann für andere Aufgaben nutzbar sein. Ob daraus tatsächlich weniger Kosten oder mehr Umsatz entstehen, muss der Pilot zeigen.',foot='<div class="formula">Personen × Minuten ÷ 60 × Tage × Nutzungsanteil = nutzbare Stunden<br>Stunden × Bewertung − laufende Kosten = bewerteter Monatsnutzen</div>')+'</div></div>')+section('messplan','Diese drei Werte im Pilot messen.','<div class="grid3">'+card('clock','Zeit je Vorgang','Vorher und nachher für dieselbe Aufgabe messen. Sonderfälle getrennt halten.')+card('users','Tatsächliche Nutzung','Nicht nur angemeldete Nutzer zählen, sondern vollständig erfasste und geprüfte Vorgänge.')+card('wallet','Gesamtkosten','Restentwicklung, Hardware, Betrieb, Betreuung und Schulung erfassen.')+'</div>')
# 08 – market
comp=load('wettbewerb.json');cr=comp['zeilen'];ch=list(cr[0]) if cr else []
comp_rows=[[e(str(r.get(h,''))) for h in ch] for r in cr]
PAGES['markt']=section('branche','Tischler und Holzgestalter in Österreich.',kpis([('briefcase','Betriebe · 31.12.2025',count(10214),'WKO-Bestandszahl, kein Software-Kundenpotenzial.',''),('users','Beschäftigte · 31.12.2025',count(29822),'Davon 1.987 Lehrlinge laut Quelle.',''),('chart','Branchenumsatz · 2024','5,5 <small>Mrd. €</small>','Umsatz, nicht Gewinn oder Softwarebudget.','accent-card'),('wallet','Investitionen · 2024','238 <small>Mio. €</small>','Gerundete Branchenangabe.','')])+src('P01')+note('Die Wirtschaftszahlen beziehen sich auf <strong>2024</strong>, die Bestandszahlen auf <strong>31.12.2025</strong>. Sie werden nicht zu scheinbaren Durchschnittsgehältern verrechnet.'))+section('einkommen','Was „verdienen“ hier bedeutet.','<div class="grid2">'+figure('mindestloehne','14,14–17,39 € brutto pro Stunde','Kollektivvertragliche Mindeststundenlöhne der Tischler-Gruppen I–VII. Keine tatsächlichen Durchschnittslöhne.','P02')+card('wallet','Lohn, Umsatz und Gewinn sind verschieden.','Für Beschäftigte liegt eine offizielle Mindestlohntabelle vor. Tatsächliche Durchschnittseinkommen, private Unternehmerentnahmen und Gewinne der Softwareanbieter sind in diesem Bericht nicht verifiziert.',foot=details('Bezugsbereich und Gültigkeit','Die dargestellten Mindeststundenlöhne gelten für die Berufszweige der Tischler in der veröffentlichten Lohnordnung vom 01.05.2026 bis 30.04.2027. Die Einstufung hängt von der tatsächlich ausgeübten Tätigkeit ab. Holzgestalter haben eine eigene Lohnordnung, die hier nicht mit der Tischler-Tabelle vermischt wird.'))+'</div>')+section('grok','Was aus der Grok-Recherche übernommen wurde.','<div class="grid3">'+card('search','23 Produkte genannt','Die Liste erweitert das Wettbewerbsumfeld. Eine Nennung ist kein Produkttest und keine Marktanteilsstudie.',foot=src('S13'))+card('shield','Zahlen neu geprüft','Branchenzahlen und Mindestlöhne wurden gegen öffentliche WKO-Quellen geprüft.',foot=src('P01','P02'))+card('cross','Unbelegte Zahlen entfernt','Unbestätigte Anbieterumsätze, Kundenzahlen und private Bilanz-/Einkommensangaben sind keine Berichtsfakten.',foot=src('S13'))+'</div>')+section('orgacalc','OrgaCalc: Umfang verstehen, nicht gleichsetzen.','<article class="card dark"><div class="card-top"><span class="icon-tile">'+icon('database')+'</span><span class="badge">Strukturvergleich</span></div><h3>348 Tabellen. 813 Beziehungen. Ein breites Branchen-ERP.</h3><p>Lunis Ist-Zeit behandelt einen Ausschnitt: Zeiterfassung und angrenzende Fachabläufe. Aus sieben Zeit-Modulen folgt keine Gleichwertigkeit mit einem vollständigen Branchen-ERP.</p><div class="mini-metrics"><span><b style="color:white">25</b>Views</span><span><b style="color:white">15</b>abgeleitete Fachgruppen</span><span><b style="color:white">0</b>Nutzdatenzeilen laut Quellenbericht extrahiert</span></div></article>'+src('S05')+details('Technische Herkunft und Grenzen','Microsoft SQL Server, nicht Firebird. Die Quelle beschreibt eine reine Katalog-/DDL-Auswertung. 346 Primärschlüssel, 963 Indizes, 3 Skalarfunktionen und 1 Trigger werden berichtet. Der Restore wurde in dieser Website-Bearbeitung nicht wiederholt. Keine Datenbanksicherung, kein komplettes DDL und keine Kundendaten werden hier mitgeliefert.'))+section('markt-matrix','Wettbewerbs-Matrix aus dem Research-Stand.',table(ch,comp_rows,'compact',id='competitor-table')+src('S10')+note('<strong>16 sichtbare Funktionen × 9 Produkte.</strong> Die Quelle kündigt 18 Zeilen an, enthält im gelesenen Abschnitt aber nur 16. Die Spalte „Hausleitner“ meint den Altbestand, nicht das neue Lunis. ● = Kernmodul, ◐ = eingeschränkt/Schnittstelle, ○ = nicht ausgewiesen, ? = keine Angabe. Kein eigener Produkttest.')+details('Hersteller-Screenshots und Aktualität','Die Herstellerbilder sind im Quellenverzeichnis benannt, konnten hier aber nicht als prüfbare Bilddateien übernommen werden. Sie werden weder nachgebaut noch als echte Produktbelege ersetzt. Die Matrix ist ein historischer Research-Stand. Funktionsumfang, Preise und Bedingungen müssen für eine konkrete Beschaffung beim jeweiligen Anbieter bestätigt werden.'))+section('schema','Die 15 Fachgruppen.',figure('schema-module','Breite des dokumentierten Datenmodells','Rot markiert den Zeit-/Personalbereich. Gruppen sind aus der Struktur abgeleitet.','S05'))+section('tickets','Ticketdaten: nur die freigegebenen Aggregate.','<div class="grid2">'+figure('ticketstatus','324 Vorgänge in der Ideensammlung','228 offen; 78 erledigt; 18 andere Abschlussarten. Keine Tickettexte.','S06')+figure('ticketjahre','Entstehungsjahre der 324 Vorgänge','2026 ist ein Teiljahr bis 21. Juli. Nicht mit Gesamt-Supportzahlen gleichsetzen.','S06')+'</div>'+note('Die 96 Vorgänge im Sammelstatus „gelöst“ enthalten auch verworfene, abgelehnte, doppelte und nicht reproduzierbare Fälle. Zwei vorhandene Themenklassifikationen widersprechen sich; sie werden nicht zu einer gemeinsamen Rangliste vermischt.'))+section('nachwuchs','Eine weitere Branchenperspektive.',figure('lehrlinge','Lehrlingszahlen 2023–2025','Bestandszahlen der WKO. Die Grafik behauptet keine Ursache der Veränderung.','P01'))
# 09 – research and brand
phrases=load('markentexte.json') if (R/'data/markentexte.json').exists() else []
PAGES['research']=section('methode','Die Methode in fünf Schritten.',figure('quellenweg','Sammeln → abgleichen → belegen','Die Berichtsaussage bleibt von der Originalquelle und der Beleggrenze getrennt.','S01'))+section('redaktion','Was gegenüber den Unterlagen verbessert wurde.',table(['Befund in älteren Unterlagen','Einordnung in diesem Bericht'],[['„Keine Projekt-E-Mails gefunden“','Überholt: spätere lokale Quellenprüfung fand Projektbelege; vollständige Mailabdeckung bleibt offen.'],['Odoo 19 als Ziel, Odoo 17/18 als Bestand','Die Versionsabweichung steht sichtbar in Technik und Funktionsmatrix.'],['„18 × 9“-Matrix, aber 16 sichtbare Zeilen','16 Zeilen ausweisen, keine fehlenden Funktionen erfinden.'],['Früheres Datenschutz-PASS und späterer Archivfehler','Späteren Fehler nicht durch älteres PASS verdecken.'],['Auftrag, Marke und Erfolg teilweise vermischt','Auftragspaket, Aufrufintervall und bestätigtes Ergebnis getrennt zählen.']])+src('S01','S03','S07','S10'))+section('marke','Eine Marke. Überall derselbe Absender.','<div class="grid2"><div><div class="logo-audit"><div class="brand"><img src="brand/lunis-logo.svg" alt="Lunis"><small>ist Zeit</small></div></div><div class="swatches">'+''.join(f'<div class="swatch"><div style="background:{c}"></div><small>{label}<br>{c}</small></div>' for c,label in [('#fe0942','Lunis Rot'),('#091a40','Navy'),('#ffffff','Weiß')])+'</div></div>'+card('eye','Logo visuell abgeglichen.','Die Flammenkachel und die Lunis-Wortmarke bleiben unverändert. Der Zusatz „ist Zeit“ steht daneben. Die ältere Regionalvariante wird nicht als zweites Bericht-Logo verwendet.',foot=src('S12'))+'</div>')+section('markentexte','Werbetexte – getrennt vom Sachbericht.','<div class="brand-phrases">'+''.join(f'<article class="phrase"><div class="brand"><img src="brand/lunis-kachel.svg" alt="Lunis-Kachel" style="width:24px;height:24px"><small>Markenentwurf</small></div><p id="phrase-{i}">{e(p["text"]).replace(chr(10),"<br>")}</p><small>Aus dem bereitgestellten Brand-Studio. Kein Funktions- oder Erfolgsversprechen.</small>{btn("Text kopieren",ic="copy",cls="small",attrs=f"data-copy=phrase-{i}")}</article>' for i,p in enumerate(phrases[:6]))+'</div>')+section('icons','Klare Zeichen statt zusätzlicher Erklärung.','<div class="icon-grid">'+''.join(f'<div class="icon-cell">{icon(k)}<small>{e(k)}</small></div>' for k in ['clock','scan','phone','monitor','users','database','layers','check-circle','partial','circle','cross','shield','wallet','chart','file','down','route','cube','search','copy','code','image','lock','package'])+'</div>'+note(f'{len(ICONS)} konsistente SVG-Piktogramme im lokalen Icon-System. Bedeutungen stehen zusätzlich als Text; Farbe oder Symbol allein tragen keine Statusaussage.'))+section('beispielauftrag','Beispiel: redaktioneller Prüfauftrag.','<div class="code-block"><div class="copy-header"><h3>Neue Textvorlage · nicht historischer Originalprompt</h3>'+btn('Kopieren',ic='copy',cls='small',attrs='data-copy="redaktion-prompt"')+'</div><pre id="redaktion-prompt">Schreibe für eine Geschäftsführung, nicht für eine Werbekampagne.\nZuerst: Ergebnis, Nutzen, Kosten, offene Entscheidung.\nPro Behauptung: Quelle, Datum und Geltungsbereich nennen.\nBestand, Demo, Test und Kundenabnahme klar trennen.\nKeine privaten Namen, Kontakte, Zugänge oder Tickettexte übernehmen.\nUnsichere Zahlen nicht abrunden oder mit anderen Jahren vermischen.\nDetails einklappen, Kernaussage kurz halten.\nJede Tabelle muss auch ohne Farbe verständlich und exportierbar sein.</pre></div>')
# 10 – notes
PAGES['transkripte']=note('<strong>Redaktionelle Zusammenfassungen, keine Wortprotokolle.</strong> Persönliche Namen, private Instanzen, lokale Ursprungsorte und Zugänge werden nicht ausgegeben. Fachliche Herkunft bleibt über Datum und Quellen-ID nachvollziehbar.')+section('notizen','Die wesentlichen Gesprächsinhalte.','<div class="stack">'+''.join(f'<article class="card" id="note-{x["id"]}"><div class="copy-header"><h3>{icon("chat")} {e(x["titel"])}</h3>{btn("Kopieren",ic="copy",cls="small",attrs=f"data-copy=text-{x['id']}")}</div><div class="copy-text" id="text-{x["id"]}">{e(x["text"])}</div>{src(x["quelle"])}</article>' for x in NOTES)+'</div>')+section('notizen-export','Notizen weiterverwenden.','<div class="button-row">'+btn('Alle Notizen als TXT','exports/Gespraechsnotizen.txt','file','primary','download')+btn('Notizen JSON','data/gespraechsnotizen.json','code','','download')+btn('Kapitel als PDF','exports/10-gespraechsnotizen.pdf','pdf','','download')+'</div>')
# 11 – quality
PAGES['qs']=section('pruefstaende','Drei verschiedene Prüfstände.','<div class="grid3">'+card('check-circle','Historische Tests','29 bestanden, 1 optional übersprungen. Der separate Odoo-ORM-Lauf war wegen DB-Namensauflösung blockiert.',foot=src('S07'))+card('partial','Historische Anweisungsprüfung','11 belegt, 2 teilweise. Das sind 13 Operator-Anweisungen und nicht A1–A6 oder eine Produktabnahme.',foot=src('S08'))+card('monitor','Diese Berichtswebsite','PDF-/Datenprüfung ist vom Browser getrennt. Die lokale Browsernavigation ist administrativ gesperrt; es gibt keinen Browser-PASS.',foot=src('B01'))+'</div>')+section('historische-grafiken','Die Zahlen mit dem richtigen Umfang.','<div class="grid2">'+figure('teststand','29 bestanden / 1 übersprungen','Historischer Teststand; keine aktuelle Gesamtfreigabe.','S07')+figure('anforderungsgate','11 belegt / 2 teilweise','Historischer Operator-Check mit 13 Anweisungen.','S08')+'</div>')+section('datenschutz','Daten entfernen, nicht nur verstecken.',figure('privacy-weg','Der neue Paketprüfweg','Rohdaten sind nicht Teil der Eingaben oder des Ausgabepakets.','B01')+note('<strong>Kein „alles sauber“ ohne Umfang.</strong> Ein früherer ZIP-Filter konnte Binärdateien ungeprüft übernehmen. Die neue Paketprüfung akzeptiert nur ausdrücklich gelistete Dateien; Text, PDF-Inhalte und Medienregister werden getrennt kontrolliert.',True)+details('Was das Skript kann – und was nicht','Das Skript entfernt bekannte Muster aus einer Ausgabekopie und protokolliert nur Kategorien und Anzahlen, keine Trefferwerte. Es erkennt unter anderem Kontaktangaben, Zugangstoken und private Ursprungslinks. Es lehnt Rohdatenformate und unbekannte Archivmitglieder ab. Eine Namensliste kann lokal zusätzlich übergeben werden und wird nicht mitgeliefert. Freier Text, Bildinhalt und indirekte Identifizierung können nicht lückenlos automatisch erkannt werden. Eine menschliche Freigabe bleibt nötig.'))+section('bilder','Vorhandene Bilder richtig einordnen.','<div class="grid3">'+photo('historisch-home-first.webp','Frühere Startseite','Vorhandener Bildstand aus einer früheren Ausführung. Kein Beleg für diese überarbeitete Version.')+photo('historisch-roi-first.webp','Frühere Kostenansicht','Historischer Screenshot. Die aktuelle Berechnung stammt aus dem offen gelegten Datenmodell.')+photo('historisch-demo-brand-studio.webp','Brand-Studio · alter Bildstand','Die alte regionale Kopfzeile wurde dauerhaft in den Bildpixeln ersetzt. Keine neue Browseraufnahme.')+'</div>')+section('pdf-bilder','Aktuelle PDF-Ansichten.','<div class="grid3">'+''.join(photo(f'pdf-{slug}.webp',title,'Aus dem PDF-Export dieser Version gerendert. Keine Browseraufnahme.','Aktuelle PDF-Ansicht') for slug,title,num in NAV)+'</div>')+section('offene-pruefungen','Offen bleibt offen.',table(['Prüfung','Stand','Was den Punkt schließt'],[['Kopflose Abnahme „8/8“',badge('offen','Protokoll fehlt'),'Einzelprotokoll mit Umfang, Revision und Ergebnissen zuordnen.'],['Browserprüfung dieser Website',badge('blockiert'),'Mitgelieferte Tests in einer erlaubten Browserumgebung ausführen.'],['Demos vollständig interaktiv',badge('offen'),'Angezeigte und geänderte Zustände im Browser prüfen.'],['Neue Herstellerbilder',badge('offen'),'Dateien übernehmen, Bildrechte und Inhalte prüfen.'],['FALZ/FALZ-02/Original-Profi',badge('offen','Dateien fehlen'),'Freigegebene Originale ergänzen; keine Ersatzbelege.'],['Produktivfreigabe',badge('offen'),'Versions-, Geräte-, Sicherheits- und Betriebsabnahme durchführen.']]))+section('freigabe','Der Weg zur Freigabe.',figure('freigabeweg','Fünf getrennte Entscheidungen','Planvorschlag. Kein bereits abgeschlossener Pilot.','S07'))+section('quellen','Quellenregister.', '<div class="grid2">'+''.join(f'<article class="card source-card" id="{x["id"].lower()}"><div class="card-top"><span class="source-id">{e(x["id"])}</span><span class="small muted">{e(x["stand"])}</span></div><h3>{e(x["titel"])}</h3><p>{e(x["zusammenfassung"])}</p><div class="source">{e(x["typ"])} · {e(x["status"])}</div>{details("Geltungsbereich und Quelle",e(x["grenze"])+(f'<p><a class="text-link" href="{e(x["url"],quote=True)}" target="_blank" rel="noopener">Öffentliche Primärquelle {icon("arrow-up")}</a></p>' if x.get('url') else '')+f'<p><a href="quellen/{x["id"]}.md" download>Quellenkarte als Markdown</a></p>')}</article>' for x in SOURCES)+'</div>','Private Ursprungsorte bleiben im Lesepaket verborgen. Öffentliche Primärquellen sind offen verlinkt.')
# 12 – export center
PDF_NAMES={'index':'01-ueberblick','funktion':'02-funktionsmatrix','leistung':'03-leistung','ablauf':'04-ablauf','technik':'05-technik','oneshot':'06-oneshot','kaufmaennisch':'07-wirtschaftlichkeit','markt':'08-markt','research':'09-methodik','transkripte':'10-gespraechsnotizen','qs':'11-qualitaet','export':'12-downloads'}
def download_row(ic,title,detail,path):return f'<div class="download-row"><span class="icon-tile">{icon(ic)}</span><div><strong>{e(title)}</strong><small>{e(detail)}</small></div>{btn("Download",path,"down","small","download")}</div>'
PAGES['export']=section('pakete','Die kompletten Exporte.','<div class="grid3">'+card('pdf','Gesamter Bericht','Alle zwölf Kapitel in einer PDF-Lesefassung. Interaktive Demos bleiben im HTML-Paket.',foot='<div class="button-row" style="margin-top:20px">'+btn('Gesamt-PDF','exports/Lunis-Endbericht.pdf','pdf','primary','download')+'</div>')+card('package','Alle Kapitel-PDFs','Zwölf einzelne Kapitel und das Gesamt-PDF in einem Archiv.',foot='<div class="button-row" style="margin-top:20px">'+btn('PDF-Archiv','exports/Lunis-PDFs.zip','down','primary','download')+'</div>')+card('code','Daten, Grafiken & Skripte','Kuratiertes JSON/CSV, SVG-Grafiken, Gesprächsnotizen und reproduzierbare Prüfskripte.',foot='<div class="button-row" style="margin-top:20px">'+btn('Arbeitsmaterial','exports/Lunis-Daten-Skripte.zip','down','primary','download')+'</div>')+'</div>')+section('einzel-pdfs','Einzelne Kapitel als PDF.','<article class="card">'+''.join(download_row('pdf',title,'Statische Lesefassung · gleiche Berichtsdaten',f'exports/{PDF_NAMES[slug]}.pdf') for slug,title,num in NAV)+'</article>')+section('tabellen-download','Tabellen und Text.','<article class="card">'+''.join(download_row(ic,t,d,p) for ic,t,d,p in [('list','Funktionsmatrix · CSV','Alle Funktionen inklusive Stand und Beleggrenze','exports/Funktionsmatrix.csv'),('code','Funktionsmatrix · JSON','Maschinenlesbare Einträge','data/funktionsmatrix.json'),('chart','Marktdaten · JSON','WKO-Quellen, Jahre und Grenzen','data/markt.json'),('list','Wettbewerbs-Matrix · CSV','16 sichtbare Funktionen aus dem Research-Stand','exports/Wettbewerb.csv'),('file','Gesprächsnotizen · TXT','Redaktionelle, anonymisierte Zusammenfassungen','exports/Gespraechsnotizen.txt'),('book','Quellenregister · JSON','Öffentliche Belege und private Quellen-IDs','data/quellen.json'),('image','Alle Piktogramme · SVG','Lokales Icon-System, keine externen Fonts','assets/icons.svg'),('list','Paket-Manifest · JSON','Dateinamen, Größen und SHA-256','MANIFEST.json')])+'</article>')+section('skripte','Die Skripte werden mitgeliefert.','<article class="card">'+''.join(download_row('code',name,desc,'tools/'+name) for name,desc in [('build.py','Erzeugt die zwölf HTML-Unterseiten aus den kuratierten Daten.'),('graphics.py','Erzeugt die SVG-Diagramme.'),('icons.py','Zentrale Piktogrammdefinitionen.'),('privacy.py','Redaktion auf Kopien und strenge Archivprüfung.'),('export_pdf.py','Erzeugt Kapitel-PDFs und Gesamtbericht; kein Browserersatz.'),('package.py','Baut Archive ausschließlich aus einer überprüften Dateiliste.'),('test_static.py','Prüft Daten, Quellen, Links, Marke und Rechenmodelle.'),('test_browser.py','Prüft Browserinteraktionen und erzeugt Screenshots in erlaubter Umgebung.'),('test_privacy.py','Testet Redaktions- und Archivregeln mit synthetischen Prüfdaten.')])+'</article>')+section('anleitung','Ohne Installation ansehen.','<div class="steps">'+''.join(f'<article class="step"><span class="icon-tile">{icon(ic)}</span><h3>{title}</h3><p>{desc}</p></article>' for ic,title,desc in [('package','ZIP vollständig entpacken','Die Ordnerstruktur beibehalten. Die HTML-Dateien verwenden lokale Assets.'),('monitor','index.html öffnen','Im eigenen Browser per Doppelklick öffnen. Ein Server ist zum Lesen nicht nötig.'),('down','Gewünschtes Format laden','PDF, CSV, JSON und SVG liegen als Dateien bei. Die Website versendet nichts.')])+'</div>'+details('Für die technische Prüfung','<div class="code-block"><pre>python tools/build.py\npython tools/test_static.py\npython tools/test_privacy.py\npython tools/export_pdf.py\npython tools/test_browser.py\npython tools/package.py</pre></div><p>Die PDF-Erzeugung benötigt WeasyPrint und PyMuPDF. Der optionale Browserlauf benötigt Playwright/Chromium. Er verändert keine Browserrichtlinien. Ist die Navigation gesperrt, wird BLOCKED statt PASS protokolliert.</p>'))
# Core shell; full content is server-free and present without JavaScript.
def html_page(slug,body):
 idx=[n[0] for n in NAV].index(slug);title,desc=META[slug];num=NAV[idx][2]
 nav=''
 for i,(s,t,n) in enumerate(NAV):
  if i==0:nav+='<div class="nav-section">Entscheidung & Ergebnis</div>'
  if i==4:nav+='<div class="nav-section">Details & Einordnung</div>'
  if i==9:nav+='<div class="nav-section">Nachweise & Export</div>'
  nav+=f'<a href="{s}.html" class="{"active" if s==slug else ""}"'+(' aria-current="page"' if s==slug else '')+f'>{icon(NI[i])}<span>{e(t)}</span></a>'
 nextslug,nexttitle,_=NAV[(idx+1)%len(NAV)]
 hero='' if slug=='index' else f'<div class="page-head" id="seitenkopf"><div class="eyebrow">{e(num+" / "+NAV[idx][1])}</div><h1>{e(title)}</h1><p class="lead">{e(desc)}</p><div class="head-meta"><span>{icon("calendar")}Stand 21.09.2026</span><span>{icon("offline")}Lokales Lesepaket</span><span>{icon("shield")}Keine Produktivfreigabe</span></div></div>'
 printhead=f'<div class="print-header"><div class="brand"><img src="brand/lunis-logo.svg" alt="Lunis"><small>ist Zeit</small></div><span>{e(NAV[idx][1])} · Entwicklungsbericht</span></div>'
 return f'''<!doctype html><html lang="de-AT"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#091a40"><meta name="description" content="{e(desc,quote=True)}"><meta http-equiv="Content-Security-Policy" content="default-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"><title>{e(NAV[idx][1])} | Lunis ist Zeit</title><link rel="icon" href="brand/lunis-kachel.svg" type="image/svg+xml"><link rel="stylesheet" href="assets/typography.css"><link rel="stylesheet" href="assets/site.css"><script defer src="assets/report-data.js"></script><script defer src="assets/model.js"></script><script defer src="assets/site.js"></script></head><body data-page="{slug}"><a class="skip" href="#inhalt">Zum Inhalt</a><aside class="sidebar" id="seitenleiste" aria-label="Hauptnavigation"><a class="brand" href="index.html"><img src="brand/lunis-logo.svg" alt="Lunis"><small>ist Zeit</small></a><div class="brand-sub">Entwicklungsbericht · 2026</div><nav class="nav">{nav}</nav><div class="sidebar-footer">Für Geschäftsführung & IT<strong>EDV-Hausleitner GmbH</strong>Lesepaket · keine Produktivfreigabe</div></aside><div class="shell"><header class="topbar"><button type="button" class="btn icon-button menu-toggle" aria-label="Navigation öffnen" aria-expanded="false" aria-controls="seitenleiste" data-menu>{icon("menu")}</button><a class="brand mobile-brand" href="index.html"><img src="brand/lunis-logo.svg" alt="Lunis"><small>ist Zeit</small></a><div class="crumb"><span>Entwicklungsbericht</span>{icon("chevron")}<b>{e(NAV[idx][1])}</b></div><div class="top-actions">{btn("Tour","index.html?tour=kurz&step=0","play")}{btn("Bewegung aus",ic="pause",attrs='data-motion aria-pressed="false"')}{btn("Ansicht drucken",ic="print",attrs="data-print")}{btn("Kapitel PDF","exports/"+PDF_NAMES[slug]+".pdf","pdf","pdf-top", "download")}</div><div class="progress" aria-hidden="true"></div></header><main class="page" id="inhalt">{printhead}{hero}{body}<a class="next-page" href="{nextslug}.html"><div><small>Nächstes Kapitel</small><strong>{e(nexttitle)}</strong></div>{icon("arrow","lg")}</a><footer class="footer"><p>Lunis ist Zeit · Stand 21.09.2026 · Internes Lesepaket</p><p><a href="qs.html">Quellen & Grenzen</a> · <a href="export.html">Downloads</a></p></footer></main></div><a class="btn icon-button back-top" href="#inhalt" aria-label="Zum Seitenanfang">{icon("arrow-up")}</a><div class="toast" id="toast" role="status" hidden></div><dialog class="image-dialog" id="image-dialog" aria-labelledby="image-caption"><div class="dialog-head"><span id="image-caption"></span><button type="button" class="btn icon-button" data-close-image aria-label="Bildansicht schließen">{icon("close")}</button></div><img id="image-large" alt=""></dialog><noscript><div class="note">Alle Berichtsinhalte und Datei-Downloads sind ohne JavaScript lesbar. Filter, Touren und Rechner benötigen JavaScript.</div></noscript></body></html>'''

def build():
 graphics.build()
 for slug,body in PAGES.items():(R/(slug+'.html')).write_text(html_page(slug,body),encoding='utf-8')
 defs=''.join(f'<symbol id="{k}" viewBox="0 0 24 24"><path d="{v}" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round"/></symbol>' for k,v in ICONS.items())
 # Downloadable sprite includes a visible icon contact sheet, not an empty definitions-only file.
 uses=''.join(f'<g transform="translate({(i%8)*100+20} {(i//8)*80+15})" color="#091a40"><use href="#{k}" width="28" height="28"/><text x="0" y="47" fill="#091a40" font-family="Arial" font-size="9">{k}</text></g>' for i,k in enumerate(ICONS))
 (R/'assets/icons.svg').write_text(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 {((len(ICONS)+7)//8)*80}"><defs>{defs}</defs>{uses}</svg>')
 public={'features':F,'models':M,'demos':DEMOS,'navigation':NAV,'statuses':{k:v[0] for k,v in STATUS.items()},'icons':{k:icon(k) for k in ['play','close','arrow','back','check','pause']}}
 (R/'assets/report-data.js').write_text('"use strict";\nwindow.LUNIS_REPORT = '+json.dumps(public,ensure_ascii=False).replace('</','<\\/')+';\n')
 ex=R/'exports';ex.mkdir(exist_ok=True)
 def writecsv(name,rows,heads):
  with (ex/name).open('w',encoding='utf-8-sig',newline='') as f:
   w=csv.writer(f,delimiter=';');w.writerow(heads)
   for row in rows:
    w.writerow([("'"+str(v)) if str(v).lstrip().startswith(('=','+','-','@')) else v for v in row])
 writecsv('Funktionsmatrix.csv',[[x[k] for k in ['id','bereich','funktion','status','vorhanden','beleggrenze','naechster_schritt','quelle']] for x in F],['ID','Bereich','Funktion','Status','Bestand','Beleggrenze','Nächster Schritt','Quelle'])
 writecsv('Wettbewerb.csv',[[str(r.get(h,'')) for h in ch] for r in cr],ch)
 writecsv('Leistungsblatt.csv',[[r[0],r[1],r[2],r[3]] for r in hours if not isinstance(r,tuple)],['Abschnitt','Zeitraum','Paket','Stunden'])
 (ex/'Gespraechsnotizen.txt').write_text('Lunis ist Zeit · Redaktionelle Zusammenfassungen · Stand 21.09.2026\nKeine Rohtranskripte. Keine private Herkunft im Lesepaket.\n\n'+'\n\n'.join(x['titel']+'\n'+x['text']+'\nQuelle: '+x['quelle'] for x in NOTES),encoding='utf-8')
 (R/'data/pdf-index.json').write_text(json.dumps(PDF_NAMES,ensure_ascii=False,indent=2))
 (R/'data/page-metadata.json').write_text(json.dumps(META,ensure_ascii=False,indent=2))
 print(f'Built {len(PAGES)} HTML pages, {len(ICONS)} icons, 19 SVG figures.')
if __name__=='__main__':build()
