Compare commits

..

107 Commits

Author SHA1 Message Date
34f537238e Kundenanlage mit aktiven Standardwerten vorbelegen #120
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-05-11 18:20:59 +02:00
64df33f0fa Ausgangsbeleg-Toolbar aufräumen #110
Gruppiert Verknüpfungen in ein Dropdown und lädt Bankbuchungen für Ausgangsbelege gezielt mit Bankstatement-Relation nach, damit das Bankbuchungsdatum korrekt angezeigt wird.
2026-05-11 18:15:01 +02:00
94ab3350ec Belegansichten um Bankbuchungsdatum erweitern #110
Zeigt Bankbuchungsdaten in Ausgangsbelegen direkt an und ergänzt Bankdetails im Modal. Überarbeitet die Eingangsbeleg-Showansicht zu einer echten Lesedarstellung mit Bankbuchungsdaten und Positionsübersicht.
2026-05-11 18:04:05 +02:00
aa162dcad3 KI-AGENT: Erweitert Länderauswahl für Lieferanten (#157)
Die Länderauswahl wird beim Laden der Spezialressource um eine vollständige deutschsprachige Standardliste ergänzt. Bestehende Datenbankeinträge bleiben erhalten und überschreiben die Standardwerte.
2026-05-11 17:52:59 +02:00
c42e57494a Behebt Breadcrumb-Navigation im Dateimanager
Issue 170: Die Dateimanager-Breadcrumbs nutzen jetzt die Nuxt-UI-Items-API, zeigen den aktuellen Ordnerpfad an und erlauben die Navigation zu übergeordneten Ordnern über die Route.
2026-05-11 17:41:39 +02:00
582af62fcb Korrigiert Kassenbuch Navbar Struktur
Die Kassenbuch-Listenansicht rendert die DashboardNavbar nun wie andere Listenseiten direkt oben. Die Detailansicht nutzt dieselbe Struktur und hält den Inhalt im PanelContent.
2026-05-11 17:30:42 +02:00
743c0e8772 Verschiebt Kassenbuch Infos in Navbar
Die Detailinformationen zum Kassenbuch werden nun in der Navbar angezeigt. Die separate Kartenzeile oberhalb der Buchungsmaske entfällt.
2026-05-11 17:27:46 +02:00
d4c39d7d44 Passt Kassenbuch Ansicht an
Kassenbücher werden nun zuerst tabellarisch angezeigt. Das Erstellen einer Barkasse erfolgt über ein Modal und einzelne Kassenbücher öffnen sich über eine Detailseite.
2026-05-11 17:25:44 +02:00
e60188f043 Dokument-Steuertypen im FEDEO MCP ergänzen
Erweitert die Ausgangsbeleg-Tools um explizite Dokument-Steuertypen. Der MCP listet die unterstützten Steuertypen, validiert taxType als Dokumentfeld und setzt bei 13b UStG, 19 UStG und 12.3 UStG die Positions-USt analog zur Oberfläche auf 0 Prozent.
2026-05-11 17:25:10 +02:00
ca4f1ba1c0 Eingangsbelege im FEDEO MCP bearbeitbar machen
Ergänzt MCP-Tools zum Prüfen, Bearbeiten, Erstellen, Buchen und Archivieren von Eingangsbelegen. Die Validierung spiegelt die Pflichtregeln aus der FEDEO-Oberfläche und verhindert das Buchen unvollständiger Belege.
2026-05-11 17:17:09 +02:00
5fe823f52a Implementiert Kassenbuch für Barkassen
Barkassen können als manuelle Bankkonten angelegt werden. Kassenbuchungen erzeugen Bankbewegungen mit Gegenkonto und sind über eine neue Buchhaltungsseite erreichbar.
2026-05-11 17:13:49 +02:00
1969610130 Finalisieren von MCP-Ausgangsbelegen korrigieren
Passt die Ausgangsbeleg-Tools an die bestehende FEDEO-Logik an: Entwürfe erhalten keine Belegnummer, Create und Update akzeptieren keine manuellen Nummern mehr, und die Nummernvergabe erfolgt ausschließlich über ein separates Finalisieren-Tool.
2026-05-11 16:38:30 +02:00
2bf52b35fe Ausgangsbelege im FEDEO MCP ergänzen
Erweitert den Accounting-MCP um lesende und schreibende Tools für Ausgangsbelege. Belege können gelistet, geladen, als Entwurf erstellt, aktualisiert und archiviert werden; optional wird beim Schreiben eine Belegnummer aus dem passenden Nummernkreis gezogen.
2026-05-11 16:33:28 +02:00
f01881a6ce Stammdaten-Tools für FEDEO MCP ergänzen
Ergänzt eine eigene MCP-Toolgruppe für Stammdaten. Enthalten sind read-only Werkzeuge für Kunden, Lieferanten, Kontakte, Artikel, Leistungen, Kostenstellen, Niederlassungen, Teams, Fahrzeuge, Inventar und Einheiten.
2026-05-11 13:12:01 +02:00
a185c6eb11 FEDEO MCP-Tools für Organisation erweitern
Ergänzt MCP-Tools für Kunden-, Projekt-, Anlagen- und Terminabfragen sowie das Laden einzelner Bankumsätze. Aufgaben können nun zusätzlich gezielt archiviert werden.
2026-05-11 13:07:11 +02:00
a8450fc0c6 MCP-Server für Buchhaltung und Organisation ergänzen
Fügt einen geschützten MCP-JSON-RPC-Endpunkt mit Buchhaltungs-Tools und Aufgaben-Tools hinzu. Berechtigungen werden rollenbasiert pro Mandant geprüft und die Auth-Logik berücksichtigt nun alle Rollen eines Nutzers.
2026-05-11 12:43:58 +02:00
0f5275b870 Ausgangsrechnungen zeigen korrektes Belegdatum
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m34s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-05-08 20:24:58 +02:00
4f37811dcc Suche im zentralen Logbuch hinzufügen 2026-05-08 20:20:44 +02:00
d7eced3e77 Vertragstypen für Änderungsanfragen pflegen 2026-05-08 20:09:24 +02:00
6a5c1e844d Vertragstypen im Kundenportal freigeben 2026-05-08 20:04:26 +02:00
5dc44e571f Kundenportal Vertragsanfragen ergänzen 2026-05-08 20:01:57 +02:00
2b1a9a456b Ausgangsbelege nach offenen Belegen filterbar machen 2026-05-08 19:36:48 +02:00
bf5d7aaed2 Dateien in Organisation verschoben 2026-05-08 19:27:00 +02:00
e166248c0d Mitarbeiternavigation neu gruppiert 2026-05-08 19:25:07 +02:00
cba4ea52e8 Bearbeiten-Aktion im Plantafel-Mitarbeitermodal ergänzen 2026-04-29 16:37:12 +02:00
0f14f7ac3d Verfügbarkeitshinweise für Mitarbeiter und Plantafel-Details ergänzen 2026-04-29 16:33:39 +02:00
2d26cedaa3 Benutzeranlage direkt aus Mitarbeiterprofil ermöglichen 2026-04-29 16:23:35 +02:00
d5aed2140e Plantafel-Modal für Profile ohne Benutzerzuordnung absichern 2026-04-29 16:19:03 +02:00
cfc5efb556 Plantafel um Mitarbeiterdetails mit Resturlaub erweitern 2026-04-29 16:12:13 +02:00
898a5459fa Ergänze Team-Zuordnung im Mitarbeiterimport 2026-04-29 15:58:32 +02:00
3b7bcb7940 Erweitere Dry-Run-Ausgabe für Mitarbeiterimport 2026-04-29 15:51:07 +02:00
2aaff0088e Füge Branch-Mapping für Mitarbeiterlisten-Import von Tenant 41 hinzu 2026-04-29 15:41:42 +02:00
e9bbc196f7 Füge Importskript für Mitarbeiterlisten mit Tenant- und Niederlassungszuordnung hinzu 2026-04-28 14:06:09 +02:00
20818beb3a Deaktivated Verify Docs Sync
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
Build and Push Docker Images / build-docs (push) Successful in 1m25s
2026-04-27 09:20:32 +02:00
6aa69cb68b Deaktivated Verify Docs Sync
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-docs (push) Has been cancelled
2026-04-27 09:19:33 +02:00
a021d3d15c Time Changes
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 9s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-27 09:17:36 +02:00
bb61caed6d Unterkostenstellen in der Kostenstellenauswertung korrekt auflösen 2026-04-24 23:03:23 +02:00
d3ab03da7e Kostenstellenhierarchie und Auswertung mit Unterkostenstellen ergänzt 2026-04-24 22:56:33 +02:00
5869f88c1a Vereinheitliche Empty States in Tabellen 2026-04-24 14:42:53 +02:00
50c76b67c7 Serienvorlagen in Liquiditätsprognose einplanen 2026-04-23 22:15:50 +02:00
f4edcc2d44 Rechnungsentwürfe optional wie Rechnungen verwenden 2026-04-23 21:49:45 +02:00
35ef3a7cf8 USt-Auswertung in Liquiditätsprognose integrieren 2026-04-23 21:44:59 +02:00
4783971000 Rechnungsentwürfe optional in Prognose anzeigen
Zeigt Rechnungsentwürfe eingeklammert als optionalen Einfluss in der Liquiditätsprognose an, ohne den eigentlichen Endstand zu verändern.
2026-04-23 21:29:14 +02:00
c085b1e4d5 Liquiditätsprognose besser nachvollziehbar machen
Ergänzt Herleitung, Einflussfaktoren, größte Treiber und eine Tagesaufschlüsselung, damit die Prognosewerte Schritt für Schritt nachvollzogen werden können.
2026-04-23 21:21:15 +02:00
46b08b29b9 Regelmäßige Bewegungen auf Ausgaben begrenzen
Berücksichtigt in der Liquiditätsprognose nur noch negative, also ausgehende, wiederkehrende Bankbewegungen aus Heuristik und KI-Erkennung.
2026-04-23 21:18:11 +02:00
5cc41f9a2d Manuelle Buchungen in Liquiditätsprognose korrekt verrechnen
Berechnet offene Eingangsbelege jetzt mit Absolutbeträgen, damit manuelle Buchungen und Bankzuordnungen unabhängig vom gespeicherten Vorzeichen korrekt in der Prognose erscheinen.
2026-04-23 21:13:04 +02:00
edec670ee0 Eingangsbelege um offenen Betrag ergänzen 2026-04-23 21:06:44 +02:00
41e5a4021b Manuelle Buchungen um zuweisbare Eingangsbelege erweitern 2026-04-23 17:52:56 +02:00
9c608cbf71 Manuelle Buchungen im Menü hinter Bank verschieben 2026-04-23 17:39:22 +02:00
543952dbf8 Manuelle Buchungen Select ohne Leerwert absichern 2026-04-23 17:36:32 +02:00
2f7819e309 Manuelle Buchungen um funktionsfähigen Steuerschlüssel und volle Beschreibungsbreite korrigieren 2026-04-23 17:35:32 +02:00
7799cbce80 Manuelle Buchungen um volle Beschreibungsbreite und einklappbare Kontenlisten ergänzen 2026-04-23 17:33:35 +02:00
0284ea8726 Manuelle Buchungen um DATEV-Steuerschlüssel und getrennte Soll/Haben-Auswahl erweitern 2026-04-23 17:24:58 +02:00
743bf0660c Manuelle Buchungen in Statementallocations integrieren 2026-04-23 16:28:44 +02:00
df4b591be4 Liquiditätsprognose zwischenspeichern
Lädt die Liquiditätsprognose aus einem lokalen Cache und erstellt sie nur noch manuell über den Refresh-Button in der Toolbar neu.
2026-04-23 16:15:04 +02:00
86e0743cbb Bezahlte Eingangsbelege aus Liquiditätsprognose entfernen
Berücksichtigt das Bezahlt-Kennzeichen und korrigiert die Verrechnung von Bankzuordnungen bei offenen Eingangsbelegen.
2026-04-23 16:06:57 +02:00
aaf91ea15e Belege und Bankmuster in Liquiditätsprognose verwalten
Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
2026-04-23 16:03:37 +02:00
cb71e9d294 Archivierte Belege aus Liquiditätsprognose ausschließen
Sichert die Liquiditätsprognose zusätzlich gegen archivierte Ausgangs- und Eingangsbelege sowie deren Zuordnungen ab.
2026-04-23 15:56:46 +02:00
75148b2718 Liquiditätsprognose für Auswertungen ergänzt
Erstellt einen KI-gestützten Backend-Endpunkt für die Liquiditätsprognose, ergänzt die Auswertungsseite mit Verlauf, offenen Belegen und regelmäßigen Bankbewegungen und verlinkt die Funktion in der Navigation.
2026-04-23 15:52:41 +02:00
81b4eee1e8 Vereinfache Bedienungsdoku und ergänze Bankportal-Anleitung 2026-04-22 19:43:37 +02:00
0fbda27609 Verschlanke Dokumentation auf Bedienung und leite Einstieg direkt dorthin 2026-04-22 19:27:17 +02:00
3562d55a12 Richte Nutzerdoku auf Bedienungsanleitung mit Frontend-Seitenkategorie aus 2026-04-22 19:21:32 +02:00
6224a25c38 Gestalte Docs-Landing und Header im Nuxt-UI-Template-Stil 2026-04-22 19:13:11 +02:00
63b1c563c1 Stelle docs-site auf Nuxt-UI-Docs-Template-Stil um 2026-04-22 19:07:15 +02:00
76f86e87c1 Behebe 404 im Nuxt-Devserver durch _path-basierte Content-Abfrage 2026-04-22 18:59:11 +02:00
8c458f4953 Ersetze Docusaurus vollständig durch Nuxt-Content-Docs-App
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 9s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 13s
Build and Push Docker Images / build-docs (push) Successful in 1m47s
2026-04-22 18:16:00 +02:00
d704e343fc Behebe Docs-Dockerbuild durch korrekten Build-Pfad
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 42s
2026-04-22 17:57:15 +02:00
4882da0d35 Stabilisiere Docs-Auslieferung unter /docs mit Traefik StripPrefix
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 7s
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-docs (push) Failing after 41s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-04-22 16:06:02 +02:00
1908a6441d Behebe Nginx Redirect-Loop für Docusaurus unter /docs
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 43s
2026-04-22 15:25:42 +02:00
a4735818fb Behebe Docs-Build in CI durch stabile Webpack-Overrides
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 1m48s
2026-04-22 15:09:12 +02:00
4fb3d3c8a0 Stabilisiere Workflow-Imagepfad auf flfeders/fedeo für Compose-Kompatibilität
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Failing after 20s
2026-04-22 14:58:30 +02:00
30dc99e4e0 Integriere Docusaurus in Haupt-Compose mit TLS unter /docs 2026-04-22 14:48:11 +02:00
9fea18b215 Behebe fehlschlagenden Docs-Workflow durch deterministische Doku-Generierung
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 3m18s
Build and Push Docker Images / build-docs (push) Failing after 1m29s
2026-04-22 14:38:51 +02:00
75c15c14c4 Commit Doku
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 8s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-22 14:35:22 +02:00
b27b00f59c Erweitere CI-Workflow um Doku-Sync-Prüfung und Docs-Image-Deploy
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 10s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-21 21:40:55 +02:00
1637d4bd91 Bereite Docusaurus-Deploy mit Docker und eigener Docs-Site vor 2026-04-21 19:45:16 +02:00
8114a8c645 Füge versionierbare Funktionsdokumentation mit automatischer Synchronisierung hinzu 2026-04-21 19:35:55 +02:00
0b7d20d946 Fix materialComp und PWA DEVOptions
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
2026-04-15 10:38:50 +02:00
849e24092e Added Teams
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 26s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
Minor Rework of Plantafel
2026-04-14 21:17:05 +02:00
6fcaf3f65c Fix Export Date Select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m52s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-04-08 20:24:33 +02:00
dce0046e63 Fix Ausgangsbeleg kopieren 2026-04-08 20:24:15 +02:00
02b5769049 Fix BWA Calc 2026-04-08 18:52:16 +02:00
f125617af0 Kundenportal arbeiten 2026-04-08 18:52:04 +02:00
d9e5df07bf serialinvoice fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s
2026-04-01 20:32:16 +02:00
7996c746c3 Add External Link
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s
Fix Plantafel
2026-03-27 21:41:20 +01:00
f679eb3624 Fix Serial invoice
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 2m58s
2026-03-26 23:00:56 +01:00
7ad44544cf Fix #119
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m36s
Build and Push Docker Images / build-frontend (push) Successful in 1m5s
Fix #153
2026-03-25 17:20:47 +01:00
669bcd93ab Tidying in TenantDropdown.vue 2026-03-25 17:14:15 +01:00
aee45e29fd Added Abschreibungen 2026-03-25 17:14:03 +01:00
42e0d7b35e Added Abschreibungen 2026-03-25 17:13:59 +01:00
f6c9875320 Fix #144
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m40s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
2026-03-25 16:04:17 +01:00
05f3b678c4 Fix for Incoming Invoices 2026-03-25 16:03:54 +01:00
eb718021fd Added Kostenschätzung und Packschein
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 22s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:38:28 +01:00
01b4d0f973 Fix for missing Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:19:33 +01:00
c29494dc0d redone admin
added branches
2026-03-25 14:59:44 +01:00
809a37a410 fix workflows 2026-03-25 14:59:32 +01:00
232e3f3260 Fix copy created document modal 2026-03-25 14:59:19 +01:00
b2657f5d52 Fix Worklow Form 2026-03-25 14:58:55 +01:00
cee0e1fa7d Fix Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 08:02:24 +01:00
7dea2de7f3 Fix unit select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 13s
Build and Push Docker Images / build-frontend (push) Successful in 58s
2026-03-23 14:11:40 +01:00
4db753d34a Fix Banking and Profiles
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-23 08:29:54 +01:00
e0e99ba6f5 Fix #146 2026-03-23 08:26:21 +01:00
ace2213cc4 Fix #145 2026-03-23 08:16:44 +01:00
7e6c5cc189 Fix Changelog
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-03-22 22:14:17 +01:00
7c644c941a fix #141
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 57s
fix #142
2026-03-22 22:10:41 +01:00
11a242d70d 4. Zwischenstand
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m0s
2026-03-22 17:43:41 +01:00
9f665fc3b8 3. Zwischenstand 2026-03-22 13:53:29 +01:00
187 changed files with 46878 additions and 2543 deletions

View File

@@ -1,5 +1,5 @@
name: Build and Push Docker Images
run-name: Build Backend & Frontend by @${{ github.actor }}
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
on: [push]
@@ -8,12 +8,38 @@ env:
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
# Beispiel: gitea.deine-domain.de
REGISTRY_HOST: git.federspiel.tech
# Der Name des Repos (z.B. user/repo)
IMAGE_NAME: ${{ github.repository }}
# Der Name des Repos (z.B. user/repo).
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
IMAGE_NAME: flfeders/fedeo
ACTOR: flfeders
jobs:
# verify-docs-sync:
# runs-on: ubuntu-latest
# steps:
# - name: Check out repository code
# uses: actions/checkout@v3
#
# - name: Prüfe Node-Version
# uses: actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Synchronisiere Funktionsdokumentation
# run: node docs/scripts/sync-funktionsdoku.mjs
#
# - name: Breche ab, wenn Doku nicht aktuell committed ist
# run: |
# if [ -n "$(git status --porcelain docs/)" ]; then
# echo "Die generierte Dokumentation ist nicht aktuell."
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
# echo "Danach die Änderungen committen."
# git status --short docs/
# exit 1
# fi
build-backend:
#needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
@@ -46,6 +72,7 @@ jobs:
labels: ${{ steps.meta-backend.outputs.labels }}
build-frontend:
#needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
@@ -74,4 +101,37 @@ jobs:
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
labels: ${{ steps.meta-frontend.outputs.labels }}
build-docs:
needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Docs
id: meta-docs
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docs
uses: docker/build-push-action@v4
with:
context: .
file: ./docs-site/Dockerfile
push: true
tags: ${{ steps.meta-docs.outputs.tags }}
labels: ${{ steps.meta-docs.outputs.labels }}

View File

@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
console.log("[DB INIT] 1. Suche Connection String...");
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
// Checken woher die URL kommt
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
if (connectionString) {
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
if (process.env.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
} else if (secrets.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
} else if (connectionString) {
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
} else {
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
}
@@ -24,4 +30,4 @@ pool.query('SELECT NOW()')
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
export const db = drizzle(pool, { schema });
export const db = drizzle(pool, { schema });

View File

@@ -0,0 +1,37 @@
CREATE TABLE "branches" (
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"number" text,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
CREATE TABLE "auth_profile_branches" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"branch_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
);
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "statementallocations"
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
ADD COLUMN "depreciation_months" integer,
ADD COLUMN "depreciation_start_date" text,
ADD COLUMN "depreciation_label" text,
ADD COLUMN "depreciation_group" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "statementallocations"
ADD COLUMN "depreciation_method" text,
ADD COLUMN "residual_value" double precision;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "products"
ADD COLUMN "supplierLink" text;

View File

@@ -0,0 +1,31 @@
CREATE TABLE "teams" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"branch" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "auth_profile_teams" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"team_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
);
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,10 @@
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "availability_note" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "contracts" ADD COLUMN "allowedContracttypes" jsonb DEFAULT '[]'::jsonb NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -138,30 +138,121 @@
{
"idx": 19,
"version": "7",
"when": 1773489600000,
"tag": "0019_custom_surcharge_percentage_decimal",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773572400000,
"tag": "0020_file_extracted_text",
"breakpoints": true
},
{
"idx": 20,
"idx": 21,
"version": "7",
"when": 1773835200000,
"tag": "0021_admin_user_flag",
"breakpoints": true
},
{
"idx": 21,
"idx": 22,
"version": "7",
"when": 1773925200000,
"tag": "0022_task_dependencies",
"breakpoints": true
},
{
"idx": 22,
"idx": 23,
"version": "7",
"when": 1774080000000,
"tag": "0023_tax_evaluation_period",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774393200000,
"tag": "0024_tenant_branches",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1774393201000,
"tag": "0025_statementallocation_depreciation",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1774393202000,
"tag": "0026_statementallocation_depreciation_method",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1774602000000,
"tag": "0027_product_supplier_link",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1776124800000,
"tag": "0028_teams",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1776211200000,
"tag": "0029_events_quick",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1776297600000,
"tag": "0030_manual_statementallocations",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1776298200000,
"tag": "0031_manual_statementallocations_tax_key",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776298800000,
"tag": "0032_manual_statementallocations_invoice_side",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1777003200000,
"tag": "0033_costcentres_parent",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1778191200000,
"tag": "0035_contract_history",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authProfiles } from "./auth_profiles"
import { branches } from "./branches"
import { authUsers } from "./auth_users"
export const authProfileBranches = pgTable(
"auth_profile_branches",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
profile_id: uuid("profile_id")
.notNull()
.references(() => authProfiles.id, { onDelete: "cascade" }),
branch_id: bigint("branch_id", { mode: "number" })
.notNull()
.references(() => branches.id, { onDelete: "cascade" }),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.profile_id, table.branch_id],
})
)
export type AuthProfileBranch = typeof authProfileBranches.$inferSelect
export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authProfiles } from "./auth_profiles"
import { teams } from "./teams"
import { authUsers } from "./auth_users"
export const authProfileTeams = pgTable(
"auth_profile_teams",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
profile_id: uuid("profile_id")
.notNull()
.references(() => authProfiles.id, { onDelete: "cascade" }),
team_id: bigint("team_id", { mode: "number" })
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.profile_id, table.team_id],
})
)
export type AuthProfileTeam = typeof authProfileTeams.$inferSelect
export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert

