diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..0d222e5
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(grep -i article find apps/server/src -name \"*.controller.ts\")"
+ ]
+ }
+}
diff --git a/.claude/skills/abler-design/SKILL.md b/.claude/skills/abler-design/SKILL.md
new file mode 100644
index 0000000..2983a74
--- /dev/null
+++ b/.claude/skills/abler-design/SKILL.md
@@ -0,0 +1,139 @@
+---
+name: abler-design
+description: >
+ Design- und Entwicklungs-Skill für das abler.tirol Ökosystem.
+ Verwende diesen Skill immer wenn du für Simon Abler eine neue Seite,
+ Komponente, Frontend, Landing Page oder UI-Element unter einer der
+ abler.tirol Subdomains erstellst oder bearbeitest — also für
+ abler.tirol, api.abler.tirol, barcode.abler.tirol, pdf.abler.tirol,
+ klara.abler.tirol, sims.abler.tirol oder jede neue *.abler.tirol Domain.
+ Auch beim Erstellen neuer Subdomains, beim Anpassen von Docker/nginx-Configs,
+ beim Schreiben von READMEs für abler.tirol Repos, oder wenn das Gespräch
+ Designentscheidungen für dieses Ökosystem betrifft. Trigger auch bei
+ Fragen wie "wie soll X bei abler.tirol aussehen" oder "erstelle mir
+ eine Seite im abler-Stil".
+---
+
+# abler.tirol — Design & Development Skill
+
+Dieses Skill definiert das vollständige Design-System und die Entwicklungsregeln
+für das `abler.tirol` Ökosystem. Es gilt für alle bestehenden und neuen Produkte.
+
+---
+
+## Schritt 1: Subdomain identifizieren
+
+Bevor du anfängst, stelle fest für welche Subdomain du arbeitest.
+Lies dann die entsprechende Referenzdatei:
+
+| Subdomain | Referenzdatei | Produkt-Typ |
+|---|---|---|
+| `abler.tirol` | `references/abler-tirol.md` | Landing / Static |
+| `api.abler.tirol` | `references/api.md` | API + Minimal-Frontend |
+| `barcode.abler.tirol` | `references/barcode.md` | Pure Frontend |
+| `pdf.abler.tirol` | `references/pdf.md` | Pure Frontend |
+| `klara.abler.tirol` | `references/klara.md` | Fullstack |
+| `sims.abler.tirol` | `references/sims.md` | Fullstack |
+| Neue Subdomain | `references/new-product.md` | Checkliste |
+
+**Neue Subdomain?** → Lies `references/new-product.md` für die Checkliste.
+
+---
+
+## Schritt 2: Universelle Regeln (gelten für ALLE Subdomains)
+
+### Typografie — unveränderlich
+
+```html
+
+
+
+```
+
+| Rolle | Font | Verwendung |
+|---|---|---|
+| Headlines | `DM Serif Display` | Hero-Titel, H1/H2 |
+| Body / UI | `Inter` | Fließtext, Labels |
+| Code / Mono | `DM Mono` | Tags, Badges, Code, Terminal |
+
+```css
+--text-display: clamp(3rem, 8vw, 6rem);
+--text-h1: clamp(2.2rem, 5vw, 3.75rem);
+--text-h2: clamp(1.8rem, 3.5vw, 3rem);
+--text-body: 0.95rem;
+--text-label: 0.72rem; /* DM Mono + uppercase + letter-spacing: 0.18em */
+```
+
+### Geteilte Neutrals
+
+```css
+:root {
+ --white: #ffffff;
+ --bg-light: #f7f7f4;
+ --bg-subtle: #fbfbf9;
+ --ink: #0f172a;
+ --ink-2: #475569;
+ --ink-3: #94a3b8;
+ --border: #e2e8f0;
+ --border-sub: rgba(226,232,240,0.7);
+ --radius-xl: 2rem;
+ --radius-lg: 1.5rem;
+ --shadow-card: 0 25px 80px -30px rgba(15,23,42,0.18);
+ --shadow-sm: 0 1px 3px rgba(15,23,42,0.06);
+ --sans: 'Inter', sans-serif;
+ --mono: 'DM Mono', monospace;
+ --serif: 'DM Serif Display', serif;
+}
+```
+
+### Layout
+
+- Max-Width: `72rem` · Section-Padding: `5rem` Desktop / `3rem` Mobile
+- Seitenabstand: `1.5rem` Mobile · `2.5rem` Tablet+
+
+### Komponenten
+
+Buttons immer Pill-Form (`border-radius: 999px`), nie eckig.
+Cards hover: `transform: translateY(-4px)` + Shadow.
+Tags: `DM Mono`, `border-radius: 999px`.
+Nav: sticky, max. 4–5 Einträge, Logo in `DM Mono uppercase`.
+
+### Animationen
+
+```css
+@keyframes fadeUp { from { opacity:0; transform:translateY(18px); } to { opacity:1; transform:translateY(0); } }
+/* Stagger: 0.05s–0.15s · max duration: 0.7s · kein Parallax, kein Bounce */
+```
+
+### Zweisprachigkeit DE/EN
+
+```html
+
+Text
Text
+```
+```css
+[data-lang], span[data-lang] { display: none !important; }
+body.de [data-lang="de"], body.de span[data-lang="de"] { display: revert !important; }
+body.en [data-lang="en"], body.en span[data-lang="en"] { display: revert !important; }
+body.de span[data-lang="de"], body.en span[data-lang="en"] { display: inline !important; }
+```
+
+### Docker / Traefik
+
+Pure Frontend → `nginx:alpine`. Jede Domain bekommt einen **eigenen Traefik-Router**
+(separates TLS-Zertifikat pro Domain, nie mehrere Domains in einem Router).
+
+---
+
+## Schritt 3: Subdomain-Referenz lesen
+
+Lies jetzt die passende Datei aus `references/` für Farben, Charakter
+und subdomain-spezifische Eigenheiten.
+
+---
+
+## Verbotsliste (alle Subdomains)
+
+❌ Fonts von Google · ❌ Lila/Violette Gradienten · ❌ System-Fonts
+❌ Eckige Buttons · ❌ `#000` als Textfarbe · ❌ >2 Akzentfarben gleichzeitig
+❌ Hardcodierte Farbwerte · ❌ Überfüllte Nav · ❌ Parallax / Bounce
diff --git a/.claude/skills/abler-design/references/abler-tirol.md b/.claude/skills/abler-design/references/abler-tirol.md
new file mode 100644
index 0000000..c0e8301
--- /dev/null
+++ b/.claude/skills/abler-design/references/abler-tirol.md
@@ -0,0 +1,51 @@
+# abler.tirol — Landing Page
+
+## Charakter
+Sachlich, übergeordnet, verbindend. Der Anker des Ökosystems.
+Kein starker Farbakzent — die Subdomains bringen Farbe.
+
+## Akzentfarben
+```css
+--accent-primary: #0f172a; /* Slate 900 */
+--accent-secondary: #334155; /* Slate 700 */
+```
+
+## Produkttyp: Landing / Static
+- Reines HTML/CSS/JS — kein Framework, kein Build-Step
+- Kein eigenes Backend, kein API-Zugriff
+- Docker: `nginx:alpine` mit `index.html`
+
+## Hero-Stil
+Hell (`--bg-light`), Dashboard-Widget rechts, fadeUp-Animationen.
+Kein Dark Hero — neutrales Intro das alle Produkte zusammenhält.
+
+## Typografie-Gewicht
+Ausgewogen. `DM Serif Display` italic für einzelne Wort-Akzente im Hero-Titel.
+`Inter` für Body. `DM Mono` für Labels und Nav-Logo.
+
+## Besonderheiten
+- Zeigt alle aktiven Produkte als Cards im 2-Spalten-Grid
+- 5. oder weitere Cards: `card-wide` (full-width, horizontal Layout Desktop)
+- Hero-Widget zeigt Anzahl aktiver Produkte + Status-Zeilen
+- Nav-Badges verlinken direkt zu den Subdomains
+- Sprach-Toggle DE/EN oben rechts
+
+## Card-Farben pro Produkt
+```css
+/* api */ linear-gradient(135deg, #0f172a, #334155)
+/* klara */ linear-gradient(135deg, #064e3b, #065f46)
+/* barcode */ linear-gradient(135deg, #78350f, #92400e)
+/* pdf */ linear-gradient(135deg, #1e1b4b, #312e81)
+/* sims */ linear-gradient(135deg, #134e4a, #0f3d3a)
+/* daedalus */ linear-gradient(135deg, #1a1a2e, #16213e)
+```
+
+## Repo
+`github.com/simonabler/abler-tirol`
+
+## Neue Produkte hinzufügen
+1. CSS-Variable `---from` / `---to` in `:root` ergänzen
+2. `.card-top-` CSS-Klasse mit Gradient anlegen
+3. Card-HTML im Products-Grid einfügen
+4. Widget-Zähler erhöhen, Widget-Row ergänzen
+5. Nav-Badge optional hinzufügen
diff --git a/.claude/skills/abler-design/references/api.md b/.claude/skills/abler-design/references/api.md
new file mode 100644
index 0000000..fb2b998
--- /dev/null
+++ b/.claude/skills/abler-design/references/api.md
@@ -0,0 +1,68 @@
+# api.abler.tirol — Zentrale REST-API
+
+## Charakter
+Technisch, präzise, terminal-nah. `DM Mono` dominiert sichtbarer als
+bei anderen Produkten. Dark Hero mit Terminal-Widget. Developer-First.
+
+## Akzentfarben
+```css
+--accent-primary: #0f172a; /* Slate 900 */
+--accent-secondary: #1e3a5f; /* Deep Blue-Slate */
+--accent-highlight: #5bffc3; /* Terminal Green — Cursor, Live-Indikatoren */
+--accent-code: #3b8fff; /* Code Blue — Syntax-Highlighting */
+--bg-dark: #0b0d11; /* Hero / Terminal backgrounds */
+```
+
+## Produkttyp: API + Minimal-Frontend
+- NestJS Backend (Port 3000)
+- Angular Frontend (Port 80)
+- Eigene Datenbank (SQLite / PostgreSQL)
+- CORS: `*` — öffentlich nutzbar
+- Fonts werden hier **gehostet** und an alle anderen Subdomains ausgeliefert
+
+## Font-Server Endpunkte
+```
+GET /fonts/abler-stack.css → kombiniertes @font-face CSS
+GET /fonts/files/:filename → individuelle woff2-Dateien
+```
+Font-Dateien liegen in `apps/server/src/assets/fonts/files/` (woff2 aus `@fontsource`).
+NestJS GlobalPrefix ist `api` — `/fonts/*` ist davon **ausgenommen** (`exclude: ['fonts/(.*)']`).
+
+## Hero-Stil
+Dark Hero (`--bg-dark`) mit Grid-Overlay, Glow-Effekten und Terminal-Widget.
+Terminal zeigt Live-Curl-Beispiele mit animiertem Cursor (`blink 1.1s step-end`).
+
+## Typografie-Gewicht
+Mono-lastig. `DM Mono` für Labels, Badges, Terminal, alle technischen Texte.
+`DM Serif Display` wird hier nicht oder sehr sparsam eingesetzt.
+
+## Swagger / API Docs
+Swagger UI unter `/api` — beide Domains als Server eingetragen:
+```ts
+.addServer('https://api.abler.tirol')
+.addServer('https://hub.abler.tirol') // Legacy, bleibt aktiv
+```
+
+## Traefik — beide Domains, getrennte Router
+```yaml
+# Frontend: hub + api → selber Service, 2 Router für 2 Zertifikate
+hub-frontend: Host(`hub.abler.tirol`) && PathPrefix(`/`)
+api-frontend: Host(`api.abler.tirol`) && PathPrefix(`/`)
+# Backend: /api/* und /fonts/* → NestJS
+hub-backend: Host(`hub.abler.tirol`) && (PathPrefix(`/api`) || PathPrefix(`/fonts`))
+api-backend: Host(`api.abler.tirol`) && (PathPrefix(`/api`) || PathPrefix(`/fonts`))
+```
+
+## Repo
+`github.com/simonabler/simonapi` (NX Monorepo: `apps/server` + `apps/simonapi`)
+
+## API-Endpunkte (Übersicht)
+`/api/qr` · `/api/barcode` · `/api/barcode/gs1` · `/api/crypto`
+`/api/watermark` · `/api/signpacks` · `/api/utils` · `/api/locks`
+`/fonts/abler-stack.css` · `/fonts/files/:filename`
+
+## Besonderheiten beim Entwickeln
+- Neue Endpunkte: eigenes NestJS Module in `apps/server/src/app//`
+- Font-Assets: woff2 aus `@fontsource` in `apps/server/src/assets/fonts/files/`
+- Webpack kopiert `src/assets/` automatisch nach `dist/`
+- API-Keys: 3 Tiers — Free (10/min), Pro (`sk_pro_`), Industrial (`sk_ind_`)
diff --git a/.claude/skills/abler-design/references/barcode.md b/.claude/skills/abler-design/references/barcode.md
new file mode 100644
index 0000000..17fbb6f
--- /dev/null
+++ b/.claude/skills/abler-design/references/barcode.md
@@ -0,0 +1,58 @@
+# barcode.abler.tirol — Barcode & QR Generator
+
+## Charakter
+Industriell, präzise, physisch-nah. Erinnert an Etiketten, Scanner,
+Lagerlogistik. Helles Layout mit Amber-Akzenten.
+Primäre Zielgruppe: Logistik, Handel, Produktion — keine Developer.
+
+## Akzentfarben
+```css
+--accent-primary: #78350f; /* Amber 900 */
+--accent-secondary: #92400e; /* Amber 800 */
+--accent-highlight: #f59e0b; /* Amber 500 — aktive Zustände, Scan-Indikator */
+--accent-light: #fef3c7; /* Amber 100 — subtile Hintergründe */
+```
+
+## Produkttyp: Pure Frontend
+- Reines HTML/CSS/JS oder Angular SPA
+- **Kein eigenes Backend** — alle Daten via `fetch()` von `api.abler.tirol`
+- Docker: `nginx:alpine`
+- Fonts: `api.abler.tirol/fonts/abler-stack.css`
+
+## API-Aufrufe (Beispiele)
+```js
+// QR Code generieren
+const res = await fetch('https://api.abler.tirol/api/qr', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: 'url', payload: { url: input }, format: 'svg' })
+});
+
+// Standard Barcode
+const url = `https://api.abler.tirol/api/barcode/svg?type=code128&text=${encodeURIComponent(input)}&includetext=true`;
+
+// GS1 (benötigt API-Key für Pro-Endpunkte)
+const res = await fetch('https://api.abler.tirol/api/barcode/gs1/render', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-api-key': key },
+ body: JSON.stringify({ symbology: 'gs1-128', format: 'png', items: [...] })
+});
+```
+
+## Hero-Stil
+Hell (`--bg-light`), Amber-Akzente. Generator-UI im Vordergrund —
+kein langer Marketing-Text, direkt zum Tool.
+
+## Typografie-Gewicht
+`DM Serif Display` + `Inter`. Kein Terminal-Look.
+Eyebrows und Tags in `DM Mono`.
+
+## UI-Besonderheiten
+- Generator steht sofort im Viewport — kein Scroll nötig
+- Live-Preview des Barcodes während der Eingabe (debounced, ~300ms)
+- Download-Button für PNG/SVG direkt neben der Vorschau
+- Amber-Akzent als Scan-Linie / aktiver Rahmen bei der Preview
+- Typ-Auswahl: QR / Code128 / EAN / GS1 als Pill-Tabs
+
+## Repo
+`github.com/simonabler/barcode-abler-tirol`
diff --git a/.claude/skills/abler-design/references/klara.md b/.claude/skills/abler-design/references/klara.md
new file mode 100644
index 0000000..308ca8c
--- /dev/null
+++ b/.claude/skills/abler-design/references/klara.md
@@ -0,0 +1,48 @@
+# klara.abler.tirol — Dokumentationstool für Lehrkräfte
+
+## Charakter
+Ruhig, warm, menschlich. Betont Vertrauen und Datenschutz.
+Helles Layout, viel Weißraum, Serif-Titel (kursiv).
+Wenig technischer Jargon — spricht Lehrkräfte an, nicht Developer.
+
+## Akzentfarben
+```css
+--accent-primary: #064e3b; /* Emerald 900 */
+--accent-secondary: #065f46; /* Emerald 800 */
+--accent-highlight: #10b981; /* Emerald 500 — aktive Elemente, Icons */
+--accent-light: #ecfdf5; /* Emerald 50 — subtile Hintergründe */
+```
+
+## Produkttyp: Fullstack (eigenständig)
+- **Eigenes NestJS Backend** + PostgreSQL/SQLite
+- **Eigene Authentifizierung** (Google OAuth + JWT)
+- Kein Zugriff auf `api.abler.tirol` — vollständig unabhängig
+- Self-Hosted: Nutzer betreiben eigene Instanz
+- Fonts: `api.abler.tirol/fonts/abler-stack.css`
+
+## Hero-Stil
+Sehr hell, viel Weißraum. `DM Serif Display` italic dominant im Titel
+("Dokumentation, die *endlich* mitdenkt.").
+Kein Dark Hero, kein Terminal-Widget.
+
+## Typografie-Gewicht
+Serif-dominant. `DM Serif Display` italic für Haupttitel.
+`Inter` light (300) für Fließtext — ruhig, lesbar.
+`DM Mono` nur für technische Labels (DSGVO-Badges, Status).
+
+## DSGVO / Datenschutz
+Klara ist DSGVO-konform by design — das ist ein Kernelement des Brandings:
+- "Self-hosted" Badge prominent
+- "Keine Daten bei Drittanbietern"
+- "Open Source — jede Zeile Code öffentlich"
+Datenschutz-Section ist Pflichtbestandteil jeder Seite.
+
+## UI-Besonderheiten
+- App-Interface: Angular, Bootstrap 5
+- Schülerprofile, Notizen, Leistungen als separate Bereiche
+- Keine komplexen Mega-Menüs — Reduktion ist Kernwert
+- Testimonials von Lehrkräften auf der Landing Page
+- CTA: "Klara jetzt ausprobieren →" + "Selbst hosten"
+
+## Repo
+`github.com/simonabler/Klara`
diff --git a/.claude/skills/abler-design/references/new-product.md b/.claude/skills/abler-design/references/new-product.md
new file mode 100644
index 0000000..4afca79
--- /dev/null
+++ b/.claude/skills/abler-design/references/new-product.md
@@ -0,0 +1,112 @@
+# Neue Subdomain / Neues Produkt — Checkliste
+
+Wenn ein neues Produkt unter `*.abler.tirol` erstellt wird,
+folge dieser Checkliste. Danach ergänze eine neue Referenzdatei
+in `references/.md` nach dem Muster der bestehenden.
+
+---
+
+## 1. Produkt-Typ festlegen
+
+| Typ | Wann | Beispiel |
+|---|---|---|
+| **Pure Frontend** | Kein eigenes Backend nötig, alle Daten von api.abler.tirol | barcode, pdf |
+| **Fullstack** | Eigene DB, eigene Auth, unabhängig | klara, sims |
+| **Landing** | Nur statische Seite | abler.tirol |
+| **API + Minimal-Frontend** | Backend ist das Produkt | api |
+
+## 2. Akzentfarbe wählen
+
+Bestehende Farben (nicht nochmal verwenden):
+- Slate / Blue-Slate → api
+- Emerald → klara
+- Amber → barcode
+- Indigo → pdf
+- Teal → sims
+- Nacht-Dunkel → daedalus
+
+Empfohlene freie Paletten für neue Produkte:
+- Rose / Pink → warme Consumer-Tools
+- Sky / Cyan → Kommunikation, Echtzeit
+- Orange → Warnung, Monitoring
+- Violet (helles) → Kreativ-Tools
+- Stone / Warm-Gray → Neutrales, Dokumentation
+
+Immer: dunkle `from`-Farbe (900) + etwas hellere `to`-Farbe (800) für Gradient.
+
+## 3. CSS-Variablen anlegen
+
+```css
+---from: <900-shade>;
+---to: <800-shade>;
+---highlight: <500-shade>;
+---light: <50-shade>;
+```
+
+## 4. Dateien erstellen
+
+### Pure Frontend (Static)
+```
+-abler-tirol/
+├── index.html
+├── nginx.conf
+├── Dockerfile
+├── docker-compose.yml
+└── README.md
+```
+
+### Fullstack
+```
+-abler-tirol/ (oder eigener Repo-Name)
+├── apps/
+│ ├── server/ (NestJS)
+│ └── frontend/ (Angular o.ä.)
+├── docker-compose.yml
+├── dockerfiles/
+└── README.md
+```
+
+## 5. Font-Einbindung
+
+```html
+
+
+```
+
+## 6. Traefik-Labels (docker-compose.yml)
+
+Eigener Router pro Domain — nie mehrere Domains in einem Router:
+```yaml
+- "traefik.http.routers.-frontend.rule=Host(`.abler.tirol`) && PathPrefix(`/`)"
+- "traefik.http.routers.-frontend.tls=true"
+- "traefik.http.routers.-frontend.tls.certresolver=letsEncrypt"
+- "traefik.http.routers.-frontend.entrypoints=websecure"
+- "traefik.http.routers.-frontend.service=-svc"
+- "traefik.http.services.-svc.loadbalancer.server.port=80"
+```
+
+## 7. abler.tirol Landing Page aktualisieren
+
+In `github.com/simonabler/abler-tirol/index.html`:
+1. CSS-Variable `---from` / `---to` in `:root`
+2. `.card-top-` Gradient-Klasse
+3. Neue Card im `products-grid` (bei 5. und folgenden: `card-wide`)
+4. Widget-Zähler erhöhen
+5. Widget-Row ergänzen
+
+## 8. DESIGN_PRINCIPLES.md aktualisieren
+
+- Neue Zeile in der Produktcharakter-Tabelle (Abschnitt 9)
+- Neue Akzentfarbe in Abschnitt 4
+- Neuer Repo-Eintrag in Abschnitt 11
+
+## 9. Diese Skill-Datei ergänzen
+
+Neue Datei `references/.md` nach dem Muster der bestehenden anlegen:
+- Charakter & Zielgruppe
+- Akzentfarben (CSS)
+- Produkttyp & Tech-Stack
+- Hero-Stil
+- Typografie-Gewicht
+- UI-Besonderheiten
+- Repo-Link
diff --git a/.claude/skills/abler-design/references/pdf.md b/.claude/skills/abler-design/references/pdf.md
new file mode 100644
index 0000000..8e08780
--- /dev/null
+++ b/.claude/skills/abler-design/references/pdf.md
@@ -0,0 +1,55 @@
+# pdf.abler.tirol — PDF Werkzeugkasten
+
+## Charakter
+Professionell, büro-nah, vertrauenswürdig. Für Notare, Verwaltung,
+Büros, Lehrkräfte. Ruhiger als api, formaler als klara.
+Primäre Zielgruppe: Büro, Verwaltung, alle die PDFs verwalten müssen.
+
+## Akzentfarben
+```css
+--accent-primary: #1e1b4b; /* Indigo 950 */
+--accent-secondary: #312e81; /* Indigo 900 */
+--accent-highlight: #6366f1; /* Indigo 500 — aktive Elemente */
+--accent-light: #eef2ff; /* Indigo 50 — subtile Hintergründe */
+```
+
+## Produkttyp: Pure Frontend
+- Reines HTML/CSS/JS oder Angular SPA
+- **Kein eigenes Backend** — alle Operationen via `api.abler.tirol`
+- Datei-Uploads gehen direkt an die API (multipart/form-data)
+- Docker: `nginx:alpine`
+- Fonts: `api.abler.tirol/fonts/abler-stack.css`
+
+## API-Aufrufe (Beispiele)
+```js
+// PDF signieren (Signpack-Workflow)
+// 1. Upload
+const form = new FormData();
+form.append('file', file);
+form.append('expiresInMinutes', '60');
+const { id, token } = await fetch('https://api.abler.tirol/api/signpacks', {
+ method: 'POST', body: form
+}).then(r => r.json());
+
+// 2. Signing-Link teilen: api.abler.tirol/api/signpacks/:id/meta?token=:token
+// 3. Bundle herunterladen
+const bundle = await fetch(`https://api.abler.tirol/api/signpacks/${id}/bundle.zip?token=${token}`);
+```
+
+## Hero-Stil
+Hell (`--bg-light`), Indigo-Akzente. Tool-First — Upload-Bereich
+prominent im Hero. Kein langer Intro-Text.
+
+## Typografie-Gewicht
+`Inter`-dominant, sans-lastig. Formaler, weniger verspielt.
+`DM Serif Display` nur sparsam für H1.
+
+## UI-Besonderheiten
+- Drag & Drop Upload-Zone prominent (Indigo-Border on hover)
+- Drei klar getrennte Tools: Sign / Merge / Split — Pill-Tab Navigation
+- Kein Account nötig, keine Daten gespeichert (Signpack: temp. Token)
+- Datenschutz-Hinweis prominent: "Kein Upload zu Drittanbietern"
+- Indigo als aktiver Zustand bei Steps / Progress-Indikatoren
+
+## Repo
+`github.com/simonabler/pdf-abler-tirol`
diff --git a/.claude/skills/abler-design/references/sims.md b/.claude/skills/abler-design/references/sims.md
new file mode 100644
index 0000000..8df178d
--- /dev/null
+++ b/.claude/skills/abler-design/references/sims.md
@@ -0,0 +1,113 @@
+# sims.abler.tirol — Lagerverwaltungssystem
+
+## Charakter
+Direkt, funktional, industriell-nah. Für Lager, Werkstätten, kleine Betriebe.
+Das UI darf nicht vom UX ablenken — Daten und Aktionen stehen im Vordergrund,
+nicht das Interface selbst. Teal als ruhiger, industrieller Gegenpol zu Emerald (klara).
+
+## Akzentfarben
+```css
+--accent-primary: #134e4a; /* Teal 900 */
+--accent-secondary: #0f3d3a; /* Teal 950 */
+--accent-highlight: #14b8a6; /* Teal 500 — aktive Elemente */
+--accent-light: #f0fdfa; /* Teal 50 — Hover-Hintergrund, subtile Fills */
+```
+
+## Status-Farben (sims-spezifisch)
+```css
+--status-ok: #14b8a6; /* Teal — In Bestand */
+--status-low: #f59e0b; /* Amber — Niedrig */
+--status-empty: #ef4444; /* Rot — Leer */
+--status-ok-bg: #f0fdfa;
+--status-low-bg: #fffbeb;
+--status-empty-bg: #fef2f2;
+```
+
+## Radius-Regeln (sims)
+sims verwendet **reduzierte Radien** — funktional, nicht dekorativ:
+```css
+--radius-pill: 999px; /* Buttons, Badges — abler-Ökosystem-Pflicht */
+--radius-md: 6px; /* Cards, Inputs, Tabellen, Panels */
+--radius-sm: 4px; /* Tags, kleine Elemente */
+--radius-xs: 3px; /* Code, inline */
+```
+Kein `--radius-xl` (2rem) oder `--radius-lg` (1.5rem) auf funktionalen Elementen.
+
+## Produkttyp: Fullstack (eigenständig)
+- **Eigenes Backend** (NestJS)
+- **Eigene Datenbank** — Artikel, Bestände, Bewegungen persistent
+- Kein Zugriff auf `api.abler.tirol`
+- Self-Hosted
+- Fonts: `api.abler.tirol/fonts/abler-stack.css`
+- Design Guid: design-guide.html
+## Hero-Stil
+Hell (`--bg-light`), Teal-Akzente. Funktional-first.
+Kein ausladender Marketing-Hero — direkt zur App oder zum Einstieg.
+
+## Typografie-Gewicht
+`Inter`-dominant. `DM Serif Display` nur sparsam (Seitenüberschriften).
+`DM Mono` für alle technischen Werte: Artikelnummern, Mengenangaben, Beträge, Status-Labels.
+
+## UI-Besonderheiten
+
+### Navigation (Sidebar im App-Kontext)
+- Hintergrund: `--accent-secondary` (#0f3d3a)
+- Aktiver Eintrag: `background: rgba(20,184,166,0.15)`, `color: --accent-highlight`
+- Sektions-Labels: `DM Mono`, `uppercase`, `rgba(255,255,255,0.2)`
+- Sidebar-Items: `border-radius: var(--radius-sm)` (4px)
+
+### Tabellen
+- Responsive: `overflow-x: auto` — Spalten werden **nie** versteckt
+- Header: `DM Mono`, `uppercase`, `letter-spacing: 0.1em`, `--ink-3`
+- Zeilenhover: `background: var(--accent-light)`
+- Artikelnummern, Mengen, Beträge: immer `DM Mono`
+- Wrapper: `border-radius: var(--radius-md)` (6px)
+
+### Cards
+- `border-radius: var(--radius-md)` (6px) — kein 2rem
+- Hover: nur `box-shadow: var(--shadow-card)` — **kein** `translateY`
+- Kontext-Signal via `border-left: 3px solid` (Teal / Amber / Rot)
+- Kein dekorativer Gradient-Top
+
+### Buttons
+- Pill-Form (`border-radius: 999px`) — Ökosystem-Pflicht
+- Hover: nur Farbwechsel — **kein** `translateY(-1px)`
+- Kompaktes Padding: `0.55rem 1.25rem`
+- Focus: `box-shadow: var(--shadow-focus)`, `outline: none`
+
+### Status-Badges
+```
+Teal → "In Bestand" → badge-ok
+Amber → "Niedrig" → badge-low
+Rot → "Leer" → badge-empty
+```
+- Font: `DM Mono`, `font-size: 0.7rem`
+- Dot-Indikator (`::before`) für schnelle Lesbarkeit in Tabellen
+
+### Formulare
+- `border-radius: 6px` auf Inputs/Selects
+- Labels: `DM Mono`, `uppercase`, `letter-spacing: 0.1em`, `--ink-3`
+- Artikelnummer- und Mengen-Inputs: `font-family: var(--mono)`
+- Schnellsuche prominent platzieren (Artikel-Nr. + Bezeichnung)
+
+### Animationen
+- Hover: **kein** `translateY` — nur Shadow/Farbwechsel
+- Page Load: `fadeUp` mit `0.4s ease`, Stagger `0.07s`
+- Focus: Teal-Ring via `box-shadow: var(--shadow-focus)`
+
+## Dokument-Layout (Lieferschein / Rechnung)
+- Header: `--accent-secondary` Hintergrund, Dokumenttyp in `DM Serif Display`
+- Dokumentnummer: `DM Mono`, `--accent-highlight`
+- Positionstabelle: Standard-Tabellen-Regeln (siehe oben)
+- Footer: Gesamtbetrag in `DM Mono`, bündig rechts
+
+## DSGVO
+Self-Hosted = keine Fremddaten. Datenschutz-Hinweis knapp —
+die Zielgruppe (Lager, Werkstatt) erwartet das als Standard.
+
+## Zweisprachigkeit
+- App-Kern (Dashboard, Tabellen, Formulare): **nur DE**
+- Öffentliche Landing-Page: DE/EN mit Toggle
+
+## Repo
+`github.com/simonabler/sim-system`
\ No newline at end of file
diff --git a/.claude/skills/test-agent/SKILL.md b/.claude/skills/test-agent/SKILL.md
new file mode 100644
index 0000000..9833f87
--- /dev/null
+++ b/.claude/skills/test-agent/SKILL.md
@@ -0,0 +1,188 @@
+---
+name: test-agent
+description: Erstellt Requirements (mit Evidenz), Testmatrizen und Tests. Keine Halluzination. Keine Tests "geradebiegen".
+---
+
+MODE CONTROL
+- Wenn die User-Nachricht mit "MODE=SPEC" beginnt: liefere nur A–D (Inventory/REQs/Unklarheiten/Risiken).
+- Wenn "MODE=MATRIX": liefere nur E–F + H–J (Dimensionen/Matrix/Coverage/Gaps/Decision).
+- Wenn "MODE=TESTS": liefere nur G + K (Testfälle/Testcode + File Map).
+- Sonst: Full pipeline A–K.
+
+
+
+
+MASTER SYSTEM PROMPT – TEST AGENT (Spec → Testmatrix → Tests) | STRICT NON-HALLUCINATION & NO-BENDING
+
+Du bist ein extrem konservativer Test-Agent. Du erzeugst (1) Requirements/Specs mit Evidenz, (2) eine Testmatrix mit Traceability, (3) konkrete Testfälle (und optional Testcode), ausschließlich basierend auf den bereitgestellten Inputs. Du bist „defect-friendly“: Wenn etwas nicht passt, kann der Code falsch sein. Du biegst keine Tests gerade.
+
+====================================================================
+0) NICHT-HALLUZINIEREN (HARTES GESETZ)
+====================================================================
+- Erfinde niemals: Anforderungen, Endpunkte, Felder, Business-Regeln, Error-Codes, Rollen, Datenformate, Validierungen, Randfälle, Mocks/Fixtures oder Tooling, die nicht aus Inputs belegbar sind.
+- Jede Aussage muss eine Quelle/Evidenz haben oder als UNBELEGT / UNKLAR / INFERIERT markiert werden.
+- INFERIERT ist nur erlaubt, wenn es unmittelbar aus Code-Struktur ableitbar ist (z.B. Funktionssignatur, Schema, klarer Kontrollfluss). INFERIERT darf niemals als Fakt formuliert werden.
+- Wenn Informationen fehlen: markiere BLOCKED statt Annahmen zu treffen.
+- Wenn Test fehlschlagen, weil der code einen Fehler hat, lasse den Test failen, der code wird im nachgang bereinigt.
+
+====================================================================
+1) KEIN „GERADEBIEGEN“ (TEST-INTEGRITÄT)
+====================================================================
+- Passe erwartete Ergebnisse NICHT an, nur damit die aktuelle Implementierung grün wird.
+- Wenn Tests scheitern, sind mögliche Ursachen:
+ a) Defekt im Code
+ b) Defekt/Unklarheit in der Spezifikation
+ c) Setup/Umgebung/Testdaten-Problem
+ -> aber niemals „wir machen die Erwartung weicher“.
+- Niemals „expected to fail“ als Ausrede markieren. Tests bleiben neutral. Abweichungen werden als Potential Defect dokumentiert.
+
+====================================================================
+2) SOURCE OF TRUTH & KONFLIKTE
+====================================================================
+Quellen-Priorität (von hoch nach niedrig):
+1) Explizite Anforderungen / Acceptance Criteria / Tickets / Spezifikation
+2) API-Verträge (OpenAPI/Swagger/JSON Schema), Schnittstellenverträge
+3) Dokumentation (README, ADRs), normative Kommentare („must/shall“)
+4) Code-Verhalten (nur wenn 1–3 fehlen; dann als Derived-from-code kennzeichnen)
+
+Konflikte:
+- Wenn Quellen widersprechen: dokumentiere den Konflikt mit Referenzen.
+- Entscheide NICHT „was richtig ist“, außer eine höher priorisierte Quelle ist eindeutig.
+- Erzeuge eine Decision Needed Liste.
+
+====================================================================
+3) INPUTS & ARBEITSMODUS
+====================================================================
+Du erhältst Inputs wie: Code, Tickets, README, API-Spec, Logs, Beispiele, Testframework-Vorgaben.
+- Wenn Testframework/Tech-Stack NICHT eindeutig gegeben ist: generiere framework-agnostische Tests (Given/When/Then + Pseudocode) und markiere Tooling als UNBELEGT.
+- Wenn ein Framework explizit gegeben ist (z.B. pytest/jest/junit): generiere lauffähige Testdateien in diesem Framework (sofern alle nötigen Details vorhanden sind). Sonst: BLOCKED-Teile mit TODO.
+
+Determinismus:
+- Keine flakey Tests.
+- Keine echten externen Calls ohne Mock/Stub, außer Inputs verlangen explizit Integration gegen echte Systeme.
+- Keine willkürlichen Sleeps/Timeouts ohne Evidenz.
+
+====================================================================
+4) OUTPUT-ANFORDERUNGEN (TRACEABILITY, STRIKTES FORMAT)
+====================================================================
+Du lieferst IMMER in exakt dieser Reihenfolge und mit diesen Überschriften:
+
+A) INPUT INVENTORY
+B) REQUIREMENTS KATALOG (mit Evidenz)
+C) UNKLARHEITEN & OFFENE FRAGEN
+D) RISIKEN & TESTAUSWIRKUNGEN
+E) TESTDIMENSIONEN
+F) TESTMATRIX (Traceability)
+G) TESTFÄLLE (Spezifikation oder Code)
+H) COVERAGE SUMMARY
+I) GAPS & BLOCKERS
+J) DECISION NEEDED
+K) OPTIONAL: TEST FILE MAP (nur wenn du Dateien generierst)
+
+Wichtig:
+- Jede Requirement bekommt eine REQ-ID: REQ-001, REQ-002, …
+- Jede Testmatrix-Zeile referenziert genau eine REQ-ID (oder „NON-REQ“ für seltene technische Checks).
+- Jeder Testfall bekommt eine TC-ID: TC-001, TC-002, …
+- Jeder Testfall referenziert mindestens eine REQ-ID (oder NON-REQ).
+
+====================================================================
+5) DETAILREGELN PRO SEKTION
+====================================================================
+
+A) INPUT INVENTORY
+- Liste alle Inputs auf (Dateiname/Quelle/Abschnitt).
+- Notiere fehlende Artefakte (z.B. keine Spec) sachlich.
+
+B) REQUIREMENTS KATALOG (mit Evidenz)
+Für jedes Requirement:
+- ID: REQ-xxx
+- Titel: kurz
+- Beschreibung: präzise, testbar
+- Quelle: (z.B. Ticket #, OpenAPI, Datei+Zeile)
+- Evidenz: kurzes Zitat/Paraphrase + Referenz (Datei/Abschnitt/Zeile wenn vorhanden)
+- Priorität: High/Med/Low nur wenn belegt, sonst UNBELEGT
+- Status: CONFIRMED / INFERIERT / UNKLAR
+Regeln:
+- Keine vagen Worte ohne messbares Kriterium.
+- Keine Best-Practice-Sätze als Requirement erfinden.
+
+C) UNKLARHEITEN & OFFENE FRAGEN
+- UQ-001, UQ-002 …
+- Frage + warum unklar (fehlende Evidenz) + was klärt es.
+
+D) RISIKEN & TESTAUSWIRKUNGEN
+- Nur Risiken nennen, die aus Inputs ableitbar sind (z.B. fehlende Validierung, fehlende Error-Codes).
+- Keine frei erfundenen Security/Performance-Annahmen.
+
+E) TESTDIMENSIONEN
+- Definiere Dimensionen NUR wenn belegbar (Rollen, Plattformen, Datenvarianten, Locale, Error-Handling).
+- Wenn nicht belegbar: „Keine belegbaren Dimensionen außer Standardpfad“.
+
+F) TESTMATRIX (Traceability)
+Erzeuge eine Markdown-Tabelle mit Spalten:
+- REQ-ID
+- Feature/Komponente (aus Evidenz, sonst UNBELEGT)
+- Szenario (konkret, testbar)
+- Testtyp (Unit/Integration/E2E/API/UI/Security/Performance) – nur wenn sinnvoll ableitbar, sonst Functional
+- Priorität/Risiko (nur wenn belegt, sonst UNBELEGT)
+- Positiv/Negativ
+- Datenvarianten (nur belegbar; sonst Standarddaten)
+- Erwartetes Ergebnis (präzise; wenn unklar: BLOCKED (UNKLAR))
+- Automatisierbarkeit (Yes/No/Maybe) – konservativ
+- Notes/Evidenz-Referenz
+
+G) TESTFÄLLE (Spezifikation oder Code)
+Entscheidung:
+- Wenn Framework gegeben UND genügend Setup-Infos vorhanden: generiere Testcode.
+- Sonst: Given/When/Then + Pseudocode.
+
+Pro Testfall:
+- TC-ID
+- REQ-ID(s)
+- Titel
+- Typ
+- Voraussetzung/Setup
+- Schritte
+- Testdaten
+- Erwartetes Ergebnis (messbar)
+- Orakel/Evidenz (woher stammt die Erwartung)
+- Automatisierung: Yes/No/Blocked
+- Status: READY / BLOCKED (mit Grund)
+Zusatz:
+- Wenn du eine wahrscheinliche Abweichung erkennst: Abschnitt „Potential Defect“ mit Evidenz (ohne Erwartung zu ändern).
+
+H) COVERAGE SUMMARY
+- Anzahl Requirements
+- Anzahl abgedeckter Requirements
+- Liste fehlender Abdeckung (REQ-IDs) + Begründung
+
+I) GAPS & BLOCKERS
+- GAP-001 …: Beschreibung, betroffene REQ-IDs, welche Info fehlt
+- BLOCKED: welche Tests/Matrix-Zeilen, warum blockiert, was benötigt wird
+
+J) DECISION NEEDED
+- Konkrete Entscheidungen, die Product/Dev klären muss, inkl. betroffene REQ-IDs und widersprüchliche Evidenz.
+
+K) OPTIONAL: TEST FILE MAP
+- Nur wenn Testcode erzeugt wird:
+ - Dateipfad → enthaltene TC-IDs/REQ-IDs
+
+====================================================================
+6) VERBOTE
+====================================================================
+- Keine stillen Annahmen über Auth, Rollen, Statuscodes, Fehlermeldungen.
+- Keine erfundenen Validierungen (z.B. E-Mail-Format) ohne Evidenz.
+- Kein Greenwashing
+- Keine Änderungsvorschläge am Produktivcode als Teil der Tests.
+- Keine versteckten Annahmen über Datenbank-/Netzwerkzustand.
+- Keine unbelegten Performance-Schwellen.
+
+====================================================================
+7) INPUT-INTERFACE (WIE DU DIE AUFGABE INTERPRETIERST)
+====================================================================
+Du interpretierst den nächsten User-Input als:
+- Artefakte/Anforderungen/Code, die du analysieren sollst
+- Optional: gewünschtes Testframework (z.B. "pytest"), Zielplattform, Ordnerstruktur
+Wenn diese Infos fehlen, bleibst du framework-agnostisch und markierst fehlende Stellen als BLOCKED.
+
+BEGINNE NUN mit Abschnitt A) INPUT INVENTORY basierend auf den erhaltenen Inputs.
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..9a2b043
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,68 @@
+##### General build context hygiene for Nx/Node/Angular/NestJS repo
+
+# VCS and metadata
+.git
+.gitignore
+.gitattributes
+.github
+
+# Node/Nx/Angular outputs and caches
+node_modules
+dist
+tmp
+out-tsc
+coverage
+.nx/cache
+.nx/workspace-data
+.angular
+.eslintcache
+.cache
+.turbo
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+testem.log
+libpeerconnection.log
+
+# IDE/editor junk
+.vscode
+.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+.DS_Store
+Thumbs.db
+
+# Types, typings and sass caches
+/typings
+/.sass-cache
+
+# Environment files (keep examples)
+.env
+.env.*
+!.env.example
+!.env.*.example
+
+# Playwright/Jest/E2E artifacts
+playwright-report
+test-results
+.nyc_output
+apps/*-e2e/playwright-report
+apps/*-e2e/test-results
+
+# Local databases and dumps
+*.sqlite
+*.db
+signpacks.sqlite
+
+# Misc
+.cursor
+/postgres
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..6e87a00
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,13 @@
+# Editor configuration, see http://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/.env b/.env
new file mode 100644
index 0000000..195a80d
--- /dev/null
+++ b/.env
@@ -0,0 +1,5 @@
+APP_PDF_SLIP_PATH=C:\Users\ematric\Desktop\private\SIMSystem\pdf
+APP_PDF_BILL_PATH=C:\Users\ematric\Desktop\private\SIMSystem\pdf
+
+SQLITE_RUN_SYNCHRONIZE=true
+#SQLITE_PATH=prod_neu.sqlite3
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6d5c92a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# compiled output
+dist
+tmp
+out-tsc
+
+# dependencies
+node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# misc
+/.sass-cache
+/connect.lock
+/coverage
+/libpeerconnection.log
+npm-debug.log
+yarn-error.log
+testem.log
+/typings
+
+# System Files
+.DS_Store
+Thumbs.db
+
+.nx/cache
+.nx/workspace-data
+
+.angular
+sim*.db
+uploads/settings/*
+!uploads/settings/.gitkeep
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..113709c
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,6 @@
+# Add files here to ignore them from prettier formatting
+/dist
+/coverage
+/.nx/cache
+/.nx/workspace-data
+.angular
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..544138b
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,3 @@
+{
+ "singleQuote": true
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..db03eda
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,9 @@
+{
+ "recommendations": [
+ "nrwl.angular-console",
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint",
+ "firsttris.vscode-jest-runner",
+ "ms-playwright.playwright"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..8137e5a
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Debug server with Nx",
+ "runtimeExecutable": "npx",
+ "runtimeArgs": ["nx", "serve", "server"],
+ "env": {
+ "NODE_OPTIONS": "--inspect=9229"
+ },
+ "console": "integratedTerminal",
+ "internalConsoleOptions": "neverOpen",
+ "skipFiles": ["/**"],
+ "sourceMaps": true,
+ "outFiles": [
+ "${workspaceFolder}/apps/server/dist/**/*.(m|c|)js",
+ "!**/node_modules/**"
+ ]
+ }
+ ]
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..552a00a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,348 @@
+# SIMS — Übergabe-Kontext für Claude Code
+
+## Projekt-Überblick
+
+**Repository:** `github.com/simonabler/sim-system` · Branch: `first-init`
+**Typ:** NX Monorepo — Fullstack Inventory Management System
+**Teil des:** `abler.tirol` Ökosystems (siehe Design-Prinzipien unten)
+
+---
+
+## Monorepo-Struktur
+
+```
+sim-system/
+├── apps/
+│ ├── server/ → NestJS 11 Backend (REST API)
+│ └── sims/ → Angular 20 Frontend (Standalone Components)
+├── libs/
+│ └── domain/ → Shared Domain Library
+├── package.json → Root (alle Dependencies)
+├── nx.json
+└── tsconfig.base.json
+```
+
+---
+
+## Backend (`apps/server`)
+
+### Tech Stack
+- NestJS 11, TypeORM 0.3, SQLite, Webpack (via NX)
+- Swagger-Docs: `GET /api/v1/doc`
+- Global Prefix: `/api/v1`
+
+### Starten
+```bash
+# Dev (watch mode)
+npx nx serve server
+
+# Production build
+npx nx build server
+node dist/apps/server/main.js
+```
+
+### Umgebungsvariablen
+Datei: `apps/server/.env` (liegt im .gitignore, `.env.example` als Vorlage)
+
+```env
+NODE_ENV=development
+APP_PORT=9000
+APP_URL=http://localhost:9000
+APP_JWT_SECRET=change_me_in_production
+SQLITE_PATH=./storage/sims.sqlite
+SQLITE_RUN_MIGRATION=true
+SQLITE_RUN_SYNCHRONIZE=false
+```
+
+### ⚠️ Wichtig beim ersten Setup
+`sqlite3` braucht native Kompilierung:
+```bash
+npm install # OHNE --ignore-scripts (kompiliert sqlite3 native binary)
+```
+
+### API-Routen (alle unter `/api/v1/`)
+| Route | Beschreibung |
+|---|---|
+| `GET /articles` | Alle Artikel |
+| `GET /articles?code=X` | Artikel nach Barcode |
+| `POST /articles` | Artikel anlegen |
+| `PATCH /articles/:id` | Artikel updaten |
+| `DELETE /articles/:id` | Artikel löschen |
+| `POST /articles/:id/inventory` | Inventurbuchung |
+| `POST /articles/import` | CSV-Import |
+| `GET /articlegroups` | Artikelgruppen |
+| `GET /customers` | Kunden |
+| `GET /customers/:id` | Kunde by ID |
+| `GET /customers/:id/slipsheets` | Lieferscheine des Kunden |
+| `GET /customers/:id/bills` | Rechnungen des Kunden |
+| `PATCH /customers/:id` | Kunde updaten |
+| `POST /customers` | Kunde anlegen |
+| `DELETE /customers/:id` | Kunde löschen |
+| `PUT /customers/:id/discounts` | Rabatt setzen |
+| `DELETE /customers/:id/discounts/:dId` | Rabatt löschen |
+| `GET /slipsheets` | Alle Lieferscheine |
+| `GET /slipsheets/:id` | Lieferschein by ID |
+| `POST /slipsheets` | Lieferschein anlegen |
+| `PUT /slipsheets/:id` | Lieferschein updaten |
+| `POST /slipsheets/:id` | Order-Entry hinzufügen |
+| `GET /slipsheets/:id/pdf` | Lieferschein als PDF |
+| `POST /slipsheets/:id/print` | Drucken |
+| `GET /bills` | Alle Rechnungen |
+| `POST /bills/generate` | Rechnung aus Lieferscheinen erzeugen |
+| `PUT /bills/:id` | Rechnung updaten |
+| `POST /bills/:id` | Rechnung neu erstellen (recreate) |
+| `GET /bills/:id/pdf` | Rechnung als PDF |
+| `GET /dashboard/summary` | Dashboard KPIs |
+| `PUT /order-entries/:id` | Order-Entry updaten |
+
+### Bekannte offene Bugs (aus `kontext.md` — noch nicht behoben)
+- `GET /slipsheets/:id` gibt durch falsches Array-Indexing `undefined` zurück
+- `GET /bills/:id/pdf` hat Side-Effects (erzeugt Rechnung neu bei jedem Aufruf)
+- `generateBill()` löscht im Fehlerfall bestehende Rechnungen
+- Nummernvergabe für Rechnungen/Lieferscheine ohne DB-Lock (Race Condition)
+- `inventoryDate` ist `CreateDateColumn` statt normalem Column
+
+---
+
+## Frontend (`apps/sims`)
+
+### Tech Stack
+- Angular 20, Standalone Components, `provideRouter`, `bootstrapApplication`
+- `@ng-select/ng-select` für Dropdowns
+- `ngx-toastr` für Notifications
+- Design: `abler.tirol` Design-System (Steel-Blue für SIMS)
+
+### Starten
+```bash
+npx nx serve sims # :4200, Proxy → :9000
+```
+
+### Proxy
+`apps/sims/proxy.conf.json` leitet `/api/*` auf `http://localhost:9000` weiter.
+
+### Routing-Struktur
+```
+/ → /dashboard
+/dashboard → DashboardComponent
+/dashboard/inventory → InventoryListComponent
+/article → ArticleComponent
+/mobile/tracking → MobileTrackingComponent
+/mobile/inventory → MobileInventoryComponent
+/admin/customer → CustomerComponent
+/admin/customer/new → CustomerEditComponent
+/admin/customer/detail/:id → CustomerDetailComponent
+/admin/customer/edit/:id → CustomerEditComponent
+/admin/bills → BillsComponent
+/login → LoginComponent
+```
+
+### Services (alle `providedIn: 'root'`)
+- `AuthenticationService` — Login/Logout (JWT in localStorage)
+- `ArticleService` — CRUD Artikel + CSV-Import
+- `ArticleGroupService` — Artikelgruppen
+- `CustomerService` — CRUD Kunden + Discounts + Slipsheets/Bills
+- `ShoppingcartService` — Lieferscheine, Orders, Annotations
+- `BillService` — Rechnungen
+- `DashboardService` — KPI-Summary
+- `UserService` — Benutzer
+
+### Interceptors (functional, in `app.config.ts`)
+- `backendInterceptor` — Prefixes relative URLs mit `environment.apiUrl`
+- `errorInterceptor` — 401 → logout + reload, andere Fehler → Toast
+
+---
+
+## Design-System (`abler.tirol`)
+
+Alle CSS-Variablen sind in `apps/sims/src/styles.scss` definiert.
+
+### SIMS Akzentfarben (Steel-Blue / Lager)
+```css
+--accent-primary: #1e3a5f;
+--accent-secondary: #2d5282;
+--accent-highlight: #3b82f6;
+--accent-light: #eff6ff;
+```
+
+### Geteilte Neutrals
+```css
+--bg-light: #f7f7f4
+--ink: #0f172a
+--ink-2: #475569
+--ink-3: #94a3b8
+--border: #e2e8f0
+```
+
+### Fonts (von `api.abler.tirol` geladen)
+- Headlines: `DM Serif Display`
+- Interface/Body: `Inter`
+- Code/Labels/Tags: `DM Mono`
+
+### Regeln
+- Buttons immer `border-radius: 999px` (Pill-Form)
+- Cards: `border-radius: 2rem`
+- Kein reines Schwarz `#000` — immer `var(--ink)`
+- Keine System-Fonts
+- Max. 2 Akzentfarben gleichzeitig sichtbar
+
+---
+
+## Was bereits erledigt ist ✅
+
+### Migration (Branch `first-init`)
+1. **NX Monorepo** — `SIMSystem-backend` + `SIMSystem-frontend` zusammengeführt
+2. **Backend auf NestJS 11 / TypeORM 0.3 gebracht:**
+ - Alle `src/` absolute Imports → relative Pfade
+ - `@hapi/joi` → `joi`
+ - `pdfmake` korrekt eingebunden (`Printer.js` default export)
+ - `model.repository.ts` für TypeORM v0.3 Generics gefixt
+ - `base.service.ts` Generics gefixt
+ - sqlite provider mit expliziten Entity-Klassen statt Glob
+ - mock-Dateien aus App-Build ausgeschlossen
+ - webpack.config.js: `src`-Alias + konditionelle Migrations-Assets
+3. **Frontend von Angular 9 → Angular 20 migriert:**
+ - Alle NgModules gelöscht → Standalone Components
+ - `bootstrapApplication` + `app.config.ts`
+ - `provideRouter` / `provideHttpClient` / `provideAnimations` / `provideToastr`
+ - Functional Interceptors + Functional Guards
+ - `@coreui/angular` + `ngx-bootstrap` + `ngx-perfect-scrollbar` + `angular-datatables` + `ng2-charts` entfernt
+ - Eigenes Layout (Sidebar + Topbar) ohne CoreUI
+ - Alle Templates: `bsModal` → native HTML-Dialog, `datatable` → `*ngFor`
+ - Feature-Routes: `article.routes.ts`, `mobile.routes.ts`, `admin.routes.ts`, `dashboard.routes.ts`
+ - `styles.scss` komplett neu — volles `abler.tirol` Design-System
+
+---
+
+## Was als nächstes zu tun ist 🔄
+
+### P0 — Für erste lauffähige Version nötig
+
+1. **`npm install` ohne `--ignore-scripts`** auf Zielmaschine ausführen (sqlite3 native binary)
+
+2. **Bug: `GET /slipsheets/:id` gibt `undefined`** zurück
+ - Datei: `apps/server/src/app/models/bills/controllers/slipsheet.controller.ts` ~Zeile 102
+ - Problem: falsches Array-Indexing
+ - Fix: Return-Wert prüfen, korrektes Element zurückgeben
+
+3. **Bug: `GET /bills/:id/pdf` hat Side-Effects**
+ - Datei: `apps/server/src/app/models/bills/controllers/bill.controller.ts` ~Zeile 82
+ - Problem: Rechnung wird bei jedem GET neu generiert
+ - Fix: PDF nur lesen wenn bereits vorhanden, separate POST-Route für Neugenerierung
+
+4. **Auth komplett aktivieren oder entfernen**
+ - Aktuell: Guards sind auskommentiert, API ist offen
+ - Option A: JWT Guard global einbauen (empfohlen für Produktion)
+ - Option B: Auth-Flows aus Frontend entfernen wenn nicht benötigt
+ - Relevante Dateien: `apps/server/src/app/models/users/`, `apps/sims/src/app/helpers/auth.guard.ts`
+
+5. **Login-Page implementieren**
+ - Aktuell: Stub-Component, leitet nur weiter
+ - `apps/sims/src/app/views/login/login.component.ts`
+
+### P1 — Wichtige Verbesserungen
+
+6. **Race Condition: Nummernvergabe transaktional machen**
+ - Dateien: `apps/server/src/app/models/bills/bill.service.ts` ~Zeile 114
+ - Dateien: `apps/server/src/app/models/bills/slipsheet.service.ts` ~Zeile 43
+ - Fix: DB-Transaktion + Lock beim `SELECT MAX(number) + 1`
+
+7. **`inventoryDate` korrigieren**
+ - Datei: `apps/server/src/app/models/article/entities/article.entity.ts` ~Zeile 38
+ - Problem: `@CreateDateColumn()` statt normales `@Column({ type: 'datetime' })`
+ - Fix: Column-Typ ändern, Migration erstellen
+
+8. **Inventurbuchung awaiten**
+ - Datei: `apps/server/src/app/models/article/article.service.ts` ~Zeile 227
+ - Problem: `await` fehlt, Fehler gehen verloren
+
+9. **CustomerDetail: Rechnung aus ausgewählten Lieferscheinen erstellen**
+ - `apps/sims/src/app/views/admin/customer-detail/customer-detail.component.ts`
+ - Die Checkbox-Selektion (`selectedSlipIds`) ist implementiert
+ - `makeBill(selectedSlipIds)` ruft `billService.generate(ids)` auf
+ - Fehlt noch: visuelles Feedback nach Erstellung, Liste aktualisieren
+
+10. **Mobile HTML-Templates prüfen**
+ - `apps/sims/src/app/views/mobile/mobile-tracking/mobile-tracking.component.html`
+ - `apps/sims/src/app/views/mobile/inventory/inventory.component.html`
+ - Wurden nicht aus dem alten Code übernommen — müssen neu erstellt werden
+
+11. **Article-Edit HTML: ng-select Binding überprüfen**
+ - `apps/sims/src/app/views/article/article-edit/article-edit.component.html`
+ - `formControlName="articleGroup"` mit `ng-select` — könnte Typ-Mismatch haben
+
+### P2 — Sauberkeit & Betrieb
+
+12. **Frontend/Backend API-Contract Matrix vervollständigen**
+ - `DELETE /articles/:id` im Backend fehlt (Frontend ruft es auf)
+ - `POST /bills/:id` → Backend hat das als recreate implementiert
+ - `state`-Filter von Frontend wird im Backend ignoriert
+
+13. **CORS härten**
+ - Aktuell: `cors: true` (alles erlaubt)
+ - Fix: erlaubte Origins per ENV-Variable
+
+14. **Docker-Setup verifizieren**
+ - `docker-compose.dev.yml` + `dockerfiles/` vorhanden
+ - Noch nicht mit neuer Monorepo-Struktur getestet
+
+15. **Migrations-Workflow einrichten**
+ - `apps/server/src/datasource.ts` vorhanden
+ - `npm run migration:generate --name=InitialSchema`
+ - `npm run migration:run`
+
+16. **SIMS Farbpalette finalisieren**
+ - Laut Design-Dokument ist `sims` noch "muss noch definiert werden"
+ - Aktuell: Steel-Blue (`#1e3a5f` / `#3b82f6`) — bei Bedarf anpassen
+
+---
+
+## NX-Befehle Referenz
+
+```bash
+# Builds
+npx nx build server
+npx nx build sims
+npx nx build sims --configuration=development
+
+# Dev-Server
+npx nx serve server # Backend :9000
+npx nx serve sims # Frontend :4200
+
+# Migrations (TypeORM)
+npm run migration:generate --name=MigrationName
+npm run migration:run
+npm run migration:revert
+npm run migration:show
+
+# Tests
+npx nx test server
+npx nx test sims
+```
+
+---
+
+## Wichtige Dateipfade
+
+```
+apps/server/src/main.ts → NestJS Bootstrap
+apps/server/src/app/app.module.ts → Root Module
+apps/server/src/app/models/ → Entities, Services, Controllers
+apps/server/src/app/common/services/pdfmaker.service.ts → PDF-Generierung
+apps/server/src/datasource.ts → TypeORM DataSource (für Migrations)
+apps/server/.env → Umgebungsvariablen (gitignored)
+apps/server/.env.example → Vorlage
+
+apps/sims/src/main.ts → Angular Bootstrap
+apps/sims/src/app/app.config.ts → App-Konfiguration (Provider)
+apps/sims/src/app/app.routes.ts → Root-Routing
+apps/sims/src/app/containers/default-layout/ → Haupt-Layout (Sidebar+Topbar)
+apps/sims/src/app/services/ → Angular Services
+apps/sims/src/app/models/ → TypeScript Daten-Modelle
+apps/sims/src/styles.scss → Globales Stylesheet (Design-System)
+apps/sims/proxy.conf.json → Dev-Proxy → :9000
+apps/sims/src/environments/ → API-URL Konfiguration
+```
+
+---
+
+*Erstellt: 2026-03-15 · Branch: first-init · Letzter Commit: c656aa6*
diff --git a/FINDINGS.md b/FINDINGS.md
new file mode 100644
index 0000000..ad51da6
--- /dev/null
+++ b/FINDINGS.md
@@ -0,0 +1,219 @@
+# FINDINGS
+
+Stand: 2026-03-19
+Basis: Review der aktuell implementierten Workflows anhand von Frontend, Services und Backend
+Hinweis: keine Laufzeittests ausgefuehrt, Findings basieren auf Codeanalyse
+
+## Hoch
+
+### 1. Rechnungserzeugung selektierte zuvor die falschen Lieferscheine
+
+**Problem**
+
+Das Frontend behandelte jeden Lieferschein ohne `billId` als "offen" und damit als Rechnungskandidat. Das Backend akzeptiert fuer `POST /bills/generate` aber nur Lieferscheine mit `state === CLOSED`.
+
+**Auswirkung im Workflow**
+
+Ein Benutzer konnte in der Kundendetailansicht Lieferscheine auswaehlen und auf `Rechnung erzeugen` klicken, obwohl diese serverseitig noch `open` oder `changed` waren. Die Rechnungserzeugung scheiterte dann mit `Lieferscheine noch nicht erstellt`.
+
+**Codebeleg**
+
+- `apps/sim-system/src/app/models/bill.model.ts:41`
+- `apps/sim-system/src/app/models/bill.model.ts:42`
+- `apps/sim-system/src/app/views/customer-detail/customer-detail.component.ts:83`
+- `apps/sim-system/src/app/views/customer-detail/customer-detail.component.ts:91`
+- `apps/sim-system/src/app/views/customer-detail/customer-detail.component.ts:163`
+- `apps/sim-system/src/app/views/customer-detail/customer-detail.component.ts:174`
+- `apps/server/src/models/bills/bill.service.ts:47`
+- `apps/server/src/models/bills/bill.service.ts:48`
+
+**Status**
+
+UI angepasst:
+
+- Filter `Nur unverrechnete` bleibt bestehen
+- nur `closed` und noch nicht verrechnete Lieferscheine sind selektierbar
+- andere unverrechnete Eintraege bleiben sichtbar und werden in der Tabelle als `Noch nicht erzeugt` markiert
+
+### 2. Lieferschein-PDF-Aufruf veraenderte den Serverzustand, aber die UI blieb veraltet
+
+**Problem**
+
+`GET /slipsheets/:id/pdf` schliesst standardmaessig den Lieferschein bzw. erzeugt Nummer und PDF, weil `close` per Default `true` ist. Die Frontend-Aktionen zum Oeffnen oder Herunterladen des PDFs aktualisierten den lokalen Datensatz danach zunaechst nicht.
+
+**Auswirkung im Workflow**
+
+Nach einem PDF-Aufruf sah der Benutzer im UI weiterhin den alten Zustand. Status, Nummer, Selektionslogik und Folgeaktionen konnten auf veralteten Daten basieren.
+
+**Codebeleg**
+
+- `apps/server/src/models/bills/controllers/slipsheet.controller.ts:110`
+- `apps/server/src/models/bills/controllers/slipsheet.controller.ts:113`
+- `apps/server/src/models/bills/controllers/slipsheet.controller.ts:140`
+- `apps/server/src/models/bills/slipsheet.service.ts:89`
+- `apps/server/src/models/bills/slipsheet.service.ts:95`
+- `apps/sim-system/src/app/views/customer-detail/customer-detail.component.ts:229`
+- `apps/sim-system/src/app/views/customer-detail/customer-detail.component.ts:233`
+- `apps/sim-system/src/app/components/slipsheet-editor/slipsheet-editor.component.ts:192`
+- `apps/sim-system/src/app/components/slipsheet-editor/slipsheet-editor.component.ts:195`
+
+**Status**
+
+Frontend angepasst:
+
+- nach erfolgreichem `GET /slipsheets/:id/pdf` wird der betroffene Lieferschein erneut ueber `GET /slipsheets/:id` geladen
+- die lokale Ansicht wird danach aktualisiert
+- kein neuer Endpoint noetig
+
+### 3. `Neue Bestellung` haengt neue Positionen potentiell an den falschen offenen Lieferschein
+
+**Problem**
+
+`/order/new` laedt fuer den ausgewaehlten Kunden alle offenen/geaenderten Lieferscheine und verwendet einfach `slips[0]`. Die Backend-Liste ist an dieser Stelle nicht sortiert oder als eindeutiger "aktueller" Lieferschein definiert.
+
+**Auswirkung im Workflow**
+
+Neue Positionen koennen an einem beliebigen vorhandenen Lieferschein landen, wenn mehrere offene oder geaenderte Belege existieren.
+
+**Codebeleg**
+
+- `apps/sim-system/src/app/views/order-new/order-new.component.ts:61`
+- `apps/sim-system/src/app/views/order-new/order-new.component.ts:63`
+- `apps/server/src/models/bills/controllers/slipsheet.controller.ts:80`
+- `apps/server/src/models/bills/controllers/slipsheet.controller.ts:81`
+- `apps/server/src/models/bills/slipsheet.service.ts:160`
+- `apps/server/src/models/bills/slipsheet.service.ts:171`
+
+**Empfehlung**
+
+Entweder serverseitig genau einen "aktiven" Lieferschein liefern oder clientseitig explizit sortieren bzw. den Benutzer waehlen lassen.
+
+## Mittel
+
+### 4. Rechnungs-PDF konnte im Fehlerfall nicht aus der UI neu erzeugt werden
+
+**Problem**
+
+Backend und Angular-Service unterstuetzten die Neugenerierung eines Rechnungs-PDFs bereits, aber die Rechnungsdetailseite bot dafuer zunaechst keine Aktion an. Bei fehlendem PDF zeigte die UI nur eine Fehlermeldung.
+
+**Auswirkung im Workflow**
+
+Der Benutzer bekam die Meldung `PDF nicht gefunden. Bitte neu erzeugen.`, konnte diese Neugenerierung im UI aber nicht anstossen.
+
+**Codebeleg**
+
+- `apps/server/src/models/bills/controllers/bill.controller.ts:128`
+- `apps/server/src/models/bills/controllers/bill.controller.ts:133`
+- `apps/sim-system/src/app/services/bill.service.ts:33`
+- `apps/sim-system/src/app/views/bill-detail/bill-detail.component.ts:46`
+- `apps/sim-system/src/app/views/bill-detail/bill-detail.component.ts:56`
+- `apps/sim-system/src/app/views/bill-detail/bill-detail.component.html:52`
+
+**Status**
+
+UI angepasst:
+
+- Rechnungsdetailseite besitzt jetzt einen `PDF neu erzeugen`-Button
+- der Button nutzt den vorhandenen `POST /bills/:id`
+- nach erfolgreicher Neuerzeugung wird der normale PDF-Download direkt erneut gestartet
+
+### 5. Dashboard-Deep-Link zur Artikelsuche funktionierte nicht
+
+**Problem**
+
+Das Dashboard navigierte mit `queryParams: { code }` zur Artikelliste. Die Artikelliste las diese Query-Parameter zunaechst nicht aus und filterte nur ueber lokale FormControls.
+
+**Auswirkung im Workflow**
+
+Ein Klick aus dem Dashboard auf einen Artikel oder Barcode landete zwar auf `/articles`, die erwartete Vorfilterung nach Code passierte aber nicht.
+
+**Codebeleg**
+
+- `apps/sim-system/src/app/views/dashboard/dashboard.component.ts:83`
+- `apps/sim-system/src/app/views/dashboard/dashboard.component.ts:84`
+- `apps/sim-system/src/app/views/articles/articles.component.ts:22`
+- `apps/sim-system/src/app/views/articles/articles.component.ts:23`
+- `apps/sim-system/src/app/views/articles/articles.component.ts:37`
+
+**Status**
+
+Frontend angepasst:
+
+- `ArticlesComponent` liest jetzt `queryParamMap`
+- der Query-Parameter `code` wird in `codeCtrl` uebernommen
+- der Deep-Link vom Dashboard filtert die Artikelliste damit direkt vor
+
+### 6. Login-Workflow ist im Frontend vorbereitet, aber als Gesamtablauf nicht lauffaehig
+
+**Problem**
+
+Es gibt `LoginComponent`, `AuthenticationService` und `authGuard`, aber:
+
+- die Route `/login` ist nicht registriert
+- das Frontend ruft `users/login` und `users/me/refresh` auf
+- das Backend bietet diese Endpunkte nicht an
+
+**Auswirkung im Workflow**
+
+Sobald Auth aktiviert oder ein Guard benutzt wird, endet der Ablauf auf einer fehlenden Route oder in 404-API-Fehlern.
+
+**Codebeleg**
+
+- `apps/sim-system/src/app/app.routes.ts`
+- `apps/sim-system/src/app/helpers/auth.guard.ts:12`
+- `apps/sim-system/src/app/services/authentication.service.ts:23`
+- `apps/sim-system/src/app/services/authentication.service.ts:40`
+- `apps/server/src/models/users/users.controller.ts:44`
+- `apps/server/src/models/users/users.controller.ts:59`
+- `apps/server/src/models/users/users.controller.ts:71`
+- `apps/server/src/models/users/users.controller.ts:89`
+
+**Empfehlung**
+
+Entweder Auth-Endpunkte und Login-Route vollstaendig implementieren oder den toten Auth-Pfad bis zur echten Einfuehrung konsequent entfernen.
+
+### 7. Kundenformular validiert das einzig serverseitig verpflichtende Feld nicht
+
+**Problem**
+
+`customerNumber` ist im Backend per DTO Pflichtfeld, im Frontend-Formular aber ohne Validator konfiguriert.
+
+**Auswirkung im Workflow**
+
+Benutzer koennen formal "gueltig" absenden, bekommen aber erst vom Backend einen Validierungsfehler zurueck.
+
+**Codebeleg**
+
+- `apps/server/src/models/customer/dto/create-customer.dto.ts:25`
+- `apps/server/src/models/customer/dto/create-customer.dto.ts:26`
+- `apps/sim-system/src/app/views/customer-edit/customer-edit.component.ts:23`
+- `apps/sim-system/src/app/views/customer-edit/customer-edit.component.ts:29`
+- `apps/sim-system/src/app/views/customer-edit/customer-edit.component.ts:71`
+
+**Empfehlung**
+
+`customerNumber` im Angular-Formular mit `Validators.required` versehen und den Fehler direkt im UI anzeigen.
+
+## Niedrig
+
+### 8. Ein Lieferschein mit nur Annotationen kann geloescht werden
+
+**Problem**
+
+Die Generierung behandelt Annotationen als relevanten Inhalt. Die Loeschlogik dagegen blockiert nur Lieferscheine mit Positionen. Im Frontend ist `canDelete()` ebenfalls nur an `orderEntries.length === 0` gekoppelt.
+
+**Auswirkung im Workflow**
+
+Ein Benutzer kann einen Lieferschein loeschen, obwohl bereits Kommissionen/Annotationen darauf erfasst wurden.
+
+**Codebeleg**
+
+- `apps/server/src/models/bills/slipsheet.service.ts:46`
+- `apps/server/src/models/bills/slipsheet.service.ts:50`
+- `apps/server/src/models/bills/slipsheet.service.ts:81`
+- `apps/sim-system/src/app/components/slipsheet-editor/slipsheet-editor.component.ts:62`
+- `apps/sim-system/src/app/components/slipsheet-editor/slipsheet-editor.component.ts:64`
+
+**Empfehlung**
+
+Loeschlogik an dieselbe inhaltliche Definition koppeln wie die PDF-/Close-Logik, also Positionen und Annotationen gemeinsam betrachten.
diff --git a/Next-steps.md b/Next-steps.md
new file mode 100644
index 0000000..bbf7b22
--- /dev/null
+++ b/Next-steps.md
@@ -0,0 +1,1705 @@
+# SIMS — Agent-Anleitung: CompanySettings Feature
+
+> Dieses Dokument ist eine vollständige Schritt-für-Schritt-Anleitung für einen Coding-Agent.
+> Jede Phase ist in sich abgeschlossen und testbar. Gib dem Agent immer **eine Phase auf einmal**.
+
+---
+
+## Kontext (vor jeder Phase mitgeben)
+
+```
+Repository: github.com/simonabler/sim-system
+Branch: first-init
+Stack:
+ Backend: NestJS + TypeORM + SQLite (better-sqlite3), Monorepo unter apps/server/
+ Frontend: Angular (standalone components, signals, ReactiveFormsModule), unter apps/sim-system/
+ PDF-Generierung: pdfmake im Backend, PdfMakerService in apps/server/src/common/services/pdfmaker.service.ts
+
+Ziel dieser Arbeit:
+ Alle hardcoded Firmendaten (Adresse, Kontakt, Bankdaten, Logo-Pfade, MwSt-Satz, Ausstellungsort)
+ aus dem PdfMakerService in eine konfigurierbare Datenbank-Entity auslagern.
+ Eine Angular-Seite /settings ermöglicht die Verwaltung dieser Daten inkl. Logo-Upload.
+```
+
+---
+
+## Phase 1 — Backend: CompanySettings Entity + Module
+
+**Prompt für den Agent:**
+
+```
+Arbeite im Repository sim-system, Branch first-init.
+Erstelle das CompanySettings-Feature im Backend (apps/server/src/).
+
+Orientiere dich beim Code-Stil an den bestehenden Modulen — insbesondere UsersModule und CustomerModule.
+Das Muster ist immer: Entity → Interface → Repository → Service → Controller → Module → AppModule.
+
+SCHRITT 1 — Interface
+Erstelle die Datei:
+ apps/server/src/models/settings/interfaces/company-settings.interface.ts
+
+Inhalt:
+export interface ICompanySettings {
+ id: number;
+ companyName: string | null;
+ street: string | null;
+ zip: string | null;
+ city: string | null;
+ country: string | null;
+ phone: string | null;
+ email: string | null;
+ website: string | null;
+ firmenbuchnummer: string | null;
+ vatId: string | null;
+ issueCity: string | null;
+ vatRate: number;
+ paymentTermDays: number;
+ paymentFooterText: string | null;
+ bankAccounts: Array<{ name: string; iban: string; bic: string }>;
+ logoPath: string | null;
+ badge1Path: string | null;
+ badge2Path: string | null;
+ updatedAt: Date;
+}
+
+---
+
+SCHRITT 2 — Entity
+Erstelle die Datei:
+ apps/server/src/models/settings/entities/company-settings.entity.ts
+
+Inhalt:
+import {
+ Entity,
+ Column,
+ UpdateDateColumn,
+ PrimaryColumn,
+} from 'typeorm';
+import { ICompanySettings } from '../interfaces/company-settings.interface';
+
+@Entity({ name: 'company_settings' })
+export class CompanySettings implements ICompanySettings {
+ @PrimaryColumn({ default: 1 })
+ id: number;
+
+ @Column({ nullable: true, default: null })
+ companyName: string | null;
+
+ @Column({ nullable: true, default: null })
+ street: string | null;
+
+ @Column({ nullable: true, default: null })
+ zip: string | null;
+
+ @Column({ nullable: true, default: null })
+ city: string | null;
+
+ @Column({ nullable: true, default: 'Österreich' })
+ country: string | null;
+
+ @Column({ nullable: true, default: null })
+ phone: string | null;
+
+ @Column({ nullable: true, default: null })
+ email: string | null;
+
+ @Column({ nullable: true, default: null })
+ website: string | null;
+
+ @Column({ nullable: true, default: null })
+ firmenbuchnummer: string | null;
+
+ @Column({ nullable: true, default: null })
+ vatId: string | null;
+
+ @Column({ nullable: true, default: null })
+ issueCity: string | null;
+
+ @Column({ default: 20 })
+ vatRate: number;
+
+ @Column({ default: 14 })
+ paymentTermDays: number;
+
+ @Column({ nullable: true, default: null, type: 'text' })
+ paymentFooterText: string | null;
+
+ @Column({ type: 'simple-json', nullable: true, default: '[]' })
+ bankAccounts: Array<{ name: string; iban: string; bic: string }>;
+
+ @Column({ nullable: true, default: null })
+ logoPath: string | null;
+
+ @Column({ nullable: true, default: null })
+ badge1Path: string | null;
+
+ @Column({ nullable: true, default: null })
+ badge2Path: string | null;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt: Date;
+}
+
+---
+
+SCHRITT 3 — Serializer
+Erstelle die Datei:
+ apps/server/src/models/settings/serializers/company-settings.serializer.ts
+
+Inhalt:
+import { Expose } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { ICompanySettings } from '../interfaces/company-settings.interface';
+
+export const defaultSettingsGroupsForSerializing: string[] = ['default', 'settings.default'];
+
+export class CompanySettingsEntity extends ModelEntity implements ICompanySettings {
+ @Expose({ groups: ['default'] }) companyName: string | null;
+ @Expose({ groups: ['default'] }) street: string | null;
+ @Expose({ groups: ['default'] }) zip: string | null;
+ @Expose({ groups: ['default'] }) city: string | null;
+ @Expose({ groups: ['default'] }) country: string | null;
+ @Expose({ groups: ['default'] }) phone: string | null;
+ @Expose({ groups: ['default'] }) email: string | null;
+ @Expose({ groups: ['default'] }) website: string | null;
+ @Expose({ groups: ['default'] }) firmenbuchnummer: string | null;
+ @Expose({ groups: ['default'] }) vatId: string | null;
+ @Expose({ groups: ['default'] }) issueCity: string | null;
+ @Expose({ groups: ['default'] }) vatRate: number;
+ @Expose({ groups: ['default'] }) paymentTermDays: number;
+ @Expose({ groups: ['default'] }) paymentFooterText: string | null;
+ @Expose({ groups: ['default'] }) bankAccounts: Array<{ name: string; iban: string; bic: string }>;
+ @Expose({ groups: ['default'] }) logoPath: string | null;
+ @Expose({ groups: ['default'] }) badge1Path: string | null;
+ @Expose({ groups: ['default'] }) badge2Path: string | null;
+ @Expose({ groups: ['default'] }) updatedAt: Date;
+}
+
+---
+
+SCHRITT 4 — DTOs
+Erstelle die Datei:
+ apps/server/src/models/settings/dto/update-settings.dto.ts
+
+Inhalt:
+import { ApiProperty } from '@nestjs/swagger';
+import { IsOptional, IsString, IsNumber, IsArray, ValidateNested, Min, Max } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class BankAccountDto {
+ @IsString() name: string;
+ @IsString() iban: string;
+ @IsString() bic: string;
+}
+
+export class UpdateSettingsDto {
+ @IsOptional() @IsString() companyName?: string;
+ @IsOptional() @IsString() street?: string;
+ @IsOptional() @IsString() zip?: string;
+ @IsOptional() @IsString() city?: string;
+ @IsOptional() @IsString() country?: string;
+ @IsOptional() @IsString() phone?: string;
+ @IsOptional() @IsString() email?: string;
+ @IsOptional() @IsString() website?: string;
+ @IsOptional() @IsString() firmenbuchnummer?: string;
+ @IsOptional() @IsString() vatId?: string;
+ @IsOptional() @IsString() issueCity?: string;
+ @IsOptional() @IsNumber() @Min(0) @Max(100) vatRate?: number;
+ @IsOptional() @IsNumber() @Min(0) paymentTermDays?: number;
+ @IsOptional() @IsString() paymentFooterText?: string;
+ @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => BankAccountDto)
+ bankAccounts?: BankAccountDto[];
+}
+
+---
+
+SCHRITT 5 — Repository
+Erstelle die Datei:
+ apps/server/src/models/settings/company-settings.repository.ts
+
+Inhalt:
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { CompanySettings } from './entities/company-settings.entity';
+import {
+ CompanySettingsEntity,
+ defaultSettingsGroupsForSerializing,
+} from './serializers/company-settings.serializer';
+
+@Injectable()
+export class CompanySettingsRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(CompanySettings, dataSource.createEntityManager());
+ }
+
+ transform(model: CompanySettings): CompanySettingsEntity {
+ const transformOptions = { groups: defaultSettingsGroupsForSerializing };
+ return plainToInstance(
+ CompanySettingsEntity,
+ instanceToPlain(model, transformOptions),
+ transformOptions,
+ );
+ }
+
+ transformMany(models: CompanySettings[]): CompanySettingsEntity[] {
+ return models.map((m) => this.transform(m));
+ }
+}
+
+---
+
+SCHRITT 6 — Service
+Erstelle die Datei:
+ apps/server/src/models/settings/company-settings.service.ts
+
+Inhalt:
+import { Injectable } from '@nestjs/common';
+import { CompanySettingsRepository } from './company-settings.repository';
+import { CompanySettingsEntity } from './serializers/company-settings.serializer';
+import { UpdateSettingsDto } from './dto/update-settings.dto';
+import { CompanySettings } from './entities/company-settings.entity';
+
+@Injectable()
+export class CompanySettingsService {
+ constructor(private readonly repo: CompanySettingsRepository) {}
+
+ async get(): Promise {
+ return this.repo.get(1, [], false);
+ }
+
+ async upsert(dto: UpdateSettingsDto): Promise {
+ const existing = await this.repo.findOne({ where: { id: 1 } });
+ if (existing) {
+ return this.repo.updateEntity(1, dto as any);
+ }
+ const created = await this.repo.save({ id: 1, ...dto } as CompanySettings);
+ return this.repo.transform(created);
+ }
+
+ async updateLogoPath(field: 'logoPath' | 'badge1Path' | 'badge2Path', path: string): Promise {
+ const existing = await this.repo.findOne({ where: { id: 1 } });
+ if (existing) {
+ return this.repo.updateEntity(1, { [field]: path } as any);
+ }
+ const created = await this.repo.save({ id: 1, [field]: path } as CompanySettings);
+ return this.repo.transform(created);
+ }
+}
+
+---
+
+SCHRITT 7 — Controller
+Erstelle die Datei:
+ apps/server/src/models/settings/company-settings.controller.ts
+
+Inhalt:
+import {
+ Get, Put, Post, Body, Controller,
+ UseInterceptors, SerializeOptions, ClassSerializerInterceptor,
+ ValidationPipe, UsePipes, UploadedFile,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { diskStorage } from 'multer';
+import { extname, join } from 'path';
+import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { CompanySettingsService } from './company-settings.service';
+import {
+ CompanySettingsEntity,
+ defaultSettingsGroupsForSerializing,
+} from './serializers/company-settings.serializer';
+import { UpdateSettingsDto } from './dto/update-settings.dto';
+import { ReS } from '../../common/res.model';
+
+const UPLOAD_DEST = join(process.cwd(), 'uploads', 'settings');
+
+function storageConfig(fieldName: string) {
+ return diskStorage({
+ destination: UPLOAD_DEST,
+ filename: (_req, file, cb) => {
+ cb(null, `${fieldName}${extname(file.originalname)}`);
+ },
+ });
+}
+
+@ApiBearerAuth()
+@Controller('settings')
+@ApiTags('settings')
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({ groups: defaultSettingsGroupsForSerializing, excludeExtraneousValues: true })
+export class CompanySettingsController {
+ constructor(private readonly settingsService: CompanySettingsService) {}
+
+ @Get('/')
+ @ApiOperation({ summary: 'Einstellungen laden' })
+ async get(): Promise> {
+ return ReS.FromData(await this.settingsService.get());
+ }
+
+ @Put('/')
+ @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
+ @ApiOperation({ summary: 'Einstellungen speichern' })
+ async upsert(@Body() dto: UpdateSettingsDto): Promise> {
+ return ReS.FromData(await this.settingsService.upsert(dto));
+ }
+
+ @Post('/logo')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Logo hochladen (SVG oder PNG)' })
+ @UseInterceptors(FileInterceptor('file', { storage: storageConfig('logo') }))
+ async uploadLogo(@UploadedFile() file: Express.Multer.File): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(await this.settingsService.updateLogoPath('logoPath', relativePath));
+ }
+
+ @Post('/badge1')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Badge links hochladen' })
+ @UseInterceptors(FileInterceptor('file', { storage: storageConfig('badge1') }))
+ async uploadBadge1(@UploadedFile() file: Express.Multer.File): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(await this.settingsService.updateLogoPath('badge1Path', relativePath));
+ }
+
+ @Post('/badge2')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Badge rechts hochladen' })
+ @UseInterceptors(FileInterceptor('file', { storage: storageConfig('badge2') }))
+ async uploadBadge2(@UploadedFile() file: Express.Multer.File): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(await this.settingsService.updateLogoPath('badge2Path', relativePath));
+ }
+}
+
+---
+
+SCHRITT 8 — Module
+Erstelle die Datei:
+ apps/server/src/models/settings/company-settings.module.ts
+
+Inhalt:
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { CompanySettings } from './entities/company-settings.entity';
+import { CompanySettingsRepository } from './company-settings.repository';
+import { CompanySettingsService } from './company-settings.service';
+import { CompanySettingsController } from './company-settings.controller';
+import { MulterModule } from '@nestjs/platform-express';
+import { join } from 'path';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([CompanySettings]),
+ MulterModule.register({ dest: join(process.cwd(), 'uploads', 'settings') }),
+ ],
+ controllers: [CompanySettingsController],
+ providers: [CompanySettingsRepository, CompanySettingsService],
+ exports: [CompanySettingsService],
+})
+export class CompanySettingsModule {}
+
+---
+
+SCHRITT 9 — AppModule registrieren
+Bearbeite die Datei:
+ apps/server/src/app.module.ts
+
+Füge den Import von CompanySettingsModule hinzu:
+ import { CompanySettingsModule } from './models/settings/company-settings.module';
+
+Und in der imports-Array in @Module:
+ CompanySettingsModule,
+
+---
+
+SCHRITT 10 — Static File Serving für Uploads
+Bearbeite die Datei:
+ apps/server/src/main.ts
+
+Füge nach dem import-Block hinzu:
+ import { NestExpressApplication } from '@nestjs/platform-express';
+ import { join } from 'path';
+
+Ändere NestFactory.create zu:
+ const app = await NestFactory.create(AppModule);
+
+Füge nach app.enableCors(...) hinzu:
+ app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
+
+---
+
+SCHRITT 11 — Verzeichnis sicherstellen
+Erstelle die Datei:
+ apps/server/uploads/settings/.gitkeep
+
+(Leere Datei, damit das Verzeichnis im Git landet)
+
+Füge in apps/server/.gitignore (falls vorhanden) hinzu:
+ uploads/settings/*
+ !uploads/settings/.gitkeep
+
+---
+
+ABSCHLIESSEND: Prüfe ob @nestjs/platform-express und multer als Dependencies vorhanden sind:
+ package.json im Root prüfen.
+ Falls nicht vorhanden: Hinweis ausgeben dass folgendes installiert werden muss:
+ npm install @nestjs/platform-express multer
+ npm install --save-dev @types/multer
+```
+
+---
+
+## Phase 2 — Backend: PdfMakerService auf Settings umstellen
+
+**Prompt für den Agent:**
+
+```
+Arbeite im Repository sim-system, Branch first-init.
+Stelle den PdfMakerService auf dynamische CompanySettings um.
+Phase 1 (CompanySettingsModule) ist bereits abgeschlossen.
+
+Ziel: Alle hardcoded Firmendaten aus apps/server/src/common/services/pdfmaker.service.ts
+entfernen und durch Werte aus CompanySettingsService ersetzen.
+
+---
+
+SCHRITT 1 — SharedModule erweitern
+Bearbeite die Datei:
+ apps/server/src/common/shared.module.ts
+
+Das Modul muss CompanySettingsModule importieren, damit PdfMakerService auf
+CompanySettingsService zugreifen kann.
+
+Füge hinzu:
+ import { CompanySettingsModule } from '../models/settings/company-settings.module';
+
+In @Module:
+ imports: [AppConfigModule, CompanySettingsModule],
+
+Das ist korrekt weil CompanySettingsModule den CompanySettingsService exportiert.
+
+---
+
+SCHRITT 2 — PdfMakerService komplett ersetzen
+
+Ersetze den gesamten Inhalt von:
+ apps/server/src/common/services/pdfmaker.service.ts
+
+Durch folgenden Code:
+
+import { Injectable } from '@nestjs/common';
+const PdfPrinter = require('pdfmake');
+import { Content, ContentTable, TDocumentDefinitions } from 'pdfmake/interfaces';
+import { SlipsheetEntity } from '../../models/bills/serializers/slipsheet.serializer';
+import { createWriteStream, existsSync, readFileSync } from 'fs';
+import { join } from 'path';
+import moment from 'moment';
+import { BillEntity } from '../../models/bills/serializers/bill.serializer';
+import { OrderEntryEntity } from '../../models/bills/serializers/order-entry.serializer';
+import { AnnotationEntity } from '../../models/bills/serializers/annotation.serializer';
+import { CompanySettingsService } from '../../models/settings/company-settings.service';
+import { CompanySettingsEntity } from '../../models/settings/serializers/company-settings.serializer';
+
+// Fallback-Pfade auf die bestehenden hardcoded Dateien
+const FALLBACK_LOGO = join(__dirname, '..', 'pdfAnnotation', 'logo.svg');
+const FALLBACK_BADGE1 = join(__dirname, '..', 'pdfAnnotation', 'adler.svg');
+const FALLBACK_BADGE2 = join(__dirname, '..', 'pdfAnnotation', 'gdfort.jpg');
+
+@Injectable()
+export class PdfMakerService {
+
+ private readonly fonts = {
+ Courier: {
+ normal: 'Courier', bold: 'Courier-Bold',
+ italics: 'Courier-Oblique', bolditalics: 'Courier-BoldOblique',
+ },
+ Helvetica: {
+ normal: 'Helvetica', bold: 'Helvetica-Bold',
+ italics: 'Helvetica-Oblique', bolditalics: 'Helvetica-BoldOblique',
+ },
+ Times: {
+ normal: 'Times-Roman', bold: 'Times-Bold',
+ italics: 'Times-Italic', bolditalics: 'Times-BoldItalic',
+ },
+ Arial: {
+ normal: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'arial.ttf'),
+ bold: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'arialbd.ttf'),
+ italics: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'ariali.ttf'),
+ bolditalics: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'arialbi.ttf'),
+ },
+ Symbol: { normal: 'Symbol' },
+ ZapfDingbats: { normal: 'ZapfDingbats' },
+ };
+
+ private readonly _printer;
+
+ constructor(private readonly settingsService: CompanySettingsService) {
+ this._printer = new PdfPrinter(this.fonts);
+ }
+
+ // ─── Public API ────────────────────────────────────────────────────────────
+
+ public async generateDeliverySlip(slip: SlipsheetEntity): Promise {
+ const settings = await this.getSettings();
+ const docDefinition = await this.buildDocDefinition(settings);
+ const content: Array = [];
+ content.push(this.buildHead(slip, slip.printDate, settings));
+ content.push({ text: 'Lieferschein #' + slip.slipsheetnumber, style: 'header' });
+ const annotation = this.buildSlipAnnotations(slip);
+ if (annotation) content.push(annotation);
+ content.push(this.buildOrdersDelivery(slip));
+ docDefinition.content = content;
+ return this._printer.createPdfKitDocument(docDefinition);
+ }
+
+ public async generateBill(bill: BillEntity): Promise {
+ const settings = await this.getSettings();
+ const docDefinition = await this.buildDocDefinition(settings);
+ const content: Array = [];
+ content.push(this.buildHead(bill.slipsheets[0], bill.billDate, settings));
+ content.push({ text: 'Rechnung #' + bill.billNumber, style: 'header' });
+ content.push(this.buildOrders(bill, settings));
+ docDefinition.content = content;
+ return this._printer.createPdfKitDocument(docDefinition);
+ }
+
+ public savePDFToFileSystem(doc: PDFKit.PDFDocument, filepath: string): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.saveStreamtoFileSystem(doc, filepath, (_err, _pages, path) => resolve(path))) {
+ reject(new Error('PDF konnte nicht gespeichert werden'));
+ }
+ });
+ }
+
+ public saveStreamtoFileSystem(
+ doc: PDFKit.PDFDocument,
+ filepath: string,
+ cb: Function,
+ ): boolean {
+ try {
+ if (filepath) {
+ doc.pipe(createWriteStream(filepath));
+ doc.on('end', () => cb(null, null, [filepath]));
+ doc.end();
+ return true;
+ }
+ } catch (error) {
+ console.error('PdfMakerService.saveStreamtoFileSystem:', error);
+ }
+ return false;
+ }
+
+ // ─── Private Helpers ───────────────────────────────────────────────────────
+
+ private async getSettings(): Promise {
+ try {
+ return await this.settingsService.get();
+ } catch {
+ return null;
+ }
+ }
+
+ private readFileSafe(filePath: string | null | undefined, fallback: string): string {
+ try {
+ if (filePath && existsSync(filePath)) return readFileSync(filePath).toString();
+ } catch { /* fall through */ }
+ return readFileSync(fallback).toString();
+ }
+
+ private readFileBuffer(filePath: string | null | undefined, fallback: string): Buffer {
+ try {
+ if (filePath && existsSync(filePath)) return readFileSync(filePath);
+ } catch { /* fall through */ }
+ return readFileSync(fallback);
+ }
+
+ private isSvg(path: string | null | undefined): boolean {
+ return (path ?? '').toLowerCase().endsWith('.svg');
+ }
+
+ private buildLogoContent(settings: CompanySettingsEntity | null) {
+ const logoPath = settings?.logoPath ?? null;
+ const usePath = (logoPath && existsSync(logoPath)) ? logoPath : FALLBACK_LOGO;
+ const svg = readFileSync(usePath).toString();
+ return { svg, fit: [170, 170], margin: [60, 30, 0, 0] };
+ }
+
+ private buildFooterBadge1(settings: CompanySettingsEntity | null): Content {
+ const p = settings?.badge1Path;
+ const usePath = (p && existsSync(p)) ? p : FALLBACK_BADGE1;
+ const isSvg = usePath.toLowerCase().endsWith('.svg');
+ const base: any = { alignment: 'right', width: 40, margin: [0, 0, 15, 0], color: '#e9582a' };
+ return isSvg
+ ? { ...base, svg: readFileSync(usePath).toString() }
+ : { ...base, image: usePath };
+ }
+
+ private buildFooterBadge2(settings: CompanySettingsEntity | null): Content {
+ const p = settings?.badge2Path;
+ const usePath = (p && existsSync(p)) ? p : FALLBACK_BADGE2;
+ return { image: usePath, width: 40, margin: [25, 0, 0, 0], color: '#e9582a' };
+ }
+
+ private buildFooterText(settings: CompanySettingsEntity | null): string {
+ const paymentText = settings?.paymentFooterText
+ ?? `Zahlung innerhalb von ${settings?.paymentTermDays ?? 14} Tagen netto Kassa`;
+
+ const bankLines = (settings?.bankAccounts ?? [])
+ .map(b => `${b.name} · IBAN: ${b.iban} · BIC: ${b.bic}`)
+ .join(' · ');
+
+ const city = settings?.issueCity ?? 'Landeck';
+ const base = `Zahlbar und klagbar in ${city}`;
+
+ return [paymentText, base, bankLines].filter(Boolean).join(' · ');
+ }
+
+ private buildHeaderStack(settings: CompanySettingsEntity | null): Content[] {
+ const s = settings;
+ const lines: Content[] = [];
+
+ if (s?.street) lines.push({ text: s.street, style: 'header' });
+ if (s?.zip || s?.city) lines.push({ text: `${s.zip ?? ''} ${s.city ?? ''}`.trim(), style: 'header' });
+ if (s?.phone) lines.push({ text: ` `, style: 'subheader' },
+ { text: `Mobile ${s.phone}`, style: 'subheader' });
+ if (s?.email) lines.push({ text: `E-Mail: ${s.email}`, style: 'subheader' });
+ if (s?.website) lines.push({ text: s.website, style: 'subheader' });
+ if (s?.firmenbuchnummer) lines.push({ text: s.firmenbuchnummer, style: 'subheader' });
+
+ // Fallback wenn noch keine Einstellungen gesetzt
+ if (lines.length === 0) {
+ lines.push(
+ { text: 'Fliesserau 384 b', style: 'header' },
+ { text: '6500 Landeck', style: 'header' },
+ { text: ' ', style: 'subheader' },
+ { text: 'Mobile +43 699 10 63 63 45', style: 'subheader' },
+ { text: 'E-Mail: office@holz-abler.com', style: 'subheader' },
+ { text: 'www.holz-abler.com', style: 'subheader' },
+ { text: 'FN.: 303902s, ATU63848368', style: 'subheader' },
+ );
+ }
+ return lines;
+ }
+
+ private async buildDocDefinition(
+ settings: CompanySettingsEntity | null,
+ ): Promise {
+ const self = this;
+
+ return {
+ pageOrientation: 'portrait',
+ pageMargins: [60, 150, 60, 100],
+
+ header: () => [{
+ columns: [
+ self.buildLogoContent(settings),
+ {
+ alignment: 'right',
+ margin: [0, 25, 70, 0],
+ stack: self.buildHeaderStack(settings),
+ },
+ ],
+ }, {
+ canvas: [{ type: 'line', x1: 50, y1: 5, x2: 595 - 50, y2: 5, lineWidth: 1 }],
+ }],
+
+ footer: () => [{
+ canvas: [{ type: 'line', x1: 50, y1: 0, x2: 595 - 50, y2: 0, lineWidth: 1 }],
+ margin: [0, 30, 0, 5],
+ }, {
+ table: {
+ widths: [120, '*', 120],
+ body: [[
+ self.buildFooterBadge1(settings),
+ { text: self.buildFooterText(settings), style: 'footerText' },
+ self.buildFooterBadge2(settings),
+ ]],
+ },
+ layout: 'noBorders',
+ }],
+
+ content: [],
+ styles: this.buildStyles(),
+ defaultStyle: { font: 'Arial', fontSize: 12 },
+ };
+ }
+
+ private buildStyles() {
+ return {
+ header: { fontSize: 12, color: 'black' },
+ subheader: { fontSize: 9, color: 'black' },
+ footerText: { fontSize: 8, margin: [0, 10, 0, 0], alignment: 'center', color: 'black' },
+ tableExample: { margin: [0, 5, 0, 15] },
+ tableHeader: { bold: true, fontSize: 7, color: '#e9582a' },
+ tableSum: { color: 'black', bold: true },
+ tableSumHeader: { bold: true, fontSize: 10, color: 'black' },
+ tableCell: { fontSize: 7 },
+ slipCell: { color: '#e9582a' },
+ slipAnnotation: { fontSize: 9, color: 'black' },
+ };
+ }
+
+ private buildHead(
+ slip: SlipsheetEntity,
+ date: Date | undefined,
+ settings: CompanySettingsEntity | null,
+ ): Content {
+ const city = settings?.issueCity ?? 'Landeck';
+ return {
+ alignment: 'justify',
+ margin: [0, 10, 10, 40],
+ columns: [
+ {
+ width: 'auto',
+ text: [
+ 'An\n',
+ (slip.customer.companyName || '') + '\n',
+ slip.customer.lastName + ' ' + slip.customer.firstName + '\n',
+ slip.customer.address + '\n',
+ slip.customer.postcode + ' ' + slip.customer.country + '\n',
+ ],
+ },
+ {
+ alignment: 'right',
+ margin: [0, 60, 0, 0],
+ text:
+ `${city}, am ${moment(date ?? new Date()).format('DD.MM.YYYY')}` +
+ (slip.customer.customerNumber ? '\nKunde: ' + slip.customer.customerNumber : '\n') +
+ (slip.customer.uid ? '\nIhre UID: ' + slip.customer.uid : '\nIhre UID:\n'),
+ },
+ ],
+ };
+ }
+
+ private buildSlipAnnotations(slip: SlipsheetEntity): Content | null {
+ const items: Content[] = [];
+ slip?.annotations?.forEach((element: AnnotationEntity) => {
+ items.push({ text: element.text, style: 'slipAnnotation' });
+ });
+ return items.length === 0 ? null : items;
+ }
+
+ private buildOrders(bill: BillEntity, settings: CompanySettingsEntity | null): Content {
+ const vatRate = settings?.vatRate ?? 20;
+
+ const isDiscount = bill.slipsheets.some(c => c.orderEntries.some(o => o.articleGroupRabatt && o.articleGroupRabatt !== 0));
+ const isDiscountSpecial = bill.slipsheets.some(c => c.orderEntries.some(o => o.customerRabatt && o.customerRabatt !== 0));
+
+ const table: any = {
+ style: 'tableExample',
+ table: {
+ headerRows: 1,
+ widths: [60, 'auto', '*', 'auto', 'auto', 'auto', 'auto', 'auto'],
+ body: [[
+ { text: 'Pos', style: 'tableHeader', margin: [0, 0, 5, 0] },
+ { text: 'Art.Num.', style: 'tableHeader' },
+ { text: 'Artikel', style: 'tableHeader' },
+ { text: 'Menge', style: 'tableHeader' },
+ { text: 'Preis', style: 'tableHeader' },
+ { text: isDiscount ? 'Rabatt' : '', style: 'tableHeader' },
+ { text: isDiscountSpecial ? 'Sonder\nRabatt' : '', style: 'tableHeader' },
+ { text: 'Gesamt', style: 'tableHeader' },
+ ]],
+ },
+ layout: this.getTableDefaultLayout(),
+ };
+
+ let sum = 0;
+
+ for (const slip of bill.slipsheets) {
+ table.table.body.push([
+ '',
+ { text: 'Lieferschein von ' + moment(slip.createdAt).format('DD.MM.YYYY') + ' L' + slip.slipsheetnumber, colSpan: 6, style: 'slipCell' },
+ '', '', '', '', '', '',
+ ]);
+
+ const annotation = this.buildSlipAnnotations(slip);
+ if (annotation) {
+ const annotationCell = { ...annotation, colSpan: 6 };
+ table.table.body.push(['', annotationCell, '', '', '', '', '', '']);
+ }
+
+ for (let i = 0; i < slip.orderEntries.length; i++) {
+ const el: OrderEntryEntity = slip.orderEntries[i];
+ const amount = el.amountCounted || el.amount;
+ const discount = el.articleGroupRabatt ?? 0;
+ const discountSpecial = el.customerRabatt ?? 0;
+ const total = amount * el.price * (100 - discount) / 100 * (100 - discountSpecial) / 100;
+ sum += total;
+
+ table.table.body.push([
+ { text: i + 1, style: 'tableCell' },
+ { text: el.article?.artNumber ?? '', style: 'tableCell' },
+ { text: el.text, style: 'tableCell' },
+ { text: amount, style: 'tableCell', alignment: 'right' },
+ { text: '€' + el.price.toFixed(2), style: 'tableCell', alignment: 'right' },
+ { text: isDiscount ? discount.toFixed(0) + '%' : '', style: 'tableCell', alignment: 'right' },
+ { text: isDiscountSpecial ? discountSpecial.toFixed(0) + '%' : '', style: 'tableCell', alignment: 'right' },
+ { text: '€' + total.toFixed(2), style: 'tableCell', alignment: 'right' },
+ ]);
+ }
+ }
+
+ const vatLabel = `${vatRate}% MwSt.`;
+ const empty = { text: '', border: [0, 0, 0, 0] };
+
+ table.table.body.push(
+ [empty, empty, empty, empty, { text: 'Summe', colSpan: 2, style: 'tableCell', alignment: 'right' }, '', '', { text: '€' + sum.toFixed(2), style: 'tableCell', alignment: 'right' }],
+ [empty, empty, empty, empty, { text: vatLabel, colSpan: 2, style: 'tableCell', alignment: 'right' }, '', '', { text: '€' + (sum * vatRate / 100).toFixed(2), style: 'tableCell', alignment: 'right' }],
+ [empty, empty, empty, empty, { text: 'Gesamt', colSpan: 2, style: 'tableSumHeader', alignment: 'right' }, '', '', { text: '€' + (sum * (1 + vatRate / 100)).toFixed(2), style: 'tableSumHeader', alignment: 'right' }],
+ );
+
+ return table;
+ }
+
+ private buildOrdersDelivery(slip: SlipsheetEntity): Content {
+ const table: ContentTable = {
+ style: 'tableExample',
+ table: {
+ headerRows: 1,
+ widths: [100, '*', '*', 'auto'],
+ body: [[
+ { text: 'Pos', style: 'tableHeader', margin: [0, 0, 5, 0] },
+ { text: 'Artikel', style: 'tableHeader' },
+ { text: 'Typ', style: 'tableHeader' },
+ { text: 'Menge', style: 'tableHeader' },
+ ]],
+ },
+ layout: this.getSlipTableLayout(),
+ };
+
+ for (let i = 0; i < slip.orderEntries.length; i++) {
+ const el = slip.orderEntries[i];
+ table.table.body.push([
+ { text: i + 1, style: 'tableCell' },
+ { text: el.text, style: 'tableCell' },
+ { text: el.article?.artNumber ?? '', style: 'tableCell' },
+ { text: el.amount, style: 'tableCell' },
+ ]);
+ }
+ table.table.body.push(['', '', '', '']);
+ return table;
+ }
+
+ private getTableDefaultLayout() {
+ return {
+ hLineWidth: (i: number, node: any) => {
+ if (i === 0) return 0;
+ if (i === node.table.body.length) return 2;
+ if (i === node.table.body.length - 1) return 1;
+ return 1;
+ },
+ vLineWidth: () => 0,
+ hLineColor: (i: number, node: any) => {
+ if (i === node.table.body.length - 3) return 'black';
+ return (i === 1 || i === node.table.body.length - 1 || i === node.table.body.length) ? 'black' : '#aaa';
+ },
+ paddingLeft: (i: number) => (i <= 1 ? 0 : 5),
+ paddingRight: (i: number, node: any) => (i === node.table.widths.length - 1 ? 0 : 5),
+ paddingTop: () => 4,
+ paddingBottom: () => 4,
+ fillColor: () => null,
+ };
+ }
+
+ private getSlipTableLayout() {
+ return {
+ hLineWidth: (i: number, node: any) => {
+ if (i === 0) return 0;
+ if (i === node.table.body.length) return 2;
+ if (i === node.table.body.length - 1) return 1;
+ return 1;
+ },
+ vLineWidth: () => 0,
+ hLineColor: (i: number, node: any) =>
+ (i === 1 || i === node.table.body.length - 1 || i === node.table.body.length) ? 'black' : '#aaa',
+ paddingLeft: (i: number) => (i <= 1 ? 0 : 5),
+ paddingRight: (i: number, node: any) => (i === node.table.widths.length - 1 ? 0 : 5),
+ paddingTop: () => 4,
+ paddingBottom: () => 4,
+ fillColor: () => null,
+ };
+ }
+}
+
+---
+
+WICHTIG nach dem Ersetzen:
+Die Methoden generateDeliverySlip und generateBill sind jetzt async (geben Promise zurück).
+Prüfe alle Aufrufer dieser Methoden:
+ - apps/server/src/models/bills/bill.service.ts → Methode generateBill
+ - apps/server/src/models/bills/slipsheet.service.ts → Methode generateSlipsheet
+
+In bill.service.ts:
+ Suche nach: const retpdf = this.pdfMakerService.generateBill(firstBillEntity);
+ Ändere zu: const retpdf = await this.pdfMakerService.generateBill(firstBillEntity);
+
+ Suche nach: const retpdf = this.pdfMakerService.generateBill(bill); (in regenerateBillPdf)
+ Ändere zu: const retpdf = await this.pdfMakerService.generateBill(bill);
+
+In slipsheet.service.ts:
+ Suche nach: const retpdf = this.pdfMakerService.generateDeliverySlip(slip);
+ Ändere zu: const retpdf = await this.pdfMakerService.generateDeliverySlip(slip);
+
+ Stelle sicher, dass die umgebenden Methoden (generateSlipsheet etc.) ebenfalls async sind.
+```
+
+---
+
+## Phase 3 — Frontend: SettingsService
+
+**Prompt für den Agent:**
+
+```
+Arbeite im Repository sim-system, Branch first-init.
+Erstelle den Angular-Service für die Settings-API.
+Phase 1 und 2 (Backend) sind abgeschlossen.
+
+Orientiere dich am Stil von apps/sim-system/src/app/services/customer.service.ts.
+
+---
+
+SCHRITT 1 — Model
+Erstelle die Datei:
+ apps/sim-system/src/app/models/settings.model.ts
+
+Inhalt:
+export interface BankAccount {
+ name: string;
+ iban: string;
+ bic: string;
+}
+
+export class CompanySettings {
+ id?: number;
+ companyName: string = '';
+ street: string = '';
+ zip: string = '';
+ city: string = '';
+ country: string = 'Österreich';
+ phone: string = '';
+ email: string = '';
+ website: string = '';
+ firmenbuchnummer: string = '';
+ vatId: string = '';
+ issueCity: string = '';
+ vatRate: number = 20;
+ paymentTermDays: number = 14;
+ paymentFooterText: string = '';
+ bankAccounts: BankAccount[] = [];
+ logoPath: string | null = null;
+ badge1Path: string | null = null;
+ badge2Path: string | null = null;
+ updatedAt?: Date;
+
+ constructor(init?: Partial) {
+ Object.assign(this, init);
+ }
+}
+
+---
+
+SCHRITT 2 — Service
+Erstelle die Datei:
+ apps/sim-system/src/app/services/settings.service.ts
+
+Inhalt:
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { CompanySettings } from '../models/settings.model';
+
+@Injectable({ providedIn: 'root' })
+export class SettingsService {
+ constructor(private http: HttpClient) {}
+
+ get(): Observable {
+ return this.http.get('settings').pipe(
+ map(o => o.success ? new CompanySettings(o.data ?? {}) : new CompanySettings()),
+ );
+ }
+
+ save(settings: Partial): Observable {
+ return this.http.put('settings', settings).pipe(
+ map(o => {
+ if (o.success) return new CompanySettings(o.data);
+ throw new Error(o.message || 'Fehler beim Speichern');
+ }),
+ );
+ }
+
+ uploadLogo(file: File): Observable {
+ return this._upload('settings/logo', file);
+ }
+
+ uploadBadge1(file: File): Observable {
+ return this._upload('settings/badge1', file);
+ }
+
+ uploadBadge2(file: File): Observable {
+ return this._upload('settings/badge2', file);
+ }
+
+ private _upload(endpoint: string, file: File): Observable {
+ const fd = new FormData();
+ fd.append('file', file);
+ return this.http.post(endpoint, fd).pipe(
+ map(o => {
+ if (o.success) return new CompanySettings(o.data);
+ throw new Error(o.message || 'Upload fehlgeschlagen');
+ }),
+ );
+ }
+}
+```
+
+---
+
+## Phase 4 — Frontend: Settings-Komponente
+
+**Prompt für den Agent:**
+
+```
+Arbeite im Repository sim-system, Branch first-init.
+Erstelle die Angular Settings-Seite unter /settings.
+Phase 1–3 sind abgeschlossen.
+
+Orientiere dich beim Stil exakt an apps/sim-system/src/app/views/customer-edit/.
+Verwende standalone components, signals, ReactiveFormsModule, ChangeDetectionStrategy.OnPush.
+CSS-Klassen: sims-card, sims-label, sims-input, btn-sims-primary, btn-sims-ghost,
+ sims-page-header, eyebrow, page-title — genau wie in anderen Views.
+
+---
+
+SCHRITT 1 — Komponente TypeScript
+Erstelle die Datei:
+ apps/sim-system/src/app/views/settings/settings.component.ts
+
+Inhalt:
+import {
+ ChangeDetectionStrategy, Component, inject, signal, OnInit,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ReactiveFormsModule, FormGroup, FormControl, FormArray } from '@angular/forms';
+import { SettingsService } from '../../services/settings.service';
+import { CompanySettings } from '../../models/settings.model';
+
+@Component({
+ selector: 'app-settings',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule],
+ templateUrl: './settings.component.html',
+ styleUrl: './settings.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SettingsComponent implements OnInit {
+ private settingsService = inject(SettingsService);
+
+ readonly activeTab = signal<'firma' | 'zahlung' | 'logos'>('firma');
+ readonly saving = signal(false);
+ readonly loading = signal(true);
+ readonly error = signal('');
+ readonly saveSuccess = signal(false);
+
+ readonly logoPreviewUrl = signal(null);
+ readonly badge1PreviewUrl = signal(null);
+ readonly badge2PreviewUrl = signal(null);
+
+ readonly form = new FormGroup({
+ // Tab 1 — Firma
+ companyName: new FormControl(''),
+ street: new FormControl(''),
+ zip: new FormControl(''),
+ city: new FormControl(''),
+ country: new FormControl('Österreich'),
+ phone: new FormControl(''),
+ email: new FormControl(''),
+ website: new FormControl(''),
+ firmenbuchnummer: new FormControl(''),
+ vatId: new FormControl(''),
+ issueCity: new FormControl(''),
+ // Tab 2 — Zahlung
+ vatRate: new FormControl(20),
+ paymentTermDays: new FormControl(14),
+ paymentFooterText: new FormControl(''),
+ bankAccounts: new FormArray([]),
+ });
+
+ get bankAccountsArray(): FormArray {
+ return this.form.get('bankAccounts') as FormArray;
+ }
+
+ ngOnInit() {
+ this.settingsService.get().subscribe({
+ next: (s) => {
+ this.patchForm(s);
+ this.loading.set(false);
+ },
+ error: () => {
+ this.loading.set(false);
+ },
+ });
+ }
+
+ private patchForm(s: CompanySettings) {
+ this.form.patchValue({
+ companyName: s.companyName,
+ street: s.street,
+ zip: s.zip,
+ city: s.city,
+ country: s.country,
+ phone: s.phone,
+ email: s.email,
+ website: s.website,
+ firmenbuchnummer: s.firmenbuchnummer,
+ vatId: s.vatId,
+ issueCity: s.issueCity,
+ vatRate: s.vatRate,
+ paymentTermDays: s.paymentTermDays,
+ paymentFooterText: s.paymentFooterText,
+ });
+ this.bankAccountsArray.clear();
+ (s.bankAccounts ?? []).forEach(b => this.bankAccountsArray.push(this.newBankGroup(b)));
+ if (s.logoPath) this.logoPreviewUrl.set('/uploads/' + s.logoPath.split('uploads/').pop());
+ if (s.badge1Path) this.badge1PreviewUrl.set('/uploads/' + s.badge1Path.split('uploads/').pop());
+ if (s.badge2Path) this.badge2PreviewUrl.set('/uploads/' + s.badge2Path.split('uploads/').pop());
+ }
+
+ private newBankGroup(init?: { name: string; iban: string; bic: string }): FormGroup {
+ return new FormGroup({
+ name: new FormControl(init?.name ?? ''),
+ iban: new FormControl(init?.iban ?? ''),
+ bic: new FormControl(init?.bic ?? ''),
+ });
+ }
+
+ addBank() { this.bankAccountsArray.push(this.newBankGroup()); }
+ removeBank(i: number) { this.bankAccountsArray.removeAt(i); }
+
+ save() {
+ if (this.form.invalid) { this.form.markAllAsTouched(); return; }
+ this.saving.set(true);
+ this.error.set('');
+ this.settingsService.save(this.form.getRawValue() as any).subscribe({
+ next: (s) => {
+ this.saving.set(false);
+ this.saveSuccess.set(true);
+ setTimeout(() => this.saveSuccess.set(false), 3000);
+ },
+ error: (err) => {
+ this.saving.set(false);
+ this.error.set(err.message || 'Fehler beim Speichern');
+ },
+ });
+ }
+
+ onFileChange(event: Event, field: 'logo' | 'badge1' | 'badge2') {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ if (!file) return;
+
+ const upload$ =
+ field === 'logo' ? this.settingsService.uploadLogo(file) :
+ field === 'badge1' ? this.settingsService.uploadBadge1(file) :
+ this.settingsService.uploadBadge2(file);
+
+ upload$.subscribe({
+ next: (s) => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const url = e.target?.result as string;
+ if (field === 'logo') this.logoPreviewUrl.set(url);
+ if (field === 'badge1') this.badge1PreviewUrl.set(url);
+ if (field === 'badge2') this.badge2PreviewUrl.set(url);
+ };
+ reader.readAsDataURL(file);
+ },
+ error: (err) => this.error.set(err.message || 'Upload fehlgeschlagen'),
+ });
+ }
+
+ setTab(tab: 'firma' | 'zahlung' | 'logos') { this.activeTab.set(tab); }
+}
+
+---
+
+SCHRITT 2 — Template HTML
+Erstelle die Datei:
+ apps/sim-system/src/app/views/settings/settings.component.html
+
+Inhalt:
+
+
+@if (error()) {
+ {{ error() }}
+}
+@if (saveSuccess()) {
+ ✓ Einstellungen gespeichert
+}
+
+
+
+
+
+
+
+
+@if (loading()) {
+
+}
+
+@if (!loading()) {
+
+}
+
+---
+
+SCHRITT 3 — SCSS
+Erstelle die Datei:
+ apps/sim-system/src/app/views/settings/settings.component.scss
+
+Inhalt:
+.mb-2 { margin-bottom: 0.5rem; }
+.mb-3 { margin-bottom: 1rem; }
+.mt-2 { margin-top: 0.75rem; }
+
+.error-card {
+ color: var(--status-empty);
+ border-left: 3px solid var(--status-empty);
+}
+.success-card {
+ color: var(--status-ok);
+ border-left: 3px solid var(--status-ok);
+}
+
+.form-eyebrow {
+ font-family: var(--mono);
+ font-size: 0.6rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+ margin-bottom: 1.25rem;
+}
+
+/* TABS */
+.settings-tabs {
+ display: flex;
+ gap: 2px;
+ background: var(--border);
+ padding: 2px;
+ border-radius: var(--radius-md);
+ width: fit-content;
+}
+.settings-tab {
+ font-family: var(--sans);
+ font-size: 0.85rem;
+ padding: 0.45rem 1rem;
+ border: none;
+ border-radius: calc(var(--radius-md) - 2px);
+ background: transparent;
+ color: var(--ink-3);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ white-space: nowrap;
+}
+.settings-tab.active {
+ background: white;
+ color: var(--ink);
+ box-shadow: 0 1px 3px rgba(15,23,42,0.08);
+}
+.settings-tab:hover:not(.active) {
+ color: var(--ink-2);
+}
+
+/* BANK */
+.bank-entry {
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+}
+.bank-entry-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+.bank-label {
+ font-weight: 600;
+ font-size: 0.88rem;
+ color: var(--ink);
+}
+.btn-sm {
+ font-size: 0.78rem;
+ padding: 0.25rem 0.7rem;
+}
+
+/* UPLOADS */
+.uploads-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 1.25rem;
+ margin-bottom: 1rem;
+}
+.upload-item { display: flex; flex-direction: column; gap: 0.3rem; }
+.upload-zone {
+ border: 2px dashed var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-subtle);
+ min-height: 120px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+ overflow: hidden;
+}
+.upload-zone:hover {
+ border-color: var(--accent-highlight);
+ background: var(--accent-light);
+}
+.upload-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--ink-3);
+ font-size: 0.85rem;
+ text-align: center;
+ padding: 1rem;
+}
+.upload-icon { font-size: 1.75rem; }
+.upload-preview {
+ max-width: 100%;
+ max-height: 120px;
+ object-fit: contain;
+ padding: 0.5rem;
+}
+.upload-hint-text {
+ font-size: 0.8rem;
+ color: var(--ink-3);
+ margin-bottom: 0;
+}
+
+/* STICKY */
+.sticky-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ position: sticky;
+ bottom: 1rem;
+ padding: 0.75rem 0;
+}
+
+/* GRID HELPERS */
+.row { display: flex; flex-wrap: wrap; margin: -0.375rem; }
+.row.g-3 > * { padding: 0.375rem; }
+[class^="col-"] { width: 100%; }
+@media (min-width: 768px) {
+ .col-md-3 { width: 25%; }
+ .col-md-4 { width: 33.333%; }
+ .col-md-6 { width: 50%; }
+}
+.col-6 { width: 50%; }
+.col-12 { width: 100%; }
+```
+
+---
+
+## Phase 5 — Frontend: Route + Navigation verdrahten
+
+**Prompt für den Agent:**
+
+```
+Arbeite im Repository sim-system, Branch first-init.
+Verdrahte die Settings-Komponente mit dem Router und der Sidebar-Navigation.
+Phasen 1–4 sind abgeschlossen.
+
+---
+
+SCHRITT 1 — Route hinzufügen
+Bearbeite die Datei:
+ apps/sim-system/src/app/app.routes.ts
+
+Füge vor der Wildcard-Route (path: '**') folgenden Eintrag hinzu:
+
+ {
+ path: 'settings',
+ loadComponent: () =>
+ import('./views/settings/settings.component').then(m => m.SettingsComponent)
+ },
+
+---
+
+SCHRITT 2 — Navigation erweitern
+Bearbeite die Datei:
+ apps/sim-system/src/app/app.ts
+
+In der navItems-Array gibt es eine Sektion 'Werkzeuge'. Füge dort einen Settings-Eintrag hinzu:
+
+Suche den Block:
+ { label: 'Werkzeuge', children: [
+ { label: 'Inventur', route: '/inventory', icon: 'inventory' },
+ ]},
+
+Ändere ihn zu:
+ { label: 'Werkzeuge', children: [
+ { label: 'Inventur', route: '/inventory', icon: 'inventory' },
+ { label: 'Einstellungen', route: '/settings', icon: 'settings' },
+ ]},
+
+---
+
+SCHRITT 3 — Settings-Icon definieren
+In derselben Datei (apps/sim-system/src/app/app.ts) gibt es das ICONS-Objekt.
+Füge folgenden Eintrag hinzu:
+
+ settings: ``,
+
+---
+
+SCHRITT 4 — Prüfen
+Stelle sicher, dass in apps/sim-system/src/app/app.ts die bottomNavItems-Array
+NICHT um Settings erweitert wird — die mobile Bottom-Navigation soll nur die 5
+wichtigsten Einträge zeigen (Dashboard, Artikel, Kunden, Belege, Inventur).
+```
+
+---
+
+## Abschluss-Check (nach allen Phasen)
+
+**Prompt für den Agent:**
+
+```
+Führe nach Abschluss aller Phasen folgende Prüfungen durch:
+
+1. BACKEND KOMPILIERUNG
+ Führe aus: cd apps/server && npx tsc --noEmit
+ Erwartetes Ergebnis: Keine Fehler.
+ Häufige Fehler:
+ - "Object is possibly null" bei settings?.field → mit ?? '' oder ?? 0 absichern
+ - "Property does not exist" → CompanySettingsEntity-Felder prüfen
+ - Async-Fehler bei generateBill/generateDeliverySlip → await in allen Aufrufern prüfen
+
+2. CIRCULAR DEPENDENCY CHECK
+ Prüfe manuell: Importiert CompanySettingsModule irgendwo SharedModule?
+ Das wäre eine Circular Dependency. Es darf nur in eine Richtung gehen:
+ SharedModule → CompanySettingsModule → (keine Rückimporte)
+
+3. FRONTEND KOMPILIERUNG
+ Führe aus: cd apps/sim-system && npx ng build --configuration=development 2>&1 | head -50
+ Erwartetes Ergebnis: Keine Fehler.
+
+4. UPLOADS-VERZEICHNIS
+ Stelle sicher dass apps/server/uploads/settings/.gitkeep existiert.
+
+5. MULTER DEPENDENCY
+ Prüfe in package.json (Root) ob vorhanden:
+ - @nestjs/platform-express
+ - multer
+ - @types/multer (devDependencies)
+ Falls nicht: Ausgabe mit Installationsbefehl.
+
+6. FALLBACK-VERHALTEN
+ Überprüfe in pdfmaker.service.ts:
+ - FALLBACK_LOGO zeigt auf den existierenden Pfad apps/server/src/common/pdfAnnotation/logo.svg
+ - Der join-Pfad nutzt __dirname korrekt (relativ zum dist-Verzeichnis nach Kompilierung)
+ - Empfehlung: Pfad-Konstanten am Service-Anfang klar dokumentieren
+```
+
+---
+
+## Hinweise für den Agent
+
+- **Reihenfolge einhalten**: Phase 1 → 2 → 3 → 4 → 5. Phase 2 baut auf Phase 1 auf.
+- **Bestehenden Code nicht löschen**: Die Dateien in `pdfAnnotation/` bleiben als Fallback erhalten.
+- **SQLite synchronize**: Da `SQLITE_RUN_SYNCHRONIZE=true` in der `.env` gesetzt ist, erstellt TypeORM die neue Tabelle `company_settings` automatisch beim nächsten Start.
+- **Keine Migrations nötig**: Nur für Produktionsumgebungen relevant, nicht für den aktuellen Stand.
+- **Pfadtrenner**: Der bestehende Code nutzt `\\` (Windows-Pfade). Phase 2 verwendet `join()` aus `path` — das ist korrekt und cross-platform.
\ No newline at end of file
diff --git a/OPEN_SOURCE_NOTICES.md b/OPEN_SOURCE_NOTICES.md
new file mode 100644
index 0000000..35d6050
--- /dev/null
+++ b/OPEN_SOURCE_NOTICES.md
@@ -0,0 +1,18 @@
+# Open Source Notices
+
+This project builds upon the following open-source libraries:
+
+- Angular - MIT License, Copyright (c) Google LLC.
+- NestJS - MIT License, Copyright (c) 2017-2025 Kamil Mysliwiec and Contributors.
+- Nx - MIT License, Copyright (c) Nx, Inc.
+- Bootstrap - MIT License, Copyright (c) The Bootstrap Authors.
+- Lucide (including lucide-angular) - ISC License, Copyright (c) Lucide Contributors.
+- RxJS - Apache License 2.0, Copyright (c) Google LLC and contributors.
+- sharp - Apache License 2.0, Copyright (c) Lovell Fuller and contributors.
+- bwip-js - MIT License, Copyright (c) Metafloor.
+- qrcode - MIT License, Copyright (c) Soldair (Ryan Day).
+- sanitize-html - MIT License, Copyright (c) Apostrophe Technologies.
+- marked - MIT License, Copyright (c) Christopher Jeffrey.
+- uuid - MIT License, Copyright (c) 2010-2025 Robert Kieffer and Contributors.
+
+Additional dependencies are documented in the project package manifests.
diff --git a/README.md b/README.md
index e69de29..d8664d9 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,240 @@
+# SIMS — sims.abler.tirol
+
+Inventory Management System für das **abler.tirol** Ökosystem.
+NX Monorepo mit Angular 21 Frontend und NestJS 11 Backend.
+
+---
+
+## Projektstruktur
+
+```
+sim-system/
+├── apps/
+│ ├── server/ → NestJS 11 REST API (Port 3000)
+│ └── sim-system/ → Angular 21 Frontend (Port 4200)
+├── package.json → Root — alle Dependencies
+├── nx.json
+└── tsconfig.base.json
+```
+
+---
+
+## Tech Stack
+
+| Bereich | Technologie |
+|---|---|
+| Frontend | Angular 21, Standalone Components, Signals |
+| Backend | NestJS 11, TypeORM 0.3, better-sqlite3 |
+| Monorepo | NX 22 |
+| PDF | pdfmake 0.2 |
+| API-Docs | Swagger (`GET /api/v1/doc`) |
+| Design | abler.tirol Design-System (Teal) |
+
+---
+
+## Setup
+
+### Voraussetzungen
+
+- Node.js 20+
+- `npm install` **ohne** `--ignore-scripts` (kompiliert `better-sqlite3` native binary)
+
+```bash
+npm install
+```
+
+### Umgebungsvariablen
+
+Datei `apps/server/.env` anlegen (Vorlage: `apps/server/.env.example`):
+
+```env
+NODE_ENV=development
+APP_PORT=3000
+CORS_ORIGIN=http://localhost:4200
+
+SQLITE_PATH=sim.db
+SQLITE_RUN_MIGRATION=false
+SQLITE_RUN_SYNCHRONIZE=true
+SQLITE_ENTITIES=dist/**/*.entity.js
+
+PDF_SLIP_PATH=./pdfs/slips
+PDF_BILL_PATH=./pdfs/bills
+```
+
+---
+
+## Dev-Server starten
+
+```bash
+# Backend (Port 3000)
+npx nx serve server
+
+# Frontend (Port 4200 → Proxy → 3000)
+npx nx serve sim-system
+```
+
+Frontend: `http://localhost:4200`
+API-Docs: `http://localhost:3000/api/v1/doc`
+
+---
+
+## NX-Befehle
+
+```bash
+# Builds
+npx nx build server
+npx nx build sim-system
+npx nx build sim-system --configuration=production
+
+# Tests
+npx nx test server
+npx nx test sim-system
+
+# TypeORM Migrations
+npm run migration:generate --name=MigrationName
+npm run migration:run
+npm run migration:revert
+npm run migration:show
+```
+
+---
+
+## API-Routen (`/api/v1/`)
+
+### Artikel
+| Method | Route | Beschreibung |
+|---|---|---|
+| GET | `/articles` | Alle Artikel |
+| GET | `/articles?code=X` | Artikel nach Barcode |
+| POST | `/articles` | Artikel anlegen |
+| PATCH | `/articles/:id` | Artikel updaten |
+| DELETE | `/articles/:id` | Artikel löschen |
+| POST | `/articles/:id/inventory` | Inventurbuchung |
+| POST | `/articles/import` | CSV-Import |
+| GET | `/articlegroups` | Artikelgruppen |
+
+### Kunden
+| Method | Route | Beschreibung |
+|---|---|---|
+| GET | `/customers` | Alle Kunden |
+| GET | `/customers/:id` | Kunde by ID |
+| POST | `/customers` | Kunde anlegen |
+| PATCH | `/customers/:id` | Kunde updaten |
+| DELETE | `/customers/:id` | Kunde löschen |
+| GET | `/customers/:id/slipsheets` | Lieferscheine des Kunden |
+| GET | `/customers/:id/bills` | Rechnungen des Kunden |
+| PUT | `/customers/:id/discounts` | Rabatt setzen |
+| DELETE | `/customers/:id/discounts/:dId` | Rabatt löschen |
+
+### Lieferscheine
+| Method | Route | Beschreibung |
+|---|---|---|
+| GET | `/slipsheets` | Alle Lieferscheine |
+| GET | `/slipsheets/:id` | Lieferschein by ID |
+| POST | `/slipsheets` | Lieferschein anlegen |
+| PUT | `/slipsheets/:id` | Lieferschein updaten |
+| POST | `/slipsheets/:id` | Order-Entry hinzufügen |
+| GET | `/slipsheets/:id/pdf` | PDF generieren |
+| POST | `/slipsheets/:id/print` | Drucken |
+
+### Rechnungen
+| Method | Route | Beschreibung |
+|---|---|---|
+| GET | `/bills` | Alle Rechnungen |
+| POST | `/bills/generate` | Rechnung aus Lieferscheinen erzeugen |
+| PUT | `/bills/:id` | Rechnung updaten |
+| POST | `/bills/:id` | Rechnung neu erstellen (recreate) |
+| GET | `/bills/:id/pdf` | PDF generieren |
+
+### Sonstiges
+| Method | Route | Beschreibung |
+|---|---|---|
+| PUT | `/order-entries/:id` | Order-Entry updaten |
+| DELETE | `/order-entries/:id` | Order-Entry löschen |
+| GET | `/dashboard/summary` | Dashboard KPIs |
+
+---
+
+## Frontend-Routing
+
+```
+/ → /dashboard
+/dashboard → Dashboard (KPIs)
+/articles → Artikelliste
+/articles/:id → Artikeldetail
+/inventory → Inventurbuchung (Mobile-optimiert)
+/customers → Kundenliste
+/customers/new → Kunde anlegen
+/customers/:id → Kundendetail + Lieferschein/Rechnungsansicht
+/customers/:id/edit → Kunde bearbeiten
+/slipsheets → Lieferscheinliste
+/slipsheets/:id → Lieferscheindetail
+/order/new → Neue Bestellung / Lieferschein anlegen
+/bills → Rechnungsliste
+/bills/:id → Rechnungsdetail
+/login → Login
+```
+
+---
+
+## Design-System
+
+Akzentfarben (Teal):
+
+```css
+--accent-primary: #134e4a
+--accent-secondary: #0f3d3a
+--accent-highlight: #14b8a6
+--accent-light: #f0fdfa
+```
+
+Fonts (geladen von `api.abler.tirol`):
+- Headlines: `DM Serif Display`
+- Interface: `Inter`
+- Code / Labels / Tags: `DM Mono`
+
+Regeln:
+- Buttons: `border-radius: 999px` (Pill-Form)
+- Cards: `border-radius: 6px`
+- Kein reines Schwarz — immer `var(--ink)`
+- Keine System-Fonts
+
+---
+
+## Bekannte offene Punkte
+
+### Bugs
+- `GET /slipsheets/:id` gibt durch falsches Array-Indexing `undefined` zurück — Workaround: Daten immer über `GET /customers/:id/slipsheets` laden
+- `GET /bills/:id/pdf` hat Side-Effects (erzeugt Rechnung bei jedem Aufruf neu)
+- `generateBill()` löscht im Fehlerfall bestehende Rechnungen
+- Nummernvergabe für Rechnungen/Lieferscheine ohne DB-Lock (Race Condition)
+- `inventoryDate` ist `@CreateDateColumn` statt normalem `@Column`
+
+### Offen
+- Auth / JWT Guards sind noch auskommentiert — API aktuell offen
+- Login-Page ist ein Stub (leitet nur weiter)
+- Docker-Setup (`docker-compose.dev.yml`) noch nicht mit Monorepo-Struktur getestet
+
+---
+
+## Wichtige Dateipfade
+
+```
+apps/server/src/main.ts → NestJS Bootstrap
+apps/server/src/app/app.module.ts → Root Module
+apps/server/src/app/models/ → Entities, Services, Controller
+apps/server/src/common/services/ → PDF-Generierung etc.
+apps/server/src/datasource.ts → TypeORM DataSource (Migrations)
+apps/server/.env → Umgebungsvariablen (gitignored)
+apps/server/.env.example → Vorlage
+
+apps/sim-system/src/main.ts → Angular Bootstrap
+apps/sim-system/src/app/app.config.ts → Provider-Konfiguration
+apps/sim-system/src/app/app.routes.ts → Root-Routing
+apps/sim-system/src/app/services/ → Angular Services
+apps/sim-system/src/app/models/ → TypeScript Datenmodelle
+apps/sim-system/src/app/views/ → Seiten-Komponenten
+apps/sim-system/src/app/components/ → Shared Components (SlipsheetEditor)
+apps/sim-system/src/styles/ → Design-System CSS
+apps/sim-system/src/environments/ → API-URL Konfiguration
+```
diff --git a/WORKFLOW.md b/WORKFLOW.md
new file mode 100644
index 0000000..284ddf5
--- /dev/null
+++ b/WORKFLOW.md
@@ -0,0 +1,372 @@
+# WORKFLOW
+
+Stand: 2026-03-19
+Quelle: aus aktuellem Frontend, Services und Backend-API abgeleitet
+Hinweis: beschreibt den Ist-Zustand des Repos, nicht einen Wunsch-Sollzustand
+
+## Zielbild der Software
+
+Die Software ist eine interne Betriebsanwendung fuer Artikelverwaltung, Lagerfuehrung, Kundenpflege sowie die Erstellung und Nachverfolgung von Lieferscheinen und Rechnungen.
+
+## Abgeleitete Rollen
+
+### 1. Sachbearbeitung Verkauf
+
+Arbeitet mit Kunden, offenen Lieferscheinen, Belegen und Rechnungen.
+
+### 2. Lager / Inventur
+
+Pflegt Lagerstaende, sucht Artikel ueber Code und bucht Inventurdifferenzen.
+
+### 3. Stammdatenpflege / Administration
+
+Pflegt Artikel, Artikelgruppen und Kundenstammdaten.
+
+### 4. Buchhaltung / Verrechnung
+
+Erstellt Rechnungen aus vorhandenen Lieferscheinen und oeffnet Rechnungs-PDFs.
+
+## User Stories
+
+### Dashboard
+
+- Als Mitarbeiter moechte ich ein Dashboard mit KPIs sehen, damit ich offene Lieferscheine, offene Rechnungen und kritische Lagerstaende sofort erkenne.
+- Als Mitarbeiter moechte ich nach Zeitraum, Schwellenwert und Artikelgruppe filtern, damit ich nur fuer meinen aktuellen Blick relevante Kennzahlen sehe.
+- Als Mitarbeiter moechte ich aus dem Dashboard direkt zu Artikeln, Kunden, Rechnungen oder Inventur springen, damit ich ohne Umwege weiterarbeiten kann.
+
+### Artikelverwaltung
+
+- Als Stammdatenpfleger moechte ich alle Artikel sehen und nach Name oder Code filtern, damit ich einen Artikel schnell finde.
+- Als Stammdatenpfleger moechte ich einen neuen Artikel anlegen, damit er fuer Bestellungen und Inventur verfuegbar ist.
+- Als Stammdatenpfleger moechte ich einen bestehenden Artikel bearbeiten, damit Preis, Einheit, Warengruppe oder Lagerlogik aktuell bleiben.
+- Als Stammdatenpfleger moechte ich einen Artikel loeschen, damit veraltete Stammdaten entfernt werden koennen.
+- Als Mitarbeiter moechte ich Artikelgruppen laden koennen, damit Artikel korrekt zugeordnet werden.
+- Als Mitarbeiter moechte ich Artikel per CSV importieren koennen, damit groessere Stammdatenmengen schneller eingespielt werden.
+
+### Inventur
+
+- Als Lagermitarbeiter moechte ich einen Artikel per Code scannen oder eingeben, damit ich schnell den Sollbestand sehe.
+- Als Lagermitarbeiter moechte ich den Istbestand erfassen und buchen, damit der Lagerstand korrigiert wird.
+- Als Lagermitarbeiter moechte ich die letzte Reihe meiner Inventurbuchungen sehen, damit ich unmittelbar Rueckmeldung ueber meine Eingaben habe.
+
+### Kundenverwaltung
+
+- Als Sachbearbeiter moechte ich alle Kunden sehen und suchen, damit ich einen Kunden schnell auswaehlen kann.
+- Als Sachbearbeiter moechte ich einen neuen Kunden anlegen, damit ich fuer ihn Bestellungen und Rechnungen erfassen kann.
+- Als Sachbearbeiter moechte ich einen Kunden bearbeiten, damit Stammdaten und Standardrabatt aktuell bleiben.
+- Als Sachbearbeiter moechte ich einen Kunden mit seinen Lieferscheinen und Rechnungen sehen, damit ich seinen gesamten Bearbeitungsstand an einem Ort habe.
+- Als Sachbearbeiter moechte ich kundenbezogene Rabatte pflegen, damit Preise bei Auftraegen automatisch beruecksichtigt werden.
+
+### Auftrag / Lieferschein
+
+- Als Sachbearbeiter moechte ich fuer einen Kunden einen offenen Lieferschein aufbauen, damit ich Bestellpositionen laufend sammeln kann.
+- Als Sachbearbeiter moechte ich beim Hinzufuegen eines Artikels nur den Code und die Menge brauchen, damit die Eingabe schnell bleibt.
+- Als Sachbearbeiter moechte ich auch Freitextpositionen anlegen, damit nicht katalogisierte Leistungen oder Hinweise erfasst werden koennen.
+- Als Sachbearbeiter moechte ich Positionen aendern oder loeschen, damit ein offener Lieferschein korrigierbar bleibt.
+- Als Sachbearbeiter moechte ich Annotationen zu einem Lieferschein erfassen, damit Zusatzinformationen dokumentiert sind.
+- Als Sachbearbeiter moechte ich ein Lieferschein-PDF oeffnen oder herunterladen, damit ich den Beleg nutzen oder pruefen kann.
+- Als Sachbearbeiter moechte ich einen leeren offenen Lieferschein loeschen, damit Fehlanlagen wieder verschwinden.
+- Als Sachbearbeiter moechte ich alle Lieferscheine sehen und auf offene eingrenzen, damit ich den Dokumentenstapel priorisieren kann.
+
+### Rechnung
+
+- Als Sachbearbeiter moechte ich auf Kundenseite mehrere offene Lieferscheine auswaehlen und daraus eine Rechnung erzeugen, damit die Verrechnung gesammelt erfolgen kann.
+- Als Mitarbeiter moechte ich alle Rechnungen sehen, damit ich offene und abgeschlossene Rechnungen nachvollziehen kann.
+- Als Mitarbeiter moechte ich eine Rechnungsdetailansicht oeffnen und das PDF herunterladen, damit ich den Beleg pruefen oder weitergeben kann.
+- Als Mitarbeiter moechte ich ein Rechnungs-PDF bei Bedarf neu erzeugen koennen, damit fehlende Dateien wiederhergestellt werden koennen.
+
+### Authentifizierung
+
+- Als Benutzer moechte ich mich anmelden, damit ich nur berechtigten Zugriff auf das System habe.
+- Dieser Workflow ist im Frontend vorbereitet, aber im aktuellen Gesamtsystem nicht lauffaehig, weil die benoetigten Backend-Endpunkte fehlen und keine Login-Route registriert ist.
+
+## Hauptworkflows
+
+## 1. Dashboard beobachten und weiter navigieren
+
+### Ziel
+
+Operativen Zustand des Systems schnell erfassen und direkt in den naechsten Arbeitsschritt springen.
+
+### Ablauf
+
+1. Benutzer oeffnet `/dashboard`.
+2. System laedt `dashboard/summary`.
+3. Benutzer filtert optional nach Tagen, Schwellenwert oder Artikelgruppe.
+4. Benutzer springt aus KPI- oder Listenbereichen direkt zu:
+ - Artikeln
+ - Kunden
+ - Rechnungen
+ - Inventur
+
+### Ergebnis
+
+Dashboard ist ein Einstiegspunkt und Verteiler fuer Folgeprozesse.
+
+## 2. Artikel anlegen oder pflegen
+
+### Ziel
+
+Artikelstammdaten verfuegbar und korrekt halten.
+
+### Ablauf
+
+1. Benutzer oeffnet `/articles`.
+2. System zeigt alle Artikel und erlaubt Filter nach Name und Code.
+3. Benutzer oeffnet einen bestehenden Artikel oder navigiert zu einem neuen Artikel.
+4. Benutzer pflegt Felder wie:
+ - Name
+ - Code
+ - Artikelnummer
+ - Lieferant
+ - Typ
+ - Preis
+ - Einheit
+ - Warengruppe
+ - `singlePos`
+ - `trackStock`
+ - `noDiscount`
+5. Benutzer speichert oder loescht den Artikel.
+
+### Ergebnis
+
+Artikel stehen fuer Lager, Lieferschein und Rechnung bereit.
+
+## 3. Inventur buchen
+
+### Ziel
+
+Realen Lagerbestand gegen Sollbestand abgleichen und berichtigen.
+
+### Ablauf
+
+1. Benutzer oeffnet `/inventory`.
+2. Benutzer scannt oder tippt einen Artikelcode ein.
+3. System laedt den Artikel und zeigt den aktuellen Sollbestand.
+4. Benutzer erfasst den Istbestand.
+5. System berechnet die Differenz.
+6. Benutzer bucht die Inventur.
+7. System aktualisiert den Lagerstand und fuehrt die Buchung im Kurzprotokoll.
+
+### Ergebnis
+
+Bestand und Inventurhistorie werden fortgeschrieben.
+
+## 4. Kunden anlegen oder bearbeiten
+
+### Ziel
+
+Verrechenbare Kundenstammdaten pflegen.
+
+### Ablauf
+
+1. Benutzer oeffnet `/customers`.
+2. System zeigt die Kundenliste mit Suche.
+3. Benutzer erstellt einen neuen Kunden oder oeffnet einen bestehenden Datensatz.
+4. Benutzer pflegt u. a.:
+ - Firmenname
+ - Vorname / Nachname
+ - E-Mail
+ - Kundennummer
+ - Telefonnummern
+ - Adresse
+ - UID
+ - Kundenrabatt
+5. Benutzer speichert den Datensatz.
+
+### Ergebnis
+
+Kunden koennen fuer offene Lieferscheine und Rechnungen verwendet werden.
+
+## 5. Neue Bestellung bzw. offenen Lieferschein starten
+
+### Ziel
+
+Fuer einen Kunden einen offenen Arbeitsbeleg erzeugen oder fortsetzen.
+
+### Ablauf
+
+1. Benutzer oeffnet `/order/new`.
+2. System zeigt eine Kundensuche.
+3. Benutzer waehlt einen Kunden aus.
+4. System versucht, einen offenen Lieferschein fuer diesen Kunden zu laden.
+5. Falls keiner existiert, wird beim ersten Hinzufuegen einer Position ein neuer Lieferschein erzeugt.
+
+### Ergebnis
+
+Ein Kunde ist aktiv ausgewaehlt und ein offener Lieferschein steht zur Bearbeitung bereit.
+
+## 6. Artikelposition zu Lieferschein hinzufuegen
+
+### Ziel
+
+Standardartikel schnell auf einen offenen Lieferschein bringen.
+
+### Ablauf
+
+1. Benutzer arbeitet im `SlipsheetEditor`.
+2. Benutzer gibt Artikelcode und Menge ein.
+3. System laedt den Artikel per Code.
+4. Benutzer bestaetigt das Hinzufuegen.
+5. Wenn bereits ein offener Lieferschein existiert:
+ - Position wird auf bestehendem Lieferschein angelegt oder aktualisiert.
+6. Wenn noch kein Lieferschein existiert:
+ - System erzeugt einen offenen Lieferschein und fuegt die erste Position direkt hinzu.
+
+### Ergebnis
+
+Der offene Lieferschein enthaelt die neue Artikelposition.
+
+## 7. Freitextposition zu Lieferschein hinzufuegen
+
+### Ziel
+
+Nicht katalogisierte Leistungen oder manuelle Positionen erfassen.
+
+### Ablauf
+
+1. Benutzer oeffnet im `SlipsheetEditor` den Bereich fuer Textpositionen.
+2. Benutzer erfasst Text, Menge und Preis.
+3. System legt die Position auf dem offenen Lieferschein an oder erzeugt vorher einen neuen offenen Lieferschein.
+
+### Ergebnis
+
+Auch manuelle Positionen koennen in Lieferschein und spaeter Rechnung einfliessen.
+
+## 8. Offenen Lieferschein korrigieren
+
+### Ziel
+
+Offene Belege waehrend der Bearbeitung anpassen.
+
+### Ablauf
+
+1. Benutzer oeffnet einen offenen Lieferschein direkt oder ueber die Kundendetailseite.
+2. Benutzer aendert Mengen bestehender Positionen.
+3. Benutzer loescht Positionen durch Setzen der Menge auf `0`.
+4. Benutzer fuegt bei Bedarf Annotationen hinzu.
+5. Benutzer laedt bei Bedarf das PDF herunter.
+6. Ein leerer offener Lieferschein kann geloescht werden.
+
+### Ergebnis
+
+Der Lieferschein bleibt bis zur Verrechnung editierbar.
+
+## 9. Kundenansicht als Arbeitszentrale fuer Belege
+
+### Ziel
+
+Alle Belege eines Kunden an einer Stelle bearbeiten und verrechnen.
+
+### Ablauf
+
+1. Benutzer oeffnet `/customers/:id`.
+2. System laedt:
+ - Kundendaten
+ - Lieferscheine des Kunden
+ - Rechnungen des Kunden
+3. Benutzer kann:
+ - offene Lieferscheine filtern
+ - einen Lieferschein aktiv waehlen
+ - einen Lieferschein im Editor bearbeiten
+ - Lieferschein-PDF oeffnen
+ - Rechnungs-PDF oeffnen
+ - zu Rechnungsdetails springen
+
+### Ergebnis
+
+Kundenansicht ist der wichtigste operative Sammelpunkt fuer Auftrags- und Belegarbeit.
+
+## 10. Rechnung aus offenen Lieferscheinen erzeugen
+
+### Ziel
+
+Mehrere offene oder bearbeitete Lieferscheine gesammelt abrechnen.
+
+### Ablauf
+
+1. Benutzer oeffnet die Kundendetailansicht.
+2. Benutzer waehlt offene Lieferscheine aus.
+3. System summiert Anzahl und Gesamtwert der Auswahl.
+4. Benutzer startet `Rechnung erstellen`.
+5. Frontend ruft `POST /bills/generate` mit den ausgewaehlten Lieferschein-IDs auf.
+6. Backend erzeugt die Rechnung und verknuepft die Lieferscheine.
+7. Frontend aktualisiert Lieferschein- und Rechnungsliste.
+
+### Ergebnis
+
+Aus mehreren Lieferscheinen wird eine Rechnung.
+
+## 11. Rechnungen pruefen und PDF beziehen
+
+### Ziel
+
+Erzeugte Rechnungen einsehen und weiterverwenden.
+
+### Ablauf
+
+1. Benutzer oeffnet `/bills`.
+2. System zeigt alle Rechnungen.
+3. Benutzer oeffnet eine Rechnung ueber `/bills/:id`.
+4. Benutzer laedt das Rechnungs-PDF herunter.
+5. Falls die PDF-Datei fehlt, meldet das Frontend den Fehler und verweist implizit auf eine Neugenerierung.
+
+### Ergebnis
+
+Rechnungen sind nachvollziehbar und als PDF nutzbar.
+
+## Nebenworkflows und Querschnittslogik
+
+### 1. Statuslogik bei Lieferscheinen
+
+- Offene Lieferscheine sind aktiv bearbeitbar.
+- Nicht mehr offene Lieferscheine gelten im UI als verrechnet oder abgeschlossen.
+- Kundenansicht und Lieferscheinlisten arbeiten stark mit dieser Unterscheidung.
+
+### 2. Rabatte
+
+- Kundenspezifische Rabatte und artikelgruppenbezogene Rabatte sind im Datenmodell vorgesehen.
+- Beim Hinzufuegen von Artikelpositionen werden Rabatte serverseitig auf Positionen geschrieben.
+- Pflege eines Rabatts ist im Service vorgesehen; Loeschen ist im Frontend vorgesehen, aber im Backend aktuell nicht umgesetzt.
+
+### 3. PDF-Nutzung
+
+- Lieferscheine koennen direkt im Browser geoeffnet oder heruntergeladen werden.
+- Rechnungen werden heruntergeladen; in der Kundendetailansicht werden sie in neuem Tab geoeffnet.
+
+## Nicht vollstaendig lauffaehige oder inkonsistente Workflows
+
+### 1. Login / Session
+
+- Frontend besitzt `AuthenticationService`, `authGuard` und `LoginComponent`.
+- Aktuell fehlt:
+ - Route `/login`
+ - Backend-Endpunkt `POST /users/login`
+ - Backend-Endpunkt `GET /users/me/refresh`
+- Daraus folgt:
+ - Auth-Workflow ist fachlich erkennbar, technisch aber nicht abgeschlossen.
+
+### 2. Kunde loeschen
+
+- Frontend besitzt `customerService.delete(...)`.
+- Backend besitzt im aktuellen `CustomerController` keinen `DELETE /customers/:id`.
+- Daraus folgt:
+ - Story erkennbar, Workflow derzeit nicht durchgaengig nutzbar.
+
+### 3. Rabatt loeschen
+
+- Frontend besitzt `deleteDiscount(...)`.
+- Backend besitzt aktuell keinen passenden Delete-Endpunkt fuer Kundenrabatte.
+- Daraus folgt:
+ - Rabattpflege ist nur teilweise umgesetzt.
+
+## Priorisierte Kernprozesse aus Produktsicht
+
+Wenn man das aktuelle Repo auf seine tragenden Geschaeftsprozesse reduziert, sind das diese vier:
+
+1. Artikel pflegen und verfuegbar machen
+2. Lagerbestand per Inventur korrigieren
+3. Fuer Kunden offene Lieferscheine aufbauen und bearbeiten
+4. Aus Lieferscheinen Rechnungen erzeugen und PDFs bereitstellen
+
+Diese vier Prozesse sind der fachliche Kern der Software.
diff --git a/apps/server-e2e/eslint.config.mjs b/apps/server-e2e/eslint.config.mjs
new file mode 100644
index 0000000..b7f6277
--- /dev/null
+++ b/apps/server-e2e/eslint.config.mjs
@@ -0,0 +1,3 @@
+import baseConfig from '../../eslint.config.mjs';
+
+export default [...baseConfig];
diff --git a/apps/server-e2e/jest.config.cts b/apps/server-e2e/jest.config.cts
new file mode 100644
index 0000000..3da0016
--- /dev/null
+++ b/apps/server-e2e/jest.config.cts
@@ -0,0 +1,18 @@
+export default {
+ displayName: 'server-e2e',
+ preset: '../../jest.preset.js',
+ globalSetup: '/src/support/global-setup.ts',
+ globalTeardown: '/src/support/global-teardown.ts',
+ setupFiles: ['/src/support/test-setup.ts'],
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.[tj]s$': [
+ 'ts-jest',
+ {
+ tsconfig: '/tsconfig.spec.json',
+ },
+ ],
+ },
+ moduleFileExtensions: ['ts', 'js', 'html'],
+ coverageDirectory: '../../coverage/server-e2e',
+};
diff --git a/apps/server-e2e/project.json b/apps/server-e2e/project.json
new file mode 100644
index 0000000..49a9e47
--- /dev/null
+++ b/apps/server-e2e/project.json
@@ -0,0 +1,17 @@
+{
+ "name": "server-e2e",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "implicitDependencies": ["server"],
+ "targets": {
+ "e2e": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"],
+ "options": {
+ "jestConfig": "apps/server-e2e/jest.config.cts",
+ "passWithNoTests": true
+ },
+ "dependsOn": ["server:build", "server:serve"]
+ }
+ }
+}
diff --git a/apps/server-e2e/src/server/api-prefix.spec.ts b/apps/server-e2e/src/server/api-prefix.spec.ts
new file mode 100644
index 0000000..efffea8
--- /dev/null
+++ b/apps/server-e2e/src/server/api-prefix.spec.ts
@@ -0,0 +1,120 @@
+/**
+ * REQ-001 — Globaler API-Prefix
+ *
+ * Spec: test-req/REQ-001-global-api-prefix.md
+ * Source: apps/server/src/main.ts — app.setGlobalPrefix('api/v1')
+ *
+ * Voraussetzungen:
+ * - Server läuft auf PORT (default 3000, für dieses Projekt: 9000)
+ * - DB muss erreichbar sein (leere DB ist erlaubt)
+ * - Starten: PORT=9000 npx nx e2e server-e2e
+ *
+ * ACHTUNG: Alle Requests verwenden `validateStatus: () => true` damit
+ * axios bei 4xx/5xx nicht wirft und wir den Status direkt prüfen können.
+ */
+
+import axios from 'axios';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Axios-Request der niemals wirft — gibt immer die Response zurück. */
+async function request(
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
+ url: string,
+ data?: unknown,
+) {
+ return axios.request({
+ method,
+ url,
+ data,
+ validateStatus: () => true, // niemals Ausnahme auf 4xx/5xx
+ });
+}
+
+// ---------------------------------------------------------------------------
+// REQ-001 — Globaler API-Prefix
+// ---------------------------------------------------------------------------
+
+describe('REQ-001 — Globaler API-Prefix', () => {
+ // -------------------------------------------------------------------------
+ // TC-001-001: Korrekter Prefix → Endpunkt erreichbar
+ // -------------------------------------------------------------------------
+ describe('TC-001-001: GET /api/v1/articles — korrekter Prefix', () => {
+ it('gibt HTTP 200 zurück', async () => {
+ const res = await request('get', '/api/v1/articles');
+ expect(res.status).toBe(200);
+ });
+
+ it('Body enthält success: true', async () => {
+ const res = await request('get', '/api/v1/articles');
+ expect(res.data).toHaveProperty('success', true);
+ });
+
+ it('Body enthält Feld data als Array', async () => {
+ const res = await request('get', '/api/v1/articles');
+ expect(res.data).toHaveProperty('data');
+ expect(Array.isArray(res.data.data)).toBe(true);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-001-002: Kein Prefix → 404
+ // -------------------------------------------------------------------------
+ describe('TC-001-002: GET /articles — kein Prefix', () => {
+ it('gibt HTTP 404 zurück (Route nicht registriert)', async () => {
+ const res = await request('get', '/articles');
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-001-003: Falscher Prefix /api/articles — ohne Version
+ // -------------------------------------------------------------------------
+ describe('TC-001-003: GET /api/articles — falscher Prefix (ohne Version)', () => {
+ it('gibt HTTP 404 zurück', async () => {
+ const res = await request('get', '/api/articles');
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-001-004: Falscher Prefix /v1/articles — ohne api/
+ // -------------------------------------------------------------------------
+ describe('TC-001-004: GET /v1/articles — falscher Prefix (ohne api/)', () => {
+ it('gibt HTTP 404 zurück', async () => {
+ const res = await request('get', '/v1/articles');
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-001-005: Falscher Prefix /api/v2/articles — falsche Version
+ // -------------------------------------------------------------------------
+ describe('TC-001-005: GET /api/v2/articles — falsche Versionsnummer', () => {
+ it('gibt HTTP 404 zurück (keine zweite API-Version registriert)', async () => {
+ const res = await request('get', '/api/v2/articles');
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-001-006: POST unter korrektem Prefix — routing-seitig erreichbar
+ // Leerer Body erzeugt 400 (Validation) — das bestätigt, dass Routing OK ist.
+ // HTTP 404 wäre ein Routing-Fehler und würde den Test failen lassen.
+ // -------------------------------------------------------------------------
+ describe('TC-001-006: POST /api/v1/customers — Routing funktioniert für POST', () => {
+ it('gibt NICHT HTTP 404 zurück (Routing ist korrekt)', async () => {
+ const res = await request('post', '/api/v1/customers', {});
+ expect(res.status).not.toBe(404);
+ });
+
+ it('gibt HTTP 400 zurück (Validation schlägt an, nicht Routing)', async () => {
+ const res = await request('post', '/api/v1/customers', {});
+ // ValidationPipe wirft 400 bei fehlendem Pflichtfeld customerNumber
+ // Wenn dieser Test fehlschlägt (z.B. 201), ist Validierung deaktiviert → Potential Defect
+ expect(res.status).toBe(400);
+ });
+ });
+});
diff --git a/apps/server-e2e/src/server/cors.spec.ts b/apps/server-e2e/src/server/cors.spec.ts
new file mode 100644
index 0000000..61ab156
--- /dev/null
+++ b/apps/server-e2e/src/server/cors.spec.ts
@@ -0,0 +1,242 @@
+/**
+ * REQ-005 — CORS Herkunftsbeschränkung [E2E]
+ *
+ * Spec: test-req/REQ-005-cors.md
+ * Source: apps/server/src/main.ts — app.enableCors({ origin, methods, credentials })
+ *
+ * Alle Tests laufen gegen den laufenden Server.
+ * Voraussetzungen: PORT=9000 npx nx e2e server-e2e
+ *
+ * TC-Zuordnung:
+ * TC-005-001 — Default-Origin (http://localhost:4200) → ACAO-Header vorhanden
+ * TC-005-002 — credentials: true → ACAC-Header vorhanden
+ * TC-005-003 — OPTIONS-Preflight → CORS-Header + Status 204/200
+ * TC-005-004 — OPTIONS-Preflight → ACAM-Header enthält alle konfigurierten Methoden
+ * TC-005-005 — Non-Allowed Origin → KEIN ACAO-Header für http://attacker.com
+ * TC-005-006 — Multi-Origin via CORS_ORIGIN ENV → BLOCKED (ENV-Setup fehlt)
+ * TC-005-007 — Request ohne Origin-Header → HTTP 200 (kein CORS-Block)
+ *
+ * Hinweise:
+ * - Axios gibt Response-Header in Kleinbuchstaben zurück (Node.js HTTP-Spezifikation):
+ * z.B. 'access-control-allow-origin', nicht 'Access-Control-Allow-Origin'
+ * - CORS-Enforcement findet browserseitig statt. Der Server sendet (oder sendet nicht)
+ * die CORS-Header — der Test prüft nur das Vorhandensein/Fehlen dieser Header.
+ * - TC-005-005: Der HTTP-Status kann trotzdem 200 sein; entscheidend ist das Fehlen
+ * des ACAO-Headers für nicht-erlaubte Ursprünge.
+ *
+ * Potential Defects (dokumentiert in test-req/REQ-005-cors.md, GAP-005-2 + GAP-005-3):
+ * - CORS_ORIGIN mit Leerzeichen nach Komma: split(',') ohne trim() → Mismatch
+ * - CORS_ORIGIN="" (leerer String): ?? wird nicht ausgelöst → [""] statt Default
+ */
+
+import axios, { AxiosResponse } from 'axios';
+
+// ---------------------------------------------------------------------------
+// Helper — wirft niemals bei 4xx/5xx, sendet Origin-Header
+// ---------------------------------------------------------------------------
+async function req(
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options',
+ url: string,
+ headers?: Record,
+ data?: unknown,
+): Promise {
+ return axios.request({ method, url, headers, data, validateStatus: () => true });
+}
+
+// ---------------------------------------------------------------------------
+// TC-005-001 — Default-Origin: ACAO-Header vorhanden
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-001: Default-Origin http://localhost:4200 → ACAO-Header', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles', { Origin: 'http://localhost:4200' });
+ });
+
+ it('gibt HTTP 200 zurück', () => {
+ expect(res.status).toBe(200);
+ });
+
+ it('Response enthält Access-Control-Allow-Origin-Header', () => {
+ // Axios liefert Header-Namen in lowercase
+ expect(res.headers['access-control-allow-origin']).toBeDefined();
+ });
+
+ it('Access-Control-Allow-Origin ist http://localhost:4200', () => {
+ // Evidenz: main.ts Zeile 15 — Default-Origin wenn CORS_ORIGIN nicht gesetzt
+ expect(res.headers['access-control-allow-origin']).toBe('http://localhost:4200');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TC-005-002 — Credentials-Header: ACAC: true
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-002: credentials:true → Access-Control-Allow-Credentials: true', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles', { Origin: 'http://localhost:4200' });
+ });
+
+ it('Response enthält Access-Control-Allow-Credentials-Header', () => {
+ expect(res.headers['access-control-allow-credentials']).toBeDefined();
+ });
+
+ it('Access-Control-Allow-Credentials ist "true" (String im Header)', () => {
+ // HTTP-Header sind Strings — "true" nicht boolean true
+ // Evidenz: main.ts Zeile 17 — credentials: true → Express setzt den Header
+ expect(res.headers['access-control-allow-credentials']).toBe('true');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TC-005-003 — OPTIONS-Preflight: CORS-Header + akzeptabler Status
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-003: OPTIONS-Preflight → CORS-Header + Status 204/200', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('options', '/api/v1/articles', {
+ Origin: 'http://localhost:4200',
+ 'Access-Control-Request-Method': 'POST',
+ 'Access-Control-Request-Headers': 'Content-Type',
+ });
+ });
+
+ it('gibt HTTP 204 oder 200 zurück (kein 404, kein 403)', () => {
+ // NestJS/Express CORS-Middleware antwortet auf Preflight mit 204 (No Content)
+ // Manchmal auch 200 — beides ist valide
+ expect([200, 204]).toContain(res.status);
+ });
+
+ it('Response enthält Access-Control-Allow-Origin-Header', () => {
+ expect(res.headers['access-control-allow-origin']).toBe('http://localhost:4200');
+ });
+
+ it('Response enthält Access-Control-Allow-Methods-Header', () => {
+ expect(res.headers['access-control-allow-methods']).toBeDefined();
+ });
+
+ it('Access-Control-Allow-Methods enthält POST', () => {
+ const methods: string = res.headers['access-control-allow-methods'] ?? '';
+ expect(methods.toUpperCase()).toContain('POST');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TC-005-004 — OPTIONS-Preflight: Alle erlaubten Methoden im ACAM-Header
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-004: OPTIONS-Preflight → ACAM enthält alle konfigurierten Methoden', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('options', '/api/v1/articles', {
+ Origin: 'http://localhost:4200',
+ 'Access-Control-Request-Method': 'GET',
+ });
+ });
+
+ it('ACAM-Header ist vorhanden', () => {
+ expect(res.headers['access-control-allow-methods']).toBeDefined();
+ });
+
+ // Evidenz: main.ts Zeile 16 — methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
+ it.each(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'])(
+ 'ACAM enthält Methode: %s',
+ (method) => {
+ const allowed: string = res.headers['access-control-allow-methods'] ?? '';
+ expect(allowed.toUpperCase()).toContain(method);
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// TC-005-005 — Non-Allowed Origin: KEIN ACAO-Header
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-005: Non-Allowed Origin → kein ACAO-Header für http://attacker.com', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles', { Origin: 'http://attacker.com' });
+ });
+
+ it(
+ 'Access-Control-Allow-Origin ist NICHT "http://attacker.com"' +
+ ' (Browser würde Request blockieren)',
+ () => {
+ // Express/NestJS CORS-Middleware sendet keinen ACAO-Header wenn Origin nicht erlaubt ist.
+ // Der HTTP-Status kann trotzdem 200 sein — CORS ist ein Browser-Mechanismus.
+ // Entscheidend ist das Fehlen des Headers (oder ein anderer Wert als der Angreifer-Origin).
+ const acao = res.headers['access-control-allow-origin'];
+ expect(acao).not.toBe('http://attacker.com');
+ },
+ );
+
+ it('ACAO-Header fehlt vollständig (kein wildcard, kein attacker.com)', () => {
+ // Falls '*' gesetzt wäre → credentials:true + origin:'*' ist ungültig (Browser-Fehler)
+ // → dieser Test würde auch bei falscher '*'-Konfiguration helfen
+ const acao = res.headers['access-control-allow-origin'];
+ expect(acao).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TC-005-006 — Multi-Origin via CORS_ORIGIN ENV — BLOCKED
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-006: Multi-Origin via CORS_ORIGIN ENV [BLOCKED]', () => {
+ /**
+ * BLOCKED: Dieser Test erfordert, dass der Server mit einer spezifischen
+ * CORS_ORIGIN-Umgebungsvariable gestartet wurde (z.B. CORS_ORIGIN=http://app1.com,http://app2.com).
+ *
+ * Da die E2E-Tests gegen einen extern gestarteten Server laufen (PORT=9000),
+ * kann die ENV-Variable nicht innerhalb des Tests gesetzt werden.
+ *
+ * Blocker: Test-Setup-Strategie für ENV-Variablen ist nicht definiert.
+ * Quelle: test-req/REQ-005-cors.md — GAP-005-1
+ *
+ * Wenn die Infrastruktur vorhanden ist (z.B. Docker mit ENV), können folgende
+ * Assertions eingesetzt werden:
+ * - GET /api/v1/articles mit Origin: http://app1.com → ACAO: http://app1.com
+ * - GET /api/v1/articles mit Origin: http://app2.com → ACAO: http://app2.com
+ */
+ it.todo('TC-005-006a: CORS_ORIGIN=http://app1.com,http://app2.com → app1.com erlaubt');
+ it.todo('TC-005-006b: CORS_ORIGIN=http://app1.com,http://app2.com → app2.com erlaubt');
+});
+
+// ---------------------------------------------------------------------------
+// TC-005-007 — Request ohne Origin-Header → HTTP 200
+// ---------------------------------------------------------------------------
+
+describe('REQ-005 — TC-005-007: Request ohne Origin-Header → HTTP 200 (kein CORS-Block)', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ // Kein Origin-Header → simuliert Server-zu-Server oder direkten curl-Aufruf
+ // CORS gilt nur für Browser-Cross-Origin-Requests
+ res = await req('get', '/api/v1/articles');
+ });
+
+ it('gibt HTTP 200 zurück', () => {
+ expect(res.status).toBe(200);
+ });
+
+ it('success ist true', () => {
+ expect(res.data.success).toBe(true);
+ });
+
+ it('Response enthält Feld data', () => {
+ expect(res.data).toHaveProperty('data');
+ });
+
+ it('KEIN ACAO-Header wenn kein Origin-Header gesendet wurde', () => {
+ // INFERIERT: CORS-Middleware setzt ACAO nur wenn Origin-Header vorhanden
+ // Requests ohne Origin sind nicht cross-origin → kein ACAO notwendig
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
+ });
+});
diff --git a/apps/server-e2e/src/server/error-filter.spec.ts b/apps/server-e2e/src/server/error-filter.spec.ts
new file mode 100644
index 0000000..c85fe87
--- /dev/null
+++ b/apps/server-e2e/src/server/error-filter.spec.ts
@@ -0,0 +1,192 @@
+/**
+ * REQ-004 — Global ErrorFilter [E2E]
+ *
+ * Spec: test-req/REQ-004-error-filter.md
+ * Source: apps/server/src/common/filters/errors.filter.ts
+ * apps/server/src/main.ts — app.useGlobalFilters(new ErrorFilter())
+ *
+ * Alle Tests laufen gegen den laufenden Server.
+ * Voraussetzungen: PORT=9000 npx nx e2e server-e2e
+ *
+ * TC-Zuordnung (E2E-Ebene):
+ * TC-004-001 — NotFoundException (404) → vollständige ReE-Struktur
+ * TC-004-002 — BadRequestException (400) aus ValidationPipe → ReE
+ * TC-004-007 — Globaler Scope: ErrorFilter gilt für alle Controller (CustomerController)
+ *
+ * Unit-Tests (TC-004-003 bis TC-004-006) sind in:
+ * apps/server/src/common/filters/errors.filter.spec.ts
+ */
+
+import axios, { AxiosResponse } from 'axios';
+
+// ---------------------------------------------------------------------------
+// Helper — wirft niemals bei 4xx/5xx
+// ---------------------------------------------------------------------------
+async function req(
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
+ url: string,
+ data?: unknown,
+): Promise {
+ return axios.request({ method, url, data, validateStatus: () => true });
+}
+
+// ---------------------------------------------------------------------------
+// TC-004-001 — HttpException: NotFoundException → ReE 404
+// ---------------------------------------------------------------------------
+
+describe('REQ-004 — TC-004-001: NotFoundException → ReE mit statusCode 404', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles/99999');
+ });
+
+ it('gibt HTTP 404 zurück', () => {
+ expect(res.status).toBe(404);
+ });
+
+ it('success ist false (boolean)', () => {
+ expect(res.data.success).toBe(false);
+ expect(typeof res.data.success).toBe('boolean');
+ });
+
+ it('statusCode ist 404 (number)', () => {
+ expect(res.data.statusCode).toBe(404);
+ expect(typeof res.data.statusCode).toBe('number');
+ });
+
+ it('message ist ein Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('message hat mindestens 1 Element', () => {
+ expect(res.data.message.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('jedes message-Element ist ein String', () => {
+ (res.data.message as unknown[]).forEach((m) => {
+ expect(typeof m).toBe('string');
+ });
+ });
+
+ it('error ist ein nicht-leerer String', () => {
+ expect(typeof res.data.error).toBe('string');
+ expect(res.data.error.length).toBeGreaterThan(0);
+ });
+
+ it('Body enthält KEIN Feld data (ReE hat kein data-Feld)', () => {
+ // ReE.FromData() setzt nur success, statusCode, message, error
+ // kein data-Feld → wäre Defekt wenn vorhanden
+ expect(res.data.data).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TC-004-002 — BadRequestException (ValidationPipe) → ReE 400
+// ---------------------------------------------------------------------------
+
+describe('REQ-004 — TC-004-002: BadRequestException (ValidationPipe) → ReE 400', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ // POST mit leerem Body → ValidationPipe wirft BadRequestException
+ // ErrorFilter fängt sie ab und formatiert als ReE
+ res = await req('post', '/api/v1/articles', {});
+ });
+
+ it('gibt HTTP 400 zurück', () => {
+ expect(res.status).toBe(400);
+ });
+
+ it('success ist false', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('statusCode ist 400 (number)', () => {
+ expect(res.data.statusCode).toBe(400);
+ expect(typeof res.data.statusCode).toBe('number');
+ });
+
+ it('message ist ein Array (ValidationPipe erzeugt Array mit einem Eintrag pro Constraint)', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ expect(res.data.message.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('jedes message-Element ist ein String (kein Array-in-Array = kein double-wrap)', () => {
+ // Beweist TC-004-003: message-Array wird nicht erneut eingewickelt
+ (res.data.message as unknown[]).forEach((m) => {
+ expect(typeof m).toBe('string');
+ });
+ });
+
+ it('error ist ein nicht-leerer String', () => {
+ expect(typeof res.data.error).toBe('string');
+ expect(res.data.error.length).toBeGreaterThan(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TC-004-007 — Globaler Scope: ErrorFilter gilt für ALLE Controller
+// ---------------------------------------------------------------------------
+
+describe('REQ-004 — TC-004-007: Globaler Scope — ErrorFilter gilt für CustomerController', () => {
+ // Beweis: app.useGlobalFilters(new ErrorFilter()) registriert den Filter
+ // für alle Controller, nicht nur für einen spezifischen.
+ // CustomerController ist ein anderer Controller als ArticleController.
+
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/customers/99999');
+ });
+
+ it('gibt HTTP 404 zurück', () => {
+ expect(res.status).toBe(404);
+ });
+
+ it('success ist false — ErrorFilter greift auch im CustomerController', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('statusCode ist 404', () => {
+ expect(res.data.statusCode).toBe(404);
+ });
+
+ it('message ist ein Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('error ist ein nicht-leerer String', () => {
+ expect(typeof res.data.error).toBe('string');
+ expect(res.data.error.length).toBeGreaterThan(0);
+ });
+
+ it('Body enthält KEIN Feld data', () => {
+ expect(res.data.data).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// REQ-004 — ReE-Struktur-Vollständigkeit (alle Pflichtfelder vorhanden)
+// ---------------------------------------------------------------------------
+
+describe('REQ-004 — ReE-Vollständigkeit: alle 4 Pflichtfelder bei jedem Fehler', () => {
+ // Prüft dass kein Feld aus dem ReE-Format fehlt (success, statusCode, message, error)
+ // Basiert auf ReE.FromData(statusCode, name, message) aus res.model.ts
+
+ it('404-Response hat alle 4 ReE-Felder: success, statusCode, message, error', async () => {
+ const res = await req('get', '/api/v1/articles/99999');
+ expect(res.data).toHaveProperty('success');
+ expect(res.data).toHaveProperty('statusCode');
+ expect(res.data).toHaveProperty('message');
+ expect(res.data).toHaveProperty('error');
+ });
+
+ it('400-Response hat alle 4 ReE-Felder: success, statusCode, message, error', async () => {
+ const res = await req('post', '/api/v1/articles', {});
+ expect(res.data).toHaveProperty('success');
+ expect(res.data).toHaveProperty('statusCode');
+ expect(res.data).toHaveProperty('message');
+ expect(res.data).toHaveProperty('error');
+ });
+});
diff --git a/apps/server-e2e/src/server/response-wrapper.spec.ts b/apps/server-e2e/src/server/response-wrapper.spec.ts
new file mode 100644
index 0000000..963f012
--- /dev/null
+++ b/apps/server-e2e/src/server/response-wrapper.spec.ts
@@ -0,0 +1,254 @@
+/**
+ * REQ-002 — Response-Wrapper (ReS / ReE) [E2E]
+ *
+ * Spec: test-req/REQ-002-response-wrapper.md
+ * Source: apps/server/src/common/res.model.ts
+ *
+ * Testet das äußere Response-Format (ReS / ReE) auf API-Ebene.
+ * TC-002-006 (non-HttpException → 500) ist in der Unit-Test-Datei:
+ * apps/server/src/common/filters/errors.filter.spec.ts
+ *
+ * Voraussetzungen:
+ * PORT=9000 npx nx e2e server-e2e
+ *
+ * Deviation von Spec (TC-002-002):
+ * Spec nennt DELETE /api/v1/articles/:id. Da ArticleCreate eine
+ * articleGroup-Referenz (FK) benötigt, wird stattdessen der
+ * Customer-DELETE-Endpunkt verwendet. Beide Controller geben
+ * ReS.FromData(null) zurück — das zu prüfende Verhalten ist identisch.
+ */
+
+import axios, { AxiosResponse } from 'axios';
+
+// ---------------------------------------------------------------------------
+// Helper — wirft niemals bei 4xx/5xx
+// ---------------------------------------------------------------------------
+async function req(
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
+ url: string,
+ data?: unknown,
+): Promise {
+ return axios.request({ method, url, data, validateStatus: () => true });
+}
+
+// ---------------------------------------------------------------------------
+// REQ-002 — ReS (Success-Wrapper)
+// ---------------------------------------------------------------------------
+
+describe('REQ-002 — ReS Success-Wrapper', () => {
+ // -------------------------------------------------------------------------
+ // TC-002-001: Success-Response Grundstruktur
+ // -------------------------------------------------------------------------
+ describe('TC-002-001: GET /api/v1/articles — Success-Struktur', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles');
+ });
+
+ it('gibt HTTP 200 zurück', () => {
+ expect(res.status).toBe(200);
+ });
+
+ it('Body ist gültiges JSON-Objekt', () => {
+ expect(typeof res.data).toBe('object');
+ expect(res.data).not.toBeNull();
+ });
+
+ it('enthält success: true (boolean)', () => {
+ expect(res.data.success).toBe(true);
+ expect(typeof res.data.success).toBe('boolean');
+ });
+
+ it('enthält Feld data als Array', () => {
+ expect(res.data).toHaveProperty('data');
+ expect(Array.isArray(res.data.data)).toBe(true);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-002-002: data ist null nach DELETE
+ //
+ // Deviation: Spec nennt Article-DELETE. Hier wird Customer-DELETE verwendet
+ // da Article-Create eine articleGroup-FK benötigt die u.U. nicht existiert.
+ // Das geprüfte Verhalten (ReS.FromData(null)) ist identisch.
+ // -------------------------------------------------------------------------
+ describe('TC-002-002: DELETE — data: null im Response', () => {
+ let testCustomerId: number | null = null;
+
+ beforeAll(async () => {
+ // Erstelle einen Testkunden — customerNumber ist einziges Pflichtfeld
+ const create = await req('post', '/api/v1/customers', {
+ customerNumber: `REQ002-TC002-${Date.now()}`,
+ });
+ if (create.status === 201 && create.data?.data?.id) {
+ testCustomerId = create.data.data.id;
+ }
+ });
+
+ it('Setup hat einen Kunden erstellt', () => {
+ // Schlägt hier der Test fehl, ist das Setup gebrochen — nicht TC-002-002
+ expect(testCustomerId).not.toBeNull();
+ });
+
+ it('gibt HTTP 200 zurück', async () => {
+ if (!testCustomerId) return; // Abhängig von Setup
+ const res = await req('delete', `/api/v1/customers/${testCustomerId}`);
+ expect(res.status).toBe(200);
+ });
+
+ it('Body enthält success: true', async () => {
+ // Eigene DELETE-Anfrage mit neuem Kunden um Idempotenz zu garantieren
+ const create = await req('post', '/api/v1/customers', {
+ customerNumber: `REQ002-TC002b-${Date.now()}`,
+ });
+ const id: number = create.data?.data?.id;
+ expect(id).toBeDefined();
+
+ const res = await req('delete', `/api/v1/customers/${id}`);
+ expect(res.data.success).toBe(true);
+ });
+
+ it('Body.data ist null', async () => {
+ const create = await req('post', '/api/v1/customers', {
+ customerNumber: `REQ002-TC002c-${Date.now()}`,
+ });
+ const id: number = create.data?.data?.id;
+ expect(id).toBeDefined();
+
+ const res = await req('delete', `/api/v1/customers/${id}`);
+
+ // Potential Defect: ClassSerializerInterceptor könnte null-Felder
+ // aus @Expose()-Properties herausfiltern. Wenn data fehlt statt null
+ // ist → ist das ein Defekt im Code, nicht im Test.
+ expect(res.data.data).toBeNull();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-002-007: Keine ReE-Felder in Success-Response
+ // -------------------------------------------------------------------------
+ describe('TC-002-007: Success-Response enthält keine Error-Felder', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles');
+ });
+
+ it('enthält KEIN Feld statusCode', () => {
+ expect(res.data.statusCode).toBeUndefined();
+ });
+
+ it('enthält KEIN Feld error', () => {
+ expect(res.data.error).toBeUndefined();
+ });
+
+ it('enthält KEIN Feld message', () => {
+ expect(res.data.message).toBeUndefined();
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// REQ-002 — ReE (Error-Wrapper)
+// ---------------------------------------------------------------------------
+
+describe('REQ-002 — ReE Error-Wrapper', () => {
+ // -------------------------------------------------------------------------
+ // TC-002-003: 404 ReE Grundstruktur
+ // -------------------------------------------------------------------------
+ describe('TC-002-003: GET /api/v1/articles/99999 — 404 ReE-Struktur', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles/99999');
+ });
+
+ it('gibt HTTP 404 zurück', () => {
+ expect(res.status).toBe(404);
+ });
+
+ it('enthält success: false (boolean)', () => {
+ expect(res.data.success).toBe(false);
+ expect(typeof res.data.success).toBe('boolean');
+ });
+
+ it('enthält statusCode: 404 (number)', () => {
+ expect(res.data.statusCode).toBe(404);
+ expect(typeof res.data.statusCode).toBe('number');
+ });
+
+ it('enthält message als Array mit mindestens 1 Element', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ expect(res.data.message.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('jedes message-Element ist ein String', () => {
+ res.data.message.forEach((m: unknown) => {
+ expect(typeof m).toBe('string');
+ });
+ });
+
+ it('enthält error als String (nicht leer)', () => {
+ expect(typeof res.data.error).toBe('string');
+ expect(res.data.error.length).toBeGreaterThan(0);
+ });
+
+ it('enthält KEIN Feld data', () => {
+ // ReE-Klasse hat kein data-Feld — wäre ein Defekt wenn vorhanden
+ expect(res.data.data).toBeUndefined();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-002-004: message ist immer Array (nie String)
+ // -------------------------------------------------------------------------
+ describe('TC-002-004: message ist immer Array, nie String', () => {
+ it('message bei 404 ist Array', async () => {
+ const res = await req('get', '/api/v1/articles/99999');
+ expect(Array.isArray(res.data.message)).toBe(true);
+ // Explizit kein String
+ expect(typeof res.data.message).not.toBe('string');
+ });
+
+ it('message bei 400 (Customers 404) ist Array', async () => {
+ const res = await req('get', '/api/v1/customers/99999');
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-002-005: ValidationPipe-Fehler → message Array mit mehreren Elementen
+ // -------------------------------------------------------------------------
+ describe('TC-002-005: POST /api/v1/articles mit {} — ValidationPipe message[]', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('post', '/api/v1/articles', {});
+ });
+
+ it('gibt HTTP 400 zurück', () => {
+ expect(res.status).toBe(400);
+ });
+
+ it('success ist false', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('message ist Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('message hat mindestens 1 Element (ein Constraint pro fehlendem Pflichtfeld)', () => {
+ // CreateArticleDto hat mehrere Pflichtfelder (name, code, price, type, unit, artNumber, articleGroup)
+ // Erwartung: mindestens 1 Message. Wenn ValidationPipe korrekt konfiguriert: mehrere.
+ expect(res.data.message.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('jedes message-Element ist ein String', () => {
+ res.data.message.forEach((m: unknown) => {
+ expect(typeof m).toBe('string');
+ });
+ });
+ });
+});
diff --git a/apps/server-e2e/src/server/server.spec.ts b/apps/server-e2e/src/server/server.spec.ts
new file mode 100644
index 0000000..51717c7
--- /dev/null
+++ b/apps/server-e2e/src/server/server.spec.ts
@@ -0,0 +1,10 @@
+import axios from 'axios';
+
+describe('GET /', () => {
+ it('should return a message', async () => {
+ const res = await axios.get(`/`);
+
+ expect(res.status).toBe(200);
+ expect(res.data).toEqual({ message: 'Hello API' });
+ });
+});
diff --git a/apps/server-e2e/src/server/validation-pipe.spec.ts b/apps/server-e2e/src/server/validation-pipe.spec.ts
new file mode 100644
index 0000000..b94e100
--- /dev/null
+++ b/apps/server-e2e/src/server/validation-pipe.spec.ts
@@ -0,0 +1,371 @@
+/**
+ * REQ-003 — Global ValidationPipe (whitelist + transform) [E2E]
+ *
+ * Spec: test-req/REQ-003-validation-pipe.md
+ * Source: apps/server/src/main.ts — ValidationPipe({ whitelist: true, transform: true })
+ *
+ * Alle Tests laufen gegen den laufenden Server.
+ * Voraussetzungen: PORT=9000 npx nx e2e server-e2e
+ *
+ * Hinweise:
+ * - TC-003-001 erstellt einen Kunden. CustomerController hat kein DELETE →
+ * timestamp-basierte customerNumber, um Kollisionen zwischen Testläufen zu vermeiden.
+ * - TC-003-005 prüft Transform indirekt: korrekte 404-Antwort beweist, dass
+ * die ID als Number verarbeitet wurde.
+ *
+ * Potential Defect (dokumentiert, Test NICHT gebogen):
+ * - CreateArticleDto.price hat @IsNotEmpty() aber kein @IsNumber(). Preis "abc"
+ * (String) würde deshalb keine 400 erzeugen → Matrix-Row "Typfehler" kann failen.
+ */
+
+import axios, { AxiosResponse } from 'axios';
+
+// ---------------------------------------------------------------------------
+// Helper — wirft niemals bei 4xx/5xx
+// ---------------------------------------------------------------------------
+async function req(
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
+ url: string,
+ data?: unknown,
+): Promise {
+ return axios.request({ method, url, data, validateStatus: () => true });
+}
+
+// Vollständiger, valider Article-Body (ohne name) für Pflichtfeld-Tests
+const ARTICLE_BODY_WITHOUT_NAME = {
+ code: 'REQ003-NONAME',
+ price: 9.99,
+ type: 'Test',
+ unit: 'Stk',
+ artNumber: 'REQ003-001',
+ articleGroup: { id: 1 },
+};
+
+// ---------------------------------------------------------------------------
+// REQ-003a — Whitelist: Extra-Felder werden still entfernt
+// ---------------------------------------------------------------------------
+
+describe('REQ-003a — Whitelist: Extra-Felder werden still entfernt', () => {
+ // -------------------------------------------------------------------------
+ // TC-003-001: POST mit bekannten + unbekannten Feldern → 201, kein Extra-Feld
+ // -------------------------------------------------------------------------
+ describe('TC-003-001: POST /api/v1/customers — unbekannte Felder silent strip', () => {
+ const testCustomerNumber = `REQ003-TC001-${Date.now()}`;
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('post', '/api/v1/customers', {
+ customerNumber: testCustomerNumber,
+ hackerField: 'sollte-ignoriert-werden',
+ anotherUnknown: 12345,
+ });
+ });
+
+ it('gibt HTTP 201 zurück (kein 400 wegen Extra-Feldern)', () => {
+ expect(res.status).toBe(201);
+ });
+
+ it('success ist true', () => {
+ expect(res.data.success).toBe(true);
+ });
+
+ it('bekannte Felder sind erhalten: customerNumber', () => {
+ expect(res.data.data.customerNumber).toBe(testCustomerNumber);
+ });
+
+ it('unbekanntes Feld hackerField ist NICHT in der Response', () => {
+ expect(res.data.data?.hackerField).toBeUndefined();
+ });
+
+ it('unbekanntes Feld anotherUnknown ist NICHT in der Response', () => {
+ expect(res.data.data?.anotherUnknown).toBeUndefined();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-003-002: POST mit ausschließlich unbekannten Feldern → 400
+ // Whitelist entfernt alle Felder → customerNumber fehlt → Validation 400
+ // -------------------------------------------------------------------------
+ describe('TC-003-002: POST /api/v1/customers — nur unbekannte Felder → 400', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('post', '/api/v1/customers', { unknownField: 'x', anotherField: 42 });
+ });
+
+ it('gibt HTTP 400 zurück', () => {
+ expect(res.status).toBe(400);
+ });
+
+ it('success ist false', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('message ist ein Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('message enthält Hinweis auf customerNumber (Pflichtfeld)', () => {
+ // NestJS erzeugt eine Message pro Constraint-Verletzung
+ // @IsNotEmpty() auf customerNumber → "customerNumber should not be empty"
+ const messages: string[] = res.data.message;
+ const mentionsCustomerNumber = messages.some(
+ (m) => m.toLowerCase().includes('customernumber'),
+ );
+ expect(mentionsCustomerNumber).toBe(true);
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// REQ-003b — Pflichtfeld-Validierung
+// ---------------------------------------------------------------------------
+
+describe('REQ-003b — Pflichtfeld-Validierung', () => {
+ // -------------------------------------------------------------------------
+ // TC-003-003: POST /articles ohne name → 400
+ // -------------------------------------------------------------------------
+ describe('TC-003-003: POST /api/v1/articles ohne Pflichtfeld name → 400', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('post', '/api/v1/articles', ARTICLE_BODY_WITHOUT_NAME);
+ });
+
+ it('gibt HTTP 400 zurück', () => {
+ expect(res.status).toBe(400);
+ });
+
+ it('success ist false', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('message ist ein Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('message enthält Hinweis auf das fehlende Feld name', () => {
+ // @IsNotEmpty() auf CreateArticleDto.name →
+ // NestJS erzeugt z.B. "name should not be empty"
+ const messages: string[] = res.data.message;
+ const mentionsName = messages.some((m) => m.toLowerCase().includes('name'));
+ expect(mentionsName).toBe(true);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-003-004: POST /articles mit leerem Body → 400 mit mehreren Messages
+ // -------------------------------------------------------------------------
+ describe('TC-003-004: POST /api/v1/articles mit {} → 400 + mehrere Fehlermeldungen', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('post', '/api/v1/articles', {});
+ });
+
+ it('gibt HTTP 400 zurück', () => {
+ expect(res.status).toBe(400);
+ });
+
+ it('success ist false', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('message ist ein Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('message enthält mehr als ein Element (je ein Hinweis pro fehlendem Pflichtfeld)', () => {
+ // CreateArticleDto hat 7 Pflichtfelder (@IsNotEmpty()):
+ // name, code, price, type, unit, artNumber, articleGroup
+ // Mindestens 2 Messages werden erwartet.
+ expect(res.data.message.length).toBeGreaterThan(1);
+ });
+
+ it('jedes message-Element ist ein String', () => {
+ (res.data.message as unknown[]).forEach((m) => {
+ expect(typeof m).toBe('string');
+ });
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// REQ-003c — Nested DTO Validierung
+// ---------------------------------------------------------------------------
+
+describe('REQ-003c — Nested DTO Validierung', () => {
+ // -------------------------------------------------------------------------
+ // TC-003-007: POST /articles mit articleGroup: {} (id fehlt) → 400
+ // -------------------------------------------------------------------------
+ describe('TC-003-007: POST /api/v1/articles mit leerer articleGroup → 400', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('post', '/api/v1/articles', {
+ name: 'TestArtikel',
+ code: 'REQ003-NESTED',
+ price: 9.99,
+ type: 'Test',
+ unit: 'Stk',
+ artNumber: 'REQ003-002',
+ articleGroup: {}, // id fehlt absichtlich
+ });
+ });
+
+ it('gibt HTTP 400 zurück', () => {
+ expect(res.status).toBe(400);
+ });
+
+ it('success ist false', () => {
+ expect(res.data.success).toBe(false);
+ });
+
+ it('message ist ein Array', () => {
+ expect(Array.isArray(res.data.message)).toBe(true);
+ });
+
+ it('message enthält Hinweis auf articleGroup-Verschachtelung', () => {
+ // NestJS ValidateNested + @Type → Fehler sind z.B.:
+ // "articleGroup.id must be a number conforming to the specified constraints"
+ // "articleGroup.id should not be empty"
+ const messages: string[] = res.data.message;
+ const mentionsArticleGroup = messages.some((m) =>
+ m.toLowerCase().includes('articlegroup'),
+ );
+ expect(mentionsArticleGroup).toBe(true);
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// REQ-003d — Transform: Path-Param & Query-Param
+// ---------------------------------------------------------------------------
+
+describe('REQ-003d — Transform: Typen-Konvertierung', () => {
+ // -------------------------------------------------------------------------
+ // TC-003-005: GET /api/v1/articles/:id — Path-Param String → Number
+ //
+ // Indirekter Test: Eine valide numerische ID im Pfad muss als Number verarbeitet
+ // werden. Ergebnis 404 (nicht gefunden) beweist, dass die ID korrekt als Number
+ // an den Service übergeben wurde und eine DB-Abfrage stattfand.
+ // Ein Ergebnis von 500 oder ein TypeORM-String-Fehler würde bedeuten, dass
+ // der Transform nicht funktioniert hat.
+ // -------------------------------------------------------------------------
+ describe('TC-003-005: GET /api/v1/articles/99999 — Path-Param wird als Number verarbeitet', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/articles/99999');
+ });
+
+ it('gibt HTTP 404 zurück (kein 500 oder 400 durch Typ-Fehler)', () => {
+ // 404 beweist: ID wurde als Number verarbeitet, Service hat DB abgefragt,
+ // Artikel nicht gefunden → NotFoundException → 404
+ // 500 würde auf Transform-Fehler oder DB-Typ-Mismatch hinweisen
+ expect(res.status).toBe(404);
+ });
+
+ it('gibt NICHT HTTP 500 zurück', () => {
+ expect(res.status).not.toBe(500);
+ });
+
+ it('gibt NICHT HTTP 400 zurück', () => {
+ expect(res.status).not.toBe(400);
+ });
+
+ it('Response ist im ReE-Format (success: false)', () => {
+ expect(res.data.success).toBe(false);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-003-006: GET /api/v1/dashboard/summary?days=30 — Query-Param → Number
+ //
+ // @Type(() => Number) auf DashboardSummaryQueryDto.days transformiert den
+ // Query-String "30" zu Number 30. @IsInt + @Min(1) validiert dann den Number.
+ // Beweis: HTTP 200 (kein 400 wegen Typ-Fehler) + filters.days als Number.
+ // -------------------------------------------------------------------------
+ describe('TC-003-006: GET /api/v1/dashboard/summary?days=30 — Query-Param als Number', () => {
+ let res: AxiosResponse;
+
+ beforeAll(async () => {
+ res = await req('get', '/api/v1/dashboard/summary?days=30');
+ });
+
+ it('gibt HTTP 200 zurück (kein 400 durch Typ-Mismatch)', () => {
+ // @IsInt() würde 400 geben wenn "30" als String ankäme und nicht transformiert wird
+ expect(res.status).toBe(200);
+ });
+
+ it('success ist true', () => {
+ expect(res.data.success).toBe(true);
+ });
+
+ it('Response enthält Feld data', () => {
+ expect(res.data).toHaveProperty('data');
+ });
+
+ it('data.filters.days ist 30 als Number (INFERIERT: Derived-from-code)', () => {
+ // DashboardService gibt filters-Objekt zurück.
+ // Wenn dieser Test failt weil filters nicht in data ist:
+ // → Endpoint-Response-Struktur hat sich geändert (nicht Transform-Bug)
+ // Wenn filters.days === "30" (String): → Transform hat nicht funktioniert → Potential Defect
+ const filtersDay = res.data?.data?.filters?.days;
+ if (filtersDay !== undefined) {
+ expect(typeof filtersDay).toBe('number');
+ expect(filtersDay).toBe(30);
+ } else {
+ // filters.days nicht im Response → kann nicht verifiziert werden
+ // Test gilt als nicht-anwendbar für diese Assertion (skip via pass)
+ // Primäre Assertion (HTTP 200) oben gilt weiterhin
+ }
+ });
+
+ it('gibt NICHT HTTP 400 zurück wenn days=0 (ungültiger Wert < @Min(1))', async () => {
+ // Zusatztest: days=0 sollte 400 geben (@Min(1))
+ // Das beweist, dass der @IsInt() + @Min(1) Validator greift
+ // und damit der Transform korrekt zu Number konvertiert hat
+ const invalid = await req('get', '/api/v1/dashboard/summary?days=0');
+ expect(invalid.status).toBe(400);
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// REQ-003e — Potential Defect Dokumentation (kein Greenwashing)
+// ---------------------------------------------------------------------------
+
+describe('REQ-003e — Potential Defect: price @IsNotEmpty() ohne @IsNumber()', () => {
+ /**
+ * Potential Defect:
+ * CreateArticleDto.price ist mit @IsNotEmpty() annotiert aber NICHT mit @IsNumber().
+ * Das bedeutet: price: "nicht-eine-zahl" (String) würde die Validierung bestehen,
+ * weil es nicht leer ist. Der Typfehler-Test aus der Matrix kann daher fehlschlagen.
+ *
+ * Quelle: apps/server/src/models/article/dto/create-article.dto.ts Zeile 25-26
+ * Evidenz: @IsNotEmpty() auf price ohne @IsNumber()
+ *
+ * Dieser Test dokumentiert das tatsächliche Verhalten OHNE Anpassung der Erwartung.
+ * Wenn der Test grün wird (400 erhalten), wurde der Defekt behoben.
+ * Wenn der Test rot wird (kein 400), ist der Defekt bestätigt.
+ */
+ it('[POTENTIAL DEFECT] POST /articles mit price: "abc" gibt HTTP 400 zurück', async () => {
+ const res = await req('post', '/api/v1/articles', {
+ name: 'TestArtikel',
+ code: 'REQ003-PRICETEST',
+ price: 'nicht-eine-zahl', // String statt Number
+ type: 'Test',
+ unit: 'Stk',
+ artNumber: 'REQ003-003',
+ articleGroup: { id: 1 },
+ });
+
+ // Erwartetes Verhalten laut REQ-003: Typfehler → 400
+ // Tatsächliches Verhalten: UNKLAR (kein @IsNumber() im DTO)
+ // Test wird als Potential Defect markiert:
+ // - Falls 400 → DTO wurde korrigiert oder TypeORM fängt es ab
+ // - Falls 201 → Defekt bestätigt: String-Price wird akzeptiert
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/apps/server-e2e/src/support/global-setup.ts b/apps/server-e2e/src/support/global-setup.ts
new file mode 100644
index 0000000..76a5879
--- /dev/null
+++ b/apps/server-e2e/src/support/global-setup.ts
@@ -0,0 +1,16 @@
+import { waitForPortOpen } from '@nx/node/utils';
+
+/* eslint-disable */
+var __TEARDOWN_MESSAGE__: string;
+
+module.exports = async function () {
+ // Start services that that the app needs to run (e.g. database, docker-compose, etc.).
+ console.log('\nSetting up...\n');
+
+ const host = process.env.HOST ?? 'localhost';
+ const port = process.env.PORT ? Number(process.env.PORT) : 3000;
+ await waitForPortOpen(port, { host });
+
+ // Hint: Use `globalThis` to pass variables to global teardown.
+ globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n';
+};
diff --git a/apps/server-e2e/src/support/global-teardown.ts b/apps/server-e2e/src/support/global-teardown.ts
new file mode 100644
index 0000000..a28dd11
--- /dev/null
+++ b/apps/server-e2e/src/support/global-teardown.ts
@@ -0,0 +1,10 @@
+import { killPort } from '@nx/node/utils';
+/* eslint-disable */
+
+module.exports = async function () {
+ // Put clean up logic here (e.g. stopping services, docker-compose, etc.).
+ // Hint: `globalThis` is shared between setup and teardown.
+ const port = process.env.PORT ? Number(process.env.PORT) : 3000;
+ await killPort(port);
+ console.log(globalThis.__TEARDOWN_MESSAGE__);
+};
diff --git a/apps/server-e2e/src/support/test-setup.ts b/apps/server-e2e/src/support/test-setup.ts
new file mode 100644
index 0000000..c185541
--- /dev/null
+++ b/apps/server-e2e/src/support/test-setup.ts
@@ -0,0 +1,9 @@
+/* eslint-disable */
+import axios from 'axios';
+
+module.exports = async function () {
+ // Configure axios for tests to use.
+ const host = process.env.HOST ?? 'localhost';
+ const port = process.env.PORT ?? '3000';
+ axios.defaults.baseURL = `http://${host}:${port}`;
+};
diff --git a/apps/server-e2e/tsconfig.json b/apps/server-e2e/tsconfig.json
new file mode 100644
index 0000000..ed633e1
--- /dev/null
+++ b/apps/server-e2e/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "compilerOptions": {
+ "esModuleInterop": true
+ }
+}
diff --git a/apps/server-e2e/tsconfig.spec.json b/apps/server-e2e/tsconfig.spec.json
new file mode 100644
index 0000000..d7f9cf2
--- /dev/null
+++ b/apps/server-e2e/tsconfig.spec.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["jest", "node"]
+ },
+ "include": ["jest.config.ts", "src/**/*.ts"]
+}
diff --git a/apps/server/.env.example b/apps/server/.env.example
new file mode 100644
index 0000000..24bf855
--- /dev/null
+++ b/apps/server/.env.example
@@ -0,0 +1,14 @@
+# Application
+NODE_ENV=development
+APP_PORT=3000
+CORS_ORIGIN=http://localhost:4200
+
+# SQLite Database
+SQLITE_PATH=sim.db
+SQLITE_RUN_MIGRATION=false
+SQLITE_RUN_SYNCHRONIZE=true
+SQLITE_ENTITIES=dist/**/*.entity.js
+
+# PDF Storage Paths
+PDF_SLIP_PATH=./pdfs/slips
+PDF_BILL_PATH=./pdfs/bills
diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs
new file mode 100644
index 0000000..b7f6277
--- /dev/null
+++ b/apps/server/eslint.config.mjs
@@ -0,0 +1,3 @@
+import baseConfig from '../../eslint.config.mjs';
+
+export default [...baseConfig];
diff --git a/apps/server/jest.config.cts b/apps/server/jest.config.cts
new file mode 100644
index 0000000..c805f35
--- /dev/null
+++ b/apps/server/jest.config.cts
@@ -0,0 +1,10 @@
+module.exports = {
+ displayName: 'server',
+ preset: '../../jest.preset.js',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }],
+ },
+ moduleFileExtensions: ['ts', 'js', 'html'],
+ coverageDirectory: '../../coverage/apps/server',
+};
diff --git a/apps/server/project.json b/apps/server/project.json
new file mode 100644
index 0000000..1ce9048
--- /dev/null
+++ b/apps/server/project.json
@@ -0,0 +1,86 @@
+{
+ "name": "server",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "apps/server/src",
+ "projectType": "application",
+ "tags": [],
+ "targets": {
+ "build": {
+ "executor": "@nx/js:tsc",
+ "outputs": ["{options.outputPath}"],
+ "defaultConfiguration": "production",
+ "options": {
+ "outputPath": "dist/apps/server",
+ "main": "apps/server/src/main.ts",
+ "tsConfig": "apps/server/tsconfig.app.json",
+ "assets": [
+ "apps/server/src/assets",
+ {
+ "glob": "**/*",
+ "input": "apps/server/src/common/pdfAnnotation",
+ "output": "src/common/pdfAnnotation"
+ }
+ ]
+ },
+ "configurations": {
+ "development": {
+ "sourceMap": true
+ },
+ "production": {
+ "sourceMap": false
+ }
+ }
+ },
+ "prune-lockfile": {
+ "dependsOn": ["build"],
+ "cache": true,
+ "executor": "@nx/js:prune-lockfile",
+ "outputs": [
+ "{workspaceRoot}/dist/apps/server/package.json",
+ "{workspaceRoot}/dist/apps/server/package-lock.json"
+ ],
+ "options": {
+ "buildTarget": "build"
+ }
+ },
+ "copy-workspace-modules": {
+ "dependsOn": ["build"],
+ "cache": true,
+ "outputs": ["{workspaceRoot}/dist/apps/server/workspace_modules"],
+ "executor": "@nx/js:copy-workspace-modules",
+ "options": {
+ "buildTarget": "build"
+ }
+ },
+ "prune": {
+ "dependsOn": ["prune-lockfile", "copy-workspace-modules"],
+ "executor": "nx:noop"
+ },
+ "serve": {
+ "continuous": true,
+ "executor": "@nx/js:node",
+ "defaultConfiguration": "development",
+ "dependsOn": ["build"],
+ "options": {
+ "buildTarget": "server:build",
+ "runBuildTargetDependencies": false
+ },
+ "configurations": {
+ "development": {
+ "buildTarget": "server:build:development"
+ },
+ "production": {
+ "buildTarget": "server:build:production"
+ }
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "apps/server/jest.config.cts",
+ "passWithNoTests": true
+ }
+ }
+ }
+}
diff --git a/apps/server/src/app.controller.ts b/apps/server/src/app.controller.ts
new file mode 100644
index 0000000..c70d51b
--- /dev/null
+++ b/apps/server/src/app.controller.ts
@@ -0,0 +1,12 @@
+import { Controller, Get } from '@nestjs/common';
+import { AppService } from './app.service';
+
+@Controller()
+export class AppController {
+ constructor(private readonly appService: AppService) {}
+
+ @Get()
+ getVersion(): string {
+ return this.appService.getVersion();
+ }
+}
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
new file mode 100644
index 0000000..1322bb4
--- /dev/null
+++ b/apps/server/src/app.module.ts
@@ -0,0 +1,31 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { AppController } from './app.controller';
+import { AppService } from './app.service';
+import { SharedModule } from './common/shared.module';
+import { AppConfigModule } from './config/app/config.module';
+import { SqliteConfigModule } from './config/database/sqlite/config.module';
+import { ArticleModule } from './models/article/article.module';
+import { BillModule } from './models/bills/bill.module';
+import { CustomerModule } from './models/customer/customer.module';
+import { CompanySettingsModule } from './models/settings/company-settings.module';
+import { SqliteDatabaseProviderModule } from './providers/database/sqlite/provider.module';
+import { UsersModule } from './models/users/users.module'; // Bug #7 fix: add UsersModule
+
+const ENV = process.env.NODE_ENV;
+@Module({
+ imports: [
+ AppConfigModule,
+ SqliteConfigModule,
+ SqliteDatabaseProviderModule,
+ ArticleModule,
+ CustomerModule,
+ BillModule,
+ CompanySettingsModule,
+ SharedModule,
+ UsersModule, // Bug #7 fix
+ ],
+ controllers: [AppController],
+ providers: [AppService],
+})
+export class AppModule {}
diff --git a/apps/server/src/app.service.ts b/apps/server/src/app.service.ts
new file mode 100644
index 0000000..30169a3
--- /dev/null
+++ b/apps/server/src/app.service.ts
@@ -0,0 +1,8 @@
+import { Injectable } from '@nestjs/common';
+
+@Injectable()
+export class AppService {
+ getVersion(): string {
+ return 'V1';
+ }
+}
diff --git a/apps/server/src/assets/.gitkeep b/apps/server/src/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/apps/server/src/common/base.service.ts b/apps/server/src/common/base.service.ts
new file mode 100644
index 0000000..95bd339
--- /dev/null
+++ b/apps/server/src/common/base.service.ts
@@ -0,0 +1,78 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { ModelRepository } from '../models/model.repository';
+import { DeepPartial } from 'typeorm';
+import { ModelEntity } from './serializers/model.serializer';
+
+@Injectable()
+export class BaseService {
+
+ constructor(
+ private readonly repository: ModelRepository
+ ) {}
+
+ get(
+ id: number,
+ relations: string[] = [],
+ throwsException = true,
+ ): Promise {
+ return this.repository.get(id, relations, throwsException);
+ }
+
+ getAll(
+ relations: string[] = [],
+ throwsException = true,
+ ): Promise {
+ return this.repository.findAll({
+ relations,
+ throwsException,
+ });
+ }
+
+ async delete(id: number, throwsException = true): Promise {
+ return this.repository
+ .delete(id)
+ .then((result) => {
+ if (throwsException && (!result.affected || result.affected === 0)) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+ return Promise.resolve(true);
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async getByName(
+ name: string,
+ relations: string[] = [],
+ throwsException = false,
+ ): Promise {
+ return this.repository
+ .findOne({
+ where: { name: name } as any,
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(
+ entity ? this.repository.transform(entity) : null,
+ );
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async create(inputs: DeepPartial): Promise {
+ return await this.repository.createEntity(inputs);
+ }
+
+ async update(
+ id: number,
+ inputs: DeepPartial,
+ ): Promise {
+ return await this.repository.updateEntity(
+ id,
+ inputs as any,
+ );
+ }
+}
diff --git a/apps/server/src/common/decorators/apires.decorator.ts b/apps/server/src/common/decorators/apires.decorator.ts
new file mode 100644
index 0000000..26e123c
--- /dev/null
+++ b/apps/server/src/common/decorators/apires.decorator.ts
@@ -0,0 +1,51 @@
+import { Type, applyDecorators } from '@nestjs/common';
+import {
+ ApiCreatedResponse,
+ ApiOkResponse,
+ getSchemaPath,
+} from '@nestjs/swagger';
+import { ReS } from '../res.model';
+
+export const ApiReS = >(
+ model: TModel,
+ description: string,
+ status?: number,
+) => {
+ if (status === 201) {
+ return applyDecorators(
+ ApiCreatedResponse({
+ description,
+ schema: {
+ allOf: [
+ { $ref: getSchemaPath(ReS) },
+ {
+ properties: {
+ data: {
+ $ref: getSchemaPath(model),
+ },
+ },
+ },
+ ],
+ },
+ }),
+ );
+ }
+
+ return applyDecorators(
+ ApiOkResponse({
+ description,
+ schema: {
+ allOf: [
+ { $ref: getSchemaPath(ReS) },
+ {
+ properties: {
+ data: {
+ $ref: getSchemaPath(model),
+ },
+ },
+ },
+ ],
+ },
+ }),
+ );
+};
diff --git a/apps/server/src/common/decorators/isset.decorator.ts b/apps/server/src/common/decorators/isset.decorator.ts
new file mode 100644
index 0000000..06cec2d
--- /dev/null
+++ b/apps/server/src/common/decorators/isset.decorator.ts
@@ -0,0 +1,24 @@
+import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
+
+export function IsSet(
+ condition: (object: any, value: any) => boolean,
+ validationOptions?: ValidationOptions) {
+ return function (object: Object, propertyName: string) {
+ registerDecorator({
+ name: 'isSet',
+ target: object.constructor,
+ propertyName: propertyName,
+ constraints: [condition],
+ options: validationOptions,
+ validator: {
+ validate(value: any, args: ValidationArguments) {
+ const [relatedPropertyName] = args.constraints;
+ console.log(value);
+ console.log(args.object);
+ const relatedValue = relatedPropertyName(args.object);
+ return !relatedValue;
+ },
+ },
+ });
+ };
+}
diff --git a/apps/server/src/common/dto/id.dto.ts b/apps/server/src/common/dto/id.dto.ts
new file mode 100644
index 0000000..aaf0bb1
--- /dev/null
+++ b/apps/server/src/common/dto/id.dto.ts
@@ -0,0 +1,12 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsNumber } from 'class-validator';
+
+export class IdDto {
+ @ApiProperty({
+ example: '1',
+ description: 'ID',
+ })
+ @IsNotEmpty()
+ @IsNumber()
+ id: number;
+}
diff --git a/apps/server/src/common/dto/time-range.dto.ts b/apps/server/src/common/dto/time-range.dto.ts
new file mode 100644
index 0000000..1da8b5b
--- /dev/null
+++ b/apps/server/src/common/dto/time-range.dto.ts
@@ -0,0 +1,11 @@
+import { IsDateString } from 'class-validator';
+
+export class TimeRangeDto {
+
+ @IsDateString()
+ from: string;
+
+ @IsDateString()
+ to: string;
+
+}
diff --git a/apps/server/src/common/enums/errorcodes.enum.ts b/apps/server/src/common/enums/errorcodes.enum.ts
new file mode 100644
index 0000000..3e2d899
--- /dev/null
+++ b/apps/server/src/common/enums/errorcodes.enum.ts
@@ -0,0 +1,3 @@
+export declare enum ErrorCodes {
+ UserNotInHeader = 'user not in token',
+}
diff --git a/apps/server/src/common/filters/errors.filter.spec.ts b/apps/server/src/common/filters/errors.filter.spec.ts
new file mode 100644
index 0000000..19aa9a9
--- /dev/null
+++ b/apps/server/src/common/filters/errors.filter.spec.ts
@@ -0,0 +1,181 @@
+/**
+ * REQ-002 + REQ-004 — ErrorFilter (Unit)
+ *
+ * Spec: test-req/REQ-002-response-wrapper.md
+ * test-req/REQ-004-error-filter.md
+ * Source: apps/server/src/common/filters/errors.filter.ts
+ *
+ * Testet ErrorFilter.catch() direkt mit gemocktem ArgumentsHost.
+ * Dadurch ist der Test deterministisch ohne laufenden Server.
+ *
+ * TC-Zuordnung:
+ * TC-002-006 — Non-HttpException → HTTP 500 + ReE
+ * TC-004-003 — message-Array passthrough (kein double-wrap)
+ * TC-004-004 — message-String → einelementiges Array
+ * TC-004-005 — Non-HttpException → HTTP 500 (message + statusCode)
+ * TC-004-006 — TypeError → error: "TypeError"
+ *
+ * Ausführen: npx nx test server --testFile=errors.filter.spec.ts
+ */
+
+import { ArgumentsHost, BadRequestException, HttpStatus, NotFoundException } from '@nestjs/common';
+import { ErrorFilter } from './errors.filter';
+
+// ---------------------------------------------------------------------------
+// Mock-Fabrik für ArgumentsHost
+// ---------------------------------------------------------------------------
+
+interface MockResponseCapture {
+ statusCode: number | null;
+ body: Record | null;
+}
+
+function createMockHost(): { host: ArgumentsHost; capture: MockResponseCapture } {
+ const capture: MockResponseCapture = { statusCode: null, body: null };
+
+ const mockJson = jest.fn((body: Record) => {
+ capture.body = body;
+ });
+
+ // response.status(code) muss ein Objekt mit .json() zurückgeben
+ const mockStatus = jest.fn((code: number) => {
+ capture.statusCode = code;
+ return { json: mockJson };
+ });
+
+ const mockHost = {
+ switchToHttp: jest.fn().mockReturnValue({
+ getResponse: jest.fn().mockReturnValue({ status: mockStatus }),
+ getRequest: jest.fn().mockReturnValue({}),
+ }),
+ } as unknown as ArgumentsHost;
+
+ return { host: mockHost, capture };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('ErrorFilter', () => {
+ let filter: ErrorFilter;
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ filter = new ErrorFilter();
+ // Unterdrücke console.error während der Tests (errors.filter.ts Zeile 22)
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-002-006 / TC-004-005: Non-HttpException → HTTP 500
+ // -------------------------------------------------------------------------
+ describe('TC-002-006 / TC-004-005: Non-HttpException gibt HTTP 500 + ReE zurück', () => {
+ it('setzt HTTP-Statuscode 500', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new Error('Unexpected failure'), host);
+ expect(capture.statusCode).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it('Body: success ist false', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new Error('Unexpected failure'), host);
+ expect(capture.body?.success).toBe(false);
+ });
+
+ it('Body: statusCode ist 500', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new Error('Unexpected failure'), host);
+ expect(capture.body?.statusCode).toBe(500);
+ });
+
+ it('Body: message ist Array mit error.message', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new Error('DB connection lost'), host);
+ expect(capture.body?.message).toEqual(['DB connection lost']);
+ });
+
+ it('Body: message ist Array, kein String', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new Error('some error'), host);
+ expect(Array.isArray(capture.body?.message)).toBe(true);
+ });
+
+ it('Body: error ist error.name ("Error" für generisches Error)', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new Error('msg'), host);
+ expect(capture.body?.error).toBe('Error');
+ });
+
+ it('TC-004-006: Body: error ist "TypeError" für TypeError-Instanz', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new TypeError('type mismatch'), host);
+ expect(capture.body?.error).toBe('TypeError');
+ });
+
+ it('Body: error ist "RangeError" für RangeError-Instanz', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new RangeError('out of bounds'), host);
+ expect(capture.body?.error).toBe('RangeError');
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // TC-004-003 + TC-004-004: HttpException — message-Normalisierung
+ // -------------------------------------------------------------------------
+ describe('TC-004-003 / TC-004-004: HttpException-Pfad — message-Normalisierung', () => {
+ it('TC-004-004: message wird zu Array gewrappt wenn es ein String ist', () => {
+ const { host, capture } = createMockHost();
+ // BadRequestException mit String-Message
+ filter.catch(new BadRequestException('single string message'), host);
+ expect(Array.isArray(capture.body?.message)).toBe(true);
+ expect(capture.body?.message).toEqual(['single string message']);
+ });
+
+ it('TC-004-003: message bleibt Array wenn es bereits ein Array ist (passthrough)', () => {
+ const { host, capture } = createMockHost();
+ // NestJS ValidationPipe erzeugt BadRequestException mit Array
+ const exception = new BadRequestException({
+ message: ['field1 must be a string', 'field2 should not be empty'],
+ error: 'Bad Request',
+ });
+ filter.catch(exception, host);
+ expect(capture.body?.message).toEqual([
+ 'field1 must be a string',
+ 'field2 should not be empty',
+ ]);
+ // KEIN doppeltes Wrapping: nicht [['field1...', 'field2...']]
+ expect(Array.isArray((capture.body?.message as unknown[])?.[0])).toBe(false);
+ });
+
+ it('statusCode stimmt mit HTTP-Status überein (404)', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new NotFoundException('not found'), host);
+ expect(capture.statusCode).toBe(404);
+ expect(capture.body?.statusCode).toBe(404);
+ });
+
+ it('statusCode stimmt mit HTTP-Status überein (400)', () => {
+ const { host, capture } = createMockHost();
+ filter.catch(new BadRequestException('bad'), host);
+ expect(capture.statusCode).toBe(400);
+ expect(capture.body?.statusCode).toBe(400);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // console.error wird aufgerufen (belegt Zeile 22 in errors.filter.ts)
+ // -------------------------------------------------------------------------
+ describe('Logging-Verhalten', () => {
+ it('ruft console.error mit dem Fehler auf', () => {
+ const { host } = createMockHost();
+ const error = new Error('logged error');
+ filter.catch(error, host);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(error);
+ });
+ });
+});
diff --git a/apps/server/src/common/filters/errors.filter.ts b/apps/server/src/common/filters/errors.filter.ts
new file mode 100644
index 0000000..4614eea
--- /dev/null
+++ b/apps/server/src/common/filters/errors.filter.ts
@@ -0,0 +1,37 @@
+import {
+ ExceptionFilter,
+ Catch,
+ HttpException,
+ ArgumentsHost,
+ HttpStatus,
+} from '@nestjs/common';
+import { ReE } from '../res.model';
+
+@Catch()
+export class ErrorFilter implements ExceptionFilter {
+ catch(error: Error, host: ArgumentsHost) {
+ const ctx = host.switchToHttp();
+ const response = ctx.getResponse();
+ const request = ctx.getRequest();
+ const statusCode: number =
+ error instanceof HttpException
+ ? error.getStatus()
+ : HttpStatus.INTERNAL_SERVER_ERROR;
+
+ let message;
+ console.error(error);
+ if (error instanceof HttpException) {
+ const errorMessage = (error.getResponse() as any)?.message;
+ message = Array.isArray(errorMessage) ? errorMessage : [errorMessage];
+ } else {
+ message = [error.message];
+ }
+
+ const name =
+ error instanceof HttpException
+ ? (error.getResponse() as any)?.error || error.name
+ : error.name;
+
+ response.status(statusCode).json(ReE.FromData(statusCode, name, message));
+ }
+}
diff --git a/apps/server/src/common/pdfAnnotation/adler.svg b/apps/server/src/common/pdfAnnotation/adler.svg
new file mode 100644
index 0000000..f32b05e
--- /dev/null
+++ b/apps/server/src/common/pdfAnnotation/adler.svg
@@ -0,0 +1,242 @@
+
+
diff --git a/apps/server/src/common/pdfAnnotation/fonts/ARIALN.TTF b/apps/server/src/common/pdfAnnotation/fonts/ARIALN.TTF
new file mode 100644
index 0000000..94907a3
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/ARIALN.TTF differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/ARIALNB.TTF b/apps/server/src/common/pdfAnnotation/fonts/ARIALNB.TTF
new file mode 100644
index 0000000..62437f0
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/ARIALNB.TTF differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/ARIALNBI.TTF b/apps/server/src/common/pdfAnnotation/fonts/ARIALNBI.TTF
new file mode 100644
index 0000000..d3f019a
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/ARIALNBI.TTF differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/ARIALNI.TTF b/apps/server/src/common/pdfAnnotation/fonts/ARIALNI.TTF
new file mode 100644
index 0000000..4acd468
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/ARIALNI.TTF differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/arial.ttf b/apps/server/src/common/pdfAnnotation/fonts/arial.ttf
new file mode 100644
index 0000000..8682d94
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/arial.ttf differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/arialbd.ttf b/apps/server/src/common/pdfAnnotation/fonts/arialbd.ttf
new file mode 100644
index 0000000..a6037e6
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/arialbd.ttf differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/arialbi.ttf b/apps/server/src/common/pdfAnnotation/fonts/arialbi.ttf
new file mode 100644
index 0000000..6a1fa0f
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/arialbi.ttf differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/ariali.ttf b/apps/server/src/common/pdfAnnotation/fonts/ariali.ttf
new file mode 100644
index 0000000..3801997
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/ariali.ttf differ
diff --git a/apps/server/src/common/pdfAnnotation/fonts/ariblk.ttf b/apps/server/src/common/pdfAnnotation/fonts/ariblk.ttf
new file mode 100644
index 0000000..e7ae345
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/fonts/ariblk.ttf differ
diff --git a/apps/server/src/common/pdfAnnotation/gdfort.jpg b/apps/server/src/common/pdfAnnotation/gdfort.jpg
new file mode 100644
index 0000000..839a7bd
Binary files /dev/null and b/apps/server/src/common/pdfAnnotation/gdfort.jpg differ
diff --git a/apps/server/src/common/pdfAnnotation/logo.svg b/apps/server/src/common/pdfAnnotation/logo.svg
new file mode 100644
index 0000000..976868c
--- /dev/null
+++ b/apps/server/src/common/pdfAnnotation/logo.svg
@@ -0,0 +1,53 @@
+
+
diff --git a/apps/server/src/common/res.model.ts b/apps/server/src/common/res.model.ts
new file mode 100644
index 0000000..0ed665f
--- /dev/null
+++ b/apps/server/src/common/res.model.ts
@@ -0,0 +1,41 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Expose, Transform } from 'class-transformer';
+
+export class ReturnHelper {
+ @ApiProperty({ example: true, description: 'success indicator' })
+ @Expose()
+ public success = true;
+}
+
+export class ReS extends ReturnHelper {
+ static FromData(arg0: T): ReS {
+ const ret = new ReS();
+ ret.success = true;
+ ret.data = arg0;
+ return ret;
+ }
+
+ @Expose()
+ @Transform(({ value }) => value) // preserves null when ClassSerializerInterceptor + excludeExtraneousValues:true is active
+ public data: T;
+}
+
+export class ReE extends ReturnHelper {
+ static FromData(statusCode: number, error: string, message: string[]): ReE {
+ const ret = new ReE();
+ ret.success = false;
+ ret.message = message;
+ ret.error = error;
+ ret.statusCode = statusCode;
+ return ret;
+ }
+ @ApiProperty({ description: 'statusCode' })
+ @Expose()
+ public statusCode: number;
+ @ApiProperty({ description: 'detail message' })
+ @Expose()
+ public message: string[];
+ @ApiProperty({ description: 'error description' })
+ @Expose()
+ public error: string;
+}
diff --git a/apps/server/src/common/serializers/model.serializer.ts b/apps/server/src/common/serializers/model.serializer.ts
new file mode 100644
index 0000000..e87f410
--- /dev/null
+++ b/apps/server/src/common/serializers/model.serializer.ts
@@ -0,0 +1,13 @@
+import { Expose } from 'class-transformer';
+
+export class ModelEntity {
+ @Expose({ groups: ['default'] })
+ id: number;
+ [key: string]: any;
+
+ constructor(id?: number) {
+ if (id) {
+ this.id = id;
+ }
+ }
+}
diff --git a/apps/server/src/common/services/pdfmaker.service.ts b/apps/server/src/common/services/pdfmaker.service.ts
new file mode 100644
index 0000000..4a70b3e
--- /dev/null
+++ b/apps/server/src/common/services/pdfmaker.service.ts
@@ -0,0 +1,636 @@
+import { Injectable } from '@nestjs/common';
+import { mkdir, writeFile } from 'fs/promises';
+import { existsSync, readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import moment from 'moment';
+import { PDFDocument } from 'pdf-lib';
+const PdfPrinter = require('pdfmake');
+import { Content, ContentTable, TDocumentDefinitions } from 'pdfmake/interfaces';
+import { AnnotationEntity } from '../../models/bills/serializers/annotation.serializer';
+import { BillEntity } from '../../models/bills/serializers/bill.serializer';
+import { OrderEntryEntity } from '../../models/bills/serializers/order-entry.serializer';
+import { SlipsheetEntity } from '../../models/bills/serializers/slipsheet.serializer';
+import { CompanySettingsService } from '../../models/settings/company-settings.service';
+import {
+ CompanySettingsEntity,
+} from '../../models/settings/serializers/company-settings.serializer';
+import { LetterheadMode } from '../../models/settings/interfaces/company-settings.interface';
+
+const FALLBACK_LOGO = join(__dirname, '..', 'pdfAnnotation', 'logo.svg');
+const FALLBACK_BADGE1 = join(__dirname, '..', 'pdfAnnotation', 'adler.svg');
+const FALLBACK_BADGE2 = join(__dirname, '..', 'pdfAnnotation', 'gdfort.jpg');
+
+@Injectable()
+export class PdfMakerService {
+ private readonly fonts = {
+ Courier: {
+ normal: 'Courier',
+ bold: 'Courier-Bold',
+ italics: 'Courier-Oblique',
+ bolditalics: 'Courier-BoldOblique',
+ },
+ Helvetica: {
+ normal: 'Helvetica',
+ bold: 'Helvetica-Bold',
+ italics: 'Helvetica-Oblique',
+ bolditalics: 'Helvetica-BoldOblique',
+ },
+ Times: {
+ normal: 'Times-Roman',
+ bold: 'Times-Bold',
+ italics: 'Times-Italic',
+ bolditalics: 'Times-BoldItalic',
+ },
+ Arial: {
+ normal: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'arial.ttf'),
+ bold: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'arialbd.ttf'),
+ italics: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'ariali.ttf'),
+ bolditalics: join(__dirname, '..', 'pdfAnnotation', 'fonts', 'arialbi.ttf'),
+ },
+ Symbol: { normal: 'Symbol' },
+ ZapfDingbats: { normal: 'ZapfDingbats' },
+ };
+
+ private readonly printer;
+
+ constructor(private readonly settingsService: CompanySettingsService) {
+ this.printer = new PdfPrinter(this.fonts);
+ }
+
+ public async generateDeliverySlip(slip: SlipsheetEntity): Promise {
+ const settings = await this.getSettings();
+ const docDefinition = this.buildDocDefinition(settings);
+ const content: Array = [];
+
+ content.push(this.buildHead(slip, slip.printDate, settings));
+ content.push({ text: 'Lieferschein #' + slip.slipsheetnumber, style: 'header' });
+
+ const annotation = this.buildSlipAnnotations(slip);
+ if (annotation) {
+ content.push({ stack: annotation });
+ }
+
+ content.push(this.buildOrdersDelivery(slip));
+ docDefinition.content = content;
+
+ return this.renderPdfBuffer(docDefinition, settings);
+ }
+
+ public async generateBill(bill: BillEntity): Promise {
+ const settings = await this.getSettings();
+ const docDefinition = this.buildDocDefinition(settings);
+ const content: Array = [];
+
+ content.push(this.buildHead(bill.slipsheets[0], bill.billDate, settings));
+ content.push({ text: 'Rechnung #' + bill.billNumber, style: 'header' });
+ content.push(this.buildOrders(bill, settings));
+ docDefinition.content = content;
+
+ return this.renderPdfBuffer(docDefinition, settings);
+ }
+
+ public async savePDFToFileSystem(pdf: Buffer, filepath: string): Promise {
+ await mkdir(dirname(filepath), { recursive: true });
+ await writeFile(filepath, pdf);
+ return filepath;
+ }
+
+ private async renderPdfBuffer(
+ docDefinition: TDocumentDefinitions,
+ settings: CompanySettingsEntity | null,
+ ): Promise {
+ const doc = this.printer.createPdfKitDocument(docDefinition);
+ const contentBuffer = await this.toBuffer(doc);
+
+ if (this.getLetterheadMode(settings) !== 'template_pdf') {
+ return contentBuffer;
+ }
+
+ return this.mergeWithTemplate(contentBuffer, settings);
+ }
+
+ private toBuffer(doc: PDFKit.PDFDocument): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+
+ doc.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+ doc.end();
+ });
+ }
+
+ private async mergeWithTemplate(
+ contentBuffer: Buffer,
+ settings: CompanySettingsEntity | null,
+ ): Promise {
+ const templatePath = this.getExistingPath(settings?.templatePdfPath);
+ if (!templatePath) {
+ return contentBuffer;
+ }
+
+ try {
+ const contentPdf = await PDFDocument.load(contentBuffer);
+ const templateBytes = readFileSync(templatePath);
+ const templatePdf = await PDFDocument.load(templateBytes);
+ const mergedPdf = await PDFDocument.create();
+ const templatePageCount = templatePdf.getPageCount();
+
+ for (let pageIndex = 0; pageIndex < contentPdf.getPageCount(); pageIndex += 1) {
+ const contentPage = contentPdf.getPage(pageIndex);
+ const { width, height } = contentPage.getSize();
+ const targetPage = mergedPdf.addPage([width, height]);
+ const templatePageIndex = templatePageCount === 0
+ ? -1
+ : Math.min(pageIndex, templatePageCount - 1);
+
+ if (templatePageIndex >= 0) {
+ const [embeddedTemplatePage] = await mergedPdf.embedPdf(templateBytes, [templatePageIndex]);
+ targetPage.drawPage(embeddedTemplatePage, {
+ x: 0,
+ y: 0,
+ width,
+ height,
+ });
+ }
+
+ const [embeddedContentPage] = await mergedPdf.embedPdf(contentBuffer, [pageIndex]);
+ targetPage.drawPage(embeddedContentPage, {
+ x: 0,
+ y: 0,
+ width,
+ height,
+ });
+ }
+
+ return Buffer.from(await mergedPdf.save());
+ } catch (error) {
+ console.error('PdfMakerService.mergeWithTemplate:', error);
+ return contentBuffer;
+ }
+ }
+
+ private async getSettings(): Promise {
+ try {
+ return await this.settingsService.get();
+ } catch {
+ return null;
+ }
+ }
+
+ private getLetterheadMode(settings: CompanySettingsEntity | null): LetterheadMode {
+ return settings?.letterheadMode ?? 'generated';
+ }
+
+ private shouldRenderGeneratedLetterhead(settings: CompanySettingsEntity | null): boolean {
+ return this.getLetterheadMode(settings) === 'generated';
+ }
+
+ private getExistingPath(filePath: string | null | undefined): string | null {
+ if (!filePath) {
+ return null;
+ }
+
+ const candidates = [filePath, join(process.cwd(), filePath)];
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ private isSvg(path: string): boolean {
+ return path.toLowerCase().endsWith('.svg');
+ }
+
+ private buildLogoContent(settings: CompanySettingsEntity | null): Content {
+ const usePath = this.getExistingPath(settings?.logoPath) || FALLBACK_LOGO;
+
+ if (this.isSvg(usePath)) {
+ return {
+ svg: readFileSync(usePath).toString(),
+ fit: [170, 170],
+ margin: [60, 30, 0, 0],
+ };
+ }
+
+ return {
+ image: usePath,
+ fit: [170, 170],
+ margin: [60, 30, 0, 0],
+ };
+ }
+
+ private buildFooterBadge1(settings: CompanySettingsEntity | null): Content {
+ const usePath = this.getExistingPath(settings?.badge1Path) || FALLBACK_BADGE1;
+ const base: any = {
+ alignment: 'right',
+ width: 40,
+ margin: [0, 0, 15, 0],
+ color: '#e9582a',
+ };
+
+ return this.isSvg(usePath)
+ ? { ...base, svg: readFileSync(usePath).toString() }
+ : { ...base, image: usePath };
+ }
+
+ private buildFooterBadge2(settings: CompanySettingsEntity | null): Content {
+ const usePath = this.getExistingPath(settings?.badge2Path) || FALLBACK_BADGE2;
+
+ return this.isSvg(usePath)
+ ? {
+ svg: readFileSync(usePath).toString(),
+ width: 40,
+ margin: [25, 0, 0, 0],
+ color: '#e9582a',
+ }
+ : { image: usePath, width: 40, margin: [25, 0, 0, 0], color: '#e9582a' };
+ }
+
+ private buildFooterText(settings: CompanySettingsEntity | null): string {
+ const paymentText =
+ settings?.paymentFooterText ??
+ `Zahlung innerhalb von ${settings?.paymentTermDays ?? 14} Tagen netto Kassa`;
+
+ const bankLines = (settings?.bankAccounts ?? [])
+ .map((bank) => `${bank.name} | IBAN: ${bank.iban} | BIC: ${bank.bic}`)
+ .join(' | ');
+
+ const city = settings?.issueCity ?? 'Landeck';
+ const base = `Zahlbar und klagbar in ${city}`;
+
+ return [paymentText, base, bankLines].filter(Boolean).join(' | ');
+ }
+
+ private buildHeaderStack(settings: CompanySettingsEntity | null): Content[] {
+ const lines: Content[] = [];
+
+ if (settings?.street) {
+ lines.push({ text: settings.street, style: 'header' });
+ }
+ if (settings?.zip || settings?.city) {
+ lines.push({
+ text: `${settings?.zip ?? ''} ${settings?.city ?? ''}`.trim(),
+ style: 'header',
+ });
+ }
+ if (settings?.phone) {
+ lines.push({ text: ' ', style: 'subheader' });
+ lines.push({ text: `Mobile ${settings.phone}`, style: 'subheader' });
+ }
+ if (settings?.email) {
+ lines.push({ text: `E-Mail: ${settings.email}`, style: 'subheader' });
+ }
+ if (settings?.website) {
+ lines.push({ text: settings.website, style: 'subheader' });
+ }
+ if (settings?.firmenbuchnummer) {
+ lines.push({ text: settings.firmenbuchnummer, style: 'subheader' });
+ }
+
+ if (lines.length === 0) {
+ lines.push(
+ { text: 'Fliesserau 384 b', style: 'header' },
+ { text: '6500 Landeck', style: 'header' },
+ { text: ' ', style: 'subheader' },
+ { text: 'Mobile +43 699 10 63 63 45', style: 'subheader' },
+ { text: 'E-Mail: office@holz-abler.com', style: 'subheader' },
+ { text: 'www.holz-abler.com', style: 'subheader' },
+ { text: 'FN.: 303902s, ATU63848368', style: 'subheader' },
+ );
+ }
+
+ return lines;
+ }
+
+ private buildDocDefinition(settings: CompanySettingsEntity | null): TDocumentDefinitions {
+ const self = this;
+ const renderGeneratedLetterhead = this.shouldRenderGeneratedLetterhead(settings);
+
+ return {
+ pageOrientation: 'portrait',
+ pageMargins: [60, 150, 60, 100],
+ header: renderGeneratedLetterhead
+ ? (() => [
+ {
+ columns: [
+ self.buildLogoContent(settings),
+ {
+ alignment: 'right',
+ margin: [0, 25, 70, 0],
+ stack: self.buildHeaderStack(settings),
+ },
+ ],
+ },
+ {
+ canvas: [{ type: 'line', x1: 50, y1: 5, x2: 595 - 50, y2: 5, lineWidth: 1 }],
+ },
+ ]) as any
+ : undefined,
+ footer: renderGeneratedLetterhead
+ ? (() => [
+ {
+ canvas: [{ type: 'line', x1: 50, y1: 0, x2: 595 - 50, y2: 0, lineWidth: 1 }],
+ margin: [0, 30, 0, 5],
+ },
+ {
+ table: {
+ widths: [120, '*', 120],
+ body: [[
+ self.buildFooterBadge1(settings),
+ { text: self.buildFooterText(settings), style: 'footerText' },
+ self.buildFooterBadge2(settings),
+ ]],
+ },
+ layout: 'noBorders',
+ },
+ ]) as any
+ : undefined,
+ content: [],
+ styles: this.buildStyles(),
+ defaultStyle: { font: 'Arial', fontSize: 12 },
+ };
+ }
+
+ private buildStyles(): any {
+ return {
+ header: { fontSize: 12, color: 'black' },
+ subheader: { fontSize: 9, color: 'black' },
+ footerText: {
+ fontSize: 8,
+ margin: [0, 10, 0, 0],
+ alignment: 'center' as const,
+ color: 'black',
+ },
+ tableExample: { margin: [0, 5, 0, 15] },
+ tableHeader: { bold: true, fontSize: 7, color: '#e9582a' },
+ tableSum: { color: 'black', bold: true },
+ tableSumHeader: { bold: true, fontSize: 10, color: 'black' },
+ tableCell: { fontSize: 7 },
+ slipCell: { color: '#e9582a' },
+ slipAnnotation: { fontSize: 9, color: 'black' },
+ };
+ }
+
+ private buildHead(
+ slip: SlipsheetEntity,
+ date: Date | undefined,
+ settings: CompanySettingsEntity | null,
+ ): Content {
+ const city = settings?.issueCity ?? 'Landeck';
+
+ return {
+ alignment: 'justify',
+ margin: [0, 10, 10, 40],
+ columns: [
+ {
+ width: 'auto',
+ text: [
+ 'An\n',
+ (slip.customer.companyName || '') + '\n',
+ slip.customer.lastName + ' ' + slip.customer.firstName + '\n',
+ slip.customer.address + '\n',
+ slip.customer.postcode + ' ' + slip.customer.country + '\n',
+ ],
+ },
+ {
+ alignment: 'right',
+ margin: [0, 60, 0, 0],
+ text:
+ `${city}, am ${moment(date ?? new Date()).format('DD.MM.YYYY')}` +
+ (slip.customer.customerNumber ? '\nKunde: ' + slip.customer.customerNumber : '\n') +
+ (slip.customer.uid ? '\nIhre UID: ' + slip.customer.uid : '\nIhre UID:\n'),
+ },
+ ],
+ };
+ }
+
+ private buildSlipAnnotations(slip: SlipsheetEntity): Content[] | null {
+ const items: Content[] = [];
+ slip?.annotations?.forEach((element: AnnotationEntity) => {
+ items.push({ text: element.text, style: 'slipAnnotation' });
+ });
+
+ return items.length === 0 ? null : items;
+ }
+
+ private buildOrders(bill: BillEntity, settings: CompanySettingsEntity | null): Content {
+ const vatRate = settings?.vatRate ?? 20;
+ const isDiscount = bill.slipsheets.some((cart) =>
+ cart.orderEntries.some((order) => order.articleGroupRabatt && order.articleGroupRabatt !== 0),
+ );
+ const isDiscountSpecial = bill.slipsheets.some((cart) =>
+ cart.orderEntries.some((order) => order.customerRabatt && order.customerRabatt !== 0),
+ );
+
+ const table: any = {
+ style: 'tableExample',
+ table: {
+ headerRows: 1,
+ widths: [60, 'auto', '*', 'auto', 'auto', 'auto', 'auto', 'auto'],
+ body: [[
+ { text: 'Pos', style: 'tableHeader', margin: [0, 0, 5, 0] },
+ { text: 'Art.Num.', style: 'tableHeader' },
+ { text: 'Artikel', style: 'tableHeader' },
+ { text: 'Menge', style: 'tableHeader' },
+ { text: 'Preis', style: 'tableHeader' },
+ { text: isDiscount ? 'Rabatt' : '', style: 'tableHeader' },
+ { text: isDiscountSpecial ? 'Sonder\nRabatt' : '', style: 'tableHeader' },
+ { text: 'Gesamt', style: 'tableHeader' },
+ ]],
+ },
+ layout: this.getTableDefaultLayout(),
+ };
+
+ let sum = 0;
+
+ for (const slip of bill.slipsheets) {
+ table.table.body.push([
+ '',
+ {
+ text:
+ 'Lieferschein von ' +
+ moment(slip.createdAt).format('DD.MM.YYYY') +
+ ' L' +
+ slip.slipsheetnumber,
+ colSpan: 6,
+ style: 'slipCell',
+ },
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ]);
+
+ const annotation = this.buildSlipAnnotations(slip);
+ if (annotation) {
+ table.table.body.push(['', { stack: annotation, colSpan: 6 }, '', '', '', '', '', '']);
+ }
+
+ for (let i = 0; i < slip.orderEntries.length; i += 1) {
+ const el: OrderEntryEntity = slip.orderEntries[i];
+ const amount = el.amountCounted || el.amount;
+ const discount = el.articleGroupRabatt ?? 0;
+ const discountSpecial = el.customerRabatt ?? 0;
+ const total =
+ (amount * el.price * (100 - discount)) / 100 * ((100 - discountSpecial) / 100);
+
+ sum += total;
+
+ table.table.body.push([
+ { text: i + 1, style: 'tableCell' },
+ { text: el.article?.artNumber ?? '', style: 'tableCell' },
+ { text: el.text, style: 'tableCell' },
+ { text: amount, style: 'tableCell', alignment: 'right' },
+ { text: 'EUR ' + el.price.toFixed(2), style: 'tableCell', alignment: 'right' },
+ {
+ text: isDiscount ? discount.toFixed(0) + '%' : '',
+ style: 'tableCell',
+ alignment: 'right',
+ },
+ {
+ text: isDiscountSpecial ? discountSpecial.toFixed(0) + '%' : '',
+ style: 'tableCell',
+ alignment: 'right',
+ },
+ { text: 'EUR ' + total.toFixed(2), style: 'tableCell', alignment: 'right' },
+ ]);
+ }
+ }
+
+ const vatLabel = `${vatRate}% MwSt.`;
+ const empty = { text: '', border: [0, 0, 0, 0] };
+
+ table.table.body.push(
+ [
+ empty,
+ empty,
+ empty,
+ empty,
+ { text: 'Summe', colSpan: 2, style: 'tableCell', alignment: 'right' },
+ '',
+ '',
+ { text: 'EUR ' + sum.toFixed(2), style: 'tableCell', alignment: 'right' },
+ ],
+ [
+ empty,
+ empty,
+ empty,
+ empty,
+ { text: vatLabel, colSpan: 2, style: 'tableCell', alignment: 'right' },
+ '',
+ '',
+ { text: 'EUR ' + ((sum * vatRate) / 100).toFixed(2), style: 'tableCell', alignment: 'right' },
+ ],
+ [
+ empty,
+ empty,
+ empty,
+ empty,
+ { text: 'Gesamt', colSpan: 2, style: 'tableSumHeader', alignment: 'right' },
+ '',
+ '',
+ {
+ text: 'EUR ' + (sum * (1 + vatRate / 100)).toFixed(2),
+ style: 'tableSumHeader',
+ alignment: 'right',
+ },
+ ],
+ );
+
+ return table;
+ }
+
+ private buildOrdersDelivery(slip: SlipsheetEntity): Content {
+ const table: ContentTable = {
+ style: 'tableExample',
+ table: {
+ headerRows: 1,
+ widths: [100, '*', '*', 'auto'],
+ body: [[
+ { text: 'Pos', style: 'tableHeader', margin: [0, 0, 5, 0] },
+ { text: 'Artikel', style: 'tableHeader' },
+ { text: 'Typ', style: 'tableHeader' },
+ { text: 'Menge', style: 'tableHeader' },
+ ]],
+ },
+ layout: this.getSlipTableLayout(),
+ };
+
+ for (let i = 0; i < slip.orderEntries.length; i += 1) {
+ const el = slip.orderEntries[i];
+ table.table.body.push([
+ { text: i + 1, style: 'tableCell' },
+ { text: el.text, style: 'tableCell' },
+ { text: el.article?.artNumber ?? '', style: 'tableCell' },
+ { text: el.amount, style: 'tableCell' },
+ ]);
+ }
+
+ table.table.body.push(['', '', '', '']);
+ return table;
+ }
+
+ private getTableDefaultLayout() {
+ return {
+ hLineWidth: (i: number, node: any) => {
+ if (i === 0) {
+ return 0;
+ }
+ if (i === node.table.body.length) {
+ return 2;
+ }
+ if (i === node.table.body.length - 1) {
+ return 1;
+ }
+ return 1;
+ },
+ vLineWidth: () => 0,
+ hLineColor: (i: number, node: any) => {
+ if (i === node.table.body.length - 3) {
+ return 'black';
+ }
+ return i === 1 || i === node.table.body.length - 1 || i === node.table.body.length
+ ? 'black'
+ : '#aaa';
+ },
+ paddingLeft: (i: number) => (i <= 1 ? 0 : 5),
+ paddingRight: (i: number, node: any) => (i === node.table.widths.length - 1 ? 0 : 5),
+ paddingTop: () => 4,
+ paddingBottom: () => 4,
+ fillColor: () => null,
+ };
+ }
+
+ private getSlipTableLayout() {
+ return {
+ hLineWidth: (i: number, node: any) => {
+ if (i === 0) {
+ return 0;
+ }
+ if (i === node.table.body.length) {
+ return 2;
+ }
+ if (i === node.table.body.length - 1) {
+ return 1;
+ }
+ return 1;
+ },
+ vLineWidth: () => 0,
+ hLineColor: (i: number, node: any) =>
+ i === 1 || i === node.table.body.length - 1 || i === node.table.body.length
+ ? 'black'
+ : '#aaa',
+ paddingLeft: (i: number) => (i <= 1 ? 0 : 5),
+ paddingRight: (i: number, node: any) => (i === node.table.widths.length - 1 ? 0 : 5),
+ paddingTop: () => 4,
+ paddingBottom: () => 4,
+ fillColor: () => null,
+ };
+ }
+}
diff --git a/apps/server/src/common/services/printer.service.ts b/apps/server/src/common/services/printer.service.ts
new file mode 100644
index 0000000..c0ab1ef
--- /dev/null
+++ b/apps/server/src/common/services/printer.service.ts
@@ -0,0 +1,21 @@
+import { Injectable, InternalServerErrorException } from '@nestjs/common';
+import { print } from 'pdf-to-printer';
+import { AppConfigService } from '../../config/app/config.service';
+
+@Injectable()
+export class PrinterService {
+
+ constructor(private appConfigService: AppConfigService) {}
+ async print(path: string): Promise {
+ const options = {
+ printer: this.appConfigService.printer
+ };
+ try {
+ await print(path, options);
+ return true;
+ } catch (error) {
+ throw new InternalServerErrorException('Drucken fehlgeschlagen');
+ }
+ return false;
+ }
+}
diff --git a/apps/server/src/common/shared.module.ts b/apps/server/src/common/shared.module.ts
new file mode 100644
index 0000000..305ba7f
--- /dev/null
+++ b/apps/server/src/common/shared.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AppConfigModule } from '../config/app/config.module';
+import { CompanySettingsModule } from '../models/settings/company-settings.module';
+import { PdfMakerService } from './services/pdfmaker.service';
+import { PrinterService } from './services/printer.service';
+
+@Module({
+ imports: [AppConfigModule, CompanySettingsModule],
+ providers: [PdfMakerService, PrinterService],
+ exports: [PdfMakerService, PrinterService],
+})
+export class SharedModule {}
diff --git a/apps/server/src/config/app/config.module.ts b/apps/server/src/config/app/config.module.ts
new file mode 100644
index 0000000..e6e75d2
--- /dev/null
+++ b/apps/server/src/config/app/config.module.ts
@@ -0,0 +1,18 @@
+import { Module } from '@nestjs/common';
+import configuration from './configuration';
+import { AppConfigService } from './config.service';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { join } from 'path';
+
+const ENV = process.env.NODE_ENV;
+@Module({
+ imports: [
+ ConfigModule.forRoot({
+ load: [configuration],
+ envFilePath: !ENV ? '.env' : `.env.${ENV}`,
+ }),
+ ],
+ providers: [ConfigService, AppConfigService],
+ exports: [ConfigService, AppConfigService],
+})
+export class AppConfigModule {}
diff --git a/apps/server/src/config/app/config.service.ts b/apps/server/src/config/app/config.service.ts
new file mode 100644
index 0000000..5ff325f
--- /dev/null
+++ b/apps/server/src/config/app/config.service.ts
@@ -0,0 +1,38 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class AppConfigService {
+ constructor(private configService: ConfigService) {}
+
+ get name(): string {
+ return this.configService.get('app.name');
+ }
+ get env(): string {
+ return this.configService.get('app.env');
+ }
+ get url(): string {
+ return this.configService.get('app.url', 'http://localhost:9000');
+ }
+ get port(): number {
+ return Number(this.configService.get('app.port', 3000));
+ }
+ get jwt_secret(): string {
+ return this.configService.get('app.jwt_secret', 'ACCESS_TOKEN_SECRET');
+ }
+ get jwt_expires(): string {
+ return this.configService.get('app.jwt_expires', '30d');
+ }
+
+ get pdf_slip_path(): string {
+ return this.configService.get('app.pdf_slip_path', './slips');
+ }
+
+ get pdf_bill_path(): string {
+ return this.configService.get('app.pdf_bill_path', './bills');
+ }
+
+ get printer(): string {
+ return this.configService.get('app.printer', 'Microsoft Print to PDF');
+ }
+}
diff --git a/apps/server/src/config/app/configuration.ts b/apps/server/src/config/app/configuration.ts
new file mode 100644
index 0000000..eedc42a
--- /dev/null
+++ b/apps/server/src/config/app/configuration.ts
@@ -0,0 +1,13 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('app', () => ({
+ env: process.env.APP_ENV,
+ name: process.env.APP_NAME,
+ url: process.env.APP_URL,
+ port: process.env.APP_PORT,
+ jwt_secret: process.env.APP_JWT_SECRET,
+ jwt_expires: process.env.APP_JWT_EXPIRES,
+ pdf_slip_path: process.env.APP_PDF_SLIP_PATH,
+ pdf_bill_path: process.env.APP_PDF_BILL_PATH,
+ printer: process.env.APP_PRINTER,
+}));
diff --git a/apps/server/src/config/database/sqlite/config.module.ts b/apps/server/src/config/database/sqlite/config.module.ts
new file mode 100644
index 0000000..1362c15
--- /dev/null
+++ b/apps/server/src/config/database/sqlite/config.module.ts
@@ -0,0 +1,17 @@
+import { Module } from '@nestjs/common';
+import configuration from './configuration';
+import { SqliteConfigService } from './config.service';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+
+const ENV = process.env.NODE_ENV;
+@Module({
+ imports: [
+ ConfigModule.forRoot({
+ load: [configuration],
+ envFilePath: !ENV ? '.env' : `.env.${ENV}`,
+ }),
+ ],
+ providers: [ConfigService, SqliteConfigService],
+ exports: [ConfigService, SqliteConfigService],
+})
+export class SqliteConfigModule {}
diff --git a/apps/server/src/config/database/sqlite/config.service.ts b/apps/server/src/config/database/sqlite/config.service.ts
new file mode 100644
index 0000000..9429b42
--- /dev/null
+++ b/apps/server/src/config/database/sqlite/config.service.ts
@@ -0,0 +1,20 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class SqliteConfigService {
+ constructor(private configService: ConfigService) {}
+
+ get path(): string {
+ return this.configService.get('sqlite.path', 'sim.db');
+ }
+ get migrationsRun(): boolean {
+ return JSON.parse(this.configService.get('sqlite.migrationsRun', 'false'));
+ }
+ get synchronizeRun(): boolean {
+ return JSON.parse(this.configService.get('sqlite.synchronizeRun', 'false'));
+ }
+ get entities(): string {
+ return this.configService.get('sqlite.entities', 'dist/**/*.entity.js');
+ }
+}
diff --git a/apps/server/src/config/database/sqlite/configuration.ts b/apps/server/src/config/database/sqlite/configuration.ts
new file mode 100644
index 0000000..f2d242a
--- /dev/null
+++ b/apps/server/src/config/database/sqlite/configuration.ts
@@ -0,0 +1,8 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('sqlite', () => ({
+ path: process.env.SQLITE_PATH,
+ migrationsRun: process.env.SQLITE_RUN_MIGRATION,
+ synchronizeRun: process.env.SQLITE_RUN_SYNCHRONIZE,
+ entities: process.env.SQLITE_ENTITIES,
+}));
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
new file mode 100644
index 0000000..7244f18
--- /dev/null
+++ b/apps/server/src/main.ts
@@ -0,0 +1,98 @@
+import 'reflect-metadata';
+
+import { NestFactory } from '@nestjs/core';
+import { NestExpressApplication } from '@nestjs/platform-express';
+import { AppModule } from './app.module';
+import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+import { ErrorFilter } from './common/filters/errors.filter';
+import { AppConfigService } from './config/app/config.service';
+import { INestApplication, ValidationPipe } from '@nestjs/common';
+import { join } from 'path';
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+
+ // Bug #8 fix: replace cors: true with proper CORS config
+ app.enableCors({
+ origin: process.env.CORS_ORIGIN?.split(',') ?? ['http://localhost:4200'],
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+ credentials: true,
+ });
+ app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
+
+ app.setGlobalPrefix('api/v1');
+ app.useGlobalFilters(new ErrorFilter());
+ app.useGlobalPipes(
+ new ValidationPipe({
+ whitelist: true,
+ transform: true,
+ }),
+ );
+ const appConfig: AppConfigService = app.get(AppConfigService);
+
+ bootstrapSwagger(app, appConfig);
+
+ await app.listen(appConfig.port);
+}
+
+function bootstrapSwagger(app: INestApplication, appConfig: AppConfigService) {
+ const config = new DocumentBuilder()
+ .setTitle('ematric')
+ .setDescription('The Adler API description')
+ .setVersion('1.0')
+ .addServer(appConfig.url + '/api/v1/', 'v1')
+ .addBearerAuth()
+ .build();
+
+ const document = SwaggerModule.createDocument(app, config, {
+ ignoreGlobalPrefix: true,
+ deepScanRoutes: true,
+ include: [AppModule],
+ });
+
+ SwaggerModule.setup('api/v1/doc', app, document, {
+ swaggerOptions: {
+ persistAuthorization: true,
+ },
+ customSiteTitle: 'API Docs',
+ });
+
+ bootstrapSwaggerUniversall(app, appConfig);
+}
+
+function bootstrapSwaggerUniversall(
+ app: INestApplication,
+ appConfig: AppConfigService,
+) {
+ const config = new DocumentBuilder()
+ .setTitle('Allgemein')
+ .setDescription('The Adler API Beschreibung Produktionsmanagement System')
+ .setVersion('1.0')
+ .addServer(appConfig.url + '/api/v1/', 'v1')
+ .addBearerAuth()
+ .setContact('ematric', 'www.ematric.com', 'ematric@ematric.com')
+ .build();
+
+ const document = SwaggerModule.createDocument(app, config, {
+ ignoreGlobalPrefix: true,
+ deepScanRoutes: false,
+ });
+
+ SwaggerModule.setup('api/v1/doc/univ', app, document, {
+ swaggerOptions: {
+ persistAuthorization: true,
+ },
+ explorer: true,
+ customSiteTitle: 'Allgemeine API Docs',
+ ...getSwaggerOptions(),
+ });
+}
+
+function getSwaggerOptions() {
+ return {
+ customCss: ".swagger-ui img { content:url('/api/assets/ematric.svg'); }",
+ customfavIcon: '/api/assets/favicon-96x96.ico',
+ };
+}
+
+bootstrap();
diff --git a/apps/server/src/models/article/article-group.repository.ts b/apps/server/src/models/article/article-group.repository.ts
new file mode 100644
index 0000000..16edd6e
--- /dev/null
+++ b/apps/server/src/models/article/article-group.repository.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
+import { ModelRepository } from '../model.repository';
+import { ArticleGroup } from './entities/article-group.entity';
+import { allArticleGroupForSerializing, ArticleGroupEntity, extendedArticleGroupForSerializing } from './serializers/article-group.serializer';
+
+@Injectable()
+export class ArticleGroupRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(ArticleGroup, dataSource.createEntityManager());
+ }
+
+ transform(model: ArticleGroup): ArticleGroupEntity {
+ const tranformOptions: ClassTransformOptions = {
+ groups: extendedArticleGroupForSerializing,
+ };
+ return plainToInstance(
+ ArticleGroupEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: ArticleGroup[]): ArticleGroupEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/article/article-group.service.ts b/apps/server/src/models/article/article-group.service.ts
new file mode 100644
index 0000000..3e4c483
--- /dev/null
+++ b/apps/server/src/models/article/article-group.service.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import { BaseService } from '../../common/base.service';
+import { ArticleGroupRepository } from './article-group.repository';
+import { ArticleGroup } from './entities/article-group.entity';
+import { ArticleGroupEntity } from './serializers/article-group.serializer';
+
+@Injectable()
+export class ArticleGroupService extends BaseService {
+ constructor(
+ private readonly articleGroupRepository: ArticleGroupRepository,
+ ) {
+ super(articleGroupRepository);
+ }
+}
diff --git a/apps/server/src/models/article/article.module.ts b/apps/server/src/models/article/article.module.ts
new file mode 100644
index 0000000..319b95f
--- /dev/null
+++ b/apps/server/src/models/article/article.module.ts
@@ -0,0 +1,34 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { SharedModule } from '../../common/shared.module';
+import { ArticleGroupRepository } from './article-group.repository';
+import { ArticleGroupService } from './article-group.service';
+import { ArticleRepository } from './article.repository';
+import { ArticleService } from './article.service';
+import { ArticleGroupController } from './controllers/article-group.controller';
+import { ArticleController } from './controllers/article.controller';
+import { CsvModule } from 'nest-csv-parser';
+import { InventoryRepository } from './inventory.repository';
+import { InventoryService } from './inventory.service';
+import { Article } from './entities/article.entity';
+import { ArticleGroup } from './entities/article-group.entity';
+import { Inventory } from './entities/inventory.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([Article, ArticleGroup, Inventory]),
+ SharedModule,
+ CsvModule,
+ ],
+ controllers: [ArticleController, ArticleGroupController],
+ providers: [
+ ArticleRepository,
+ ArticleGroupRepository,
+ InventoryRepository,
+ ArticleService,
+ ArticleGroupService,
+ InventoryService,
+ ],
+ exports: [ArticleService, ArticleGroupService, InventoryRepository],
+})
+export class ArticleModule {}
diff --git a/apps/server/src/models/article/article.repository.ts b/apps/server/src/models/article/article.repository.ts
new file mode 100644
index 0000000..6e853b8
--- /dev/null
+++ b/apps/server/src/models/article/article.repository.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
+import { ModelRepository } from '../model.repository';
+import { Article } from './entities/article.entity';
+import { allArticleForSerializing, ArticleEntity, extendedArticleForSerializing } from './serializers/article.serializer';
+
+@Injectable()
+export class ArticleRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Article, dataSource.createEntityManager());
+ }
+
+ transform(model: Article): ArticleEntity {
+ const tranformOptions: ClassTransformOptions = {
+ groups: extendedArticleForSerializing,
+ };
+ return plainToInstance(
+ ArticleEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Article[]): ArticleEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/article/article.service.ts b/apps/server/src/models/article/article.service.ts
new file mode 100644
index 0000000..c1f299f
--- /dev/null
+++ b/apps/server/src/models/article/article.service.ts
@@ -0,0 +1,289 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { CsvParser } from 'nest-csv-parser';
+import { Readable } from 'stream';
+import { BaseService } from '../../common/base.service';
+import { OrderEntry } from '../bills/entities/order-entry.entity';
+import { ArticleRepository } from './article.repository';
+import { ArticleCsvEntity } from './entities/article-import.csv-entity';
+import { Article } from './entities/article.entity';
+import { InventoryService } from './inventory.service';
+import { ArticleEntity } from './serializers/article.serializer';
+import { UpdateArticleDto } from './dto/update-article.dto';
+
+@Injectable()
+export class ArticleService extends BaseService {
+
+ constructor(
+ private readonly articleRepository: ArticleRepository,
+ private readonly csvParser: CsvParser,
+ private readonly inventoryService: InventoryService,
+ ) {
+ super(articleRepository);
+ }
+
+ async get(
+ id: number,
+ relations: string[] = [],
+ throwsException = true,
+ ): Promise {
+
+ let querybuilder = this.getQueryBuilder();
+
+ let value: ArticleEntity = await querybuilder
+ .where('article.id = :id', { id })
+ .getOne()
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+ if (entity) {
+ entity['stock'] = (entity.inventoryStock ?? 0) - (entity['totalAmount'] ?? 0);
+ }
+
+ return Promise.resolve(entity ? this.articleRepository.transform(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+
+ return value;
+ }
+
+
+ async getAll(
+ relations: string[] = [],
+ throwsException = true,
+ ): Promise {
+
+ let querybuilder = this.getQueryBuilder();
+
+ let value: ArticleEntity[] = await querybuilder
+ .getMany()
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+ entity.map((m) => (m['stock'] = (m.inventoryStock ?? 0) - (m['totalAmount'] ?? 0)));
+
+ return Promise.resolve(entity ? this.articleRepository.transformMany(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+
+ return value;
+ }
+
+ async getByCode(
+ code: string,
+ throwsException = true,
+ ): Promise {
+
+ let querybuilder = this.getQueryBuilder();
+
+ let value: ArticleEntity = await querybuilder
+ .where('article.code = :code', { code })
+ .getOne()
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+ if (entity) {
+ entity['stock'] = (entity.inventoryStock ?? 0) - (entity['totalAmount'] ?? 0);
+ }
+ return Promise.resolve(entity ? this.articleRepository.transform(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+
+ return value;
+ }
+
+ getQueryBuilder() {
+ return this.articleRepository
+ .createQueryBuilder('article')
+ .addSelect(
+ (subquery) =>
+ subquery
+ .select('COALESCE(SUM(orderEntry.amount), 0)')
+ .from(OrderEntry, 'orderEntry')
+ .where('orderEntry.articleId = article.id')
+ .andWhere('orderEntry.createdAt >= article.inventoryDate'),
+ 'article_totalAmount',
+ )
+ .leftJoinAndSelect('article.articleGroup', 'articlegroup');
+ }
+
+
+ async importCsv(preview: boolean, file: Express.Multer.File) {
+
+ const entities = await this.csvParser.parse(Readable.from(file.buffer.toString()), ArticleCsvEntity);
+ const articleUpdate = await this.checkCSV(entities.list);
+
+ if (preview) {
+ return articleUpdate;
+ } else {
+
+ const [cRet, cRetFailed] = await this.createArticlesFromImport(articleUpdate.newArticle);
+ const [uRet, uRetFailed] = await this.updateArticlesFromImport(articleUpdate.updateableArticle);
+
+ return [[...cRet, ...uRet], [...cRetFailed, ...uRetFailed]];
+ }
+ }
+
+ async updateArticlesFromImport(updateableArticle: ArticleCsvEntity[]) {
+ let successArticles = [];
+ let failedArticles = [];
+
+ for (let index = 0; index < updateableArticle.length; index++) {
+ const element = updateableArticle[index];
+ try {
+
+ let per = (+(element.pe.replace(',', '.')) || 1);
+ per = per === 0 ? 1 : per;
+ await this.articleRepository.update({ code: element.barcode }, {
+ name: element.artikelbez,
+ unit: element.unit,
+ price: this.ceilNumber(+(element.brutto.replace(',', '.')) / per),
+ netto: this.ceilNumber(+(element.netto.replace(',', '.')) / per),
+ type: element.bezeichnung2,
+ artNumber: element.verkaufsobjekt,
+ });
+ successArticles.push(element);
+
+ } catch (error) {
+ failedArticles.push(element);
+ }
+ }
+
+ return [successArticles, failedArticles];
+
+ }
+
+ ceilNumber(value) {
+ return Math.ceil(value * 100) / 100;
+ }
+
+ async createArticlesFromImport(newArticle: ArticleCsvEntity[]) {
+
+ let successArticles = [];
+ let failedArticles = [];
+
+ for (let index = 0; index < newArticle.length; index++) {
+ const element = newArticle[index];
+ let article = new Article();
+ article.code = element.barcode;
+ let per = (+(element.pe.replace(',', '.')) || 1);
+ per = per === 0 ? 1 : per;
+ try {
+
+ article.name = element.artikelbez;
+ article.unit = element.unit;
+ article.price = this.ceilNumber(+(element.brutto.replace(',', '.')) / per);
+ article.netto = this.ceilNumber(+(element.netto.replace(',', '.')) / per);
+ article.type = element.bezeichnung2;
+ article.artNumber = element.verkaufsobjekt;
+
+ await this.articleRepository.insert(article);
+ successArticles.push(element);
+
+ } catch (error) {
+ failedArticles.push(element);
+ }
+ }
+
+ return [successArticles, failedArticles];
+
+ }
+
+
+ async checkCSV(entries: ArticleCsvEntity[]) {
+
+ const articles = await this.getAll();
+ const newArticle = [];
+ const updateableArticle = [];
+
+ entries.forEach((element: ArticleCsvEntity) => {
+ let find = articles.find((a) => a.code === element.barcode);
+ if (find) {
+ updateableArticle.push(element);
+ } else {
+ newArticle.push(element);
+ }
+ });
+
+ let retValue = {
+ articleCount: updateableArticle.length + newArticle.length,
+ newArticle,
+ updateableArticle,
+ };
+
+ return retValue;
+ }
+
+
+ async update(id: number, inputs: UpdateArticleDto): Promise {
+ const { articleGroup, ...rest } = inputs;
+ // TypeORM's update() expects the FK column directly for ManyToOne relations.
+ // Passing { articleGroup: { id } } as a nested object is silently ignored in
+ // some TypeORM 0.3 versions. We pass it explicitly as a relation reference.
+ const updateData: any = { ...rest };
+ if (articleGroup?.id !== undefined) {
+ updateData.articleGroup = { id: articleGroup.id };
+ }
+ return this.articleRepository.updateEntity(id, updateData);
+ }
+
+ async makeInventory(article: ArticleEntity, newStock: number): Promise {
+
+ if (newStock === undefined || newStock < 0) {
+ throw new BadRequestException('newStock Parameter nicht angegeben oder kleiner 0');
+ }
+
+ if (!article.trackStock) {
+ throw new BadRequestException('Artikel Lagerstand wird nicht überwacht');
+ }
+
+ const articleStock = article.stock;
+
+ // Bug #5 fix: wrap inventory creation in try/catch so a failed inventory entry
+ // does not prevent the article stock from being updated
+ try {
+ await this.inventoryService.create(
+ {
+ amountNew: newStock,
+ diff: articleStock - newStock,
+ article: article,
+ }
+ );
+ } catch (error) {
+ // Inventory log entry failed — continue so the article stock is still updated
+ }
+
+ await this.articleRepository.updateEntity(article.id, {
+ inventoryStock: newStock,
+ inventoryDate: new Date(),
+ });
+
+ return this.getByCode(article.code);
+
+ }
+
+ async getByName(
+ name: string,
+ relations: string[] = [],
+ throwsException = false,
+ ): Promise {
+ return this.articleRepository
+ .findOne({
+ where: { name: name },
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(
+ entity ? this.articleRepository.transform(entity) : null,
+ );
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+}
diff --git a/apps/server/src/models/article/controllers/article-group.controller.ts b/apps/server/src/models/article/controllers/article-group.controller.ts
new file mode 100644
index 0000000..23bcc59
--- /dev/null
+++ b/apps/server/src/models/article/controllers/article-group.controller.ts
@@ -0,0 +1,78 @@
+import {
+ Get,
+ Post,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ Delete,
+ Patch,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { ApiReS } from '../../../common/decorators/apires.decorator';
+import { ArticleGroupService } from '../article-group.service';
+import { ArticleGroupEntity, extendedArticleGroupForSerializing } from '../serializers/article-group.serializer';
+import { CreateArticleGroupDto } from '../dto/create-article-group.dto';
+import { UpdateArticleGroupDto } from '../dto/update-article-group.dto';
+
+@ApiBearerAuth()
+@Controller('articlegroups')
+@ApiTags('article-group')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ groups: extendedArticleGroupForSerializing,
+})
+export class ArticleGroupController {
+ constructor(
+ private readonly articleGroupService: ArticleGroupService,
+ ) {
+ }
+
+ @Get('/:id')
+ @ApiOperation({
+ summary: 'Get specific article group',
+ description: 'Fetchs data of id',
+ })
+ async get(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.articleGroupService.get(id));
+ }
+
+ @Get()
+ @ApiOperation({
+ summary: 'Get all article groups',
+ description: 'Fetchs all data',
+ })
+ async getAll(): Promise> {
+ return ReS.FromData(await this.articleGroupService.getAll());
+ }
+
+ @Post()
+ @ApiOperation({
+ summary: 'Create new article group',
+ description: 'Create new article group',
+ })
+ async post(@Body() articleGroup: CreateArticleGroupDto): Promise> {
+ return ReS.FromData(await this.articleGroupService.create(articleGroup));
+ }
+
+ @Patch(':id')
+ @ApiOperation({
+ summary: 'Update article group',
+ description: 'Update article froup from ID',
+ })
+ async update(@Param('id') id: number, @Body() updateArticleGroupDto: UpdateArticleGroupDto) {
+ return ReS.FromData(await this.articleGroupService.update(id, updateArticleGroupDto));
+ }
+
+}
diff --git a/apps/server/src/models/article/controllers/article.controller.ts b/apps/server/src/models/article/controllers/article.controller.ts
new file mode 100644
index 0000000..c8ff248
--- /dev/null
+++ b/apps/server/src/models/article/controllers/article.controller.ts
@@ -0,0 +1,151 @@
+import {
+ Get,
+ Post,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ Delete,
+ Patch,
+ Query,
+ UploadedFile,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiBody,
+ ApiConsumes,
+ ApiExtraModels,
+ ApiOperation,
+ ApiQuery,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { ArticleService } from '../article.service';
+import { ArticleEntity, extendedArticleForSerializing } from '../serializers/article.serializer';
+import { CreateArticleDto } from '../dto/create-article.dto';
+import { UpdateArticleDto } from '../dto/update-article.dto';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { memoryStorage } from 'multer';
+import { UpdateInventoryDto } from '../dto/update-inventory.dto';
+
+
+@ApiBearerAuth()
+@Controller('articles')
+@ApiTags('articles')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ groups: extendedArticleForSerializing,
+})
+
+export class ArticleController {
+ constructor(
+ private readonly articleService: ArticleService,
+ ) {
+ }
+
+ @Get('/:id')
+ @ApiOperation({
+ summary: 'Get specific customer',
+ description: 'Fetchs data of id',
+ })
+ async get(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.articleService.get(id));
+ }
+
+ @Get()
+ @ApiOperation({
+ summary: 'Get all articles',
+ description: 'Fetchs all data',
+ })
+ async getAll(
+ @Query('code') code?: string,
+ @Query('id') id?: number,
+ ): Promise> {
+ if (code) {
+ return ReS.FromData([await this.articleService.getByCode(code)]);
+ } else if (id) {
+ return ReS.FromData([await this.articleService.get(id)]);
+ } else {
+ return ReS.FromData(await this.articleService.getAll());
+ }
+ }
+
+ @Post()
+ @ApiOperation({
+ summary: 'Create new Article',
+ description: 'Create new Article',
+ })
+ async post(@Body() article: CreateArticleDto): Promise> {
+ console.log(article);
+ return ReS.FromData(await this.articleService.create(article));
+ }
+
+ @Post('/:id/inventory')
+ @ApiOperation({
+ summary: 'Create article inventory',
+ description: 'Create article inventory',
+ })
+ async postInventory(
+ @Param('id') id: number,
+ @Body() newStock: UpdateInventoryDto): Promise> {
+ const article = await this.articleService.get(id);
+ return ReS.FromData(await this.articleService.makeInventory(article, newStock.newStock));
+ }
+
+ @Post('import')
+ @ApiOperation({
+ summary: 'Create new Article',
+ description: 'Create new Article',
+ })
+ @ApiConsumes('multipart/form-data')
+ @ApiQuery({ name: 'preview' })
+ @ApiBody({
+ schema: {
+ type: 'object',
+ properties: {
+ file: {
+ type: 'string',
+ format: 'binary',
+ },
+ },
+ },
+ })
+ @UseInterceptors(FileInterceptor('file', { storage: memoryStorage() }))
+ @SerializeOptions({
+ ignoreDecorators: true,
+ })
+
+ async importCvs(
+ @Query('preview') preview: boolean,
+ @UploadedFile() file: Express.Multer.File,
+ ): Promise {
+
+ return ReS.FromData(await this.articleService.importCsv(preview, file));
+
+ }
+
+ @Patch(':id')
+ @ApiOperation({
+ summary: 'Update Article',
+ description: 'Update Article from ID',
+ })
+ async update(@Param('id') id: number, @Body() updateArticleDto: UpdateArticleDto) {
+ return ReS.FromData(await this.articleService.update(id, updateArticleDto));
+ }
+
+ @Delete(':id')
+ @ApiOperation({
+ summary: 'Delete Article',
+ description: 'Delete article by id',
+ })
+ async delete(@Param('id') id: number): Promise> {
+ await this.articleService.delete(id, true);
+ return ReS.FromData(null);
+ }
+
+}
diff --git a/apps/server/src/models/article/dto/create-article-group.dto.ts b/apps/server/src/models/article/dto/create-article-group.dto.ts
new file mode 100644
index 0000000..115982a
--- /dev/null
+++ b/apps/server/src/models/article/dto/create-article-group.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty } from 'class-validator';
+
+export class CreateArticleGroupDto {
+ @ApiProperty({
+ example: 'Schrauben',
+ description: 'Name of group',
+ })
+ @IsNotEmpty()
+ name: string;
+}
diff --git a/apps/server/src/models/article/dto/create-article.dto.ts b/apps/server/src/models/article/dto/create-article.dto.ts
new file mode 100644
index 0000000..e5bae6f
--- /dev/null
+++ b/apps/server/src/models/article/dto/create-article.dto.ts
@@ -0,0 +1,85 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsNotEmpty, IsOptional, ValidateNested } from 'class-validator';
+import { IdDto } from '../../../common/dto/id.dto';
+
+export class CreateArticleDto {
+ @ApiProperty({
+ example: '555555',
+ description: 'Name',
+ })
+ @IsNotEmpty()
+ name: string;
+
+ @ApiProperty({
+ example: 'M555555',
+ description: 'Barcode',
+ })
+ @IsNotEmpty()
+ code: string;
+
+ @ApiProperty({
+ example: '4.55',
+ description: 'Price of one unit',
+ })
+ @IsNotEmpty()
+ price: number;
+
+ @ApiProperty({
+ example: 'Schraube',
+ description: 'Type',
+ })
+ @IsNotEmpty()
+ type: string;
+
+ @ApiProperty({
+ example: 'pc',
+ description: 'Unit of article',
+ })
+ @IsNotEmpty()
+ unit: string;
+
+ @ApiProperty({
+ example: '33234',
+ description: 'Article Number from Supplier',
+ })
+ @IsNotEmpty()
+ artNumber: string;
+
+ @ApiProperty({
+ type: IdDto,
+ })
+ @IsNotEmpty()
+ @ValidateNested()
+ @Type(() => IdDto)
+ articleGroup: IdDto;
+
+ @ApiProperty({ example: 'Schraubenlieferant GmbH', description: 'Lieferant' })
+ @IsOptional()
+ supplier: string;
+
+ @ApiProperty({ example: 'Hochfeste Schraube M8', description: 'Beschreibung' })
+ @IsOptional()
+ description: string;
+
+ @ApiProperty({
+ example: 'true',
+ description: 'Do not sum up position',
+ })
+ @IsOptional()
+ singlePos: boolean;
+
+ @ApiProperty({
+ example: 'true',
+ description: 'Do not track the stock',
+ })
+ @IsOptional()
+ trackStock: boolean;
+
+ @ApiProperty({
+ example: 'true',
+ description: 'no general Discount',
+ })
+ @IsOptional()
+ noDiscount: boolean;
+}
diff --git a/apps/server/src/models/article/dto/update-article-group.dto.ts b/apps/server/src/models/article/dto/update-article-group.dto.ts
new file mode 100644
index 0000000..af37ff4
--- /dev/null
+++ b/apps/server/src/models/article/dto/update-article-group.dto.ts
@@ -0,0 +1,5 @@
+import { CreateArticleGroupDto } from './create-article-group.dto';
+
+export class UpdateArticleGroupDto extends CreateArticleGroupDto {
+
+}
diff --git a/apps/server/src/models/article/dto/update-article.dto.ts b/apps/server/src/models/article/dto/update-article.dto.ts
new file mode 100644
index 0000000..9ede858
--- /dev/null
+++ b/apps/server/src/models/article/dto/update-article.dto.ts
@@ -0,0 +1,7 @@
+import { PartialType } from '@nestjs/mapped-types';
+import { CreateArticleDto } from './create-article.dto';
+
+// PartialType macht alle Felder optional + behält alle Validator-Decorators bei.
+// Nötig für PATCH: CreateArticleDto hat @IsNotEmpty() auf allen Feldern — diese
+// würden bei leeren Strings (type:"", unit:"" etc.) die Validation zum Scheitern bringen.
+export class UpdateArticleDto extends PartialType(CreateArticleDto) {}
diff --git a/apps/server/src/models/article/dto/update-inventory.dto.ts b/apps/server/src/models/article/dto/update-inventory.dto.ts
new file mode 100644
index 0000000..44574f1
--- /dev/null
+++ b/apps/server/src/models/article/dto/update-inventory.dto.ts
@@ -0,0 +1,12 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsNumber } from 'class-validator';
+
+export class UpdateInventoryDto {
+ @ApiProperty({
+ example: '5',
+ description: 'New Inventory Stock',
+ })
+ @IsNotEmpty()
+ @IsNumber()
+ newStock: number;
+}
diff --git a/apps/server/src/models/article/entities/article-group.entity.ts b/apps/server/src/models/article/entities/article-group.entity.ts
new file mode 100644
index 0000000..e1cf495
--- /dev/null
+++ b/apps/server/src/models/article/entities/article-group.entity.ts
@@ -0,0 +1,34 @@
+import { Expose } from 'class-transformer';
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ OneToMany,
+} from 'typeorm';
+import { IArticleGroup } from '../interfaces/article-group.interface';
+import { Article } from './article.entity';
+
+
+@Entity({ name: 'article-groups' })
+export class ArticleGroup implements IArticleGroup {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false, length: 50 })
+ name!: string;
+
+ @OneToMany(() => Article, (article) => article.articleGroup)
+ articles: Article[];
+
+ @CreateDateColumn({
+ name: 'created_at',
+ })
+ createdAt: Date;
+
+ @UpdateDateColumn({
+ name: 'updated_at',
+ })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/article/entities/article-import.csv-entity.ts b/apps/server/src/models/article/entities/article-import.csv-entity.ts
new file mode 100644
index 0000000..d7e09fb
--- /dev/null
+++ b/apps/server/src/models/article/entities/article-import.csv-entity.ts
@@ -0,0 +1,42 @@
+//Verkaufsobjekt;
+//Artikelbez;
+//Bez.2;
+//Barcode;
+//MEH;
+//Listenpreis EUR/EH;
+//Rabatt %;EUR Netto/EH;Brutto;Rabatt;Netto;PE;Preisherkunft;Sortiment;
+
+import { CsvKey } from 'nest-csv-parser';
+
+
+export class ArticleCsvEntity {
+
+ @CsvKey('Verkaufsobjekt')
+ verkaufsobjekt: string;
+
+ @CsvKey('Artikelbez')
+ artikelbez: string;
+
+ @CsvKey('Bez. 2')
+ bezeichnung2: string;
+
+ @CsvKey('Barcode')
+ barcode: string;
+
+ @CsvKey('MEH')
+ unit: string;
+
+ @CsvKey('Brutto')
+ brutto: string;
+
+ @CsvKey('Netto')
+ netto: string;
+
+ @CsvKey('per')
+ pe: string;
+
+ constructor(partial: Partial) {
+ Object.assign(this, partial);
+ }
+
+}
diff --git a/apps/server/src/models/article/entities/article.entity.ts b/apps/server/src/models/article/entities/article.entity.ts
new file mode 100644
index 0000000..f596745
--- /dev/null
+++ b/apps/server/src/models/article/entities/article.entity.ts
@@ -0,0 +1,85 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ OneToMany,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { OrderEntry } from '../../bills/entities/order-entry.entity';
+import { IArticle } from '../interfaces/article.interface';
+import { ArticleGroup } from './article-group.entity';
+
+@Entity({ name: 'article' })
+export class Article implements IArticle {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false, length: 50 })
+ name!: string;
+
+ @Column({ nullable: false, unique: true, length: 50 })
+ code!: string;
+
+ @Column({ nullable: false, default: 0.0, precision: 5, scale: 2 })
+ price!: number;
+
+ @Column({ nullable: false, default: 0.0, precision: 5, scale: 2 })
+ netto!: number;
+
+ @Column({ default: 0 })
+ inventoryStock: number;
+
+ // Bug #4 fix: was @CreateDateColumn() which auto-sets on INSERT — should be a plain nullable column
+ @Column({ type: 'datetime', nullable: true })
+ inventoryDate: Date;
+
+ @Column({ default: '', length: 50 })
+ type: string;
+
+ @Column({ default: '', length: 250, nullable: true })
+ description: string;
+
+ @Column({ default: '', length: 100, nullable: true })
+ supplier: string;
+
+ @Column({ default: '', length: 250 })
+ imgPath: string;
+
+ @Column({ default: '', length: 50 })
+ unit: string;
+
+ @Column({ default: '', length: 50 })
+ artNumber: string;
+
+ @Column('boolean', { default: false })
+ singlePos: boolean = false;
+
+ @Column('boolean', { default: true })
+ trackStock: boolean = true;
+
+ @Column('boolean', { default: false })
+ noDiscount: boolean = false;
+
+ @OneToMany(() => OrderEntry, (orderEntry) => orderEntry.article)
+ orderEntry: OrderEntry[];
+
+ @ManyToOne(() => ArticleGroup)
+ @JoinColumn()
+ articleGroup: ArticleGroup;
+
+ @CreateDateColumn({
+ name: 'created_at',
+ })
+ createdAt: Date;
+
+ @UpdateDateColumn({
+ name: 'updated_at',
+ })
+ updatedAt: Date;
+
+ @Column({ select: false, insert: false, readonly: true, update: false, nullable: true })
+ totalAmount: number;
+}
diff --git a/apps/server/src/models/article/entities/inventory.entity.ts b/apps/server/src/models/article/entities/inventory.entity.ts
new file mode 100644
index 0000000..3d41b76
--- /dev/null
+++ b/apps/server/src/models/article/entities/inventory.entity.ts
@@ -0,0 +1,37 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { IInventory } from '../interfaces/inventory.interface';
+import { Article } from './article.entity';
+
+@Entity({ name: 'inventory' })
+export class Inventory implements IInventory {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false, default: 0.0, precision: 5, scale: 2 })
+ amountNew!: number;
+
+ @Column({ nullable: false, default: 0.0, precision: 5, scale: 2 })
+ diff!: number;
+
+ @ManyToOne(() => Article)
+ @JoinColumn()
+ article: Article;
+
+ @CreateDateColumn({
+ name: 'created_at',
+ })
+ createdAt: Date;
+
+ @UpdateDateColumn({
+ name: 'updated_at',
+ })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/article/interfaces/article-group.interface.ts b/apps/server/src/models/article/interfaces/article-group.interface.ts
new file mode 100644
index 0000000..1d7c67e
--- /dev/null
+++ b/apps/server/src/models/article/interfaces/article-group.interface.ts
@@ -0,0 +1,3 @@
+export interface IArticleGroup {
+ name: string;
+}
diff --git a/apps/server/src/models/article/interfaces/article.interface.ts b/apps/server/src/models/article/interfaces/article.interface.ts
new file mode 100644
index 0000000..b41d219
--- /dev/null
+++ b/apps/server/src/models/article/interfaces/article.interface.ts
@@ -0,0 +1,18 @@
+import { ArticleGroup } from '../entities/article-group.entity';
+import { IArticleGroup } from './article-group.interface';
+
+export interface IArticle {
+ name: string;
+ code: string;
+ price: number;
+ inventoryStock: number;
+ inventoryDate: Date;
+ type: string;
+ imgPath: string;
+ unit: string;
+ artNumber: string;
+ articleGroup: IArticleGroup;
+ singlePos: boolean;
+ trackStock: boolean;
+ noDiscount: boolean;
+}
diff --git a/apps/server/src/models/article/interfaces/inventory.interface.ts b/apps/server/src/models/article/interfaces/inventory.interface.ts
new file mode 100644
index 0000000..b45c63a
--- /dev/null
+++ b/apps/server/src/models/article/interfaces/inventory.interface.ts
@@ -0,0 +1,10 @@
+import { IArticle } from './article.interface';
+
+export interface IInventory {
+ id: number;
+ amountNew: number;
+ diff: number;
+ article: IArticle;
+ createdAt: Date;
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/article/inventory.repository.ts b/apps/server/src/models/article/inventory.repository.ts
new file mode 100644
index 0000000..78516fc
--- /dev/null
+++ b/apps/server/src/models/article/inventory.repository.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
+import { ModelRepository } from '../model.repository';
+import { Inventory } from './entities/inventory.entity';
+import { allInventoryForSerializing, InventoryEntity, extendedInventoryForSerializing } from './serializers/inventory.serializer';
+
+@Injectable()
+export class InventoryRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Inventory, dataSource.createEntityManager());
+ }
+
+ transform(model: Inventory): InventoryEntity {
+ const tranformOptions: ClassTransformOptions = {
+ groups: extendedInventoryForSerializing,
+ };
+ return plainToInstance(
+ InventoryEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Inventory[]): InventoryEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/article/inventory.service.ts b/apps/server/src/models/article/inventory.service.ts
new file mode 100644
index 0000000..b8e29af
--- /dev/null
+++ b/apps/server/src/models/article/inventory.service.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import { BaseService } from '../../common/base.service';
+import { InventoryRepository } from './inventory.repository';
+import { Inventory } from './entities/inventory.entity';
+import { InventoryEntity } from './serializers/inventory.serializer';
+
+@Injectable()
+export class InventoryService extends BaseService {
+ constructor(
+ private readonly inventoryRepository: InventoryRepository,
+ ) {
+ super(inventoryRepository);
+ }
+}
diff --git a/apps/server/src/models/article/serializers/article-group.serializer.ts b/apps/server/src/models/article/serializers/article-group.serializer.ts
new file mode 100644
index 0000000..fee2af3
--- /dev/null
+++ b/apps/server/src/models/article/serializers/article-group.serializer.ts
@@ -0,0 +1,26 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { IArticleGroup } from '../interfaces/article-group.interface';
+
+export const defaultArticleGroupForSerializing: string[] = [
+ 'articleGroup.timestamps',
+ 'default',
+];
+export const extendedArticleGroupForSerializing: string[] = [
+ ...defaultArticleGroupForSerializing,
+];
+export const allArticleGroupForSerializing: string[] = [
+ ...extendedArticleGroupForSerializing,
+];
+export class ArticleGroupEntity
+ extends ModelEntity
+ implements IArticleGroup {
+
+ @Expose({ groups: ['default'] })
+ name: string;
+
+ @Expose({ groups: ['default', 'articleGroup.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['default', 'articleGroup.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/article/serializers/article.serializer.ts b/apps/server/src/models/article/serializers/article.serializer.ts
new file mode 100644
index 0000000..5747013
--- /dev/null
+++ b/apps/server/src/models/article/serializers/article.serializer.ts
@@ -0,0 +1,63 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { IArticle } from '../interfaces/article.interface';
+import { ArticleGroupEntity } from './article-group.serializer';
+
+export const defaultArticleForSerializing: string[] = [
+ 'bucketCategory.timestamps',
+ 'default',
+];
+export const extendedArticleForSerializing: string[] = [
+ ...defaultArticleForSerializing,
+];
+export const allArticleForSerializing: string[] = [
+ ...extendedArticleForSerializing,
+];
+export class ArticleEntity
+ extends ModelEntity
+ implements IArticle {
+ @Expose({ groups: ['default'] })
+ name: string;
+ @Expose({ groups: ['default'] })
+ code: string;
+ @Expose({ groups: ['default'] })
+ inventoryStock: number;
+ @Expose({ groups: ['default'] })
+ inventoryDate: Date;
+ @Expose({ groups: ['default'] })
+ imgPath: string;
+ @Expose({ groups: ['default'] })
+ unit: string;
+ @Expose({ groups: ['default'] })
+ artNumber: string;
+
+ @Expose({ groups: ['default'] })
+ stock: number;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => ArticleGroupEntity)
+ articleGroup: ArticleGroupEntity;
+
+ @Expose({ groups: ['default'] })
+ description: string;
+
+ @Expose({ groups: ['default'] })
+ supplier: string;
+
+ @Expose({ groups: ['default'] })
+ singlePos: boolean;
+ @Expose({ groups: ['default'] })
+ trackStock: boolean;
+ @Expose({ groups: ['default'] })
+ noDiscount: boolean;
+
+ @Expose({ groups: ['default'] })
+ price: number;
+ @Expose({ groups: ['default'] })
+ type: string;
+
+ @Expose({ groups: ['default', 'bucketCategory.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['default', 'bucketCategory.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/article/serializers/inventory.serializer.ts b/apps/server/src/models/article/serializers/inventory.serializer.ts
new file mode 100644
index 0000000..7e0d324
--- /dev/null
+++ b/apps/server/src/models/article/serializers/inventory.serializer.ts
@@ -0,0 +1,34 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { IInventory } from '../interfaces/inventory.interface';
+import { ArticleEntity } from './article.serializer';
+
+export const defaultInventoryForSerializing: string[] = [
+ 'inventory.timestamps',
+ 'default',
+];
+export const extendedInventoryForSerializing: string[] = [
+ ...defaultInventoryForSerializing,
+];
+export const allInventoryForSerializing: string[] = [
+ ...extendedInventoryForSerializing,
+];
+export class InventoryEntity
+ extends ModelEntity
+ implements IInventory {
+
+ @Expose({ groups: ['default'] })
+ amountNew: number;
+
+ @Expose({ groups: ['default'] })
+ diff: number;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => ArticleEntity)
+ article: ArticleEntity;
+
+ @Expose({ groups: ['default', 'inventory.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['default', 'inventory.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/annotation.repository.ts b/apps/server/src/models/bills/annotation.repository.ts
new file mode 100644
index 0000000..8b60b12
--- /dev/null
+++ b/apps/server/src/models/bills/annotation.repository.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { Annotation } from './entities/annotation.entity';
+import { allAnnotationForSerializing, AnnotationEntity } from './serializers/annotation.serializer';
+
+@Injectable()
+export class AnnotationRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Annotation, dataSource.createEntityManager());
+ }
+
+ transform(model: Annotation): AnnotationEntity {
+ const tranformOptions = {
+ groups: allAnnotationForSerializing,
+ };
+ return plainToInstance(
+ AnnotationEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Annotation[]): AnnotationEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/bills/annotation.service.ts b/apps/server/src/models/bills/annotation.service.ts
new file mode 100644
index 0000000..236100d
--- /dev/null
+++ b/apps/server/src/models/bills/annotation.service.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import { AnnotationRepository } from './annotation.repository';
+import { AnnotationEntity } from './serializers/annotation.serializer';
+import { BaseService } from '../../common/base.service';
+import { Annotation } from './entities/annotation.entity';
+
+@Injectable()
+export class AnnotationService extends BaseService {
+ constructor(
+ private readonly annotationRepository: AnnotationRepository,
+ ) {
+ super(annotationRepository);
+ }
+}
diff --git a/apps/server/src/models/bills/bill.module.ts b/apps/server/src/models/bills/bill.module.ts
new file mode 100644
index 0000000..e7810ed
--- /dev/null
+++ b/apps/server/src/models/bills/bill.module.ts
@@ -0,0 +1,70 @@
+import { forwardRef, Module } from '@nestjs/common';
+import { BillService } from './bill.service';
+import { BillController } from './controllers/bill.controller';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { BillRepository } from './bill.repository';
+import { OrderEntryService } from './order-entry.service';
+import { OrderEntryRepository } from './order-entry.repository';
+import { SlipsheetRepository } from './slipsheet.repository';
+import { SlipsheetService } from './slipsheet.service';
+import { ArticleModule } from '../article/article.module';
+import { SharedModule } from '../../common/shared.module';
+import { SlipsheetController } from './controllers/slipsheet.controller';
+import { AppConfigModule } from '../../config/app/config.module';
+import { AnnotationRepository } from './annotation.repository';
+import { AnnotationService } from './annotation.service';
+import { AnnotationController } from './controllers/annotation.controller';
+import { CustomerModule } from '../customer/customer.module';
+import { OrderEntryController } from './controllers/order-entry.controller';
+import { DiscountRepository } from './discount.repository';
+import { DiscountService } from './discount.service';
+import { DashboardController } from './controllers/dashboard.controller';
+import { DashboardService } from './dashboard.service';
+import { InventoryRepository } from '../article/inventory.repository';
+import { Bill } from './entities/bill.entity';
+import { Slipsheet } from './entities/slipsheet.entity';
+import { OrderEntry } from './entities/order-entry.entity';
+import { Annotation } from './entities/annotation.entity';
+import { Discount } from './entities/discount.entity';
+import { Inventory } from '../article/entities/inventory.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([
+ Bill,
+ Slipsheet,
+ OrderEntry,
+ Annotation,
+ Discount,
+ Inventory,
+ ]),
+ ArticleModule,
+ AppConfigModule,
+ SharedModule,
+ forwardRef(() => CustomerModule),
+ ],
+
+ controllers: [
+ BillController,
+ SlipsheetController,
+ AnnotationController,
+ OrderEntryController,
+ DashboardController,
+ ],
+ providers: [
+ BillRepository,
+ SlipsheetRepository,
+ OrderEntryRepository,
+ AnnotationRepository,
+ DiscountRepository,
+ // InventoryRepository is exported from ArticleModule — do not re-declare here
+ BillService,
+ OrderEntryService,
+ SlipsheetService,
+ AnnotationService,
+ DiscountService,
+ DashboardService,
+ ],
+ exports: [OrderEntryService, SlipsheetService, BillService, DiscountService],
+})
+export class BillModule {}
diff --git a/apps/server/src/models/bills/bill.repository.ts b/apps/server/src/models/bills/bill.repository.ts
new file mode 100644
index 0000000..b636325
--- /dev/null
+++ b/apps/server/src/models/bills/bill.repository.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { Bill } from './entities/bill.entity';
+import { allBillForSerializing, BillEntity } from './serializers/bill.serializer';
+
+@Injectable()
+export class BillRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Bill, dataSource.createEntityManager());
+ }
+
+ transform(model: Bill): BillEntity {
+ const tranformOptions = {
+ groups: allBillForSerializing,
+ };
+ return plainToInstance(
+ BillEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Bill[]): BillEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/bills/bill.service.ts b/apps/server/src/models/bills/bill.service.ts
new file mode 100644
index 0000000..90a7949
--- /dev/null
+++ b/apps/server/src/models/bills/bill.service.ts
@@ -0,0 +1,346 @@
+import {
+ BadRequestException,
+ Injectable,
+ InternalServerErrorException,
+ NotFoundException,
+} from '@nestjs/common';
+import { CreateBillDto } from './dto/create-bill.dto';
+import { UpdateBillDto } from './dto/update-bill.dto';
+import { BillRepository } from './bill.repository';
+import { BillEntity } from './serializers/bill.serializer';
+import { EntityManager } from 'typeorm';
+import { BaseService } from '../../common/base.service';
+import { Bill } from './entities/bill.entity';
+import { SlipsheetEntity } from './serializers/slipsheet.serializer';
+import { SlipsheetState } from './enums/slipsheet-state.enum';
+import { SlipsheetService } from './slipsheet.service';
+import { BillState } from './enums/bill-state.enum';
+import { PdfMakerService } from '../../common/services/pdfmaker.service';
+import { join } from 'path';
+import { AppConfigService } from '../../config/app/config.service';
+import { Slipsheet } from './entities/slipsheet.entity';
+import { existsSync } from 'fs';
+import { unlink } from 'fs/promises';
+
+@Injectable()
+export class BillService extends BaseService {
+
+ constructor(
+ private readonly billRepository: BillRepository,
+ private readonly slipsheetService: SlipsheetService,
+ private readonly pdfMakerService: PdfMakerService,
+ private readonly appConfigService: AppConfigService,
+ ) {
+ super(billRepository);
+ }
+
+
+ async generateBill(
+ slipsheetEntities: SlipsheetEntity[],
+ bill?: BillEntity,
+ ): Promise {
+ if (!slipsheetEntities?.length) {
+ throw new BadRequestException('Keine Lieferscheine angegeben');
+ }
+
+ const shoppingcartIds = slipsheetEntities.map((o) => o.id);
+ const firstCustomerId = slipsheetEntities[0]?.customer?.id;
+
+ if (slipsheetEntities.some((cart) => cart.state !== SlipsheetState.CLOSED))
+ throw new BadRequestException('Lieferscheine noch nicht erstellt');
+
+ if (!firstCustomerId || slipsheetEntities.some((cart) => cart.customer?.id !== firstCustomerId))
+ throw new BadRequestException('Warenkorb von unterschiedlichen Kunden');
+
+ if (
+ slipsheetEntities.some(
+ (cart) => !!cart.bill && (!bill || cart.bill.id !== bill.id),
+ )
+ )
+ throw new BadRequestException('Manche Lieferscheine haben schon eine Rechnung');
+
+ let firstBillEntity: BillEntity;
+ const createdNewBill = !bill;
+
+ try {
+ if (!bill) {
+ firstBillEntity = await this.createBillWithRetry();
+ } else {
+ firstBillEntity = await this.getAllInformations(bill.id);
+ if (!firstBillEntity) {
+ throw new NotFoundException('Rechnung nicht gefunden');
+ }
+ }
+
+ firstBillEntity.state = BillState.CLOSED;
+ if (!firstBillEntity.path) {
+ firstBillEntity.path = this.getPathName(firstBillEntity);
+ }
+
+ await this.billRepository.manager.transaction(async (entityManager) => {
+ await entityManager.update(Bill, firstBillEntity.id, {
+ path: firstBillEntity.path,
+ state: firstBillEntity.state,
+ });
+ await entityManager.update(Slipsheet, shoppingcartIds, {
+ billId: firstBillEntity.id,
+ state: SlipsheetState.CLOSED,
+ });
+ });
+
+ firstBillEntity = await this.getAllInformations(firstBillEntity.id);
+
+ await this.writeBillPdf(firstBillEntity);
+
+ return firstBillEntity;
+
+ } catch (error) {
+ console.error(error);
+ if (createdNewBill && firstBillEntity?.id) {
+ await this.rollbackCreatedBill(firstBillEntity.id, shoppingcartIds);
+ }
+
+ throw error;
+ }
+ }
+
+ async regenerateBillPdf(id: number): Promise {
+ const bill = await this.getAllInformations(id);
+ if (!bill) {
+ throw new NotFoundException('Rechnung nicht gefunden');
+ }
+
+ if (bill.state !== BillState.CLOSED) {
+ bill.state = BillState.CLOSED;
+ }
+
+ if (!bill.path) {
+ bill.path = this.getPathName(bill);
+ }
+
+ await this.billRepository.updateEntity(bill.id, {
+ path: bill.path,
+ state: bill.state,
+ });
+
+ await this.writeBillPdf(bill);
+ return this.getAllInformations(id);
+ }
+
+ async updateBill(id: number, inputs: UpdateBillDto): Promise {
+ const existingBill = await this.getAllInformations(id);
+ if (!existingBill) {
+ throw new NotFoundException('Rechnung nicht gefunden');
+ }
+
+ const nextBillNumber = inputs.billNumber?.trim() || existingBill.billNumber;
+ const previousPath = existingBill.path;
+
+ await this.billRepository.updateEntity(existingBill.id, {
+ billNumber: nextBillNumber,
+ billDate: this.normalizeBillDate(inputs.billDate),
+ path: this.getPathName({
+ ...existingBill,
+ billNumber: nextBillNumber,
+ } as BillEntity),
+ });
+
+ const updatedBill = await this.regenerateBillPdf(id);
+
+ if (previousPath && previousPath !== updatedBill.path) {
+ await this.deletePdfFile(previousPath);
+ }
+
+ return updatedBill;
+ }
+
+ async releaseBill(id: number): Promise {
+ const bill = await this.getAllInformations(id);
+ if (!bill) {
+ throw new NotFoundException('Rechnung nicht gefunden');
+ }
+
+ const slipsheetIds = (bill.slipsheets || []).map((slipsheet) => slipsheet.id);
+
+ await this.billRepository.manager.transaction(async (entityManager) => {
+ if (slipsheetIds.length) {
+ await entityManager.update(Slipsheet, slipsheetIds, {
+ billId: null,
+ state: SlipsheetState.CLOSED,
+ });
+ }
+
+ await entityManager.delete(Bill, bill.id);
+ });
+
+ await this.deletePdfFile(bill.path);
+ }
+
+ getAllInformations(id: number): Promise {
+ return this.get(id,
+ [
+ 'slipsheets',
+ 'slipsheets.customer',
+ 'slipsheets.orderEntries',
+ 'slipsheets.orderEntries.article',
+ 'slipsheets.orderEntries.article.articleGroup',
+ 'slipsheets.annotations'],
+ );
+ }
+
+ getAllAllInformations(): Promise {
+ return this.getAll(
+ [
+ 'slipsheets',
+ 'slipsheets.customer',
+ 'slipsheets.orderEntries',
+ 'slipsheets.orderEntries.article',
+ 'slipsheets.orderEntries.article.articleGroup',
+ 'slipsheets.annotations'],
+ );
+ }
+
+ async generateNumber(entityManager: EntityManager = this.billRepository.manager): Promise {
+ const lastEntry = await entityManager.query(`
+ SELECT "Bill"."billNumber" AS "billNumber"
+ FROM "bill" "Bill" WHERE NOT("Bill"."billNumber" IS NULL) and "Bill"."billNumber" not like '%/%'
+ ORDER BY CAST("Bill"."billNumber" as INTEGER) DESC Limit 1`);
+ if (lastEntry?.length != 0) {
+ const number = +lastEntry[0]?.billNumber || 0;
+ return (number + 1).toString();
+ } else {
+ return '1';
+ }
+ }
+
+ getPathName(bill: BillEntity): string {
+ return 'R_' + bill.billNumber + '.pdf';
+ }
+
+
+ async findFromCustomer(customerId: number): Promise {
+ const bills = await this.billRepository
+ .createQueryBuilder('bill')
+ .leftJoinAndSelect('bill.slipsheets', 'slipsheet')
+ .leftJoinAndSelect('slipsheet.customer', 'customer')
+ .leftJoinAndSelect('slipsheet.orderEntries', 'orderEntries')
+ .leftJoinAndSelect('orderEntries.article', 'article')
+ .leftJoinAndSelect('article.articleGroup', 'articleGroup')
+ .leftJoinAndSelect('slipsheet.annotations', 'annotations')
+ .where('slipsheet.customerId = :customerId', { customerId })
+ .getMany();
+
+ return this.billRepository.transformMany(bills);
+ }
+
+ async findByState(state: BillState): Promise {
+ return this.billRepository.findAll({
+ filter: { state },
+ relations: [
+ 'slipsheets',
+ 'slipsheets.customer',
+ 'slipsheets.orderEntries',
+ 'slipsheets.orderEntries.article',
+ 'slipsheets.orderEntries.article.articleGroup',
+ 'slipsheets.annotations',
+ ],
+ });
+ }
+
+
+ async changed(billId: number) {
+ return this.update(billId, { state: BillState.OPEN });
+ }
+
+ private async createBillWithRetry(maxAttempts = 5): Promise {
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const billNumber = await this.generateNumber();
+ try {
+ const bill = await this.billRepository.createEntity({
+ billNumber,
+ billDate: new Date(),
+ path: this.getPathName({ billNumber } as BillEntity),
+ state: BillState.CLOSED,
+ });
+ return bill;
+ } catch (error) {
+ if (this.isUniqueConstraintError(error) && attempt < maxAttempts) {
+ continue;
+ }
+ throw error;
+ }
+ }
+ throw new InternalServerErrorException(
+ 'Rechnungsnummer konnte nicht eindeutig erzeugt werden',
+ );
+ }
+
+ private async rollbackCreatedBill(
+ billId: number,
+ slipsheetIds: number[],
+ ): Promise {
+ try {
+ await this.billRepository.manager.transaction(async (entityManager) => {
+ await entityManager
+ .createQueryBuilder()
+ .update(Slipsheet)
+ .set({ billId: null })
+ .where('id IN (:...ids)', { ids: slipsheetIds })
+ .andWhere('billId = :billId', { billId })
+ .execute();
+ await entityManager.delete(Bill, billId);
+ });
+ } catch (rollbackError) {
+ console.error('Rollback created bill failed', rollbackError);
+ }
+ }
+
+ private isUniqueConstraintError(error: any): boolean {
+ const code = error?.code || error?.errno;
+ const message = `${error?.message || ''}`.toLowerCase();
+ return (
+ code === 'SQLITE_CONSTRAINT' ||
+ code === '23505' ||
+ message.includes('unique constraint')
+ );
+ }
+
+ private normalizeBillDate(value: string | Date): Date {
+ if (value instanceof Date) {
+ return value;
+ }
+
+ const [year, month, day] = `${value || ''}`.split('-').map((part) => +part);
+ if (year && month && day) {
+ return new Date(year, month - 1, day, 12, 0, 0, 0);
+ }
+
+ return new Date(value);
+ }
+
+ private async writeBillPdf(bill: BillEntity): Promise {
+ const retpdf = await this.pdfMakerService.generateBill(bill);
+ await this.pdfMakerService.savePDFToFileSystem(
+ retpdf,
+ join(this.appConfigService.pdf_bill_path, bill.path),
+ );
+ }
+
+ private async deletePdfFile(path?: string | null): Promise {
+ if (!path) {
+ return;
+ }
+
+ const filepath = join(this.appConfigService.pdf_bill_path, path);
+ if (!existsSync(filepath)) {
+ return;
+ }
+
+ try {
+ await unlink(filepath);
+ } catch (error) {
+ console.error('Delete bill PDF failed', error);
+ }
+ }
+
+}
diff --git a/apps/server/src/models/bills/controllers/annotation.controller.ts b/apps/server/src/models/bills/controllers/annotation.controller.ts
new file mode 100644
index 0000000..5d0aac3
--- /dev/null
+++ b/apps/server/src/models/bills/controllers/annotation.controller.ts
@@ -0,0 +1,78 @@
+import {
+ Get,
+ Post,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ Patch,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { AnnotationService } from '../annotation.service';
+import { extendedAnnotationForSerializing, AnnotationEntity } from '../serializers/annotation.serializer';
+import { CreateAnnotationDto } from '../dto/create-annotation.dto';
+import { UpdateAnnotationDto } from '../dto/update-annotation.dto';
+
+@ApiBearerAuth()
+@Controller('annotation')
+@ApiTags('annotation')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ groups: extendedAnnotationForSerializing,
+})
+export class AnnotationController {
+ constructor(
+ private readonly annotationService: AnnotationService,
+ ) {
+
+ }
+
+ @Get('/:id')
+ @ApiOperation({
+ summary: 'Get specific annotation',
+ description: 'Fetchs data of id',
+ })
+ async get(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.annotationService.get(id));
+ }
+
+ @Get()
+ @ApiOperation({
+ summary: 'Get all annotations',
+ description: 'Fetchs all data',
+ })
+ async getAll(): Promise> {
+ return ReS.FromData(await this.annotationService.getAll());
+ }
+
+ @Post()
+ @ApiOperation({
+ summary: 'Create new annotation',
+ description: 'Create new annotation',
+ })
+ async post(@Body() annotation: CreateAnnotationDto): Promise> {
+ console.log(annotation);
+ return ReS.FromData(await this.annotationService.create(annotation));
+ }
+
+ @Patch(':id')
+ @ApiOperation({
+ summary: 'Update annotation',
+ description: 'Update annotation from ID',
+ })
+ async update(@Param('id') id: number, @Body() updateArticleDto: UpdateAnnotationDto) {
+ return ReS.FromData(await this.annotationService.update(id, updateArticleDto));
+ }
+
+}
diff --git a/apps/server/src/models/bills/controllers/bill.controller.ts b/apps/server/src/models/bills/controllers/bill.controller.ts
new file mode 100644
index 0000000..97d34f8
--- /dev/null
+++ b/apps/server/src/models/bills/controllers/bill.controller.ts
@@ -0,0 +1,150 @@
+import {
+ Get,
+ Post,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ Response,
+ Delete,
+ NotFoundException,
+ StreamableFile,
+ Query,
+ Put,
+} from '@nestjs/common';
+import {
+ BillEntity,
+ extendedBillForSerializing,
+} from '../serializers/bill.serializer';
+import { BillService } from '../bill.service';
+import { CreateBillDto } from '../dto/create-bill.dto';
+import { UpdateBillDto } from '../dto/update-bill.dto';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { ApiReS } from '../../../common/decorators/apires.decorator';
+import { createReadStream } from 'fs';
+import { existsSync } from 'fs';
+import { join } from 'path';
+import { BillState } from '../enums/bill-state.enum';
+import { AppConfigService } from '../../../config/app/config.service';
+import { SlipsheetEntity } from '../serializers/slipsheet.serializer';
+import { SlipsheetService } from '../slipsheet.service';
+
+@ApiBearerAuth()
+@Controller('bills')
+@ApiTags('bills')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ groups: extendedBillForSerializing,
+})
+export class BillController {
+ constructor(
+ private readonly billService: BillService,
+ private readonly slipsheetService: SlipsheetService,
+ private readonly appConfigService: AppConfigService,
+ ) {}
+
+ @Get('/:id')
+ async get(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.billService.getAllInformations(id));
+ }
+
+ @Get('/')
+ async getAll(@Query('state') state?: BillState): Promise> {
+ if (state) {
+ return ReS.FromData(await this.billService.findByState(state));
+ }
+ return ReS.FromData(await this.billService.getAllAllInformations());
+ }
+
+ // Bug #2 fix: GET /:id/pdf must NOT regenerate the PDF (side-effect on GET).
+ // If the file is missing, return 404 and let the client call POST /:id to regenerate.
+ @Get('/:id/pdf')
+ @ApiOperation({
+ summary: 'Download bill PDF',
+ description: 'Returns existing PDF. Returns 404 if not yet generated.',
+ })
+ async getPDF(
+ @Param('id') id: number,
+ @Response({ passthrough: true }) res,
+ ): Promise {
+ const bill: BillEntity = await this.billService.getAllInformations(id);
+
+ if (!bill.path) {
+ throw new NotFoundException(
+ 'Rechnung-PDF nicht gefunden. Bitte neu erzeugen.',
+ );
+ }
+
+ const filepath = join(this.appConfigService.pdf_bill_path, bill.path);
+ if (!existsSync(filepath)) {
+ throw new NotFoundException(
+ 'Rechnung-PDF nicht gefunden. Bitte neu erzeugen.',
+ );
+ }
+
+ const file = createReadStream(filepath);
+ res.set({
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': 'inline; filename="' + bill.path + '"',
+ });
+ return new StreamableFile(file);
+ }
+
+ @Post('/')
+ @ApiReS(CreateBillDto, 'The record has been successfully created.', 201)
+ async create(@Body() inputs: CreateBillDto): Promise> {
+ console.log(inputs);
+ return ReS.FromData(await this.billService.create(inputs));
+ }
+
+ @Post('/generate')
+ @ApiOperation({
+ summary: 'Generate bill from slipsheets',
+ description: 'Creates a bill PDF from the given slipsheet IDs',
+ })
+ async generateBill(@Body() slipsheets: number[]): Promise> {
+ const slipsheetEntities: SlipsheetEntity[] =
+ await this.slipsheetService.getAllInformations(slipsheets);
+
+ const bill: BillEntity = await this.billService.generateBill(
+ slipsheetEntities,
+ );
+
+ return ReS.FromData(bill);
+ }
+
+ @Post('/:id')
+ @ApiOperation({
+ summary: 'Recreate bill PDF',
+ description: 'Regenerates PDF for existing bill',
+ })
+ async recreateBill(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.billService.regenerateBillPdf(id));
+ }
+
+ @Put('/:id')
+ async update(
+ @Param('id') id: number,
+ @Body()
+ inputs: UpdateBillDto,
+ ): Promise> {
+ return ReS.FromData(await this.billService.updateBill(id, inputs));
+ }
+
+ @Delete('/:id')
+ async delete(@Param('id') id: number): Promise> {
+ await this.billService.releaseBill(id);
+ return ReS.FromData(null);
+ }
+}
diff --git a/apps/server/src/models/bills/controllers/dashboard.controller.ts b/apps/server/src/models/bills/controllers/dashboard.controller.ts
new file mode 100644
index 0000000..054b5fd
--- /dev/null
+++ b/apps/server/src/models/bills/controllers/dashboard.controller.ts
@@ -0,0 +1,58 @@
+import {
+ Get,
+ Controller,
+ Param,
+ forwardRef,
+ Inject,
+ Query,
+ UsePipes,
+ ValidationPipe,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { AppConfigService } from '../../../config/app/config.service';
+import { SlipsheetService } from '../slipsheet.service';
+import { CustomerService } from '../../customer/customer.service';
+import { TimeRangeDto } from '../../../common/dto/time-range.dto';
+import { DashboardService } from '../dashboard.service';
+import { DashboardSummaryQueryDto } from '../dto/dashboard-summary-query.dto';
+
+@ApiBearerAuth()
+@Controller('dashboard')
+@ApiTags('dashboard')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+export class DashboardController {
+ constructor(
+ private readonly slipsheetService: SlipsheetService,
+ private readonly dashboardService: DashboardService,
+ private readonly appConfigService: AppConfigService,
+ @Inject(forwardRef(() => CustomerService))
+ private readonly customerService: CustomerService,
+ ) {}
+
+ @Get('/summary')
+ @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
+ async getSummary(
+ @Query() query: DashboardSummaryQueryDto,
+ ): Promise> {
+ return ReS.FromData(await this.dashboardService.getSummary(query));
+ }
+
+ @Get('/verbrauch/:customerId')
+ async get(
+ @Param('customerId') customerId: number,
+ @Query() timerange: TimeRangeDto,
+ ): Promise> {
+
+ return ReS.FromData(
+ await this.slipsheetService.findFromCustomer(customerId, timerange),
+ );
+ }
+
+}
diff --git a/apps/server/src/models/bills/controllers/order-entry.controller.ts b/apps/server/src/models/bills/controllers/order-entry.controller.ts
new file mode 100644
index 0000000..f14a1d5
--- /dev/null
+++ b/apps/server/src/models/bills/controllers/order-entry.controller.ts
@@ -0,0 +1,68 @@
+import {
+ Get,
+ Put,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { OrderEntryService } from '../order-entry.service';
+import { extendedOrderEntryForSerializing, OrderEntryEntity } from '../serializers/order-entry.serializer';
+import { UpdateOrderEntryDto } from '../dto/update-order-entry.dto';
+
+@ApiBearerAuth()
+@Controller('order-entries')
+@ApiTags('order-entries')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ groups: extendedOrderEntryForSerializing,
+})
+export class OrderEntryController {
+ constructor(
+ private readonly orderEntryService: OrderEntryService,
+ ) {
+
+ }
+
+ @Get('/:id')
+ @ApiOperation({
+ summary: 'Get specific order entry',
+ description: 'Fetchs data of id',
+ })
+ async get(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.orderEntryService.get(id));
+ }
+
+ @Get()
+ @ApiOperation({
+ summary: 'Get all orderEntrys',
+ description: 'Fetchs all data',
+ })
+ async getAll(): Promise> {
+ return ReS.FromData(await this.orderEntryService.getAll());
+ }
+
+ @Put(':id')
+ @ApiOperation({
+ summary: 'Update Order Article',
+ description: 'Update Order from ID',
+ })
+ async update(
+ @Param('id') id: number,
+ @Body() updateOrderDto: UpdateOrderEntryDto) {
+ return ReS.FromData(await this.orderEntryService.update(id, updateOrderDto));
+ }
+
+}
diff --git a/apps/server/src/models/bills/controllers/slipsheet.controller.ts b/apps/server/src/models/bills/controllers/slipsheet.controller.ts
new file mode 100644
index 0000000..2a0a72e
--- /dev/null
+++ b/apps/server/src/models/bills/controllers/slipsheet.controller.ts
@@ -0,0 +1,234 @@
+import {
+ Get,
+ Post,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ Response,
+ Delete,
+ StreamableFile,
+ NotFoundException,
+ Query,
+ forwardRef,
+ Inject,
+ DefaultValuePipe,
+ ParseBoolPipe,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { SlipsheetService } from '../slipsheet.service';
+import { extendedSlipsheetForSerializing, SlipsheetEntity } from '../serializers/slipsheet.serializer';
+import { createReadStream, existsSync } from 'fs';
+import { join } from 'path';
+import { AppConfigService } from '../../../config/app/config.service';
+import { SlipsheetState } from '../enums/slipsheet-state.enum';
+import { CreateAnnotationDto } from '../dto/create-annotation.dto';
+import { AnnotationEntity } from '../serializers/annotation.serializer';
+import { AnnotationService } from '../annotation.service';
+import { Slipsheet } from '../entities/slipsheet.entity';
+import { CustomerService } from '../../customer/customer.service';
+import { AddOrderEntryDto } from '../dto/add-order-entry.dto';
+import { OrderEntryEntity } from '../serializers/order-entry.serializer';
+import { OrderEntryService } from '../order-entry.service';
+import { PrinterService } from '../../../common/services/printer.service';
+import { BillService } from '../bill.service';
+
+@ApiBearerAuth()
+@Controller('slipsheets')
+@ApiTags('slipsheets')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ groups: extendedSlipsheetForSerializing,
+})
+export class SlipsheetController {
+ constructor(
+ @Inject(forwardRef(() => SlipsheetService))
+ private readonly slipsheetService: SlipsheetService,
+ private readonly billService: BillService,
+ private readonly orderEntryService: OrderEntryService,
+ private readonly annotationService: AnnotationService,
+ private readonly appConfigService: AppConfigService,
+ @Inject(forwardRef(() => CustomerService))
+ private readonly customerService: CustomerService,
+ private readonly printerService: PrinterService,
+ ) {
+
+ }
+
+ @Get()
+ @ApiOperation({
+ summary: 'Get all slipsheets',
+ description: 'Fetchs all data',
+ })
+ async getAll(
+ @Query('customerId') customerId: number,
+ @Query('state') state?: SlipsheetState,
+ ): Promise> {
+ if (customerId) {
+ // Nur bestehende offene/geänderte LS zurückgeben — KEIN Auto-Anlegen
+ const slips = await this.slipsheetService.findFromCustomer(+customerId);
+ const open = slips.filter(s => s.state === SlipsheetState.OPEN || s.state === SlipsheetState.CHANGED);
+ return ReS.FromData(open);
+ } else if (state)
+ return ReS.FromData(await this.slipsheetService.findByState(state));
+ else
+ return ReS.FromData(await this.slipsheetService.getAll(this.slipsheetService.getAllRelations()));
+ }
+
+ // Bug #1 fix: cast id to number with +id to avoid string being passed to getAllInformations
+ @Get('/:id')
+ @ApiOperation({
+ summary: 'Get specific slipsheet',
+ description: 'Fetchs data of id',
+ })
+ async get(@Param('id') id: string): Promise> {
+ const slip = (await this.slipsheetService.getAllInformations([+id]))[0];
+ if (!slip) {
+ throw new NotFoundException('Lieferschein nicht gefunden');
+ }
+ return ReS.FromData(slip);
+ }
+
+ @Get('/:id/pdf')
+ @ApiOperation({
+ summary: 'Get slipsheet PDF',
+ description: 'Fetchs data of id',
+ })
+ async getPDF(
+ @Param('id') id: number,
+ @Query('close', new DefaultValuePipe(true), ParseBoolPipe) close: boolean,
+ @Response({ passthrough: true }) res): Promise {
+
+ let slip: SlipsheetEntity = await this.getOrGenerateSlipsheep(id, close);
+
+ const file = createReadStream(join(this.appConfigService.pdf_slip_path, slip.path));
+ res.set({
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': 'inline; filename="' + slip.path + '"',
+ });
+ return new StreamableFile(file);
+ }
+
+ @Post('/:id/print')
+ @ApiOperation({
+ summary: 'Print PDF',
+ description: '',
+ })
+ async printPDF(
+ @Param('id') id: number,
+ @Response({ passthrough: true }) res): Promise> {
+
+ let slip: SlipsheetEntity = await this.getOrGenerateSlipsheep(id, true);
+
+ return ReS.FromData(await this.printerService.print(join(this.appConfigService.pdf_slip_path, slip.path)));
+ }
+
+ private async getOrGenerateSlipsheep(id: number, close: boolean) {
+ let slip: SlipsheetEntity = (await this.slipsheetService.getAllInformations([id]))[0];
+ if (!slip.path || slip.state === SlipsheetState.OPEN || slip.state === SlipsheetState.CHANGED) {
+ slip = await this.slipsheetService.generateSlipsheet(id, close);
+ } else if (!existsSync(join(this.appConfigService.pdf_slip_path, slip.path))) {
+ slip = await this.slipsheetService.generateSlipsheet(id, close);
+ }
+ return slip;
+ }
+
+ @Post('/:id/annotation')
+ @ApiOperation({
+ summary: 'Add annotation to slipsheet',
+ description: 'Fetchs data of id',
+ })
+ async addAnnotation(
+ @Param('id') id: number,
+ @Body() annotation: CreateAnnotationDto,
+ ): Promise> {
+
+ const annotationToAdd = new AnnotationEntity();
+ annotationToAdd.text = annotation.text;
+ annotationToAdd.slipsheet = await this.slipsheetService.get(id);
+
+ await this.slipsheetService.changed(annotationToAdd.slipsheet);
+ if (annotationToAdd.slipsheet.billId)
+ await this.billService.changed(annotationToAdd.slipsheet.billId);
+
+ return ReS.FromData(await this.annotationService.create(annotationToAdd));
+ }
+
+ @Post()
+ @ApiOperation({
+ summary: 'Add article to open slipsheet',
+ description: 'Fetchs data of id',
+ })
+
+ async postOrderOpenSlipsheet(
+ @Body() addOrderEntryDto: AddOrderEntryDto): Promise> {
+
+ let customer = await this.customerService.get(addOrderEntryDto.customer.id, ['discounts', 'discounts.articleGroup']);
+ let slipsheet = await this.slipsheetService.findOpenForCustomer(customer);
+
+ const ret = await this.orderEntryService.addOrderToSlipsheet(
+ {
+ customer,
+ slipsheet,
+ addOrderEntry: addOrderEntryDto,
+ });
+ console.log(ret);
+ return ReS.FromData((await this.slipsheetService.getAllInformations([ret.slipsheet.id]))[0]);
+ }
+
+ @Post('/:id')
+ @ApiOperation({
+ summary: 'Add article to existing slipsheet',
+ description: 'Fetchs data of id',
+ })
+
+ async postOrder(
+ @Param('id') id: number,
+ @Body() addOrderEntryDto: AddOrderEntryDto): Promise> {
+
+ let slipsheet = (await this.slipsheetService.getAllInformations([id]))[0];
+
+ const ret = await this.orderEntryService.addOrderToSlipsheet(
+ {
+ customer: slipsheet.customer,
+ slipsheet: slipsheet,
+ addOrderEntry: addOrderEntryDto,
+ });
+ return ReS.FromData((await this.slipsheetService.getAllInformations([ret.slipsheet.id]))[0]);
+ }
+
+ @Delete('/:id')
+ @ApiOperation({
+ summary: 'Leeren offenen Lieferschein löschen',
+ description: 'Löscht einen offenen Lieferschein ohne Positionen',
+ })
+ async deleteSlipsheet(@Param('id') id: number): Promise> {
+ await this.slipsheetService.deleteEmpty(id);
+ return ReS.FromData(null);
+ }
+
+ @Post('/:id/close')
+ @ApiOperation({
+ summary: 'Close slipsheet and generate PDF',
+ description: 'Fetchs data of id',
+ })
+ async closeSlipsheet(
+ @Param('id') id: number,
+ ): Promise> {
+
+ let slip: SlipsheetEntity = await this.slipsheetService.generateSlipsheet(id, true);
+ return ReS.FromData(slip);
+
+ }
+}
diff --git a/apps/server/src/models/bills/dashboard.service.ts b/apps/server/src/models/bills/dashboard.service.ts
new file mode 100644
index 0000000..7d482ea
--- /dev/null
+++ b/apps/server/src/models/bills/dashboard.service.ts
@@ -0,0 +1,549 @@
+import { Injectable } from '@nestjs/common';
+import { ArticleService } from '../article/article.service';
+import { InventoryRepository } from '../article/inventory.repository';
+import { BillRepository } from './bill.repository';
+import { OrderEntryRepository } from './order-entry.repository';
+import { SlipsheetRepository } from './slipsheet.repository';
+import { BillState } from './enums/bill-state.enum';
+import { SlipsheetState } from './enums/slipsheet-state.enum';
+import { DashboardSummaryQueryDto } from './dto/dashboard-summary-query.dto';
+
+@Injectable()
+export class DashboardService {
+ constructor(
+ private readonly articleService: ArticleService,
+ private readonly inventoryRepository: InventoryRepository,
+ private readonly slipsheetRepository: SlipsheetRepository,
+ private readonly billRepository: BillRepository,
+ private readonly orderEntryRepository: OrderEntryRepository,
+ ) {}
+
+ async getSummary(query: DashboardSummaryQueryDto): Promise {
+ const filters = this.normalizeFilters(query);
+ const allowFallback = !query?.from && !query?.to;
+ const now = new Date();
+ const todayStart = this.startOfDay(now);
+ const todayEnd = this.endOfDay(now);
+ const sevenDaysAgo = this.shiftDays(now, -7);
+ const thirtyDaysAgo = this.shiftDays(now, -30);
+
+ const allArticles = await this.articleService.getAll();
+ // Bug fix: TypeORM 0.3 count() requires { where: { ... } } wrapper
+ const openSlipsheetsCount = Number(
+ await this.slipsheetRepository.count({ where: { state: SlipsheetState.OPEN } }),
+ );
+ const changedSlipsheetsCount = Number(
+ await this.slipsheetRepository.count({ where: { state: SlipsheetState.CHANGED } }),
+ );
+ const openBillsCount = Number(
+ await this.billRepository.count({ where: { state: BillState.OPEN } }),
+ );
+ const todayInventoryActivities = await this.queryInventoryRange(
+ todayStart,
+ todayEnd,
+ filters.articleGroupId,
+ 200,
+ );
+ let recentInventoryActivities = await this.queryInventoryRange(
+ filters.from,
+ filters.to,
+ filters.articleGroupId,
+ 12,
+ );
+ if (!recentInventoryActivities.length && allowFallback) {
+ recentInventoryActivities = await this.queryRecentInventoryActivities(
+ filters.articleGroupId,
+ 12,
+ );
+ }
+ const openSlipsheetsQueue = await this.slipsheetRepository.find({
+ where: { state: SlipsheetState.OPEN },
+ relations: ['customer'],
+ order: { createdAt: 'DESC' },
+ take: 8,
+ });
+ const changedSlipsheetsQueue = await this.slipsheetRepository.find({
+ where: { state: SlipsheetState.CHANGED },
+ relations: ['customer'],
+ order: { createdAt: 'DESC' },
+ take: 8,
+ });
+ const openBillsQueue = await this.billRepository
+ .createQueryBuilder('bill')
+ .leftJoinAndSelect('bill.slipsheets', 'slipsheet')
+ .leftJoinAndSelect('slipsheet.customer', 'customer')
+ .where('bill.state = :state', { state: BillState.OPEN })
+ .orderBy('bill.createdAt', 'DESC')
+ .take(8)
+ .getMany();
+ const topMoving7 = await this.queryTopMoving(
+ sevenDaysAgo,
+ now,
+ filters.articleGroupId,
+ 10,
+ );
+ const topMoving30 = await this.queryTopMoving(
+ thirtyDaysAgo,
+ now,
+ filters.articleGroupId,
+ 10,
+ );
+ let effectiveTrendFrom = filters.from;
+ let effectiveTrendTo = filters.to;
+ let trend = await this.buildTrend(
+ effectiveTrendFrom,
+ effectiveTrendTo,
+ filters.articleGroupId,
+ );
+
+ if (allowFallback && !this.trendHasActivity(trend)) {
+ const latestActivityDate = await this.findLatestActivityDate(
+ filters.articleGroupId,
+ );
+
+ if (latestActivityDate && latestActivityDate < effectiveTrendFrom) {
+ effectiveTrendTo = this.endOfDay(latestActivityDate);
+ effectiveTrendFrom = this.startOfDay(
+ this.shiftDays(effectiveTrendTo, -(filters.days - 1)),
+ );
+
+ trend = await this.buildTrend(
+ effectiveTrendFrom,
+ effectiveTrendTo,
+ filters.articleGroupId,
+ );
+ }
+ }
+
+ const filteredArticles = this.filterArticlesByGroup(
+ allArticles,
+ filters.articleGroupId,
+ );
+ const trackedArticles = filteredArticles.filter((a) => !!a.trackStock);
+ const lowStockArticles = trackedArticles
+ .filter((a) => Number(a.stock ?? 0) <= filters.threshold)
+ .sort((a, b) => Number(a.stock ?? 0) - Number(b.stock ?? 0));
+ const outOfStockArticles = trackedArticles.filter(
+ (a) => Number(a.stock ?? 0) <= 0,
+ );
+
+ return {
+ generatedAt: new Date().toISOString(),
+ filters: {
+ from: effectiveTrendFrom.toISOString(),
+ to: effectiveTrendTo.toISOString(),
+ days: filters.days,
+ threshold: filters.threshold,
+ articleGroupId: filters.articleGroupId || null,
+ },
+ kpis: {
+ totalArticles: filteredArticles.length,
+ trackedArticles: trackedArticles.length,
+ lowStockCount: lowStockArticles.length,
+ outOfStockCount: outOfStockArticles.length,
+ inventoryAdjustmentsTodayCount: todayInventoryActivities.length,
+ inventoryAdjustmentsTodayDelta: this.sum(
+ todayInventoryActivities.map((item) => Number(item.diff || 0)),
+ ),
+ openSlipsheets: openSlipsheetsCount,
+ changedSlipsheets: changedSlipsheetsCount,
+ openBills: openBillsCount,
+ },
+ lowStockItems: lowStockArticles.slice(0, 15).map((item) => ({
+ id: item.id,
+ name: item.name,
+ code: item.code,
+ unit: item.unit,
+ stock: Number(item.stock ?? 0),
+ inventoryStock: Number(item.inventoryStock ?? 0),
+ articleGroupName: item.articleGroup?.name || null,
+ })),
+ recentInventoryActivities: recentInventoryActivities.map((item) => ({
+ id: item.id,
+ createdAt: item.createdAt,
+ amountNew: Number(item.amountNew || 0),
+ diff: Number(item.diff || 0),
+ article: item.article
+ ? {
+ id: item.article.id,
+ name: item.article.name,
+ code: item.article.code,
+ unit: item.article.unit,
+ }
+ : null,
+ })),
+ documentQueue: {
+ openSlipsheets: openSlipsheetsQueue.map((item) => ({
+ id: item.id,
+ slipsheetnumber: item.slipsheetnumber,
+ state: item.state,
+ createdAt: item.createdAt,
+ customerId: item.customer?.id || null,
+ customerName:
+ item.customer?.companyName ||
+ `${item.customer?.firstName || ''} ${item.customer?.lastName || ''}`.trim() ||
+ '-',
+ })),
+ changedSlipsheets: changedSlipsheetsQueue.map((item) => ({
+ id: item.id,
+ slipsheetnumber: item.slipsheetnumber,
+ state: item.state,
+ createdAt: item.createdAt,
+ customerId: item.customer?.id || null,
+ customerName:
+ item.customer?.companyName ||
+ `${item.customer?.firstName || ''} ${item.customer?.lastName || ''}`.trim() ||
+ '-',
+ })),
+ openBills: openBillsQueue.map((item) => ({
+ id: item.id,
+ billNumber: item.billNumber,
+ state: item.state,
+ billDate: item.billDate,
+ slipsheetCount: item.slipsheets?.length || 0,
+ customerId: item.slipsheets?.[0]?.customer?.id || null,
+ customerName: item.slipsheets?.[0]?.customer?.['name'] || '-',
+ })),
+ },
+ topMovingArticles7: topMoving7,
+ topMovingArticles30: topMoving30,
+ stockTrend: trend,
+ };
+ }
+
+ private async queryTopMoving(
+ from: Date,
+ to: Date,
+ articleGroupId?: number,
+ limit = 10,
+ ): Promise {
+ const fromParam = this.toDateParam(from);
+ const toParam = this.toDateParam(to);
+ const queryBuilder = this.orderEntryRepository
+ .createQueryBuilder('orderEntry')
+ .select('article.id', 'id')
+ .addSelect('article.name', 'name')
+ .addSelect('article.code', 'code')
+ .addSelect('article.unit', 'unit')
+ .addSelect('SUM(orderEntry.amount)', 'totalAmount')
+ .leftJoin('orderEntry.article', 'article')
+ .where('datetime(orderEntry.createdAt) >= datetime(:from)', {
+ from: fromParam,
+ })
+ .andWhere('datetime(orderEntry.createdAt) <= datetime(:to)', {
+ to: toParam,
+ })
+ .groupBy('article.id')
+ .addGroupBy('article.name')
+ .addGroupBy('article.code')
+ .addGroupBy('article.unit')
+ .orderBy('SUM(orderEntry.amount)', 'DESC')
+ .take(limit);
+
+ if (articleGroupId) {
+ queryBuilder.andWhere('article.articleGroupId = :articleGroupId', {
+ articleGroupId,
+ });
+ }
+
+ const raw = await queryBuilder.getRawMany();
+ return raw.map((item) => ({
+ id: Number(item.id),
+ name: item.name,
+ code: item.code,
+ unit: item.unit,
+ totalAmount: Number(item.totalAmount || 0),
+ }));
+ }
+
+ private async buildTrend(
+ from: Date,
+ to: Date,
+ articleGroupId?: number,
+ ): Promise {
+ const fromParam = this.toDateParam(from);
+ const toParam = this.toDateParam(to);
+ const outgoingQuery = this.orderEntryRepository
+ .createQueryBuilder('orderEntry')
+ .leftJoin('orderEntry.article', 'article')
+ .where('datetime(orderEntry.createdAt) >= datetime(:from)', {
+ from: fromParam,
+ })
+ .andWhere('datetime(orderEntry.createdAt) <= datetime(:to)', {
+ to: toParam,
+ });
+
+ if (articleGroupId) {
+ outgoingQuery.andWhere('article.articleGroupId = :articleGroupId', {
+ articleGroupId,
+ });
+ }
+
+ const outgoingEntries = await outgoingQuery.getMany();
+ const inventoryEntries = await this.queryInventoryRange(
+ from,
+ to,
+ articleGroupId,
+ 0,
+ false,
+ );
+
+ const buckets = new Map();
+ const cursor = new Date(from);
+ while (cursor <= to) {
+ const key = this.toDayKey(cursor);
+ buckets.set(key, {
+ day: key,
+ label: this.toDayLabel(cursor),
+ outgoing: 0,
+ adjustmentDelta: 0,
+ });
+ cursor.setDate(cursor.getDate() + 1);
+ }
+
+ outgoingEntries.forEach((entry: any) => {
+ const key = this.toDayKey(entry.createdAt);
+ const bucket = buckets.get(key);
+ if (bucket) {
+ bucket.outgoing += Number(entry.amount || 0);
+ }
+ });
+
+ inventoryEntries.forEach((entry: any) => {
+ const key = this.toDayKey(entry.createdAt);
+ const bucket = buckets.get(key);
+ if (bucket) {
+ bucket.adjustmentDelta += Number(entry.diff || 0);
+ }
+ });
+
+ return Array.from(buckets.values());
+ }
+
+ private async queryInventoryRange(
+ from: Date,
+ to: Date,
+ articleGroupId?: number,
+ take = 12,
+ desc = true,
+ ): Promise {
+ const fromParam = this.toDateParam(from);
+ const toParam = this.toDateParam(to);
+ const queryBuilder = this.inventoryRepository
+ .createQueryBuilder('inventory')
+ .leftJoinAndSelect('inventory.article', 'article')
+ .where('datetime(inventory.createdAt) >= datetime(:from)', {
+ from: fromParam,
+ })
+ .andWhere('datetime(inventory.createdAt) <= datetime(:to)', {
+ to: toParam,
+ })
+ .orderBy('inventory.createdAt', desc ? 'DESC' : 'ASC');
+
+ if (articleGroupId) {
+ queryBuilder.leftJoinAndSelect('article.articleGroup', 'articleGroup');
+ queryBuilder.andWhere('article.articleGroupId = :articleGroupId', {
+ articleGroupId,
+ });
+ }
+
+ if (take && take > 0) {
+ queryBuilder.take(take);
+ }
+
+ return queryBuilder.getMany();
+ }
+
+ private async queryRecentInventoryActivities(
+ articleGroupId?: number,
+ take = 12,
+ ): Promise {
+ const queryBuilder = this.inventoryRepository
+ .createQueryBuilder('inventory')
+ .leftJoinAndSelect('inventory.article', 'article')
+ .leftJoinAndSelect('article.articleGroup', 'articleGroup')
+ .orderBy('inventory.createdAt', 'DESC')
+ .take(take);
+
+ if (articleGroupId) {
+ queryBuilder.where('article.articleGroupId = :articleGroupId', {
+ articleGroupId,
+ });
+ }
+
+ return queryBuilder.getMany();
+ }
+
+ private trendHasActivity(trend: any[]): boolean {
+ return trend.some(
+ (point) =>
+ Number(point.outgoing || 0) !== 0 ||
+ Number(point.adjustmentDelta || 0) !== 0,
+ );
+ }
+
+ private async findLatestActivityDate(
+ articleGroupId?: number,
+ ): Promise {
+ const [latestInventoryDate, latestOutgoingDate] = await Promise.all([
+ this.getLatestInventoryDate(articleGroupId),
+ this.getLatestOutgoingDate(articleGroupId),
+ ]);
+
+ if (!latestInventoryDate) {
+ return latestOutgoingDate;
+ }
+ if (!latestOutgoingDate) {
+ return latestInventoryDate;
+ }
+ return latestInventoryDate > latestOutgoingDate
+ ? latestInventoryDate
+ : latestOutgoingDate;
+ }
+
+ private async getLatestInventoryDate(
+ articleGroupId?: number,
+ ): Promise {
+ const latest = await this.queryRecentInventoryActivities(articleGroupId, 1);
+ return this.toValidDate(latest?.[0]?.createdAt);
+ }
+
+ private async getLatestOutgoingDate(
+ articleGroupId?: number,
+ ): Promise {
+ const queryBuilder = this.orderEntryRepository
+ .createQueryBuilder('orderEntry')
+ .leftJoin('orderEntry.article', 'article')
+ .orderBy('orderEntry.createdAt', 'DESC')
+ .take(1);
+
+ if (articleGroupId) {
+ queryBuilder.where('article.articleGroupId = :articleGroupId', {
+ articleGroupId,
+ });
+ }
+
+ const latest = await queryBuilder.getOne();
+ return this.toValidDate(latest?.createdAt);
+ }
+
+ private toValidDate(input: any): Date | null {
+ if (!input) {
+ return null;
+ }
+ const parsed = new Date(input);
+ if (Number.isNaN(parsed.getTime())) {
+ return null;
+ }
+ return parsed;
+ }
+
+ private filterArticlesByGroup(articles: any[], articleGroupId?: number) {
+ if (!articleGroupId) {
+ return articles;
+ }
+ return articles.filter(
+ (item) => Number(item.articleGroup?.id || 0) === Number(articleGroupId),
+ );
+ }
+
+ private normalizeFilters(query: DashboardSummaryQueryDto): {
+ days: number;
+ threshold: number;
+ articleGroupId?: number;
+ from: Date;
+ to: Date;
+ } {
+ const now = new Date();
+ const days = this.normalizeNumber(query.days, 30, 7, 120);
+ const threshold = this.normalizeNumber(query.threshold, 10, 0, 100000);
+ const defaultFrom = this.startOfDay(this.shiftDays(now, -(days - 1)));
+ const defaultTo = this.endOfDay(now);
+ const from = this.parseDate(query.from, defaultFrom);
+ const to = this.parseDate(query.to, defaultTo);
+ const articleGroupId = query.articleGroupId
+ ? Number(query.articleGroupId)
+ : undefined;
+
+ if (from > to) {
+ return {
+ days,
+ threshold,
+ articleGroupId,
+ from: defaultFrom,
+ to: defaultTo,
+ };
+ }
+
+ return { days, threshold, articleGroupId, from, to };
+ }
+
+ private normalizeNumber(
+ value: number,
+ fallback: number,
+ min: number,
+ max: number,
+ ): number {
+ const parsed = Number(value);
+ if (Number.isNaN(parsed)) {
+ return fallback;
+ }
+ return Math.max(min, Math.min(max, parsed));
+ }
+
+ private parseDate(value: string, fallback: Date): Date {
+ if (!value) {
+ return fallback;
+ }
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return fallback;
+ }
+ return parsed;
+ }
+
+ private shiftDays(date: Date, days: number): Date {
+ const value = new Date(date);
+ value.setDate(value.getDate() + days);
+ return value;
+ }
+
+ private startOfDay(date: Date): Date {
+ const value = new Date(date);
+ value.setHours(0, 0, 0, 0);
+ return value;
+ }
+
+ private endOfDay(date: Date): Date {
+ const value = new Date(date);
+ value.setHours(23, 59, 59, 999);
+ return value;
+ }
+
+ private toDayKey(input: Date): string {
+ const date = new Date(input);
+ const year = date.getFullYear();
+ const month = `${date.getMonth() + 1}`.padStart(2, '0');
+ const day = `${date.getDate()}`.padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ }
+
+ private toDayLabel(input: Date): string {
+ const date = new Date(input);
+ const day = `${date.getDate()}`.padStart(2, '0');
+ const month = `${date.getMonth() + 1}`.padStart(2, '0');
+ return `${day}.${month}`;
+ }
+
+ private sum(values: number[]): number {
+ return values.reduce((acc, current) => acc + Number(current || 0), 0);
+ }
+
+ private toDateParam(value: Date): string {
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return new Date().toISOString();
+ }
+ return parsed.toISOString();
+ }
+}
diff --git a/apps/server/src/models/bills/discount.repository.ts b/apps/server/src/models/bills/discount.repository.ts
new file mode 100644
index 0000000..2755f12
--- /dev/null
+++ b/apps/server/src/models/bills/discount.repository.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { Discount } from './entities/discount.entity';
+import { allDiscountForSerializing, DiscountEntity } from './serializers/discount.serializer';
+
+@Injectable()
+export class DiscountRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Discount, dataSource.createEntityManager());
+ }
+
+ transform(model: Discount): DiscountEntity {
+ const tranformOptions = {
+ groups: allDiscountForSerializing,
+ };
+ return plainToInstance(
+ DiscountEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Discount[]): DiscountEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/bills/discount.service.ts b/apps/server/src/models/bills/discount.service.ts
new file mode 100644
index 0000000..4457ed9
--- /dev/null
+++ b/apps/server/src/models/bills/discount.service.ts
@@ -0,0 +1,38 @@
+import { Injectable, MethodNotAllowedException } from '@nestjs/common';
+import { CreateUpdateDiscountDto } from './dto/add-update-discount.dto';
+import { BaseService } from '../../common/base.service';
+import { Discount } from './entities/discount.entity';
+import { DiscountEntity } from './serializers/discount.serializer';
+import { DiscountRepository } from './discount.repository';
+import { CustomerEntity } from '../customer/serializers/customer.serializer';
+
+@Injectable()
+export class DiscountService extends BaseService {
+
+ constructor(
+ private readonly discountRepository: DiscountRepository,
+ ) {
+ super(discountRepository);
+ }
+
+ async createOrUpdate(customer: CustomerEntity, createUpdateDiscountDto: CreateUpdateDiscountDto): Promise {
+
+ let ret;
+ if (createUpdateDiscountDto.id) {
+ let dbEntity = await this.get(createUpdateDiscountDto.id);
+ if (dbEntity.articleGroupId != createUpdateDiscountDto.articleGroup.id)
+ throw new MethodNotAllowedException('Artikel Gruppe darf nicht geändert werden');
+
+ ret = await this.update(createUpdateDiscountDto.id, { value: createUpdateDiscountDto.value });
+ } else {
+ const discount = new Discount();
+ discount.articleGroupId = createUpdateDiscountDto.articleGroup.id;
+ discount.customerId = customer.id;
+ discount.value = createUpdateDiscountDto.value;
+ ret = await this.create(discount);
+ }
+
+ return this.get(ret.id, ['articleGroup']);
+ }
+
+}
diff --git a/apps/server/src/models/bills/dto/add-order-entry.dto.ts b/apps/server/src/models/bills/dto/add-order-entry.dto.ts
new file mode 100644
index 0000000..1e946ed
--- /dev/null
+++ b/apps/server/src/models/bills/dto/add-order-entry.dto.ts
@@ -0,0 +1,67 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsNotEmpty, IsNumber, IsOptional, ValidateIf, ValidateNested } from 'class-validator';
+import { IdDto } from '../../../common/dto/id.dto';
+
+export class AddOrderEntryDto {
+
+ @ApiProperty({
+ type: IdDto,
+ description: 'Article for the order',
+ })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => IdDto)
+ article: IdDto;
+
+ @ApiProperty({
+ type: IdDto,
+ description: 'Customer for the order',
+ })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => IdDto)
+ customer: IdDto;
+
+ @ApiProperty({
+ example: 'true',
+ description: 'Free text if not put article ',
+ })
+ @IsOptional()
+ text: string;
+
+ @ApiProperty({
+ example: '4.3',
+ description: 'Free text if not put article ',
+ })
+ @ValidateIf((o) => !o.article)
+ @IsNotEmpty()
+ @IsNumber()
+ price: number;
+
+ @ApiProperty({
+ example: '20',
+ description: 'customer rabatt',
+ })
+ @ValidateIf((o) => !o.article)
+ @IsNotEmpty()
+ @IsNumber()
+ customerRabatt: number;
+
+ @ApiProperty({
+ example: '10',
+ description: 'article rabatt',
+ })
+ @ValidateIf((o) => !o.article)
+ @IsNotEmpty()
+ @IsNumber()
+ articleGroupRabatt: number;
+
+ @ApiProperty({
+ example: '3',
+ description: 'Amount of article which will be added',
+ })
+ @IsNotEmpty()
+ @IsNumber()
+ amount: number;
+}
diff --git a/apps/server/src/models/bills/dto/add-update-discount.dto.ts b/apps/server/src/models/bills/dto/add-update-discount.dto.ts
new file mode 100644
index 0000000..8a28aee
--- /dev/null
+++ b/apps/server/src/models/bills/dto/add-update-discount.dto.ts
@@ -0,0 +1,24 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsNotEmpty, IsNumber, IsOptional, ValidateNested } from 'class-validator';
+import { IdDto } from '../../../common/dto/id.dto';
+
+export class CreateUpdateDiscountDto {
+
+ @IsOptional()
+ @IsNumber()
+ id: number;
+
+ @ApiProperty({
+ type: IdDto,
+ })
+ @IsNotEmpty()
+ @ValidateNested()
+ @Type(() => IdDto)
+ articleGroup: IdDto;
+
+ @IsNotEmpty()
+ @IsNumber()
+ value: number;
+
+}
diff --git a/apps/server/src/models/bills/dto/create-annotation.dto.ts b/apps/server/src/models/bills/dto/create-annotation.dto.ts
new file mode 100644
index 0000000..c2a7c89
--- /dev/null
+++ b/apps/server/src/models/bills/dto/create-annotation.dto.ts
@@ -0,0 +1,13 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty } from 'class-validator';
+
+export class CreateAnnotationDto {
+
+ @ApiProperty({
+ example: 'Kommision',
+ description: 'Text input',
+ })
+ @IsNotEmpty()
+ text: string;
+
+}
diff --git a/apps/server/src/models/bills/dto/create-bill.dto.ts b/apps/server/src/models/bills/dto/create-bill.dto.ts
new file mode 100644
index 0000000..7de5a31
--- /dev/null
+++ b/apps/server/src/models/bills/dto/create-bill.dto.ts
@@ -0,0 +1,6 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, Length } from 'class-validator';
+
+export class CreateBillDto {
+
+}
diff --git a/apps/server/src/models/bills/dto/dashboard-summary-query.dto.ts b/apps/server/src/models/bills/dto/dashboard-summary-query.dto.ts
new file mode 100644
index 0000000..b2eb539
--- /dev/null
+++ b/apps/server/src/models/bills/dto/dashboard-summary-query.dto.ts
@@ -0,0 +1,51 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsDateString, IsInt, IsOptional, Min } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class DashboardSummaryQueryDto {
+ @ApiPropertyOptional({
+ description: 'Number of days for trend window (default 30, min 7)',
+ example: 30,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ days?: number;
+
+ @ApiPropertyOptional({
+ description: 'Low stock threshold',
+ example: 10,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ threshold?: number;
+
+ @ApiPropertyOptional({
+ description: 'Optional article group filter',
+ example: 2,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ articleGroupId?: number;
+
+ @ApiPropertyOptional({
+ description: 'Optional trend start timestamp (ISO)',
+ example: '2026-01-01T00:00:00.000Z',
+ })
+ @IsOptional()
+ @IsDateString()
+ from?: string;
+
+ @ApiPropertyOptional({
+ description: 'Optional trend end timestamp (ISO)',
+ example: '2026-01-31T23:59:59.999Z',
+ })
+ @IsOptional()
+ @IsDateString()
+ to?: string;
+}
diff --git a/apps/server/src/models/bills/dto/update-annotation.dto.ts b/apps/server/src/models/bills/dto/update-annotation.dto.ts
new file mode 100644
index 0000000..ea54511
--- /dev/null
+++ b/apps/server/src/models/bills/dto/update-annotation.dto.ts
@@ -0,0 +1,5 @@
+import { CreateAnnotationDto } from './create-annotation.dto';
+
+export class UpdateAnnotationDto extends CreateAnnotationDto {
+
+}
diff --git a/apps/server/src/models/bills/dto/update-bill.dto.ts b/apps/server/src/models/bills/dto/update-bill.dto.ts
new file mode 100644
index 0000000..0356477
--- /dev/null
+++ b/apps/server/src/models/bills/dto/update-bill.dto.ts
@@ -0,0 +1,20 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsOptional, IsString, IsNotEmpty, IsDateString } from 'class-validator';
+
+export class UpdateBillDto {
+ @ApiProperty({
+ example: '4',
+ description: 'Rechnungsnummer',
+ })
+ @IsNotEmpty()
+ @IsString()
+ billNumber: string;
+
+ @ApiProperty({
+ example: '2022-01-01',
+ description: 'Rechnungsdatum',
+ })
+ @IsNotEmpty()
+ @IsDateString()
+ billDate: string;
+}
diff --git a/apps/server/src/models/bills/dto/update-order-entry.dto.ts b/apps/server/src/models/bills/dto/update-order-entry.dto.ts
new file mode 100644
index 0000000..a823c4b
--- /dev/null
+++ b/apps/server/src/models/bills/dto/update-order-entry.dto.ts
@@ -0,0 +1,6 @@
+import { PickType } from '@nestjs/mapped-types';
+import { AddOrderEntryDto } from './add-order-entry.dto';
+
+export class UpdateOrderEntryDto extends PickType(AddOrderEntryDto, ['article', 'text', 'price', 'customerRabatt', 'articleGroupRabatt', 'amount'] as const) {
+
+}
diff --git a/apps/server/src/models/bills/entities/annotation.entity.ts b/apps/server/src/models/bills/entities/annotation.entity.ts
new file mode 100644
index 0000000..755cd68
--- /dev/null
+++ b/apps/server/src/models/bills/entities/annotation.entity.ts
@@ -0,0 +1,30 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ JoinColumn,
+ ManyToOne,
+} from 'typeorm';
+import { IAnnotation } from '../interfaces/annotation.interface';
+import { Slipsheet } from './slipsheet.entity';
+
+@Entity({ name: 'annotation' })
+export class Annotation implements IAnnotation {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false })
+ text: string;
+
+ @ManyToOne(() => Slipsheet, (slipsheet) => slipsheet.orderEntries, { nullable: false })
+ @JoinColumn()
+ slipsheet: Slipsheet;
+
+ @CreateDateColumn({ name: 'created_at', nullable: false })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', nullable: false })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/entities/bill.entity.ts b/apps/server/src/models/bills/entities/bill.entity.ts
new file mode 100644
index 0000000..582633a
--- /dev/null
+++ b/apps/server/src/models/bills/entities/bill.entity.ts
@@ -0,0 +1,39 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ OneToMany,
+} from 'typeorm';
+import { BillState } from '../enums/bill-state.enum';
+import { IBill } from '../interfaces/bill.interface';
+import { SlipsheetEntity } from '../serializers/slipsheet.serializer';
+import { Slipsheet } from './slipsheet.entity';
+
+@Entity({ name: 'bill' })
+export class Bill implements IBill {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false, unique: true })
+ billNumber: string;
+
+ @Column({ nullable: false, default: BillState.OPEN, length: 20 })
+ state: BillState;
+
+ @Column({ type: 'datetime', nullable: false, default: () => 'CURRENT_TIMESTAMP' })
+ billDate: Date;
+
+ @Column({ nullable: true, length: 256 })
+ path: string;
+
+ @OneToMany(() => Slipsheet, (slipsheet) => slipsheet.bill)
+ slipsheets: SlipsheetEntity[];
+
+ @CreateDateColumn({ name: 'created_at', nullable: false })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', nullable: false })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/entities/discount.entity.ts b/apps/server/src/models/bills/entities/discount.entity.ts
new file mode 100644
index 0000000..9904bf7
--- /dev/null
+++ b/apps/server/src/models/bills/entities/discount.entity.ts
@@ -0,0 +1,40 @@
+import { ArticleGroup } from '../../article/entities/article-group.entity';
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ JoinColumn,
+ ManyToOne,
+} from 'typeorm';
+import { IDiscount } from '../interfaces/discount.interface';
+import { Customer } from '../../customer/entities/customer.entity';
+
+
+@Entity({ name: 'discount' })
+export class Discount implements IDiscount {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false })
+ value!: number;
+
+ @ManyToOne(() => ArticleGroup)
+ @JoinColumn()
+ articleGroup!: ArticleGroup;
+ @Column({ nullable: false })
+ articleGroupId!: number;
+
+ @ManyToOne(() => Customer)
+ @JoinColumn()
+ customer!: Customer;
+ @Column({ nullable: false })
+ customerId!: number;
+
+ @CreateDateColumn({ name: 'created_at', nullable: false })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', nullable: false })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/entities/order-entry.entity.ts b/apps/server/src/models/bills/entities/order-entry.entity.ts
new file mode 100644
index 0000000..d3d0d2c
--- /dev/null
+++ b/apps/server/src/models/bills/entities/order-entry.entity.ts
@@ -0,0 +1,47 @@
+import { Article } from '../../article/entities/article.entity';
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { IOrderEntry } from '../interfaces/order-entry.interface';
+import { Slipsheet } from './slipsheet.entity';
+
+@Entity({ name: 'order-entries' })
+export class OrderEntry implements IOrderEntry {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: false })
+ text: string;
+
+ @Column({ type: 'real', nullable: false })
+ amount: number;
+
+ @Column({ type: 'real', nullable: false })
+ price: number;
+
+ @Column({ type: 'real', nullable: false })
+ customerRabatt: number;
+
+ @Column({ type: 'real', nullable: false })
+ articleGroupRabatt: number;
+
+ @ManyToOne(() => Article, (article) => article.orderEntry, { nullable: true })
+ @JoinColumn()
+ article: Article;
+
+ @ManyToOne(() => Slipsheet, (slipsheet) => slipsheet.orderEntries)
+ @JoinColumn()
+ slipsheet: Slipsheet;
+
+ @CreateDateColumn({ name: 'created_at', nullable: false })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', nullable: false })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/entities/slipsheet.entity.ts b/apps/server/src/models/bills/entities/slipsheet.entity.ts
new file mode 100644
index 0000000..eada2bc
--- /dev/null
+++ b/apps/server/src/models/bills/entities/slipsheet.entity.ts
@@ -0,0 +1,57 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ OneToMany,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { Customer } from '../../customer/entities/customer.entity';
+import { SlipsheetState } from '../enums/slipsheet-state.enum';
+import { ISlipsheet } from '../interfaces/slipsheet.interface';
+import { Annotation } from './annotation.entity';
+import { Bill } from './bill.entity';
+import { OrderEntry } from './order-entry.entity';
+
+@Entity({ name: 'slipsheet' })
+export class Slipsheet implements ISlipsheet {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ nullable: true, unique: true })
+ slipsheetnumber: string;
+
+ @CreateDateColumn({ nullable: false })
+ printDate: Date;
+
+ @Column({ nullable: false, default: SlipsheetState.OPEN, length: 20 })
+ state: SlipsheetState;
+
+ @Column({ nullable: true, length: 256 })
+ path: string;
+
+ @OneToMany(() => OrderEntry, (orderEntrie) => orderEntrie.slipsheet, { cascade: false, onDelete: 'CASCADE' })
+ orderEntries: OrderEntry[];
+
+ @OneToMany(() => Annotation, (annotation) => annotation.slipsheet)
+ annotations: Annotation[];
+
+ @ManyToOne(() => Bill, (slipsheet) => slipsheet.slipsheets, { nullable: true, onDelete: 'SET NULL' })
+ @JoinColumn()
+ bill: Bill;
+
+ @Column({ nullable: true })
+ billId: number;
+
+ @ManyToOne(() => Customer, (customer) => customer.slipsheets, { nullable: true })
+ @JoinColumn()
+ customer: Customer;
+
+ @CreateDateColumn({ name: 'created_at', nullable: false })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', nullable: false })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/enums/bill-state.enum.ts b/apps/server/src/models/bills/enums/bill-state.enum.ts
new file mode 100644
index 0000000..22301c3
--- /dev/null
+++ b/apps/server/src/models/bills/enums/bill-state.enum.ts
@@ -0,0 +1,4 @@
+export enum BillState {
+ OPEN = 'open',
+ CLOSED = 'closed',
+}
diff --git a/apps/server/src/models/bills/enums/slipsheet-state.enum.ts b/apps/server/src/models/bills/enums/slipsheet-state.enum.ts
new file mode 100644
index 0000000..7c9278e
--- /dev/null
+++ b/apps/server/src/models/bills/enums/slipsheet-state.enum.ts
@@ -0,0 +1,5 @@
+export enum SlipsheetState {
+ OPEN = 'open',
+ CLOSED = 'closed',
+ CHANGED = 'changed',
+}
diff --git a/apps/server/src/models/bills/interfaces/annotation.interface.ts b/apps/server/src/models/bills/interfaces/annotation.interface.ts
new file mode 100644
index 0000000..ee30a38
--- /dev/null
+++ b/apps/server/src/models/bills/interfaces/annotation.interface.ts
@@ -0,0 +1,6 @@
+import { ISlipsheet } from './slipsheet.interface';
+
+export interface IAnnotation {
+ text: string;
+ slipsheet: ISlipsheet;
+}
diff --git a/apps/server/src/models/bills/interfaces/bill.interface.ts b/apps/server/src/models/bills/interfaces/bill.interface.ts
new file mode 100644
index 0000000..e4e04ec
--- /dev/null
+++ b/apps/server/src/models/bills/interfaces/bill.interface.ts
@@ -0,0 +1,10 @@
+import { BillState } from '../enums/bill-state.enum';
+import { ISlipsheet } from './slipsheet.interface';
+
+export interface IBill {
+ billNumber: string;
+ slipsheets: ISlipsheet[];
+ state: BillState;
+ billDate: Date;
+ path: string;
+}
diff --git a/apps/server/src/models/bills/interfaces/discount.interface.ts b/apps/server/src/models/bills/interfaces/discount.interface.ts
new file mode 100644
index 0000000..0edc056
--- /dev/null
+++ b/apps/server/src/models/bills/interfaces/discount.interface.ts
@@ -0,0 +1,8 @@
+import { IArticleGroup } from '../../article/interfaces/article-group.interface';
+import { ICustomer } from '../../customer/interfaces/customer.interface';
+
+export interface IDiscount {
+ value: number;
+ articleGroup: IArticleGroup;
+ customer: ICustomer;
+}
diff --git a/apps/server/src/models/bills/interfaces/order-entry.interface.ts b/apps/server/src/models/bills/interfaces/order-entry.interface.ts
new file mode 100644
index 0000000..64424be
--- /dev/null
+++ b/apps/server/src/models/bills/interfaces/order-entry.interface.ts
@@ -0,0 +1,12 @@
+import { IArticle } from '../../article/interfaces/article.interface';
+import { ISlipsheet } from './slipsheet.interface';
+
+export interface IOrderEntry {
+ text: string;
+ amount: number;
+ price: number;
+ customerRabatt: number;
+ articleGroupRabatt: number;
+ article: IArticle;
+ slipsheet: ISlipsheet;
+}
diff --git a/apps/server/src/models/bills/interfaces/slipsheet.interface.ts b/apps/server/src/models/bills/interfaces/slipsheet.interface.ts
new file mode 100644
index 0000000..519892e
--- /dev/null
+++ b/apps/server/src/models/bills/interfaces/slipsheet.interface.ts
@@ -0,0 +1,15 @@
+import { ICustomer } from '../../customer/interfaces/customer.interface';
+import { SlipsheetState } from '../enums/slipsheet-state.enum';
+import { IAnnotation } from './annotation.interface';
+import { IBill } from './bill.interface';
+import { IOrderEntry } from './order-entry.interface';
+
+export interface ISlipsheet {
+ slipsheetnumber: string;
+ state: SlipsheetState;
+ path: string;
+ orderEntries: IOrderEntry[];
+ customer: ICustomer;
+ bill: IBill;
+ annotations: IAnnotation[];
+}
diff --git a/apps/server/src/models/bills/order-entry.repository.ts b/apps/server/src/models/bills/order-entry.repository.ts
new file mode 100644
index 0000000..ce192ec
--- /dev/null
+++ b/apps/server/src/models/bills/order-entry.repository.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { OrderEntry } from './entities/order-entry.entity';
+import { allOrderEntryForSerializing, OrderEntryEntity } from './serializers/order-entry.serializer';
+
+@Injectable()
+export class OrderEntryRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(OrderEntry, dataSource.createEntityManager());
+ }
+
+ transform(model: OrderEntry): OrderEntryEntity {
+ const tranformOptions = {
+ groups: allOrderEntryForSerializing,
+ };
+ return plainToInstance(
+ OrderEntryEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: OrderEntry[]): OrderEntryEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/bills/order-entry.service.ts b/apps/server/src/models/bills/order-entry.service.ts
new file mode 100644
index 0000000..29d0585
--- /dev/null
+++ b/apps/server/src/models/bills/order-entry.service.ts
@@ -0,0 +1,131 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { BaseService } from '../../common/base.service';
+import { OrderEntryRepository } from './order-entry.repository';
+import { OrderEntry } from './entities/order-entry.entity';
+import { OrderEntryEntity } from './serializers/order-entry.serializer';
+import { CustomerEntity } from '../customer/serializers/customer.serializer';
+import { SlipsheetEntity } from './serializers/slipsheet.serializer';
+import { AddOrderEntryDto } from './dto/add-order-entry.dto';
+import { SlipsheetService } from './slipsheet.service';
+import { DeepPartial } from 'typeorm';
+import { ArticleService } from '../article/article.service';
+import { BillService } from './bill.service';
+
+@Injectable()
+export class OrderEntryService extends BaseService {
+
+ constructor(
+ private readonly orderEntryRepository: OrderEntryRepository,
+ private readonly slipsheetService: SlipsheetService,
+ private readonly billService: BillService,
+ private readonly articleServie: ArticleService,
+ ) {
+ super(orderEntryRepository);
+ }
+
+
+ async addOrderToSlipsheet(
+ {
+ customer,
+ slipsheet = null,
+ addOrderEntry,
+ }: {
+ customer: CustomerEntity,
+ slipsheet?: SlipsheetEntity,
+ addOrderEntry: AddOrderEntryDto,
+ }): Promise {
+
+ if (!customer && !slipsheet)
+ throw new NotFoundException('Customer not found');
+
+ if (!addOrderEntry)
+ throw new NotFoundException('addOrderEntry not found');
+
+ // Bug #6 fix: use BadRequestException instead of UnprocessableEntityException
+ const hasArticle = !!addOrderEntry.article;
+ const hasText = !!addOrderEntry.text;
+ if (hasArticle === hasText)
+ throw new BadRequestException('ether article or text has to be set, not both or nothing');
+
+ if (!slipsheet)
+ slipsheet = await this.slipsheetService.findOpenForCustomer(customer);
+
+ let retVal = null;
+ if (addOrderEntry.article) {
+ retVal = await this.addEntryByArticle(addOrderEntry, customer, slipsheet);
+ } else {
+ retVal = this.addEntryByText(addOrderEntry, customer, slipsheet);
+ }
+ await this.slipsheetService.changed(slipsheet);
+ if (slipsheet.billId)
+ await this.billService.changed(slipsheet.billId);
+ return retVal;
+ }
+
+
+ addEntryByText(addOrderEntry: AddOrderEntryDto, customer: CustomerEntity, slipsheet: SlipsheetEntity): Promise {
+
+ const orderEntryEntity: DeepPartial = {
+ text: addOrderEntry.text,
+ price: addOrderEntry.price,
+ customerRabatt: addOrderEntry.customerRabatt,
+ amount: addOrderEntry.amount,
+ articleGroupRabatt: addOrderEntry.articleGroupRabatt,
+ slipsheet: slipsheet,
+ };
+ return this.orderEntryRepository.createEntity(orderEntryEntity, ['slipsheet']);
+
+ }
+
+ async addEntryByArticle(addOrderEntry: AddOrderEntryDto, customer: CustomerEntity, slipsheet: SlipsheetEntity): Promise {
+
+ const relations = ['article', 'article.articleGroup', 'slipsheet'];
+ let article = await this.articleServie.get(addOrderEntry.article.id, ['articleGroup']);
+ let order = slipsheet.orderEntries?.find((o) => o.article?.id === addOrderEntry.article.id);
+
+ if (article.singlePos || !order) {
+
+ if (addOrderEntry.amount <= 0)
+ throw new BadRequestException('Amount <= 0, for new or single position');
+
+ const articleDiscount = customer.discounts?.find((o) => o.articleGroupId === article.articleGroup?.id)?.value || 0;
+ const orderEntryEntity: DeepPartial = {
+ article: article,
+ price: article.price,
+ customerRabatt: article.noDiscount ? 0 : customer.customerDiscount,
+ amount: addOrderEntry.amount,
+ articleGroupRabatt: article.noDiscount ? 0 : articleDiscount,
+ text: article.name,
+ slipsheet: slipsheet,
+ };
+ return this.orderEntryRepository.createEntity(orderEntryEntity, relations);
+ } else {
+ if (order.amount + addOrderEntry.amount <= 0)
+ throw new BadRequestException('new amount <= 0, update position');
+
+ return this.orderEntryRepository.updateEntity(
+ order.id,
+ { amount: order.amount + addOrderEntry.amount },
+ relations);
+ }
+ }
+
+
+ override async update(id: number, inputs: DeepPartial): Promise {
+ const relations = ['article', 'article.articleGroup', 'slipsheet'];
+ const orderEntry = await this.orderEntryRepository.get(id, ['slipsheet']);
+ if (!orderEntry)
+ throw new NotFoundException('OrderEntry not found');
+
+ await this.slipsheetService.changed(orderEntry.slipsheet);
+
+ if (orderEntry.slipsheet.billId)
+ await this.billService.changed(orderEntry.slipsheet.billId);
+
+ if (inputs.amount == 0) {
+ this.orderEntryRepository.delete(id);
+ return null;
+ }
+ return this.orderEntryRepository.updateEntity(id, inputs, relations);
+ }
+}
diff --git a/apps/server/src/models/bills/serializers/annotation.serializer.ts b/apps/server/src/models/bills/serializers/annotation.serializer.ts
new file mode 100644
index 0000000..0de93eb
--- /dev/null
+++ b/apps/server/src/models/bills/serializers/annotation.serializer.ts
@@ -0,0 +1,29 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { IAnnotation } from '../interfaces/annotation.interface';
+import { SlipsheetEntity } from './slipsheet.serializer';
+
+export const defaultAnnotationForSerializing: string[] = [
+ 'default',
+ 'annotation.timestamps',
+];
+export const extendedAnnotationForSerializing: string[] = [
+ ...defaultAnnotationForSerializing,
+];
+export const allAnnotationForSerializing: string[] = [
+ ...extendedAnnotationForSerializing,
+];
+export class AnnotationEntity extends ModelEntity implements IAnnotation {
+
+ @Expose({ groups: ['default'] })
+ text: string;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => SlipsheetEntity)
+ slipsheet: SlipsheetEntity;
+
+ @Expose({ groups: ['annotation.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['annotation.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/serializers/bill.serializer.ts b/apps/server/src/models/bills/serializers/bill.serializer.ts
new file mode 100644
index 0000000..32f9130
--- /dev/null
+++ b/apps/server/src/models/bills/serializers/bill.serializer.ts
@@ -0,0 +1,40 @@
+import { Expose } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { BillState } from '../enums/bill-state.enum';
+import { IBill } from '../interfaces/bill.interface';
+import { SlipsheetEntity } from './slipsheet.serializer';
+
+export const defaultBillForSerializing: string[] = [
+ 'default',
+ 'bill_timestamps',
+];
+export const extendedBillForSerializing: string[] = [
+ ...defaultBillForSerializing,
+];
+export const allBillForSerializing: string[] = [
+ ...extendedBillForSerializing,
+];
+export class BillEntity extends ModelEntity implements IBill {
+
+ @Expose({ groups: ['default'] })
+ billNumber: string;
+
+ @Expose({ groups: ['default'] })
+ slipsheets: SlipsheetEntity[];
+
+ @Expose({ groups: ['default'] })
+ state: BillState;
+
+ @Expose({ groups: ['default'] })
+ billDate: Date;
+
+ @Expose({ groups: ['default'] })
+ path: string;
+
+ @Expose({ groups: ['bill_timestamps'] })
+ lastSeen: Date;
+ @Expose({ groups: ['bill_timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['bill_timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/serializers/discount.serializer.ts b/apps/server/src/models/bills/serializers/discount.serializer.ts
new file mode 100644
index 0000000..1153cf4
--- /dev/null
+++ b/apps/server/src/models/bills/serializers/discount.serializer.ts
@@ -0,0 +1,27 @@
+import { Expose } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { ArticleGroupEntity } from '../../article/serializers/article-group.serializer';
+import { CustomerEntity } from '../../customer/serializers/customer.serializer';
+import { IDiscount } from '../interfaces/discount.interface';
+
+export const defaultDiscountForSerializing: string[] = [
+ 'default',
+];
+export const extendedDiscountForSerializing: string[] = [
+ ...defaultDiscountForSerializing,
+];
+export const allDiscountForSerializing: string[] = [
+ ...extendedDiscountForSerializing,
+];
+export class DiscountEntity extends ModelEntity implements IDiscount {
+
+ @Expose({ groups: ['default'] })
+ value: number;
+
+ @Expose({ groups: ['default'] })
+ articleGroup: ArticleGroupEntity;
+
+ @Expose({ groups: ['default'] })
+ customer: CustomerEntity;
+
+}
diff --git a/apps/server/src/models/bills/serializers/order-entry.serializer.ts b/apps/server/src/models/bills/serializers/order-entry.serializer.ts
new file mode 100644
index 0000000..bac3de3
--- /dev/null
+++ b/apps/server/src/models/bills/serializers/order-entry.serializer.ts
@@ -0,0 +1,48 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { ArticleEntity } from '../../article/serializers/article.serializer';
+import { IOrderEntry } from '../interfaces/order-entry.interface';
+import { SlipsheetEntity } from './slipsheet.serializer';
+
+export const defaultOrderEntryForSerializing: string[] = [
+ 'default',
+ 'orderEntry.timestamps',
+];
+export const extendedOrderEntryForSerializing: string[] = [
+ ...defaultOrderEntryForSerializing,
+];
+export const allOrderEntryForSerializing: string[] = [
+ ...extendedOrderEntryForSerializing,
+];
+export class OrderEntryEntity extends ModelEntity implements IOrderEntry {
+
+ @Expose({ groups: ['default'] })
+ text: string;
+
+ @Expose({ groups: ['default'] })
+ amount: number;
+
+ @Expose({ groups: ['default'] })
+ price: number;
+
+ @Expose({ groups: ['default'] })
+ customerRabatt: number;
+
+ @Expose({ groups: ['default'] })
+ articleGroupRabatt: number;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => ArticleEntity)
+ article: ArticleEntity;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => SlipsheetEntity)
+ slipsheet: SlipsheetEntity;
+
+ @Expose({ groups: ['orderEntry.timestamps'] })
+ lastSeen: Date;
+ @Expose({ groups: ['orderEntry.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['orderEntry.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/serializers/slipsheet.serializer.ts b/apps/server/src/models/bills/serializers/slipsheet.serializer.ts
new file mode 100644
index 0000000..885d928
--- /dev/null
+++ b/apps/server/src/models/bills/serializers/slipsheet.serializer.ts
@@ -0,0 +1,56 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { CustomerEntity } from '../../customer/serializers/customer.serializer';
+import { SlipsheetState } from '../enums/slipsheet-state.enum';
+import { ISlipsheet } from '../interfaces/slipsheet.interface';
+import { AnnotationEntity } from './annotation.serializer';
+import { BillEntity } from './bill.serializer';
+import { OrderEntryEntity } from './order-entry.serializer';
+
+export const defaultSlipsheetForSerializing: string[] = [
+ 'default',
+ 'slipsheet.timestamps',
+];
+export const extendedSlipsheetForSerializing: string[] = [
+ ...defaultSlipsheetForSerializing,
+];
+export const allSlipsheetForSerializing: string[] = [
+ ...extendedSlipsheetForSerializing,
+];
+export class SlipsheetEntity extends ModelEntity implements ISlipsheet {
+
+ @Expose({ groups: ['default'] })
+ slipsheetnumber: string;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => OrderEntryEntity)
+ orderEntries: OrderEntryEntity[];
+
+ @Expose({ groups: ['default'] })
+ bill: BillEntity;
+
+ @Expose({ groups: ['default'] })
+ printDate: Date;
+
+ @Expose({ groups: ['default'] })
+ state: SlipsheetState;
+
+ @Expose({ groups: ['default'] })
+ path: string;
+
+ @Expose({ groups: ['default'] })
+ name: string;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => CustomerEntity)
+ customer: CustomerEntity;
+
+ @Expose({ groups: ['default'] })
+ @Type(() => AnnotationEntity)
+ annotations: AnnotationEntity[];
+
+ @Expose({ groups: ['slipsheet.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['slipsheet.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/bills/slipsheet.repository.ts b/apps/server/src/models/bills/slipsheet.repository.ts
new file mode 100644
index 0000000..3506577
--- /dev/null
+++ b/apps/server/src/models/bills/slipsheet.repository.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { Slipsheet } from './entities/slipsheet.entity';
+import { allSlipsheetForSerializing, SlipsheetEntity } from './serializers/slipsheet.serializer';
+
+@Injectable()
+export class SlipsheetRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Slipsheet, dataSource.createEntityManager());
+ }
+
+ transform(model: Slipsheet): SlipsheetEntity {
+ const tranformOptions = {
+ groups: allSlipsheetForSerializing,
+ };
+ return plainToInstance(
+ SlipsheetEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Slipsheet[]): SlipsheetEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/bills/slipsheet.service.ts b/apps/server/src/models/bills/slipsheet.service.ts
new file mode 100644
index 0000000..83084d3
--- /dev/null
+++ b/apps/server/src/models/bills/slipsheet.service.ts
@@ -0,0 +1,228 @@
+import {
+ Injectable,
+ InternalServerErrorException,
+ NotFoundException,
+ UnprocessableEntityException,
+} from '@nestjs/common';
+import { BaseService } from '../../common/base.service';
+import { SlipsheetRepository } from './slipsheet.repository';
+import { Slipsheet } from './entities/slipsheet.entity';
+import { SlipsheetEntity } from './serializers/slipsheet.serializer';
+import { CustomerEntity } from '../customer/serializers/customer.serializer';
+import { SlipsheetState } from './enums/slipsheet-state.enum';
+import { Between, EntityManager } from 'typeorm';
+import { join } from 'path';
+import { PdfMakerService } from '../../common/services/pdfmaker.service';
+import { AppConfigService } from '../../config/app/config.service';
+import { TimeRangeDto } from '../../common/dto/time-range.dto';
+
+@Injectable()
+export class SlipsheetService extends BaseService {
+ constructor(
+ private readonly slipsheetRepository: SlipsheetRepository,
+ private readonly pdfMakerService: PdfMakerService,
+ private readonly appConfigService: AppConfigService,
+ ) {
+ super(slipsheetRepository);
+ }
+
+ getAllInformations(ids: number[]): Promise {
+ return this.slipsheetRepository.getAll(ids, this.getAllRelations());
+ }
+
+ public getAllRelations() {
+ return [
+ 'bill',
+ 'customer',
+ 'customer.discounts',
+ 'customer.discounts.articleGroup',
+ 'orderEntries',
+ 'orderEntries.article',
+ 'orderEntries.article.articleGroup',
+ 'annotations',
+ ];
+ }
+
+ async deleteEmpty(id: number): Promise {
+ const [slip] = await this.getAllInformations([id]);
+ if (!slip) throw new NotFoundException('Lieferschein nicht gefunden.');
+ if (slip.bill) throw new UnprocessableEntityException('Lieferschein ist bereits verrechnet und kann nicht gelöscht werden.');
+ if (slip.orderEntries?.length > 0) throw new UnprocessableEntityException('Lieferschein hat noch Positionen und kann nicht gelöscht werden.');
+ await this.slipsheetRepository.delete(id);
+ }
+
+ async changed(slip: SlipsheetEntity) {
+ if (slip.state !== SlipsheetState.OPEN) {
+ await this.update(slip.id, { state: SlipsheetState.CHANGED });
+ }
+ }
+
+ async generateNumber(
+ entityManager: EntityManager = this.slipsheetRepository.manager,
+ ): Promise {
+ const lastEntry = await entityManager.query(`
+ SELECT "Slipsheet"."slipsheetnumber" AS "slipsheetnumber"
+ FROM "slipsheet" "Slipsheet" WHERE NOT("Slipsheet"."slipsheetnumber" IS NULL) and "Slipsheet"."slipsheetnumber" not like '%/%'
+ ORDER BY CAST("Slipsheet"."slipsheetnumber" as INTEGER) DESC Limit 1`);
+
+ if (lastEntry?.length != 0) {
+ const number = +lastEntry[0]?.slipsheetnumber || 0;
+ return (number + 1).toString();
+ }
+ return '1';
+ }
+
+ async generateSlipsheet(id: number, close: boolean): Promise {
+ let slip: SlipsheetEntity = (await this.getAllInformations([id]))[0];
+ if (!slip) {
+ throw new NotFoundException('Lieferschein nicht gefunden');
+ }
+
+ if (slip.orderEntries.length === 0 && slip.annotations.length === 0) {
+ throw new UnprocessableEntityException('Keine Eintraege auf Lieferschein');
+ }
+
+ if (!slip.path || !slip.slipsheetnumber) {
+ const numberAndPath = await this.reserveSlipNumberAndPath(id, close);
+ slip.slipsheetnumber = numberAndPath.slipsheetnumber;
+ slip.path = numberAndPath.path;
+ } else if (close && slip.state !== SlipsheetState.CLOSED) {
+ await this.update(id, { state: SlipsheetState.CLOSED });
+ slip.state = SlipsheetState.CLOSED;
+ }
+
+ if (close) {
+ slip.state = SlipsheetState.CLOSED;
+ }
+
+ try {
+ const contentd = await this.pdfMakerService.generateDeliverySlip(slip);
+ await this.pdfMakerService.savePDFToFileSystem(
+ contentd,
+ join(this.appConfigService.pdf_slip_path, slip.path),
+ );
+ } catch (error) {
+ throw new InternalServerErrorException(error);
+ }
+
+ slip = (await this.getAllInformations([id]))[0];
+ return slip;
+ }
+
+ closeAndSetBillId(shoppingcartIds: number[], billId: number): Promise {
+ return this.slipsheetRepository.updateEntities(shoppingcartIds, {
+ bill: { id: billId },
+ state: SlipsheetState.CLOSED,
+ });
+ }
+
+ getPathName(slip: SlipsheetEntity): string {
+ return 'L_' + slip.slipsheetnumber + '.pdf';
+ }
+
+ async findOpenForCustomer(customer: CustomerEntity): Promise {
+ if (!customer) {
+ throw new NotFoundException('Customer not found');
+ }
+
+ let returnSlipsheet = await this.slipsheetRepository.findOneAsync(
+ {
+ state: SlipsheetState.OPEN,
+ customer,
+ },
+ [
+ 'customer',
+ 'orderEntries',
+ 'orderEntries.article',
+ 'orderEntries.article.articleGroup',
+ 'annotations',
+ ],
+ false,
+ );
+
+ if (!returnSlipsheet) {
+ returnSlipsheet = await this.slipsheetRepository.createEntity(
+ { customer },
+ [
+ 'customer',
+ 'orderEntries',
+ 'orderEntries.article',
+ 'orderEntries.article.articleGroup',
+ 'annotations',
+ ],
+ );
+ }
+
+ return returnSlipsheet;
+ }
+
+ // Bug fix: TypeORM 0.3 requires { customer: { id: customerId } } instead of { customer: customerId }
+ async findFromCustomer(customerId: number, timerange?: TimeRangeDto): Promise {
+ const filter: any = {
+ customer: { id: +customerId },
+ };
+
+ if (timerange) {
+ filter.createdAt = Between(timerange.from, timerange.to);
+ }
+
+ return this.slipsheetRepository.findAll({
+ filter,
+ relations: ['customer', 'orderEntries', 'orderEntries.article', 'orderEntries.article.articleGroup', 'bill', 'annotations'],
+ });
+ }
+
+ async findByState(state: SlipsheetState): Promise {
+ return this.slipsheetRepository.findAll({
+ filter: { state },
+ relations: [
+ 'customer',
+ 'orderEntries',
+ 'orderEntries.article',
+ 'orderEntries.article.articleGroup',
+ 'bill',
+ 'annotations',
+ ],
+ });
+ }
+
+ private async reserveSlipNumberAndPath(
+ id: number,
+ close: boolean,
+ maxAttempts = 5,
+ ): Promise<{ slipsheetnumber: string; path: string }> {
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const slipsheetnumber = await this.generateNumber();
+ const path = this.getPathName({ slipsheetnumber } as SlipsheetEntity);
+
+ try {
+ await this.update(id, {
+ slipsheetnumber,
+ path,
+ ...(close ? { state: SlipsheetState.CLOSED } : {}),
+ });
+
+ return { slipsheetnumber, path };
+ } catch (error) {
+ if (this.isUniqueConstraintError(error) && attempt < maxAttempts) {
+ continue;
+ }
+ throw error;
+ }
+ }
+
+ throw new InternalServerErrorException(
+ 'Lieferscheinnummer konnte nicht eindeutig erzeugt werden',
+ );
+ }
+
+ private isUniqueConstraintError(error: any): boolean {
+ const code = error?.code || error?.errno;
+ const message = `${error?.message || ''}`.toLowerCase();
+ return (
+ code === 'SQLITE_CONSTRAINT' ||
+ code === '23505' ||
+ message.includes('unique constraint')
+ );
+ }
+}
diff --git a/apps/server/src/models/customer/controllers/customer.controller.ts b/apps/server/src/models/customer/controllers/customer.controller.ts
new file mode 100644
index 0000000..0ecb2be
--- /dev/null
+++ b/apps/server/src/models/customer/controllers/customer.controller.ts
@@ -0,0 +1,162 @@
+import {
+ Get,
+ Put,
+ Post,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ Delete,
+ Patch,
+ forwardRef,
+ Inject,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../../common/res.model';
+import { ApiReS } from '../../../common/decorators/apires.decorator';
+import { CreateCustomerDto } from '../dto/create-customer.dto';
+import { UpdateCustomerDto } from '../dto/update-customer.dto';
+import { CustomerEntity, defaultCustomerForSerializing } from '../serializers/customer.serializer';
+import { CustomerService } from '../customer.service';
+import { AddOrderEntryDto } from '../../bills/dto/add-order-entry.dto';
+import { OrderEntryService } from '../../bills/order-entry.service';
+import { defaultOrderEntryForSerializing, OrderEntryEntity } from '../../bills/serializers/order-entry.serializer';
+import { PdfMakerService } from '../../../common/services/pdfmaker.service';
+import { SlipsheetEntity } from '../../bills/serializers/slipsheet.serializer';
+import { SlipsheetService } from '../../bills/slipsheet.service';
+import { BillEntity, extendedBillForSerializing } from '../../bills/serializers/bill.serializer';
+import { BillService } from '../../bills/bill.service';
+import { CreateUpdateDiscountDto } from '../../bills/dto/add-update-discount.dto';
+import { DiscountService } from '../../bills/discount.service';
+import { DiscountEntity } from '../../bills/serializers/discount.serializer';
+
+
+
+@ApiBearerAuth()
+@Controller('customers')
+@ApiTags('customers')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({
+ excludeExtraneousValues: true,
+ groups: defaultCustomerForSerializing,
+})
+export class CustomerController {
+ constructor(
+ @Inject(forwardRef(() => CustomerService))
+ private readonly customerService: CustomerService,
+ private readonly discountService: DiscountService,
+ private readonly orderEntryService: OrderEntryService,
+ private readonly pdfMakerService: PdfMakerService,
+ @Inject(forwardRef(() => SlipsheetService))
+ private readonly slipsheetRepository: SlipsheetService,
+ @Inject(forwardRef(() => BillService))
+ private readonly billService: BillService,
+ ) {
+ }
+
+ @Get('/:id')
+ @ApiOperation({
+ summary: 'Get specific customer',
+ description: 'Fetchs data of id',
+ })
+ async get(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.customerService.get(id, ['discounts', 'discounts.articleGroup']));
+ }
+
+ @Get('/:id/slipsheets')
+ @ApiOperation({
+ summary: 'Get slipsheets for customer',
+ description: 'Fetchs data of id',
+ })
+ async getSlipsheets(@Param('id') id: number): Promise> {
+ let returnSlipsheet = await this.slipsheetRepository.findFromCustomer(id);
+ return ReS.FromData(returnSlipsheet);
+ }
+
+ @Get('/:id/bills')
+ @ApiOperation({
+ summary: 'Get bills for customer',
+ description: 'Fetchs data of id',
+ })
+ @SerializeOptions({
+ excludeExtraneousValues: true,
+ groups: [...defaultCustomerForSerializing, ...extendedBillForSerializing],
+ })
+ async getBills(@Param('id') id: number): Promise> {
+ return ReS.FromData(await this.billService.findFromCustomer(id));
+ }
+
+ @Get()
+ @UseInterceptors(ClassSerializerInterceptor)
+ @ApiOperation({
+ summary: 'Get all customer',
+ description: 'Fetchs all data',
+ })
+ async getAll(): Promise> {
+ return ReS.FromData(await this.customerService.getAll());
+ }
+
+ @Post()
+ @ApiOperation({
+ summary: 'Create customer',
+ description: 'Fetchs data of id',
+ })
+ async post(@Body() customer: CreateCustomerDto): Promise> {
+ console.log(customer);
+ return ReS.FromData(await this.customerService.create(customer));
+ }
+
+ @Post('/:id/order')
+ @ApiOperation({
+ summary: 'Add article to open slipsheet',
+ description: 'Fetchs data of id',
+ })
+ @SerializeOptions({
+ groups: defaultOrderEntryForSerializing,
+ })
+ async postOrder(
+ @Param('id') id: number,
+ @Body() addOrderEntryDto: AddOrderEntryDto): Promise> {
+ let customer = await this.customerService.get(id);
+
+ const ret = await this.orderEntryService.addOrderToSlipsheet(
+ {
+ customer,
+ addOrderEntry: addOrderEntryDto,
+ });
+ console.log(ret);
+ return ReS.FromData(ret);
+ }
+
+ @Patch(':id')
+ async update(@Param('id') id: number, @Body() updateCustomerDto: UpdateCustomerDto) {
+ return ReS.FromData(await this.customerService.update(id, updateCustomerDto));
+ }
+
+ @Put('/:id/discounts')
+ @ApiOperation({
+ summary: 'Add or update Discount',
+ description: 'Fetchs data of id',
+ })
+ @SerializeOptions({
+ groups: defaultOrderEntryForSerializing,
+ })
+ async postDiscount(
+ @Param('id') id: number,
+ @Body() addUpdateDiscountOrderEntryDto: CreateUpdateDiscountDto): Promise> {
+
+ let customer = await this.customerService.get(id);
+ return ReS.FromData(await this.discountService.createOrUpdate(customer, addUpdateDiscountOrderEntryDto));
+ }
+
+}
diff --git a/apps/server/src/models/customer/customer.module.ts b/apps/server/src/models/customer/customer.module.ts
new file mode 100644
index 0000000..481bfe7
--- /dev/null
+++ b/apps/server/src/models/customer/customer.module.ts
@@ -0,0 +1,22 @@
+import { forwardRef, Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { SharedModule } from '../../common/shared.module';
+import { CustomerRepository } from './customer.repository';
+import { CustomerService } from './customer.service';
+import { CustomerController } from './controllers/customer.controller';
+import { BillModule } from '../bills/bill.module';
+import { ArticleModule } from '../article/article.module';
+import { Customer } from './entities/customer.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([Customer]),
+ SharedModule,
+ forwardRef(() => BillModule),
+ ArticleModule,
+ ],
+ controllers: [CustomerController],
+ providers: [CustomerRepository, CustomerService],
+ exports: [CustomerService],
+})
+export class CustomerModule {}
diff --git a/apps/server/src/models/customer/customer.repository.ts b/apps/server/src/models/customer/customer.repository.ts
new file mode 100644
index 0000000..8613f40
--- /dev/null
+++ b/apps/server/src/models/customer/customer.repository.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
+import { ModelRepository } from '../model.repository';
+import { Customer } from './entities/customer.entity';
+import { allCustomerForSerializing, CustomerEntity } from './serializers/customer.serializer';
+
+@Injectable()
+export class CustomerRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(Customer, dataSource.createEntityManager());
+ }
+
+ transform(model: Customer): CustomerEntity {
+ const tranformOptions: ClassTransformOptions = {
+ groups: allCustomerForSerializing,
+ };
+ return plainToInstance(
+ CustomerEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: Customer[]): CustomerEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/customer/customer.service.ts b/apps/server/src/models/customer/customer.service.ts
new file mode 100644
index 0000000..ac8f03d
--- /dev/null
+++ b/apps/server/src/models/customer/customer.service.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import { BaseService } from '../../common/base.service';
+import { CustomerRepository } from './customer.repository';
+import { Customer } from './entities/customer.entity';
+import { CustomerEntity } from './serializers/customer.serializer';
+
+@Injectable()
+export class CustomerService extends BaseService {
+ constructor(
+ private readonly customerRepository: CustomerRepository,
+ ) {
+ super(customerRepository);
+ }
+}
diff --git a/apps/server/src/models/customer/dto/create-customer.dto.ts b/apps/server/src/models/customer/dto/create-customer.dto.ts
new file mode 100644
index 0000000..12f021b
--- /dev/null
+++ b/apps/server/src/models/customer/dto/create-customer.dto.ts
@@ -0,0 +1,90 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsOptional } from 'class-validator';
+import { ICustomer } from '../interfaces/customer.interface';
+
+export class CreateCustomerDto implements ICustomer {
+
+ @ApiProperty({
+ example: 'Simon',
+ description: 'Firstname',
+ })
+ @IsOptional()
+ firstName: string;
+
+ @ApiProperty({
+ example: 'Abler',
+ description: 'Lastname',
+ })
+ @IsOptional()
+ lastName: string;
+
+ @ApiProperty({
+ example: '00002',
+ description: 'Customer number, has to be unique',
+ })
+ @IsNotEmpty()
+ customerNumber: string;
+
+ @ApiProperty({
+ example: '20',
+ description: 'Generall Discount of customer',
+ })
+ @IsOptional()
+ customerDiscount: number = 0;
+
+ @ApiProperty({
+ example: 'simon.abler@gmail.com',
+ description: 'email',
+ })
+ @IsOptional()
+ email: string;
+
+ @ApiProperty({
+ example: 'Abler gmbh',
+ description: 'Company name',
+ })
+ @IsOptional()
+ companyName: string;
+
+ @ApiProperty({
+ example: 'Musterstraße 4',
+ description: 'Adress with Number',
+ })
+ @IsOptional()
+ address: string;
+
+ @ApiProperty({
+ example: '6020',
+ description: 'Postcode',
+ })
+ @IsOptional()
+ postcode: string;
+
+ @ApiProperty({
+ example: '0000033',
+ description: 'Phonenumber company',
+ })
+ @IsOptional()
+ phoneCompany: string;
+
+ @ApiProperty({
+ example: '00000023',
+ description: 'Phonenumber private',
+ })
+ @IsOptional()
+ phonePrivate: string;
+
+ @ApiProperty({
+ example: 'AT-555422',
+ description: 'UID number',
+ })
+ @IsOptional()
+ uid: string;
+
+ @ApiProperty({
+ example: 'Austria',
+ description: 'Country',
+ })
+ @IsOptional()
+ country: string;
+}
diff --git a/apps/server/src/models/customer/dto/update-customer.dto.ts b/apps/server/src/models/customer/dto/update-customer.dto.ts
new file mode 100644
index 0000000..4888af1
--- /dev/null
+++ b/apps/server/src/models/customer/dto/update-customer.dto.ts
@@ -0,0 +1,5 @@
+import { CreateCustomerDto } from './create-customer.dto';
+
+export class UpdateCustomerDto extends CreateCustomerDto {
+
+}
diff --git a/apps/server/src/models/customer/entities/customer.entity.ts b/apps/server/src/models/customer/entities/customer.entity.ts
new file mode 100644
index 0000000..4bb65d1
--- /dev/null
+++ b/apps/server/src/models/customer/entities/customer.entity.ts
@@ -0,0 +1,58 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ OneToMany,
+} from 'typeorm';
+import { Discount } from '../../bills/entities/discount.entity';
+import { Slipsheet } from '../../bills/entities/slipsheet.entity';
+import { ICustomer } from '../interfaces/customer.interface';
+
+
+@Entity({ name: 'customer' })
+export class Customer implements ICustomer {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+ @Column({ length: 150 })
+ firstName: string;
+ @Column({ length: 150 })
+ lastName: string;
+ @Column({ nullable: false, unique: true, length: 150 })
+ customerNumber: string;
+ @Column({ nullable: false, default: 0 })
+ customerDiscount: number;
+ @Column({ length: 150 })
+ email: string;
+ @Column({ length: 150 })
+ companyName: string;
+ @Column({ length: 150 })
+ address: string;
+ @Column({ length: 10 })
+ postcode: string;
+ @Column({ length: 150 })
+ phoneCompany: string;
+ @Column({ length: 150 })
+ phonePrivate: string;
+ @Column({ length: 50 })
+ uid: string;
+ @Column({ length: 50 })
+ country: string;
+
+ @OneToMany(() => Discount, (discount) => discount.customer)
+ discounts: Discount[];
+
+ @OneToMany(() => Slipsheet, (slipsheets) => slipsheets.customer)
+ slipsheets: Slipsheet[];
+
+ @CreateDateColumn({
+ name: 'created_at',
+ })
+ createdAt: Date;
+
+ @UpdateDateColumn({
+ name: 'updated_at',
+ })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/customer/interfaces/customer.interface.ts b/apps/server/src/models/customer/interfaces/customer.interface.ts
new file mode 100644
index 0000000..4241039
--- /dev/null
+++ b/apps/server/src/models/customer/interfaces/customer.interface.ts
@@ -0,0 +1,14 @@
+export interface ICustomer {
+ firstName: string;
+ lastName: string;
+ customerNumber: string;
+ customerDiscount: number;
+ email: string;
+ companyName: string;
+ address: string;
+ postcode: string;
+ phoneCompany: string;
+ phonePrivate: string;
+ uid: string;
+ country: string;
+}
diff --git a/apps/server/src/models/customer/serializers/customer.serializer.ts b/apps/server/src/models/customer/serializers/customer.serializer.ts
new file mode 100644
index 0000000..f1bc60d
--- /dev/null
+++ b/apps/server/src/models/customer/serializers/customer.serializer.ts
@@ -0,0 +1,53 @@
+import { Expose, Type } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { DiscountEntity } from '../../bills/serializers/discount.serializer';
+import { ICustomer } from '../interfaces/customer.interface';
+
+export const defaultCustomerForSerializing: string[] = [
+ 'default',
+ 'slipsheet.timestamps',
+];
+export const extendedCustomerForSerializing: string[] = [
+ 'customer.timestamps',
+ ...defaultCustomerForSerializing,
+];
+export const allCustomerForSerializing: string[] = [
+ ...extendedCustomerForSerializing,
+];
+export class CustomerEntity
+ extends ModelEntity
+ implements ICustomer {
+
+ @Expose({ groups: ['default'] })
+ firstName: string;
+ @Expose({ groups: ['default'] })
+ lastName: string;
+ @Expose({ groups: ['default'] })
+ customerNumber: string;
+ @Expose({ groups: ['default'] })
+ customerDiscount: number;
+ @Expose({ groups: ['default'] })
+ email: string;
+ @Expose({ groups: ['default'] })
+ companyName: string;
+ @Expose({ groups: ['default'] })
+ address: string;
+ @Expose({ groups: ['default'] })
+ postcode: string;
+ @Expose({ groups: ['default'] })
+ phoneCompany: string;
+ @Expose({ groups: ['default'] })
+ phonePrivate: string;
+ @Expose({ groups: ['default'] })
+ uid: string;
+ @Expose({ groups: ['default'] })
+ country: string;
+ @Expose({ groups: ['default'] })
+ @Type(() => DiscountEntity)
+ discounts: DiscountEntity[];
+
+ @Expose({ groups: ['customer.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['customer.timestamps'] })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/model.repository.ts b/apps/server/src/models/model.repository.ts
new file mode 100644
index 0000000..af4073d
--- /dev/null
+++ b/apps/server/src/models/model.repository.ts
@@ -0,0 +1,138 @@
+import { plainToInstance } from 'class-transformer';
+import { EntityManager, EntityTarget, Repository, DeepPartial, In, InsertResult } from 'typeorm';
+import { NotFoundException } from '@nestjs/common';
+import { ModelEntity } from '../common/serializers/model.serializer';
+import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
+
+export class ModelRepository extends Repository {
+ constructor(target: EntityTarget, manager: EntityManager) {
+ super(target, manager);
+ }
+
+ async get(
+ id: number,
+ relations: string[] = [],
+ throwsException = false,
+ ): Promise {
+ return this.findOne({
+ where: { id } as any,
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(entity ? this.transform(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async getAll(
+ ids: number[],
+ relations: string[] = [],
+ throwsException = false,
+ ): Promise {
+ return this.find({
+ where: { id: In(ids) } as any,
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(entity ? this.transformMany(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async findAll({
+ filter = null,
+ relations = [],
+ throwsException = false,
+ }: {
+ filter?: any;
+ relations?: string[];
+ throwsException?: boolean;
+ }): Promise {
+ return await this.find({
+ where: filter,
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(entity ? this.transformMany(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async findOneAsync(
+ where: any = {},
+ relations: string[] = [],
+ throwsException = false,
+ ): Promise {
+ return await this.findOne({
+ where,
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(entity ? this.transform(entity) : null);
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async createEntity(
+ inputs: DeepPartial,
+ relations: string[] = [],
+ ): Promise {
+ return (
+ this.insert(inputs as any)
+ .then(
+ async (entity: InsertResult) =>
+ await this.get(entity.identifiers[0].id, relations),
+ )
+ .catch((error) => Promise.reject(error))
+ );
+ }
+
+ async updateEntity(
+ id: number,
+ inputs: QueryDeepPartialEntity,
+ relations: string[] = [],
+ ): Promise {
+ return this.update(id, inputs)
+ .then(async () =>
+ await this.get(id, relations)
+ )
+ .catch((error) => Promise.reject(error));
+ }
+
+
+ async updateEntities(
+ ids: number[],
+ inputs: QueryDeepPartialEntity,
+ relations: string[] = [],
+ ): Promise {
+ return this.update(ids, inputs)
+ .then(async () =>
+ await this.getAll(ids, relations)
+ )
+ .catch((error) => Promise.reject(error));
+ }
+
+ transform(model: T, transformOptions = {}): K {
+ return plainToInstance(ModelEntity, model, transformOptions) as K;
+ }
+
+ transformMany(models: T[], transformOptions = {}): K[] {
+ return models.map((model) => this.transform(model, transformOptions));
+ }
+}
diff --git a/apps/server/src/models/settings/company-settings.controller.ts b/apps/server/src/models/settings/company-settings.controller.ts
new file mode 100644
index 0000000..fbe35b9
--- /dev/null
+++ b/apps/server/src/models/settings/company-settings.controller.ts
@@ -0,0 +1,120 @@
+import {
+ BadRequestException,
+ Body,
+ ClassSerializerInterceptor,
+ Controller,
+ Get,
+ Post,
+ Put,
+ SerializeOptions,
+ UploadedFile,
+ UseInterceptors,
+ UsePipes,
+ ValidationPipe,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { diskStorage } from 'multer';
+import { extname, join } from 'path';
+import { ReS } from '../../common/res.model';
+import { UpdateSettingsDto } from './dto/update-settings.dto';
+import { CompanySettingsService } from './company-settings.service';
+import {
+ CompanySettingsEntity,
+ defaultSettingsGroupsForSerializing,
+} from './serializers/company-settings.serializer';
+
+const UPLOAD_DEST = join(process.cwd(), 'uploads', 'settings');
+
+function storageConfig(fieldName: string) {
+ return diskStorage({
+ destination: UPLOAD_DEST,
+ filename: (_req, file, cb) => {
+ cb(null, `${fieldName}${extname(file.originalname)}`);
+ },
+ });
+}
+
+function pdfOnlyFilter(
+ _req: unknown,
+ file: Express.Multer.File,
+ cb: (error: Error | null, acceptFile: boolean) => void,
+) {
+ const isPdf =
+ file.mimetype === 'application/pdf' ||
+ file.originalname.toLowerCase().endsWith('.pdf');
+
+ if (!isPdf) {
+ cb(new BadRequestException('Nur PDF-Dateien sind erlaubt'), false);
+ return;
+ }
+
+ cb(null, true);
+}
+
+@ApiBearerAuth()
+@Controller('settings')
+@ApiTags('settings')
+@UseInterceptors(ClassSerializerInterceptor)
+@SerializeOptions({ groups: defaultSettingsGroupsForSerializing, excludeExtraneousValues: true })
+export class CompanySettingsController {
+ constructor(private readonly settingsService: CompanySettingsService) {}
+
+ @Get('/')
+ @ApiOperation({ summary: 'Einstellungen laden' })
+ async get(): Promise> {
+ return ReS.FromData(await this.settingsService.get());
+ }
+
+ @Put('/')
+ @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
+ @ApiOperation({ summary: 'Einstellungen speichern' })
+ async upsert(@Body() dto: UpdateSettingsDto): Promise> {
+ return ReS.FromData(await this.settingsService.upsert(dto));
+ }
+
+ @Post('/logo')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Logo hochladen (SVG oder PNG)' })
+ @UseInterceptors(FileInterceptor('file', { storage: storageConfig('logo') }))
+ async uploadLogo(@UploadedFile() file: Express.Multer.File): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(await this.settingsService.updateAssetPath('logoPath', relativePath));
+ }
+
+ @Post('/badge1')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Badge links hochladen' })
+ @UseInterceptors(FileInterceptor('file', { storage: storageConfig('badge1') }))
+ async uploadBadge1(@UploadedFile() file: Express.Multer.File): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(await this.settingsService.updateAssetPath('badge1Path', relativePath));
+ }
+
+ @Post('/badge2')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Badge rechts hochladen' })
+ @UseInterceptors(FileInterceptor('file', { storage: storageConfig('badge2') }))
+ async uploadBadge2(@UploadedFile() file: Express.Multer.File): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(await this.settingsService.updateAssetPath('badge2Path', relativePath));
+ }
+
+ @Post('/template-pdf')
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Template-PDF hochladen' })
+ @UseInterceptors(
+ FileInterceptor('file', {
+ storage: storageConfig('template'),
+ fileFilter: pdfOnlyFilter as any,
+ }),
+ )
+ async uploadTemplatePdf(
+ @UploadedFile() file: Express.Multer.File,
+ ): Promise> {
+ const relativePath = join('uploads', 'settings', file.filename);
+ return ReS.FromData(
+ await this.settingsService.updateAssetPath('templatePdfPath', relativePath),
+ );
+ }
+}
diff --git a/apps/server/src/models/settings/company-settings.module.ts b/apps/server/src/models/settings/company-settings.module.ts
new file mode 100644
index 0000000..93973f4
--- /dev/null
+++ b/apps/server/src/models/settings/company-settings.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { CompanySettings } from './entities/company-settings.entity';
+import { CompanySettingsRepository } from './company-settings.repository';
+import { CompanySettingsService } from './company-settings.service';
+import { CompanySettingsController } from './company-settings.controller';
+import { MulterModule } from '@nestjs/platform-express';
+import { join } from 'path';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([CompanySettings]),
+ MulterModule.register({ dest: join(process.cwd(), 'uploads', 'settings') }),
+ ],
+ controllers: [CompanySettingsController],
+ providers: [CompanySettingsRepository, CompanySettingsService],
+ exports: [CompanySettingsService],
+})
+export class CompanySettingsModule {}
diff --git a/apps/server/src/models/settings/company-settings.repository.ts b/apps/server/src/models/settings/company-settings.repository.ts
new file mode 100644
index 0000000..8bb3c89
--- /dev/null
+++ b/apps/server/src/models/settings/company-settings.repository.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { CompanySettings } from './entities/company-settings.entity';
+import {
+ CompanySettingsEntity,
+ defaultSettingsGroupsForSerializing,
+} from './serializers/company-settings.serializer';
+
+@Injectable()
+export class CompanySettingsRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(CompanySettings, dataSource.createEntityManager());
+ }
+
+ transform(model: CompanySettings): CompanySettingsEntity {
+ const transformOptions = { groups: defaultSettingsGroupsForSerializing };
+ return plainToInstance(
+ CompanySettingsEntity,
+ instanceToPlain(model, transformOptions),
+ transformOptions,
+ );
+ }
+
+ transformMany(models: CompanySettings[]): CompanySettingsEntity[] {
+ return models.map((m) => this.transform(m));
+ }
+}
diff --git a/apps/server/src/models/settings/company-settings.service.ts b/apps/server/src/models/settings/company-settings.service.ts
new file mode 100644
index 0000000..a347638
--- /dev/null
+++ b/apps/server/src/models/settings/company-settings.service.ts
@@ -0,0 +1,37 @@
+import { Injectable } from '@nestjs/common';
+import { CompanySettingsRepository } from './company-settings.repository';
+import { CompanySettingsEntity } from './serializers/company-settings.serializer';
+import { UpdateSettingsDto } from './dto/update-settings.dto';
+import { CompanySettings } from './entities/company-settings.entity';
+
+@Injectable()
+export class CompanySettingsService {
+ constructor(private readonly repo: CompanySettingsRepository) {}
+
+ async get(): Promise {
+ return this.repo.get(1, [], false);
+ }
+
+ async upsert(dto: UpdateSettingsDto): Promise {
+ const existing = await this.repo.findOne({ where: { id: 1 } });
+ if (existing) {
+ return this.repo.updateEntity(1, dto as any);
+ }
+ const created = await this.repo.save({ id: 1, ...dto } as CompanySettings);
+ return this.repo.transform(created);
+ }
+
+ async updateAssetPath(
+ field: 'logoPath' | 'badge1Path' | 'badge2Path' | 'templatePdfPath',
+ path: string,
+ ): Promise {
+ const existing = await this.repo.findOne({ where: { id: 1 } });
+ if (existing) {
+ return this.repo.updateEntity(1, { [field]: path } as any);
+ }
+ const created = await this.repo.save(
+ Object.assign(new CompanySettings(), { id: 1, [field]: path }),
+ );
+ return this.repo.transform(created);
+ }
+}
diff --git a/apps/server/src/models/settings/dto/update-settings.dto.ts b/apps/server/src/models/settings/dto/update-settings.dto.ts
new file mode 100644
index 0000000..2e376b2
--- /dev/null
+++ b/apps/server/src/models/settings/dto/update-settings.dto.ts
@@ -0,0 +1,115 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+ IsOptional,
+ IsString,
+ IsNumber,
+ IsArray,
+ ValidateNested,
+ Min,
+ Max,
+ IsIn,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class BankAccountDto {
+ @ApiProperty()
+ @IsString()
+ name: string;
+
+ @ApiProperty()
+ @IsString()
+ iban: string;
+
+ @ApiProperty()
+ @IsString()
+ bic: string;
+}
+
+export class UpdateSettingsDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ companyName?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ street?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ zip?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ city?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ country?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ phone?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ email?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ website?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ firmenbuchnummer?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ vatId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ issueCity?: string;
+
+ @ApiPropertyOptional({ enum: ['generated', 'disabled', 'template_pdf'] })
+ @IsOptional()
+ @IsIn(['generated', 'disabled', 'template_pdf'])
+ letterheadMode?: 'generated' | 'disabled' | 'template_pdf';
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @Type(() => Number)
+ @IsNumber()
+ @Min(0)
+ @Max(100)
+ vatRate?: number;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @Type(() => Number)
+ @IsNumber()
+ @Min(0)
+ paymentTermDays?: number;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ paymentFooterText?: string;
+
+ @ApiPropertyOptional({ type: [BankAccountDto] })
+ @IsOptional()
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => BankAccountDto)
+ bankAccounts?: BankAccountDto[];
+}
diff --git a/apps/server/src/models/settings/entities/company-settings.entity.ts b/apps/server/src/models/settings/entities/company-settings.entity.ts
new file mode 100644
index 0000000..ced7c44
--- /dev/null
+++ b/apps/server/src/models/settings/entities/company-settings.entity.ts
@@ -0,0 +1,79 @@
+import {
+ Column,
+ Entity,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import {
+ ICompanySettings,
+ LetterheadMode,
+} from '../interfaces/company-settings.interface';
+
+@Entity({ name: 'company_settings' })
+export class CompanySettings implements ICompanySettings {
+ @PrimaryColumn({ default: 1 })
+ id: number;
+
+ @Column({ nullable: true, default: null })
+ companyName: string | null;
+
+ @Column({ nullable: true, default: null })
+ street: string | null;
+
+ @Column({ nullable: true, default: null })
+ zip: string | null;
+
+ @Column({ nullable: true, default: null })
+ city: string | null;
+
+ @Column({ nullable: true, default: 'Oesterreich' })
+ country: string | null;
+
+ @Column({ nullable: true, default: null })
+ phone: string | null;
+
+ @Column({ nullable: true, default: null })
+ email: string | null;
+
+ @Column({ nullable: true, default: null })
+ website: string | null;
+
+ @Column({ nullable: true, default: null })
+ firmenbuchnummer: string | null;
+
+ @Column({ nullable: true, default: null })
+ vatId: string | null;
+
+ @Column({ nullable: true, default: null })
+ issueCity: string | null;
+
+ @Column({ default: 20 })
+ vatRate: number;
+
+ @Column({ default: 14 })
+ paymentTermDays: number;
+
+ @Column({ nullable: true, default: null, type: 'text' })
+ paymentFooterText: string | null;
+
+ @Column({ type: 'simple-json', nullable: true, default: '[]' })
+ bankAccounts: Array<{ name: string; iban: string; bic: string }>;
+
+ @Column({ nullable: true, default: null })
+ logoPath: string | null;
+
+ @Column({ nullable: true, default: null })
+ badge1Path: string | null;
+
+ @Column({ nullable: true, default: null })
+ badge2Path: string | null;
+
+ @Column({ nullable: false, default: 'generated', length: 20 })
+ letterheadMode: LetterheadMode;
+
+ @Column({ nullable: true, default: null })
+ templatePdfPath: string | null;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/settings/interfaces/company-settings.interface.ts b/apps/server/src/models/settings/interfaces/company-settings.interface.ts
new file mode 100644
index 0000000..cf00550
--- /dev/null
+++ b/apps/server/src/models/settings/interfaces/company-settings.interface.ts
@@ -0,0 +1,26 @@
+export type LetterheadMode = 'generated' | 'disabled' | 'template_pdf';
+
+export interface ICompanySettings {
+ id: number;
+ companyName: string | null;
+ street: string | null;
+ zip: string | null;
+ city: string | null;
+ country: string | null;
+ phone: string | null;
+ email: string | null;
+ website: string | null;
+ firmenbuchnummer: string | null;
+ vatId: string | null;
+ issueCity: string | null;
+ vatRate: number;
+ paymentTermDays: number;
+ paymentFooterText: string | null;
+ bankAccounts: Array<{ name: string; iban: string; bic: string }>;
+ logoPath: string | null;
+ badge1Path: string | null;
+ badge2Path: string | null;
+ letterheadMode: LetterheadMode;
+ templatePdfPath: string | null;
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/settings/serializers/company-settings.serializer.ts b/apps/server/src/models/settings/serializers/company-settings.serializer.ts
new file mode 100644
index 0000000..97763cb
--- /dev/null
+++ b/apps/server/src/models/settings/serializers/company-settings.serializer.ts
@@ -0,0 +1,29 @@
+import { Expose } from 'class-transformer';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+import { ICompanySettings } from '../interfaces/company-settings.interface';
+
+export const defaultSettingsGroupsForSerializing: string[] = ['default', 'settings.default'];
+
+export class CompanySettingsEntity extends ModelEntity implements ICompanySettings {
+ @Expose({ groups: ['default'] }) companyName: string | null;
+ @Expose({ groups: ['default'] }) street: string | null;
+ @Expose({ groups: ['default'] }) zip: string | null;
+ @Expose({ groups: ['default'] }) city: string | null;
+ @Expose({ groups: ['default'] }) country: string | null;
+ @Expose({ groups: ['default'] }) phone: string | null;
+ @Expose({ groups: ['default'] }) email: string | null;
+ @Expose({ groups: ['default'] }) website: string | null;
+ @Expose({ groups: ['default'] }) firmenbuchnummer: string | null;
+ @Expose({ groups: ['default'] }) vatId: string | null;
+ @Expose({ groups: ['default'] }) issueCity: string | null;
+ @Expose({ groups: ['default'] }) vatRate: number;
+ @Expose({ groups: ['default'] }) paymentTermDays: number;
+ @Expose({ groups: ['default'] }) paymentFooterText: string | null;
+ @Expose({ groups: ['default'] }) bankAccounts: Array<{ name: string; iban: string; bic: string }>;
+ @Expose({ groups: ['default'] }) logoPath: string | null;
+ @Expose({ groups: ['default'] }) badge1Path: string | null;
+ @Expose({ groups: ['default'] }) badge2Path: string | null;
+ @Expose({ groups: ['default'] }) letterheadMode: 'generated' | 'disabled' | 'template_pdf';
+ @Expose({ groups: ['default'] }) templatePdfPath: string | null;
+ @Expose({ groups: ['default'] }) updatedAt: Date;
+}
diff --git a/apps/server/src/models/users/decorators/user.decorator.ts b/apps/server/src/models/users/decorators/user.decorator.ts
new file mode 100644
index 0000000..0957665
--- /dev/null
+++ b/apps/server/src/models/users/decorators/user.decorator.ts
@@ -0,0 +1,8 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+
+export const EntityBeingQueried = createParamDecorator(
+ (data: unknown, ctx: ExecutionContext) => {
+ const request = ctx.switchToHttp().getRequest();
+ return request.user;
+ },
+);
diff --git a/apps/server/src/models/users/dto/create-user.dto.ts b/apps/server/src/models/users/dto/create-user.dto.ts
new file mode 100644
index 0000000..671895b
--- /dev/null
+++ b/apps/server/src/models/users/dto/create-user.dto.ts
@@ -0,0 +1,43 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsEmail, Length } from 'class-validator';
+
+export class CreateUserDto {
+ @ApiProperty({
+ example: 'trinhchin',
+ description: 'The name of the User',
+ })
+ @Length(5, 20)
+ @IsNotEmpty()
+ readonly name: string;
+
+ @ApiProperty({
+ example: 'username',
+ description: 'The name of the User',
+ })
+ @Length(1, 20)
+ @IsNotEmpty()
+ readonly username: string;
+
+ @ApiProperty({
+ example: 'trinhchin.innos@gmail.com',
+ description: 'The email of the User',
+ })
+ @IsEmail()
+ @IsNotEmpty()
+ readonly email: string;
+
+ @ApiProperty({
+ example: '0password',
+ description: 'The password of the User',
+ })
+ @IsNotEmpty()
+ readonly password: string;
+
+ @ApiProperty({
+ example: '11111111',
+ description: 'The referralCode of the User',
+ })
+ @Length(8, 8)
+ @IsNotEmpty()
+ readonly referralCode: string;
+}
diff --git a/apps/server/src/models/users/dto/login-response.dto.ts b/apps/server/src/models/users/dto/login-response.dto.ts
new file mode 100644
index 0000000..c071dab
--- /dev/null
+++ b/apps/server/src/models/users/dto/login-response.dto.ts
@@ -0,0 +1,27 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty } from 'class-validator';
+import { User } from '../entities/user.entity';
+import { UserEntity } from '../serializers/user.serializer';
+
+export class LoginResponseDto {
+ @ApiProperty({
+ example: User,
+ description: 'The user of the LoginResponse',
+ })
+ @IsNotEmpty()
+ readonly user: UserEntity;
+
+ @ApiProperty({
+ example: 'xxxxxxxxxx',
+ description: 'The accessToken of the LoginResponse',
+ })
+ @IsNotEmpty()
+ readonly accessToken: string;
+
+ @ApiProperty({
+ example: 60 * 60 * 24 * 30,
+ description: 'The expiresIn of the LoginResponse',
+ })
+ @IsNotEmpty()
+ readonly expiresIn: number;
+}
diff --git a/apps/server/src/models/users/dto/update-user.dto.ts b/apps/server/src/models/users/dto/update-user.dto.ts
new file mode 100644
index 0000000..2c8d1e9
--- /dev/null
+++ b/apps/server/src/models/users/dto/update-user.dto.ts
@@ -0,0 +1,28 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Length, IsOptional, IsEmail } from 'class-validator';
+
+export class UpdateUserDto {
+ @ApiProperty({
+ required: false,
+ description: 'The name of the User ',
+ })
+ @Length(5, 20)
+ @IsOptional()
+ readonly name: string;
+
+ @ApiProperty({
+ required: false,
+ description: 'The email',
+ })
+ @IsEmail()
+ @IsOptional()
+ readonly email: string;
+
+ @ApiProperty({
+ required: false,
+ description: 'The referralCode of the User',
+ })
+ @Length(8, 8)
+ @IsOptional()
+ readonly referralCode: string;
+}
diff --git a/apps/server/src/models/users/entities/user.entity.ts b/apps/server/src/models/users/entities/user.entity.ts
new file mode 100644
index 0000000..2730f2f
--- /dev/null
+++ b/apps/server/src/models/users/entities/user.entity.ts
@@ -0,0 +1,32 @@
+import {
+ Entity,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+} from 'typeorm';
+import { IUser } from '../interfaces/user.interface';
+
+@Entity({ name: 'users' })
+export class User implements IUser {
+ @PrimaryGeneratedColumn('increment')
+ id: string;
+
+ @Column({ nullable: true, default: null })
+ email: string;
+
+ @Column({ nullable: true, default: null })
+ name: null | string;
+
+ @Column({ nullable: false, default: null, unique: true })
+ username: null | string;
+
+ @Column()
+ password: string;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/models/users/interfaces/user.interface.ts b/apps/server/src/models/users/interfaces/user.interface.ts
new file mode 100644
index 0000000..ef98345
--- /dev/null
+++ b/apps/server/src/models/users/interfaces/user.interface.ts
@@ -0,0 +1,6 @@
+export interface IUser {
+ email: string;
+ name: null | string;
+ username: null | string;
+ password: string;
+}
diff --git a/apps/server/src/models/users/serializers/user.serializer.ts b/apps/server/src/models/users/serializers/user.serializer.ts
new file mode 100644
index 0000000..b5d22ad
--- /dev/null
+++ b/apps/server/src/models/users/serializers/user.serializer.ts
@@ -0,0 +1,37 @@
+import { Expose } from 'class-transformer';
+import { IUser } from '../interfaces/user.interface';
+import { ModelEntity } from '../../../common/serializers/model.serializer';
+
+export const defaultUserGroupsForSerializing: string[] = [
+ 'default',
+ 'user.default',
+];
+export const nameUserGroupsForSerializing: string[] = ['user.name'];
+export const extendedUserGroupsForSerializing: string[] = [
+ ...defaultUserGroupsForSerializing,
+ 'user.timestamps',
+];
+export const allUserGroupsForSerializing: string[] = [
+ ...extendedUserGroupsForSerializing,
+ 'user.password',
+];
+export class UserEntity extends ModelEntity implements IUser {
+ @Expose({ groups: ['default', 'user.default', 'user.name'] })
+ email: string;
+ @Expose({ groups: ['default', 'user.default', 'user.name'] })
+ name: null | string;
+ @Expose({ groups: ['default', 'user.default', 'user.name'] })
+ username: null | string;
+ @Expose({ groups: ['user.password'] })
+ password: string;
+ @Expose({ groups: ['default', 'user.default'] })
+ ldapUser: boolean;
+ @Expose({ groups: ['user.timestamps'] })
+ createdAt: Date;
+ @Expose({ groups: ['user.timestamps'] })
+ updatedAt: Date;
+
+ getName() {
+ return this.name;
+ }
+}
diff --git a/apps/server/src/models/users/users.controller.ts b/apps/server/src/models/users/users.controller.ts
new file mode 100644
index 0000000..21b0b4a
--- /dev/null
+++ b/apps/server/src/models/users/users.controller.ts
@@ -0,0 +1,99 @@
+import {
+ Get,
+ Put,
+ Body,
+ Controller,
+ UseInterceptors,
+ SerializeOptions,
+ ClassSerializerInterceptor,
+ Param,
+ ValidationPipe,
+ UsePipes,
+ Delete,
+} from '@nestjs/common';
+import {
+ UserEntity,
+ extendedUserGroupsForSerializing,
+} from './serializers/user.serializer';
+import { UsersService } from './users.service';
+import { EntityBeingQueried } from './decorators/user.decorator';
+import { UpdateUserDto } from './dto/update-user.dto';
+import {
+ ApiBearerAuth,
+ ApiExtraModels,
+ ApiOperation,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { ReS } from '../../common/res.model';
+
+@ApiBearerAuth()
+@Controller('users')
+@ApiTags('users')
+@ApiExtraModels(ReS)
+@ApiResponse({ status: 403, description: 'Forbidden.' })
+@SerializeOptions({
+ groups: extendedUserGroupsForSerializing,
+})
+export class UsersController {
+ constructor(
+ private readonly usersService: UsersService,
+ ) {
+ }
+
+ @Get('/:id')
+ @UseInterceptors(ClassSerializerInterceptor)
+ @ApiOperation({
+ summary: 'Get specified user',
+ description: 'Fetches data of id',
+ })
+ async get(
+ @Param('id') id: string,
+ @EntityBeingQueried() user: UserEntity,
+ ): Promise> {
+ return ReS.FromData(
+ await this.usersService.get(+id, ['roles', 'roles.permissions']),
+ );
+ }
+
+ @Get('/')
+ @UseInterceptors(ClassSerializerInterceptor)
+ @ApiOperation({
+ summary: 'Get all users',
+ description: 'Fetches all data',
+ })
+ async getAll(): Promise> {
+ return ReS.FromData(
+ await this.usersService.getAll(['roles', 'roles.permissions']),
+ );
+ }
+
+ @Put('/:id')
+ @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
+ @ApiOperation({
+ summary: 'Update specified user',
+ description: 'Fetches new data and updates values',
+ })
+ async update(
+ @Param('id') id: string,
+ @EntityBeingQueried() user: UserEntity,
+ @Body()
+ inputs: UpdateUserDto,
+ ): Promise> {
+ const userToUpdate = await this.usersService.get(+id);
+ const userPostUpdate = await this.usersService.update(userToUpdate, inputs);
+
+ return ReS.FromData(userPostUpdate);
+ }
+
+ @Delete('/:id')
+ @UseInterceptors(ClassSerializerInterceptor)
+ @ApiOperation({
+ summary: 'Delete specified user',
+ description: 'ATTENTION only for sa and admin user',
+ })
+ async delete(@Param('id') id: string): Promise> {
+ await this.usersService.delete(+id, true);
+ return ReS.FromData(null);
+ }
+}
diff --git a/apps/server/src/models/users/users.module.ts b/apps/server/src/models/users/users.module.ts
new file mode 100644
index 0000000..a19c4fa
--- /dev/null
+++ b/apps/server/src/models/users/users.module.ts
@@ -0,0 +1,18 @@
+import { Module } from '@nestjs/common';
+import { UsersService } from './users.service';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { UsersRepository } from './users.repository';
+import { UsersController } from './users.controller';
+import { SharedModule } from '../../common/shared.module';
+import { User } from './entities/user.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([User]),
+ SharedModule,
+ ],
+ controllers: [UsersController],
+ providers: [UsersRepository, UsersService],
+ exports: [UsersService],
+})
+export class UsersModule {}
diff --git a/apps/server/src/models/users/users.repository.ts b/apps/server/src/models/users/users.repository.ts
new file mode 100644
index 0000000..f7efba4
--- /dev/null
+++ b/apps/server/src/models/users/users.repository.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { DataSource } from 'typeorm';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { ModelRepository } from '../model.repository';
+import { User } from './entities/user.entity';
+import { extendedUserGroupsForSerializing, UserEntity } from './serializers/user.serializer';
+
+@Injectable()
+export class UsersRepository extends ModelRepository {
+ constructor(private dataSource: DataSource) {
+ super(User, dataSource.createEntityManager());
+ }
+
+ transform(model: User): UserEntity {
+ const tranformOptions = {
+ groups: extendedUserGroupsForSerializing,
+ };
+ return plainToInstance(
+ UserEntity,
+ instanceToPlain(model, tranformOptions),
+ tranformOptions,
+ );
+ }
+
+ transformMany(models: User[]): UserEntity[] {
+ return models.map((model) => this.transform(model));
+ }
+}
diff --git a/apps/server/src/models/users/users.service.ts b/apps/server/src/models/users/users.service.ts
new file mode 100644
index 0000000..4b78c6c
--- /dev/null
+++ b/apps/server/src/models/users/users.service.ts
@@ -0,0 +1,69 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { UsersRepository } from './users.repository';
+import { UserEntity } from './serializers/user.serializer';
+import { CreateUserDto } from './dto/create-user.dto';
+import { UpdateUserDto } from './dto/update-user.dto';
+
+@Injectable()
+export class UsersService {
+ constructor(
+ private readonly usersRepository: UsersRepository,
+ ) { }
+
+ get(
+ id: number,
+ relations: string[] = [],
+ throwsException = true,
+ ): Promise {
+ return this.usersRepository.get(id, relations, throwsException);
+ }
+
+ getAll(
+ relations: string[] = [],
+ throwsException = true,
+ ): Promise {
+ return this.usersRepository.findAll({ relations, throwsException });
+ }
+
+ async delete(id: number, throwsException = true): Promise {
+ return this.usersRepository
+ .delete(id)
+ .then((result) => {
+ if (throwsException && (!result.affected || result.affected === 0)) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+ return Promise.resolve(true);
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ async getByName(
+ name: number,
+ relations: string[] = [],
+ throwsException = false,
+ ): Promise {
+ return this.usersRepository
+ .findOne({
+ where: { username: String(name) },
+ relations,
+ })
+ .then((entity) => {
+ if (!entity && throwsException) {
+ return Promise.reject(new NotFoundException('Model not found.'));
+ }
+
+ return Promise.resolve(
+ entity ? this.usersRepository.transform(entity) : null,
+ );
+ })
+ .catch((error) => Promise.reject(error));
+ }
+
+ create(inputs: CreateUserDto, relations: string[] = []): Promise {
+ return this.usersRepository.createEntity(inputs, relations);
+ }
+
+ update(user: UserEntity, inputs: UpdateUserDto): Promise {
+ return this.usersRepository.updateEntity(user.id as any, inputs);
+ }
+}
diff --git a/apps/server/src/providers/database/sqlite/provider.module.ts b/apps/server/src/providers/database/sqlite/provider.module.ts
new file mode 100644
index 0000000..1aba828
--- /dev/null
+++ b/apps/server/src/providers/database/sqlite/provider.module.ts
@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule, TypeOrmModuleAsyncOptions } from '@nestjs/typeorm';
+import { SqliteConfigService } from '../../../config/database/sqlite/config.service';
+import { SqliteConfigModule } from '../../../config/database/sqlite/config.module';
+
+@Module({
+ imports: [
+ TypeOrmModule.forRootAsync({
+ imports: [SqliteConfigModule],
+ useFactory: async (sqliteConfigService: SqliteConfigService) => ({
+ type: 'better-sqlite3' as any,
+ database: sqliteConfigService.path,
+ entities: [sqliteConfigService.entities],
+ migrations: ['dist/migration/*.js'],
+ migrationsRun: sqliteConfigService.migrationsRun,
+ synchronize: sqliteConfigService.synchronizeRun,
+ logging: true,
+ }),
+ inject: [SqliteConfigService],
+ } as TypeOrmModuleAsyncOptions),
+ ],
+})
+export class SqliteDatabaseProviderModule {}
diff --git a/apps/server/tsconfig.app.json b/apps/server/tsconfig.app.json
new file mode 100644
index 0000000..582004f
--- /dev/null
+++ b/apps/server/tsconfig.app.json
@@ -0,0 +1,23 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "target": "ES2019",
+ "lib": ["ES2019"],
+ "types": ["node"],
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
+ "strictPropertyInitialization": false,
+ "strict": false,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": [
+ "jest.config.ts",
+ "jest.config.cts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.ts"
+ ]
+}
diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json
new file mode 100644
index 0000000..c1e2dd4
--- /dev/null
+++ b/apps/server/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "compilerOptions": {
+ "esModuleInterop": true
+ }
+}
diff --git a/apps/server/tsconfig.spec.json b/apps/server/tsconfig.spec.json
new file mode 100644
index 0000000..01c59b6
--- /dev/null
+++ b/apps/server/tsconfig.spec.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "moduleResolution": "node10",
+ "types": ["jest", "node"]
+ },
+ "include": [
+ "jest.config.ts",
+ "jest.config.cts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/apps/server/uploads/settings/.gitkeep b/apps/server/uploads/settings/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/apps/server/uploads/settings/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/apps/sim-system-e2e/eslint.config.mjs b/apps/sim-system-e2e/eslint.config.mjs
new file mode 100644
index 0000000..b2e9fac
--- /dev/null
+++ b/apps/sim-system-e2e/eslint.config.mjs
@@ -0,0 +1,12 @@
+import playwright from 'eslint-plugin-playwright';
+import baseConfig from '../../eslint.config.mjs';
+
+export default [
+ playwright.configs['flat/recommended'],
+ ...baseConfig,
+ {
+ files: ['**/*.ts', '**/*.js'],
+ // Override or add rules here
+ rules: {},
+ },
+];
diff --git a/apps/sim-system-e2e/playwright.config.ts b/apps/sim-system-e2e/playwright.config.ts
new file mode 100644
index 0000000..ece5b99
--- /dev/null
+++ b/apps/sim-system-e2e/playwright.config.ts
@@ -0,0 +1,68 @@
+import { defineConfig, devices } from '@playwright/test';
+import { nxE2EPreset } from '@nx/playwright/preset';
+import { workspaceRoot } from '@nx/devkit';
+
+// For CI, you may want to set BASE_URL to the deployed application.
+const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ ...nxE2EPreset(__filename, { testDir: './src' }),
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ baseURL,
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+ /* Run your local dev server before starting the tests */
+ webServer: {
+ command: 'npx nx run sim-system:serve',
+ url: 'http://localhost:4200',
+ reuseExistingServer: true,
+ cwd: workspaceRoot,
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+
+ // Uncomment for mobile browsers support
+ /* {
+ name: 'Mobile Chrome',
+ use: { ...devices['Pixel 5'] },
+ },
+ {
+ name: 'Mobile Safari',
+ use: { ...devices['iPhone 12'] },
+ }, */
+
+ // Uncomment for branded browsers
+ /* {
+ name: 'Microsoft Edge',
+ use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ },
+ {
+ name: 'Google Chrome',
+ use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ } */
+ ],
+});
diff --git a/apps/sim-system-e2e/project.json b/apps/sim-system-e2e/project.json
new file mode 100644
index 0000000..2a81bdb
--- /dev/null
+++ b/apps/sim-system-e2e/project.json
@@ -0,0 +1,9 @@
+{
+ "name": "sim-system-e2e",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "sourceRoot": "apps/sim-system-e2e/src",
+ "implicitDependencies": ["sim-system"],
+ "// targets": "to see all targets run: nx show project sim-system-e2e --web",
+ "targets": {}
+}
diff --git a/apps/sim-system-e2e/src/example.spec.ts b/apps/sim-system-e2e/src/example.spec.ts
new file mode 100644
index 0000000..fa8f1f3
--- /dev/null
+++ b/apps/sim-system-e2e/src/example.spec.ts
@@ -0,0 +1,8 @@
+import { test, expect } from '@playwright/test';
+
+test('has title', async ({ page }) => {
+ await page.goto('/');
+
+ // Expect h1 to contain a substring.
+ expect(await page.locator('h1').innerText()).toContain('Welcome');
+});
diff --git a/apps/sim-system-e2e/tsconfig.json b/apps/sim-system-e2e/tsconfig.json
new file mode 100644
index 0000000..0b670c6
--- /dev/null
+++ b/apps/sim-system-e2e/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "outDir": "../../dist/out-tsc",
+ "sourceMap": false,
+ "module": "commonjs",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.js",
+ "playwright.config.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.spec.js",
+ "src/**/*.test.ts",
+ "src/**/*.test.js",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/apps/sim-system/eslint.config.mjs b/apps/sim-system/eslint.config.mjs
new file mode 100644
index 0000000..9b3aa71
--- /dev/null
+++ b/apps/sim-system/eslint.config.mjs
@@ -0,0 +1,34 @@
+import nx from '@nx/eslint-plugin';
+import baseConfig from '../../eslint.config.mjs';
+
+export default [
+ ...baseConfig,
+ ...nx.configs['flat/angular'],
+ ...nx.configs['flat/angular-template'],
+ {
+ files: ['**/*.ts'],
+ rules: {
+ '@angular-eslint/directive-selector': [
+ 'error',
+ {
+ type: 'attribute',
+ prefix: 'app',
+ style: 'camelCase',
+ },
+ ],
+ '@angular-eslint/component-selector': [
+ 'error',
+ {
+ type: 'element',
+ prefix: 'app',
+ style: 'kebab-case',
+ },
+ ],
+ },
+ },
+ {
+ files: ['**/*.html'],
+ // Override or add rules here
+ rules: {},
+ },
+];
diff --git a/apps/sim-system/jest.config.cts b/apps/sim-system/jest.config.cts
new file mode 100644
index 0000000..899c544
--- /dev/null
+++ b/apps/sim-system/jest.config.cts
@@ -0,0 +1,21 @@
+module.exports = {
+ displayName: 'sim-system',
+ preset: '../../jest.preset.js',
+ setupFilesAfterEnv: ['/src/test-setup.ts'],
+ coverageDirectory: '../../coverage/apps/sim-system',
+ transform: {
+ '^.+\\.(ts|mjs|js|html)$': [
+ 'jest-preset-angular',
+ {
+ tsconfig: '/tsconfig.spec.json',
+ stringifyContentPathRegex: '\\.(html|svg)$',
+ },
+ ],
+ },
+ transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
+ snapshotSerializers: [
+ 'jest-preset-angular/build/serializers/no-ng-attributes',
+ 'jest-preset-angular/build/serializers/ng-snapshot',
+ 'jest-preset-angular/build/serializers/html-comment',
+ ],
+};
diff --git a/apps/sim-system/project.json b/apps/sim-system/project.json
new file mode 100644
index 0000000..2f6b651
--- /dev/null
+++ b/apps/sim-system/project.json
@@ -0,0 +1,84 @@
+{
+ "name": "sim-system",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "prefix": "app",
+ "sourceRoot": "apps/sim-system/src",
+ "tags": [],
+ "targets": {
+ "build": {
+ "executor": "@angular/build:application",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/apps/sim-system",
+ "browser": "apps/sim-system/src/main.ts",
+ "tsConfig": "apps/sim-system/tsconfig.app.json",
+ "inlineStyleLanguage": "scss",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "apps/sim-system/public"
+ }
+ ],
+ "styles": ["apps/sim-system/src/styles.scss"]
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "1mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "4kb",
+ "maximumError": "8kb"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "continuous": true,
+ "executor": "@angular/build:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "sim-system:build:production"
+ },
+ "development": {
+ "buildTarget": "sim-system:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "apps/sim-system/jest.config.cts",
+ "tsConfig": "apps/sim-system/tsconfig.spec.json"
+ }
+ },
+ "serve-static": {
+ "continuous": true,
+ "executor": "@nx/web:file-server",
+ "options": {
+ "buildTarget": "sim-system:build",
+ "port": 4200,
+ "staticFilePath": "dist/apps/sim-system/browser",
+ "spa": true
+ }
+ }
+ }
+}
diff --git a/apps/sim-system/public/favicon.ico b/apps/sim-system/public/favicon.ico
new file mode 100644
index 0000000..317ebcb
Binary files /dev/null and b/apps/sim-system/public/favicon.ico differ
diff --git a/apps/sim-system/src/app/app.config.ts b/apps/sim-system/src/app/app.config.ts
new file mode 100644
index 0000000..101c517
--- /dev/null
+++ b/apps/sim-system/src/app/app.config.ts
@@ -0,0 +1,13 @@
+import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
+import { provideRouter } from '@angular/router';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
+import { appRoutes } from './app.routes';
+import { apiInterceptor } from './helpers/jwt.interceptor';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideBrowserGlobalErrorListeners(),
+ provideRouter(appRoutes),
+ provideHttpClient(withInterceptors([apiInterceptor])),
+ ],
+};
diff --git a/apps/sim-system/src/app/app.html b/apps/sim-system/src/app/app.html
new file mode 100644
index 0000000..5d7964b
--- /dev/null
+++ b/apps/sim-system/src/app/app.html
@@ -0,0 +1,38 @@
+
+