View File

@@ -10,6 +10,7 @@ import {
jsonb,
} from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const authProfiles = pgTable("auth_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -71,6 +74,7 @@ export const authProfiles = pgTable("auth_profiles", {
contract_type: text("contract_type"),
position: text("position"),
qualification: text("qualification"),
availability_note: text("availability_note"),
address_street: text("address_street"),
address_zip: text("address_zip"),

View File

@@ -0,0 +1,37 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const branches = pgTable("branches", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
number: text("number"),
description: text("description"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Branch = typeof branches.$inferSelect
export type NewBranch = typeof branches.$inferInsert

View File

@@ -52,6 +52,7 @@ export const contracts = pgTable(
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"),

View File

@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
import { projects } from "./projects"
import { vehicles } from "./vehicles"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const costcentres = pgTable("costcentres", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -28,10 +29,14 @@ export const costcentres = pgTable("costcentres", {
number: text("number").notNull(),
name: text("name").notNull(),
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
project: bigint("project", { mode: "number" }).references(() => projects.id),
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
() => inventoryitems.id
),

View File

@@ -31,6 +31,7 @@ export const events = pgTable(
endDate: timestamp("endDate", { withTimezone: true }),
eventtype: text("eventtype").default("Umsetzung"),
quick: boolean("quick").notNull().default(false),
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists

View File

@@ -35,6 +35,7 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users"
import {files} from "./files";
import { memberrelations } from "./memberrelations";
import { contracts } from "./contracts";
export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" })
@@ -52,6 +53,11 @@ export const historyitems = pgTable("historyitems", {
{ onDelete: "cascade" }
),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id,
{ onDelete: "cascade" }
),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),

View File

@@ -1,5 +1,7 @@
export * from "./accounts"
export * from "./auth_profiles"
export * from "./auth_profile_branches"
export * from "./auth_profile_teams"
export * from "./auth_role_permisssions"
export * from "./auth_roles"
export * from "./auth_tenant_users"
@@ -8,6 +10,7 @@ export * from "./auth_users"
export * from "./bankaccounts"
export * from "./bankrequisitions"
export * from "./bankstatements"
export * from "./branches"
export * from "./checkexecutions"
export * from "./checks"
export * from "./citys"
@@ -67,6 +70,7 @@ export * from "./staff_time_entry_connects"
export * from "./staff_zeitstromtimestamps"
export * from "./statementallocations"
export * from "./tasks"
export * from "./teams"
export * from "./taxtypes"
export * from "./tenants"
export * from "./texttemplates"

View File

@@ -50,6 +50,7 @@ export const products = pgTable("products", {
vendor_allocation: jsonb("vendorAllocation").default([]),
article_number: text("articleNumber"),
supplier_link: text("supplierLink"),
barcodes: text("barcodes").array().notNull().default([]),

View File

@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(),
// foreign keys
bankstatement: integer("bs_id")
.notNull()
.references(() => bankstatements.id),
bankstatement: integer("bs_id").references(() => bankstatements.id),
createddocument: integer("cd_id").references(() => createddocuments.id),
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id
),
manualInvoiceSide: text("manual_invoice_side"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
@@ -43,20 +42,43 @@ export const statementallocations = pgTable("statementallocations", {
() => accounts.id
),
contraAccount: bigint("contra_account", { mode: "number" }).references(
() => accounts.id
),
created_at: timestamp("created_at", {
withTimezone: false,
}).defaultNow(),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
description: text("description"),
manualBookingDate: text("manual_booking_date"),
datevTaxKey: text("datev_tax_key"),
bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"),
depreciationStartDate: text("depreciation_start_date"),
depreciationMethod: text("depreciation_method"),
depreciationLabel: text("depreciation_label"),
depreciationGroup: text("depreciation_group"),
residualValue: doublePrecision("residual_value"),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
() => customers.id
),
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id),

View File

@@ -0,0 +1,40 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const teams = pgTable("teams", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
branch: bigint("branch", { mode: "number" })
.references(() => branches.id),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Team = typeof teams.$inferSelect
export type NewTeam = typeof teams.$inferInsert

View File

@@ -92,6 +92,8 @@ export const tenants = pgTable(
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
branches: true,
teams: true,
accounts: true,
ownaccounts: true,
banking: true,
@@ -127,8 +129,11 @@ export const tenants = pgTable(
customers: { prefix: "", suffix: "", nextNumber: 10000 },
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
deliveryNotes: { prefix: "LS-", suffix: "", nextNumber: 1000 },
packingSlips: { prefix: "PS-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },

View File

@@ -1,11 +1,14 @@
import "dotenv/config"
import { defineConfig } from "drizzle-kit"
import {secrets} from "./src/utils/secrets";
const fallbackDatabaseUrl = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
const databaseUrl = process.env.DATABASE_URL || fallbackDatabaseUrl
export default defineConfig({
dialect: "postgresql",
schema: "./db/schema",
out: "./db/migrations",
dbCredentials: {
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
url: databaseUrl,
},
})
})

View File

@@ -5,6 +5,7 @@
"main": "index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"migrate": "tsx scripts/migrate.ts",
"fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc",
@@ -12,6 +13,7 @@
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
},
"repository": {

View File

@@ -0,0 +1,589 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { execFileSync } from "node:child_process"
import { and, eq } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
authProfileTeams,
branches,
teams,
tenants,
} from "../db/schema"
type ImportRow = {
rowNumber: number
mitarbeiter: string
betrieb: string
anstellung: string
position: string
bereich: string
stundenMonat: number | null
urlaub: number | null
}
type CliOptions = {
workbookPath: string
tenantId: number
dryRun: boolean
defaultBranchId: number | null
branchMap: Record<string, number>
}
type ImportAction = "create" | "update"
function printHelp() {
console.log(`
Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles.
Verwendung:
npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm
Optionen:
--tenant-id=ID Pflicht. Tenant-ID für den Import.
--branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId.
--branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId.
--default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe.
--dry-run Führt keine Schreiboperationen aus.
--help Zeigt diese Hilfe an.
Beispiel branch-map.json:
{
"Strandcafé": 10,
"1848 Pütt": 11,
"Oceans11": 12,
"Winnys": 13
}
`.trim())
}
function parseArgs(argv: string[]): CliOptions | null {
const options: CliOptions = {
workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm",
tenantId: Number.NaN,
dryRun: false,
defaultBranchId: null,
branchMap: {},
}
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
return null
}
if (arg === "--dry-run") {
options.dryRun = true
continue
}
if (arg.startsWith("--tenant-id=")) {
options.tenantId = Number(arg.slice("--tenant-id=".length))
continue
}
if (arg.startsWith("--default-branch-id=")) {
options.defaultBranchId = Number(arg.slice("--default-branch-id=".length))
continue
}
if (arg.startsWith("--branch-map=")) {
options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI")
continue
}
if (arg.startsWith("--branch-map-file=")) {
const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length))
options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath)
continue
}
if (!arg.startsWith("--")) {
options.workbookPath = path.resolve(arg)
continue
}
throw new Error(`Unbekanntes Argument: ${arg}`)
}
if (!Number.isFinite(options.tenantId)) {
throw new Error("Bitte --tenant-id=... angeben.")
}
if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) {
throw new Error("--default-branch-id muss numerisch sein.")
}
return options
}
function parseBranchMap(raw: string, sourceLabel: string): Record<string, number> {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`)
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`)
}
const normalizedEntries = Object.entries(parsed).map(([key, value]) => {
const branchId = Number(value)
if (!Number.isFinite(branchId)) {
throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`)
}
return [normalizeKey(key), branchId] as const
})
return Object.fromEntries(normalizedEntries)
}
function normalizeKey(value: string) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.toLocaleLowerCase("de-DE")
}
function decodeXmlText(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
}
function getWorkbookXml(workbookPath: string, innerPath: string) {
return execFileSync("unzip", ["-p", workbookPath, innerPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
})
}
function readSharedStrings(workbookPath: string) {
const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml")
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
const parts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1]))
return parts.join("")
})
}
function readSheetRows(workbookPath: string, sharedStrings: string[]) {
const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml")
const rows: Record<string, string>[] = []
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
const cellMap: Record<string, string> = {}
const rowXml = rowMatch[2]
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = cellMatch[1]
const cellXml = cellMatch[2]
const refMatch = attrs.match(/r="([A-Z]+)\d+"/)
if (!refMatch) continue
const column = refMatch[1]
const typeMatch = attrs.match(/t="([^"]+)"/)
const type = typeMatch?.[1] || ""
const valueMatch = cellXml.match(/<v>([\s\S]*?)<\/v>/)
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\s\S]*?)<\/t>/)
let value = ""
if (type === "s" && valueMatch) {
value = sharedStrings[Number(valueMatch[1])] || ""
} else if (inlineTextMatch) {
value = decodeXmlText(inlineTextMatch[1])
} else if (valueMatch) {
value = decodeXmlText(valueMatch[1])
}
cellMap[column] = value.trim()
}
rows.push(cellMap)
}
return rows
}
function parseNumber(value: string) {
const normalized = String(value || "").trim().replace(",", ".")
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function parseWorkbook(workbookPath: string): ImportRow[] {
const sharedStrings = readSharedStrings(workbookPath)
const rows = readSheetRows(workbookPath, sharedStrings)
if (!rows.length) {
throw new Error("Die Arbeitsmappe enthält keine Zeilen.")
}
const header = rows[0]
if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") {
throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.")
}
return rows
.slice(1)
.map((row, index) => ({
rowNumber: index + 2,
mitarbeiter: row.A || "",
betrieb: row.B || "",
anstellung: row.C || "",
position: row.D || "",
bereich: row.E || "",
stundenMonat: parseNumber(row.F || ""),
urlaub: parseNumber(row.G || ""),
}))
.filter((row) => row.mitarbeiter)
}
function splitFullName(fullName: string) {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length <= 1) {
return {
firstName: fullName.trim(),
lastName: "Unbekannt",
}
}
return {
firstName: parts.slice(0, -1).join(" "),
lastName: parts[parts.length - 1],
}
}
function toWeeklyHours(monthlyHours: number | null) {
if (monthlyHours == null) return null
return Math.round(((monthlyHours * 12) / 52) * 100) / 100
}
function formatValue(value: unknown) {
if (value == null) return "-"
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
function mapEmploymentCategory(value: string) {
const normalized = normalizeKey(value)
if (normalized === "aushilfe") return "Aushilfen"
if (normalized === "teilzeit" || normalized === "vollzeit") return "Festangestellte"
return null
}
function buildTeamName(bereich: string, anstellung: string) {
const employmentCategory = mapEmploymentCategory(anstellung)
const normalizedBereich = String(bereich || "").trim()
if (!normalizedBereich || !employmentCategory) return null
return `${normalizedBereich} ${employmentCategory}`
}
function collectFieldChanges(existing: any, nextPayload: Record<string, unknown>) {
if (!existing) return []
const changes: string[] = []
for (const [field, nextValue] of Object.entries(nextPayload)) {
const currentValue = existing[field]
const currentFormatted = formatValue(currentValue)
const nextFormatted = formatValue(nextValue)
if (currentFormatted !== nextFormatted) {
changes.push(`${field}: ${currentFormatted} -> ${nextFormatted}`)
}
}
return changes
}
async function validateTenantAndBranches(
db: any,
tenantId: number,
branchIds: number[]
) {
const [tenant] = await db
.select({ id: tenants.id, name: tenants.name })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (!tenant) {
throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`)
}
const branchRows = await db
.select({ id: branches.id, name: branches.name })
.from(branches)
.where(eq(branches.tenant, tenantId))
const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id)))
for (const branchId of branchIds) {
if (!validBranchIds.has(branchId)) {
throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`)
}
}
return {
tenant,
branchRows,
}
}
async function loadTenantTeams(db: any, tenantId: number) {
const teamRows = await db
.select({
id: teams.id,
name: teams.name,
branch: teams.branch,
archived: teams.archived,
})
.from(teams)
.where(eq(teams.tenant, tenantId))
return new Map(
teamRows
.filter((team: any) => !team.archived)
.map((team: any) => [`${team.branch}::${normalizeKey(team.name)}`, team])
)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (!options) {
printHelp()
return
}
if (!fs.existsSync(options.workbookPath)) {
throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`)
}
const rows = parseWorkbook(options.workbookPath)
if (!rows.length) {
throw new Error("Keine importierbaren Mitarbeiter gefunden.")
}
const mappedBranchIds = [
...new Set(
Object.values(options.branchMap)
.concat(options.defaultBranchId != null ? [options.defaultBranchId] : [])
),
]
const { db, pool } = await import("../db")
try {
const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds)
const teamByBranchAndName = await loadTenantTeams(db, options.tenantId)
const existingProfiles = await db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, options.tenantId))
const existingByName = new Map<string, any>()
for (const profile of existingProfiles) {
const key = normalizeKey(`${profile.first_name} ${profile.last_name}`)
if (existingByName.has(key)) {
throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`)
}
existingByName.set(key, profile)
}
const duplicateImportNames = new Set<string>()
const seenImportNames = new Set<string>()
for (const row of rows) {
const key = normalizeKey(row.mitarbeiter)
if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter)
seenImportNames.add(key)
}
if (duplicateImportNames.size) {
throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`)
}
const missingBranchMappings = new Set<string>()
const missingTeams = new Set<string>()
const preparedRows = rows.map((row) => {
const branchKey = normalizeKey(row.betrieb)
const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null
if (!branchId) {
missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`)
}
const teamName = buildTeamName(row.bereich, row.anstellung)
const team = branchId && teamName
? (teamByBranchAndName.get(`${branchId}::${normalizeKey(teamName)}`) as any) || null
: null
if (branchId && teamName && !team) {
missingTeams.add(`${row.betrieb} | ${teamName}`)
}
return {
...row,
branchId,
teamId: team?.id ?? null,
teamName,
weeklyHours: toWeeklyHours(row.stundenMonat),
}
})
if (missingBranchMappings.size) {
throw new Error(
`Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}`
)
}
if (missingTeams.size) {
throw new Error(
`Für folgende Niederlassung-/Bereich-Kombinationen fehlen Teams: ${[...missingTeams].join(", ")}`
)
}
let createdProfiles = 0
let updatedProfiles = 0
const actionLogs: string[] = []
for (const row of preparedRows) {
const nameParts = splitFullName(row.mitarbeiter)
const nameKey = normalizeKey(row.mitarbeiter)
const existing = existingByName.get(nameKey)
const tempConfig = {
...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}),
mitarbeiterImport: {
betrieb: row.betrieb,
bereich: row.bereich,
stundenMonat: row.stundenMonat,
urlaub: row.urlaub,
quelle: path.basename(options.workbookPath),
importiertAm: new Date().toISOString(),
},
}
const payload = {
tenant_id: options.tenantId,
branch_id: row.branchId,
first_name: nameParts.firstName,
last_name: nameParts.lastName,
contract_type: row.anstellung || null,
position: row.position || null,
qualification: row.bereich || null,
weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0,
annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null,
temp_config: tempConfig,
active: existing?.active ?? true,
}
const action: ImportAction = existing ? "update" : "create"
const fieldChanges = existing ? collectFieldChanges(existing, payload) : []
const actionPrefix = action === "create" ? "ERSTELLEN" : "AKTUALISIEREN"
const branchLabel = `${row.betrieb} -> ${row.branchId}`
const teamLabel = row.teamName ? `${row.teamName} -> ${row.teamId}` : "-"
if (action === "create") {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Vertrag ${row.anstellung || "-"} | Position ${row.position || "-"}`
)
} else {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Änderungen: ${fieldChanges.length ? fieldChanges.join("; ") : "keine"}`
)
}
if (!existing) {
if (!options.dryRun) {
const [created] = await db
.insert(authProfiles)
.values(payload)
.returning()
if (!created) {
throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`)
}
await db.insert(authProfileBranches).values({
profile_id: created.id,
branch_id: row.branchId,
})
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: created.id,
team_id: row.teamId,
})
}
existingByName.set(nameKey, created)
}
createdProfiles += 1
continue
}
if (!options.dryRun) {
await db
.update(authProfiles)
.set(payload)
.where(
and(
eq(authProfiles.id, existing.id),
eq(authProfiles.tenant_id, options.tenantId)
)
)
await db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, existing.id))
await db.insert(authProfileBranches).values({
profile_id: existing.id,
branch_id: row.branchId,
})
await db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, existing.id))
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: existing.id,
team_id: row.teamId,
})
}
}
updatedProfiles += 1
}
console.log("")
console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`)
console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`)
console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`)
console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`)
console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`)
console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`)
if (actionLogs.length) {
console.log("[IMPORT MITARBEITER] Details:")
for (const logLine of actionLogs) {
console.log(` ${logLine}`)
}
}
console.log("")
} finally {
await pool.end()
}
}
main().catch((error) => {
console.error("[IMPORT MITARBEITER] Fehler:", error)
process.exitCode = 1
})

View File

@@ -0,0 +1,47 @@
import "dotenv/config"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import { Pool } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
async function run() {
let connectionString = process.env.DATABASE_URL
if (!connectionString) {
await loadSecrets()
connectionString = secrets.DATABASE_URL
}
if (!connectionString) {
throw new Error("DATABASE_URL not configured")
}
const pool = new Pool({
connectionString,
max: 1,
})
try {
const db = drizzle(pool)
await migrate(db, {
migrationsFolder: path.resolve(__dirname, "../db/migrations"),
})
console.log("✅ Drizzle-Migrationen erfolgreich angewendet")
} finally {
await pool.end()
}
}
run().catch((err) => {
console.error("❌ Migration fehlgeschlagen")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,7 @@
{
"1848 Pütt": 1,
"Strandcafé": 3,
"Oceans11": 4,
"Oceans 11": 4,
"Winnys": 5
}

View File

@@ -29,6 +29,8 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
import wikiRoutes from "./routes/wiki";
import portalContractRoutes from "./routes/portal/contracts";
import mcpRoutes from "./routes/mcp";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -146,6 +148,8 @@ async function main() {
await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes);
await subApp.register(portalContractRoutes);
await subApp.register(mcpRoutes);
},{prefix: "/api"})

88
backend/src/mcp/authz.ts Normal file
View File

@@ -0,0 +1,88 @@
import { FastifyInstance, FastifyRequest } from "fastify"
import { and, eq, or, isNull, inArray } from "drizzle-orm"
import {
authRoles,
authRolePermissions,
authUserRoles,
} from "../../db/schema"
import { McpContext, McpTool } from "./types"
export async function loadTenantPermissions(
server: FastifyInstance,
userId: string,
tenantId: number
) {
const roleRows = await server.db
.select({
roleId: authUserRoles.role_id,
})
.from(authUserRoles)
.innerJoin(
authRoles,
and(
eq(authRoles.id, authUserRoles.role_id),
or(isNull(authRoles.tenant_id), eq(authRoles.tenant_id, tenantId))
)
)
.where(
and(
eq(authUserRoles.user_id, userId),
eq(authUserRoles.tenant_id, tenantId)
)
)
const roleIds = Array.from(new Set(roleRows.map((row) => row.roleId)))
if (roleIds.length === 0) return []
const permissionRows = await server.db
.select({
permission: authRolePermissions.permission,
})
.from(authRolePermissions)
.where(inArray(authRolePermissions.role_id, roleIds))
return Array.from(new Set(permissionRows.map((row) => row.permission)))
}
export async function createMcpContext(
server: FastifyInstance,
request: FastifyRequest
): Promise<McpContext> {
const user = request.user
if (!user?.user_id) {
throw Object.assign(new Error("Authentication required"), { statusCode: 401 })
}
if (!user.tenant_id) {
throw Object.assign(new Error("MCP benötigt einen aktiven Mandanten"), { statusCode: 403 })
}
const permissions = await loadTenantPermissions(server, user.user_id, user.tenant_id)
return {
server,
request,
tenantId: user.tenant_id,
userId: user.user_id,
isAdmin: Boolean(user.is_admin),
permissions,
}
}
export function assertToolPermission(context: McpContext, tool: McpTool) {
if (context.isAdmin) return
const allowed = tool.requiredPermissions.every((permission) =>
context.permissions.includes(permission)
)
if (!allowed) {
throw Object.assign(
new Error(`Fehlende Berechtigung für ${tool.name}: ${tool.requiredPermissions.join(", ")}`),
{ statusCode: 403 }
)
}
}

View File

@@ -0,0 +1,11 @@
import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation"
export const mcpTools = [
...accountingTools,
...masterdataTools,
...organisationTools,
]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

36
backend/src/mcp/result.ts Normal file
View File

@@ -0,0 +1,36 @@
import { McpToolResult } from "./types"
export function asToolResult(payload: unknown): McpToolResult {
const structuredContent =
payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: { result: payload }
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent,
}
}
export function asToolError(error: unknown): McpToolResult {
const message = error instanceof Error ? error.message : "Unbekannter Fehler"
return {
content: [
{
type: "text",
text: message,
},
],
isError: true,
structuredContent: {
error: message,
},
}
}

View File

@@ -0,0 +1,970 @@
import { and, desc, eq, ilike, or } from "drizzle-orm"
import {
accounts,
bankstatements,
createddocuments,
files,
incominginvoices,
statementallocations,
} from "../../../db/schema"
import { useNextNumberRangeNumber } from "../../utils/functions"
import { McpTool } from "../types"
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
const raw = Number(args.limit ?? fallback)
if (!Number.isFinite(raw)) return fallback
return Math.min(Math.max(Math.trunc(raw), 1), 100)
}
const stringArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const numberArg = (args: Record<string, unknown>, key: string) => {
const value = Number(args[key])
return Number.isFinite(value) ? value : null
}
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
const allowedOutgoingDocumentTypes = new Set([
"quotes",
"costEstimates",
"confirmationOrders",
"deliveryNotes",
"packingSlips",
"invoices",
"advanceInvoices",
"cancellationInvoices",
"serialInvoices",
])
const allowedOutgoingDocumentTaxTypes = new Set([
"Standard",
"13b UStG",
"19 UStG",
"12.3 UStG",
])
const outgoingDocumentTaxableTypes = new Set([
"invoices",
"cancellationInvoices",
"advanceInvoices",
"serialInvoices",
"confirmationOrders",
"quotes",
"costEstimates",
])
const documentTypeArg = (args: Record<string, unknown>, key = "type") => {
const type = stringArg(args, key) || "invoices"
if (!allowedOutgoingDocumentTypes.has(type)) {
throw new Error(`Ungültige Belegart: ${type}`)
}
return type
}
const normalizeOutgoingDocumentTaxType = (value: unknown) => {
const taxType = typeof value === "string" && value.trim() ? value.trim() : "Standard"
if (!allowedOutgoingDocumentTaxTypes.has(taxType)) {
throw new Error(`Ungültiger Dokument-Steuertyp: ${taxType}`)
}
return taxType
}
const applyOutgoingDocumentTaxType = (
payload: Record<string, unknown>,
args: Record<string, unknown>,
documentType: string,
existingTaxType?: unknown,
existingRows?: unknown
) => {
if (!outgoingDocumentTaxableTypes.has(documentType)) {
payload.taxType = null
return
}
const taxType = args.taxType !== undefined
? normalizeOutgoingDocumentTaxType(args.taxType)
: existingTaxType
? normalizeOutgoingDocumentTaxType(existingTaxType)
: "Standard"
payload.taxType = taxType
if (["13b UStG", "19 UStG", "12.3 UStG"].includes(taxType)) {
const rows = Array.isArray(payload.rows)
? payload.rows
: Array.isArray(existingRows)
? existingRows
: null
if (!rows) return
payload.rows = rows.map((row: any) => ({
...row,
taxPercent: 0,
}))
}
}
const optionalObjectArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return value && typeof value === "object" && !Array.isArray(value) ? value : null
}
const optionalArrayArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return Array.isArray(value) ? value : null
}
const buildOutgoingDocumentPayload = (
args: Record<string, unknown>,
userId: string,
tenantId: number,
includeCreateDefaults = false
) => {
const payload: Record<string, unknown> = {
updatedAt: new Date(),
updatedBy: userId,
}
if (includeCreateDefaults) {
payload.tenant = tenantId
payload.createdBy = userId
payload.created_by = userId
payload.archived = false
payload.state = stringArg(args, "state") || "Entwurf"
payload.type = documentTypeArg(args)
payload.rows = optionalArrayArg(args, "rows") || []
}
const stringFields = [
"state",
"documentDate",
"deliveryDate",
"deliveryDateEnd",
"deliveryDateType",
"payment_type",
"title",
"description",
"startText",
"endText",
]
for (const field of stringFields) {
if (args[field] !== undefined) payload[field] = stringArg(args, field)
}
for (const field of ["customer", "contact", "contract", "project", "plant", "letterhead", "createddocument"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field)
}
for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field)
}
if (args.type !== undefined) payload.type = documentTypeArg(args)
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
if (args.info !== undefined) payload.info = optionalObjectArg(args, "info")
if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture")
if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {}
if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {}
if (args.rows !== undefined) payload.rows = optionalArrayArg(args, "rows") || []
if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || []
if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal
if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved
if (args.customSurchargePercentage !== undefined) payload.customSurchargePercentage = numberArg(args, "customSurchargePercentage") || 0
return payload
}
const assertNoManualDocumentNumber = (args: Record<string, unknown>) => {
if (args.documentNumber !== undefined || args.assignDocumentNumber !== undefined) {
throw new Error("Belegnummern werden nur beim Finalisieren vergeben")
}
}
const incomingInvoiceAccountsArg = (args: Record<string, unknown>) => {
if (args.accounts === undefined) return undefined
if (!Array.isArray(args.accounts)) throw new Error("accounts muss ein Array sein")
return args.accounts
}
const buildIncomingInvoicePayload = (
args: Record<string, unknown>,
userId: string,
tenantId: number,
includeCreateDefaults = false
) => {
const payload: Record<string, unknown> = {
updatedAt: new Date(),
updatedBy: userId,
}
if (includeCreateDefaults) {
payload.tenant = tenantId
payload.state = "Entwurf"
payload.expense = args.expense !== undefined ? args.expense === true : true
payload.paid = false
payload.archived = false
}
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
if (args[field] !== undefined) payload[field] = stringArg(args, field)
}
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
if (args.document !== undefined) payload.document = numberArg(args, "document")
if (args.expense !== undefined) payload.expense = args.expense === true
if (args.paid !== undefined) payload.paid = args.paid === true
const accountsPayload = incomingInvoiceAccountsArg(args)
if (accountsPayload !== undefined) payload.accounts = accountsPayload
return payload
}
const isDepreciationBookingMode = (value: unknown) =>
["depreciation", "depreciation_bundle"].includes(String(value || ""))
const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
const errors: Array<{ message: string; type: "breaking" | "warning" }> = []
if (!invoice.vendor) errors.push({ message: "Es ist kein Lieferant ausgewählt", type: "breaking" })
if (!String(invoice.reference || "").trim()) errors.push({ message: "Es ist keine Referenz angegeben", type: "breaking" })
if (!invoice.date) errors.push({ message: "Es ist kein Datum ausgewählt", type: "breaking" })
if (!Array.isArray(invoice.accounts) || invoice.accounts.length === 0) {
errors.push({ message: "Es ist keine Position vorhanden", type: "breaking" })
}
;(Array.isArray(invoice.accounts) ? invoice.accounts : []).forEach((account: any, idx: number) => {
const pos = idx + 1
if (!account?.account) errors.push({ message: `Pos ${pos}: Keine Kategorie`, type: "breaking" })
if (!hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountGross)) {
errors.push({ message: `Pos ${pos}: Kein gültiger Betrag`, type: "breaking" })
}
if (!account?.taxType) errors.push({ message: `Pos ${pos}: Kein Steuerschlüssel`, type: "breaking" })
if (hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountTax)) {
errors.push({ message: `Pos ${pos}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning" })
}
if (isDepreciationBookingMode(account?.bookingMode) && !Number(account?.depreciationMonths)) {
errors.push({ message: `Pos ${pos}: Abschreibungsdauer fehlt`, type: "breaking" })
}
if (account?.bookingMode === "depreciation_bundle" && !String(account?.depreciationGroup || "").trim()) {
errors.push({ message: `Pos ${pos}: Sammelposten benötigt einen Gruppennamen`, type: "breaking" })
}
})
const order = { breaking: 0, warning: 1 }
errors.sort((a, b) => order[a.type] - order[b.type])
return {
valid: errors.every((error) => error.type !== "breaking"),
errors,
blockingErrors: errors.filter((error) => error.type === "breaking"),
warnings: errors.filter((error) => error.type === "warning"),
}
}
export const accountingTools: McpTool[] = [
{
name: "accounting.outgoing_documents.tax_types.list",
title: "Steuertypen für Ausgangsbelege auflisten",
description: "Listet die unterstützten Dokument-Steuertypen für Ausgangsbelege.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
properties: {},
},
async handler() {
return {
rows: [
{ key: "Standard", label: "Standard", forcesZeroTaxPercent: false },
{ key: "13b UStG", label: "13b UStG", forcesZeroTaxPercent: true },
{ key: "19 UStG", label: "19 UStG Kleinunternehmer", forcesZeroTaxPercent: true },
{ key: "12.3 UStG", label: "12.3 UStG", forcesZeroTaxPercent: true },
],
}
},
},
{
name: "accounting.outgoing_documents.list",
title: "Ausgangsbelege auflisten",
description: "Listet Ausgangsbelege des aktiven Mandanten mit optionalen Filtern.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
properties: {
type: { type: "string", description: "Belegart, z. B. invoices, quotes oder deliveryNotes." },
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
customer: { type: "number" },
project: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(createddocuments.tenant, context.tenantId)]
const type = stringArg(args, "type")
const state = stringArg(args, "state")
const customer = numberArg(args, "customer")
const project = numberArg(args, "project")
if (type) {
if (!allowedOutgoingDocumentTypes.has(type)) throw new Error(`Ungültige Belegart: ${type}`)
conditions.push(eq(createddocuments.type, type))
}
if (state) conditions.push(eq(createddocuments.state, state))
if (customer) conditions.push(eq(createddocuments.customer, customer))
if (project) conditions.push(eq(createddocuments.project, project))
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
const rows = await context.server.db
.select()
.from(createddocuments)
.where(and(...conditions))
.orderBy(desc(createddocuments.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "accounting.outgoing_documents.get",
title: "Ausgangsbeleg laden",
description: "Lädt einen Ausgangsbeleg des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
return { document: rows[0] }
},
},
{
name: "accounting.outgoing_documents.create",
title: "Ausgangsbeleg erstellen",
description: "Erstellt einen Ausgangsbeleg-Entwurf im aktiven Mandanten. Belegnummern werden erst beim Finalisieren vergeben.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["type"],
properties: {
type: { type: "string" },
customer: { type: "number" },
contact: { type: "number" },
contract: { type: "number" },
project: { type: "number" },
plant: { type: "number" },
documentDate: { type: "string" },
deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" },
paymentDays: { type: "number" },
payment_type: { type: "string" },
taxType: {
type: "string",
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
},
title: { type: "string" },
description: { type: "string" },
startText: { type: "string" },
endText: { type: "string" },
address: { type: "object" },
rows: { type: "array" },
letterhead: { type: "number" },
availableInPortal: { type: "boolean" },
customSurchargePercentage: { type: "number" },
report: { type: "object" },
},
},
async handler(context, args) {
assertNoManualDocumentNumber(args)
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
payload.state = "Entwurf"
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
const [created] = await context.server.db
.insert(createddocuments)
.values(payload as any)
.returning()
return { document: created }
},
},
{
name: "accounting.outgoing_documents.update",
title: "Ausgangsbeleg aktualisieren",
description: "Aktualisiert einen Ausgangsbeleg im aktiven Mandanten, solange er noch nicht finalisiert ist.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
type: { type: "string" },
state: { type: "string" },
customer: { type: "number" },
contact: { type: "number" },
contract: { type: "number" },
project: { type: "number" },
plant: { type: "number" },
documentDate: { type: "string" },
deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" },
paymentDays: { type: "number" },
payment_type: { type: "string" },
taxType: {
type: "string",
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
},
title: { type: "string" },
description: { type: "string" },
startText: { type: "string" },
endText: { type: "string" },
address: { type: "object" },
rows: { type: "array" },
letterhead: { type: "number" },
availableInPortal: { type: "boolean" },
customSurchargePercentage: { type: "number" },
report: { type: "object" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
assertNoManualDocumentNumber(args)
const [existing] = await context.server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
payload.state = "Entwurf"
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
const [updated] = await context.server.db
.update(createddocuments)
.set(payload as any)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.returning()
return { document: updated }
},
},
{
name: "accounting.outgoing_documents.finalize",
title: "Ausgangsbeleg finalisieren",
description: "Finalisiert einen Ausgangsbeleg, setzt den Status auf Gebucht und vergibt dabei genau einmal eine Belegnummer.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
documentDate: { type: "string" },
deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" },
paymentDays: { type: "number" },
payment_type: { type: "string" },
taxType: {
type: "string",
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
},
rows: { type: "array" },
report: { type: "object" },
availableInPortal: { type: "boolean" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
assertNoManualDocumentNumber(args)
const [existing] = await context.server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
payload.state = "Gebucht"
payload.documentNumber = result.usedNumber
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
const [updated] = await context.server.db
.update(createddocuments)
.set(payload as any)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.returning()
return { document: updated }
},
},
{
name: "accounting.outgoing_documents.archive",
title: "Ausgangsbeleg archivieren",
description: "Archiviert einen Ausgangsbeleg im aktiven Mandanten.",
requiredPermissions: ["accounting.outgoing_documents.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const [updated] = await context.server.db
.update(createddocuments)
.set({
archived: true,
updatedAt: new Date(),
updatedBy: context.userId,
})
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.returning()
if (!updated) throw new Error("Ausgangsbeleg nicht gefunden")
return { document: updated }
},
},
{
name: "accounting.accounts.search",
title: "Konten suchen",
description: "Sucht Sachkonten im aktiven Kontenrahmen des Mandanten.",
requiredPermissions: ["accounting.accounts.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const tenantRows = await context.server.db.query.tenants.findMany({
where: (tenant, { eq }) => eq(tenant.id, context.tenantId),
columns: {
accountChart: true,
},
limit: 1,
})
const accountChart = tenantRows[0]?.accountChart || "skr03"
const query = stringArg(args, "query")
const limit = limitFromArgs(args)
const whereCond = query
? and(
eq(accounts.accountChart, accountChart),
or(
ilike(accounts.number, `%${query}%`),
ilike(accounts.label, `%${query}%`),
ilike(accounts.description, `%${query}%`)
)
)
: eq(accounts.accountChart, accountChart)
const rows = await context.server.db
.select()
.from(accounts)
.where(whereCond)
.orderBy(accounts.number)
.limit(limit)
return { accountChart, rows }
},
},
{
name: "accounting.incoming_invoices.list",
title: "Eingangsrechnungen auflisten",
description: "Listet Eingangsrechnungen des aktiven Mandanten.",
requiredPermissions: ["accounting.incoming_invoices.read"],
inputSchema: {
type: "object",
properties: {
state: { type: "string", description: "Optionaler Statusfilter." },
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(incominginvoices.tenant, context.tenantId)]
const state = stringArg(args, "state")
if (state) conditions.push(eq(incominginvoices.state, state))
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
const rows = await context.server.db
.select()
.from(incominginvoices)
.where(and(...conditions))
.orderBy(desc(incominginvoices.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "accounting.incoming_invoices.get",
title: "Eingangsrechnung laden",
description: "Lädt eine Eingangsrechnung des aktiven Mandanten anhand ihrer ID.",
requiredPermissions: ["accounting.incoming_invoices.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
return { invoice: rows[0] }
},
},
{
name: "accounting.incoming_invoices.files.list",
title: "Dateien eines Eingangsbelegs auflisten",
description: "Listet Dateien, die mit einem Eingangsbeleg im aktiven Mandanten verknüpft sind.",
requiredPermissions: ["accounting.incoming_invoices.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(files)
.where(and(
eq(files.incominginvoice, id),
eq(files.tenant, context.tenantId),
eq(files.archived, false)
))
.orderBy(desc(files.createdAt))
return { rows }
},
},
{
name: "accounting.incoming_invoices.validate",
title: "Eingangsbeleg validieren",
description: "Prüft einen Eingangsbeleg mit denselben Pflichtregeln wie die FEDEO-Oberfläche vor dem Buchen.",
requiredPermissions: ["accounting.incoming_invoices.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Eingangsbeleg nicht gefunden")
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
},
},
{
name: "accounting.incoming_invoices.create",
title: "Eingangsbeleg erstellen",
description: "Erstellt einen Eingangsbeleg-Entwurf im aktiven Mandanten.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
properties: {
vendor: { type: "number" },
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
accounts: { type: "array" },
expense: { type: "boolean" },
},
},
async handler(context, args) {
const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [created] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
return { invoice: created, validation: validateIncomingInvoiceData(created as Record<string, any>) }
},
},
{
name: "accounting.incoming_invoices.update",
title: "Eingangsbeleg bearbeiten",
description: "Bearbeitet einen noch nicht gebuchten Eingangsbeleg im aktiven Mandanten.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
vendor: { type: "number" },
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
accounts: { type: "array" },
expense: { type: "boolean" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
if (existing.state === "Gebucht") throw new Error("Gebuchte Eingangsbelege können nicht über update geändert werden")
const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId)
payload.state = existing.state || "Entwurf"
const [updated] = await context.server.db
.update(incominginvoices)
.set(payload as any)
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.returning()
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
},
},
{
name: "accounting.incoming_invoices.book",
title: "Eingangsbeleg buchen",
description: "Validiert und bucht einen vorbereiteten oder als Entwurf gespeicherten Eingangsbeleg.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
if (existing.state === "Gebucht") return { invoice: existing, validation: validateIncomingInvoiceData(existing as Record<string, any>) }
const validation = validateIncomingInvoiceData(existing as Record<string, any>)
if (!validation.valid) {
return {
booked: false,
invoice: existing,
validation,
}
}
const [updated] = await context.server.db
.update(incominginvoices)
.set({
state: "Gebucht",
updatedAt: new Date(),
updatedBy: context.userId,
})
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.returning()
return {
booked: true,
invoice: updated,
validation: validateIncomingInvoiceData(updated as Record<string, any>),
}
},
},
{
name: "accounting.incoming_invoices.archive",
title: "Eingangsbeleg archivieren",
description: "Archiviert einen Eingangsbeleg im aktiven Mandanten.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const [updated] = await context.server.db
.update(incominginvoices)
.set({
archived: true,
updatedAt: new Date(),
updatedBy: context.userId,
})
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
.returning()
if (!updated) throw new Error("Eingangsbeleg nicht gefunden")
return { invoice: updated }
},
},
{
name: "accounting.bank_statements.list",
title: "Bankumsätze auflisten",
description: "Listet Bankumsätze des aktiven Mandanten.",
requiredPermissions: ["accounting.bank.read"],
inputSchema: {
type: "object",
properties: {
account: { type: "number", description: "Optionale Bankkonto-ID." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(bankstatements.tenant, context.tenantId)]
const account = numberArg(args, "account")
if (account) conditions.push(eq(bankstatements.account, account))
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
const rows = await context.server.db
.select()
.from(bankstatements)
.where(and(...conditions))
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "accounting.bank_statements.get",
title: "Bankumsatz laden",
description: "Lädt einen Bankumsatz des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["accounting.bank.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
return { bankStatement: rows[0] }
},
},
{
name: "accounting.statement_allocations.list",
title: "Buchungszuordnungen auflisten",
description: "Listet Buchungszuordnungen des aktiven Mandanten.",
requiredPermissions: ["accounting.statement_allocations.read"],
inputSchema: {
type: "object",
properties: {
bankstatement: { type: "number" },
incominginvoice: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(statementallocations.tenant, context.tenantId)]
const bankstatement = numberArg(args, "bankstatement")
const incominginvoice = numberArg(args, "incominginvoice")
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
const rows = await context.server.db
.select()
.from(statementallocations)
.where(and(...conditions))
.orderBy(desc(statementallocations.created_at))
.limit(limitFromArgs(args))
return { rows }
},
},
]

View File

@@ -0,0 +1,571 @@
import { and, desc, eq, ilike, or } from "drizzle-orm"
import {
branches,
contacts,
costcentres,
customers,
inventoryitems,
products,
services,
teams,
units,
vehicles,
vendors,
} from "../../../db/schema"
import { McpTool } from "../types"
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
const raw = Number(args.limit ?? fallback)
if (!Number.isFinite(raw)) return fallback
return Math.min(Math.max(Math.trunc(raw), 1), 100)
}
const stringArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const numberArg = (args: Record<string, unknown>, key: string) => {
const value = Number(args[key])
return Number.isFinite(value) ? value : null
}
const uuidArg = (args: Record<string, unknown>, key: string) => {
const value = stringArg(args, key)
return value && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
? value
: null
}
export const masterdataTools: McpTool[] = [
{
name: "masterdata.customers.get",
title: "Kunde laden",
description: "Lädt einen Kunden des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["masterdata.customers.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(customers)
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Kunde nicht gefunden")
return { customer: rows[0] }
},
},
{
name: "masterdata.vendors.search",
title: "Lieferanten suchen",
description: "Sucht Lieferanten des aktiven Mandanten.",
requiredPermissions: ["masterdata.vendors.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(vendors.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(vendors.name, `%${query}%`),
ilike(vendors.vendorNumber, `%${query}%`),
ilike(vendors.notes, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
const rows = await context.server.db
.select()
.from(vendors)
.where(and(...conditions))
.orderBy(vendors.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.vendors.get",
title: "Lieferant laden",
description: "Lädt einen Lieferanten des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["masterdata.vendors.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(vendors)
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
return { vendor: rows[0] }
},
},
{
name: "masterdata.contacts.search",
title: "Kontakte suchen",
description: "Sucht Kontakte des aktiven Mandanten, optional zu Kunde oder Lieferant.",
requiredPermissions: ["masterdata.contacts.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
customer: { type: "number" },
vendor: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(contacts.tenant, context.tenantId)]
const query = stringArg(args, "query")
const customer = numberArg(args, "customer")
const vendor = numberArg(args, "vendor")
if (query) {
conditions.push(or(
ilike(contacts.fullName, `%${query}%`),
ilike(contacts.firstName, `%${query}%`),
ilike(contacts.lastName, `%${query}%`),
ilike(contacts.email, `%${query}%`),
ilike(contacts.phoneMobile, `%${query}%`),
ilike(contacts.phoneHome, `%${query}%`),
ilike(contacts.role, `%${query}%`),
ilike(contacts.notes, `%${query}%`)
))
}
if (customer) conditions.push(eq(contacts.customer, customer))
if (vendor) conditions.push(eq(contacts.vendor, vendor))
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
const rows = await context.server.db
.select()
.from(contacts)
.where(and(...conditions))
.orderBy(contacts.fullName)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.products.search",
title: "Artikel suchen",
description: "Sucht Artikel und Materialstammdaten des aktiven Mandanten.",
requiredPermissions: ["masterdata.products.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(products.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(products.name, `%${query}%`),
ilike(products.article_number, `%${query}%`),
ilike(products.manufacturer, `%${query}%`),
ilike(products.manufacturer_number, `%${query}%`),
ilike(products.ean, `%${query}%`),
ilike(products.barcode, `%${query}%`),
ilike(products.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
const rows = await context.server.db
.select()
.from(products)
.where(and(...conditions))
.orderBy(products.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.products.get",
title: "Artikel laden",
description: "Lädt einen Artikel des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["masterdata.products.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(products)
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Artikel nicht gefunden")
return { product: rows[0] }
},
},
{
name: "masterdata.services.search",
title: "Leistungen suchen",
description: "Sucht Leistungsstammdaten des aktiven Mandanten.",
requiredPermissions: ["masterdata.services.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(services.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(services.name, `%${query}%`),
ilike(services.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
const rows = await context.server.db
.select()
.from(services)
.where(and(...conditions))
.orderBy(services.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.services.get",
title: "Leistung laden",
description: "Lädt eine Leistung des aktiven Mandanten anhand ihrer ID.",
requiredPermissions: ["masterdata.services.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(services)
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Leistung nicht gefunden")
return { service: rows[0] }
},
},
{
name: "masterdata.cost_centres.list",
title: "Kostenstellen auflisten",
description: "Listet Kostenstellen des aktiven Mandanten.",
requiredPermissions: ["masterdata.cost_centres.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
branch: { type: "number" },
project: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(costcentres.tenant, context.tenantId)]
const query = stringArg(args, "query")
const branch = numberArg(args, "branch")
const project = numberArg(args, "project")
if (query) {
conditions.push(or(
ilike(costcentres.number, `%${query}%`),
ilike(costcentres.name, `%${query}%`),
ilike(costcentres.description, `%${query}%`)
))
}
if (branch) conditions.push(eq(costcentres.branch, branch))
if (project) conditions.push(eq(costcentres.project, project))
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
const rows = await context.server.db
.select()
.from(costcentres)
.where(and(...conditions))
.orderBy(costcentres.number)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.cost_centres.get",
title: "Kostenstelle laden",
description: "Lädt eine Kostenstelle des aktiven Mandanten anhand ihrer UUID.",
requiredPermissions: ["masterdata.cost_centres.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
async handler(context, args) {
const id = uuidArg(args, "id")
if (!id) throw new Error("gültige id ist erforderlich")
const rows = await context.server.db
.select()
.from(costcentres)
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
return { costCentre: rows[0] }
},
},
{
name: "masterdata.branches.list",
title: "Niederlassungen auflisten",
description: "Listet Niederlassungen des aktiven Mandanten.",
requiredPermissions: ["masterdata.branches.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(branches.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(branches.number, `%${query}%`),
ilike(branches.name, `%${query}%`),
ilike(branches.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
const rows = await context.server.db
.select()
.from(branches)
.where(and(...conditions))
.orderBy(branches.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.teams.list",
title: "Teams auflisten",
description: "Listet Teams des aktiven Mandanten.",
requiredPermissions: ["masterdata.teams.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
branch: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(teams.tenant, context.tenantId)]
const query = stringArg(args, "query")
const branch = numberArg(args, "branch")
if (query) {
conditions.push(or(
ilike(teams.name, `%${query}%`),
ilike(teams.description, `%${query}%`)
))
}
if (branch) conditions.push(eq(teams.branch, branch))
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
const rows = await context.server.db
.select()
.from(teams)
.where(and(...conditions))
.orderBy(teams.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.vehicles.list",
title: "Fahrzeuge auflisten",
description: "Listet Fahrzeuge des aktiven Mandanten.",
requiredPermissions: ["masterdata.vehicles.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(vehicles.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(vehicles.name, `%${query}%`),
ilike(vehicles.license_plate, `%${query}%`),
ilike(vehicles.vin, `%${query}%`),
ilike(vehicles.color, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
const rows = await context.server.db
.select()
.from(vehicles)
.where(and(...conditions))
.orderBy(vehicles.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.inventory_items.search",
title: "Inventar suchen",
description: "Sucht Inventar- und Geräte-Stammdaten des aktiven Mandanten.",
requiredPermissions: ["masterdata.inventory_items.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
vendor: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(inventoryitems.tenant, context.tenantId)]
const query = stringArg(args, "query")
const vendor = numberArg(args, "vendor")
if (query) {
conditions.push(or(
ilike(inventoryitems.name, `%${query}%`),
ilike(inventoryitems.articleNumber, `%${query}%`),
ilike(inventoryitems.serialNumber, `%${query}%`),
ilike(inventoryitems.manufacturer, `%${query}%`),
ilike(inventoryitems.manufacturerNumber, `%${query}%`),
ilike(inventoryitems.description, `%${query}%`)
))
}
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
const rows = await context.server.db
.select()
.from(inventoryitems)
.where(and(...conditions))
.orderBy(inventoryitems.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.inventory_items.get",
title: "Inventar laden",
description: "Lädt einen Inventar- oder Geräte-Stammdatensatz anhand seiner ID.",
requiredPermissions: ["masterdata.inventory_items.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(inventoryitems)
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Inventar nicht gefunden")
return { inventoryItem: rows[0] }
},
},
{
name: "masterdata.units.list",
title: "Einheiten auflisten",
description: "Listet globale Mengeneinheiten.",
requiredPermissions: ["masterdata.units.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const query = stringArg(args, "query")
const rows = await context.server.db
.select()
.from(units)
.where(query
? or(
ilike(units.name, `%${query}%`),
ilike(units.single, `%${query}%`),
ilike(units.multiple, `%${query}%`),
ilike(units.short, `%${query}%`)
)
: undefined)
.orderBy(units.name)
.limit(limitFromArgs(args))
return { rows }
},
},
]

View File

@@ -0,0 +1,407 @@
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { customers, events, plants, projects, tasks } from "../../../db/schema"
import { McpTool } from "../types"
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
const raw = Number(args.limit ?? fallback)
if (!Number.isFinite(raw)) return fallback
return Math.min(Math.max(Math.trunc(raw), 1), 100)
}
const stringArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const numberArg = (args: Record<string, unknown>, key: string) => {
const value = Number(args[key])
return Number.isFinite(value) ? value : null
}
export const organisationTools: McpTool[] = [
{
name: "organisation.customers.search",
title: "Kunden suchen",
description: "Sucht aktive Kunden des aktiven Mandanten.",
requiredPermissions: ["organisation.customers.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(customers.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(customers.name, `%${query}%`),
ilike(customers.customerNumber, `%${query}%`),
ilike(customers.firstname, `%${query}%`),
ilike(customers.lastname, `%${query}%`),
ilike(customers.notes, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
const rows = await context.server.db
.select({
id: customers.id,
customerNumber: customers.customerNumber,
name: customers.name,
firstname: customers.firstname,
lastname: customers.lastname,
type: customers.type,
isCompany: customers.isCompany,
active: customers.active,
archived: customers.archived,
})
.from(customers)
.where(and(...conditions))
.orderBy(customers.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.projects.list",
title: "Projekte auflisten",
description: "Listet Projekte des aktiven Mandanten mit optionalen Filtern.",
requiredPermissions: ["organisation.projects.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
customer: { type: "number" },
activePhase: { type: "string" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(projects.tenant, context.tenantId)]
const query = stringArg(args, "query")
const customer = numberArg(args, "customer")
const activePhase = stringArg(args, "activePhase")
if (query) {
conditions.push(or(
ilike(projects.name, `%${query}%`),
ilike(projects.projectNumber, `%${query}%`),
ilike(projects.customerRef, `%${query}%`),
ilike(projects.notes, `%${query}%`)
))
}
if (customer) conditions.push(eq(projects.customer, customer))
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
const rows = await context.server.db
.select()
.from(projects)
.where(and(...conditions))
.orderBy(desc(projects.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.projects.get",
title: "Projekt laden",
description: "Lädt ein Projekt des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["organisation.projects.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(projects)
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Projekt nicht gefunden")
return { project: rows[0] }
},
},
{
name: "organisation.plants.list",
title: "Anlagen auflisten",
description: "Listet Anlagen des aktiven Mandanten mit optionalem Kundenfilter.",
requiredPermissions: ["organisation.plants.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name." },
customer: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(plants.tenant, context.tenantId)]
const query = stringArg(args, "query")
const customer = numberArg(args, "customer")
if (query) conditions.push(ilike(plants.name, `%${query}%`))
if (customer) conditions.push(eq(plants.customer, customer))
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
const rows = await context.server.db
.select()
.from(plants)
.where(and(...conditions))
.orderBy(plants.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.events.list",
title: "Termine auflisten",
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
requiredPermissions: ["organisation.events.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
project: { type: "number" },
customer: { type: "number" },
eventtype: { type: "string" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(events.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
const eventtype = stringArg(args, "eventtype")
if (query) {
conditions.push(or(
ilike(events.name, `%${query}%`),
ilike(events.notes, `%${query}%`),
ilike(events.link, `%${query}%`)
))
}
if (project) conditions.push(eq(events.project, project))
if (customer) conditions.push(eq(events.customer, customer))
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
const rows = await context.server.db
.select()
.from(events)
.where(and(...conditions))
.orderBy(desc(events.startDate))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.tasks.list",
title: "Aufgaben auflisten",
description: "Listet Aufgaben des aktiven Mandanten mit optionalen Filtern.",
requiredPermissions: ["organisation.tasks.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
project: { type: "number" },
customer: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(tasks.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
if (query) {
conditions.push(or(
ilike(tasks.name, `%${query}%`),
ilike(tasks.description, `%${query}%`),
ilike(tasks.categorie, `%${query}%`)
))
}
if (project) conditions.push(eq(tasks.project, project))
if (customer) conditions.push(eq(tasks.customer, customer))
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
const rows = await context.server.db
.select()
.from(tasks)
.where(and(...conditions))
.orderBy(desc(tasks.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.tasks.get",
title: "Aufgabe laden",
description: "Lädt eine Aufgabe des aktiven Mandanten anhand ihrer ID.",
requiredPermissions: ["organisation.tasks.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
return { task: rows[0] }
},
},
{
name: "organisation.tasks.create",
title: "Aufgabe erstellen",
description: "Erstellt eine neue Aufgabe im aktiven Mandanten.",
requiredPermissions: ["organisation.tasks.write"],
inputSchema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
description: { type: "string" },
categorie: { type: "string" },
project: { type: "number" },
plant: { type: "number" },
customer: { type: "number" },
userId: { type: "string" },
profiles: { type: "array", items: { type: "string" } },
},
},
async handler(context, args) {
const name = stringArg(args, "name")
if (!name) throw new Error("name ist erforderlich")
const [created] = await context.server.db
.insert(tasks)
.values({
name,
description: stringArg(args, "description"),
categorie: stringArg(args, "categorie"),
tenant: context.tenantId,
userId: stringArg(args, "userId") || context.userId,
project: numberArg(args, "project"),
plant: numberArg(args, "plant"),
customer: numberArg(args, "customer"),
profiles: Array.isArray(args.profiles) ? args.profiles : [],
updatedAt: new Date(),
updatedBy: context.userId,
})
.returning()
return { task: created }
},
},
{
name: "organisation.tasks.update",
title: "Aufgabe aktualisieren",
description: "Aktualisiert Felder einer Aufgabe im aktiven Mandanten.",
requiredPermissions: ["organisation.tasks.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
name: { type: "string" },
description: { type: "string" },
categorie: { type: "string" },
project: { type: "number" },
plant: { type: "number" },
customer: { type: "number" },
userId: { type: "string" },
profiles: { type: "array", items: { type: "string" } },
archived: { type: "boolean" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const update: Record<string, unknown> = {
updatedAt: new Date(),
updatedBy: context.userId,
}
for (const key of ["name", "description", "categorie", "userId"] as const) {
if (args[key] !== undefined) update[key] = stringArg(args, key)
}
for (const key of ["project", "plant", "customer"] as const) {
if (args[key] !== undefined) update[key] = numberArg(args, key)
}
if (Array.isArray(args.profiles)) update.profiles = args.profiles
if (typeof args.archived === "boolean") update.archived = args.archived
const [updated] = await context.server.db
.update(tasks)
.set(update)
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.returning()
if (!updated) throw new Error("Aufgabe nicht gefunden")
return { task: updated }
},
},
{
name: "organisation.tasks.archive",
title: "Aufgabe archivieren",
description: "Archiviert eine Aufgabe im aktiven Mandanten.",
requiredPermissions: ["organisation.tasks.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const [updated] = await context.server.db
.update(tasks)
.set({
archived: true,
updatedAt: new Date(),
updatedBy: context.userId,
})
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.returning()
if (!updated) throw new Error("Aufgabe nicht gefunden")
return { task: updated }
},
},
]

36
backend/src/mcp/types.ts Normal file
View File

@@ -0,0 +1,36 @@
import { FastifyInstance, FastifyRequest } from "fastify"
export type McpContext = {
server: FastifyInstance
request: FastifyRequest
tenantId: number
userId: string
isAdmin: boolean
permissions: string[]
}
export type McpToolResult = {
content: Array<{
type: "text"
text: string
}>
structuredContent?: Record<string, unknown>
isError?: boolean
}
export type McpTool = {
name: string
title: string
description: string
requiredPermissions: string[]
inputSchema: Record<string, unknown>
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
}
export type JsonRpcRequest = {
jsonrpc?: string
id?: string | number | null
method?: string
params?: any
}

View File

@@ -9,12 +9,15 @@ export type DerivedSpan = {
sourceEventIds: string[];
status: SpanStatus;
statusActorId?: string;
payload?: Record<string, any> | null;
description?: string;
};
type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
payload?: Record<string, any> | null;
};
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
let currentStart: Date | null = null;
let currentType: DerivedSpan["type"] | null = null;
let sourceEventIds: string[] = [];
let currentPayload: Record<string, any> | null = null;
const closeSpan = (end: Date) => {
if (!currentStart || !currentType) return;
if (end.getTime() <= currentStart.getTime()) {
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
return;
}
spans.push({
type: currentType,
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: end,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
});
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
};
const closeOpenSpanAsRunning = () => {
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: null,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
});
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
};
for (const event of events) {
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
currentPayload = event.payload || null;
break;
case "pause_start":
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "PAUSED";
currentStart = event.eventtime;
currentType = "pause";
currentPayload = event.payload || null;
}
break;
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
currentPayload = event.payload || null;
}
break;
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "ABSENT";
currentStart = event.eventtime;
currentType = newType;
currentPayload = event.payload || null;
break;
case "vacation_end":
@@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
}
return spans;
}
}

View File

@@ -1,7 +1,7 @@
// src/services/loadValidEvents.ts
import { stafftimeevents } from "../../../db/schema";
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
import { FastifyInstance } from "fastify";
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
@@ -12,11 +12,43 @@ export type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
actoruser_id: string;
actoruser_id?: string;
related_event_id: string | null;
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
payload?: Record<string, any> | null;
created_at?: Date | null;
};
const EVENT_TYPE_ORDER: Record<string, number> = {
auto_stop: 10,
work_end: 10,
pause_end: 10,
vacation_end: 10,
sick_end: 10,
overtime_compensation_end: 10,
work_start: 20,
pause_start: 20,
vacation_start: 20,
sick_start: 20,
overtime_compensation_start: 20,
submitted: 30,
approved: 30,
rejected: 30,
invalidated: 40,
};
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
if (eventTimeDiff !== 0) return eventTimeDiff;
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
if (typeOrderDiff !== 0) return typeOrderDiff;
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
}
export async function loadValidEvents(
server: FastifyInstance,
tenantId: number,
@@ -62,10 +94,9 @@ export async function loadValidEvents(
)
)
.orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
baseEvents.eventtime,
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
baseEvents.id
asc(baseEvents.eventtime),
asc(baseEvents.created_at),
asc(baseEvents.id)
);
// Mapping auf den sauberen TimeEvent Typ
@@ -73,8 +104,10 @@ export async function loadValidEvents(
id: e.id,
eventtype: e.eventtype,
eventtime: e.eventtime,
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
// ...
actoruser_id: e.actoruser_id,
related_event_id: e.related_event_id,
payload: e.payload,
created_at: e.created_at,
})) as TimeEvent[];
}
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
)
)
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
.orderBy(stafftimeevents.eventtime);
.orderBy(
asc(stafftimeevents.eventtime),
asc(stafftimeevents.created_at),
asc(stafftimeevents.id)
);
return adminEvents;
}
return adminEvents as TimeEvent[];
}

View File

@@ -9,7 +9,7 @@ import {
authUsers,
} from "../../db/schema"
import { eq, and } from "drizzle-orm"
import { eq, and, inArray } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => {
server.addHook("preHandler", async (req, reply) => {
@@ -63,10 +63,12 @@ export default fp(async (server: FastifyInstance) => {
const userId = req.user.user_id
// --------------------------------------------------------
// 3⃣ Rolle des Nutzers im Tenant holen
// 3⃣ Rollen des Nutzers im Tenant holen
// --------------------------------------------------------
const roleRows = await server.db
.select()
.select({
role_id: authUserRoles.role_id,
})
.from(authUserRoles)
.where(
and(
@@ -74,7 +76,6 @@ export default fp(async (server: FastifyInstance) => {
eq(authUserRoles.tenant_id, tenantId)
)
)
.limit(1)
if (roleRows.length === 0) {
if (req.user.is_admin) {
@@ -89,22 +90,22 @@ export default fp(async (server: FastifyInstance) => {
.send({ error: "No role assigned for this tenant" })
}
const roleId = roleRows[0].role_id
const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id)))
// --------------------------------------------------------
// 4⃣ Berechtigungen der Rolle laden
// 4⃣ Berechtigungen der Rollen laden
// --------------------------------------------------------
const permissionRows = await server.db
.select()
.from(authRolePermissions)
.where(eq(authRolePermissions.role_id, roleId))
.where(inArray(authRolePermissions.role_id, roleIds))
const permissions = permissionRows.map((p) => p.permission)
const permissions = Array.from(new Set(permissionRows.map((p) => p.permission)))
// --------------------------------------------------------
// 5⃣ An Request hängen für spätere Nutzung
// --------------------------------------------------------
req.role = roleId
req.role = roleIds[0]
req.permissions = permissions
req.hasPermission = (perm: string) => permissions.includes(perm)

View File

@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
import {
authTenantUsers,
authProfiles,
customers,
authRoles,
authUserRoles,
authUsers,
@@ -12,6 +13,7 @@ import {
tenants,
} from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer";
export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => {
@@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) {
return currentUser;
};
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
const existingRoles = await server.db
.select({
id: authRoles.id,
name: authRoles.name,
})
.from(authRoles)
.where(eq(authRoles.tenant_id, tenantId));
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
if (portalRole) return portalRole.id;
const [createdRole] = await server.db
.insert(authRoles)
.values({
name: "Kundenportal",
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
tenant_id: tenantId,
created_by: createdBy,
})
.returning({
id: authRoles.id,
});
return createdRole.id;
};
// -------------------------------------------------------------
// GET /admin/overview
// -------------------------------------------------------------
@@ -422,6 +451,343 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// POST /admin/profiles/:profileId/create-user
// -------------------------------------------------------------
server.post("/admin/profiles/:profileId/create-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { profileId } = req.params as { profileId: string };
const body = req.body as { email?: string };
const email = body.email?.trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "email required" });
}
const [profile] = await server.db
.select({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
})
.from(authProfiles)
.where(eq(authProfiles.id, profileId))
.limit(1);
if (!profile) {
return reply.code(404).send({ error: "Profile not found" });
}
if (profile.user_id) {
return reply.code(409).send({ error: "Profile already linked to a user" });
}
const existingUsers = await server.db
.select({ id: authUsers.id })
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
if (existingUsers.length) {
return reply.code(409).send({ error: "User with this email already exists" });
}
const initialPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(initialPassword);
const result = await server.db.transaction(async (tx) => {
const [createdUser] = await tx
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: true,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
email: authUsers.email,
must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin,
multiTenant: authUsers.multiTenant,
created_at: authUsers.created_at,
});
await tx
.insert(authTenantUsers)
.values({
tenant_id: profile.tenant_id,
user_id: createdUser.id,
created_by: currentUser.id,
});
const [updatedProfile] = await tx
.update(authProfiles)
.set({
user_id: createdUser.id,
email,
})
.where(eq(authProfiles.id, profile.id))
.returning({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
});
return {
user: createdUser,
profile: updatedProfile,
};
});
return {
...result,
initialPassword,
};
} catch (err) {
console.error("ERROR /admin/profiles/:profileId/create-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const tenantId = Number(req.user?.tenant_id);
const { customerId } = req.params as { customerId: string };
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const [tenantRecord] = await server.db
.select({
id: tenants.id,
name: tenants.name,
portalDomain: tenants.portalDomain,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
const [customerRecord] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
.limit(1);
if (!customerRecord) {
return reply.code(404).send({ error: "Customer not found" });
}
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "Customer has no email address" });
}
const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword);
const [existingUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
let userId = existingUser?.id || null;
let createdNewUser = false;
if (existingUser) {
const [existingProfile] = await server.db
.select({
id: authProfiles.id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, existingUser.id),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingUser.is_admin) {
return reply.code(409).send({ error: "Email address is already used by an admin user" });
}
if (!existingProfile) {
return reply.code(409).send({ error: "Email address is already used by another user" });
}
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
}
await server.db
.update(authUsers)
.set({
passwordHash,
must_change_password: true,
multiTenant: false,
updatedAt: new Date(),
})
.where(eq(authUsers.id, existingUser.id));
userId = existingUser.id;
} else {
const [createdUser] = await server.db
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: false,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
});
userId = createdUser.id;
createdNewUser = true;
}
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
const existingMemberships = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, userId!),
eq(authTenantUsers.tenant_id, tenantId)
))
.limit(1);
if (!existingMemberships.length) {
await server.db
.insert(authTenantUsers)
.values({
tenant_id: tenantId,
user_id: userId!,
created_by: currentUser.id,
});
}
const existingPortalRoleAssignment = await server.db
.select()
.from(authUserRoles)
.where(and(
eq(authUserRoles.user_id, userId!),
eq(authUserRoles.tenant_id, tenantId),
eq(authUserRoles.role_id, portalRoleId)
))
.limit(1);
if (!existingPortalRoleAssignment.length) {
await server.db
.insert(authUserRoles)
.values({
user_id: userId!,
tenant_id: tenantId,
role_id: portalRoleId,
created_by: currentUser.id,
});
}
const [existingTenantProfile] = await server.db
.select({
id: authProfiles.id,
user_id: authProfiles.user_id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, userId!),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingTenantProfile) {
await server.db
.update(authProfiles)
.set({
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
})
.where(eq(authProfiles.id, existingTenantProfile.id));
} else {
await server.db
.insert(authProfiles)
.values({
user_id: userId!,
tenant_id: tenantId,
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
});
}
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail(
email,
`FEDEO | Einladung ins Kundenportal`,
`
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
`
);
if (!mailResult.success) {
return reply.code(500).send({ error: "Invitation email could not be sent" });
}
return {
success: true,
createdNewUser,
email,
initialPassword: generatedPassword,
portalUrl,
};
} catch (err) {
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// POST /admin/tenants
// -------------------------------------------------------------

View File

@@ -9,6 +9,7 @@ import {
authRolePermissions,
} from "../../../db/schema"
import { eq, and, or, isNull } from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
export default async function meRoutes(server: FastifyInstance) {
server.get("/me", async (req, reply) => {
@@ -51,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
id: tenants.id,
name: tenants.name,
short: tenants.short,
hasActiveLicense: tenants.hasActiveLicense,
locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules,
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
)
.limit(1)
profile = profileResult?.[0] ?? null
const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
profile = enrichedProfiles?.[0] ?? null
}
// ----------------------------------------------------

View File

@@ -10,11 +10,14 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import {
bankrequisitions,
bankaccounts,
bankstatements,
accounts,
createddocuments,
customers,
entitybankaccounts,
incominginvoices,
ownaccounts,
statementallocations,
vendors,
} from "../../db/schema"
@@ -22,10 +25,355 @@ import {
import {
eq,
and,
isNull,
aliasedTable,
desc,
} from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) {
const CASHBOOK_BANK_ID = "fedeo-cashbook"
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors")
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
const normalizeManualSide = (payload: any, keys: string[]) =>
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
const cashbookAccountFilter = (tenantId: number) => and(
eq(bankaccounts.tenant, tenantId),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
eq(bankaccounts.archived, false)
)
const buildCashbookCounterPayload = (type: string, id: any) => {
const numericId = type === "ownaccount" ? id : Number(id)
if (!id || (type !== "ownaccount" && !Number.isFinite(numericId))) return null
if (type === "account") return { account: numericId }
if (type === "customer") return { customer: numericId }
if (type === "vendor") return { vendor: numericId }
if (type === "ownaccount") return { ownaccount: numericId }
if (type === "incominginvoice") return { incominginvoice: numericId }
return null
}
const prepareStatementAllocationPayload = (payload: any) => {
const next = { ...payload }
const isManualBooking = !next.bankstatement
if (!isManualBooking) {
next.manualBookingDate = null
next.contraAccount = null
next.contraCustomer = null
next.contraVendor = null
next.contraOwnaccount = null
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
next.manualInvoiceSide = null
return { data: next }
}
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
const debitSide = normalizeManualSide(next, debitKeys)
const creditSide = normalizeManualSide(next, creditKeys)
if (hasManualInvoice) {
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
} else {
next.manualInvoiceSide = null
}
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
}
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
}
if (debitSide.length !== 1 || creditSide.length !== 1) {
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
}
next.amount = Math.abs(Number(next.amount))
next.bankstatement = null
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
return { data: next }
}
server.get("/banking/cashbooks", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const rows = await server.db
.select()
.from(bankaccounts)
.where(cashbookAccountFilter(req.user.tenant_id))
.orderBy(bankaccounts.name)
return reply.send(rows)
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to load cashbooks" })
}
})
server.post("/banking/cashbooks", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const body = req.body as {
name?: string
datevNumber?: string
openingBalance?: number
}
const name = String(body.name || "").trim()
const datevNumber = String(body.datevNumber || "").trim()
const openingBalance = Number(body.openingBalance || 0)
if (!name) return reply.code(400).send({ error: "Bitte eine Bezeichnung für die Kasse angeben." })
if (!datevNumber) return reply.code(400).send({ error: "Bitte eine Kontennummer für die Kasse angeben." })
if (!Number.isFinite(openingBalance)) return reply.code(400).send({ error: "Der Anfangsbestand ist ungültig." })
const uniquePart = `${req.user.tenant_id}-${Date.now()}`
const inserted = await server.db.insert(bankaccounts).values({
name,
iban: `CASH-${uniquePart}`,
tenant: req.user.tenant_id,
bankId: CASHBOOK_BANK_ID,
ownerName: name,
accountId: `cashbook-${uniquePart}`,
balance: openingBalance,
datevNumber,
updatedBy: req.user.user_id,
}).returning()
const createdRecord = inserted[0]
return reply.send(createdRecord)
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to create cashbook" })
}
})
server.patch("/banking/cashbooks/:id/archive", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const updated = await server.db.update(bankaccounts)
.set({
archived: true,
updatedAt: new Date(),
updatedBy: req.user.user_id,
})
.where(and(
eq(bankaccounts.id, Number(id)),
eq(bankaccounts.tenant, req.user.tenant_id),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
))
.returning()
if (!updated[0]) return reply.code(404).send({ error: "Cashbook not found" })
return reply.send(updated[0])
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to archive cashbook" })
}
})
server.get("/banking/cashbooks/:id/bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const cashbookId = Number(id)
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
const rows = await server.db.select({
statement: bankstatements,
allocation: statementallocations,
account: accounts,
customer: customers,
vendor: vendors,
ownaccount: ownaccounts,
incominginvoice: ManualInvoices,
incominginvoiceVendor: ManualInvoiceVendors,
})
.from(bankstatements)
.leftJoin(statementallocations, eq(statementallocations.bankstatement, bankstatements.id))
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
.where(and(
eq(bankstatements.tenant, req.user.tenant_id),
eq(bankstatements.account, cashbookId),
eq(bankstatements.archived, false)
))
.orderBy(desc(bankstatements.date), desc(bankstatements.createdAt))
return reply.send(rows.map((row) => ({
...row.statement,
allocation: row.allocation,
account: row.account,
customer: row.customer,
vendor: row.vendor,
ownaccount: row.ownaccount,
incominginvoice: row.incominginvoice ? {
...row.incominginvoice,
vendor: row.incominginvoiceVendor,
} : null,
})))
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to load cashbook bookings" })
}
})
server.post("/banking/cashbooks/:id/bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const cashbookId = Number(id)
const body = req.body as {
date?: string
amount?: number
direction?: "income" | "expense"
counterType?: string
counterId?: string | number
description?: string
datevTaxKey?: string | null
}
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." })
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
const cashbook = await server.db.select().from(bankaccounts).where(and(
eq(bankaccounts.id, cashbookId),
eq(bankaccounts.tenant, req.user.tenant_id),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
eq(bankaccounts.archived, false)
)).limit(1)
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." })
const signedAmount = body.direction === "income"
? Math.abs(Number(body.amount))
: -Math.abs(Number(body.amount))
const description = String(body.description || "").trim() || (body.direction === "income" ? "Bareinnahme" : "Barausgabe")
const created = await server.db.transaction(async (tx) => {
const insertedStatements = await tx.insert(bankstatements).values({
account: cashbookId,
date: dayjs(body.date).format("YYYY-MM-DD"),
amount: signedAmount,
tenant: req.user.tenant_id,
text: description,
currency: "EUR",
credName: body.direction === "income" ? cashbook[0].name : description,
debName: body.direction === "expense" ? cashbook[0].name : description,
updatedBy: req.user.user_id,
}).returning()
const statement = insertedStatements[0]
const insertedAllocations = await tx.insert(statementallocations).values({
bankstatement: statement.id,
amount: signedAmount,
tenant: req.user.tenant_id,
description,
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
...counterPayload,
}).returning()
return {
statement,
allocation: insertedAllocations[0],
}
})
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(created.statement.id),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: created,
text: "Kassenbuchung erstellt",
})
return reply.send(created)
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to create cashbook booking" })
}
})
server.delete("/banking/cashbook-bookings/:id", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const statementId = Number(id)
if (!Number.isFinite(statementId)) return reply.code(400).send({ error: "Ungültige Buchung." })
const records = await server.db.select({
statement: bankstatements,
cashbook: bankaccounts,
})
.from(bankstatements)
.innerJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
.where(and(
eq(bankstatements.id, statementId),
eq(bankstatements.tenant, req.user.tenant_id),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
))
.limit(1)
if (!records[0]) return reply.code(404).send({ error: "Kassenbuchung nicht gefunden." })
await server.db.transaction(async (tx) => {
await tx.delete(statementallocations).where(eq(statementallocations.bankstatement, statementId))
await tx.delete(bankstatements).where(eq(bankstatements.id, statementId))
})
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: statementId,
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: records[0].statement,
newVal: null,
text: "Kassenbuchung gelöscht",
})
return reply.send({ success: true })
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to delete cashbook booking" })
}
})
const normalizeIban = (value?: string | null) =>
String(value || "").replace(/\s+/g, "").toUpperCase()
@@ -677,6 +1025,64 @@ export default async function bankingRoutes(server: FastifyInstance) {
}
})
// ------------------------------------------------------------------
// 📒 List Manual Statement Allocations
// ------------------------------------------------------------------
server.get("/banking/manual-bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const rows = await server.db.select({
allocation: statementallocations,
account: accounts,
customer: customers,
vendor: vendors,
ownaccount: ownaccounts,
contraAccount: ContraAccounts,
contraCustomer: ContraCustomers,
contraVendor: ContraVendors,
contraOwnaccount: ContraOwnaccounts,
incominginvoice: ManualInvoices,
incominginvoiceVendor: ManualInvoiceVendors,
})
.from(statementallocations)
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
.where(and(
eq(statementallocations.tenant, req.user.tenant_id),
eq(statementallocations.archived, false),
isNull(statementallocations.bankstatement)
))
return reply.send(rows.map((row) => ({
...row.allocation,
account: row.account,
customer: row.customer,
vendor: row.vendor,
ownaccount: row.ownaccount,
contraAccount: row.contraAccount,
contraCustomer: row.contraCustomer,
contraVendor: row.contraVendor,
contraOwnaccount: row.contraOwnaccount,
incominginvoice: row.incominginvoice ? {
...row.incominginvoice,
vendor: row.incominginvoiceVendor,
} : null,
})))
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to load manual bookings" })
}
})
// ------------------------------------------------------------------
// 💰 Create Statement Allocation
@@ -686,9 +1092,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { data: payload } = req.body as { data: any }
const prepared = prepareStatementAllocationPayload(payload)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
const inserted = await server.db.insert(statementallocations).values({
...payload,
...prepared.data,
tenant: req.user.tenant_id
}).returning()
@@ -720,16 +1128,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
}
}
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
if (createdRecord.bankstatement) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
}
return reply.send(createdRecord)
@@ -763,16 +1173,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
.delete(statementallocations)
.where(eq(statementallocations.id, id))
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
if (old.bankstatement) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
}
return reply.send({ success: true })

View File

@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files"
import { eq, inArray } from "drizzle-orm"
import { and } from "drizzle-orm"
import {
authProfiles,
files,
createddocuments,
customers
@@ -18,6 +20,55 @@ import {
export default async function fileRoutes(server: FastifyInstance) {
const getPortalCustomerId = async (req: any) => {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
const loadSingleFileForRequest = async (req: any, id: string) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return null
const portalCustomerId = await getPortalCustomerId(req)
if (!portalCustomerId) {
const rows = await server.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
return rows[0] || null
}
const rows = await server.db
.select({
file: files,
})
.from(files)
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
.where(and(
eq(files.id, id),
eq(files.tenant, tenantId),
eq(createddocuments.customer, portalCustomerId),
eq(createddocuments.availableInPortal, true)
))
.limit(1)
return rows[0]?.file || null
}
// -------------------------------------------------------------
// MULTIPART INIT
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 🔹 EINZELNE DATEI
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" })
return file
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 1⃣ SINGLE DOWNLOAD
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "File not found" })
const command = new GetObjectCommand({
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// SINGLE FILE PRESIGNED URL
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" })
const url = await getSignedUrl(

View File

@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import dayjs from "dayjs";
@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -57,6 +58,42 @@ function resolveGitRoot() {
return null
}
function getDeploymentChangelogFallback() {
const backendPackagePath = path.resolve(process.cwd(), "package.json")
let version = "unbekannt"
if (existsSync(backendPackagePath)) {
try {
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
version = packageJson?.version || version
} catch (err) {
console.error("Could not read backend package.json for changelog fallback", err)
}
}
const commitHash =
process.env.RAILWAY_GIT_COMMIT_SHA ||
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.GITHUB_SHA ||
process.env.COMMIT_SHA ||
process.env.SOURCE_COMMIT ||
null
const committedAt =
process.env.BUILD_DATE ||
process.env.RENDER_GIT_COMMIT_DATE ||
process.env.VERCEL_GIT_COMMIT_DATE ||
new Date().toISOString()
return [{
hash: commitHash || `version-${version}`,
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
subject: `Bereitgestellte Version ${version}`,
authorName: "Deployment",
committedAt
}]
}
export default async function functionRoutes(server: FastifyInstance) {
const streamToBuffer = async (stream: any): Promise<Buffer> =>
new Promise((resolve, reject) => {
@@ -201,7 +238,11 @@ export default async function functionRoutes(server: FastifyInstance) {
const gitRoot = resolveGitRoot()
if (!gitRoot) {
return reply.code(500).send({ error: 'Git repository not found' })
return reply.send({
repositoryRoot: null,
source: 'deployment',
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
})
}
try {
@@ -232,11 +273,16 @@ export default async function functionRoutes(server: FastifyInstance) {
return reply.send({
repositoryRoot: gitRoot,
source: 'git',
entries
})
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: 'Failed to load changelog' })
return reply.send({
repositoryRoot: gitRoot,
source: 'deployment',
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
})
}
})
@@ -261,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
})
server.get('/functions/liquidity-forecast', async (req, reply) => {
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
const ignoredKeys = String(ignoredRecurringKeys || "")
.split(",")
.map((key) => key.trim())
.filter(Boolean)
try {
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
}
})
server.post('/functions/services/backfillfiletext', async (req, reply) => {
const tenantId = req.user.tenant_id

View File

@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, any> = {
customers: historyitems.customer,
contracts: historyitems.contract,
members: historyitems.customer,
vendors: historyitems.vendor,
projects: historyitems.project,
@@ -30,6 +31,7 @@ const columnMap: Record<string, any> = {
const insertFieldMap: Record<string, string> = {
customers: "customer",
contracts: "contract",
members: "customer",
vendors: "vendor",
projects: "project",

View File

@@ -8,6 +8,8 @@ import {
} from "../../../db/schema"
import {and, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
export default async function tenantRoutesInternal(server: FastifyInstance) {
@@ -53,7 +55,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
@@ -61,6 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
@@ -91,12 +95,13 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const tenantId = req.params.id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
return data
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
return await enrichProfilesWithTeams(server, profilesWithBranches)
} catch (err) {
console.error("/tenant/profiles ERROR:", err)

View File

@@ -11,7 +11,7 @@ import {
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);

144
backend/src/routes/mcp.ts Normal file
View File

@@ -0,0 +1,144 @@
import { FastifyInstance } from "fastify"
import { assertToolPermission, createMcpContext } from "../mcp/authz"
import { mcpToolMap, mcpTools } from "../mcp/registry"
import { asToolError, asToolResult } from "../mcp/result"
import { JsonRpcRequest } from "../mcp/types"
const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25",
"2025-06-18",
"2025-03-26",
"2024-11-05",
]
function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) {
return {
jsonrpc: "2.0",
id,
result,
}
}
function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) {
return {
jsonrpc: "2.0",
id: id ?? null,
error: {
code,
message,
},
}
}
function selectProtocolVersion(clientVersion?: string) {
if (clientVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
return clientVersion
}
return SUPPORTED_PROTOCOL_VERSIONS[0]
}
export default async function mcpRoutes(server: FastifyInstance) {
server.post("/mcp", async (req, reply) => {
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
const requests = Array.isArray(body) ? body : [body]
const responses = []
for (const request of requests) {
const id = request?.id
if (!request || request.jsonrpc !== "2.0" || !request.method) {
responses.push(jsonRpcError(id, -32600, "Invalid JSON-RPC request"))
continue
}
if (request.method === "notifications/initialized") {
continue
}
if (request.method === "initialize") {
const clientVersion = request.params?.protocolVersion
responses.push(jsonRpcResult(id, {
protocolVersion: selectProtocolVersion(clientVersion),
capabilities: {
tools: {
listChanged: false,
},
},
serverInfo: {
name: "fedeo-mcp",
version: "1.0.0",
},
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
}))
continue
}
if (request.method === "ping") {
responses.push(jsonRpcResult(id, {}))
continue
}
if (request.method === "tools/list") {
responses.push(jsonRpcResult(id, {
tools: mcpTools.map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: {
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
},
})),
}))
continue
}
if (request.method === "tools/call") {
const toolName = request.params?.name
const tool = typeof toolName === "string" ? mcpToolMap.get(toolName) : null
if (!tool) {
responses.push(jsonRpcError(id, -32602, `Unknown tool: ${toolName}`))
continue
}
try {
const context = await createMcpContext(server, req)
assertToolPermission(context, tool)
const result = await tool.handler(context, request.params?.arguments || {})
responses.push(jsonRpcResult(id, asToolResult(result)))
} catch (error) {
const statusCode = (error as any)?.statusCode
if (statusCode === 401 || statusCode === 403) {
responses.push(jsonRpcError(id, statusCode === 401 ? -32001 : -32003, error instanceof Error ? error.message : "Forbidden"))
} else {
responses.push(jsonRpcResult(id, asToolError(error)))
}
}
continue
}
responses.push(jsonRpcError(id, -32601, `Method not found: ${request.method}`))
}
if (responses.length === 0) {
return reply.code(204).send()
}
return Array.isArray(body) ? responses : responses[0]
})
server.get("/mcp", async (_req, reply) => {
return reply.send({
name: "fedeo-mcp",
transport: "http-json-rpc",
endpoint: "/api/mcp",
tools: mcpTools.map((tool) => tool.name),
})
})
}

View File

@@ -0,0 +1,230 @@
import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
const portalCustomerId = await getPortalCustomerId(server, req)
if (!portalCustomerId) return null
const [contract] = await server.db
.select({
id: contracts.id,
name: contracts.name,
tenant: contracts.tenant,
customer: contracts.customer,
contracttype: contracts.contracttype,
allowedContracttypes: contracts.allowedContracttypes,
archived: contracts.archived,
})
.from(contracts)
.where(and(
eq(contracts.id, contractId),
eq(contracts.tenant, req.user?.tenant_id),
eq(contracts.customer, portalCustomerId),
eq(contracts.archived, false)
))
.limit(1)
return contract || null
}
function normalizeMessage(message: unknown) {
if (typeof message !== "string") return ""
return message.trim()
}
function appendMessage(text: string, message: string) {
return message ? `${text} Nachricht: ${message}` : text
}
function formatDateForHistory(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
}).format(date)
}
export default async function portalContractRoutes(server: FastifyInstance) {
server.post<{
Params: { id: string }
Body: { contracttype?: number | string; message?: string }
}>("/portal/contracts/:id/change-request", {
schema: {
tags: ["Portal"],
summary: "Request contract type change from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["contracttype"],
properties: {
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedContracttypeId = Number(req.body.contracttype)
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const [requestedContracttype] = await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, requestedContracttypeId),
eq(contracttypes.tenant, req.user?.tenant_id),
eq(contracttypes.archived, false)
))
.limit(1)
if (!requestedContracttype) {
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
}
const allowedContracttypes = Array.isArray(contract.allowedContracttypes)
? contract.allowedContracttypes.map((id) => Number(id)).filter((id) => Number.isInteger(id))
: []
if (!allowedContracttypes.includes(requestedContracttype.id)) {
return reply.code(400).send({ error: "Dieser Vertragstyp steht für diesen Vertrag nicht zur Auswahl" })
}
const [currentContracttype] = contract.contracttype
? await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, contract.contracttype),
eq(contracttypes.tenant, req.user?.tenant_id)
))
.limit(1)
: []
const message = normalizeMessage(req.body.message)
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
const newName = requestedContracttype.name
const text = appendMessage(
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
oldVal: { contracttype: contract.contracttype, name: oldName },
newVal: { contracttype: requestedContracttype.id, name: newName },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
server.post<{
Params: { id: string }
Body: { requestedEndDate?: string; message?: string }
}>("/portal/contracts/:id/cancellation-request", {
schema: {
tags: ["Portal"],
summary: "Request contract cancellation from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["requestedEndDate"],
properties: {
requestedEndDate: { type: "string" },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedEndDate = typeof req.body.requestedEndDate === "string"
? req.body.requestedEndDate.trim()
: ""
if (!Number.isInteger(contractId) || !requestedEndDate) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const parsedDate = new Date(requestedEndDate)
if (Number.isNaN(parsedDate.getTime())) {
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const message = normalizeMessage(req.body.message)
const text = appendMessage(
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
newVal: { requestedEndDate },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
}

View File

@@ -4,6 +4,16 @@ import { eq, and } from "drizzle-orm";
import {
authProfiles,
} from "../../db/schema";
import {
loadProfileWithBranches,
resolveTenantBranchIds,
syncProfileBranches,
} from "../utils/profileBranches";
import {
enrichProfilesWithTeams,
resolveTenantTeamIds,
syncProfileTeams,
} from "../utils/profileTeams";
export default async function authProfilesRoutes(server: FastifyInstance) {
@@ -19,22 +29,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "No tenant selected" });
}
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1);
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
if (!rows.length) {
if (!profile) {
return reply.code(404).send({ error: "User not found or not in tenant" });
}
return rows[0];
return profile;
} catch (error) {
console.error("GET /profiles/:id ERROR:", error);
@@ -48,7 +52,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
// ❌ Systemfelder entfernen
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name"
"updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch"
]
forbidden.forEach(f => delete cleaned[f])
@@ -89,8 +94,32 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
// Clean + Normalize
body = sanitizeProfileUpdate(body)
const { primaryBranchId, branchIds } = await resolveTenantBranchIds(
server,
tenantId,
[
...(Array.isArray(body.branch_ids) ? body.branch_ids : []),
...(Array.isArray(body.branches) ? body.branches : []),
],
body.branch_id ?? body.branch?.id ?? null
)
const teamIds = await resolveTenantTeamIds(
server,
tenantId,
[
...(Array.isArray(body.team_ids) ? body.team_ids : []),
...(Array.isArray(body.teams) ? body.teams : []),
],
)
delete body.branch_ids
delete body.branches
delete body.team_ids
delete body.teams
const updateData = {
...body,
branch_id: primaryBranchId,
updatedAt: new Date(),
updatedBy: userId
}
@@ -110,10 +139,23 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "User not found or not in tenant" })
}
return updated[0]
await syncProfileBranches(server, id, branchIds, userId)
await syncProfileTeams(server, id, teamIds, userId)
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
return profile || updated[0]
} catch (err) {
console.error("PUT /profiles/:id ERROR:", err)
if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) {
return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" })
}
if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") {
return reply.code(400).send({ error: "Ungültige Teamauswahl" })
}
return reply.code(500).send({ error: "Internal Server Error" })
}
})

View File

@@ -11,6 +11,7 @@ import {
sql,
} from "drizzle-orm"
import { authProfiles, costcentres } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
// -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// -------------------------------------------------------------
@@ -130,12 +134,92 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
return whereCond
}
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
if (!portalCustomerId) return whereCond
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
return null
}
if (resource === "customers") {
return and(whereCond, eq(table.id, portalCustomerId))
}
if (resource === "contracts") {
return and(whereCond, eq(table.customer, portalCustomerId))
}
if (resource === "createddocuments") {
return and(
whereCond,
eq(table.customer, portalCustomerId),
eq(table.availableInPortal, true),
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
)
}
return whereCond
}
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
return {
name: payload.name,
firstname: payload.firstname,
lastname: payload.lastname,
salutation: payload.salutation,
title: payload.title,
nameAddition: payload.nameAddition,
infoData: nextInfoData,
}
}
function getTenantColumn(resource: string, table: any) {
const config = resourceConfig[resource]
const tenantKey = config?.tenantKey || "tenant"
return table[tenantKey]
}
function getRelationConfig(relation: string) {
const candidateKeys = [
relation,
`${relation}s`,
]
if (relation.endsWith("y")) {
candidateKeys.push(`${relation.slice(0, -1)}ies`)
}
if (/(s|x|z|ch|sh)$/.test(relation)) {
candidateKeys.push(`${relation}es`)
}
for (const key of candidateKeys) {
if (resourceConfig[key]) return resourceConfig[key]
}
return null
}
function isDateLikeField(key: string) {
if (key === "deliveryDateType") return false
if (key.includes("_at") || key.endsWith("At")) return true
@@ -176,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
return null
}
async function validateCostCentreParent(
server: FastifyInstance,
tenantId: number,
costCentreId: string | null,
parentCostcentreId: string | null
) {
if (!parentCostcentreId) {
return null
}
const hierarchyRows = await server.db
.select({
id: costcentres.id,
parentCostcentre: costcentres.parentCostcentre,
})
.from(costcentres)
.where(eq(costcentres.tenant, tenantId))
const hierarchyMap = new Map(
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
)
if (!hierarchyMap.has(parentCostcentreId)) {
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
}
if (costCentreId && parentCostcentreId === costCentreId) {
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
}
if (!costCentreId) {
return null
}
let currentParentId: string | null = parentCostcentreId
const visited = new Set<string>()
while (currentParentId) {
if (currentParentId === costCentreId) {
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
}
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
currentParentId = hierarchyMap.get(currentParentId) || null
}
return null
}
function maskIban(iban: string) {
if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "")
@@ -250,18 +387,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) {
return reply.code(404).send({ error: "Unknown resource" })
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table
const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
const relConfig = getRelationConfig(rel)
if (relConfig) {
const relTable = relConfig.table
@@ -307,7 +449,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
})
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
@@ -358,6 +501,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) {
return reply.code(404).send({ error: "Unknown resource" });
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table;
const { queryConfig } = req;
@@ -367,6 +514,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
const parsedFilters: Array<{ key: string; value: any }> = []
@@ -376,7 +524,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConfig = getRelationConfig(rel)
if (relConfig) {
const relTable = relConfig.table;
@@ -457,7 +605,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConfig = getRelationConfig(rel)
if (!relConfig) return;
const relTable = relConfig.table;
if (relTable !== table) {
@@ -467,6 +615,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
@@ -496,7 +645,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
});
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table;
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
@@ -547,10 +697,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const projRows = await server.db
.select()
@@ -567,7 +722,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad) {
if (data[relation]) {
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
const relConf = getRelationConfig(relation)
if (!relConf) continue
const relTable = relConf.table
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
data[relation] = relData[0] || null
@@ -600,6 +756,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId) {
return reply.code(403).send({ error: "Forbidden" })
}
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
@@ -623,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
createData = prepared.data!
}
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
req.user.tenant_id,
null,
createData.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
@@ -679,8 +852,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
const portalCustomerId = await getPortalCustomerId(server, req)
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
if (portalCustomerId && resource !== "customers") {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
@@ -688,13 +865,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1)
if (!oldRecord) {
return reply.code(404).send({ error: "Resource not found" })
}
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore
delete data.updatedBy; delete data.updatedAt;
if (portalCustomerId) {
data = {
...sanitizePortalCustomerUpdate(data),
updated_at: data.updated_at,
updated_by: data.updated_by,
}
}
if (resource === "members") {
data = normalizeMemberPayload(data)
const validationError = validateMemberPayload(data)
@@ -713,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
tenantId,
oldRecord.id,
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
? data.parentCostcentre || null
: oldRecord.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
Object.keys(data).forEach((key) => {
const value = data[key]
const shouldNormalize =

View File

@@ -4,6 +4,7 @@ import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units, countrys, tenants } from "../../db/schema"
import { defaultCountries } from "../utils/countries"
const TABLE_MAP: Record<string, any> = {
accounts,
@@ -96,6 +97,24 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
const data = await query
if (resource === "countrys") {
const countryMap = new Map<string, any>()
for (const country of defaultCountries) {
countryMap.set(country.toLocaleLowerCase("de"), { id: country, name: country })
}
for (const country of data) {
countryMap.set(country.name.toLocaleLowerCase("de"), country)
}
return sortData(
Array.from(countryMap.values()),
sort || "name",
sort ? ascQuery === "true" : true
)
}
// Falls sort clientseitig wie früher notwendig ist:
const sorted = sortData(
data,

View File

@@ -11,7 +11,7 @@ import {
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
].sort(compareTimeEvents);
// SCHRITT 4: Ableiten und Anreichern
const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
}
});
}
}

View File

@@ -12,6 +12,8 @@ import {
} from "../../db/schema"
import {and, desc, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams"
export default async function tenantRoutes(server: FastifyInstance) {
@@ -123,7 +125,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
@@ -131,6 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) {
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
@@ -160,11 +164,13 @@ export default async function tenantRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
return { data }
} catch (err) {

View File

@@ -0,0 +1,258 @@
const COUNTRY_CODES = [
"AF",
"AX",
"AL",
"DZ",
"AS",
"AD",
"AO",
"AI",
"AQ",
"AG",
"AR",
"AM",
"AW",
"AU",
"AT",
"AZ",
"BS",
"BH",
"BD",
"BB",
"BY",
"BE",
"BZ",
"BJ",
"BM",
"BT",
"BO",
"BQ",
"BA",
"BW",
"BV",
"BR",
"IO",
"BN",
"BG",
"BF",
"BI",
"CV",
"KH",
"CM",
"CA",
"KY",
"CF",
"TD",
"CL",
"CN",
"CX",
"CC",
"CO",
"KM",
"CG",
"CD",
"CK",
"CR",
"CI",
"HR",
"CU",
"CW",
"CY",
"CZ",
"DK",
"DJ",
"DM",
"DO",
"EC",
"EG",
"SV",
"GQ",
"ER",
"EE",
"SZ",
"ET",
"FK",
"FO",
"FJ",
"FI",
"FR",
"GF",
"PF",
"TF",
"GA",
"GM",
"GE",
"DE",
"GH",
"GI",
"GR",
"GL",
"GD",
"GP",
"GU",
"GT",
"GG",
"GN",
"GW",
"GY",
"HT",
"HM",
"VA",
"HN",
"HK",
"HU",
"IS",
"IN",
"ID",
"IR",
"IQ",
"IE",
"IM",
"IL",
"IT",
"JM",
"JP",
"JE",
"JO",
"KZ",
"KE",
"KI",
"KP",
"KR",
"KW",
"KG",
"LA",
"LV",
"LB",
"LS",
"LR",
"LY",
"LI",
"LT",
"LU",
"MO",
"MG",
"MW",
"MY",
"MV",
"ML",
"MT",
"MH",
"MQ",
"MR",
"MU",
"YT",
"MX",
"FM",
"MD",
"MC",
"MN",
"ME",
"MS",
"MA",
"MZ",
"MM",
"NA",
"NR",
"NP",
"NL",
"NC",
"NZ",
"NI",
"NE",
"NG",
"NU",
"NF",
"MK",
"MP",
"NO",
"OM",
"PK",
"PW",
"PS",
"PA",
"PG",
"PY",
"PE",
"PH",
"PN",
"PL",
"PT",
"PR",
"QA",
"RE",
"RO",
"RU",
"RW",
"BL",
"SH",
"KN",
"LC",
"MF",
"PM",
"VC",
"WS",
"SM",
"ST",
"SA",
"SN",
"RS",
"SC",
"SL",
"SG",
"SX",
"SK",
"SI",
"SB",
"SO",
"ZA",
"GS",
"SS",
"ES",
"LK",
"SD",
"SR",
"SJ",
"SE",
"CH",
"SY",
"TW",
"TJ",
"TZ",
"TH",
"TL",
"TG",
"TK",
"TO",
"TT",
"TN",
"TR",
"TM",
"TC",
"TV",
"UG",
"UA",
"AE",
"GB",
"UM",
"US",
"UY",
"UZ",
"VU",
"VE",
"VN",
"VG",
"VI",
"WF",
"EH",
"YE",
"ZM",
"ZW",
] as const
const countryNames = new Intl.DisplayNames(["de"], { type: "region" })
export const defaultCountries = COUNTRY_CODES
.map((code) => countryNames.of(code))
.filter((name): name is string => Boolean(name))
.sort((a, b) => a.localeCompare(b, "de"))

View File

@@ -8,7 +8,7 @@ import { s3 } from "../s3";
import { secrets } from "../secrets";
// Drizzle Core Imports
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
// Tabellen Imports (keine Relations nötig!)
import {
@@ -136,6 +136,10 @@ export async function buildExportZip(
const CdCustomer = aliasedTable(customers, "cd_customer");
const IiVendor = aliasedTable(vendors, "ii_vendor");
const ContraAccount = aliasedTable(accounts, "contra_account");
const ContraVendor = aliasedTable(vendors, "contra_vendor");
const ContraCustomer = aliasedTable(customers, "contra_customer");
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
const allocRaw = await server.db.select({
allocation: statementallocations,
@@ -148,11 +152,15 @@ export async function buildExportZip(
acc: accounts,
direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts
own: ownaccounts,
contra_acc: ContraAccount,
contra_vend: ContraVendor,
contra_cust: ContraCustomer,
contra_own: ContraOwnaccount
})
.from(statementallocations)
// JOIN 1: Bankstatement (Pflicht, für Datum Filter)
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
.leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 2: Bankaccount (für DATEV Nummer)
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
@@ -169,13 +177,25 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
.where(and(
eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false),
// Datum Filter direkt auf dem Bankstatement
gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
or(
and(
gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
),
and(
isNull(statementallocations.bankstatement),
gte(statementallocations.manualBookingDate, startDate),
lte(statementallocations.manualBookingDate, endDate)
)
)
));
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
@@ -196,7 +216,11 @@ export async function buildExportZip(
account: r.acc,
vendor: r.direct_vend,
customer: r.direct_cust,
ownaccount: r.own
ownaccount: r.own,
contraAccount: r.contra_acc,
contraVendor: r.contra_vend,
contraCustomer: r.contra_cust,
contraOwnaccount: r.contra_own
}));
// --- D) Stammdaten Accounts ---
@@ -311,8 +335,42 @@ export async function buildExportZip(
});
// Bank
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
const prefix = side === "credit" ? "contra" : "";
const account = side === "credit" ? alloc.contraAccount : alloc.account;
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
if (incominginvoice) {
return {
number: incominginvoice.vendor?.vendorNumber || "",
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
type: "Eingangsbeleg",
reference: incominginvoice.reference || "",
};
}
return { number: "", name: "", type: prefix };
};
statementallocationsList.forEach(alloc => {
const bs = alloc.bankstatement; // durch Mapping verfügbar
if(!bs && alloc.manualBookingDate) {
const debit = getManualBookingSide(alloc, "debit");
const credit = getManualBookingSide(alloc, "credit");
const dateManual = dayjs(alloc.manualBookingDate).format("DDMM");
const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY");
const belegnummer = debit.reference || credit.reference || "";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
return;
}
if(!bs) return;
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
@@ -425,4 +483,4 @@ export async function buildExportZip(
console.error("DATEV Export Error:", error);
throw error;
}
}
}

View File

@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
tenantId: number,
numberRange: string
) => {
const numberRangeFallbacks: Record<string, string> = {
costEstimates: "quotes",
packingSlips: "deliveryNotes",
advanceInvoices: "invoices",
cancellationInvoices: "invoices",
}
const [tenant] = await server.db
.select()
.from(tenants)
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
const numberRanges = tenant.numberRanges || {}
if (!numberRanges[numberRange]) {
const resolvedNumberRange = numberRanges[numberRange]
? numberRange
: numberRangeFallbacks[numberRange]
if (!resolvedNumberRange || !numberRanges[resolvedNumberRange]) {
throw new Error(`Number range '${numberRange}' not found`)
}
const current = numberRanges[numberRange]
const current = numberRanges[resolvedNumberRange]
const usedNumber =
(current.prefix || "") +
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
const updatedRanges = {
// @ts-ignore
...numberRanges,
[numberRange]: {
[resolvedNumberRange]: {
...current,
nextNumber: current.nextNumber + 1,
},

View File

@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
const HISTORY_ENTITY_LABELS: Record<string, string> = {
customers: "Kunden",
contracts: "Verträge",
members: "Mitglieder",
vendors: "Lieferanten",
projects: "Projekte",
@@ -32,6 +33,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
incominginvoices: "Eingangsrechnungen",
files: "Dateien",
memberrelations: "Mitgliedsverhältnisse",
teams: "Teams",
}
export function getHistoryEntityLabel(entity: string) {
@@ -62,6 +64,7 @@ export async function insertHistoryItem(
const columnMap: Record<string, string> = {
customers: "customer",
contracts: "contract",
members: "customer",
vendors: "vendor",
projects: "project",

View File

@@ -0,0 +1,839 @@
import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import { createHash } from "node:crypto";
import {
bankaccounts,
bankstatements,
createddocuments,
incominginvoices,
statementallocations,
tenants,
} from "../../db/schema";
import { secrets } from "./secrets";
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";
type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";
type ForecastEvent = {
date: string;
amount: number;
label: string;
source: ForecastEventSource;
sourceId?: number | string | null;
recurringKey?: string;
confidence?: number;
};
type TaxBreakdown = {
net19: number;
tax19: number;
net7: number;
tax7: number;
net0: number;
};
type TaxForecastPeriod = {
key: string;
label: string;
range: string;
dueDate: string;
outputTax: number;
inputTax: number;
balance: number;
outputCount: number;
inputCount: number;
};
type RecurringCandidate = {
key?: string;
label: string;
amount: number;
interval: "weekly" | "monthly" | "quarterly" | "yearly";
nextDate: string;
confidence: number;
evidence: string;
};
const AiRecurringFormat = z.object({
candidates: z.array(z.object({
label: z.string(),
amount: z.number(),
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
nextDate: z.string(),
confidence: z.number().min(0).max(1),
evidence: z.string(),
})),
});
const FORECAST_DAYS = 90;
const HISTORY_MONTHS = 12;
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
const createZeroTaxBreakdown = (): TaxBreakdown => ({
net19: 0,
tax19: 0,
net7: 0,
tax7: 0,
net0: 0,
});
const normalizeText = (value: unknown) => String(value || "")
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
if (value === "quarterly" || value === "yearly") return value;
return "monthly";
};
const isTaxFreeDocument = (taxType?: string | null) => {
return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
};
const getTaxEvaluationPeriodBounds = (
referenceDate: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const base = dayjs(referenceDate);
if (period === "yearly") {
return {
start: base.startOf("year"),
end: base.endOf("year"),
};
}
if (period === "quarterly") {
const quarterStartMonth = Math.floor(base.month() / 3) * 3;
const start = base.month(quarterStartMonth).startOf("month");
return {
start,
end: start.add(2, "month").endOf("month"),
};
}
return {
start: base.startOf("month"),
end: base.endOf("month"),
};
};
const shiftTaxEvaluationPeriodStart = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod,
offset: number
) => {
const base = dayjs(periodStart);
if (period === "yearly") return base.add(offset, "year").startOf("year");
if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
return base.add(offset, "month").startOf("month");
};
const formatTaxEvaluationPeriodLabel = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start } = getTaxEvaluationPeriodBounds(periodStart, period);
if (period === "yearly") {
return start.format("YYYY");
}
if (period === "quarterly") {
return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
}
return start.format("MMMM YYYY");
};
const formatTaxEvaluationPeriodRange = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
};
const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
return periodEnd.add(1, "month").date(10).startOf("day");
};
const getRecurringKey = (candidate: RecurringCandidate) => {
const rawKey = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label),
candidate.interval,
].join("|");
return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
};
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
if (interval === "weekly") return date.add(1, "week");
if (interval === "quarterly") return date.add(3, "month");
if (interval === "yearly") return date.add(1, "year");
return date.add(1, "month");
};
const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
if (interval === "wöchentlich") return date.add(1, "week");
if (interval === "2 - wöchentlich") return date.add(2, "week");
if (interval === "vierteljährlich") return date.add(3, "month");
if (interval === "halbjährlich") return date.add(6, "month");
if (interval === "jährlich") return date.add(1, "year");
return date.add(1, "month");
};
const getStatementPartner = (statement: any) => {
return statement.amount < 0
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
};
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
let totalNet = 0;
let totalTax = 0;
(document.rows || []).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return;
const rowNet = Number(
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
);
const taxPercent = Number(row.taxPercent);
totalNet += rowNet;
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
});
let advancePayments = 0;
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
if (!advanceRow) return;
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
});
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
};
const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
if (!document || isTaxFreeDocument(document.taxType)) {
return breakdown;
}
(document.rows || []).forEach((row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;
const quantity = Number(row.quantity || 0);
const price = Number(row.price || 0);
const discountPercent = Number(row.discountPercent || 0);
const taxPercent = Number(row.taxPercent || 0);
const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));
if (!Number.isFinite(net) || net === 0) return;
if (taxPercent === 19) {
breakdown.net19 += net;
breakdown.tax19 += Number((net * 0.19).toFixed(2));
} else if (taxPercent === 7) {
breakdown.net7 += net;
breakdown.tax7 += Number((net * 0.07).toFixed(2));
} else {
breakdown.net0 += net;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
(invoice?.accounts || []).forEach((account: any) => {
const taxType = String(account?.taxType || "");
const amountNet = Number(account?.amountNet || 0);
const amountTax = Number(account?.amountTax || 0);
if (taxType === "19") {
breakdown.net19 += amountNet;
breakdown.tax19 += amountTax;
} else if (taxType === "7") {
breakdown.net7 += amountNet;
breakdown.tax7 += amountTax;
} else {
breakdown.net0 += amountNet;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceSignedAmount = (invoice: any) => {
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
}, 0);
return roundMoney(invoice.expense === false ? amount : amount * -1);
};
const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
if (remainingAbsolute <= 0.01) return 0;
return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
};
const findCancellationDocumentIds = (documents: any[]) => {
return new Set(
documents
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
.filter(Boolean)
);
};
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
if (dates.length < 3) return null;
const gaps = dates
.slice(1)
.map((date, index) => date.diff(dates[index], "day"))
.filter((gap) => gap > 0);
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
if (averageGap >= 6 && averageGap <= 8) return "weekly";
if (averageGap >= 25 && averageGap <= 35) return "monthly";
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
if (averageGap >= 340 && averageGap <= 390) return "yearly";
return null;
};
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
const groups = new Map<string, any[]>();
statements.forEach((statement) => {
const parsedDate = dayjs(statement.valueDate || statement.date);
if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;
const partner = normalizeText(getStatementPartner(statement));
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(statement);
});
return [...groups.values()]
.map((group) => {
const sorted = group
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
if (!interval) return null;
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
while (next.isBefore(dayjs(), "day")) {
next = addInterval(next, interval);
}
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
const partner = getStatementPartner(sorted[sorted.length - 1]);
return {
label: partner,
amount,
interval,
nextDate: next.format("YYYY-MM-DD"),
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
};
})
.filter(Boolean) as RecurringCandidate[];
};
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
const compactStatements = statements.slice(0, 220).map((statement) => ({
date: statement.valueDate || statement.date,
amount: roundMoney(Number(statement.amount || 0)),
partner: getStatementPartner(statement),
text: String(statement.text || "").slice(0, 160),
}));
try {
const completion = await openai.chat.completions.parse({
model: "gpt-4o",
store: true,
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
messages: [
{
role: "system",
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
},
{
role: "user",
content: JSON.stringify({
today: dayjs().format("YYYY-MM-DD"),
horizonDays: FORECAST_DAYS,
bankStatements: compactStatements,
rules: [
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
"nextDate muss in der Zukunft liegen.",
"Nutze keine einmaligen oder unsicheren Bewegungen.",
],
}),
},
],
});
return (completion.choices[0].message.parsed?.candidates || [])
.filter((candidate) => dayjs(candidate.nextDate).isValid())
.filter((candidate) => Number(candidate.amount || 0) < 0)
.map((candidate) => ({
...candidate,
amount: roundMoney(candidate.amount),
confidence: Math.max(0, Math.min(1, candidate.confidence)),
}));
} catch (error) {
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
server.log.warn(error);
return [];
}
};
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
const merged = new Map<string, RecurringCandidate>();
[...heuristic, ...ai].forEach((candidate) => {
const key = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label).slice(0, 32),
candidate.interval,
].join("|");
const existing = merged.get(key);
if (!existing || candidate.confidence > existing.confidence) {
merged.set(key, candidate);
}
});
return [...merged.values()]
.map((candidate) => ({
...candidate,
key: getRecurringKey(candidate),
}))
.filter((candidate) => Number(candidate.amount || 0) < 0)
.filter((candidate) => Math.abs(candidate.amount) >= 1)
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
const events: ForecastEvent[] = [];
recurring.forEach((candidate) => {
let date = dayjs(candidate.nextDate);
let guard = 0;
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
events.push({
date: date.format("YYYY-MM-DD"),
amount: roundMoney(candidate.amount),
label: candidate.label,
source: "recurring_bankstatement",
recurringKey: candidate.key,
confidence: candidate.confidence,
});
date = addInterval(date, candidate.interval);
guard += 1;
}
});
return events;
};
const buildTaxForecastPeriods = (
documents: any[],
incomingInvoices: any[],
periodType: TaxEvaluationPeriod,
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
const periods: TaxForecastPeriod[] = [];
let offset = 0;
while (offset < 12) {
const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
const dueDate = getTaxSettlementDate(bounds.end);
if (dueDate.isAfter(endDate, "day")) break;
const outputDocs = documents.filter((document) => {
if (document?.state !== "Gebucht") return false;
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;
const date = dayjs(document.documentDate);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const inputDocs = incomingInvoices.filter((invoice) => {
if (invoice?.state !== "Gebucht" || !invoice?.date) return false;
const date = dayjs(invoice.date);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
const breakdown = getCreatedDocumentTaxBreakdown(document);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const balance = roundMoney(outputTax - inputTax);
periods.push({
key: bounds.start.format("YYYY-MM-DD"),
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
dueDate: dueDate.format("YYYY-MM-DD"),
outputTax,
inputTax,
balance,
outputCount: outputDocs.length,
inputCount: inputDocs.length,
});
offset += 1;
}
return periods;
};
const buildSerialTemplateEvents = (
documents: any[],
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const events: ForecastEvent[] = [];
documents
.filter((document) => document.type === "serialInvoices")
.filter((document) => document.serialConfig?.active)
.forEach((document) => {
const firstExecution = dayjs(document.serialConfig?.firstExecution);
const executionUntil = dayjs(document.serialConfig?.executionUntil);
if (!firstExecution.isValid() || !executionUntil.isValid()) return;
const amount = getCreatedDocumentGrossAmount(document, documents);
if (amount <= 0.01) return;
let executionDate = firstExecution.startOf("day");
let guard = 0;
while (executionDate.isBefore(today, "day") && guard < 240) {
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
while (
executionDate.isValid()
&& !executionDate.isAfter(executionUntil, "day")
&& !executionDate.isAfter(endDate, "day")
&& guard < 400
) {
const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");
events.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount,
label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
source: "serial_template",
sourceId: document.id,
confidence: 0.75,
});
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
});
return events.sort((a, b) => a.date.localeCompare(b.date));
};
export const generateLiquidityForecast = async (
server: FastifyInstance,
tenantId: number,
ignoredRecurringKeys: string[] = []
) => {
const today = dayjs().startOf("day");
const endDate = today.add(FORECAST_DAYS, "day");
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
server.db
.select()
.from(bankaccounts)
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
.orderBy(desc(bankstatements.valueDate)),
server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
server.db
.select()
.from(statementallocations)
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
server.db
.select({
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1),
]);
const activeAccounts = accounts.filter((account) => !account.archived);
const activeStatements = statements.filter((statement) => !statement.archived);
const activeDocuments = documents.filter((document) => !document.archived);
const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
const activeAllocations = allocations.filter((allocation) => {
if (allocation.archived) return false;
if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
return true;
});
const startingBalance = roundMoney(
activeAccounts
.filter((account) => !account.expired)
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
);
const allocationByDocument = new Map<number, number>();
const allocationByIncomingInvoice = new Map<number, number>();
activeAllocations.forEach((allocation) => {
if (allocation.createddocument) {
allocationByDocument.set(
allocation.createddocument,
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
);
}
if (allocation.incominginvoice) {
allocationByIncomingInvoice.set(
allocation.incominginvoice,
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
);
}
});
const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
const openEvents: ForecastEvent[] = [];
const draftEvents: ForecastEvent[] = [];
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
if (openAmount <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
source: "open_createddocument",
sourceId: document.id,
confidence: 1,
});
});
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
if (total <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
draftEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: total,
label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
source: "draft_createddocument",
sourceId: document.id,
confidence: 0.35,
});
});
activeIncomingInvoices
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
.filter((invoice) => !invoice.paid)
.forEach((invoice) => {
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
if (Math.abs(openAmount) <= 0.01) return;
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
? dayjs(invoice.dueDate || invoice.date)
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
source: "open_incominginvoice",
sourceId: invoice.id,
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
});
});
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
.filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
const taxEvents: ForecastEvent[] = taxPeriods
.filter((period) => Math.abs(period.balance) > 0.01)
.map((period) => ({
date: period.dueDate,
amount: roundMoney(period.balance * -1),
label: `USt ${period.label}`,
source: "tax_settlement" as const,
sourceId: period.key,
confidence: 0.95,
}));
const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
const events = [
...openEvents,
...taxEvents,
...serialTemplateEvents,
...expandRecurringEvents(recurring, endDate),
].filter((event) => {
const date = dayjs(event.date);
return date.isValid() && !date.isAfter(endDate, "day");
});
const dailyEvents = new Map<string, ForecastEvent[]>();
events.forEach((event) => {
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
dailyEvents.get(event.date)!.push(event);
});
let runningBalance = startingBalance;
const points = [];
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
const date = today.add(offset, "day").format("YYYY-MM-DD");
const dayEvents = dailyEvents.get(date) || [];
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
runningBalance = roundMoney(runningBalance + income + expense);
points.push({
date,
balance: runningBalance,
income,
expense,
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
});
}
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
return {
generatedAt: new Date().toISOString(),
horizonDays: FORECAST_DAYS,
startingBalance,
endingBalance: points[points.length - 1]?.balance || startingBalance,
lowestBalance: lowestPoint?.balance || startingBalance,
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
totalIncome,
totalExpense,
accounts: activeAccounts.map((account) => ({
id: account.id,
name: account.name,
iban: account.iban,
balance: roundMoney(Number(account.balance || 0)),
expired: account.expired,
syncedAt: account.syncedAt,
})),
recurring,
events: events.sort((a, b) => a.date.localeCompare(b.date)),
draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
draftIncome,
tax: {
periodType: taxPeriodType,
periods: taxPeriods,
totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
},
points,
ai: {
enabled: Boolean(secrets.OPENAI_API_KEY),
candidates: aiRecurring.length,
},
};
};

View File

@@ -58,6 +58,8 @@ const getDuration = (time) => {
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const isPackingSlip = invoiceData?.type === "packingSlips"
const genPDF = async (invoiceData, backgroundSourceBuffer) => {
const pdfDoc = await PDFDocument.create()
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
if (invoiceData.type !== "deliveryNotes") {
if (isPackingSlip) {
pages[pageCounter - 1].drawText("Check", {
...getCoordinatesForPDFLib(180, 137, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText("Steuer", {
...getCoordinatesForPDFLib(135, 137, page1),
size: 12,
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
maxWidth: 240
})
if (isPackingSlip) {
pages[pageCounter - 1].drawRectangle({
...getCoordinatesForPDFLib(182, rowHeight + 1, page1),
width: 12,
height: 12,
borderColor: rgb(0, 0, 0),
borderWidth: 0.8,
opacity: 1,
borderOpacity: 1,
})
}
let rowTextLines = 0
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight, page1),
size: 10,
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
rowTextLines = splitStringBySpace(row.text, 35).length
} else {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 80).join("\n"), {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, isPackingSlip ? 68 : 80).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight, page1),
size: 10,
color: rgb(0, 0, 0),
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
rowTextLines = splitStringBySpace(row.text, 80).length
rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
}
let rowDescriptionLines = 0
if (row.descriptionText) {
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
})
} else {
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), {
rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
size: 10,
color: rgb(0, 0, 0),
@@ -466,7 +492,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
...getCoordinatesForPDFLib(135, rowHeight, page1),
size: 10,
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
if (invoiceData.type !== "deliveryNotes") {
if (isPackingSlip) {
page.drawText("Check", {
...getCoordinatesForPDFLib(180, 22, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
page.drawText("Steuer", {
...getCoordinatesForPDFLib(135, 22, page1),
size: 12,
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
let endTextDiff = 35
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawLine({
start: getCoordinatesForPDFLib(20, rowHeight, page1),
end: getCoordinatesForPDFLib(198, rowHeight, page1),
@@ -864,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
opacity: 1,
maxWidth: 500
})
return await pdfDoc.saveAsBase64()
}
return await pdfDoc.saveAsBase64()
}
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
@@ -1138,4 +1176,4 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
console.log(error)
throw error; // Fehler weiterwerfen, damit er oben ankommt
}
}
}

View File

@@ -0,0 +1,142 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfileBranches, authProfiles, branches } from "../../db/schema"
function normalizeBranchIds(values: any[]): number[] {
return [...new Set(
values
.map((value) => {
if (typeof value === "number") return value
if (typeof value === "string" && value.trim()) return Number(value)
if (value && typeof value === "object" && "id" in value) return Number(value.id)
return NaN
})
.filter((value) => Number.isFinite(value))
)]
}
export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) {
if (!profiles.length) return profiles
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
if (!profileIds.length) return profiles
const profileBranchRows = await server.db
.select()
.from(authProfileBranches)
.where(inArray(authProfileBranches.profile_id, profileIds))
const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))]
const branchRows = branchIds.length
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
: []
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
const branchIdsByProfile = new Map<string, number[]>()
for (const row of profileBranchRows) {
const current = branchIdsByProfile.get(row.profile_id) || []
current.push(row.branch_id)
branchIdsByProfile.set(row.profile_id, current)
}
return profiles.map((profile) => {
const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])]
return {
...profile,
branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null,
branches: assignedBranchIds
.map((branchId) => branchMap.get(branchId))
.filter(Boolean),
branch_ids: assignedBranchIds,
}
})
}
export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) {
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1)
if (!rows.length) return null
const [profile] = await enrichProfilesWithBranches(server, rows)
return profile
}
export async function resolveTenantBranchIds(
server: FastifyInstance,
tenantId: number,
values: any[],
primaryBranchId?: any
) {
const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === ""
? null
: Number(primaryBranchId)
const requestedBranchIds = normalizeBranchIds([
...values,
normalizedPrimaryBranchId,
])
if (!requestedBranchIds.length) {
return {
primaryBranchId: normalizedPrimaryBranchId,
branchIds: [],
}
}
const validBranches = await server.db
.select({ id: branches.id })
.from(branches)
.where(
and(
eq(branches.tenant, tenantId),
inArray(branches.id, requestedBranchIds)
)
)
const validBranchIds = validBranches.map((branch) => branch.id)
if (validBranchIds.length !== requestedBranchIds.length) {
throw new Error("INVALID_BRANCH_SELECTION")
}
if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) {
throw new Error("INVALID_PRIMARY_BRANCH")
}
return {
primaryBranchId: normalizedPrimaryBranchId,
branchIds: validBranchIds,
}
}
export async function syncProfileBranches(
server: FastifyInstance,
profileId: string,
branchIds: number[],
userId?: string | null
) {
await server.db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, profileId))
if (!branchIds.length) return
await server.db
.insert(authProfileBranches)
.values(branchIds.map((branchId) => ({
profile_id: profileId,
branch_id: branchId,
created_by: userId || null,
})))
}

View File

@@ -0,0 +1,117 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfileTeams, branches, teams } from "../../db/schema"
function normalizeTeamIds(values: any[]): number[] {
return [...new Set(
values
.map((value) => {
if (typeof value === "number") return value
if (typeof value === "string" && value.trim()) return Number(value)
if (value && typeof value === "object" && "id" in value) return Number(value.id)
return NaN
})
.filter((value) => Number.isFinite(value))
)]
}
export async function enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) {
if (!profiles.length) return profiles
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
if (!profileIds.length) return profiles
const profileTeamRows = await server.db
.select()
.from(authProfileTeams)
.where(inArray(authProfileTeams.profile_id, profileIds))
const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))]
const teamRows = teamIds.length
? await server.db.select().from(teams).where(inArray(teams.id, teamIds))
: []
const branchIds = [...new Set(teamRows.map((team) => team.branch).filter(Boolean))]
const branchRows = branchIds.length
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
: []
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
const teamMap = new Map(teamRows.map((team) => [
team.id,
{
...team,
branch: team.branch ? branchMap.get(team.branch) || null : null,
},
]))
const teamIdsByProfile = new Map<string, number[]>()
for (const row of profileTeamRows) {
const current = teamIdsByProfile.get(row.profile_id) || []
current.push(row.team_id)
teamIdsByProfile.set(row.profile_id, current)
}
return profiles.map((profile) => {
const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])]
return {
...profile,
teams: assignedTeamIds
.map((teamId) => teamMap.get(teamId))
.filter(Boolean),
team_ids: assignedTeamIds,
}
})
}
export async function resolveTenantTeamIds(
server: FastifyInstance,
tenantId: number,
values: any[],
) {
const requestedTeamIds = normalizeTeamIds(values)
if (!requestedTeamIds.length) {
return []
}
const validTeams = await server.db
.select({ id: teams.id })
.from(teams)
.where(
and(
eq(teams.tenant, tenantId),
inArray(teams.id, requestedTeamIds)
)
)
const validTeamIds = validTeams.map((team) => team.id)
if (validTeamIds.length !== requestedTeamIds.length) {
throw new Error("INVALID_TEAM_SELECTION")
}
return validTeamIds
}
export async function syncProfileTeams(
server: FastifyInstance,
profileId: string,
teamIds: number[],
userId?: string | null
) {
await server.db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, profileId))
if (!teamIds.length) return
await server.db
.insert(authProfileTeams)
.values(teamIds.map((teamId) => ({
profile_id: profileId,
team_id: teamId,
created_by: userId || null,
})))
}

View File

@@ -4,6 +4,7 @@ import {
bankaccounts,
bankrequisitions,
bankstatements,
branches,
entitybankaccounts,
events,
contacts,
@@ -35,6 +36,7 @@ import {
spaces,
statementallocations,
tasks,
teams,
texttemplates,
units,
vehicles,
@@ -162,9 +164,23 @@ export const resourceConfig = {
costcentres: {
table: costcentres,
searchColumns: ["name","number","description"],
mtoLoad: ["vehicle","project","inventoryitem"],
mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
numberRangeHolder: "number",
},
parentCostcentre: {
table: costcentres,
searchColumns: ["name", "number", "description"],
},
branches: {
table: branches,
searchColumns: ["name","number","description"],
numberRangeHolder: "number",
},
teams: {
table: teams,
searchColumns: ["name", "description"],
mtoLoad: ["branch"],
},
tasks: {
table: tasks,
},
@@ -186,7 +202,7 @@ export const resourceConfig = {
table: createddocuments,
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
mtmLoad: ["statementallocations","files","createddocuments"],
mtmListLoad: ["statementallocations"],
mtmListLoad: ["statementallocations", "files"],
},
texttemplates: {
table: texttemplates

9
docker-compose.docs.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
docs:
build:
context: .
dockerfile: docs-site/Dockerfile
container_name: fedeo-docs
restart: unless-stopped
ports:
- "3205:3000"

View File

@@ -17,10 +17,35 @@ services:
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
- "traefik.http.routers.fedeo-frontend.priority=1"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
docs:
image: git.federspiel.tech/flfeders/fedeo/docs:dev
restart: always
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs"
# Web Entrypoint
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
- "traefik.http.routers.fedeo-docs.entrypoints=web"
- "traefik.http.routers.fedeo-docs.priority=120"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip"
- "traefik.http.routers.fedeo-docs-secure.priority=120"
backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev
restart: always
@@ -90,4 +115,4 @@ services:
- traefik
networks:
traefik:
external: false
external: false

3
docs-site/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.nuxt
.output

3
docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.nuxt
.output

19
docs-site/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS builder
WORKDIR /app/docs-site
COPY docs-site/package.json docs-site/package-lock.json ./
RUN npm ci
COPY docs-site ./
COPY docs /app/docs
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app/docs-site
ENV NODE_ENV=production
COPY --from=builder /app/docs-site/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

35
docs-site/README.md Normal file
View File

@@ -0,0 +1,35 @@
# FEDEO Docs Site (Nuxt UI + Nuxt Content)
Diese Docs-App nutzt den Standardstil des offiziellen Nuxt-UI-Docs-Templates und rendert Inhalte aus `docs/`.
## Lokale Entwicklung
Im Ordner `docs-site` ausführen:
```bash
npm install
npm run dev
```
Danach ist die App unter `http://localhost:3005` erreichbar.
## Build
```bash
npm run build
npm run preview
```
## Production-Deploy
Das Docker-Image startet einen Nuxt Node-Server auf Port `3000`.
In der Haupt-`docker-compose.yml` ist der Service hinter Traefik unter `/docs` veröffentlicht.
## Content-Synchronisierung
Vor `dev` und `build` wird automatisch synchronisiert:
- Quelle: `../docs/**/*.md`
- Ziel: `docs-site/content`
Dabei wird `docs/README.md` zu `content/index.md` gemappt.

View File

@@ -0,0 +1,56 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'slate'
},
footer: {
slots: {
root: 'border-t border-default',
left: 'text-sm text-muted'
}
}
},
seo: {
siteName: 'FEDEO Bedienung'
},
header: {
title: 'FEDEO Bedienung',
to: '/bedienung',
search: true,
colorMode: true,
links: [
{
icon: 'i-simple-icons-github',
to: 'https://git.federspiel.tech/flfeders/FEDEO',
target: '_blank',
'aria-label': 'Repository'
}
]
},
footer: {
credits: `Built with Nuxt UI • © ${new Date().getFullYear()} FEDEO`,
colorMode: false,
links: [
{
icon: 'i-simple-icons-github',
to: 'https://git.federspiel.tech/flfeders/FEDEO',
target: '_blank',
'aria-label': 'Repository'
}
]
},
toc: {
title: 'Inhaltsverzeichnis',
bottom: {
title: 'Links',
links: [
{
icon: 'i-lucide-book-open',
label: 'Bedienung',
to: '/bedienung'
}
]
}
}
})

44
docs-site/app/app.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
const { seo } = useAppConfig()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
useHead({
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
htmlAttrs: { lang: 'de' }
})
useSeoMeta({
titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName,
twitterCard: 'summary_large_image'
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<NuxtLoadingIndicator />
<AppHeader />
<UMain>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UMain>
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@@ -0,0 +1,25 @@
@import "tailwindcss";
@import "@nuxt/ui";
@source "../../../content/**/*";
@theme static {
--container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
}
:root {
--ui-container: var(--container-8xl);
}

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const { footer } = useAppConfig()
</script>
<template>
<UFooter>
<template #left>
{{ footer.credits }}
</template>
<template #right>
<UColorModeButton v-if="footer?.colorMode" />
<template v-if="footer?.links">
<UButton
v-for="(link, index) of footer?.links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
</UFooter>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const { header } = useAppConfig()
</script>
<template>
<UHeader
:ui="{ center: 'flex-1' }"
:to="header?.to || '/'"
>
<UContentSearchButton
v-if="header?.search"
:collapsed="false"
class="w-full"
/>
<template #left>
<NuxtLink :to="header?.to || '/'">
<AppLogo class="w-auto h-6 shrink-0" />
</NuxtLink>
</template>
<template #right>
<UContentSearchButton
v-if="header?.search"
class="lg:hidden"
/>
<UColorModeButton v-if="header?.colorMode" />
<template v-if="header?.links">
<UButton
v-for="(link, index) of header.links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
<template #body>
<UContentNavigation
highlight
:navigation="navigation"
/>
</template>
</UHeader>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<span class="font-semibold text-primary">FEDEO Docs</span>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const route = useRoute()
const repoBase = 'https://git.federspiel.tech/flfeders/FEDEO/src/branch/dev/docs'
const sourceUrl = computed(() => {
if (route.path === '/') {
return `${repoBase}/README.md`
}
return `${repoBase}${route.path}.md`
})
</script>
<template>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-file-text"
label="Quellseite"
:to="sourceUrl"
target="_blank"
/>
</template>

31
docs-site/app/error.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<AppHeader />
<UError :error="error" />
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
</script>
<template>
<UContainer>
<UPage>
<template #left>
<UPageAside>
<UContentNavigation
highlight
:navigation="navigation"
/>
</UPageAside>
</template>
<slot />
</UPage>
</UContainer>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
definePageMeta({
layout: 'docs'
})
const route = useRoute()
const { toc } = useAppConfig()
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Seite nicht gefunden', fatal: true })
}
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
return queryCollectionItemSurroundings('docs', route.path, {
fields: ['description']
})
})
const title = page.value.seo?.title || page.value.title
const description = page.value.seo?.description || page.value.description
useSeoMeta({
title,
ogTitle: title,
description,
ogDescription: description
})
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
const links = computed(() => [...(toc?.bottom?.links || [])].filter(Boolean))
</script>
<template>
<UPage v-if="page">
<UPageHeader
:title="page.title"
:description="page.description"
:headline="headline"
>
<template #links>
<UButton
v-for="(link, index) in page.links"
:key="index"
v-bind="link"
/>
<PageHeaderLinks />
</template>
</UPageHeader>
<UPageBody>
<ContentRenderer
v-if="page"
:value="page"
/>
<USeparator v-if="surround?.length" />
<UContentSurround :surround="surround" />
</UPageBody>
<template
v-if="page?.body?.toc?.links?.length"
#right
>
<UContentToc
:title="toc?.title"
:links="page.body?.toc?.links"
>
<template
v-if="toc?.bottom"
#bottom
>
<div
class="hidden lg:block space-y-6"
:class="{ 'mt-6!': page.body?.toc?.links?.length }"
>
<USeparator
v-if="page.body?.toc?.links?.length"
type="dashed"
/>
<UPageLinks
:title="toc.bottom.title"
:links="links"
/>
</div>
</template>
</UContentToc>
</template>
</UPage>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
await navigateTo('/bedienung', { redirectCode: 302 })
</script>
<template>
<div />
</template>

View File

@@ -0,0 +1,18 @@
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
docs: defineCollection({
type: 'page',
source: '**',
schema: z.object({
links: z.array(z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional()
})).optional()
})
})
}
})

View File

@@ -0,0 +1,42 @@
# Ausgangsbelege erstellen und bearbeiten
Diese Anleitung hilft dir beim Erstellen von Rechnungen, Angeboten, Lieferscheinen und ähnlichen Belegen.
## Typischer Ablauf
1. Neuen Beleg anlegen.
2. Belegart wählen (z. B. Rechnung oder Angebot).
3. Kunden- und Adressdaten prüfen.
4. Positionen eintragen.
5. Daten wie Belegdatum und Zahlungsziel prüfen.
6. Beleg speichern und bei Bedarf buchen.
## Wichtige Eingaben einfach erklärt
- `Belegart`: Legt fest, welche Art von Dokument erstellt wird.
- `Kunde`: Empfänger des Belegs.
- `Ansprechpartner`: Person beim Kunden für Rückfragen.
- `Adresse`: Zieladresse auf dem Dokument.
- `Belegnummer`: Eindeutige Nummer zur Wiedererkennung.
- `Belegdatum`: Offizielles Ausstellungsdatum.
- `Liefer-/Leistungsdatum`: Zeitraum oder Datum der Leistung.
- `Zahlungsziel`: Frist für den Zahlungseingang.
- `Zahlungsart`: Überweisung oder Lastschrift.
- `Positionen`: Leistungen oder Artikel mit Menge, Preis und Steuersatz.
## Empfehlungen für fehlerfreie Belege
- Vor dem Buchen immer Kunde, Datum und Belegnummer prüfen.
- Bei Rechnungen Zahlungsziel und Zahlungsart kontrollieren.
- Bei Zeiträumen Start- und Enddatum vollständig setzen.
- Vorschau prüfen, bevor der Beleg verschickt wird.
## Häufige Fragen
### Warum kann ich nicht buchen?
Meist fehlt eine Pflichtangabe wie Kunde, Briefpapier, Datum oder eine gültige Position.
### Wann ist ein Beleg im Kundenportal sichtbar?
Nur wenn die Freigabe aktiv ist und der Beleg nicht mehr im Entwurfsstatus steht.

View File

@@ -0,0 +1,41 @@
# Bankportal nutzen
Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordnung zu Belegen.
## Ziele im Bankportal
- Kontobewegungen aktuell halten
- Offene Zahlungsein- und -ausgänge schneller zuordnen
- Buchhaltungsprozesse vorbereiten
## Typischer Arbeitsablauf
1. Kontoverbindung prüfen oder aktualisieren.
2. Neue Umsätze abrufen.
3. Offene Bewegungen sichten.
4. Vorschläge zur Zuordnung prüfen.
5. Passende Belege oder Konten zuweisen.
6. Ergebnis kontrollieren.
## Wichtige Bereiche
- `Umsatzliste`: Zeigt alle importierten Bankbewegungen.
- `Filter/Suche`: Hilft beim schnellen Finden einzelner Vorgänge.
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
- Unklare Buchungen zeitnah klären.
- Bei wiederkehrenden Zahlungen auf konsistente Bezeichnung achten.
## Häufige Probleme
### Ein Umsatz wird nicht automatisch zugeordnet
Prüfe Betrag, Datum, Verwendungszweck und ob ein passender Beleg im System vorhanden ist.
### Es erscheinen doppelte oder fehlende Umsätze
Kontoverbindung aktualisieren und den Zeitraum der Synchronisation prüfen.

View File

@@ -0,0 +1,13 @@
# Bedienung
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
## Bereiche
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
- [Bankportal nutzen](./bankportal.md)
## Für wen ist diese Anleitung?
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.

View File

@@ -0,0 +1,31 @@
# Serienrechnungen planen und ausführen
Mit Serienrechnungen kannst du wiederkehrende Abrechnungen automatisieren.
## Wofür nutzt man Serienrechnungen?
- Regelmäßige Leistungen (z. B. monatliche Betreuung)
- Verträge mit festen Abständen
- Einheitliche Abrechnung ohne manuelles Neuerstellen jedes Belegs
## So legst du eine Serienrechnung an
1. Neue Serienrechnung öffnen.
2. Kunde, Leistungen und Preise eintragen.
3. Intervall festlegen (z. B. monatlich, quartalsweise).
4. Start- und Endzeitraum definieren.
5. Vorlage aktivieren und speichern.
## Manuelle Ausführung eines Laufs
1. Serienrechnungsübersicht öffnen.
2. Ausführungsdatum setzen.
3. Gewünschte Vorlagen auswählen.
4. Lauf starten.
5. Ergebnis prüfen und ggf. abschließen.
## Wichtige Hinweise
- Das Ausführungsdatum wirkt sich auf den Leistungszeitraum aus.
- Vor dem Lauf prüfen, ob alle Vorlagen aktiv und vollständig sind.
- Bei ungewöhnlichen Ergebnissen zuerst Beträge und Intervalle der Vorlage prüfen.

View File

@@ -0,0 +1,7 @@
# Bedienungsanleitung
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg
- [Bedienung](./bedienung/README.md)

27
docs-site/nuxt.config.ts Normal file
View File

@@ -0,0 +1,27 @@
export default defineNuxtConfig({
modules: [
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content'
],
css: ['~/assets/css/main.css'],
content: {
build: {
markdown: {
toc: {
searchDepth: 1
}
}
}
},
experimental: {
asyncContext: true
},
compatibilityDate: '2024-07-11',
nitro: {
preset: 'node-server'
},
icon: {
provider: 'iconify'
}
})

16365
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
docs-site/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "fedeo-docs-site",
"private": true,
"type": "module",
"scripts": {
"build": "node ./scripts/sync-content.mjs && nuxt build",
"dev": "node ./scripts/sync-content.mjs && nuxt dev --host 0.0.0.0 --port 3005",
"preview": "nuxt preview --host 0.0.0.0 --port 3005",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.102",
"@iconify-json/simple-icons": "^1.2.78",
"@nuxt/content": "^3.12.0",
"@nuxt/image": "^2.0.0",
"@nuxt/ui": "^4.6.1",
"better-sqlite3": "^12.9.0",
"nuxt": "^4.4.2"
},
"devDependencies": {
"typescript": "^6.0.2"
},
"engines": {
"node": ">=20.0"
}
}

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs'
import path from 'node:path'
const ROOT = process.cwd()
const DOCS_SOURCE = path.resolve(ROOT, '../docs')
const CONTENT_TARGET = path.resolve(ROOT, 'content')
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function clearDir(dirPath) {
await fs.rm(dirPath, { recursive: true, force: true })
await ensureDir(dirPath)
}
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const out = []
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
out.push(...(await walk(full)))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
out.push(full)
}
}
return out
}
function toPosix(p) {
return p.split(path.sep).join('/')
}
async function main() {
await clearDir(CONTENT_TARGET)
const files = await walk(DOCS_SOURCE)
for (const file of files) {
const rel = toPosix(path.relative(DOCS_SOURCE, file))
let targetRel = rel
if (rel === 'README.md') {
targetRel = 'index.md'
} else if (rel.endsWith('/README.md')) {
targetRel = `${rel.slice(0, -'/README.md'.length)}/index.md`
}
const target = path.join(CONTENT_TARGET, targetRel)
await ensureDir(path.dirname(target))
await fs.copyFile(file, target)
}
console.log(`Nuxt-Content Synchronisierung abgeschlossen: ${files.length} Dateien`)
}
main().catch((err) => {
console.error('Fehler bei der Content-Synchronisierung', err)
process.exit(1)
})

3
docs-site/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

7
docs/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Bedienungsanleitung
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg
- [Bedienung](./bedienung/README.md)

13
docs/bedienung/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Bedienung
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
## Bereiche
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
- [Bankportal nutzen](./bankportal.md)
## Für wen ist diese Anleitung?
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.

Some files were not shown because too many files have changed in this diff Show More