Compare commits
54 Commits
4e05906556
...
v3.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 65588686c7 | |||
| 8f06d06037 | |||
| fcf9cc3fc9 | |||
| eaa5627062 | |||
| 9fe5a9b1da | |||
| a65bd72d74 | |||
| b9cfdbe29e | |||
| dde46f10e1 | |||
| 20091143d2 | |||
| 8e1505023b | |||
| 60553bbba8 | |||
| 96295ee4f0 | |||
| f708d0a2b2 | |||
| 7164989da9 | |||
| cbec5b058d | |||
| 847a616285 | |||
| fdf1d42a3d | |||
| 7e9f4409f6 | |||
| 0ff74cdcdf | |||
| f51f2b13a1 | |||
| 2e63af1aa2 | |||
| 74c0cf40b3 | |||
| 5ebd2d508c | |||
| f6996c1d7b | |||
| 9128fbb165 | |||
| bb94e73229 | |||
| 8473221c13 | |||
| 68c0a224e5 | |||
| 1329f164d8 | |||
| 92859f3ccf | |||
| dd37622bb6 | |||
| cfef07b96d | |||
| 342ad9764e | |||
| 58fc252110 | |||
| 0c430e993e | |||
| a50ff256ee | |||
| ebbd1e0cd0 | |||
| 2ec162c0e7 | |||
| d0bc8a5e0f | |||
| a6f55b61e4 | |||
| c383a291e1 | |||
| 0eadf42973 | |||
| 616bb5d7f3 | |||
| 6297480b89 | |||
| 48199b0ed7 | |||
| bb18a974dd | |||
| e7554fa2cc | |||
| 7c1fabf58a | |||
| 1203b6cbd1 | |||
| 525f2906fb | |||
| b105382abf | |||
| b1cdec7d17 | |||
| f1d512b2e5 | |||
| db21b43120 |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
**/node_modules
|
||||||
|
**/.nuxt
|
||||||
|
**/.output
|
||||||
|
**/dist
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
.release
|
||||||
|
gmail-puett
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
# FEDEO Selfhosting
|
# FEDEO Selfhosting
|
||||||
|
# Feste Release-Version, z. B. 3.0.0. dev behält den bisherigen Entwicklungskanal bei.
|
||||||
|
FEDEO_VERSION=dev
|
||||||
DOMAIN=app.example.com
|
DOMAIN=app.example.com
|
||||||
CONTACT_EMAIL=admin@deine-domain.de
|
CONTACT_EMAIL=admin@deine-domain.de
|
||||||
|
|
||||||
|
|||||||
@@ -2,37 +2,18 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||||
title: '[BUG] '
|
title: '[BUG] '
|
||||||
labels: bug
|
labels: Problem
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Beschreibung**
|
**Beschreibung**
|
||||||
Eine klare und prägnante Beschreibung des Fehlers.
|
|
||||||
|
|
||||||
**Reproduktion**
|
**Reproduktion**
|
||||||
Schritte, um den Fehler zu reproduzieren:
|
|
||||||
|
|
||||||
Entweder:
|
|
||||||
1. Gehe zu '...'
|
|
||||||
2. Klicke auf '...'
|
|
||||||
3. Scrolle runter zu '...'
|
|
||||||
4. Siehe Fehler
|
|
||||||
|
|
||||||
Oder Link zur Seite
|
|
||||||
|
|
||||||
**Erwartetes Verhalten**
|
|
||||||
Eine klare Beschreibung dessen, was du erwartet hast.
|
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots**
|
||||||
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
|
|
||||||
|
|
||||||
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
||||||
|
|
||||||
**Umgebung:**
|
|
||||||
- Betriebssystem: [z.B. Windows, macOS, Linux]
|
|
||||||
- Browser / Version (falls relevant): [z.B. Chrome 120]
|
|
||||||
- Projekt-Version: [z.B. v1.0.2]
|
|
||||||
|
|
||||||
**Zusätzlicher Kontext**
|
|
||||||
Füge hier alle anderen Informationen zum Problem hinzu.
|
|
||||||
@@ -2,19 +2,16 @@
|
|||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
about: Schlage eine Idee für dieses Projekt vor.
|
about: Schlage eine Idee für dieses Projekt vor.
|
||||||
title: '[FEATURE] '
|
title: '[FEATURE] '
|
||||||
labels: enhancement
|
labels: Funktionswunsch
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
|
||||||
|
|
||||||
**Lösungsvorschlag**
|
**Lösungsvorschlag**
|
||||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
|
||||||
|
|
||||||
**Alternativen**
|
**Alternativen**
|
||||||
Hast du über alternative Lösungen oder Workarounds nachgedacht?
|
|
||||||
|
|
||||||
**Zusätzlicher Kontext**
|
|
||||||
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.
|
|
||||||
@@ -1,233 +1,57 @@
|
|||||||
name: Build and Push Docker Images
|
name: Build and Release FEDEO
|
||||||
run-name: Build Backend, Frontend, Website & Docs by @${{ github.actor }}
|
|
||||||
|
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
env:
|
branches: [dev, main]
|
||||||
# Passe dies an deine Registry an.
|
workflow_dispatch:
|
||||||
# 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).
|
|
||||||
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
|
|
||||||
IMAGE_NAME: flfeders/fedeo
|
|
||||||
ACTOR: flfeders
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# verify-docs-sync:
|
verify:
|
||||||
# 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_HOST }}
|
ref: ${{ github.sha }}
|
||||||
# Gitea stellt secrets.GITHUB_TOKEN automatisch bereit (wie GitLab CI_JOB_TOKEN)
|
fetch-depth: 0
|
||||||
username: ${{ env.ACTOR }}
|
- uses: actions/setup-node@v4
|
||||||
password: ${{ vars.CI_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Backend
|
|
||||||
id: meta-backend
|
|
||||||
uses: docker/metadata-action@v4
|
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/backend
|
node-version: '22'
|
||||||
tags: |
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
type=ref,event=branch
|
- uses: actions/setup-python@v5
|
||||||
type=ref,event=tag
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Backend
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
with:
|
||||||
context: ./backend # Hier wird der Ordner gewechselt (wie 'cd backend')
|
python-version: '3.12'
|
||||||
push: true
|
- name: Test releases and selfhost updates
|
||||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
run: npm test
|
||||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
- name: Check Conventional Commits
|
||||||
|
run: npm run commits:check
|
||||||
|
|
||||||
build-frontend:
|
build-and-release:
|
||||||
#needs: verify-docs-sync
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_HOST }}
|
ref: ${{ github.sha }}
|
||||||
username: ${{ env.ACTOR }}
|
fetch-depth: 0
|
||||||
password: ${{ vars.CI_TOKEN }}
|
# dev uses the normal read-only checkout; only main needs a release writer.
|
||||||
|
token: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || github.token }}
|
||||||
- name: Extract metadata (tags, labels) for Frontend
|
- uses: actions/setup-node@v4
|
||||||
id: meta-frontend
|
|
||||||
uses: docker/metadata-action@v4
|
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/frontend
|
node-version: '22'
|
||||||
tags: |
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
type=ref,event=branch
|
- uses: docker/setup-buildx-action@v3
|
||||||
type=ref,event=tag
|
- uses: docker/login-action@v3
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Frontend
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
with:
|
||||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
registry: git.federspiel.tech
|
||||||
push: true
|
username: flfeders
|
||||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
# Same credential source as the existing image-build workflows.
|
||||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
password: ${{ vars.CI_TOKEN || secrets.CI_TOKEN }}
|
||||||
|
- name: Build all images and publish main releases
|
||||||
build-docs:
|
# main uses a Git ref lock. dev only reads Git and writes to the registry.
|
||||||
needs: verify-docs-sync
|
env:
|
||||||
runs-on: ubuntu-latest
|
CI: 'true'
|
||||||
steps:
|
FEDEO_REF: ${{ github.ref }}
|
||||||
- name: Check out repository code
|
FEDEO_SHA: ${{ github.sha }}
|
||||||
uses: actions/checkout@v3
|
FEDEO_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
GITEA_TOKEN: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || '' }}
|
||||||
- name: Log in to Docker Registry
|
run: npm run release:ci
|
||||||
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 }}
|
|
||||||
|
|
||||||
build-website:
|
|
||||||
#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 Website
|
|
||||||
id: meta-website
|
|
||||||
uses: docker/metadata-action@v4
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/website
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Website
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: ./website
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta-website.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-website.outputs.labels }}
|
|
||||||
|
|
||||||
build-central-services-api:
|
|
||||||
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 for Central Services API
|
|
||||||
id: meta-central-api
|
|
||||||
uses: docker/metadata-action@v4
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/central-services-api
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Central Services API
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: ./push-server
|
|
||||||
file: ./push-server/apps/api/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta-central-api.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-central-api.outputs.labels }}
|
|
||||||
|
|
||||||
build-central-services-admin:
|
|
||||||
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 for Central Services Admin
|
|
||||||
id: meta-central-admin
|
|
||||||
uses: docker/metadata-action@v4
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/central-services-admin
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Build and push Central Services Admin
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: ./push-server
|
|
||||||
file: ./push-server/apps/admin/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta-central-admin.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-central-admin.outputs.labels }}
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
.output/
|
.output/
|
||||||
|
/.release/
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
# Lokale Runtime-Daten und generierte Konfigurationen
|
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||||
matrix/postgres/
|
matrix/postgres/
|
||||||
|
|||||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Projektanweisungen für FEDEO
|
||||||
|
|
||||||
|
## Git und Commit-Nachrichten
|
||||||
|
|
||||||
|
- Neue Projektdateien immer gezielt mit `git add` aufnehmen.
|
||||||
|
- Nach einem abgeschlossenen Arbeitsschritt die eigenen Änderungen committen. Bereits vorhandene Änderungen anderer Aufgaben nicht mitcommitten oder zurücksetzen.
|
||||||
|
- Jede selbst verfasste Commit-Nachricht muss Conventional Commits verwenden: `type(scope): Beschreibung`. Der Scope ist optional.
|
||||||
|
- `fix:` für Fehlerkorrekturen (Patch), `feat:` für neue Funktionen (Minor), `perf:` für Leistungsverbesserungen (Patch).
|
||||||
|
- `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, `style:` und `revert:` passend verwenden. Reine Wartungsänderungen lösen standardmäßig kein Release aus; Reverts können einen Patch auslösen.
|
||||||
|
- Inkompatible Änderungen mit `!` im Betreff und einem Footer `BREAKING CHANGE: Beschreibung und Migrationshinweis` markieren (Major). Diese Kennzeichnung niemals weglassen, um einen Versionssprung zu vermeiden.
|
||||||
|
- Beispiele: `fix(accounting): validate invoice totals`, `feat(stock): add inventory transfers`, `feat(api)!: replace legacy authentication`.
|
||||||
|
- Auch selbst verfasste Squash- und Merge-Betreffzeilen entsprechend benennen. Die Release-Auswertung liest die ursprünglichen Commits und überspringt echte Merge-Commits.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
- `dev` ist der Entwicklungszweig. Ein Push auf `main` ist der vom Benutzer gewählte Release-Auslöser; der Gitea-Workflow prüft, baut und veröffentlicht automatisch.
|
||||||
|
- Änderungen regulär auf dem aktuellen Entwicklungszweig committen. Erst bei einem ausdrücklichen Release-/Merge-/Push-Auftrag nach `main` übernehmen und pushen.
|
||||||
|
- Versionsnummern werden aus den Git-Tags `vMAJOR.MINOR.PATCH` und den Commit-Nachrichten berechnet. Keine manuellen Release-Commits oder Änderungen an Paketversionen für Backend/Frontend erzeugen.
|
||||||
|
- Vor Release-Arbeiten `docs/releases.md` lesen. Vorschau: `npm ci && npm run release:preview`. Prüfungen: `npm test && npm run commits:check`.
|
||||||
|
- Veröffentlichte Versions-Tags und Images niemals überschreiben oder löschen. Einen fehlgeschlagenen Release anhand der dokumentierten Wiederaufnahme fortsetzen.
|
||||||
|
- Mobile-App und Device-Agent behalten eigene Paketversionen; ihr Stand wird im Release-Manifest dokumentiert. App-Store-Veröffentlichungen haben einen eigenen Auslöser.
|
||||||
11
CHANGELOG.md
Normal file
11
CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# FEDEO Versionshistorie
|
||||||
|
|
||||||
|
Die Release-Pipeline erzeugt die Versionshistorie automatisch aus Conventional Commits.
|
||||||
|
|
||||||
|
- [Alle veröffentlichten Releases mit Änderungslisten](https://git.federspiel.tech/flfeders/FEDEO/releases)
|
||||||
|
- Jeder Release enthält seine Änderungen zusätzlich als herunterladbare `CHANGELOG.md`.
|
||||||
|
- `release-manifest.json` ordnet Version, Commit, Docker-Digests, Komponenten und Migrationen eindeutig zu.
|
||||||
|
|
||||||
|
Die Versionshistorie vor Einführung der Releases bleibt in der Git-Historie erhalten. Die automatische Reihe beginnt mit `v3.0.0`; Ausgangspunkt ist der in `release.config.json` dokumentierte Commit.
|
||||||
|
|
||||||
|
Die Pipeline schreibt keine automatischen Changelog-Commits zurück nach `main`. Dadurch bleiben Merge-Historie und Release-Auslöser eindeutig.
|
||||||
48
README.md
48
README.md
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
|
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
|
||||||
|
|
||||||
|
## Versionen und Releases
|
||||||
|
|
||||||
|
Ein Push auf `main` veröffentlicht nach erfolgreichen Prüfungen und Builds automatisch die nächste FEDEO-Version. Die Versionsnummer und Release Notes entstehen aus Conventional Commits (`fix:`, `feat:`, Breaking Changes). `dev` bleibt der Entwicklungskanal.
|
||||||
|
|
||||||
|
Für feste Selfhost-Versionen `FEDEO_VERSION` in `.env` auf eine veröffentlichte Version wie `3.0.0` setzen und die Betriebsdateien aus demselben Release verwenden. Ohne gesetzte Version bleibt der bisherige `dev`-Kanal aktiv. Die folgenden `raw/branch/dev`-Beispiele beschreiben diesen Entwicklungskanal.
|
||||||
|
|
||||||
|
Release-Freigabe, Einrichtung der CI-Tokens, versionierte Installation, Updates und Wiederaufnahme sind unter [Versionierung und Releases](docs/releases.md) beschrieben. Die laufende Backend-Version liefert `/backend/version`; im Frontend steht sie unten in der Seitenleiste.
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
Der Stack besteht aus:
|
Der Stack besteht aus:
|
||||||
@@ -479,49 +487,27 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten.
|
Vollständige Release-Updates einschließlich Compose-Datei werden mit dem Host-Skript vorbereitet und ausgeführt:
|
||||||
|
|
||||||
Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/fedeo
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
|
||||||
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
|
`3.1.0` durch den gewünschten veröffentlichten Release ersetzen. Das Skript benötigt Python ab 3.10 und Docker Compose ab 2.24. Es sichert die lokale Datenbank, MinIO, Konfiguration und Zertifikate vor dem Austausch, erhält `.env` und prüft die gestarteten Versionen. Während Backup und Aktualisierung gibt es eine Wartungsunterbrechung.
|
||||||
|
|
||||||
```bash
|
Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen.
|
||||||
cd /opt/fedeo
|
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
|
||||||
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
|
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen:
|
Unter **Administration → Systemstatus → FEDEO-Updates** können Systemadministratoren neue Releases prüfen und nach Bestätigung installieren. Dazu führt `fedeo-updater.service` die Aufträge unabhängig von Browser und Backend-Neustarts aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die aktuelle Selfhost-Compose-Datei bindet dessen lokales Socket-Verzeichnis im Backend ein. Bestehende Installationen benötigen einmalig die aktuellen Hostskripte und ein CLI-Update auf einen Release mit der Oberfläche. Einrichtung und Schritte stehen in der folgenden Anleitung.
|
||||||
|
|
||||||
```bash
|
Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md).
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
|
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Anschliessend den Zustand prüfen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
|
|
||||||
curl -I https://app.example.com
|
|
||||||
curl https://app.example.com/backend/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
|
||||||
|
|
||||||
## Backup-Empfehlung
|
## Backup-Empfehlung
|
||||||
|
|
||||||
Regelmassig sichern:
|
Regelmassig sichern:
|
||||||
|
|
||||||
- `./postgres`
|
- PostgreSQL mit einem konsistenten Datenbankbackup, beispielsweise `pg_dump`
|
||||||
- `./minio` falls MinIO lokal genutzt wird
|
- `./minio` falls MinIO lokal genutzt wird
|
||||||
- `./traefik/letsencrypt/acme.json`
|
- `./traefik/letsencrypt/acme.json`
|
||||||
- deine `.env`
|
- deine `.env`
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ dist
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
__pycache__
|
||||||
|
tests
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ FEDEO_SCAN_FORMAT=pdf
|
|||||||
FEDEO_SCAN_RESOLUTION=300
|
FEDEO_SCAN_RESOLUTION=300
|
||||||
FEDEO_SCAN_MODE=Color
|
FEDEO_SCAN_MODE=Color
|
||||||
FEDEO_SCAN_SOURCE=
|
FEDEO_SCAN_SOURCE=
|
||||||
|
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||||
FEDEO_SCAN_POSTPROCESS=false
|
FEDEO_SCAN_POSTPROCESS=false
|
||||||
|
FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||||
|
FEDEO_SCAN_OCR=true
|
||||||
|
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||||
FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||||
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
||||||
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||||
|
|||||||
1
agents/fedeo-device-agent/.gitignore
vendored
1
agents/fedeo-device-agent/.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ FROM node:20-bookworm-slim AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json tsconfig.json ./
|
COPY package.json package-lock.json tsconfig.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -12,8 +12,11 @@ FROM node:20-bookworm-slim AS runtime
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV FEDEO_WORK_DIR=/work
|
ENV FEDEO_WORK_DIR=/work
|
||||||
ENV FEDEO_SCAN_POSTPROCESS=true
|
ENV FEDEO_SCAN_POSTPROCESS=false
|
||||||
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=receipt
|
ENV FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||||
|
ENV FEDEO_SCAN_OCR=true
|
||||||
|
ENV FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||||
|
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||||
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
|
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
|
||||||
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
|
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ RUN apt-get update \
|
|||||||
python3-pip \
|
python3-pip \
|
||||||
python3-venv \
|
python3-venv \
|
||||||
sane-utils \
|
sane-utils \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-deu \
|
||||||
|
tesseract-ocr-eng \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements-opencv.txt ./
|
COPY requirements-opencv.txt ./
|
||||||
|
|||||||
@@ -4,15 +4,52 @@ Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er h
|
|||||||
|
|
||||||
Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst.
|
Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst.
|
||||||
|
|
||||||
|
## Geführte Linux-Installation in FEDEO
|
||||||
|
|
||||||
|
Unter **Administration → Scanner → Agent einrichten** führt ein Assistent durch die Einrichtung:
|
||||||
|
|
||||||
|
1. Rechner benennen und den Agenten anlegen.
|
||||||
|
2. Die vom Scanner-Rechner erreichbare FEDEO-Serveradresse prüfen. Ein Backend-Pfad wie `/backend` muss erhalten bleiben. Optional den Autostart bei der Linux-Anmeldung aktivieren.
|
||||||
|
3. **Linux-Installation herunterladen**. Die Datei enthält die Software und die persönliche Konfiguration; ein Projekt-Checkout oder `npm install` ist nicht nötig.
|
||||||
|
4. Auf dem Scanner-Rechner im Download-Ordner den angezeigten Befehl `bash fedeo-agent-<ID>.sh` ausführen, ohne vorangestelltes `sudo`. Fehlende Pakete werden auf Mint/Ubuntu/Debian über `sudo apt-get` installiert. Node.js muss mindestens Version 20 haben; bei einer älteren Distribution gegebenenfalls zuerst Node.js aktualisieren.
|
||||||
|
5. Der Assistent wartet auf eine aktuelle Meldung des Agenten. Danach den erkannten Scanner und beispielsweise das Profil **ScanSnap iX1600: A4, Duplex, PDF, 300 dpi** wählen und abschließen.
|
||||||
|
6. Optional **Testscan öffnen**. Der eingerichtete Agent ist vorausgewählt. Erst ein Klick auf **Scannen** startet den Einzug; das Ergebnis landet im Hauptordner des aktuellen Mandanten.
|
||||||
|
|
||||||
|
Die Installationsdatei enthält einen Zugangstoken. Nach erfolgreicher Einrichtung löschen oder geschützt aufbewahren. Unter **Einrichtung öffnen** lässt sich ein bestehender Agent erneut installieren. Dafür den gespeicherten Token einfügen oder ausdrücklich einen neuen erzeugen; der bisherige Zugang wird dabei sofort ungültig.
|
||||||
|
|
||||||
|
Die Installation ist je Agent getrennt:
|
||||||
|
|
||||||
|
- Software: `~/.local/share/fedeo-device-agent/<ID>/`
|
||||||
|
- Konfiguration mit Dateirechten `0600`: `~/.config/fedeo-device-agent/<ID>.env`
|
||||||
|
- Arbeitsdateien: `~/.local/state/fedeo-device-agent/<ID>/`
|
||||||
|
- Benutzerdienst: `fedeo-device-agent-<ID>.service`
|
||||||
|
|
||||||
|
Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten.
|
||||||
|
|
||||||
|
### Scannerverwaltung meldet HTTP 500 nach einem Serverupdate
|
||||||
|
|
||||||
|
Die älteren Scanner-SQL-Dateien `0051` bis `0053` waren nicht im Drizzle-Migrationsjournal eingetragen und wurden dadurch bei regulären Updates übersprungen. Die Vorwärtsmigration `0072_restore_instance_scan_agents` legt fehlende Tabellen und Spalten nachträglich an. Vorhandene Agenten, Tokens, Einstellungen und Scanaufträge bleiben erhalten.
|
||||||
|
|
||||||
|
Zuerst einen Backend-Stand mit dieser Migration ausrollen. Bei aktiviertem `FEDEO_RUN_MIGRATIONS` wird sie beim Start automatisch ausgeführt. Bei bewusst deaktivierten Startmigrationen muss anschließend im aktualisierten Backend `npm run migrate` ausgeführt werden. Die alten Journal-Zeitstempel nachträglich einzufügen genügt bei bereits aktualisierten Datenbanken nicht.
|
||||||
|
|
||||||
|
Der Datenbanktest läuft isoliert gegen ein temporäres PostgreSQL-Image und verwendet keine Anwendungskonfiguration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAgentMigration.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install node sane-backends
|
brew install node sane-backends tesseract tesseract-lang
|
||||||
scanimage -L
|
scanimage -L
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Für die Dateierstellung und Hintergrundbereinigung wird Python mit Pillow und OpenCV benötigt, beispielsweise über `npm run setup:opencv` (siehe unten). `tesseract --list-langs` muss für die Standardkonfiguration `deu` und `eng` enthalten.
|
||||||
|
|
||||||
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -23,11 +60,55 @@ lpstat -p
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y nodejs npm sane-utils cups
|
sudo apt install -y nodejs npm sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
|
||||||
scanimage -L
|
scanimage -L
|
||||||
lpstat -p
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Node.js muss mindestens Version 20 haben (`node --version`). CUPS ist nur für die optionale Druckererkennung erforderlich.
|
||||||
|
|
||||||
|
## ScanSnap iX1600 unter Linux per USB
|
||||||
|
|
||||||
|
Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist `04c5:1632`; die WLAN-Schnittstelle wird von diesem Backend nicht unterstützt. Quellen: [SANE-Geräteliste](https://sane-project.gitlab.io/website/sane-mfgs.html), [Fujitsu-Backend](https://sane-project.gitlab.io/website/man/sane-fujitsu.5.html).
|
||||||
|
|
||||||
|
1. Scanner einschalten und per USB anschließen. `lsusb -d 04c5:1632` und `scanimage -L` müssen ihn anzeigen. Den vollständigen Gerätenamen übernehmen, beispielsweise `fujitsu:ScanSnap iX1600:123456`.
|
||||||
|
2. Den oben beschriebenen Einrichtungsassistenten verwenden. Für eine manuelle Installation kann alternativ die API `POST /api/instance-agents` als Administrator einen Agenten und dessen einmalig ausgegebenen Token erstellen.
|
||||||
|
3. Im Verzeichnis `agents/fedeo-device-agent` die Abhängigkeiten installieren und konfigurieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
FEDEO_URL=https://deine-fedeo-instanz
|
||||||
|
FEDEO_AGENT_TOKEN=fedeo_agent_DEIN_TOKEN
|
||||||
|
FEDEO_SCANNER_NAME="fujitsu:ScanSnap iX1600:DEINE_SERIENNUMMER"
|
||||||
|
FEDEO_SCAN_SOURCE="ADF Duplex"
|
||||||
|
FEDEO_SCAN_FORMAT=pdf
|
||||||
|
FEDEO_SCAN_RESOLUTION=300
|
||||||
|
FEDEO_SCAN_MODE=Color
|
||||||
|
FEDEO_SCAN_POSTPROCESS=false
|
||||||
|
FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||||
|
FEDEO_SCAN_OCR=true
|
||||||
|
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||||
|
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||||
|
```
|
||||||
|
|
||||||
|
`FEDEO_URL` ist die Basis-URL, unter der `/instance-agent/heartbeat` erreichbar ist, ohne zusätzliches `/api`.
|
||||||
|
4. `npm run build` und `npm start` ausführen. In FEDEO muss der Agent als online erscheinen.
|
||||||
|
5. In der Scannerverwaltung das erkannte Gerät wählen, **ScanSnap iX1600: A4, Duplex, PDF** anklicken und speichern.
|
||||||
|
6. Papier einlegen. Unter **Dateien → Scan** den Agenten wählen und **Scannen** anklicken. Der gesamte Stapel landet als ein PDF im aktuellen Ordner. Für den ersten Test zwei Blätter mit markierten Vorder- und Rückseiten verwenden: Das PDF muss vier Seiten in dieser Reihenfolge enthalten.
|
||||||
|
|
||||||
|
Der Agent liest im SANE-Batch-Modus bis zum leeren Einzug. Vorder- und Rückseiten werden in der vom Scanner gelieferten Reihenfolge gespeichert, einschließlich leerer Rückseiten. Es gibt keine automatische Leerseitenentfernung. „ADF Front“ scannt einen Stapel einseitig, „Flatbed“ eine einzelne Seite. Einzug-Scans benötigen PDF; PNG und TIFF sind für Einzelseiten verfügbar. Die Optionen entsprechen dem [scanimage-Handbuch](https://sane-project.gitlab.io/website/man/scanimage.1.html).
|
||||||
|
|
||||||
|
**Papierformat:** Das iX1600-Profil verwendet A4 mit 210 × 297 mm, einschließlich der Fujitsu-Einstellungen für die Papiergröße. Für andere Größen die Einstellung anpassen; „Scanner-Vorgabe“ übernimmt den am Treiber eingestellten Scanbereich. Die SANE-Quellen und Optionen lassen sich ohne Scan mit `scanimage -d 'DEIN_GERAETENAME' --source 'ADF Duplex' --help` prüfen.
|
||||||
|
|
||||||
|
**USB-Zugriff:** Wenn `lsusb` das Gerät sieht, `scanimage -L` jedoch nicht, zuerst Kabel, Stromversorgung, SANE-Version und Zugriffsrechte prüfen. Debian/Ubuntu/Mint liefern normalerweise passende udev-Regeln mit `libsane-common`. Andere Scanprogramme schließen. Einen dauerhaft laufenden Agenten unter einem Benutzer betreiben, der ebenfalls mit `scanimage -L` auf das Gerät zugreifen kann.
|
||||||
|
|
||||||
|
**Lange Stapel und Fehler:** Das Scan-Zeitlimit beträgt standardmäßig 15 Minuten; die Dateierstellung hat ein eigenes gleich langes Limit. Der Agent hält seinen Online-Status währenddessen aktuell. Der Dialog kann im Hintergrund weiterlaufen; Schließen stoppt den Scan nicht. Verbindungsfehler beim Abfragen des Status lösen keinen neuen Scan aus. Bei Papierstau, unvollständigem Duplex-Scan oder Uploadfehler wird kein automatischer erneuter Papiereinzug gestartet. Die Rohseiten bzw. die fertige Datei bleiben im gemeldeten Arbeitsverzeichnis zur Wiederherstellung erhalten. Nach erfolgreichem Upload werden sie gelöscht. Das Upload-Limit beträgt 100 MiB pro PDF; gegebenenfalls den Stapel teilen oder die Auflösung reduzieren.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -49,11 +130,36 @@ Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanne
|
|||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## OpenCV-Nachbearbeitung
|
Nach Änderungen am Agenten oder am Linux-Installationsskript das im Frontend ausgelieferte Paket neu erstellen und mitcommitten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:installer
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle.
|
||||||
|
|
||||||
|
## Scannerhintergrund und OCR
|
||||||
|
|
||||||
|
Hintergrundbereinigung und OCR sind standardmäßig aktiv und lassen sich in **Administration → Scanner** als Vorgabe oder im einzelnen Scan-Dialog einstellen:
|
||||||
|
|
||||||
|
- **Scannerhintergrund weiß machen:** Bei einem kleinen Bon oder Dokument werden sicher erkannte Flächen außerhalb des Papiers weiß. Seitengröße, Position und Inhalt des Papiers bleiben erhalten. Unsichere Papiergrenzen werden nicht verändert. Diese Verarbeitung ist unabhängig vom zusätzlichen Zuschnitt.
|
||||||
|
- **Durchsuchbares PDF (OCR):** Der Agent erkennt nach der Bildverarbeitung Text mit Tesseract und bettet ihn direkt in das PDF ein, bevor er es hochlädt. Das gilt auch für normale Dateien ohne Eingangsbeleg. Deutsch und Englisch sind vorausgewählt; PNG und TIFF erhalten keine OCR-Textschicht. Die Darstellung bleibt das gescannte Bild, ergänzt um auswählbaren und durchsuchbaren Text ([Tesseract-Dokumentation](https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html)).
|
||||||
|
|
||||||
|
```env
|
||||||
|
FEDEO_SCAN_BACKGROUND_CLEANUP=true
|
||||||
|
FEDEO_SCAN_OCR=true
|
||||||
|
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach einem Update von FEDEO bestehende Agenten über **Einrichtung öffnen** mit dem neuen Linux-Installer aktualisieren. Dieser installiert auch OpenCV, Tesseract und die Sprachpakete. Bei manueller Installation die oben genannten Pakete und die aktuelle Agent-Software installieren und den Dienst neu starten. Der Agent meldet die verfügbare Verarbeitung beim Start an FEDEO; fehlende Fähigkeiten werden vor dem Auftrag gemeldet. Zusätzlich prüft er die benötigten Pakete und die gewählte OCR-Sprache vor jedem Papiereinzug. Ein OCR-Fehler erzeugt keinen erfolgreichen Scan ohne Textschicht; die Rohseiten bleiben zur Wiederherstellung erhalten.
|
||||||
|
|
||||||
|
Die OCR läuft innerhalb des Scan-Auftrags nach dem Einzug. Bei langen Stapeln kann dieser Schritt länger dauern; das Zeitlimit für die Dateierstellung wird ebenfalls über `FEDEO_SCAN_TIMEOUT_SECONDS` gesetzt. Leere Rückseiten und Seitenreihenfolge bleiben erhalten.
|
||||||
|
|
||||||
|
## Zusätzliche OpenCV-Nachbearbeitung
|
||||||
|
|
||||||
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
|
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
|
||||||
|
|
||||||
@@ -70,7 +176,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
|
|||||||
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||||
```
|
```
|
||||||
|
|
||||||
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, lädt der Agent den Rohscan hoch, statt den Auftrag komplett fehlschlagen zu lassen.
|
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls die zusätzliche Korrektur fehlschlägt und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die betroffenen Rohseiten weiterverarbeitet; die Warnung erscheint im Scan-Ergebnis. Fehlendes OpenCV kann nur bei ausgeschalteter Hintergrundbereinigung toleriert werden. Beim iX1600-Profil ist der zusätzliche Zuschnitt ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt; Hintergrundbereinigung und OCR sind eingeschaltet.
|
||||||
|
|
||||||
Profile:
|
Profile:
|
||||||
|
|
||||||
@@ -80,7 +186,7 @@ Profile:
|
|||||||
|
|
||||||
## Container-Betrieb
|
## Container-Betrieb
|
||||||
|
|
||||||
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
|
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV, Tesseract einschließlich Deutsch/Englisch und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -109,6 +215,17 @@ npm run build
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run test:python
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests benötigen Python 3 mit Pillow, NumPy und OpenCV. Für den OCR-Integrationstest sind Tesseract mit `deu`/`eng` und `pdftotext` aus Poppler erforderlich; ohne diese Programme wird er übersprungen. Bei Verwendung der virtuellen Umgebung zuerst `. .venv-opencv/bin/activate` ausführen. Die Tests simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Bildtests prüfen die Hintergrundbereinigung bei geraden und schrägen Bons sowie den Erhalt von Text, farbigen Logos und vollständigen Seiten. Der OCR-Test liest die tatsächliche PDF-Textschicht einschließlich leerer Rückseiten aus. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
|
||||||
|
|
||||||
|
Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet.
|
||||||
|
|
||||||
## FEDEO-Endpunkte
|
## FEDEO-Endpunkte
|
||||||
|
|
||||||
Der Agent nutzt:
|
Der Agent nutzt:
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
FEDEO_WORK_DIR: /work
|
FEDEO_WORK_DIR: /work
|
||||||
FEDEO_SCAN_POSTPROCESS: "true"
|
|
||||||
FEDEO_SCAN_POSTPROCESS_PROFILE: receipt
|
|
||||||
FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python
|
FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python
|
||||||
FEDEO_SCAN_POSTPROCESS_STRICT: "false"
|
FEDEO_SCAN_POSTPROCESS_STRICT: "false"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
572
agents/fedeo-device-agent/package-lock.json
generated
Normal file
572
agents/fedeo-device-agent/package-lock.json
generated
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
{
|
||||||
|
"name": "@fedeo/device-agent",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@fedeo/device-agent",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"bin": {
|
||||||
|
"fedeo-device-agent": "dist/main.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz",
|
||||||
|
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||||
|
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,12 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"build:installer": "npm run build && node scripts/build-installer.mjs",
|
||||||
|
"check:installer": "npm run build && node scripts/build-installer.mjs --check",
|
||||||
"dev": "tsx src/main.ts",
|
"dev": "tsx src/main.ts",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
"test": "node --import tsx --test tests/*.test.ts && node --test tests/*.test.mjs && npm run check:installer",
|
||||||
|
"test:python": "python3 -m unittest discover -s tests -p '*_test.py'",
|
||||||
"setup:opencv": "sh scripts/setup-opencv.sh"
|
"setup:opencv": "sh scripts/setup-opencv.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
|
|||||||
40
agents/fedeo-device-agent/scripts/build-installer.mjs
Normal file
40
agents/fedeo-device-agent/scripts/build-installer.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const output = path.resolve(root, '../../frontend/assets/device-agent/bundle.json')
|
||||||
|
async function filesUnder(directory) {
|
||||||
|
const entries = await readdir(path.join(root, directory), { withFileTypes: true })
|
||||||
|
const paths = await Promise.all(entries.map(entry => entry.isDirectory()
|
||||||
|
? filesUnder(`${directory}/${entry.name}`)
|
||||||
|
: [`${directory}/${entry.name}`]))
|
||||||
|
return paths.flat().sort()
|
||||||
|
}
|
||||||
|
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
|
||||||
|
const paths = [
|
||||||
|
...(await filesUnder('dist')).filter(file => file.endsWith('.js')),
|
||||||
|
'scripts/scan_document.py', 'scripts/scan_background.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
|
||||||
|
'requirements-opencv.txt',
|
||||||
|
]
|
||||||
|
const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')])))
|
||||||
|
files['package.json'] = Buffer.from(JSON.stringify({
|
||||||
|
name: packageJson.name, version: packageJson.version, type: 'module', engines: packageJson.engines,
|
||||||
|
scripts: { start: 'node dist/main.js', 'setup:opencv': 'sh scripts/setup-opencv.sh' },
|
||||||
|
}, null, 2) + '\n').toString('base64')
|
||||||
|
const bundle = JSON.stringify({
|
||||||
|
version: packageJson.version,
|
||||||
|
installerTemplate: await readFile(path.join(root, 'scripts/install-linux.sh'), 'utf8'),
|
||||||
|
files,
|
||||||
|
}, null, 2) + '\n'
|
||||||
|
|
||||||
|
if (process.argv.includes('--check')) {
|
||||||
|
if (await readFile(output, 'utf8') !== bundle) {
|
||||||
|
throw new Error('Das Installationspaket ist veraltet. Bitte npm run build:installer ausführen und bundle.json mitcommitten.')
|
||||||
|
}
|
||||||
|
console.log('Installationspaket entspricht dem aktuellen Agenten.')
|
||||||
|
} else {
|
||||||
|
await mkdir(path.dirname(output), { recursive: true })
|
||||||
|
await writeFile(output, bundle)
|
||||||
|
console.log(`Installationspaket erstellt: ${output}`)
|
||||||
|
}
|
||||||
118
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
118
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generated by the FEDEO setup wizard. This file contains the agent's access token.
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" != Linux ]]; then
|
||||||
|
echo 'Diese Installation ist für Linux vorgesehen.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$(id -u)" == 0 ]]; then
|
||||||
|
echo 'Bitte dieses Skript als normaler Benutzer ohne sudo starten.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" != '' && "${1:-}" != --no-start ]]; then
|
||||||
|
echo 'Aufruf: bash install.sh [--no-start]' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FEDEO_SETUP_ID='@@AGENT_ID@@'
|
||||||
|
FEDEO_SETUP_AUTOSTART='@@AUTOSTART@@'
|
||||||
|
FEDEO_SETUP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/fedeo-device-agent/$FEDEO_SETUP_ID"
|
||||||
|
FEDEO_SETUP_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/fedeo-device-agent/$FEDEO_SETUP_ID.env"
|
||||||
|
FEDEO_SETUP_WORK="${XDG_STATE_HOME:-$HOME/.local/state}/fedeo-device-agent/$FEDEO_SETUP_ID"
|
||||||
|
FEDEO_SETUP_UNIT="fedeo-device-agent-$FEDEO_SETUP_ID.service"
|
||||||
|
FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
|
||||||
|
echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.'
|
||||||
|
fedeo_processing_ready() {
|
||||||
|
python3 -c 'import PIL, cv2, numpy' >/dev/null 2>&1 &&
|
||||||
|
tesseract --list-langs 2>/dev/null | grep -qx deu &&
|
||||||
|
tesseract --list-langs 2>/dev/null | grep -qx eng
|
||||||
|
}
|
||||||
|
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! fedeo_processing_ready; then
|
||||||
|
if ! command -v apt-get >/dev/null; then
|
||||||
|
echo 'Bitte Node.js >= 20, SANE, Python/Pillow/OpenCV sowie Tesseract mit Deutsch/Englisch installieren und das Skript erneut starten.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y nodejs sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
|
||||||
|
fi
|
||||||
|
node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }'
|
||||||
|
fedeo_processing_ready || { echo 'Die benötigte Bildverarbeitung oder die OCR-Sprachpakete fehlen weiterhin.' >&2; exit 1; }
|
||||||
|
|
||||||
|
FEDEO_SETUP_NODE="$(command -v node)"
|
||||||
|
FEDEO_SETUP_HAS_SYSTEMD=false
|
||||||
|
if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then
|
||||||
|
FEDEO_SETUP_HAS_SYSTEMD=true
|
||||||
|
fi
|
||||||
|
if [[ "$FEDEO_SETUP_AUTOSTART" == true && "$FEDEO_SETUP_HAS_SYSTEMD" != true ]]; then
|
||||||
|
echo 'Der Benutzerdienst von systemd ist nicht erreichbar. Bitte in der Desktop-Sitzung starten oder den Autostart im Assistenten ausschalten.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$FEDEO_SETUP_DIR" "$(dirname "$FEDEO_SETUP_CONFIG")" "$FEDEO_SETUP_WORK" "$FEDEO_SETUP_UNITS"
|
||||||
|
# Stop only the matching installation before replacing its files.
|
||||||
|
if [[ "$FEDEO_SETUP_HAS_SYSTEMD" == true && "${1:-}" != --no-start ]]; then
|
||||||
|
systemctl --user stop "$FEDEO_SETUP_UNIT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$FEDEO_SETUP_DIR" "$FEDEO_SETUP_CONFIG" "$FEDEO_SETUP_WORK" "$FEDEO_SETUP_NODE" "$FEDEO_SETUP_UNITS/$FEDEO_SETUP_UNIT" <<'FEDEO_PYTHON'
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
agent_dir, config_path, work_dir, node, unit_path = map(Path, sys.argv[1:])
|
||||||
|
files = json.loads(base64.b64decode('@@PAYLOAD_BASE64@@'))
|
||||||
|
for name, encoded in files.items():
|
||||||
|
relative = Path(name)
|
||||||
|
if relative.is_absolute() or '..' in relative.parts:
|
||||||
|
raise ValueError('Ungültiger Dateiname im Installationspaket')
|
||||||
|
target = agent_dir / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(base64.b64decode(encoded, validate=True))
|
||||||
|
|
||||||
|
config = base64.b64decode('@@CONFIG_BASE64@@').decode('utf8')
|
||||||
|
config += 'FEDEO_WORK_DIR=' + str(work_dir) + '\n'
|
||||||
|
config_path.write_text(config, encoding='utf8')
|
||||||
|
config_path.chmod(0o600)
|
||||||
|
|
||||||
|
def unit_quote(value):
|
||||||
|
return '"' + str(value).replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%') + '"'
|
||||||
|
|
||||||
|
unit_path.write_text('\n'.join([
|
||||||
|
'[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '',
|
||||||
|
'[Service]', 'Type=simple',
|
||||||
|
# WorkingDirectory takes a literal path, unlike Environment/ExecStart.
|
||||||
|
# Quotes would become part of the path; only systemd specifiers need escaping.
|
||||||
|
'WorkingDirectory=' + str(agent_dir).replace('%', '%%'),
|
||||||
|
'Environment=' + unit_quote('FEDEO_AGENT_ENV=' + str(config_path)),
|
||||||
|
'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'),
|
||||||
|
'Restart=on-failure', 'RestartSec=5', 'UMask=0077', '',
|
||||||
|
'[Install]', 'WantedBy=default.target', '',
|
||||||
|
]), encoding='utf8')
|
||||||
|
FEDEO_PYTHON
|
||||||
|
|
||||||
|
echo "Agent installiert: $FEDEO_SETUP_DIR"
|
||||||
|
echo "Konfiguration: $FEDEO_SETUP_CONFIG"
|
||||||
|
if [[ "${1:-}" == --no-start ]]; then
|
||||||
|
echo 'Installation abgeschlossen; Agent wurde nicht gestartet.'
|
||||||
|
elif [[ "$FEDEO_SETUP_AUTOSTART" == true ]]; then
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "$FEDEO_SETUP_UNIT"
|
||||||
|
systemctl --user restart "$FEDEO_SETUP_UNIT"
|
||||||
|
echo 'Agent gestartet. Er startet künftig bei der Anmeldung.'
|
||||||
|
printf 'Protokoll: journalctl --user -u %q -f\n' "$FEDEO_SETUP_UNIT"
|
||||||
|
echo 'Jetzt im FEDEO-Assistenten die Verbindung prüfen. Die heruntergeladene Installationsdatei kann anschließend gelöscht werden.'
|
||||||
|
else
|
||||||
|
if [[ "$FEDEO_SETUP_HAS_SYSTEMD" == true ]]; then
|
||||||
|
systemctl --user disable "$FEDEO_SETUP_UNIT" 2>/dev/null || true
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
fi
|
||||||
|
echo 'Agent startet im Terminal. Dieses Fenster geöffnet lassen; Beenden mit Strg+C.'
|
||||||
|
cd "$FEDEO_SETUP_DIR"
|
||||||
|
FEDEO_AGENT_ENV="$FEDEO_SETUP_CONFIG" "$FEDEO_SETUP_NODE" dist/main.js
|
||||||
|
fi
|
||||||
74
agents/fedeo-device-agent/scripts/scan_background.py
Normal file
74
agents/fedeo-device-agent/scripts/scan_background.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Conservative paper masking. Keep the canvas and every pixel inside the paper."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def whiten_background(image):
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
scale = min(1.0, 1000.0 / max(height, width))
|
||||||
|
small = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
||||||
|
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||||
|
border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1]))
|
||||||
|
background = float(np.median(border))
|
||||||
|
# White surroundings already need no correction. Never search inside a
|
||||||
|
# white document for an isolated text box or logo to keep instead.
|
||||||
|
if background >= 235 or np.percentile(border, 75) - np.percentile(border, 25) > 55:
|
||||||
|
return image
|
||||||
|
|
||||||
|
threshold, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||||
|
mask = np.uint8(gray > max(threshold, background + 20)) * 255
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
candidates = []
|
||||||
|
area = gray.size
|
||||||
|
for contour in contours:
|
||||||
|
contour_area = cv2.contourArea(contour)
|
||||||
|
if not 0.015 * area < contour_area < 0.985 * area:
|
||||||
|
continue
|
||||||
|
perimeter = cv2.arcLength(contour, True)
|
||||||
|
polygon = cv2.approxPolyDP(contour, 0.018 * perimeter, True)
|
||||||
|
if len(polygon) != 4 or not cv2.isContourConvex(polygon):
|
||||||
|
continue
|
||||||
|
# Use the quadrilateral only to recognize a sheet, then keep its full
|
||||||
|
# contour hull so rounded or slightly uneven edges cannot lose content.
|
||||||
|
polygon = cv2.convexHull(contour)
|
||||||
|
paper = np.zeros_like(gray)
|
||||||
|
cv2.fillPoly(paper, [polygon], 255)
|
||||||
|
inside = cv2.erode(paper, np.ones((7, 7), np.uint8)) > 0
|
||||||
|
ring = (cv2.dilate(paper, np.ones((9, 9), np.uint8)) > 0) & (paper == 0)
|
||||||
|
if inside.sum() < 100 or ring.sum() < 50:
|
||||||
|
continue
|
||||||
|
paper_light = float(np.median(gray[inside]))
|
||||||
|
# A paper edge must separate a mostly light sheet from darker scanner
|
||||||
|
# backing all around it. Ambiguous photos/pages are left untouched.
|
||||||
|
if paper_light < background + 30 or np.mean(gray[ring] < paper_light - 20) < 0.85:
|
||||||
|
continue
|
||||||
|
candidates.append(polygon)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return image
|
||||||
|
keep_small = np.zeros_like(gray)
|
||||||
|
cv2.fillPoly(keep_small, candidates, 255)
|
||||||
|
# Do not erase another significant object that we could not classify.
|
||||||
|
outside = (keep_small == 0)
|
||||||
|
if np.mean(gray[outside] > background + 30) > 0.04:
|
||||||
|
return image
|
||||||
|
keep = np.zeros((height, width), dtype=np.uint8)
|
||||||
|
polygons = [np.rint(polygon / scale).astype(np.int32) for polygon in candidates]
|
||||||
|
cv2.fillPoly(keep, polygons, 255)
|
||||||
|
# Rounding/downsampling uncertainty grows with the original scan DPI.
|
||||||
|
padding = max(2, int(np.ceil(2 / scale)))
|
||||||
|
keep = cv2.dilate(keep, np.ones((2 * padding + 1, 2 * padding + 1), np.uint8))
|
||||||
|
result = image.copy()
|
||||||
|
result[keep == 0] = 255
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def clean_scan_background(input_path: Path, output_path: Path):
|
||||||
|
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
raise RuntimeError("Scan-Bild konnte nicht gelesen werden")
|
||||||
|
if not cv2.imwrite(str(output_path), whiten_background(image)):
|
||||||
|
raise RuntimeError("Bereinigtes Scan-Bild konnte nicht gespeichert werden")
|
||||||
154
agents/fedeo-device-agent/scripts/scan_document.py
Normal file
154
agents/fedeo-device-agent/scripts/scan_document.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare ordered scanner pages and publish an optionally searchable PDF atomically."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from PIL import Image, PdfParser
|
||||||
|
|
||||||
|
|
||||||
|
def run_ocr_command(arguments):
|
||||||
|
process = subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
try:
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
except BaseException:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
raise
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError("OCR fehlgeschlagen: " + stderr.strip())
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
def check_ocr(language):
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9_]+(?:\+[a-zA-Z0-9_]+)*", language):
|
||||||
|
raise ValueError("Ungültige OCR-Sprache")
|
||||||
|
try:
|
||||||
|
languages = run_ocr_command(["tesseract", "--list-langs"]).splitlines()
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
raise RuntimeError("Tesseract fehlt. Bitte tesseract-ocr und die Sprachpakete tesseract-ocr-deu/tesseract-ocr-eng installieren.") from error
|
||||||
|
missing = set(language.split("+")) - {line.strip() for line in languages}
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError("OCR-Sprachpakete fehlen: " + ", ".join(sorted(missing)))
|
||||||
|
|
||||||
|
|
||||||
|
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False,
|
||||||
|
ocr=False, ocr_language="deu+eng", background_cleanup=False):
|
||||||
|
if not inputs:
|
||||||
|
raise ValueError("Keine Seiten vorhanden")
|
||||||
|
if not math.isfinite(resolution) or resolution <= 0:
|
||||||
|
raise ValueError("Ungültige Scan-Auflösung")
|
||||||
|
output = Path(output)
|
||||||
|
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
|
||||||
|
if not file_format:
|
||||||
|
raise ValueError("Unbekanntes Ausgabeformat")
|
||||||
|
if (len(inputs) != 1 or ocr) and file_format != "PDF":
|
||||||
|
raise ValueError("Mehrseitige Scans und OCR benötigen PDF als Ausgabeformat")
|
||||||
|
if ocr:
|
||||||
|
check_ocr(ocr_language)
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
processor = None
|
||||||
|
if use_postprocess:
|
||||||
|
try:
|
||||||
|
from opencv_postprocess import postprocess
|
||||||
|
processor = postprocess
|
||||||
|
except ImportError:
|
||||||
|
if strict:
|
||||||
|
raise
|
||||||
|
warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.")
|
||||||
|
if background_cleanup:
|
||||||
|
from scan_background import clean_scan_background
|
||||||
|
|
||||||
|
partial = output.with_name(output.name + ".partial")
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="prepared-", dir=output.parent) as directory:
|
||||||
|
prepared = []
|
||||||
|
for index, input_path in enumerate(inputs):
|
||||||
|
image_path = Path(input_path)
|
||||||
|
corrected = Path(directory) / f"corrected-{index:06}.png"
|
||||||
|
if processor:
|
||||||
|
try:
|
||||||
|
processor(image_path, corrected, profile)
|
||||||
|
image_path = corrected
|
||||||
|
except Exception:
|
||||||
|
if strict:
|
||||||
|
raise
|
||||||
|
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
|
||||||
|
if background_cleanup:
|
||||||
|
cleaned = Path(directory) / f"cleaned-{index:06}.png"
|
||||||
|
clean_scan_background(image_path, cleaned)
|
||||||
|
image_path = cleaned
|
||||||
|
|
||||||
|
# Only one decoded page is kept in memory, even for duplex stacks.
|
||||||
|
with Image.open(image_path) as image:
|
||||||
|
with image.convert("RGB") as rgb:
|
||||||
|
if ocr:
|
||||||
|
page = Path(directory).resolve() / f"page-{index:06}.png"
|
||||||
|
rgb.save(page, "PNG", dpi=(resolution, resolution))
|
||||||
|
prepared.append(page)
|
||||||
|
elif file_format == "PDF":
|
||||||
|
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
|
||||||
|
else:
|
||||||
|
rgb.save(partial, file_format, dpi=(resolution, resolution))
|
||||||
|
corrected.unlink(missing_ok=True)
|
||||||
|
if background_cleanup:
|
||||||
|
cleaned.unlink(missing_ok=True)
|
||||||
|
if ocr:
|
||||||
|
page_list = Path(directory) / "pages.txt"
|
||||||
|
if any("\n" in str(page) or "\r" in str(page) for page in prepared):
|
||||||
|
raise ValueError("Das Scan-Arbeitsverzeichnis darf keinen Zeilenumbruch enthalten")
|
||||||
|
page_list.write_text("\n".join(map(str, prepared)) + "\n", encoding="utf8")
|
||||||
|
prefix = Path(directory) / "searchable"
|
||||||
|
run_ocr_command(["tesseract", str(page_list), str(prefix), "-l", ocr_language,
|
||||||
|
"--dpi", str(round(resolution)), "pdf"])
|
||||||
|
searchable = prefix.with_suffix(".pdf")
|
||||||
|
with PdfParser.PdfParser(filename=str(searchable)) as pdf:
|
||||||
|
if len(pdf.pages) != len(inputs):
|
||||||
|
raise RuntimeError("OCR-PDF ist unvollständig; Rohscans bleiben erhalten")
|
||||||
|
searchable.replace(partial)
|
||||||
|
partial.replace(output)
|
||||||
|
finally:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
return {"pageCount": len(inputs), "warnings": warnings}
|
||||||
|
|
||||||
|
|
||||||
|
def interrupted(*_):
|
||||||
|
raise SystemExit("Scan-Verarbeitung abgebrochen")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Let finally blocks stop Tesseract and remove incomplete PDFs on agent timeout.
|
||||||
|
signal.signal(signal.SIGTERM, interrupted)
|
||||||
|
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
|
||||||
|
parser.add_argument("--inputs", nargs="+")
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--resolution", type=float, default=300)
|
||||||
|
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
|
||||||
|
parser.add_argument("--postprocess", action="store_true")
|
||||||
|
parser.add_argument("--strict", action="store_true")
|
||||||
|
parser.add_argument("--background-cleanup", action="store_true")
|
||||||
|
parser.add_argument("--ocr", action="store_true")
|
||||||
|
parser.add_argument("--ocr-language", default="deu+eng")
|
||||||
|
parser.add_argument("--check-runtime", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.check_runtime:
|
||||||
|
if args.background_cleanup or (args.postprocess and args.strict):
|
||||||
|
import cv2, numpy
|
||||||
|
if args.ocr:
|
||||||
|
check_ocr(args.ocr_language)
|
||||||
|
print(json.dumps({"ready": True}))
|
||||||
|
return
|
||||||
|
if not args.inputs or not args.output:
|
||||||
|
parser.error("--inputs und --output sind erforderlich")
|
||||||
|
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile,
|
||||||
|
args.strict, args.ocr, args.ocr_language, args.background_cleanup)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile } from "node:fs/promises"
|
import { readFile, stat } from "node:fs/promises"
|
||||||
import { basename } from "node:path"
|
import { basename } from "node:path"
|
||||||
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
|
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
|
||||||
|
|
||||||
@@ -16,10 +16,11 @@ export class FedeoApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
private async request<T>(path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<T> {
|
||||||
const response = await fetch(this.url(path), {
|
const response = await fetch(this.url(path), {
|
||||||
...init,
|
...init,
|
||||||
headers: this.headers(init.headers),
|
headers: this.headers(init.headers),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -51,6 +52,9 @@ export class FedeoApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uploadScan(jobId: string, result: ScanResult) {
|
async uploadScan(jobId: string, result: ScanResult) {
|
||||||
|
if ((await stat(result.path)).size > 100 * 1024 * 1024) {
|
||||||
|
throw new Error("Die Scan-Datei überschreitet das Upload-Limit von 100 MiB. Bitte den Stapel aufteilen oder die Auflösung reduzieren.")
|
||||||
|
}
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
const fileBuffer = await readFile(result.path)
|
const fileBuffer = await readFile(result.path)
|
||||||
const file = new File([fileBuffer], result.filename || basename(result.path), {
|
const file = new File([fileBuffer], result.filename || basename(result.path), {
|
||||||
@@ -62,6 +66,6 @@ export class FedeoApi {
|
|||||||
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
|
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
}, 120_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type CommandResult = {
|
|||||||
|
|
||||||
export const commandExists = (command: string) =>
|
export const commandExists = (command: string) =>
|
||||||
new Promise<boolean>((resolve) => {
|
new Promise<boolean>((resolve) => {
|
||||||
const child = spawn("sh", ["-lc", `command -v ${command}`])
|
const child = spawn("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "--", command])
|
||||||
child.on("error", () => resolve(false))
|
child.on("error", () => resolve(false))
|
||||||
child.on("close", (code) => resolve(code === 0))
|
child.on("close", (code) => resolve(code === 0))
|
||||||
})
|
})
|
||||||
@@ -16,33 +16,50 @@ export const commandExists = (command: string) =>
|
|||||||
export const runCommand = (
|
export const runCommand = (
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
options: { timeoutMs?: number } = {}
|
options: { timeoutMs?: number; cwd?: string } = {}
|
||||||
) =>
|
) =>
|
||||||
new Promise<CommandResult>((resolve, reject) => {
|
new Promise<CommandResult>((resolve, reject) => {
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
cwd: options.cwd,
|
||||||
})
|
})
|
||||||
|
|
||||||
const stdout: Buffer[] = []
|
const stdout: Buffer[] = []
|
||||||
const stderr: Buffer[] = []
|
const stderr: Buffer[] = []
|
||||||
|
|
||||||
|
let timedOut = false
|
||||||
|
let killTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
const timeout = options.timeoutMs
|
const timeout = options.timeoutMs
|
||||||
? setTimeout(() => {
|
? setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
child.kill("SIGTERM")
|
child.kill("SIGTERM")
|
||||||
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
killTimeout = setTimeout(() => child.kill("SIGKILL"), 2000)
|
||||||
}, options.timeoutMs)
|
}, options.timeoutMs)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
|
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
|
||||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
|
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
|
||||||
child.on("error", reject)
|
child.on("error", (error) => {
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timeout) clearTimeout(timeout)
|
if (timeout) clearTimeout(timeout)
|
||||||
|
if (killTimeout) clearTimeout(killTimeout)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
if (killTimeout) clearTimeout(killTimeout)
|
||||||
|
if (timedOut) {
|
||||||
|
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (signal) {
|
||||||
|
reject(new Error(`${command} wurde durch ${signal} beendet`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||||
code: code ?? 0,
|
code: code ?? 1,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export const loadConfig = (): AgentConfig => {
|
|||||||
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
|
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
|
||||||
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
|
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
|
||||||
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
||||||
|
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
|
||||||
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
|
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
|
||||||
|
scanBackgroundCleanup: booleanFromEnv(process.env.FEDEO_SCAN_BACKGROUND_CLEANUP, true),
|
||||||
|
scanOcr: booleanFromEnv(process.env.FEDEO_SCAN_OCR, true),
|
||||||
|
scanOcrLanguage: optional(process.env.FEDEO_SCAN_OCR_LANGUAGE) || "deu+eng",
|
||||||
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
|
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
|
||||||
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
|
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
|
||||||
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),
|
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { rm } from "node:fs/promises"
|
||||||
import { FedeoApi } from "./api.js"
|
import { FedeoApi } from "./api.js"
|
||||||
import { loadConfig } from "./config.js"
|
import { loadConfig } from "./config.js"
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
import { listPrinters } from "./print/cups.js"
|
import { listPrinters } from "./print/cups.js"
|
||||||
import { hasSane, listScanners, runScan } from "./scan/sane.js"
|
import { hasSane, listScanners, runScan } from "./scan/sane.js"
|
||||||
|
import { processingCapabilities } from "./scan/postprocess.js"
|
||||||
|
import { AgentHeartbeat } from "./types.js"
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ const stringifyError = (error: unknown) => {
|
|||||||
const main = async () => {
|
const main = async () => {
|
||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
const api = new FedeoApi(config)
|
const api = new FedeoApi(config)
|
||||||
|
const processing = await processingCapabilities(config)
|
||||||
|
|
||||||
log.info("FEDEO Geräte-Agent startet", {
|
log.info("FEDEO Geräte-Agent startet", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -25,13 +30,17 @@ const main = async () => {
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const scannerNames = await listScanners()
|
const scannerNames = await listScanners().catch((error) => {
|
||||||
const printerNames = await listPrinters()
|
log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error))
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
const printerNames = await listPrinters().catch(() => [])
|
||||||
const scanAvailable = await hasSane()
|
const scanAvailable = await hasSane()
|
||||||
|
|
||||||
const heartbeat = await api.heartbeat({
|
const heartbeatPayload: AgentHeartbeat = {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
scan: scanAvailable,
|
scan: scanAvailable,
|
||||||
|
...processing,
|
||||||
print: printerNames.length > 0,
|
print: printerNames.length > 0,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
},
|
},
|
||||||
@@ -44,7 +53,8 @@ const main = async () => {
|
|||||||
node: process.version,
|
node: process.version,
|
||||||
uptimeSeconds: Math.round(os.uptime()),
|
uptimeSeconds: Math.round(os.uptime()),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
const heartbeat = await api.heartbeat(heartbeatPayload)
|
||||||
|
|
||||||
if (heartbeat.pendingScanJobs > 0) {
|
if (heartbeat.pendingScanJobs > 0) {
|
||||||
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
|
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
|
||||||
@@ -62,14 +72,44 @@ const main = async () => {
|
|||||||
scannerName: next.job.scannerName || config.scannerName || "default",
|
scannerName: next.job.scannerName || config.scannerName || "default",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Keep online status fresh during long scans without probing an occupied USB scanner.
|
||||||
|
let heartbeatRunning = false
|
||||||
|
const heartbeatTimer = setInterval(async () => {
|
||||||
|
if (heartbeatRunning) return
|
||||||
|
heartbeatRunning = true
|
||||||
|
try {
|
||||||
|
await api.heartbeat(heartbeatPayload)
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Heartbeat während Scan fehlgeschlagen", stringifyError(error))
|
||||||
|
} finally {
|
||||||
|
heartbeatRunning = false
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateScanJobStatus(next.job.id, "running")
|
await api.updateScanJobStatus(next.job.id, "running")
|
||||||
const scanResult = await runScan(config, next.job)
|
const jobId = next.job.id
|
||||||
await api.uploadScan(next.job.id, scanResult)
|
const reportProgress = async (message: string) => {
|
||||||
|
await api.updateScanJobStatus(jobId, "running", message).catch((error) => {
|
||||||
|
log.warn("Scan-Status konnte nicht übertragen werden", stringifyError(error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const scanResult = await runScan(config, next.job, reportProgress)
|
||||||
|
await reportProgress(`${scanResult.pageCount} Seiten gescannt. ${scanResult.warnings.join(" ")}`.trim())
|
||||||
|
try {
|
||||||
|
await api.uploadScan(jobId, scanResult)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${stringifyError(error)} Scan-Datei bleibt zur Wiederherstellung unter ${scanResult.path}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(path.dirname(scanResult.path), { recursive: true, force: true }).catch((error) => {
|
||||||
|
log.warn("Scan gespeichert; temporäre Dateien konnten nicht entfernt werden", stringifyError(error))
|
||||||
|
})
|
||||||
|
|
||||||
log.info("Scan-Auftrag abgeschlossen", {
|
log.info("Scan-Auftrag abgeschlossen", {
|
||||||
jobId: next.job.id,
|
jobId: next.job.id,
|
||||||
file: scanResult.filename,
|
file: scanResult.filename,
|
||||||
|
pages: scanResult.pageCount,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = stringifyError(error)
|
const message = stringifyError(error)
|
||||||
@@ -79,6 +119,8 @@ const main = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await api.updateScanJobStatus(next.job.id, "failed", message)
|
await api.updateScanJobStatus(next.job.id, "failed", message)
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
|
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
|
||||||
|
|||||||
@@ -1,66 +1,67 @@
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { AgentConfig, ScanResult } from "../types.js"
|
import { AgentConfig, ScanResult } from "../types.js"
|
||||||
import { commandExists, runCommand } from "../commands.js"
|
import { runCommand } from "../commands.js"
|
||||||
|
|
||||||
const currentFile = fileURLToPath(import.meta.url)
|
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||||
const agentRoot = path.resolve(path.dirname(currentFile), "../..")
|
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
|
||||||
const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py")
|
|
||||||
|
|
||||||
const extensionMimeTypes: Record<string, string> = {
|
type ProcessingOptions = { ocr: boolean; ocrLanguage: string; backgroundCleanup: boolean }
|
||||||
".pdf": "application/pdf",
|
|
||||||
".png": "image/png",
|
|
||||||
".tif": "image/tiff",
|
|
||||||
".tiff": "image/tiff",
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
|
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean, options: ProcessingOptions) => {
|
||||||
const ext = path.extname(filename)
|
const args = [documentScript, "--check-runtime", "--ocr-language", options.ocrLanguage]
|
||||||
if (ext) return filename
|
if (postprocess) args.push("--postprocess")
|
||||||
return `${filename}.${format}`
|
if (config.postprocessStrict) args.push("--strict")
|
||||||
}
|
if (options.backgroundCleanup) args.push("--background-cleanup")
|
||||||
|
if (options.ocr) args.push("--ocr")
|
||||||
export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => {
|
try {
|
||||||
if (!await commandExists(config.postprocessPython)) return false
|
const result = await runCommand(config.postprocessPython, args, { timeoutMs: 10_000 })
|
||||||
|
if (result.code === 0) return
|
||||||
const result = await runCommand(config.postprocessPython, [
|
throw new Error(result.stderr.trim())
|
||||||
"-c",
|
} catch (error) {
|
||||||
"import cv2, PIL, numpy",
|
// Report missing runtimes before the scanner consumes any paper.
|
||||||
], { timeoutMs: 10_000 })
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new Error("Scan-Verarbeitung nicht verfügbar. Bitte den Agenten mit Python/Pillow, OpenCV und Tesseract samt OCR-Sprachpaketen installieren. " + detail)
|
||||||
return result.code === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export const postprocessScan = async (
|
|
||||||
config: AgentConfig,
|
|
||||||
inputPath: string,
|
|
||||||
outputFilename: string,
|
|
||||||
outputFormat: AgentConfig["scanFormat"],
|
|
||||||
profile: AgentConfig["postprocessProfile"]
|
|
||||||
): Promise<ScanResult> => {
|
|
||||||
const filename = ensureOutputExtension(outputFilename, outputFormat)
|
|
||||||
const outputPath = path.join(config.workDir, filename)
|
|
||||||
|
|
||||||
const result = await runCommand(config.postprocessPython, [
|
|
||||||
postprocessScript,
|
|
||||||
"--input",
|
|
||||||
inputPath,
|
|
||||||
"--output",
|
|
||||||
outputPath,
|
|
||||||
"--profile",
|
|
||||||
profile,
|
|
||||||
], { timeoutMs: 5 * 60 * 1000 })
|
|
||||||
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extension = path.extname(outputPath).toLowerCase()
|
export const processingCapabilities = async (config: AgentConfig) => {
|
||||||
|
const available = async (options: ProcessingOptions) => {
|
||||||
|
try { await checkScanRuntime(config, false, options); return true } catch { return false }
|
||||||
|
}
|
||||||
|
const [ocr, backgroundCleanup] = await Promise.all([
|
||||||
|
available({ ocr: true, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: false }),
|
||||||
|
available({ ocr: false, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: true }),
|
||||||
|
])
|
||||||
|
return { ocr, backgroundCleanup }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createScanDocument = async (
|
||||||
|
config: AgentConfig,
|
||||||
|
pages: string[],
|
||||||
|
outputPath: string,
|
||||||
|
resolution: number,
|
||||||
|
postprocess: boolean,
|
||||||
|
profile: AgentConfig["postprocessProfile"],
|
||||||
|
options: ProcessingOptions
|
||||||
|
): Promise<ScanResult> => {
|
||||||
|
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
|
||||||
|
if (postprocess) args.push("--postprocess")
|
||||||
|
if (config.postprocessStrict) args.push("--strict")
|
||||||
|
if (options.backgroundCleanup) args.push("--background-cleanup")
|
||||||
|
if (options.ocr) args.push("--ocr", "--ocr-language", options.ocrLanguage)
|
||||||
|
args.push("--inputs", ...pages)
|
||||||
|
|
||||||
|
const result = await runCommand(config.postprocessPython, args, {
|
||||||
|
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
|
||||||
|
|
||||||
|
const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
|
||||||
return {
|
return {
|
||||||
path: outputPath,
|
path: outputPath,
|
||||||
filename,
|
filename: path.basename(outputPath),
|
||||||
mimeType: extensionMimeTypes[extension] || "application/octet-stream",
|
mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff",
|
||||||
|
...details,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +1,125 @@
|
|||||||
import { mkdirSync } from "node:fs"
|
import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { AgentConfig, ScanJob, ScanResult } from "../types.js"
|
import { AgentConfig, ScanJob, ScanResult } from "../types.js"
|
||||||
import { commandExists, runCommand } from "../commands.js"
|
import { commandExists, runCommand } from "../commands.js"
|
||||||
import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js"
|
import { checkScanRuntime, createScanDocument } from "./postprocess.js"
|
||||||
import { log } from "../logger.js"
|
|
||||||
|
|
||||||
const mimeTypes = {
|
const stringSetting = (settings: Record<string, unknown>, key: string) => {
|
||||||
pdf: "application/pdf",
|
const value = settings[key]
|
||||||
png: "image/png",
|
|
||||||
tiff: "image/tiff",
|
|
||||||
}
|
|
||||||
|
|
||||||
const stringSetting = (settings: Record<string, unknown> | undefined, key: string) => {
|
|
||||||
const value = settings?.[key]
|
|
||||||
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberSetting = (settings: Record<string, unknown> | undefined, key: string) => {
|
const booleanSetting = (settings: Record<string, unknown>, key: string, fallback: boolean) => {
|
||||||
const value = settings?.[key]
|
const value = settings[key]
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
|
||||||
const parsed = Number(value)
|
|
||||||
if (Number.isFinite(parsed)) return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const booleanSetting = (settings: Record<string, unknown> | undefined, key: string, fallback: boolean) => {
|
|
||||||
const value = settings?.[key]
|
|
||||||
if (typeof value === "boolean") return value
|
if (typeof value === "boolean") return value
|
||||||
if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
|
if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileSetting = (
|
|
||||||
settings: Record<string, unknown> | undefined,
|
|
||||||
fallback: AgentConfig["postprocessProfile"]
|
|
||||||
): AgentConfig["postprocessProfile"] => {
|
|
||||||
const value = settings?.postprocessProfile
|
|
||||||
if (value === "document" || value === "receipt" || value === "raw") return value
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureFilenameExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
|
|
||||||
const ext = path.extname(filename)
|
|
||||||
if (!ext) return `${filename}.${format}`
|
|
||||||
|
|
||||||
const expectedExt = `.${format}`
|
|
||||||
if (ext.toLowerCase() === expectedExt) return filename
|
|
||||||
return `${filename.slice(0, -ext.length)}${expectedExt}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackRawResult = (scanOutputPath: string, jobId: string): ScanResult => ({
|
|
||||||
path: scanOutputPath,
|
|
||||||
filename: `${jobId}.raw.png`,
|
|
||||||
mimeType: "image/png",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const hasSane = () => commandExists("scanimage")
|
export const hasSane = () => commandExists("scanimage")
|
||||||
|
|
||||||
export const listScanners = async () => {
|
export const listScanners = async () => {
|
||||||
if (!await hasSane()) return []
|
if (!await hasSane()) return []
|
||||||
|
// SANE also probes network backends; these can take more than ten seconds
|
||||||
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
|
// even when the USB scanner is already available.
|
||||||
|
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 30_000 })
|
||||||
if (result.code !== 0) return []
|
if (result.code !== 0) return []
|
||||||
|
return result.stdout.split(/\r?\n/)
|
||||||
return result.stdout
|
.map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.startsWith("device `"))
|
|
||||||
.map((line) => line.match(/device `([^']+)'/)?.[1])
|
|
||||||
.filter((device): device is string => Boolean(device))
|
.filter((device): device is string => Boolean(device))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runScan = async (config: AgentConfig, job: ScanJob): Promise<ScanResult> => {
|
export const runScan = async (
|
||||||
if (!await hasSane()) {
|
config: AgentConfig,
|
||||||
throw new Error("scanimage ist nicht installiert oder nicht im PATH")
|
job: ScanJob,
|
||||||
}
|
reportProgress: (message: string) => Promise<void> = async () => {}
|
||||||
|
): Promise<ScanResult> => {
|
||||||
mkdirSync(config.workDir, { recursive: true })
|
if (!await hasSane()) throw new Error("scanimage ist nicht installiert oder nicht im PATH")
|
||||||
|
|
||||||
const settings = job.settings || {}
|
const settings = job.settings || {}
|
||||||
const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat
|
const format = stringSetting(settings, "format") || config.scanFormat
|
||||||
const resolution = numberSetting(settings, "resolution") || config.scanResolution
|
if (!["pdf", "png", "tiff"].includes(format)) throw new Error("Scan-Format muss PDF, PNG oder TIFF sein")
|
||||||
|
const resolution = Number(settings.resolution ?? config.scanResolution)
|
||||||
|
if (!Number.isFinite(resolution) || resolution < 75 || resolution > 1200) {
|
||||||
|
throw new Error("Die Scan-Auflösung muss zwischen 75 und 1200 dpi liegen")
|
||||||
|
}
|
||||||
const mode = stringSetting(settings, "mode") || config.scanMode
|
const mode = stringSetting(settings, "mode") || config.scanMode
|
||||||
const source = stringSetting(settings, "source") || config.scanSource
|
const source = stringSetting(settings, "source") || config.scanSource
|
||||||
const scannerName = job.scannerName || config.scannerName
|
const scannerName = job.scannerName || config.scannerName
|
||||||
const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format)
|
const paperSize = stringSetting(settings, "paperSize") || "auto"
|
||||||
const outputPath = path.join(config.workDir, filename)
|
if (!["auto", "A4", "Letter"].includes(paperSize)) throw new Error("Unbekanntes Papierformat")
|
||||||
const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
const batch = Boolean(source && /adf|document feeder/i.test(source))
|
||||||
const postprocessProfile = profileSetting(settings, config.postprocessProfile)
|
if (batch && format !== "pdf") throw new Error("Für Einzug- und Duplex-Scans bitte PDF als Format wählen")
|
||||||
const scanFormat = shouldPostprocess ? "png" : format
|
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
||||||
const scanOutputPath = shouldPostprocess
|
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
|
||||||
? path.join(config.workDir, `${job.id}.raw.png`)
|
if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
|
||||||
: outputPath
|
const processing = {
|
||||||
|
backgroundCleanup: booleanSetting(settings, "backgroundCleanup", config.scanBackgroundCleanup),
|
||||||
const args = [
|
ocr: format === "pdf" && booleanSetting(settings, "ocr", config.scanOcr),
|
||||||
"--format",
|
ocrLanguage: stringSetting(settings, "ocrLanguage") || config.scanOcrLanguage,
|
||||||
scanFormat,
|
|
||||||
"--resolution",
|
|
||||||
String(resolution),
|
|
||||||
"--mode",
|
|
||||||
mode,
|
|
||||||
"--output-file",
|
|
||||||
scanOutputPath,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (source) args.push("--source", source)
|
|
||||||
if (scannerName) args.push("--device-name", scannerName)
|
|
||||||
|
|
||||||
const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 })
|
|
||||||
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`)
|
|
||||||
}
|
}
|
||||||
|
await checkScanRuntime(config, postprocess, processing)
|
||||||
|
|
||||||
if (shouldPostprocess) {
|
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
|
||||||
if (!await hasOpenCvPostprocessRuntime(config)) {
|
await mkdir(config.workDir, { recursive: true, mode: 0o700 })
|
||||||
const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar"
|
const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-"))
|
||||||
if (config.postprocessStrict) throw new Error(message)
|
const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/"))
|
||||||
|
const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan"
|
||||||
log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, {
|
const outputPath = path.join(jobDir, `${stem}.${format}`)
|
||||||
jobId: job.id,
|
|
||||||
python: config.postprocessPython,
|
|
||||||
})
|
|
||||||
return fallbackRawResult(scanOutputPath, job.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile)
|
// Device/source come first: changing source can change the remaining SANE options.
|
||||||
} catch (error) {
|
const args: string[] = []
|
||||||
if (config.postprocessStrict) throw error
|
if (scannerName) args.push("--device-name", scannerName)
|
||||||
|
if (source) args.push("--source", source)
|
||||||
log.warn("OpenCV-Nachbearbeitung fehlgeschlagen. Rohscan wird ohne Korrektur hochgeladen.", {
|
args.push("--mode", mode, "--resolution", String(resolution), "--format=png")
|
||||||
jobId: job.id,
|
if (paperSize !== "auto") {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
const [width, height] = paperSize === "A4" ? [210, 297] : [215.9, 279.4]
|
||||||
|
if (scannerName?.startsWith("fujitsu:")) {
|
||||||
|
args.push("--page-width", String(width), "--page-height", String(height))
|
||||||
|
}
|
||||||
|
args.push("-l", "0", "-t", "0", "-x", String(width), "-y", String(height))
|
||||||
|
}
|
||||||
|
if (batch) {
|
||||||
|
// Relative pattern avoids interpreting '%' in a user-configured work directory.
|
||||||
|
args.push("--batch=page-%06d.png", "--batch-start=1", "--batch-increment=1")
|
||||||
|
} else {
|
||||||
|
args.push("--output-file", "page-000001.png")
|
||||||
|
}
|
||||||
|
await reportProgress(batch ? "Dokumente werden eingezogen und gescannt..." : "Seite wird gescannt...")
|
||||||
|
const result = await runCommand("scanimage", args, {
|
||||||
|
cwd: jobDir,
|
||||||
|
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||||
})
|
})
|
||||||
return fallbackRawResult(scanOutputPath, job.id)
|
|
||||||
|
// SANE_STATUS_NO_DOCS (7) is the expected end of an unlimited ADF batch.
|
||||||
|
// Never turn paper jams, I/O errors or canceled scans into a successful partial upload.
|
||||||
|
if (result.code !== 0 && !(batch && result.code === 7)) {
|
||||||
|
throw new Error(result.stderr.trim() || `scanimage wurde mit Code ${result.code} beendet`)
|
||||||
}
|
}
|
||||||
|
const names = (await readdir(jobDir))
|
||||||
|
.filter((name) => /^page-\d+\.png$/.test(name))
|
||||||
|
.sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]))
|
||||||
|
const pages: string[] = []
|
||||||
|
for (const [index, name] of names.entries()) {
|
||||||
|
const page = path.join(jobDir, name)
|
||||||
|
// Some scanimage versions leave a zero-byte file for the final NO_DOCS attempt.
|
||||||
|
if ((await stat(page)).size === 0) {
|
||||||
|
if (batch && result.code === 7 && index === names.length - 1) continue
|
||||||
|
throw new Error(`Unvollständige Scan-Seite: ${name}`)
|
||||||
|
}
|
||||||
|
if (Number(name.match(/\d+/)![0]) !== index + 1) throw new Error("Im Scan fehlen Seiten")
|
||||||
|
pages.push(page)
|
||||||
|
}
|
||||||
|
if (!pages.length) throw new Error("Keine Seiten gescannt. Bitte Papier in den Einzug legen.")
|
||||||
|
if (source?.toLowerCase().includes("duplex") && pages.length % 2 !== 0) {
|
||||||
|
throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
await reportProgress(`${pages.length} Seiten gescannt. ${processing.ocr ? "Durchsuchbares PDF mit OCR wird erstellt..." : "Datei wird erstellt..."}`)
|
||||||
path: outputPath,
|
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"], processing)
|
||||||
filename,
|
} catch (error) {
|
||||||
mimeType: mimeTypes[format] || "application/octet-stream",
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export type AgentConfig = {
|
|||||||
scanResolution: number
|
scanResolution: number
|
||||||
scanMode: string
|
scanMode: string
|
||||||
scanSource?: string
|
scanSource?: string
|
||||||
|
scanTimeoutSeconds: number
|
||||||
scanPostprocess: boolean
|
scanPostprocess: boolean
|
||||||
|
scanBackgroundCleanup: boolean
|
||||||
|
scanOcr: boolean
|
||||||
|
scanOcrLanguage: string
|
||||||
postprocessProfile: "document" | "receipt" | "raw"
|
postprocessProfile: "document" | "receipt" | "raw"
|
||||||
postprocessPython: string
|
postprocessPython: string
|
||||||
postprocessStrict: boolean
|
postprocessStrict: boolean
|
||||||
@@ -19,6 +23,8 @@ export type AgentHeartbeat = {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
scan: boolean
|
scan: boolean
|
||||||
print: boolean
|
print: boolean
|
||||||
|
ocr: boolean
|
||||||
|
backgroundCleanup: boolean
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
}
|
}
|
||||||
scannerNames: string[]
|
scannerNames: string[]
|
||||||
@@ -45,4 +51,6 @@ export type ScanResult = {
|
|||||||
path: string
|
path: string
|
||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
|
pageCount: number
|
||||||
|
warnings: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
91
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
91
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { readFile, writeFile, mkdir, mkdtemp, stat, rm } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { agentBackendUrl, agentSetupReady, renderAgentInstaller } from '../../../frontend/utils/agentInstaller.mjs'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const bundle = JSON.parse(await readFile(path.resolve(root, '../../frontend/assets/device-agent/bundle.json'), 'utf8'))
|
||||||
|
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
|
||||||
|
const token = 'fedeo_agent_' + 'a'.repeat(64)
|
||||||
|
const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true }
|
||||||
|
const execute = promisify(execFile)
|
||||||
|
const hasSystemdAnalyze = process.platform === 'linux' && await execute('systemd-analyze', ['--version'])
|
||||||
|
.then(() => true, error => { if (error.code === 'ENOENT') return false; throw error })
|
||||||
|
|
||||||
|
test('backend URLs preserve reverse-proxy paths and reject credentials or non-HTTP protocols', () => {
|
||||||
|
assert.equal(agentBackendUrl('/backend/', 'https://fedeo.example.com'), options.backendUrl)
|
||||||
|
assert.equal(agentBackendUrl('', 'http://localhost:3100'), 'http://localhost:3100')
|
||||||
|
assert.throws(() => agentBackendUrl('javascript:alert(1)'))
|
||||||
|
assert.throws(() => agentBackendUrl('https://user:secret@example.com'))
|
||||||
|
assert.throws(() => agentBackendUrl('https://example.com?token=secret'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('installer validates tokens and identities before generating executable content', () => {
|
||||||
|
assert.throws(() => renderAgentInstaller(bundle, { ...options, token: 'not-a-token' }))
|
||||||
|
assert.throws(() => renderAgentInstaller(bundle, { ...options, agentId: "x'; touch /tmp/unwanted; '" }))
|
||||||
|
assert.throws(() => renderAgentInstaller(bundle, { ...options, backendUrl: 'https://example.com/\nFEDEO_AGENT_TOKEN=bad' }))
|
||||||
|
const script = renderAgentInstaller(bundle, options)
|
||||||
|
assert.ok(!script.includes('@@'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connection readiness requires an active agent and a fresh heartbeat for this setup', () => {
|
||||||
|
const now = Date.now()
|
||||||
|
assert.equal(agentSetupReady({ active: true, lastSeenAt: new Date(now).toISOString() }, now - 1000, now), true)
|
||||||
|
assert.equal(agentSetupReady({ active: false, lastSeenAt: new Date(now).toISOString() }, 0, now), false)
|
||||||
|
assert.equal(agentSetupReady({ active: true, lastSeenAt: new Date(now - 5000).toISOString() }, now - 1000, now), false)
|
||||||
|
assert.equal(agentSetupReady({ active: true, lastSeenAt: 'invalid' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const start of [false, true]) {
|
||||||
|
test('downloaded installer writes runtime and credentials; start=' + start, { skip: process.platform !== 'linux' }, async (t) => {
|
||||||
|
const temp = await mkdtemp(path.join(os.tmpdir(), 'fedeo-installer-test-'))
|
||||||
|
t.after(() => rm(temp, { recursive: true, force: true }))
|
||||||
|
const bin = path.join(temp, 'bin')
|
||||||
|
await mkdir(bin)
|
||||||
|
// No package manager, scanner or real systemd command may run during this test.
|
||||||
|
await writeFile(path.join(bin, 'sudo'), '#!/bin/sh\nexit 99\n', { mode: 0o755 })
|
||||||
|
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||||
|
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
|
||||||
|
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
|
||||||
|
const realPython = (await execute('sh', ['-c', 'command -v python3'])).stdout.trim()
|
||||||
|
await writeFile(path.join(bin, 'python3'), '#!/bin/sh\nif [ "$1" = "-c" ] && [ "$2" = "import PIL, cv2, numpy" ]; then exit 0; fi\nexec "$FEDEO_TEST_PYTHON" "$@"\n', { mode: 0o755 })
|
||||||
|
await writeFile(path.join(bin, 'tesseract'), '#!/bin/sh\nprintf "deu\\neng\\n"\n', { mode: 0o755 })
|
||||||
|
const env = {
|
||||||
|
...process.env, PATH: bin + ':' + process.env.PATH,
|
||||||
|
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
|
||||||
|
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
|
||||||
|
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
|
||||||
|
FEDEO_TEST_PYTHON: realPython,
|
||||||
|
}
|
||||||
|
const scriptPath = path.join(temp, 'install.sh')
|
||||||
|
await writeFile(scriptPath, renderAgentInstaller(bundle, options))
|
||||||
|
await execute('bash', ['-n', scriptPath])
|
||||||
|
const { stdout: output } = await execute('bash', [scriptPath, ...(start ? [] : ['--no-start'])], { env, encoding: 'utf8' })
|
||||||
|
const agentDir = path.join(env.XDG_DATA_HOME, 'fedeo-device-agent', agentId)
|
||||||
|
const configPath = path.join(env.XDG_CONFIG_HOME, 'fedeo-device-agent', agentId + '.env')
|
||||||
|
const config = await readFile(configPath, 'utf8')
|
||||||
|
assert.ok(config.includes('FEDEO_URL=' + options.backendUrl + '\n'))
|
||||||
|
assert.ok(config.includes('FEDEO_AGENT_TOKEN=' + token + '\n'))
|
||||||
|
assert.equal((await stat(configPath)).mode & 0o777, 0o600)
|
||||||
|
assert.ok(!output.includes(token))
|
||||||
|
for (const [file, encoded] of Object.entries(bundle.files)) {
|
||||||
|
assert.equal((await readFile(path.join(agentDir, file))).toString('base64'), encoded)
|
||||||
|
}
|
||||||
|
const unitPath = path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service')
|
||||||
|
const unit = await readFile(unitPath, 'utf8')
|
||||||
|
assert.ok(unit.includes('data with spaces %%'))
|
||||||
|
assert.ok(!unit.includes(token))
|
||||||
|
await t.test('systemd accepts the generated service, including a working directory with spaces and percent signs', { skip: !hasSystemdAnalyze }, async () => {
|
||||||
|
await execute('systemd-analyze', ['--user', 'verify', unitPath])
|
||||||
|
})
|
||||||
|
const calls = await readFile(env.FEDEO_TEST_SYSTEMD_LOG, 'utf8')
|
||||||
|
assert.equal(calls.includes('--user enable fedeo-device-agent-'), start)
|
||||||
|
assert.equal(calls.includes('--user restart fedeo-device-agent-'), start)
|
||||||
|
assert.equal(calls.includes('--user stop fedeo-device-agent-'), start)
|
||||||
|
})
|
||||||
|
}
|
||||||
184
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
184
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test, { TestContext } from "node:test"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
||||||
|
import { listScanners, runScan } from "../src/scan/sane.js"
|
||||||
|
import { commandExists, runCommand } from "../src/commands.js"
|
||||||
|
import { AgentConfig, ScanJob } from "../src/types.js"
|
||||||
|
|
||||||
|
const fakeScanner = `#!/usr/bin/env python3
|
||||||
|
import json, os, sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
args = sys.argv[1:]
|
||||||
|
Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args))
|
||||||
|
if args == ['-L']:
|
||||||
|
time.sleep(float(os.environ.get('SCAN_TEST_DISCOVERY_DELAY', '0')))
|
||||||
|
print("device " + chr(96) + "fujitsu:ScanSnap iX1600:test' is a FUJITSU scanner")
|
||||||
|
print("device " + chr(96) + "airscan:test' is a network scanner")
|
||||||
|
sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '0')))
|
||||||
|
batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None)
|
||||||
|
single = args[args.index('--output-file') + 1] if '--output-file' in args else None
|
||||||
|
count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1
|
||||||
|
for index in range(1, count + 1):
|
||||||
|
target = batch % index if batch else single
|
||||||
|
Image.new('RGB', (100 + index, 200 + index), (index * 10, 20, 30)).save(target)
|
||||||
|
if batch and os.environ.get('SCAN_TEST_EMPTY_FINAL') == '1':
|
||||||
|
Path(batch % (count + 1)).touch()
|
||||||
|
if os.environ.get('SCAN_TEST_CORRUPT') == '1':
|
||||||
|
Path(batch % 2 if batch else single).write_bytes(b'not a PNG')
|
||||||
|
if os.environ.get('SCAN_TEST_EMPTY_MIDDLE') == '1':
|
||||||
|
Path(batch % 2).write_bytes(b'')
|
||||||
|
sys.stderr.write('simulated scanner status')
|
||||||
|
sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '7' if batch else '0')))
|
||||||
|
`
|
||||||
|
|
||||||
|
const setup = async (t: TestContext) => {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-test-"))
|
||||||
|
const oldEnv = { ...process.env }
|
||||||
|
t.after(async () => {
|
||||||
|
for (const key of Object.keys(process.env)) if (!(key in oldEnv)) delete process.env[key]
|
||||||
|
Object.assign(process.env, oldEnv)
|
||||||
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
await writeFile(path.join(dir, "scanimage"), fakeScanner)
|
||||||
|
await chmod(path.join(dir, "scanimage"), 0o755)
|
||||||
|
process.env.PATH = `${dir}:${process.env.PATH}`
|
||||||
|
process.env.SCAN_TEST_CALL = path.join(dir, "call.json")
|
||||||
|
const config: AgentConfig = {
|
||||||
|
fedeoUrl: "https://example.invalid", agentToken: "test", pollSeconds: 5,
|
||||||
|
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
|
||||||
|
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
|
||||||
|
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
|
||||||
|
scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng",
|
||||||
|
postprocessPython: "python3", postprocessStrict: false,
|
||||||
|
}
|
||||||
|
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
|
||||||
|
return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("scanner discovery waits for network probes beyond the former ten-second limit", async (t) => {
|
||||||
|
await setup(t)
|
||||||
|
process.env.SCAN_TEST_DISCOVERY_DELAY = "10.2"
|
||||||
|
assert.deepEqual(await listScanners(), ["fujitsu:ScanSnap iX1600:test", "airscan:test"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("failed scanner discovery does not advertise devices from an unsuccessful command", async (t) => {
|
||||||
|
await setup(t)
|
||||||
|
process.env.SCAN_TEST_EXIT = "1"
|
||||||
|
assert.deepEqual(await listScanners(), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => {
|
||||||
|
const { config, job, args } = await setup(t)
|
||||||
|
process.env.SCAN_TEST_EMPTY_FINAL = "1"
|
||||||
|
const progress: string[] = []
|
||||||
|
const result = await runScan(config, job, async (message) => { progress.push(message) })
|
||||||
|
assert.equal(result.pageCount, 4)
|
||||||
|
assert.equal(result.mimeType, "application/pdf")
|
||||||
|
assert.equal((await readFile(result.path)).subarray(0, 5).toString(), "%PDF-")
|
||||||
|
assert.deepEqual(result.warnings, [])
|
||||||
|
const call = await args()
|
||||||
|
assert.ok(call.includes("--batch=page-%06d.png"))
|
||||||
|
assert.ok(!call.includes("--output-file"))
|
||||||
|
assert.ok(!call.includes("--batch-double"))
|
||||||
|
assert.equal(call[call.indexOf("--source") + 1], "ADF Duplex")
|
||||||
|
assert.equal(call[call.indexOf("--page-height") + 1], "297")
|
||||||
|
assert.equal(call[call.indexOf("-y") + 1], "297")
|
||||||
|
assert.ok(progress.at(-1)?.startsWith("4 Seiten"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("empty ADF reports missing paper instead of creating an empty PDF", async (t) => {
|
||||||
|
const { config, job } = await setup(t)
|
||||||
|
process.env.SCAN_TEST_PAGES = "0"
|
||||||
|
process.env.SCAN_TEST_EMPTY_FINAL = "1"
|
||||||
|
await assert.rejects(runScan(config, job), /Keine Seiten gescannt/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("paper jam after partial capture fails and keeps raw pages for recovery", async (t) => {
|
||||||
|
const { config, job } = await setup(t)
|
||||||
|
process.env.SCAN_TEST_EXIT = "6"
|
||||||
|
await assert.rejects(runScan(config, job), /simulated scanner status.*Rohscans/)
|
||||||
|
const [jobDir] = await readdir(config.workDir)
|
||||||
|
assert.equal((await readdir(path.join(config.workDir, jobDir))).filter((name) => name.endsWith(".png")).length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("odd duplex page count cannot silently lose a back side", async (t) => {
|
||||||
|
const { config, job } = await setup(t)
|
||||||
|
process.env.SCAN_TEST_PAGES = "3"
|
||||||
|
await assert.rejects(runScan(config, job), /Rückseite/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ADF simplex keeps odd page counts and scanimage success is accepted", async (t) => {
|
||||||
|
const { config, job } = await setup(t)
|
||||||
|
process.env.SCAN_TEST_PAGES = "3"
|
||||||
|
process.env.SCAN_TEST_EXIT = "0"
|
||||||
|
job.settings!.source = "ADF Front"
|
||||||
|
assert.equal((await runScan(config, job)).pageCount, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("corrupt or empty interior pages fail instead of disappearing from a successful PDF", async (t) => {
|
||||||
|
const { config, job } = await setup(t)
|
||||||
|
process.env.SCAN_TEST_CORRUPT = "1"
|
||||||
|
await assert.rejects(runScan(config, job), /cannot identify image file/)
|
||||||
|
delete process.env.SCAN_TEST_CORRUPT
|
||||||
|
process.env.SCAN_TEST_EMPTY_MIDDLE = "1"
|
||||||
|
await assert.rejects(runScan(config, job), /Unvollständige Scan-Seite/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-PDF feeder output and invalid resolution are rejected before feeding paper", async (t) => {
|
||||||
|
const { config, job, args } = await setup(t)
|
||||||
|
job.settings!.format = "png"
|
||||||
|
await assert.rejects(runScan(config, job), /PDF/)
|
||||||
|
delete job.settings!.format
|
||||||
|
job.settings!.resolution = -300
|
||||||
|
await assert.rejects(runScan(config, job), /Auflösung/)
|
||||||
|
await assert.rejects(args(), /ENOENT/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("missing image runtime is detected before feeding paper", async (t) => {
|
||||||
|
const { config, job, args } = await setup(t)
|
||||||
|
config.postprocessPython = "/not-installed/python"
|
||||||
|
await assert.rejects(runScan(config, job), /Python\/Pillow/)
|
||||||
|
await assert.rejects(args(), /ENOENT/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("missing requested OCR language fails before the scanner consumes paper", async (t) => {
|
||||||
|
const { config, job, dir, args } = await setup(t)
|
||||||
|
const tesseract = path.join(dir, "tesseract")
|
||||||
|
await writeFile(tesseract, "#!/bin/sh\nprintf 'List of available languages (1):\\neng\\n'\n")
|
||||||
|
await chmod(tesseract, 0o755)
|
||||||
|
job.settings!.ocr = true
|
||||||
|
job.settings!.ocrLanguage = "deu+eng"
|
||||||
|
await assert.rejects(runScan(config, job), /OCR-Sprachpakete fehlen: deu/)
|
||||||
|
await assert.rejects(args(), /ENOENT/)
|
||||||
|
await assert.rejects(readdir(config.workDir), /ENOENT/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
|
||||||
|
const { config, job, args } = await setup(t)
|
||||||
|
job.settings = { source: "Flatbed", format: "png" }
|
||||||
|
job.requestedFilename = "../../outside.pdf"
|
||||||
|
const first = await runScan(config, job)
|
||||||
|
const second = await runScan(config, job)
|
||||||
|
assert.equal(first.filename, "outside.png")
|
||||||
|
assert.equal(first.pageCount, 1)
|
||||||
|
assert.equal(first.mimeType, "image/png")
|
||||||
|
assert.ok(first.path.startsWith(`${config.workDir}${path.sep}`))
|
||||||
|
assert.notEqual(path.dirname(first.path), path.dirname(second.path))
|
||||||
|
assert.ok((await args()).includes("--output-file"))
|
||||||
|
assert.ok(!(await args()).some((arg) => arg.startsWith("--batch")))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a process terminated by a signal is never reported as a successful scan", async () => {
|
||||||
|
await assert.rejects(runCommand(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"]), /SIGTERM/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a scan timeout terminates the child and reports failure", async () => {
|
||||||
|
await assert.rejects(runCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 50 }), /beendet/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("command discovery treats executable paths as data", async () => {
|
||||||
|
assert.equal(await commandExists("python3; exit 0"), false)
|
||||||
|
})
|
||||||
64
agents/fedeo-device-agent/tests/scan_background_test.py
Normal file
64
agents/fedeo-device-agent/tests/scan_background_test.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||||
|
from scan_background import whiten_background
|
||||||
|
|
||||||
|
|
||||||
|
class ScanBackgroundTest(unittest.TestCase):
|
||||||
|
def receipt(self, background=45, paper=245):
|
||||||
|
image = np.full((1600, 1200, 3), background, dtype=np.uint8)
|
||||||
|
image[120:1440, 360:820] = paper
|
||||||
|
for y in range(220, 1350, 70):
|
||||||
|
cv2.putText(image, "BON 12,34 EUR", (375, y), cv2.FONT_HERSHEY_SIMPLEX, .7, (20, 20, 20), 2)
|
||||||
|
cv2.rectangle(image, (400, 140), (780, 180), (40, 80, 210), -1)
|
||||||
|
return image
|
||||||
|
|
||||||
|
def test_receipt_on_dark_or_gray_backing_keeps_canvas_text_and_colored_logo(self):
|
||||||
|
for background in (35, 140):
|
||||||
|
image = self.receipt(background)
|
||||||
|
result = whiten_background(image)
|
||||||
|
self.assertEqual(result.shape, image.shape)
|
||||||
|
self.assertTrue(np.all(result[:80] == 255))
|
||||||
|
self.assertTrue(np.all(result[:, :250] == 255))
|
||||||
|
self.assertTrue(np.array_equal(result[120:1440, 360:820], image[120:1440, 360:820]))
|
||||||
|
|
||||||
|
def test_slanted_receipt_is_whitened_without_rotating_or_cropping(self):
|
||||||
|
image = np.full((1600, 1200, 3), 55, dtype=np.uint8)
|
||||||
|
polygon = np.array([[450, 100], [890, 155], [720, 1450], [280, 1390]])
|
||||||
|
cv2.fillPoly(image, [polygon], (245, 245, 245))
|
||||||
|
cv2.putText(image, "FEDEO", (470, 700), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 0), 3)
|
||||||
|
result = whiten_background(image)
|
||||||
|
self.assertEqual(result.shape, image.shape)
|
||||||
|
inside = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||||
|
cv2.fillPoly(inside, [polygon], 255)
|
||||||
|
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
|
||||||
|
self.assertTrue(np.all(result[:50] == 255))
|
||||||
|
|
||||||
|
def test_white_full_page_is_not_replaced_by_its_largest_text_box(self):
|
||||||
|
image = np.full((1600, 1200, 3), 255, dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (300, 400), (800, 1200), (30, 30, 30), 4)
|
||||||
|
cv2.putText(image, "Footer", (20, 1550), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
|
||||||
|
self.assertTrue(np.array_equal(whiten_background(image), image))
|
||||||
|
|
||||||
|
def test_slanted_receipt_retains_every_paper_edge_pixel_at_300_dpi(self):
|
||||||
|
image = np.full((3508, 2480, 3), 45, dtype=np.uint8)
|
||||||
|
polygon = np.array([[820, 200], [1700, 340], [1250, 3300], [380, 3160]])
|
||||||
|
cv2.fillPoly(image, [polygon], (245, 245, 245))
|
||||||
|
inside = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||||
|
cv2.fillPoly(inside, [polygon], 255)
|
||||||
|
result = whiten_background(image)
|
||||||
|
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
|
||||||
|
self.assertTrue(np.all(result[:50] == 255))
|
||||||
|
|
||||||
|
def test_blank_and_ambiguous_low_contrast_pages_remain_unchanged(self):
|
||||||
|
for image in [np.full((300, 200, 3), 245, dtype=np.uint8), self.receipt(215, 230)]:
|
||||||
|
self.assertTrue(np.array_equal(whiten_background(image), image))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
93
agents/fedeo-device-agent/tests/scan_document_test.py
Normal file
93
agents/fedeo-device-agent/tests/scan_document_test.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image, PdfParser
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||||
|
from scan_document import create_document
|
||||||
|
|
||||||
|
|
||||||
|
class ScanDocumentTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
|
||||||
|
def pages(self, count=4):
|
||||||
|
paths = []
|
||||||
|
for index in range(1, count + 1):
|
||||||
|
target = self.root / f"page-{index:06}.png"
|
||||||
|
# Distinct geometry identifies every front/back page and its order.
|
||||||
|
with Image.new("RGB", (100 + index, 200 + index), "white") as image:
|
||||||
|
image.save(target)
|
||||||
|
paths.append(target)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def assert_pdf(self, output, count, resolution=300):
|
||||||
|
with PdfParser.PdfParser(filename=str(output)) as pdf:
|
||||||
|
self.assertEqual(len(pdf.pages), count)
|
||||||
|
for index, reference in enumerate(pdf.pages, 1):
|
||||||
|
page = pdf.read_indirect(reference)
|
||||||
|
box = page[b"MediaBox"]
|
||||||
|
self.assertAlmostEqual(box[2], (100 + index) * 72 / resolution, places=4)
|
||||||
|
self.assertAlmostEqual(box[3], (200 + index) * 72 / resolution, places=4)
|
||||||
|
|
||||||
|
def test_twelve_pages_keep_front_back_order_blank_pages_and_scan_resolution(self):
|
||||||
|
output = self.root / "scan.pdf"
|
||||||
|
result = create_document(self.pages(12), output, 150)
|
||||||
|
self.assertEqual(result, {"pageCount": 12, "warnings": []})
|
||||||
|
self.assert_pdf(output, 12, 150)
|
||||||
|
|
||||||
|
def test_missing_opencv_still_creates_complete_pdf(self):
|
||||||
|
output = self.root / "scan.pdf"
|
||||||
|
with patch.dict(sys.modules, {"opencv_postprocess": None}):
|
||||||
|
result = create_document(self.pages(), output, 300, use_postprocess=True)
|
||||||
|
self.assertEqual(len(result["warnings"]), 1)
|
||||||
|
self.assert_pdf(output, 4)
|
||||||
|
|
||||||
|
def test_failed_correction_falls_back_per_page_without_losing_sides(self):
|
||||||
|
def correct(source, target, profile):
|
||||||
|
if source.name.endswith("000002.png"):
|
||||||
|
raise RuntimeError("correction failed")
|
||||||
|
target.write_bytes(source.read_bytes())
|
||||||
|
|
||||||
|
output = self.root / "scan.pdf"
|
||||||
|
with patch.dict(sys.modules, {"opencv_postprocess": SimpleNamespace(postprocess=correct)}):
|
||||||
|
result = create_document(self.pages(), output, 300, use_postprocess=True)
|
||||||
|
self.assertEqual(len(result["warnings"]), 1)
|
||||||
|
self.assertIn("Seite 2", result["warnings"][0])
|
||||||
|
self.assert_pdf(output, 4)
|
||||||
|
|
||||||
|
def test_strict_mode_does_not_silently_skip_missing_correction(self):
|
||||||
|
output = self.root / "scan.pdf"
|
||||||
|
with patch.dict(sys.modules, {"opencv_postprocess": None}):
|
||||||
|
with self.assertRaises(ImportError):
|
||||||
|
create_document(self.pages(), output, 300, use_postprocess=True, strict=True)
|
||||||
|
self.assertFalse(output.exists())
|
||||||
|
|
||||||
|
def test_corrupt_page_never_publishes_partial_pdf(self):
|
||||||
|
pages = self.pages()
|
||||||
|
pages[2].write_bytes(b"corrupt")
|
||||||
|
output = self.root / "scan.pdf"
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
create_document(pages, output, 300)
|
||||||
|
self.assertFalse(output.exists())
|
||||||
|
self.assertFalse((self.root / "scan.pdf.partial").exists())
|
||||||
|
self.assertTrue(all(page.exists() for page in pages))
|
||||||
|
|
||||||
|
def test_single_page_png_and_tiff_remain_supported(self):
|
||||||
|
pages = self.pages(1)
|
||||||
|
for extension in ["png", "tiff"]:
|
||||||
|
output = self.root / f"scan.{extension}"
|
||||||
|
create_document(pages, output, 150)
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (101, 201))
|
||||||
|
self.assertAlmostEqual(image.info["dpi"][0], 150, delta=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
71
agents/fedeo-device-agent/tests/scan_ocr_test.py
Normal file
71
agents/fedeo-device-agent/tests/scan_ocr_test.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont, PdfParser
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||||
|
from scan_document import create_document, check_ocr
|
||||||
|
|
||||||
|
|
||||||
|
class ScanOcrTest(unittest.TestCase):
|
||||||
|
def test_missing_language_is_reported_before_processing(self):
|
||||||
|
with patch("scan_document.run_ocr_command", return_value="List of available languages (1):\neng\n"):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "deu"):
|
||||||
|
check_ocr("deu+eng")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
check_ocr("--bad-language")
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("tesseract") and shutil.which("pdftotext"), "Tesseract and Poppler required")
|
||||||
|
def test_ocr_keeps_receipt_canvas_blank_back_and_page_order_with_real_extractable_text(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
pages = []
|
||||||
|
for index, text in enumerate(["FEDEO Bon 12345", "", "Zweite Seite 24680"]):
|
||||||
|
page = root / f"page-{index}.png"
|
||||||
|
image = Image.new("RGB", (1240, 1754), (45, 45, 45) if index == 0 else "white")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
if index == 0:
|
||||||
|
draw.rectangle((260, 150, 1000, 1550), fill="white")
|
||||||
|
if text:
|
||||||
|
draw.text((300, 300), text, fill="black", font=ImageFont.load_default(size=44))
|
||||||
|
draw.text((300, 390), "Summe EUR 12,34", fill="black", font=ImageFont.load_default(size=36))
|
||||||
|
image.save(page)
|
||||||
|
pages.append(page)
|
||||||
|
output = root / "searchable.pdf"
|
||||||
|
result = create_document(pages, output, 150, ocr=True, background_cleanup=True)
|
||||||
|
self.assertEqual(result["pageCount"], 3)
|
||||||
|
with PdfParser.PdfParser(filename=str(output)) as pdf:
|
||||||
|
self.assertEqual(len(pdf.pages), 3)
|
||||||
|
for reference in pdf.pages:
|
||||||
|
box = pdf.read_indirect(reference)[b"MediaBox"]
|
||||||
|
self.assertAlmostEqual(box[2], 1240 * 72 / 150, delta=.1)
|
||||||
|
self.assertAlmostEqual(box[3], 1754 * 72 / 150, delta=.1)
|
||||||
|
text = subprocess.check_output(["pdftotext", str(output), "-"], text=True)
|
||||||
|
extracted = text.split("\f")
|
||||||
|
self.assertIn("12345", extracted[0])
|
||||||
|
self.assertEqual(extracted[1].strip(), "")
|
||||||
|
self.assertIn("24680", extracted[2])
|
||||||
|
self.assertTrue(all(page.exists() for page in pages))
|
||||||
|
|
||||||
|
def test_ocr_failure_never_publishes_a_partial_document_or_removes_raw_pages(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
page = root / "raw.png"
|
||||||
|
Image.new("RGB", (400, 600), "white").save(page)
|
||||||
|
output = root / "failed.pdf"
|
||||||
|
with patch("scan_document.check_ocr"), patch("scan_document.run_ocr_command", side_effect=RuntimeError("OCR failed")):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OCR failed"):
|
||||||
|
create_document([page], output, 300, ocr=True)
|
||||||
|
self.assertFalse(output.exists())
|
||||||
|
self.assertFalse(output.with_name("failed.pdf.partial").exists())
|
||||||
|
self.assertTrue(page.exists())
|
||||||
|
self.assertEqual(list(root.glob("prepared-*")), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"typeRoots": ["../../backend/node_modules/@types"],
|
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -13,15 +13,18 @@ RUN apt-get update \
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Dev + Prod Dependencies (für TS-Build nötig).
|
# Dev + Prod Dependencies (für TS-Build nötig).
|
||||||
# Sharp benötigt im Linux-Container native optionale Pakete, auch wenn das Lockfile auf macOS erzeugt wurde.
|
# Optionale native Linux-Pakete werden aus dem Lockfile installiert.
|
||||||
RUN npm install --include=optional \
|
RUN npm ci --include=optional
|
||||||
&& npm install --include=optional --os=linux --cpu=x64 sharp
|
|
||||||
|
|
||||||
# Restlicher Sourcecode
|
# Restlicher Sourcecode
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# TypeScript Build
|
# TypeScript Build
|
||||||
RUN npm run build
|
ARG FEDEO_VERSION=0.0.0-dev
|
||||||
|
ARG FEDEO_REVISION=unknown
|
||||||
|
ENV FEDEO_VERSION=$FEDEO_VERSION
|
||||||
|
ENV FEDEO_REVISION=$FEDEO_REVISION
|
||||||
|
RUN npm run build && node --import tsx --test tests/*.test.ts
|
||||||
|
|
||||||
# Port freigeben
|
# Port freigeben
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "statementallocations"
|
||||||
|
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");
|
||||||
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- Forward repair: 0051-0053 were shipped without migration journal entries.
|
||||||
|
-- Keep existing agents/jobs when these SQL files were applied manually.
|
||||||
|
CREATE TABLE IF NOT EXISTS "instance_agents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"token_prefix" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
|
||||||
|
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"last_debug_info" jsonb,
|
||||||
|
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"agent_id" uuid NOT NULL,
|
||||||
|
"requested_by" uuid,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"scanner_name" text,
|
||||||
|
"requested_filename" text,
|
||||||
|
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"agent_message" text,
|
||||||
|
"attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"claimed_at" timestamp with time zone,
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"file_id" uuid,
|
||||||
|
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
|
||||||
|
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
|
||||||
|
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
|
||||||
|
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;
|
||||||
@@ -477,6 +477,20 @@
|
|||||||
"when": 1788850800000,
|
"when": 1788850800000,
|
||||||
"tag": "0070_native_communication_chat",
|
"tag": "0070_native_communication_chat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790121600000,
|
||||||
|
"tag": "0071_statement_allocation_costcentre",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 69,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790121600001,
|
||||||
|
"tag": "0072_restore_instance_scan_agents",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices"
|
|||||||
import { createddocuments } from "./createddocuments"
|
import { createddocuments } from "./createddocuments"
|
||||||
import { bankstatements } from "./bankstatements"
|
import { bankstatements } from "./bankstatements"
|
||||||
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
|
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
|
||||||
|
import { costcentres } from "./costcentres"
|
||||||
|
|
||||||
export const statementallocations = pgTable("statementallocations", {
|
export const statementallocations = pgTable("statementallocations", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
|
|
||||||
manualBookingDate: text("manual_booking_date"),
|
manualBookingDate: text("manual_booking_date"),
|
||||||
datevTaxKey: text("datev_tax_key"),
|
datevTaxKey: text("datev_tax_key"),
|
||||||
|
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||||
|
|
||||||
bookingMode: text("booking_mode").notNull().default("expense"),
|
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||||
depreciationMonths: integer("depreciation_months"),
|
depreciationMonths: integer("depreciation_months"),
|
||||||
|
|||||||
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
@@ -5794,6 +5794,44 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@infisical/sdk": {
|
"node_modules/@infisical/sdk": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@infisical/sdk/-/sdk-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@infisical/sdk/-/sdk-4.0.6.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import authRoutes from "./routes/auth/auth";
|
|||||||
import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
|
import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
|
||||||
import authPlugin from "./plugins/auth";
|
import authPlugin from "./plugins/auth";
|
||||||
import adminRoutes from "./routes/admin";
|
import adminRoutes from "./routes/admin";
|
||||||
|
import selfhostUpdateRoutes from "./routes/selfhostUpdates";
|
||||||
import corsPlugin from "./plugins/cors";
|
import corsPlugin from "./plugins/cors";
|
||||||
import queryConfigPlugin from "./plugins/queryconfig";
|
import queryConfigPlugin from "./plugins/queryconfig";
|
||||||
import dbPlugin from "./plugins/db";
|
import dbPlugin from "./plugins/db";
|
||||||
@@ -98,10 +99,6 @@ async function main() {
|
|||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/health', async (req, res) => {
|
|
||||||
return res.send({ status: 'ok' })
|
|
||||||
})
|
|
||||||
|
|
||||||
//Plugin nur auf bestimmten Routes
|
//Plugin nur auf bestimmten Routes
|
||||||
await app.register(queryConfigPlugin, {
|
await app.register(queryConfigPlugin, {
|
||||||
routes: ['/api/resource/:resource/paginated']
|
routes: ['/api/resource/:resource/paginated']
|
||||||
@@ -147,6 +144,7 @@ async function main() {
|
|||||||
await subApp.register(meRoutes);
|
await subApp.register(meRoutes);
|
||||||
await subApp.register(tenantRoutes);
|
await subApp.register(tenantRoutes);
|
||||||
await subApp.register(adminRoutes);
|
await subApp.register(adminRoutes);
|
||||||
|
await subApp.register(selfhostUpdateRoutes);
|
||||||
await subApp.register(resourceRoutesSpecial);
|
await subApp.register(resourceRoutesSpecial);
|
||||||
await subApp.register(historyRoutes);
|
await subApp.register(historyRoutes);
|
||||||
await subApp.register(fileRoutes);
|
await subApp.register(fileRoutes);
|
||||||
|
|||||||
62
backend/src/mcp/documentSchemas.ts
Normal file
62
backend/src/mcp/documentSchemas.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const nullableText = { type: ["string", "null"] }
|
||||||
|
|
||||||
|
export const outgoingAddressSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Abweichende Empfängeradresse; Feldnamen entsprechen dem Belegeditor.",
|
||||||
|
properties: { street: nullableText, special: nullableText, zip: nullableText, city: nullableText },
|
||||||
|
additionalProperties: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const outgoingRowsSchema = {
|
||||||
|
type: "array",
|
||||||
|
description: "Belegpositionen als Objekte. Beträge numerisch in EUR, unit als Einheiten-ID. Mengen und Preise dürfen für Korrekturpositionen negativ sein.",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", format: "uuid", description: "Optionale Positions-ID; wird sonst erzeugt." },
|
||||||
|
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
||||||
|
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
||||||
|
text: { type: "string" },
|
||||||
|
descriptionText: { type: "string" },
|
||||||
|
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
||||||
|
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
||||||
|
quantity: { type: "number", default: 1 },
|
||||||
|
unit: { type: "integer", minimum: 1 },
|
||||||
|
price: { type: "number", description: "Netto-Einzelpreis vor Positionsrabatt." },
|
||||||
|
inputPrice: { type: "number", description: "Eingegebener Einzelpreis; wird ohne price auch als price verwendet." },
|
||||||
|
purchasePrice: { type: "number" },
|
||||||
|
taxPercent: { type: "number", minimum: 0, maximum: 100 },
|
||||||
|
discountPercent: { type: "number", minimum: 0, maximum: 100, default: 0 },
|
||||||
|
costCentre: { type: ["string", "null"], format: "uuid", description: "Positionskostenstelle (abweichende Schreibweise zum Belegfeld costcentre)." },
|
||||||
|
optional: { type: "boolean" },
|
||||||
|
alternative: { type: "boolean" },
|
||||||
|
titleLevel: { type: "integer", minimum: 1, maximum: 3 },
|
||||||
|
linkedEntitys: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object", properties: { type: { type: "string" }, subtype: { type: "string" }, id: { type: "integer", minimum: 1 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Preserve module-specific position fields used by existing callers.
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const outgoingReportSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Optionale, vom Aufrufer berechnete Kalkulationswerte. Diese Felder werden gespeichert, nicht durch MCP berechnet.",
|
||||||
|
properties: {
|
||||||
|
totalProductsPurchasePrice: { type: "number" },
|
||||||
|
totalProductsFromServicesPurchasePrice: { type: "number" },
|
||||||
|
totalMargin: { type: "number" },
|
||||||
|
totalHoursSellingPrice: { type: "number" },
|
||||||
|
totalHoursFromServices: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
total: { type: "number" },
|
||||||
|
totalPurchasePrice: { type: "number" },
|
||||||
|
byName: { type: "object", additionalProperties: { type: "number" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
}
|
||||||
83
backend/src/mcp/masterdataFields.ts
Normal file
83
backend/src/mcp/masterdataFields.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { getTableColumns } from "drizzle-orm"
|
||||||
|
|
||||||
|
export type InputField = {
|
||||||
|
schema: Record<string, any>
|
||||||
|
parse: (value: unknown, path: string) => any
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = (path: string, expected: string): never => { throw new Error(`${path}: ${expected}`) }
|
||||||
|
export const textField = (nonEmpty = false): InputField => ({
|
||||||
|
schema: { type: "string", ...(nonEmpty ? { minLength: 1 } : {}) },
|
||||||
|
parse: (value, path) => typeof value === "string" && (!nonEmpty || value.trim())
|
||||||
|
? value.trim() : invalid(path, "Text" + (nonEmpty ? " darf nicht leer sein" : " erwartet")),
|
||||||
|
})
|
||||||
|
export const numericField = (integer = false, minimum?: number, maximum?: number): InputField => ({
|
||||||
|
schema: { type: integer ? "integer" : "number", ...(minimum !== undefined ? { minimum } : {}), ...(maximum !== undefined ? { maximum } : {}) },
|
||||||
|
parse: (value, path) => {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || (integer && !Number.isSafeInteger(value))
|
||||||
|
|| (minimum !== undefined && value < minimum) || (maximum !== undefined && value > maximum)) {
|
||||||
|
return invalid(path, `gültige ${integer ? "ganze " : ""}Zahl im angegebenen Bereich erwartet`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export const idField = numericField(true, 1)
|
||||||
|
export const uuidField: InputField = {
|
||||||
|
schema: { type: "string", format: "uuid" },
|
||||||
|
parse: (value, path) => typeof value === "string" && /^[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.toLowerCase() : invalid(path, "gültige UUID erwartet"),
|
||||||
|
}
|
||||||
|
export const booleanField: InputField = {
|
||||||
|
schema: { type: "boolean" },
|
||||||
|
parse: (value, path) => typeof value === "boolean" ? value : invalid(path, "Boolean erwartet"),
|
||||||
|
}
|
||||||
|
export const dateField: InputField = {
|
||||||
|
schema: { type: "string", format: "date", description: "Kalenderdatum YYYY-MM-DD." },
|
||||||
|
parse: (value, path) => {
|
||||||
|
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return invalid(path, "Datum YYYY-MM-DD erwartet")
|
||||||
|
const date = new Date(`${value}T00:00:00Z`)
|
||||||
|
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) return invalid(path, "ungültiges Kalenderdatum")
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export const nullable = (field: InputField): InputField => ({
|
||||||
|
schema: { ...field.schema, type: [field.schema.type, "null"] },
|
||||||
|
parse: (value, path) => value === null ? null : field.parse(value, path),
|
||||||
|
})
|
||||||
|
export const arrayField = (item: InputField): InputField => ({
|
||||||
|
schema: { type: "array", items: item.schema },
|
||||||
|
parse: (value, path) => Array.isArray(value) ? value.map((entry, index) => item.parse(entry, `${path}[${index}]`)) : invalid(path, "Array erwartet"),
|
||||||
|
})
|
||||||
|
export const objectField = (fields: Record<string, InputField>, additionalProperties = false): InputField => ({
|
||||||
|
schema: { type: "object", properties: Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema])), additionalProperties },
|
||||||
|
parse: (value, path) => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(path, "Objekt erwartet")
|
||||||
|
const entries = Object.entries(value).map(([key, entry]) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(fields, key)) return [key, fields[key].parse(entry, `${path}.${key}`)]
|
||||||
|
if (!additionalProperties || ["__proto__", "constructor", "prototype"].includes(key)) return invalid(`${path}.${key}`, "unbekanntes Feld")
|
||||||
|
return [key, entry]
|
||||||
|
})
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Explicit allowlist; scalar types and nullability follow the persisted columns. */
|
||||||
|
export function tableFields(table: any, names: string, overrides: Record<string, InputField> = {}) {
|
||||||
|
const columns = getTableColumns(table) as Record<string, any>
|
||||||
|
return Object.fromEntries(names.split(/\s+/).filter(Boolean).map((name) => {
|
||||||
|
const column = columns[name]
|
||||||
|
if (!column) throw new Error(`Unbekanntes Stammdatenfeld ${name}`)
|
||||||
|
let field = overrides[name]
|
||||||
|
if (!field) {
|
||||||
|
if (column.columnType === "PgUUID") field = uuidField
|
||||||
|
else if (column.columnType === "PgDateString") field = dateField
|
||||||
|
else if (column.dataType === "string") field = textField(name === "name")
|
||||||
|
else if (column.dataType === "boolean") field = booleanField
|
||||||
|
else if (column.dataType === "number") {
|
||||||
|
field = column.columnType === "PgSmallInt" ? numericField(true, -32768, 32767)
|
||||||
|
: numericField(column.columnType !== "PgDoublePrecision")
|
||||||
|
} else throw new Error(`Explizites Eingabeschema für ${name} erforderlich`)
|
||||||
|
}
|
||||||
|
return [name, column.notNull ? field : nullable(field)]
|
||||||
|
}))
|
||||||
|
}
|
||||||
31
backend/src/mcp/pagination.ts
Normal file
31
backend/src/mcp/pagination.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** Offset pagination for a stable dataset; fetch one extra row to detect the next page. */
|
||||||
|
export const paginationProperties = {
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 100, default: 25 },
|
||||||
|
offset: { type: "integer", minimum: 0, default: 0, description: "Startposition. Für Folgeseiten pagination.nextOffset mit denselben Filtern verwenden." },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitFromArgs(args: Record<string, unknown>, fallback = 25) {
|
||||||
|
const value = args.limit ?? fallback
|
||||||
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100) {
|
||||||
|
throw new Error("limit muss eine ganze Zahl zwischen 1 und 100 sein")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offsetFromArgs(args: Record<string, unknown>) {
|
||||||
|
const value = args.offset ?? 0
|
||||||
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new Error("offset muss eine nicht negative ganze Zahl sein")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginatedResult<T>(rows: T[], args: Record<string, unknown>, fallback = 25) {
|
||||||
|
const limit = limitFromArgs(args, fallback)
|
||||||
|
const offset = offsetFromArgs(args)
|
||||||
|
const hasMore = rows.length > limit
|
||||||
|
return {
|
||||||
|
rows: rows.slice(0, limit),
|
||||||
|
pagination: { limit, offset, hasMore, nextOffset: hasMore ? offset + limit : null },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,42 @@
|
|||||||
import { McpToolResult } from "./types"
|
import { McpToolResult } from "./types"
|
||||||
|
|
||||||
|
const OMIT_ARCHIVED = Symbol("omit-archived")
|
||||||
|
|
||||||
|
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map(omitArchivedRecords)
|
||||||
|
.filter((item) => item !== OMIT_ARCHIVED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object") return value
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
if (prototype !== Object.prototype && prototype !== null) return value
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
if (record.archived === true) return OMIT_ARCHIVED
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(record)
|
||||||
|
.map(([key, item]) => [key, omitArchivedRecords(item)] as const)
|
||||||
|
.filter(([, item]) => item !== OMIT_ARCHIVED),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function asToolResult(payload: unknown): McpToolResult {
|
export function asToolResult(payload: unknown): McpToolResult {
|
||||||
|
const sanitizedPayload = omitArchivedRecords(payload)
|
||||||
|
const resultPayload = sanitizedPayload === OMIT_ARCHIVED ? {} : sanitizedPayload
|
||||||
const structuredContent =
|
const structuredContent =
|
||||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
resultPayload && typeof resultPayload === "object" && !Array.isArray(resultPayload)
|
||||||
? payload as Record<string, unknown>
|
? resultPayload as Record<string, unknown>
|
||||||
: { result: payload }
|
: { result: resultPayload }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(payload, null, 2),
|
text: JSON.stringify(resultPayload, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
@@ -33,4 +59,3 @@ export function asToolError(error: unknown): McpToolResult {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details"
|
||||||
|
|
||||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||||
|
|
||||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
@@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
|||||||
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
|
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const details = normalizeBankAllocationDetails({ ...payload, costcentre: args.costcentre })
|
||||||
|
if (args.costcentre !== undefined) payload.costcentre = details.costcentre
|
||||||
|
if (args.datevTaxKey !== undefined) payload.datevTaxKey = details.datevTaxKey
|
||||||
|
|
||||||
if (bookingMode === "expense") return payload
|
if (bookingMode === "expense") return payload
|
||||||
|
|
||||||
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
@@ -10,12 +13,15 @@ import {
|
|||||||
folders,
|
folders,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
ownaccounts,
|
ownaccounts,
|
||||||
|
serialExecutions,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
vendors,
|
vendors,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||||
import { saveFile } from "../../utils/files"
|
import { saveFile } from "../../utils/files"
|
||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||||
import {
|
import {
|
||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
@@ -24,11 +30,6 @@ import { McpTool } from "../types"
|
|||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
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 stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
@@ -43,6 +44,22 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
|||||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||||
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
const serialConfigSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Ausführungsplan einer Serienrechnungsvorlage.",
|
||||||
|
properties: {
|
||||||
|
firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." },
|
||||||
|
executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." },
|
||||||
|
intervall: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"],
|
||||||
|
},
|
||||||
|
active: { type: "boolean" },
|
||||||
|
dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const allowedOutgoingDocumentTypes = new Set([
|
const allowedOutgoingDocumentTypes = new Set([
|
||||||
"quotes",
|
"quotes",
|
||||||
@@ -390,6 +407,7 @@ const assertTenantEntityExists = async (
|
|||||||
|
|
||||||
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
|
||||||
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
||||||
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
||||||
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
|
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
|
||||||
@@ -437,8 +455,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -455,16 +472,17 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (state) conditions.push(eq(createddocuments.state, state))
|
if (state) conditions.push(eq(createddocuments.state, state))
|
||||||
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
||||||
if (project) conditions.push(eq(createddocuments.project, project))
|
if (project) conditions.push(eq(createddocuments.project, project))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
|
conditions.push(eq(createddocuments.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(createddocuments)
|
.from(createddocuments)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(createddocuments.createdAt))
|
.orderBy(desc(createddocuments.createdAt), createddocuments.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -486,7 +504,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(createddocuments)
|
.from(createddocuments)
|
||||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||||
@@ -502,7 +520,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["type"],
|
required: ["type"],
|
||||||
properties: {
|
properties: {
|
||||||
type: { type: "string" },
|
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
contact: { type: "number" },
|
contact: { type: "number" },
|
||||||
contract: { type: "number" },
|
contract: { type: "number" },
|
||||||
@@ -524,12 +542,13 @@ export const accountingTools: McpTool[] = [
|
|||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
startText: { type: "string" },
|
startText: { type: "string" },
|
||||||
endText: { type: "string" },
|
endText: { type: "string" },
|
||||||
address: { type: "object" },
|
address: outgoingAddressSchema,
|
||||||
rows: { type: "array" },
|
rows: outgoingRowsSchema,
|
||||||
letterhead: { type: "number" },
|
letterhead: { type: "number" },
|
||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
customSurchargePercentage: { type: "number" },
|
customSurchargePercentage: { type: "number" },
|
||||||
report: { type: "object" },
|
report: outgoingReportSchema,
|
||||||
|
serialConfig: serialConfigSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -556,7 +575,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
required: ["id"],
|
required: ["id"],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
type: { type: "string" },
|
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||||
state: { type: "string" },
|
state: { type: "string" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
contact: { type: "number" },
|
contact: { type: "number" },
|
||||||
@@ -579,12 +598,13 @@ export const accountingTools: McpTool[] = [
|
|||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
startText: { type: "string" },
|
startText: { type: "string" },
|
||||||
endText: { type: "string" },
|
endText: { type: "string" },
|
||||||
address: { type: "object" },
|
address: outgoingAddressSchema,
|
||||||
rows: { type: "array" },
|
rows: outgoingRowsSchema,
|
||||||
letterhead: { type: "number" },
|
letterhead: { type: "number" },
|
||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
customSurchargePercentage: { type: "number" },
|
customSurchargePercentage: { type: "number" },
|
||||||
report: { type: "object" },
|
report: outgoingReportSchema,
|
||||||
|
serialConfig: serialConfigSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -614,6 +634,72 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document: updated }
|
return { document: updated }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.outgoing_documents.cost_centres.update",
|
||||||
|
title: "Kostenstellen eines Ausgangsbelegs ändern",
|
||||||
|
description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
anyOf: [
|
||||||
|
{ required: ["costcentre"] },
|
||||||
|
{ required: ["rowCostCentres"] },
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." },
|
||||||
|
rowCostCentres: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["rowId", "costcentre"],
|
||||||
|
properties: {
|
||||||
|
rowId: { type: "string", description: "ID der Belegposition." },
|
||||||
|
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const input: {
|
||||||
|
costcentre?: string | null
|
||||||
|
rowCostCentres?: Array<{ rowId: string, costcentre: string | null }>
|
||||||
|
} = {}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(args, "costcentre")) {
|
||||||
|
const costcentre = args.costcentre
|
||||||
|
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||||
|
throw new Error("costcentre muss eine gültige UUID oder null sein")
|
||||||
|
}
|
||||||
|
input.costcentre = costcentre as string | null
|
||||||
|
}
|
||||||
|
if (args.rowCostCentres !== undefined) {
|
||||||
|
if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein")
|
||||||
|
input.rowCostCentres = args.rowCostCentres.map((assignment: any) => {
|
||||||
|
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||||
|
const costcentre = assignment?.costcentre
|
||||||
|
if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId")
|
||||||
|
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||||
|
throw new Error(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||||
|
}
|
||||||
|
return { rowId, costcentre }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await updateOutgoingDocumentCostCentres(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
context.userId,
|
||||||
|
id,
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
return { document }
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.finalize",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
title: "Ausgangsbeleg finalisieren",
|
||||||
@@ -634,8 +720,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
||||||
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
||||||
},
|
},
|
||||||
rows: { type: "array" },
|
rows: outgoingRowsSchema,
|
||||||
report: { type: "object" },
|
report: outgoingReportSchema,
|
||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -669,6 +755,91 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document: updated }
|
return { document: updated }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.serial_invoices.execute",
|
||||||
|
title: "Serienrechnungslauf starten",
|
||||||
|
description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["executionDate", "templateIds"],
|
||||||
|
properties: {
|
||||||
|
executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." },
|
||||||
|
templateIds: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
uniqueItems: true,
|
||||||
|
items: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const executionDate = stringArg(args, "executionDate")
|
||||||
|
const parsedExecutionDate = executionDate ? new Date(executionDate) : null
|
||||||
|
const templateIds = Array.isArray(args.templateIds)
|
||||||
|
? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))]
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) {
|
||||||
|
throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein")
|
||||||
|
}
|
||||||
|
if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten")
|
||||||
|
|
||||||
|
return executeManualGeneration(
|
||||||
|
context.server,
|
||||||
|
parsedExecutionDate,
|
||||||
|
templateIds,
|
||||||
|
context.tenantId,
|
||||||
|
context.userId,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.serial_invoice_executions.list",
|
||||||
|
title: "Serienrechnungsläufe auflisten",
|
||||||
|
description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["draft", "completed", "error"] },
|
||||||
|
...paginationProperties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(serialExecutions.tenant, context.tenantId)]
|
||||||
|
const status = stringArg(args, "status")
|
||||||
|
if (status) conditions.push(eq(serialExecutions.status, status))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(serialExecutions)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(serialExecutions.createdAt), serialExecutions.id)
|
||||||
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
|
return paginatedResult(rows, args)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.serial_invoice_executions.finish",
|
||||||
|
title: "Serienrechnungslauf abschließen",
|
||||||
|
description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "UUID des Serienrechnungslaufs." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = stringArg(args, "id")
|
||||||
|
if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein")
|
||||||
|
return finishManualGeneration(context.server, id, context.tenantId)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.archive",
|
name: "accounting.outgoing_documents.archive",
|
||||||
title: "Ausgangsbeleg archivieren",
|
title: "Ausgangsbeleg archivieren",
|
||||||
@@ -708,7 +879,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -738,10 +909,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(accounts)
|
.from(accounts)
|
||||||
.where(whereCond)
|
.where(whereCond)
|
||||||
.orderBy(accounts.number)
|
.orderBy(accounts.number, accounts.id)
|
||||||
.limit(limit)
|
.limit(limit + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { accountChart, rows }
|
return { accountChart, ...paginatedResult(rows, args) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -754,8 +926,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
state: { type: "string", description: "Optionaler Statusfilter." },
|
state: { type: "string", description: "Optionaler Statusfilter." },
|
||||||
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -764,16 +935,17 @@ export const accountingTools: McpTool[] = [
|
|||||||
|
|
||||||
if (state) conditions.push(eq(incominginvoices.state, state))
|
if (state) conditions.push(eq(incominginvoices.state, state))
|
||||||
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
|
conditions.push(eq(incominginvoices.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(incominginvoices)
|
.from(incominginvoices)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(incominginvoices.createdAt))
|
.orderBy(desc(incominginvoices.createdAt), incominginvoices.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -795,7 +967,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(incominginvoices)
|
.from(incominginvoices)
|
||||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId), eq(incominginvoices.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
||||||
@@ -812,6 +984,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
required: ["id"],
|
required: ["id"],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -826,9 +999,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
eq(files.tenant, context.tenantId),
|
eq(files.tenant, context.tenantId),
|
||||||
eq(files.archived, false)
|
eq(files.archived, false)
|
||||||
))
|
))
|
||||||
.orderBy(desc(files.createdAt))
|
.orderBy(desc(files.createdAt), files.id)
|
||||||
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1142,8 +1317,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -1151,16 +1325,17 @@ export const accountingTools: McpTool[] = [
|
|||||||
const account = numberArg(args, "account")
|
const account = numberArg(args, "account")
|
||||||
|
|
||||||
if (account) conditions.push(eq(bankstatements.account, account))
|
if (account) conditions.push(eq(bankstatements.account, account))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
|
conditions.push(eq(bankstatements.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(bankstatements)
|
.from(bankstatements)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
|
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1182,7 +1357,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(bankstatements)
|
.from(bankstatements)
|
||||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
|
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId), eq(bankstatements.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
||||||
@@ -1199,8 +1374,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
bankstatement: { type: "number" },
|
bankstatement: { type: "number" },
|
||||||
incominginvoice: { type: "number" },
|
incominginvoice: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -1210,16 +1384,17 @@ export const accountingTools: McpTool[] = [
|
|||||||
|
|
||||||
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
||||||
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
|
conditions.push(eq(statementallocations.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(statementallocations.created_at))
|
.orderBy(desc(statementallocations.created_at), statementallocations.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1246,7 +1421,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
|
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
|
||||||
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
|
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." },
|
datevTaxKey: { type: "string", description: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." },
|
||||||
|
costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." },
|
||||||
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
|
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
|
||||||
depreciationMonths: { type: "number", minimum: 1 },
|
depreciationMonths: { type: "number", minimum: 1 },
|
||||||
depreciationStartDate: { type: "string", format: "date" },
|
depreciationStartDate: { type: "string", format: "date" },
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { masterdataMutationTools } from "./masterdataWrites"
|
||||||
|
import { organisationTools } from "./organisation"
|
||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
branches,
|
branches,
|
||||||
@@ -14,11 +17,6 @@ import {
|
|||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { McpTool } from "../types"
|
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 stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
@@ -38,6 +36,12 @@ const uuidArg = (args: Record<string, unknown>, key: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const masterdataTools: McpTool[] = [
|
export const masterdataTools: McpTool[] = [
|
||||||
|
...masterdataMutationTools,
|
||||||
|
{
|
||||||
|
...organisationTools.find((tool) => tool.name === "organisation.customers.search")!,
|
||||||
|
name: "masterdata.customers.search",
|
||||||
|
requiredPermissions: ["masterdata.customers.read"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "masterdata.customers.get",
|
name: "masterdata.customers.get",
|
||||||
title: "Kunde laden",
|
title: "Kunde laden",
|
||||||
@@ -55,7 +59,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
|
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId), eq(customers.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
||||||
@@ -71,8 +75,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -86,16 +89,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(vendors.notes, `%${query}%`)
|
ilike(vendors.notes, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
|
conditions.push(eq(vendors.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(vendors)
|
.from(vendors)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(vendors.name)
|
.orderBy(vendors.name, vendors.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,7 +119,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(vendors)
|
.from(vendors)
|
||||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
|
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId), eq(vendors.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
||||||
@@ -133,8 +137,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
vendor: { type: "number" },
|
vendor: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -157,16 +160,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (customer) conditions.push(eq(contacts.customer, customer))
|
if (customer) conditions.push(eq(contacts.customer, customer))
|
||||||
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
|
conditions.push(eq(contacts.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(contacts.fullName)
|
.orderBy(contacts.fullName, contacts.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -178,8 +182,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -197,16 +200,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(products.description, `%${query}%`)
|
ilike(products.description, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
|
conditions.push(eq(products.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(products)
|
.from(products)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(products.name)
|
.orderBy(products.name, products.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -226,7 +230,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(products)
|
.from(products)
|
||||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
|
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId), eq(products.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
||||||
@@ -242,8 +246,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -256,16 +259,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(services.description, `%${query}%`)
|
ilike(services.description, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
|
conditions.push(eq(services.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(services)
|
.from(services)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(services.name)
|
.orderBy(services.name, services.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -285,7 +289,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(services)
|
.from(services)
|
||||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
|
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId), eq(services.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
||||||
@@ -303,8 +307,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
branch: { type: "number" },
|
branch: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -322,16 +325,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (branch) conditions.push(eq(costcentres.branch, branch))
|
if (branch) conditions.push(eq(costcentres.branch, branch))
|
||||||
if (project) conditions.push(eq(costcentres.project, project))
|
if (project) conditions.push(eq(costcentres.project, project))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
|
conditions.push(eq(costcentres.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(costcentres)
|
.from(costcentres)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(costcentres.number)
|
.orderBy(costcentres.number, costcentres.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -351,7 +355,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(costcentres)
|
.from(costcentres)
|
||||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
|
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId), eq(costcentres.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
||||||
@@ -367,8 +371,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -382,16 +385,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(branches.description, `%${query}%`)
|
ilike(branches.description, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
|
conditions.push(eq(branches.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(branches)
|
.from(branches)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(branches.name)
|
.orderBy(branches.name, branches.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -404,8 +408,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||||
branch: { type: "number" },
|
branch: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -420,16 +423,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (branch) conditions.push(eq(teams.branch, branch))
|
if (branch) conditions.push(eq(teams.branch, branch))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
|
conditions.push(eq(teams.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(teams)
|
.from(teams)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(teams.name)
|
.orderBy(teams.name, teams.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -441,8 +445,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -457,16 +460,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(vehicles.color, `%${query}%`)
|
ilike(vehicles.color, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
|
conditions.push(eq(vehicles.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(vehicles)
|
.from(vehicles)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(vehicles.name)
|
.orderBy(vehicles.name, vehicles.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -479,8 +483,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||||
vendor: { type: "number" },
|
vendor: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -499,16 +502,17 @@ export const masterdataTools: McpTool[] = [
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
|
conditions.push(eq(inventoryitems.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(inventoryitems)
|
.from(inventoryitems)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(inventoryitems.name)
|
.orderBy(inventoryitems.name, inventoryitems.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -528,7 +532,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(inventoryitems)
|
.from(inventoryitems)
|
||||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
|
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId), eq(inventoryitems.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
||||||
@@ -544,7 +548,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
|
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -561,11 +565,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(units.short, `%${query}%`)
|
ilike(units.short, `%${query}%`)
|
||||||
)
|
)
|
||||||
: undefined)
|
: undefined)
|
||||||
.orderBy(units.name)
|
.orderBy(units.name, units.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
277
backend/src/mcp/tools/masterdataWrites.ts
Normal file
277
backend/src/mcp/tools/masterdataWrites.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"
|
||||||
|
import * as tables from "../../../db/schema"
|
||||||
|
import { assertToolPermission } from "../authz"
|
||||||
|
import { McpContext, McpTool } from "../types"
|
||||||
|
import { arrayField, booleanField, idField, InputField, nullable, numericField, objectField, tableFields, textField, uuidField } from "../masterdataFields"
|
||||||
|
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||||
|
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
|
||||||
|
|
||||||
|
const infoDataField = objectField({
|
||||||
|
...Object.fromEntries("street special zip city country tel mobileTel email invoiceEmail web ustid".split(" ").map((key) => [key, nullable(textField())])),
|
||||||
|
bankAccountIds: arrayField(idField),
|
||||||
|
hasSEPA: booleanField,
|
||||||
|
sepaSignedAt: nullable(textField()),
|
||||||
|
}, true)
|
||||||
|
infoDataField.schema.description = "Kontaktdaten und Zusatzfelder. Bei update werden angegebene Unterfelder mit infoData zusammengeführt; Arrays werden ersetzt."
|
||||||
|
const profilesField = arrayField(uuidField)
|
||||||
|
const compositionField = arrayField(objectField({
|
||||||
|
id: uuidField, name: textField(), quantity: numericField(), price: numericField(), purchasePrice: numericField(),
|
||||||
|
product: nullable(idField), service: nullable(idField), hourrate: nullable(uuidField),
|
||||||
|
}, true))
|
||||||
|
const priceField = objectField({ total: numericField(), material: numericField(), worker: numericField() }, true)
|
||||||
|
|
||||||
|
type Reference = { field: string; table: any; idKey?: string; tenantKey?: string }
|
||||||
|
type Config = {
|
||||||
|
resource: string
|
||||||
|
namespace: string
|
||||||
|
label: string
|
||||||
|
resultKey: string
|
||||||
|
table: any
|
||||||
|
fields: Record<string, InputField>
|
||||||
|
required: string[]
|
||||||
|
numberField?: string
|
||||||
|
references?: Reference[]
|
||||||
|
addGet?: boolean
|
||||||
|
}
|
||||||
|
const makeConfig = (config: Omit<Config, "fields"> & { fieldNames: string; overrides?: Record<string, InputField> }): Config => ({
|
||||||
|
...config,
|
||||||
|
fields: tableFields(config.table, config.fieldNames, config.overrides),
|
||||||
|
})
|
||||||
|
const commonProfiles: Reference = { field: "profiles", table: tables.authProfiles, tenantKey: "tenant_id" }
|
||||||
|
const bankAccounts: Reference = { field: "infoData.bankAccountIds", table: tables.entitybankaccounts }
|
||||||
|
|
||||||
|
export const masterdataWriteConfigs: Config[] = [
|
||||||
|
makeConfig({
|
||||||
|
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
|
||||||
|
required: ["name"], numberField: "customerNumber",
|
||||||
|
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation",
|
||||||
|
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
|
||||||
|
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
|
||||||
|
required: ["name"], numberField: "vendorNumber",
|
||||||
|
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod",
|
||||||
|
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
|
||||||
|
references: [commonProfiles, bankAccounts],
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "contacts", namespace: "contacts", label: "Kontakt", resultKey: "contact", table: tables.contacts, required: [], addGet: true,
|
||||||
|
fieldNames: "firstName lastName fullName email customer vendor phoneMobile phoneHome role salutation active birthday notes profiles title",
|
||||||
|
overrides: { profiles: profilesField, customer: idField, vendor: idField },
|
||||||
|
references: [commonProfiles, { field: "customer", table: tables.customers }, { field: "vendor", table: tables.vendors }],
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "products", namespace: "products", label: "Artikel", resultKey: "product", table: tables.products, required: ["name", "unit"],
|
||||||
|
fieldNames: "name manufacturer unit tags ean barcode purchase_price selling_price description manufacturer_number vendor_allocation article_number supplier_link barcodes productcategories tax_percentage markup_percentage",
|
||||||
|
overrides: {
|
||||||
|
unit: idField, tags: arrayField(textField()), barcodes: arrayField(textField()), productcategories: arrayField(idField), tax_percentage: numericField(true, 0, 100),
|
||||||
|
vendor_allocation: arrayField(objectField({ vendor: idField, articleNumber: textField(), purchasePrice: numericField() }, true)),
|
||||||
|
},
|
||||||
|
references: [{ field: "unit", table: tables.units }, { field: "productcategories", table: tables.productcategories }, { field: "vendor_allocation.vendor", table: tables.vendors }],
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "services", namespace: "services", label: "Leistung", resultKey: "service", table: tables.services, required: ["name"],
|
||||||
|
fieldNames: "name sellingPrice description unit costcentre serviceNumber tags servicecategories purchasePriceComposed sellingPriceComposed taxPercentage materialComposition personalComposition priceUpdateLocked",
|
||||||
|
overrides: {
|
||||||
|
unit: idField, tags: arrayField(textField()), servicecategories: arrayField(idField), taxPercentage: numericField(true, 0, 100),
|
||||||
|
purchasePriceComposed: priceField, sellingPriceComposed: priceField, materialComposition: compositionField, personalComposition: compositionField,
|
||||||
|
},
|
||||||
|
references: [
|
||||||
|
{ field: "unit", table: tables.units }, { field: "costcentre", table: tables.costcentres }, { field: "servicecategories", table: tables.servicecategories },
|
||||||
|
{ field: "materialComposition.product", table: tables.products }, { field: "materialComposition.service", table: tables.services },
|
||||||
|
{ field: "personalComposition.hourrate", table: tables.hourrates },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "costcentres", namespace: "cost_centres", label: "Kostenstelle", resultKey: "costCentre", table: tables.costcentres,
|
||||||
|
required: ["name"], numberField: "number",
|
||||||
|
fieldNames: "name number parentCostcentre vehicle project branch inventoryitem description profiles",
|
||||||
|
overrides: { number: textField(true), vehicle: idField, project: idField, branch: idField, inventoryitem: idField, profiles: profilesField },
|
||||||
|
references: [commonProfiles, { field: "parentCostcentre", table: tables.costcentres }, { field: "vehicle", table: tables.vehicles }, { field: "project", table: tables.projects }, { field: "branch", table: tables.branches }, { field: "inventoryitem", table: tables.inventoryitems }],
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "branches", namespace: "branches", label: "Niederlassung", resultKey: "branch", table: tables.branches,
|
||||||
|
required: ["name"], numberField: "number", fieldNames: "name number description", overrides: { number: textField(true) }, addGet: true,
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "teams", namespace: "teams", label: "Team", resultKey: "team", table: tables.teams,
|
||||||
|
required: ["name"], fieldNames: "name description branch", overrides: { branch: idField }, references: [{ field: "branch", table: tables.branches }], addGet: true,
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "vehicles", namespace: "vehicles", label: "Fahrzeug", resultKey: "vehicle", table: tables.vehicles,
|
||||||
|
required: ["name"], fieldNames: "name license_plate type active driver vin tank_size build_year towing_capacity power_in_kw color profiles",
|
||||||
|
overrides: { profiles: profilesField, tank_size: numericField(false, 0), towing_capacity: numericField(true, 0), power_in_kw: numericField(true, 0) },
|
||||||
|
references: [commonProfiles, { field: "driver", table: tables.authProfiles, idKey: "user_id", tenantKey: "tenant_id" }], addGet: true,
|
||||||
|
}),
|
||||||
|
makeConfig({
|
||||||
|
resource: "inventoryitems", namespace: "inventory_items", label: "Inventar", resultKey: "inventoryItem", table: tables.inventoryitems,
|
||||||
|
required: ["name"], numberField: "articleNumber",
|
||||||
|
fieldNames: "name usePlanning description currentSpace articleNumber serialNumber purchaseDate vendor quantity purchasePrice manufacturer manufacturerNumber currentValue profiles",
|
||||||
|
overrides: { articleNumber: textField(true), currentSpace: idField, vendor: idField, profiles: profilesField, quantity: numericField(true, 0) },
|
||||||
|
references: [commonProfiles, { field: "currentSpace", table: tables.spaces }, { field: "vendor", table: tables.vendors }],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const identifier = (config: Config) => config.resource === "costcentres" ? uuidField : idField
|
||||||
|
|
||||||
|
export function prepareMasterdataInput(config: Config, action: "create" | "update" | "archive", args: Record<string, unknown>) {
|
||||||
|
const fields = action === "archive" ? {} : config.fields
|
||||||
|
const input = objectField({ ...fields, ...(action === "create" ? {} : { id: identifier(config) }) }).parse(args, "arguments")
|
||||||
|
for (const key of action === "create" ? config.required : ["id"]) {
|
||||||
|
if (input[key] === undefined || input[key] === null) throw new Error(`${key} ist erforderlich`)
|
||||||
|
}
|
||||||
|
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
function valuesAtPath(value: any, path: string[]): any[] {
|
||||||
|
if (Array.isArray(value)) return value.flatMap((entry) => valuesAtPath(entry, path))
|
||||||
|
if (!path.length) return value === undefined || value === null ? [] : [value]
|
||||||
|
return valuesAtPath(value?.[path[0]], path.slice(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateReferences(db: any, config: Config, tenantId: number, data: Record<string, any>) {
|
||||||
|
for (const reference of config.references || []) {
|
||||||
|
const ids = [...new Set(valuesAtPath(data, reference.field.split(".")))]
|
||||||
|
if (!ids.length) continue
|
||||||
|
const table = reference.table
|
||||||
|
const idColumn = table[reference.idKey || "id"]
|
||||||
|
const tenantColumn = table[reference.tenantKey || "tenant"]
|
||||||
|
const conditions = [inArray(idColumn, ids)]
|
||||||
|
if (tenantColumn) conditions.push(eq(tenantColumn, tenantId))
|
||||||
|
if (table.archived) conditions.push(eq(table.archived, false))
|
||||||
|
if (table === tables.authProfiles) conditions.push(eq(table.active, true))
|
||||||
|
const found = await db.select({ id: idColumn }).from(table).where(and(...conditions))
|
||||||
|
if (new Set(found.map((row: any) => row.id)).size !== ids.length) {
|
||||||
|
throw new Error(`${reference.field}: mindestens ein verknüpfter Datensatz ist im aktiven Mandanten nicht verfügbar`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(context: McpContext, config: Config, action: "create" | "update" | "archive", args: Record<string, unknown>) {
|
||||||
|
const { id, ...input } = prepareMasterdataInput(config, action, args)
|
||||||
|
return context.server.db.transaction(async (tx) => {
|
||||||
|
// Coordinate number allocation and hierarchy changes across concurrent MCP calls.
|
||||||
|
// The existing number-range helper uses the same transaction-scoped tenant lock.
|
||||||
|
await tx.execute(sql`select pg_advisory_xact_lock(${context.tenantId})`)
|
||||||
|
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
|
||||||
|
const table = config.table
|
||||||
|
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId), eq(table.archived, false))
|
||||||
|
let existing: any = null
|
||||||
|
if (action !== "create") {
|
||||||
|
;[existing] = await tx.select().from(table).where(scope).limit(1).for("update")
|
||||||
|
if (!existing) throw new Error(`${config.label} nicht gefunden oder archiviert`)
|
||||||
|
}
|
||||||
|
const data: Record<string, any> = action === "archive" ? { archived: true } : { ...input }
|
||||||
|
if (action !== "archive") {
|
||||||
|
if (data.infoData && existing?.infoData) data.infoData = { ...existing.infoData, ...data.infoData }
|
||||||
|
if (config.resource === "contacts" && data.fullName === undefined && (action === "create" || data.firstName !== undefined || data.lastName !== undefined)) {
|
||||||
|
const merged = { ...existing, ...data }
|
||||||
|
data.fullName = [merged.firstName, merged.lastName].filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
const merged = { ...existing, ...data }
|
||||||
|
if (config.resource === "contacts" && !merged.fullName?.trim()) throw new Error("Kontakt benötigt fullName oder Vor-/Nachname")
|
||||||
|
if (config.resource === "customers" && merged.type === "Mitglied") {
|
||||||
|
data.isCompany = false
|
||||||
|
if (!merged.firstname?.trim() || !merged.lastname?.trim() || !merged.infoData?.bankAccountIds?.length) {
|
||||||
|
throw new Error("Mitglieder benötigen Vorname, Nachname und mindestens ein Bankkonto")
|
||||||
|
}
|
||||||
|
if (merged.infoData.hasSEPA && !merged.infoData.sepaSignedAt) throw new Error("SEPA-Mandat benötigt ein Unterschriftsdatum")
|
||||||
|
}
|
||||||
|
if (config.resource === "services") {
|
||||||
|
// The shared recalculation service prefers sellingPriceComposed.total.
|
||||||
|
// Keep both representations consistent for manual price edits, including zero.
|
||||||
|
if (data.sellingPrice !== undefined) {
|
||||||
|
const total = data.sellingPrice ?? 0
|
||||||
|
if (data.sellingPriceComposed?.total !== undefined && data.sellingPriceComposed.total !== total) {
|
||||||
|
throw new Error("sellingPrice und sellingPriceComposed.total widersprechen sich")
|
||||||
|
}
|
||||||
|
data.sellingPriceComposed = { ...existing?.sellingPriceComposed, ...data.sellingPriceComposed, total }
|
||||||
|
} else if (data.sellingPriceComposed?.total !== undefined) {
|
||||||
|
data.sellingPrice = data.sellingPriceComposed.total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await validateReferences(tx, config, context.tenantId, data)
|
||||||
|
if (config.resource === "costcentres") {
|
||||||
|
const error = await validateCostCentreParent(server, context.tenantId, id || null, merged.parentCostcentre || null)
|
||||||
|
if (error) throw new Error(error)
|
||||||
|
}
|
||||||
|
if (config.numberField) {
|
||||||
|
if (action === "create" && !data[config.numberField]) {
|
||||||
|
const result = await useNextNumberRangeNumber(server, context.tenantId, config.resource, async (candidate, numberTx) => {
|
||||||
|
const rows = await numberTx.select({ id: table.id }).from(table)
|
||||||
|
.where(and(eq(table.tenant, context.tenantId), eq(table[config.numberField!], candidate))).limit(1)
|
||||||
|
return rows.length === 0
|
||||||
|
})
|
||||||
|
data[config.numberField] = result.usedNumber
|
||||||
|
} else if (data[config.numberField] !== undefined && data[config.numberField] !== null) {
|
||||||
|
const rows = await tx.select({ id: table.id }).from(table)
|
||||||
|
.where(and(eq(table.tenant, context.tenantId), eq(table[config.numberField], data[config.numberField]))).limit(2)
|
||||||
|
if (rows.some((row: any) => row.id !== id)) throw new Error(`${config.numberField} ist bereits vergeben`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const columns = getTableColumns(table)
|
||||||
|
data["updated_at" in columns ? "updated_at" : "updatedAt"] = new Date()
|
||||||
|
data["updated_by" in columns ? "updated_by" : "updatedBy"] = context.userId
|
||||||
|
let result: any
|
||||||
|
if (action === "create") {
|
||||||
|
;[result] = await tx.insert(table).values({ ...data, tenant: context.tenantId, archived: false }).returning()
|
||||||
|
} else {
|
||||||
|
;[result] = await tx.update(table).set(data).where(scope).returning()
|
||||||
|
}
|
||||||
|
if (["products", "services"].includes(config.resource)) {
|
||||||
|
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
|
||||||
|
;[result] = await tx.select().from(table).where(and(eq(table.id, result.id), eq(table.tenant, context.tenantId))).limit(1)
|
||||||
|
}
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: context.tenantId, created_by: context.userId, entity: config.resource, entityId: result.id,
|
||||||
|
action: action === "create" ? "created" : action === "archive" ? "archived" : "updated", oldVal: existing, newVal: result,
|
||||||
|
})
|
||||||
|
// Archived objects are filtered from normal MCP results; return an explicit confirmation.
|
||||||
|
return action === "archive" ? { id: result.id, success: true, action: "archived" } : { [config.resultKey]: result }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const masterdataMutationTools: McpTool[] = masterdataWriteConfigs.flatMap((config) => {
|
||||||
|
const tools: McpTool[] = (["create", "update", "archive"] as const).map((action) => {
|
||||||
|
const fields = action === "archive" ? {} : config.fields
|
||||||
|
const properties = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema]))
|
||||||
|
if (action !== "create") properties.id = identifier(config).schema
|
||||||
|
if (config.numberField && action === "create") properties[config.numberField] = { ...properties[config.numberField], description: "Optional; ohne Angabe Vergabe aus dem FEDEO-Nummernkreis." }
|
||||||
|
const tool: McpTool = {
|
||||||
|
name: `masterdata.${config.namespace}.${action}`,
|
||||||
|
title: `${config.label} ${action === "create" ? "anlegen" : action === "update" ? "bearbeiten" : "archivieren"}`,
|
||||||
|
description: `${config.label} im aktiven Mandanten ${action === "create" ? "anlegen" : action === "update" ? "bearbeiten. Nur übergebene Felder werden geändert; null entfernt nullable Werte, Arrays werden ersetzt" : "archivieren"}. Verknüpfungen werden auf Mandantenzugehörigkeit geprüft.`,
|
||||||
|
requiredPermissions: [`masterdata.${config.namespace}.write`],
|
||||||
|
inputSchema: { type: "object", properties, required: action === "create" ? config.required : ["id"], additionalProperties: false },
|
||||||
|
async handler(context, args) {
|
||||||
|
assertToolPermission(context, tool)
|
||||||
|
return mutate(context, config, action, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return tool
|
||||||
|
})
|
||||||
|
if (config.addGet) {
|
||||||
|
const tool: McpTool = {
|
||||||
|
name: `masterdata.${config.namespace}.get`, title: `${config.label} laden`,
|
||||||
|
description: `Lädt ${config.label} des aktiven Mandanten anhand der ID.`,
|
||||||
|
requiredPermissions: [`masterdata.${config.namespace}.read`],
|
||||||
|
inputSchema: { type: "object", properties: { id: identifier(config).schema }, required: ["id"], additionalProperties: false },
|
||||||
|
async handler(context, args) {
|
||||||
|
assertToolPermission(context, tool)
|
||||||
|
const id = identifier(config).parse(args.id, "id")
|
||||||
|
const table = config.table
|
||||||
|
const [result] = await context.server.db.select().from(table)
|
||||||
|
.where(and(eq(table.id, id), eq(table.tenant, context.tenantId), eq(table.archived, false))).limit(1)
|
||||||
|
if (!result) throw new Error(`${config.label} nicht gefunden oder archiviert`)
|
||||||
|
return { [config.resultKey]: result }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tools.push(tool)
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
})
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
import { activateProjectPhase } from "../projectPhases"
|
import { activateProjectPhase } from "../projectPhases"
|
||||||
import { McpTool } from "../types"
|
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 stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
@@ -30,8 +26,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -47,7 +42,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
ilike(customers.notes, `%${query}%`)
|
ilike(customers.notes, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
conditions.push(eq(customers.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select({
|
.select({
|
||||||
@@ -63,10 +58,11 @@ export const organisationTools: McpTool[] = [
|
|||||||
})
|
})
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(customers.name)
|
.orderBy(customers.name, customers.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,8 +76,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
activePhase: { type: "string" },
|
activePhase: { type: "string" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -100,16 +95,17 @@ export const organisationTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (customer) conditions.push(eq(projects.customer, customer))
|
if (customer) conditions.push(eq(projects.customer, customer))
|
||||||
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
conditions.push(eq(projects.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(projects.createdAt))
|
.orderBy(desc(projects.createdAt), projects.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,7 +127,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId), eq(projects.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
||||||
@@ -219,8 +215,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name." },
|
query: { type: "string", description: "Suchtext für Name." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -230,16 +225,17 @@ export const organisationTools: McpTool[] = [
|
|||||||
|
|
||||||
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
||||||
if (customer) conditions.push(eq(plants.customer, customer))
|
if (customer) conditions.push(eq(plants.customer, customer))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
conditions.push(eq(plants.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(plants)
|
.from(plants)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(plants.name)
|
.orderBy(plants.name, plants.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -254,8 +250,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
eventtype: { type: "string" },
|
eventtype: { type: "string" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -275,16 +270,17 @@ export const organisationTools: McpTool[] = [
|
|||||||
if (project) conditions.push(eq(events.project, project))
|
if (project) conditions.push(eq(events.project, project))
|
||||||
if (customer) conditions.push(eq(events.customer, customer))
|
if (customer) conditions.push(eq(events.customer, customer))
|
||||||
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
conditions.push(eq(events.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(events.startDate))
|
.orderBy(desc(events.startDate), events.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -298,8 +294,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
...paginationProperties,
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -317,16 +312,17 @@ export const organisationTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (project) conditions.push(eq(tasks.project, project))
|
if (project) conditions.push(eq(tasks.project, project))
|
||||||
if (customer) conditions.push(eq(tasks.customer, customer))
|
if (customer) conditions.push(eq(tasks.customer, customer))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
conditions.push(eq(tasks.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(tasks.createdAt))
|
.orderBy(desc(tasks.createdAt), tasks.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -348,7 +344,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId), eq(tasks.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, asc, eq, ilike, isNull } from "drizzle-orm"
|
import { and, asc, eq, ilike, isNull } from "drizzle-orm"
|
||||||
import { wikiPages } from "../../../db/schema"
|
import { wikiPages } from "../../../db/schema"
|
||||||
import { McpContext, McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
@@ -27,17 +28,7 @@ const pageIdArg = (args: Record<string, unknown>, key = "id") => {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>) => {
|
|
||||||
const value = Number(args.limit ?? 50)
|
|
||||||
if (!Number.isFinite(value)) return 50
|
|
||||||
return Math.min(Math.max(Math.trunc(value), 1), 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetFromArgs = (args: Record<string, unknown>) => {
|
|
||||||
const value = Number(args.offset ?? 0)
|
|
||||||
if (!Number.isFinite(value)) return 0
|
|
||||||
return Math.max(Math.trunc(value), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prepareWikiScope(args: Record<string, unknown>): WikiScope {
|
export function prepareWikiScope(args: Record<string, unknown>): WikiScope {
|
||||||
const entityType = stringArg(args, "entityType")
|
const entityType = stringArg(args, "entityType")
|
||||||
@@ -144,8 +135,8 @@ export const wikiTools: McpTool[] = [
|
|||||||
...entityScopeProperties,
|
...entityScopeProperties,
|
||||||
query: { type: "string", description: "Optionaler Suchtext im Titel." },
|
query: { type: "string", description: "Optionaler Suchtext im Titel." },
|
||||||
parentId: { type: ["string", "null"], format: "uuid", description: "Optional auf direkte Kinder dieses Ordners einschränken; null steht für die oberste Ebene." },
|
parentId: { type: ["string", "null"], format: "uuid", description: "Optional auf direkte Kinder dieses Ordners einschränken; null steht für die oberste Ebene." },
|
||||||
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
...paginationProperties,
|
||||||
offset: { type: "integer", minimum: 0, default: 0 },
|
limit: { ...paginationProperties.limit, default: 50 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -173,11 +164,11 @@ export const wikiTools: McpTool[] = [
|
|||||||
})
|
})
|
||||||
.from(wikiPages)
|
.from(wikiPages)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
|
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title), wikiPages.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args, 50) + 1)
|
||||||
.offset(offsetFromArgs(args))
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args, 50)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
30
backend/src/modules/bank-allocation-details.ts
Normal file
30
backend/src/modules/bank-allocation-details.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { costcentres } from "../../db/schema"
|
||||||
|
|
||||||
|
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
|
||||||
|
|
||||||
|
export function normalizeBankAllocationDetails(payload: Record<string, any>) {
|
||||||
|
const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null
|
||||||
|
const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null
|
||||||
|
if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) {
|
||||||
|
throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.")
|
||||||
|
}
|
||||||
|
if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) {
|
||||||
|
throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.")
|
||||||
|
}
|
||||||
|
if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount)
|
||||||
|
|| payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) {
|
||||||
|
throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.")
|
||||||
|
}
|
||||||
|
return { costcentre, datevTaxKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) {
|
||||||
|
if (!costcentre) return
|
||||||
|
const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and(
|
||||||
|
eq(costcentres.id, costcentre),
|
||||||
|
eq(costcentres.tenant, tenantId),
|
||||||
|
eq(costcentres.archived, false),
|
||||||
|
)).limit(1)
|
||||||
|
if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.")
|
||||||
|
}
|
||||||
@@ -24,15 +24,25 @@ const adminPermissions = [
|
|||||||
"mcp.tokens.write",
|
"mcp.tokens.write",
|
||||||
"staff.time.read_all",
|
"staff.time.read_all",
|
||||||
"masterdata.customers.read",
|
"masterdata.customers.read",
|
||||||
|
"masterdata.customers.write",
|
||||||
"masterdata.vendors.read",
|
"masterdata.vendors.read",
|
||||||
|
"masterdata.vendors.write",
|
||||||
"masterdata.contacts.read",
|
"masterdata.contacts.read",
|
||||||
|
"masterdata.contacts.write",
|
||||||
"masterdata.products.read",
|
"masterdata.products.read",
|
||||||
|
"masterdata.products.write",
|
||||||
"masterdata.services.read",
|
"masterdata.services.read",
|
||||||
|
"masterdata.services.write",
|
||||||
"masterdata.cost_centres.read",
|
"masterdata.cost_centres.read",
|
||||||
|
"masterdata.cost_centres.write",
|
||||||
"masterdata.branches.read",
|
"masterdata.branches.read",
|
||||||
|
"masterdata.branches.write",
|
||||||
"masterdata.teams.read",
|
"masterdata.teams.read",
|
||||||
|
"masterdata.teams.write",
|
||||||
"masterdata.vehicles.read",
|
"masterdata.vehicles.read",
|
||||||
|
"masterdata.vehicles.write",
|
||||||
"masterdata.inventory_items.read",
|
"masterdata.inventory_items.read",
|
||||||
|
"masterdata.inventory_items.write",
|
||||||
"masterdata.units.read",
|
"masterdata.units.read",
|
||||||
"accounting.outgoing_documents.read",
|
"accounting.outgoing_documents.read",
|
||||||
"accounting.outgoing_documents.write",
|
"accounting.outgoing_documents.write",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"
|
|||||||
|
|
||||||
import { centralServicesClient } from "./push-server.client"
|
import { centralServicesClient } from "./push-server.client"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { getBuildInfo } from "../utils/buildInfo"
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export function startCentralServicesHeartbeat(server: FastifyInstance) {
|
|||||||
const sendHeartbeat = async () => {
|
const sendHeartbeat = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await centralServicesClient.heartbeat({
|
const response = await centralServicesClient.heartbeat({
|
||||||
fedeoVersion: secrets.FEDEO_VERSION || "selfhost",
|
fedeoVersion: getBuildInfo().version,
|
||||||
baseUrl: instanceBaseUrl(),
|
baseUrl: instanceBaseUrl(),
|
||||||
})
|
})
|
||||||
console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`)
|
console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`)
|
||||||
|
|||||||
56
backend/src/modules/cost-centre-hierarchy.service.ts
Normal file
56
backend/src/modules/cost-centre-hierarchy.service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { costcentres } from "../../db/schema"
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { costcentres, createddocuments } from "../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../utils/history"
|
||||||
|
|
||||||
|
export type RowCostCentreAssignment = {
|
||||||
|
rowId: string
|
||||||
|
costcentre: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutgoingDocumentCostCentreUpdate = {
|
||||||
|
costcentre?: string | null
|
||||||
|
rowCostCentres?: RowCostCentreAssignment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceError = (message: string, statusCode = 400) =>
|
||||||
|
Object.assign(new Error(message), { statusCode })
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
export function applyRowCostCentreAssignments(
|
||||||
|
rows: unknown,
|
||||||
|
assignments: RowCostCentreAssignment[] = [],
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(rows)) return []
|
||||||
|
if (!assignments.length) return rows
|
||||||
|
|
||||||
|
const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre]))
|
||||||
|
const existingIds = new Set(rows
|
||||||
|
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
||||||
|
.map((row) => (row as Record<string, unknown>).id)
|
||||||
|
.filter((id) => id !== null && id !== undefined && id !== "")
|
||||||
|
.map(String))
|
||||||
|
|
||||||
|
for (const rowId of assignmentMap.keys()) {
|
||||||
|
if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
if (!row || typeof row !== "object" || Array.isArray(row)) return row
|
||||||
|
const rowRecord = row as Record<string, unknown>
|
||||||
|
if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row
|
||||||
|
const rowId = String(rowRecord.id)
|
||||||
|
if (!assignmentMap.has(rowId)) return row
|
||||||
|
|
||||||
|
const { costcentre: _legacyCostCentre, ...rest } = rowRecord
|
||||||
|
return { ...rest, costCentre: assignmentMap.get(rowId) ?? null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOutgoingDocumentCostCentres(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
documentId: number,
|
||||||
|
input: OutgoingDocumentCostCentreUpdate,
|
||||||
|
) {
|
||||||
|
const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre")
|
||||||
|
if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) {
|
||||||
|
throw serviceError("costcentre muss eine gültige UUID oder null sein")
|
||||||
|
}
|
||||||
|
if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) {
|
||||||
|
throw serviceError("rowCostCentres muss ein Array sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowAssignments = (input.rowCostCentres || []).map((assignment) => {
|
||||||
|
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||||
|
const costcentre = assignment?.costcentre
|
||||||
|
if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId")
|
||||||
|
if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) {
|
||||||
|
throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||||
|
}
|
||||||
|
return { rowId, costcentre }
|
||||||
|
})
|
||||||
|
if (!hasDocumentCostCentre && !rowAssignments.length) {
|
||||||
|
throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(
|
||||||
|
eq(createddocuments.id, documentId),
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
eq(createddocuments.archived, false),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||||
|
if (existing.state !== "Gebucht") {
|
||||||
|
throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedCostCentreIds = [...new Set([
|
||||||
|
...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []),
|
||||||
|
...rowAssignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
|
||||||
|
])]
|
||||||
|
|
||||||
|
if (referencedCostCentreIds.length) {
|
||||||
|
const validCostCentres = await server.db
|
||||||
|
.select({ id: costcentres.id })
|
||||||
|
.from(costcentres)
|
||||||
|
.where(and(
|
||||||
|
eq(costcentres.tenant, tenantId),
|
||||||
|
eq(costcentres.archived, false),
|
||||||
|
inArray(costcentres.id, referencedCostCentreIds),
|
||||||
|
))
|
||||||
|
|
||||||
|
if (validCostCentres.length !== referencedCostCentreIds.length) {
|
||||||
|
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments)
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
rows: updatedRows,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId,
|
||||||
|
}
|
||||||
|
if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null
|
||||||
|
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(createddocuments)
|
||||||
|
.set(update)
|
||||||
|
.where(and(
|
||||||
|
eq(createddocuments.id, documentId),
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
eq(createddocuments.archived, false),
|
||||||
|
eq(createddocuments.state, "Gebucht"),
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: userId,
|
||||||
|
entity: "createddocuments",
|
||||||
|
entityId: documentId,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: updated,
|
||||||
|
text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert",
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
91
backend/src/modules/selfhost-updater.ts
Normal file
91
backend/src/modules/selfhost-updater.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { request } from "node:http";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
|
||||||
|
export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/);
|
||||||
|
export const checkBody = z.object({ requestId, version: version.optional() }).strict();
|
||||||
|
export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict();
|
||||||
|
const errors = {
|
||||||
|
INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.",
|
||||||
|
BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.",
|
||||||
|
REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.",
|
||||||
|
CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.",
|
||||||
|
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
|
||||||
|
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
|
||||||
|
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
|
||||||
|
UPDATE_FAILED: "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
|
||||||
|
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
|
||||||
|
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
|
||||||
|
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
|
||||||
|
};
|
||||||
|
type ErrorCode = keyof typeof errors;
|
||||||
|
const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code);
|
||||||
|
export class UpdaterError extends Error {
|
||||||
|
constructor(public code: ErrorCode, public status = 503) { super(errors[code]); }
|
||||||
|
}
|
||||||
|
const state = z.object({
|
||||||
|
status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]),
|
||||||
|
phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
|
||||||
|
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(),
|
||||||
|
command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(),
|
||||||
|
version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(),
|
||||||
|
recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(),
|
||||||
|
startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(),
|
||||||
|
});
|
||||||
|
const plan = state.omit({ status: true }).extend({
|
||||||
|
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
|
||||||
|
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
|
||||||
|
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
|
||||||
|
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
|
||||||
|
});
|
||||||
|
const job = state.extend({ plan: plan.optional(), progress: state.optional(),
|
||||||
|
error: z.object({ code: z.string() }).transform(({ code }) => ({
|
||||||
|
code: knownError(code) ? code : "UPDATE_FAILED",
|
||||||
|
message: knownError(code) ? errors[code] : errors.UPDATE_FAILED,
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
|
||||||
|
installed: state.pick({ version: true, revision: true }), request: job.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
|
||||||
|
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
|
||||||
|
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
|
||||||
|
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(path)))
|
||||||
|
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
|
||||||
|
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||||
|
const req = request({ socketPath, path, method, agent: false, headers: payload ? {
|
||||||
|
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload),
|
||||||
|
} : {} }, res => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
res.on("data", chunk => {
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502));
|
||||||
|
else chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE")));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
const code = data?.error?.code;
|
||||||
|
if (![200, 202].includes(res.statusCode || 0)) {
|
||||||
|
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
|
||||||
|
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode!, body: (path === "/v1/status" ? status : job).parse(data) });
|
||||||
|
} catch (error) {
|
||||||
|
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs);
|
||||||
|
req.on("close", () => clearTimeout(timer));
|
||||||
|
req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE")));
|
||||||
|
req.end(payload);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
|
|||||||
dayjs.extend(quarterOfYear);
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
|
|
||||||
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
|
export const executeManualGeneration = async (
|
||||||
try {
|
server: FastifyInstance,
|
||||||
console.log(executedBy)
|
executionDate: string | Date,
|
||||||
|
templateIds: number[],
|
||||||
|
tenantId: number,
|
||||||
|
executedBy: string,
|
||||||
|
) => {
|
||||||
|
|
||||||
const executionDayjs = dayjs(executionDate);
|
const executionDayjs = dayjs(executionDate);
|
||||||
|
|
||||||
@@ -33,20 +37,22 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
|||||||
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
||||||
|
|
||||||
// 2. Templates laden
|
// 2. Templates laden
|
||||||
const templates = await server.db
|
const uniqueTemplateIds = [...new Set(templateIds)]
|
||||||
|
const templates = (await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.createddocuments)
|
.from(schema.createddocuments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.createddocuments.tenant, tenantId),
|
eq(schema.createddocuments.tenant, tenantId),
|
||||||
eq(schema.createddocuments.type, "serialInvoices"),
|
eq(schema.createddocuments.type, "serialInvoices"),
|
||||||
inArray(schema.createddocuments.id, templateIds)
|
eq(schema.createddocuments.archived, false),
|
||||||
|
inArray(schema.createddocuments.id, uniqueTemplateIds)
|
||||||
)
|
)
|
||||||
);
|
))
|
||||||
|
.filter((template) => Boolean((template.serialConfig as any)?.active));
|
||||||
|
|
||||||
if (templates.length === 0) {
|
if (templates.length !== uniqueTemplateIds.length) {
|
||||||
console.warn("Keine passenden Vorlagen gefunden.");
|
throw new Error("Mindestens eine Serienrechnungsvorlage wurde nicht gefunden, ist archiviert oder inaktiv.");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
||||||
@@ -62,7 +68,7 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
|||||||
executionDate: executionDayjs.toDate(),
|
executionDate: executionDayjs.toDate(),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
createdBy: executedBy,
|
createdBy: executedBy,
|
||||||
summary: `${templateIds.length} Vorlagen verarbeitet`
|
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -88,13 +94,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return { execution: executionRecord, results };
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
|
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
|
||||||
try {
|
try {
|
||||||
console.log(`Beende Ausführung ${executionId}...`);
|
console.log(`Beende Ausführung ${executionId}...`);
|
||||||
|
|
||||||
@@ -103,15 +106,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
const [executionRecord] = await server.db
|
const [executionRecord] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.serialExecutions)// @ts-ignore
|
.from(schema.serialExecutions)// @ts-ignore
|
||||||
.where(eq(schema.serialExecutions.id, executionId))
|
.where(and(
|
||||||
|
eq(schema.serialExecutions.id, executionId),
|
||||||
|
eq(schema.serialExecutions.tenant, tenantId),
|
||||||
|
))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
||||||
|
|
||||||
console.log(executionRecord);
|
console.log(executionRecord);
|
||||||
|
|
||||||
const tenantId = executionRecord.tenant;
|
|
||||||
|
|
||||||
console.log(tenantId)
|
console.log(tenantId)
|
||||||
|
|
||||||
// Tenant laden (für Settings etc.)
|
// Tenant laden (für Settings etc.)
|
||||||
@@ -132,7 +136,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
const documents = await server.db
|
const documents = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.createddocuments)
|
.from(schema.createddocuments)
|
||||||
.where(eq(schema.createddocuments.serialexecution, executionId));
|
.where(and(
|
||||||
|
eq(schema.createddocuments.serialexecution, executionId),
|
||||||
|
eq(schema.createddocuments.tenant, tenantId),
|
||||||
|
eq(schema.createddocuments.archived, false),
|
||||||
|
));
|
||||||
|
|
||||||
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
||||||
|
|
||||||
@@ -228,7 +236,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
||||||
})// @ts-ignore
|
})// @ts-ignore
|
||||||
.where(eq(schema.serialExecutions.id, executionId));
|
.where(and(
|
||||||
|
eq(schema.serialExecutions.id, executionId),
|
||||||
|
eq(schema.serialExecutions.tenant, tenantId),
|
||||||
|
));
|
||||||
|
|
||||||
return { success: true, processed: successCount, errors: errorCount };
|
return { success: true, processed: successCount, errors: errorCount };
|
||||||
|
|
||||||
@@ -240,7 +251,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
.update(schema.serialExecutions)
|
.update(schema.serialExecutions)
|
||||||
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
.where(eq(schema.serialExecutions.id, executionId));
|
.where(and(
|
||||||
|
eq(schema.serialExecutions.id, executionId),
|
||||||
|
eq(schema.serialExecutions.tenant, tenantId),
|
||||||
|
));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
importTenantFullExportArchive,
|
importTenantFullExportArchive,
|
||||||
} from "../utils/tenantFullExport";
|
} from "../utils/tenantFullExport";
|
||||||
import type { TenantFullExport } from "../utils/tenantFullExport";
|
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
||||||
import { buildSystemStatus } from "../modules/system-status.service";
|
import { buildSystemStatus } from "../modules/system-status.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
@@ -563,29 +564,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
return job;
|
return job;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
const requireAdmin = (req: FastifyRequest, reply: FastifyReply) => requireSystemAdmin(server, req, reply);
|
||||||
if (!req.user?.user_id) {
|
|
||||||
reply.code(401).send({ error: "Unauthorized" });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [currentUser] = await server.db
|
|
||||||
.select({
|
|
||||||
id: authUsers.id,
|
|
||||||
email: authUsers.email,
|
|
||||||
is_admin: authUsers.is_admin,
|
|
||||||
})
|
|
||||||
.from(authUsers)
|
|
||||||
.where(eq(authUsers.id, req.user.user_id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!currentUser?.is_admin) {
|
|
||||||
reply.code(403).send({ error: "Admin access required" });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentUser;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||||
const existingRoles = await server.db
|
const existingRoles = await server.db
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
|||||||
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||||
import { centralServicesClient } from "../modules/push-server.client"
|
import { centralServicesClient } from "../modules/push-server.client"
|
||||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||||
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const { data: payload } = req.body as { data: any }
|
const { data: payload } = req.body as { data: any }
|
||||||
const prepared = prepareStatementAllocationPayload(payload)
|
const prepared = prepareStatementAllocationPayload(payload)
|
||||||
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||||
|
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
||||||
|
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
||||||
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
||||||
|
|
||||||
const inserted = await server.db.insert(statementallocations).values({
|
const inserted = await server.db.insert(statementallocations).values({
|
||||||
@@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
return reply.send(createdRecord)
|
return reply.send(createdRecord)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.statusCode === 400) return reply.code(400).send({ error: err.message })
|
||||||
console.error(err)
|
console.error(err)
|
||||||
return reply.code(500).send({ error: "Failed to create statement" })
|
return reply.code(500).send({ error: "Failed to create statement" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
|||||||
import {citys, files} from "../../db/schema";
|
import {citys, files} from "../../db/schema";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
@@ -296,15 +297,34 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/serial/start', async (req, reply) => {
|
server.post('/functions/serial/start', async (req, reply) => {
|
||||||
console.log(req.body)
|
const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]}
|
||||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
|
||||||
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||||
const {execution_id} = req.params as { execution_id: string }
|
const {execution_id} = req.params as { execution_id: string }
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
await finishManualGeneration(server,execution_id)
|
return finishManualGeneration(server, execution_id, req.user.tenant_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.put('/functions/outgoing-documents/:id/cost-centres', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const documentId = Number(id)
|
||||||
|
if (!Number.isFinite(documentId)) return reply.code(400).send({ error: "Ungültige Ausgangsbeleg-ID" })
|
||||||
|
|
||||||
|
const document = await updateOutgoingDocumentCostCentres(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
documentId,
|
||||||
|
req.body as any,
|
||||||
|
)
|
||||||
|
return { document }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { getBuildInfo } from "../utils/buildInfo";
|
||||||
|
|
||||||
export default async function routes(server: FastifyInstance) {
|
export default async function routes(server: FastifyInstance) {
|
||||||
|
server.get("/health", async (_request, reply) => {
|
||||||
|
reply.header("Cache-Control", "no-store");
|
||||||
|
try {
|
||||||
|
await server.db.execute("SELECT 1");
|
||||||
|
return { status: "ok", ...getBuildInfo() };
|
||||||
|
} catch {
|
||||||
|
return reply.code(503).send({ status: "unavailable" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/version", async (_request, reply) => {
|
||||||
|
reply.header("Cache-Control", "no-store");
|
||||||
|
return getBuildInfo();
|
||||||
|
});
|
||||||
|
|
||||||
server.get("/ping", async () => {
|
server.get("/ping", async () => {
|
||||||
// Testquery gegen DB
|
// Testquery gegen DB
|
||||||
const result = await server.db.execute("SELECT NOW()");
|
const result = await server.db.execute("SELECT NOW()");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { authProfiles, historyitems } from "../../db/schema";
|
import { authProfiles, historyitems } from "../../db/schema";
|
||||||
|
import { sanitizeHistoryItem, sanitizeHistoryText, sanitizeHistoryValue } from "../utils/historySanitization";
|
||||||
|
|
||||||
const columnMap: Record<string, any> = {
|
const columnMap: Record<string, any> = {
|
||||||
customers: historyitems.customer,
|
customers: historyitems.customer,
|
||||||
@@ -95,7 +96,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
profiles.map((profile) => [profile.user_id, profile])
|
profiles.map((profile) => [profile.user_id, profile])
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.map((historyitem) => ({
|
return data.map((historyitem) => sanitizeHistoryItem({
|
||||||
...historyitem,
|
...historyitem,
|
||||||
created_at: historyitem.createdAt,
|
created_at: historyitem.createdAt,
|
||||||
created_by: historyitem.createdBy,
|
created_by: historyitem.createdBy,
|
||||||
@@ -153,7 +154,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
profiles.map((profile) => [profile.user_id, profile])
|
profiles.map((profile) => [profile.user_id, profile])
|
||||||
)
|
)
|
||||||
|
|
||||||
const dataCombined = data.map((historyitem) => ({
|
const dataCombined = data.map((historyitem) => sanitizeHistoryItem({
|
||||||
...historyitem,
|
...historyitem,
|
||||||
created_at: historyitem.createdAt,
|
created_at: historyitem.createdAt,
|
||||||
created_by: historyitem.createdBy,
|
created_by: historyitem.createdBy,
|
||||||
@@ -223,11 +224,11 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
const inserted = await server.db
|
const inserted = await server.db
|
||||||
.insert(historyitems)
|
.insert(historyitems)
|
||||||
.values({
|
.values({
|
||||||
text,
|
text: sanitizeHistoryText(text),
|
||||||
[fkField]: parseId(id),
|
[fkField]: parseId(id),
|
||||||
oldVal: old_val || null,
|
oldVal: sanitizeHistoryValue(old_val) || null,
|
||||||
newVal: new_val || null,
|
newVal: sanitizeHistoryValue(new_val) || null,
|
||||||
config: config || null,
|
config: sanitizeHistoryValue(config) || null,
|
||||||
tenant: (req.user as any)?.tenant_id,
|
tenant: (req.user as any)?.tenant_id,
|
||||||
createdBy: userId
|
createdBy: userId
|
||||||
})
|
})
|
||||||
@@ -238,10 +239,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(500).send({ error: "Failed to create history entry" });
|
return reply.code(500).send({ error: "Failed to create history entry" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send(sanitizeHistoryItem({
|
||||||
...data,
|
...data,
|
||||||
created_at: data.createdAt,
|
created_at: data.createdAt,
|
||||||
created_by: data.createdBy
|
created_by: data.createdBy
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FastifyInstance, FastifyRequest } from "fastify"
|
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||||
import multipart from "@fastify/multipart"
|
import multipart from "@fastify/multipart"
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { and, asc, eq, sql } from "drizzle-orm"
|
import { basename } from "node:path"
|
||||||
|
import { and, asc, eq, inArray, sql } from "drizzle-orm"
|
||||||
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
|
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
|
||||||
import { saveFile } from "../utils/files"
|
import { saveFile } from "../utils/files"
|
||||||
|
|
||||||
@@ -178,11 +179,12 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
|
|||||||
})
|
})
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(instanceAgentScanJobs.id, req.params.id),
|
eq(instanceAgentScanJobs.id, req.params.id),
|
||||||
eq(instanceAgentScanJobs.agentId, agent.id)
|
eq(instanceAgentScanJobs.agentId, agent.id),
|
||||||
|
inArray(instanceAgentScanJobs.status, ["pending", "running"])
|
||||||
))
|
))
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
if (!job) return reply.code(404).send({ error: "Scan job not found" })
|
if (!job) return reply.code(409).send({ error: "Scan job is no longer active or does not exist" })
|
||||||
|
|
||||||
return { job }
|
return { job }
|
||||||
})
|
})
|
||||||
@@ -209,7 +211,8 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
|
|||||||
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
|
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
|
||||||
|
|
||||||
const fileBuffer = await data.toBuffer()
|
const fileBuffer = await data.toBuffer()
|
||||||
const filename = job.requestedFilename || data.filename || `${job.id}.pdf`
|
// The agent normalizes the extension to the actual output format.
|
||||||
|
const filename = basename(data.filename || job.requestedFilename || `${job.id}.pdf`)
|
||||||
|
|
||||||
const createdFile = await saveFile(
|
const createdFile = await saveFile(
|
||||||
server,
|
server,
|
||||||
|
|||||||
@@ -90,10 +90,22 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
|||||||
createdAt: instanceAgents.createdAt,
|
createdAt: instanceAgents.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
reply.header("Cache-Control", "no-store")
|
||||||
agent,
|
return { agent, token }
|
||||||
token,
|
})
|
||||||
}
|
|
||||||
|
server.post<{ Params: { id: string } }>("/instance-agents/:id/token", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return
|
||||||
|
const id = z.string().uuid().parse(req.params.id)
|
||||||
|
const token = createAgentToken()
|
||||||
|
const [agent] = await server.db
|
||||||
|
.update(instanceAgents)
|
||||||
|
.set({ tokenPrefix: token.slice(0, 24), tokenHash: hashToken(token), lastSeenAt: null, updatedAt: new Date() })
|
||||||
|
.where(eq(instanceAgents.id, id))
|
||||||
|
.returning({ id: instanceAgents.id })
|
||||||
|
if (!agent) return reply.code(404).send({ error: "Agent not found" })
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
return { token }
|
||||||
})
|
})
|
||||||
|
|
||||||
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
|
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
|
||||||
@@ -140,6 +152,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
|||||||
active: instanceAgents.active,
|
active: instanceAgents.active,
|
||||||
preferredScannerName: instanceAgents.preferredScannerName,
|
preferredScannerName: instanceAgents.preferredScannerName,
|
||||||
scanDefaults: instanceAgents.scanDefaults,
|
scanDefaults: instanceAgents.scanDefaults,
|
||||||
|
capabilities: instanceAgents.capabilities,
|
||||||
})
|
})
|
||||||
.from(instanceAgents)
|
.from(instanceAgents)
|
||||||
.where(eq(instanceAgents.id, body.agentId))
|
.where(eq(instanceAgents.id, body.agentId))
|
||||||
@@ -149,6 +162,14 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "Active agent not found" })
|
return reply.code(404).send({ error: "Active agent not found" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settings = { ...((agent.scanDefaults || {}) as Record<string, any>), ...(body.settings || {}) }
|
||||||
|
const enabled = (value: unknown) => value === true || (typeof value === "string" && ["1", "true", "yes", "ja", "on"].includes(value.toLowerCase().trim()))
|
||||||
|
const capabilities = agent.capabilities as Record<string, any>
|
||||||
|
if (((settings.format || "pdf") === "pdf" && enabled(settings.ocr) && !capabilities?.ocr) ||
|
||||||
|
(enabled(settings.backgroundCleanup) && !capabilities?.backgroundCleanup)) {
|
||||||
|
return reply.code(409).send({ error: "Bitte den Scanner-Agenten mit Tesseract und OpenCV aktualisieren. Die angeforderte Scan-Verarbeitung ist dort noch nicht verfügbar." })
|
||||||
|
}
|
||||||
|
|
||||||
const [job] = await server.db
|
const [job] = await server.db
|
||||||
.insert(instanceAgentScanJobs)
|
.insert(instanceAgentScanJobs)
|
||||||
.values({
|
.values({
|
||||||
@@ -157,10 +178,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
|||||||
requestedBy: req.user?.user_id,
|
requestedBy: req.user?.user_id,
|
||||||
scannerName: body.scannerName || agent.preferredScannerName,
|
scannerName: body.scannerName || agent.preferredScannerName,
|
||||||
requestedFilename: body.requestedFilename,
|
requestedFilename: body.requestedFilename,
|
||||||
settings: {
|
settings,
|
||||||
...((agent.scanDefaults || {}) as Record<string, any>),
|
|
||||||
...(body.settings || {}),
|
|
||||||
},
|
|
||||||
target: body.target || {},
|
target: body.target || {},
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|||||||
@@ -198,34 +198,51 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await svc.trigger({
|
const devices = await server.db
|
||||||
tenantId,
|
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||||
userId: profile.userId,
|
.from(notificationMobilePushDevices)
|
||||||
eventType: "system.test_push",
|
.where(and(
|
||||||
title: "FEDEO Push ist aktiv",
|
eq(notificationMobilePushDevices.tenantId, tenantId),
|
||||||
message: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
|
eq(notificationMobilePushDevices.userId, profile.userId),
|
||||||
payload: {
|
isNull(notificationMobilePushDevices.disabledAt)
|
||||||
link: "/",
|
))
|
||||||
icon: "/favicon.ico",
|
|
||||||
},
|
|
||||||
channels: ["push"],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.created === 0) {
|
if (!devices.length) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: "Pushnachrichten sind für diesen Mitarbeiter deaktiviert",
|
error: "Für diesen Mitarbeiter ist kein aktives mobiles Push-Gerät registriert",
|
||||||
result,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || result.delivered === 0) {
|
try {
|
||||||
return reply.code(424).send({
|
const result = await pushServerClient.sendPush({
|
||||||
error: "Die Pushnachricht konnte an kein aktives Gerät zugestellt werden",
|
idempotencyKey: `profile-mobile-test:${tenantId}:${profile.userId}:${Date.now()}`,
|
||||||
|
devices: devices.map((device) => device.centralDeviceId),
|
||||||
|
priority: "high",
|
||||||
|
ttlSeconds: 600,
|
||||||
|
notification: {
|
||||||
|
title: "FEDEO Push ist aktiv",
|
||||||
|
body: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: "system.test_mobile_push",
|
||||||
|
link: "/",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.accepted === 0) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: "Der zentrale Push-Server hat kein Gerät zur Zustellung angenommen",
|
||||||
result,
|
result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.error({ err: error, profileId, userId: profile.userId }, "Mitarbeiter-Test-Push fehlgeschlagen")
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: error?.message || "Der zentrale Push-Server konnte die Nachricht nicht annehmen",
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post("/notifications/trigger", async (req, reply) => {
|
server.post("/notifications/trigger", async (req, reply) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
authProfiles,
|
authProfiles,
|
||||||
costcentres,
|
|
||||||
createddocuments,
|
createddocuments,
|
||||||
customers,
|
customers,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
@@ -324,58 +324,6 @@ function validateMemberPayload(payload: Record<string, any>) {
|
|||||||
return null
|
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) {
|
function maskIban(iban: string) {
|
||||||
if (!iban) return ""
|
if (!iban) return ""
|
||||||
@@ -1035,7 +983,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
const numberRangeColumn = table[config.numberRangeHolder]
|
||||||
|
const tenantColumn = getTenantColumn(resource, table)
|
||||||
|
const result = await useNextNumberRangeNumber(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
numberRangeResource,
|
||||||
|
async (candidate, tx) => {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: table.id })
|
||||||
|
.from(table)
|
||||||
|
.where(and(
|
||||||
|
eq(tenantColumn, req.user!.tenant_id),
|
||||||
|
eq(numberRangeColumn, candidate)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return existing.length === 0
|
||||||
|
}
|
||||||
|
)
|
||||||
createData[config.numberRangeHolder] = result.usedNumber
|
createData[config.numberRangeHolder] = result.usedNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
backend/src/routes/selfhostUpdates.ts
Normal file
38
backend/src/routes/selfhostUpdates.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { applyBody, checkBody, createUpdaterClient, requestId, UpdaterError } from "../modules/selfhost-updater";
|
||||||
|
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
||||||
|
import { getBuildInfo } from "../utils/buildInfo";
|
||||||
|
|
||||||
|
export default async function selfhostUpdateRoutes(server: FastifyInstance, options: { client?: ReturnType<typeof createUpdaterClient> }) {
|
||||||
|
const client = options.client || createUpdaterClient();
|
||||||
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
|
reply.header("Cache-Control", "no-store");
|
||||||
|
await requireSystemAdmin(server, req, reply);
|
||||||
|
});
|
||||||
|
const forward = async (reply: any, method: "GET" | "POST", path: string, body?: unknown) => {
|
||||||
|
try {
|
||||||
|
const result = await client(method, path, body);
|
||||||
|
return reply.code(result.status).send(path === "/v1/status"
|
||||||
|
? { ...result.body as object, backend: getBuildInfo() } : result.body);
|
||||||
|
} catch (error) {
|
||||||
|
const safe = error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE");
|
||||||
|
return reply.code(safe.status).send({ error: { code: safe.code, message: safe.message } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
server.get("/admin/updates/status", async (_req, reply) => forward(reply, "GET", "/v1/status"));
|
||||||
|
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id", async (req, reply) => {
|
||||||
|
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||||
|
return forward(reply, "GET", `/v1/requests/${req.params.id}`);
|
||||||
|
});
|
||||||
|
for (const command of ["check", "apply"] as const) {
|
||||||
|
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
|
||||||
|
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF).
|
||||||
|
if (!/^Bearer \S{11,}$/.test(req.headers.authorization || "")) {
|
||||||
|
return reply.code(403).send({ error: { code: "BEARER_REQUIRED", message: "Bitte erneut anmelden." } });
|
||||||
|
}
|
||||||
|
const parsed = (command === "apply" ? applyBody : checkBody).safeParse(req.body);
|
||||||
|
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||||
|
return forward(reply, "POST", `/v1/${command}`, parsed.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/src/utils/buildInfo.ts
Normal file
7
backend/src/utils/buildInfo.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function getBuildInfo() {
|
||||||
|
return {
|
||||||
|
product: "FEDEO",
|
||||||
|
version: process.env.FEDEO_VERSION || "0.0.0-dev",
|
||||||
|
revision: process.env.FEDEO_REVISION || "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
vendors,
|
vendors,
|
||||||
bankaccounts,
|
bankaccounts,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
ownaccounts
|
ownaccounts,
|
||||||
|
costcentres
|
||||||
} from "../../../db/schema";
|
} from "../../../db/schema";
|
||||||
|
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
@@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
|
|||||||
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
|
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBankAccountBookingLine = (alloc: any) => {
|
||||||
|
const bs = alloc.bankstatement;
|
||||||
|
const account = alloc.account || alloc.ownaccount;
|
||||||
|
const columns = Array(124).fill("");
|
||||||
|
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
|
||||||
|
columns[0] = displayCurrency(Number(alloc.amount), true);
|
||||||
|
columns[1] = quote(Number(alloc.amount) < 0 ? "H" : "S");
|
||||||
|
columns[6] = bs.account?.datevNumber || "";
|
||||||
|
columns[7] = account.number;
|
||||||
|
columns[8] = quote(alloc.datevTaxKey || "");
|
||||||
|
columns[9] = formatDatevDate(bs.date, "DDMM");
|
||||||
|
columns[13] = quote(`${Number(alloc.amount) > 0 ? "ZE" : "ZA"} ${account.number} - ${account.label || account.name || ""} ${alloc.description || ""} ${bs.text || ""}`.substring(0, 59));
|
||||||
|
columns[20] = quote("Geschäftspartner");
|
||||||
|
columns[21] = quote(bs.debName || bs.credName || "");
|
||||||
|
columns[28] = quote("Belegdatum");
|
||||||
|
columns[29] = quote(formatDatevDate(bs.date, "DD.MM.YYYY"));
|
||||||
|
columns[36] = quote(alloc.costcentre?.number || "");
|
||||||
|
columns[47] = "Bank-Id";
|
||||||
|
columns[48] = bs.id;
|
||||||
|
columns[112] = "0";
|
||||||
|
return columns.join(";");
|
||||||
|
};
|
||||||
|
|
||||||
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
||||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||||
|
|
||||||
@@ -136,7 +160,7 @@ export async function buildExportZip(
|
|||||||
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
|
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
|
||||||
|
|
||||||
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
|
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
|
||||||
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
|
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// 1. DATEN LADEN (CORE API SELECT & JOIN)
|
// 1. DATEN LADEN (CORE API SELECT & JOIN)
|
||||||
@@ -209,6 +233,7 @@ export async function buildExportZip(
|
|||||||
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
||||||
direct_cust: customers, // Direkte Zuordnung an Debitor
|
direct_cust: customers, // Direkte Zuordnung an Debitor
|
||||||
own: ownaccounts,
|
own: ownaccounts,
|
||||||
|
costcentre: costcentres,
|
||||||
contra_acc: ContraAccount,
|
contra_acc: ContraAccount,
|
||||||
contra_vend: ContraVendor,
|
contra_vend: ContraVendor,
|
||||||
contra_cust: ContraCustomer,
|
contra_cust: ContraCustomer,
|
||||||
@@ -233,6 +258,7 @@ export async function buildExportZip(
|
|||||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||||
|
.leftJoin(costcentres, and(eq(statementallocations.costcentre, costcentres.id), eq(costcentres.tenant, tenantId)))
|
||||||
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
|
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
|
||||||
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
|
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
|
||||||
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
|
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
|
||||||
@@ -257,10 +283,10 @@ export async function buildExportZip(
|
|||||||
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
||||||
const statementallocationsList = allocRaw.map(r => ({
|
const statementallocationsList = allocRaw.map(r => ({
|
||||||
...r.allocation,
|
...r.allocation,
|
||||||
bankstatement: {
|
bankstatement: r.bs ? {
|
||||||
...r.bs,
|
...r.bs,
|
||||||
account: r.ba // Nesting für bs.account.datevNumber
|
account: r.ba // Nesting für bs.account.datevNumber
|
||||||
},
|
} : null,
|
||||||
createddocument: r.cd ? {
|
createddocument: r.cd ? {
|
||||||
...r.cd,
|
...r.cd,
|
||||||
customer: r.cd_cust
|
customer: r.cd_cust
|
||||||
@@ -273,6 +299,7 @@ export async function buildExportZip(
|
|||||||
vendor: r.direct_vend,
|
vendor: r.direct_vend,
|
||||||
customer: r.direct_cust,
|
customer: r.direct_cust,
|
||||||
ownaccount: r.own,
|
ownaccount: r.own,
|
||||||
|
costcentre: r.costcentre,
|
||||||
contraAccount: r.contra_acc,
|
contraAccount: r.contra_acc,
|
||||||
contraVendor: r.contra_vend,
|
contraVendor: r.contra_vend,
|
||||||
contraCustomer: r.contra_cust,
|
contraCustomer: r.contra_cust,
|
||||||
@@ -453,9 +480,7 @@ export async function buildExportZip(
|
|||||||
const vend = ii.vendor;
|
const vend = ii.vendor;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
} else if(alloc.account) {
|
} else if(alloc.account) {
|
||||||
const acc = alloc.account;
|
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
|
||||||
} else if(alloc.vendor) {
|
} else if(alloc.vendor) {
|
||||||
const vend = alloc.vendor;
|
const vend = alloc.vendor;
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
@@ -465,9 +490,7 @@ export async function buildExportZip(
|
|||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
} else if(alloc.ownaccount) {
|
} else if(alloc.ownaccount) {
|
||||||
const own = alloc.ownaccount;
|
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { eq, sql } from "drizzle-orm"
|
|||||||
export const useNextNumberRangeNumber = async (
|
export const useNextNumberRangeNumber = async (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
numberRange: string
|
numberRange: string,
|
||||||
|
isAvailable: (candidate: string, tx: any) => Promise<boolean> = async () => true
|
||||||
) => {
|
) => {
|
||||||
const numberRangeFallbacks: Record<string, string> = {
|
const numberRangeFallbacks: Record<string, string> = {
|
||||||
costEstimates: "quotes",
|
costEstimates: "quotes",
|
||||||
@@ -44,18 +45,39 @@ export const useNextNumberRangeNumber = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = numberRanges[resolvedNumberRange]
|
const current = numberRanges[resolvedNumberRange]
|
||||||
|
let nextNumber = Number(current.nextNumber)
|
||||||
|
|
||||||
const usedNumber =
|
if (!Number.isFinite(nextNumber)) {
|
||||||
|
throw new Error(`Number range '${resolvedNumberRange}' has an invalid nextNumber`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let usedNumber = ""
|
||||||
|
const maxAttempts = 10000
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
const candidate =
|
||||||
(current.prefix || "") +
|
(current.prefix || "") +
|
||||||
current.nextNumber +
|
nextNumber +
|
||||||
(current.suffix || "")
|
(current.suffix || "")
|
||||||
|
|
||||||
|
if (await isAvailable(candidate, tx)) {
|
||||||
|
usedNumber = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
nextNumber++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usedNumber) {
|
||||||
|
throw new Error(`Could not find an available number in range '${resolvedNumberRange}'`)
|
||||||
|
}
|
||||||
|
|
||||||
const updatedRanges = {
|
const updatedRanges = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...numberRanges,
|
...numberRanges,
|
||||||
[resolvedNumberRange]: {
|
[resolvedNumberRange]: {
|
||||||
...current,
|
...current,
|
||||||
nextNumber: current.nextNumber + 1,
|
nextNumber: nextNumber + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { historyitems } from "../../db/schema";
|
import { historyitems } from "../../db/schema";
|
||||||
|
import { sanitizeHistoryText, sanitizeHistoryValue } from "./historySanitization";
|
||||||
|
|
||||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||||
customers: "Kunden",
|
customers: "Kunden",
|
||||||
@@ -114,11 +115,11 @@ export async function insertHistoryItem(
|
|||||||
const entry = {
|
const entry = {
|
||||||
tenant: params.tenant_id,
|
tenant: params.tenant_id,
|
||||||
createdBy: params.created_by,
|
createdBy: params.created_by,
|
||||||
text: params.text || textMap[params.action],
|
text: sanitizeHistoryText(params.text || textMap[params.action]),
|
||||||
action: params.action,
|
action: params.action,
|
||||||
[fkColumn]: params.entityId,
|
[fkColumn]: params.entityId,
|
||||||
oldVal: stringifyHistoryValue(params.oldVal),
|
oldVal: stringifyHistoryValue(sanitizeHistoryValue(params.oldVal)),
|
||||||
newVal: stringifyHistoryValue(params.newVal)
|
newVal: stringifyHistoryValue(sanitizeHistoryValue(params.newVal))
|
||||||
}
|
}
|
||||||
|
|
||||||
await server.db.insert(historyitems).values(entry as any)
|
await server.db.insert(historyitems).values(entry as any)
|
||||||
|
|||||||
45
backend/src/utils/historySanitization.ts
Normal file
45
backend/src/utils/historySanitization.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const IBAN_IN_TEXT_PATTERN = /\b([A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]){11,30})(?=["'\]},.;:!?)]|$)/gi
|
||||||
|
|
||||||
|
export function maskIban(iban: string): string {
|
||||||
|
const normalized = iban.replace(/[\s-]+/g, "").toUpperCase()
|
||||||
|
if (normalized.length <= 8) return normalized
|
||||||
|
return `${normalized.slice(0, 4)} **** **** ${normalized.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHistoryText(text: string): string {
|
||||||
|
return text.replace(IBAN_IN_TEXT_PATTERN, (candidate) => maskIban(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIbanField(value: any): any {
|
||||||
|
if (typeof value === "string") return maskIban(value)
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeIbanField)
|
||||||
|
return sanitizeHistoryValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHistoryValue(value: any): any {
|
||||||
|
if (typeof value === "string") return sanitizeHistoryText(value)
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeHistoryValue)
|
||||||
|
if (!value || typeof value !== "object" || value instanceof Date) return value
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
if (prototype !== Object.prototype && prototype !== null) return value
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, nestedValue]) => [
|
||||||
|
key,
|
||||||
|
key.toLowerCase().includes("iban")
|
||||||
|
? sanitizeIbanField(nestedValue)
|
||||||
|
: sanitizeHistoryValue(nestedValue),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHistoryItem<T extends Record<string, any>>(item: T): T {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
text: typeof item.text === "string" ? sanitizeHistoryText(item.text) : item.text,
|
||||||
|
oldVal: sanitizeHistoryValue(item.oldVal),
|
||||||
|
newVal: sanitizeHistoryValue(item.newVal),
|
||||||
|
config: sanitizeHistoryValue(item.config),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/utils/requireSystemAdmin.ts
Normal file
18
backend/src/utils/requireSystemAdmin.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { authUsers } from "../../db/schema/auth_users";
|
||||||
|
|
||||||
|
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
if (!req.user?.user_id) {
|
||||||
|
reply.code(401).send({ error: "Unauthorized" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Read the current global role; tenant roles and stale JWT claims do not grant access.
|
||||||
|
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
|
||||||
|
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
|
||||||
|
if (!user?.is_admin) {
|
||||||
|
reply.code(403).send({ error: "Admin access required" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -147,6 +147,7 @@ export const resourceConfig = {
|
|||||||
},
|
},
|
||||||
products: {
|
products: {
|
||||||
table: products,
|
table: products,
|
||||||
|
mtoLoad: ["unit"],
|
||||||
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
||||||
},
|
},
|
||||||
productcategories: {
|
productcategories: {
|
||||||
|
|||||||
69
backend/tests/bankAllocationDetails.test.ts
Normal file
69
backend/tests/bankAllocationDetails.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details"
|
||||||
|
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||||||
|
import bankingRoutes from "../src/routes/banking"
|
||||||
|
import { statementallocations } from "../db/schema"
|
||||||
|
|
||||||
|
const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba"
|
||||||
|
|
||||||
|
test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => {
|
||||||
|
const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " })
|
||||||
|
assert.equal(prepared.amount, -119)
|
||||||
|
assert.equal(prepared.costcentre, costcentre)
|
||||||
|
assert.equal(prepared.datevTaxKey, "9")
|
||||||
|
assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => {
|
||||||
|
assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/)
|
||||||
|
assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/)
|
||||||
|
assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cost centre lookup requires the active tenant and excludes archived centres", async () => {
|
||||||
|
let condition: any
|
||||||
|
const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] }
|
||||||
|
await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/)
|
||||||
|
const sql = new PgDialect().sqlToQuery(condition)
|
||||||
|
assert.match(sql.sql, /"costcentres"\."tenant"/)
|
||||||
|
assert.match(sql.sql, /"costcentres"\."archived"/)
|
||||||
|
assert.deepEqual(sql.params, [costcentre, 7, false])
|
||||||
|
await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre)
|
||||||
|
await validateBankAllocationCostCentre({}, 7, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => {
|
||||||
|
const handlers = new Map<string, any>()
|
||||||
|
const saved: any[] = []
|
||||||
|
let centreExists = true
|
||||||
|
const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] }
|
||||||
|
const server: any = {
|
||||||
|
get() {}, patch() {}, delete() {},
|
||||||
|
post: (url: string, handler: any) => handlers.set(url, handler),
|
||||||
|
db: {
|
||||||
|
select: () => lookup,
|
||||||
|
insert: (table: any) => ({ values: (value: any) => {
|
||||||
|
if (table === statementallocations) saved.push(value)
|
||||||
|
return { returning: async () => [{ id: "allocation", ...value }] }
|
||||||
|
} }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await bankingRoutes(server)
|
||||||
|
const handler = handlers.get("/banking/statements")
|
||||||
|
const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } }
|
||||||
|
let status = 200
|
||||||
|
let response: any
|
||||||
|
const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } }
|
||||||
|
await handler(request, reply)
|
||||||
|
assert.equal(status, 200)
|
||||||
|
assert.equal(response.amount, -119)
|
||||||
|
assert.equal(response.costcentre, costcentre)
|
||||||
|
assert.equal(response.datevTaxKey, "9")
|
||||||
|
assert.equal(saved[0].tenant, 7)
|
||||||
|
centreExists = false
|
||||||
|
await handler(request, reply)
|
||||||
|
assert.equal(status, 400)
|
||||||
|
assert.equal(saved.length, 1)
|
||||||
|
})
|
||||||
47
backend/tests/buildInfo.test.ts
Normal file
47
backend/tests/buildInfo.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import healthRoutes from '../src/routes/health';
|
||||||
|
|
||||||
|
test('health checks the database and reports a failure without leaking connection details', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
let available = true;
|
||||||
|
app.decorate('db', { execute: async () => {
|
||||||
|
if (!available) throw new Error('postgres://secret@database');
|
||||||
|
return { rows: [{ result: 1 }] };
|
||||||
|
} } as any);
|
||||||
|
try {
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
const healthy = await app.inject('/health');
|
||||||
|
assert.equal(healthy.statusCode, 200);
|
||||||
|
assert.equal(healthy.json().status, 'ok');
|
||||||
|
assert.equal(healthy.headers['cache-control'], 'no-store');
|
||||||
|
available = false;
|
||||||
|
const unhealthy = await app.inject('/health');
|
||||||
|
assert.equal(unhealthy.statusCode, 503);
|
||||||
|
assert.deepEqual(unhealthy.json(), { status: 'unavailable' });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version endpoint reports image metadata without a database or authentication', async () => {
|
||||||
|
const previousVersion = process.env.FEDEO_VERSION;
|
||||||
|
const previousRevision = process.env.FEDEO_REVISION;
|
||||||
|
process.env.FEDEO_VERSION = '1.2.3';
|
||||||
|
process.env.FEDEO_REVISION = 'a'.repeat(40);
|
||||||
|
const app = Fastify();
|
||||||
|
try {
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/version' });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
|
assert.deepEqual(response.json(), { product: 'FEDEO', version: '1.2.3', revision: 'a'.repeat(40) });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
if (previousVersion === undefined) delete process.env.FEDEO_VERSION;
|
||||||
|
else process.env.FEDEO_VERSION = previousVersion;
|
||||||
|
if (previousRevision === undefined) delete process.env.FEDEO_REVISION;
|
||||||
|
else process.env.FEDEO_REVISION = previousRevision;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
|
import { buildExportZip, getBankAccountBookingLine, getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
|
||||||
|
import { Uint8ArrayReader, TextWriter, ZipReader } from "@zip.js/zip.js";
|
||||||
|
import { statementallocations } from "../db/schema";
|
||||||
|
|
||||||
test("exports gross amounts for DATEV automatic revenue accounts", () => {
|
test("exports gross amounts for DATEV automatic revenue accounts", () => {
|
||||||
const revenueLines = getCreatedDocumentRevenueLines({
|
const revenueLines = getCreatedDocumentRevenueLines({
|
||||||
@@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => {
|
|||||||
|
|
||||||
assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]);
|
assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const [amount, taxKey, side] of [[-119, "9", "H"], [-107, "8", "H"], [119, "3", "S"], [107, "2", "S"], [119, "9", "S"], [-119, "3", "H"], [-100, "40", "H"]] as const) {
|
||||||
|
test(`exports gross ${amount} with tax key ${taxKey} and cost centre`, () => {
|
||||||
|
const columns = getBankAccountBookingLine({
|
||||||
|
amount, datevTaxKey: taxKey, account: { number: 4900, label: "Konto" },
|
||||||
|
costcentre: { id: "internal-uuid", number: "KST-1001" },
|
||||||
|
bankstatement: { id: 42, date: "2026-09-23", account: { datevNumber: "1200" } },
|
||||||
|
}).split(";");
|
||||||
|
assert.equal(columns.length, 124);
|
||||||
|
assert.equal(columns[0], `${Math.abs(amount)},00`);
|
||||||
|
assert.equal(columns[1], `"${side}"`);
|
||||||
|
assert.equal(columns[6], "1200");
|
||||||
|
assert.equal(columns[7], "4900");
|
||||||
|
assert.equal(columns[8], `"${taxKey}"`);
|
||||||
|
assert.equal(columns[36], '"KST-1001"');
|
||||||
|
assert.equal(columns[112], "0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exports legacy allocations and additional accounts without inventing a tax key", () => {
|
||||||
|
const columns = getBankAccountBookingLine({
|
||||||
|
amount: -12.34, ownaccount: { number: "4901", name: "Zusatzkonto" },
|
||||||
|
bankstatement: { id: 1, date: "2026-09-23", account: { datevNumber: "1200" } },
|
||||||
|
}).split(";");
|
||||||
|
assert.equal(columns[0], "12,34");
|
||||||
|
assert.equal(columns[7], "4901");
|
||||||
|
assert.equal(columns[8], '""');
|
||||||
|
assert.equal(columns[36], '""');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ZIP export carries allocation metadata into the matching CSV columns and leaves invoice payments untaxed", async () => {
|
||||||
|
const bank = { id: 42, date: "2026-09-23", text: "Zahlung" };
|
||||||
|
const rows = [
|
||||||
|
{ allocation: { id: "a", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, acc: { number: 4900 }, costcentre: { number: "1001" } },
|
||||||
|
{ allocation: { id: "b", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, own: { number: 4901 }, costcentre: { number: "1002" } },
|
||||||
|
{ allocation: { id: "c", amount: -119, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, ii: { id: 3, date: bank.date, reference: "ER-3" }, ii_vend: { vendorNumber: 70001 } },
|
||||||
|
{ allocation: { id: "d", amount: 119, datevTaxKey: "9", manualBookingDate: bank.date }, bs: null, acc: { number: 4900 }, contra_acc: { number: 1200 } },
|
||||||
|
];
|
||||||
|
const db = {
|
||||||
|
select: () => {
|
||||||
|
let result: any[] = [];
|
||||||
|
const query: any = {
|
||||||
|
from: (table: any) => { result = table === statementallocations ? rows : []; return query; },
|
||||||
|
leftJoin: () => query, where: () => query, orderBy: () => query,
|
||||||
|
then: (resolve: any) => Promise.resolve(result).then(resolve),
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const archive = await buildExportZip({ db } as any, 1, "2026-09-01", "2026-09-30", "123", "456");
|
||||||
|
const reader = new ZipReader(new Uint8ArrayReader(archive));
|
||||||
|
try {
|
||||||
|
const entries = await reader.getEntries();
|
||||||
|
const csvEntry = entries.find(entry => entry.filename.startsWith("EXTF_Buchungsstapel"));
|
||||||
|
assert.ok(csvEntry && "getData" in csvEntry);
|
||||||
|
const csv = await csvEntry.getData!(new TextWriter());
|
||||||
|
const [, header, ...bookings] = csv.trim().split("\n").map(line => line.split(";"));
|
||||||
|
assert.equal(bookings.length, 4);
|
||||||
|
assert.equal(header.length, 124);
|
||||||
|
for (const [i, costcentre] of ["1001", "1002"].entries()) {
|
||||||
|
assert.equal(bookings[i].length, header.length);
|
||||||
|
assert.equal(bookings[i][header.indexOf("Umsatz")], "59,50");
|
||||||
|
assert.equal(bookings[i][header.indexOf("BU-Schluessel")], '"9"');
|
||||||
|
assert.equal(bookings[i][header.indexOf("KOST1 - Kostenstelle")], `"${costcentre}"`);
|
||||||
|
assert.equal(bookings[i][header.indexOf("Festschreibung")], "0");
|
||||||
|
}
|
||||||
|
assert.equal(bookings[2][8], '""');
|
||||||
|
assert.equal(bookings[3][8], '"9"');
|
||||||
|
assert.equal(bookings[3][6], "4900");
|
||||||
|
assert.equal(bookings[3][7], "1200");
|
||||||
|
} finally {
|
||||||
|
await reader.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
55
backend/tests/historySanitization.test.ts
Normal file
55
backend/tests/historySanitization.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import test from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
|
||||||
|
import {
|
||||||
|
maskIban,
|
||||||
|
sanitizeHistoryItem,
|
||||||
|
sanitizeHistoryText,
|
||||||
|
sanitizeHistoryValue,
|
||||||
|
} from "../src/utils/historySanitization"
|
||||||
|
|
||||||
|
const IBAN = "DE89370400440532013000"
|
||||||
|
|
||||||
|
test("masks all but the first and last four IBAN characters", () => {
|
||||||
|
assert.equal(maskIban(IBAN), "DE89 **** **** 3000")
|
||||||
|
assert.equal(maskIban("DE89 3704 0044 0532 0130 00"), "DE89 **** **** 3000")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("masks IBAN values in nested history data", () => {
|
||||||
|
const sanitized = sanitizeHistoryValue({
|
||||||
|
name: "Beispielkunde",
|
||||||
|
infoData: {
|
||||||
|
bankingIban: IBAN,
|
||||||
|
bankingIbans: [IBAN, "AT61 1904 3002 3457 3201"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(sanitized, {
|
||||||
|
name: "Beispielkunde",
|
||||||
|
infoData: {
|
||||||
|
bankingIban: "DE89 **** **** 3000",
|
||||||
|
bankingIbans: ["DE89 **** **** 3000", "AT61 **** **** 3201"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("masks IBANs embedded in generated history text", () => {
|
||||||
|
const text = `Kunden: Info Daten geändert von "{\"bankingIbans\":[\"${IBAN}\"]}"`
|
||||||
|
const sanitized = sanitizeHistoryText(text)
|
||||||
|
|
||||||
|
assert.equal(sanitized.includes(IBAN), false)
|
||||||
|
assert.equal(sanitized.includes("DE89 **** **** 3000"), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sanitizes existing history items before they are returned", () => {
|
||||||
|
const sanitized = sanitizeHistoryItem({
|
||||||
|
text: `IBAN: ${IBAN}.`,
|
||||||
|
oldVal: JSON.stringify({ bankingIban: IBAN }),
|
||||||
|
newVal: { iban: IBAN },
|
||||||
|
config: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(sanitized.text, "IBAN: DE89 **** **** 3000.")
|
||||||
|
assert.equal(sanitized.oldVal.includes(IBAN), false)
|
||||||
|
assert.deepEqual(sanitized.newVal, { iban: "DE89 **** **** 3000" })
|
||||||
|
})
|
||||||
89
backend/tests/instanceAgentGateway.test.ts
Normal file
89
backend/tests/instanceAgentGateway.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import path from "node:path"
|
||||||
|
import vm from "node:vm"
|
||||||
|
import ts from "typescript"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { getTableColumns, getTableName } from "drizzle-orm"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
// Load the real route with only file storage replaced; tests never contact S3 or a live database.
|
||||||
|
const routePath = path.resolve(__dirname, "../src/routes/instanceAgentGateway.ts")
|
||||||
|
const routeRequire = createRequire(routePath)
|
||||||
|
const compiled = ts.transpileModule(readFileSync(routePath, "utf8"), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
const fixture = async (status = "running") => {
|
||||||
|
const token = "test-agent-token"
|
||||||
|
const agent = { id: "agent", tokenHash: createHash("sha256").update(token).digest("hex"), active: true }
|
||||||
|
const job: any = { id: "job", agentId: "agent", tenantId: 7, requestedBy: "user", requestedFilename: "scan.png", target: { folder: "folder" }, status }
|
||||||
|
const saved: any[] = []
|
||||||
|
const rows: Record<string, any[]> = { instance_agents: [agent], instance_agent_scan_jobs: [job] }
|
||||||
|
const dialect = new PgDialect()
|
||||||
|
const matching = (table: any, condition: any) => {
|
||||||
|
const query = dialect.sqlToQuery(condition)
|
||||||
|
const columns = getTableColumns(table) as Record<string, any>
|
||||||
|
const clauses = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)]
|
||||||
|
assert.ok(clauses.length, query.sql)
|
||||||
|
return rows[getTableName(table)].filter(row => clauses.every(match => {
|
||||||
|
const key = Object.keys(columns).find(key => columns[key].name === match[1])!
|
||||||
|
if (match[2]) return row[key] === query.params[Number(match[2]) - 1]
|
||||||
|
return [...match[3].matchAll(/\$(\d+)/g)].some(parameter => row[key] === query.params[Number(parameter[1]) - 1])
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: (table: any) => ({ where: (where: any) => ({ limit: async () => matching(table, where) }) }) }),
|
||||||
|
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({ returning: async () => matching(table, where).map(row => Object.assign(row, data)) }) }) }),
|
||||||
|
}
|
||||||
|
const exports: any = {}
|
||||||
|
vm.runInNewContext(compiled, {
|
||||||
|
exports,
|
||||||
|
require: (name: string) => name === "../utils/files"
|
||||||
|
? { saveFile: async (...args: any[]) => { saved.push(args); return { id: "saved-file" } } }
|
||||||
|
: routeRequire(name),
|
||||||
|
})
|
||||||
|
const app = Fastify()
|
||||||
|
app.decorate("db", db)
|
||||||
|
await app.register(exports.default)
|
||||||
|
return { app, job, saved, headers: { "x-agent-token": token } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("late failure status cannot overwrite an already completed scan", async (t) => {
|
||||||
|
const f = await fixture("completed")
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status: "failed", message: "upload timeout" } })
|
||||||
|
assert.equal(response.statusCode, 409)
|
||||||
|
assert.equal(f.job.status, "completed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("running jobs accept progress and scanner failures", async (t) => {
|
||||||
|
const f = await fixture()
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
for (const status of ["running", "failed"]) {
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status, message: "4 Seiten" } })
|
||||||
|
assert.equal(response.statusCode, 200)
|
||||||
|
assert.equal(f.job.status, status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("upload preserves the actual PDF filename, tenant and target folder", async (t) => {
|
||||||
|
const f = await fixture()
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const boundary = "fedeo-test-boundary"
|
||||||
|
const response = await f.app.inject({
|
||||||
|
method: "POST", url: "/scan-jobs/job/upload",
|
||||||
|
headers: { ...f.headers, "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="scan.pdf"\r\nContent-Type: application/pdf\r\n\r\n%PDF-test\r\n--${boundary}--\r\n`,
|
||||||
|
})
|
||||||
|
assert.equal(response.statusCode, 200, response.body)
|
||||||
|
assert.equal(f.saved[0][1], 7)
|
||||||
|
assert.equal(f.saved[0][3].filename, "scan.pdf")
|
||||||
|
assert.equal(f.saved[0][3].contentType, "application/pdf")
|
||||||
|
assert.equal(f.saved[0][4], "folder")
|
||||||
|
assert.equal(f.job.status, "completed")
|
||||||
|
assert.equal(f.job.fileId, "saved-file")
|
||||||
|
})
|
||||||
133
backend/tests/instanceAgentMigration.test.ts
Normal file
133
backend/tests/instanceAgentMigration.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { execFile } from "node:child_process"
|
||||||
|
import { promisify } from "node:util"
|
||||||
|
import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||||
|
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import routes from "../src/routes/instanceAgents"
|
||||||
|
|
||||||
|
const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep
|
||||||
|
const repairTag = "0072_restore_instance_scan_agents"
|
||||||
|
const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries
|
||||||
|
const repair = journal.find((entry: any) => entry.tag === repairTag)
|
||||||
|
const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when))
|
||||||
|
const execute = promisify(execFile)
|
||||||
|
|
||||||
|
test("the scanner repair is registered after every previously shipped migration", () => {
|
||||||
|
assert.ok(repair, "scanner repair must be in the Drizzle journal")
|
||||||
|
assert.ok(repair.when > previousWatermark)
|
||||||
|
const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark)
|
||||||
|
assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own
|
||||||
|
// disposable database container; it never reads DATABASE_URL or application secrets.
|
||||||
|
test("migration repairs upgraded databases and preserves manually installed scanners", {
|
||||||
|
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
|
||||||
|
}, async t => {
|
||||||
|
// Isolate this repair from unrelated future migrations. Registration in the
|
||||||
|
// actual shipped journal is checked above; its timestamp remains unchanged.
|
||||||
|
const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-"))
|
||||||
|
t.after(() => rm(testMigrations, { recursive: true, force: true }))
|
||||||
|
await mkdir(path.join(testMigrations, "meta"))
|
||||||
|
await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] }))
|
||||||
|
await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql"))
|
||||||
|
const container = "fedeo-scan-migration-" + randomUUID()
|
||||||
|
const password = randomUUID()
|
||||||
|
await execute("docker", ["run", "--rm", "-d", "--name", container,
|
||||||
|
"-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432",
|
||||||
|
process.env.FEDEO_TEST_POSTGRES_IMAGE!])
|
||||||
|
let admin: Pool | undefined
|
||||||
|
t.after(async () => {
|
||||||
|
try { await admin?.end() }
|
||||||
|
finally { await execute("docker", ["rm", "-f", container]) }
|
||||||
|
})
|
||||||
|
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
|
||||||
|
const port = Number(stdout.trim().split(":").at(-1))
|
||||||
|
admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try { await admin.query("SELECT 1"); break } catch (error) {
|
||||||
|
if (attempt >= 80) throw error
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const state of ["absent", "0051", "0052", "0053"]) {
|
||||||
|
await t.test(state, async () => {
|
||||||
|
const database = "scan_" + state
|
||||||
|
await admin.query('CREATE DATABASE "' + database + '"')
|
||||||
|
const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 })
|
||||||
|
const app = Fastify()
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE tenants (id bigint PRIMARY KEY);
|
||||||
|
CREATE TABLE auth_users (id uuid PRIMARY KEY);
|
||||||
|
CREATE TABLE files (id uuid PRIMARY KEY);
|
||||||
|
INSERT INTO tenants VALUES (1);
|
||||||
|
CREATE SCHEMA drizzle;
|
||||||
|
CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||||
|
`)
|
||||||
|
// An existing installation already passed 0071. Re-inserting old
|
||||||
|
// journal entries 0051-0053 would therefore NOT fix this database.
|
||||||
|
await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark])
|
||||||
|
if (state !== "absent") {
|
||||||
|
for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) {
|
||||||
|
if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8"))
|
||||||
|
}
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO instance_agents (id, name, token_prefix, token_hash)
|
||||||
|
VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash');
|
||||||
|
INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings)
|
||||||
|
VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}');
|
||||||
|
`)
|
||||||
|
if (state !== "0051") {
|
||||||
|
await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = drizzle(pool)
|
||||||
|
await migrate(db, { migrationsFolder: testMigrations })
|
||||||
|
await migrate(db, { migrationsFolder: testMigrations })
|
||||||
|
// Also prove the repair SQL itself is safe after manual application.
|
||||||
|
await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8"))
|
||||||
|
app.decorate("db", db)
|
||||||
|
app.addHook("onRequest", async req => { req.user = { is_admin: true } as any })
|
||||||
|
await app.register(routes)
|
||||||
|
const listing = await app.inject({ method: "GET", url: "/instance-agents" })
|
||||||
|
assert.equal(listing.statusCode, 200, listing.body)
|
||||||
|
const agents = listing.json().agents
|
||||||
|
assert.equal(agents.length, state === "absent" ? 0 : 1)
|
||||||
|
if (state !== "absent") {
|
||||||
|
assert.equal(agents[0].name, "Existing scanner")
|
||||||
|
assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash")
|
||||||
|
assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600)
|
||||||
|
if (state !== "0051") {
|
||||||
|
assert.equal(agents[0].preferredScannerName, "existing-device")
|
||||||
|
assert.deepEqual(agents[0].scanDefaults, { resolution: 600 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } })
|
||||||
|
assert.equal(created.statusCode, 200, created.body)
|
||||||
|
assert.equal(created.json().agent.scanDefaults.postprocess, false)
|
||||||
|
assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document")
|
||||||
|
assert.equal(created.json().agent.scanDefaults.format, "pdf")
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1)
|
||||||
|
const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname)
|
||||||
|
assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx"))
|
||||||
|
assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx"))
|
||||||
|
await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" })
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
84
backend/tests/instanceAgentSetup.test.ts
Normal file
84
backend/tests/instanceAgentSetup.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
import routes from "../src/routes/instanceAgents"
|
||||||
|
|
||||||
|
const id = "b48c0e33-1abd-4b11-89fa-1bb7b39a3057"
|
||||||
|
async function fixture(isAdmin: boolean) {
|
||||||
|
const updates: any[] = []
|
||||||
|
const app = Fastify()
|
||||||
|
app.addHook("onRequest", async (request) => { request.user = { is_admin: isAdmin } as any })
|
||||||
|
app.decorate("db", {
|
||||||
|
update: () => ({ set: (data: any) => ({ where: (condition: any) => ({ returning: async () => {
|
||||||
|
if (new PgDialect().sqlToQuery(condition).params[0] !== id) return []
|
||||||
|
updates.push(data)
|
||||||
|
return [{ id }]
|
||||||
|
} }) }) }),
|
||||||
|
})
|
||||||
|
await app.register(routes)
|
||||||
|
return { app, updates }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("only administrators can rotate an agent token", async (t) => {
|
||||||
|
const f = await fixture(false)
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/instance-agents/" + id + "/token" })
|
||||||
|
assert.equal(response.statusCode, 403)
|
||||||
|
assert.equal(f.updates.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rotation returns a new token, stores only its hash and requires a new heartbeat", async (t) => {
|
||||||
|
const f = await fixture(true)
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const tokens = []
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/instance-agents/" + id + "/token" })
|
||||||
|
assert.equal(response.statusCode, 200)
|
||||||
|
assert.equal(response.headers["cache-control"], "no-store")
|
||||||
|
const { token } = response.json()
|
||||||
|
assert.match(token, /^fedeo_agent_[a-f0-9]{64}$/)
|
||||||
|
assert.equal(f.updates[i].tokenHash, createHash("sha256").update(token).digest("hex"))
|
||||||
|
assert.equal(f.updates[i].tokenPrefix, token.slice(0, 24))
|
||||||
|
assert.equal(f.updates[i].lastSeenAt, null)
|
||||||
|
assert.equal(f.updates[i].token, undefined)
|
||||||
|
tokens.push(token)
|
||||||
|
}
|
||||||
|
assert.notEqual(tokens[0], tokens[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unknown agents cannot receive setup credentials", async (t) => {
|
||||||
|
const f = await fixture(true)
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/instance-agents/00000000-0000-4000-8000-000000000000/token" })
|
||||||
|
assert.equal(response.statusCode, 404)
|
||||||
|
assert.equal(f.updates.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("OCR and background cleanup require an agent that supports the requested processing", async t => {
|
||||||
|
const app = Fastify()
|
||||||
|
t.after(() => app.close())
|
||||||
|
const jobs: any[] = []
|
||||||
|
let capabilities: Record<string, boolean> = { scan: true }
|
||||||
|
app.addHook("onRequest", async request => { request.user = { is_admin: true, tenant_id: 1 } as any })
|
||||||
|
app.decorate("db", {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, active: true, scanDefaults: {}, capabilities }] }) }) }),
|
||||||
|
insert: () => ({ values: (data: any) => ({ returning: async () => { jobs.push(data); return [{ id: "job", ...data }] } }) }),
|
||||||
|
})
|
||||||
|
await app.register(routes)
|
||||||
|
for (const settings of [{ ocr: true }, { ocr: "true" }, { backgroundCleanup: true }]) {
|
||||||
|
const response = await app.inject({ method: "POST", url: "/scan-jobs", payload: { agentId: id, settings } })
|
||||||
|
assert.equal(response.statusCode, 409)
|
||||||
|
assert.equal(jobs.length, 0)
|
||||||
|
}
|
||||||
|
capabilities = { scan: true, ocr: true, backgroundCleanup: true }
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST", url: "/scan-jobs",
|
||||||
|
payload: { agentId: id, settings: { ocr: true, ocrLanguage: "deu+eng", backgroundCleanup: true }, target: { folder: null, type: null } },
|
||||||
|
})
|
||||||
|
assert.equal(response.statusCode, 200)
|
||||||
|
assert.equal(jobs.length, 1)
|
||||||
|
assert.equal(jobs[0].settings.ocr, true)
|
||||||
|
assert.equal(jobs[0].target.type, null)
|
||||||
|
})
|
||||||
37
backend/tests/mcpArchivedResults.test.ts
Normal file
37
backend/tests/mcpArchivedResults.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { mcpTools } from "../src/mcp/registry"
|
||||||
|
import { asToolResult } from "../src/mcp/result"
|
||||||
|
|
||||||
|
test("removes archived records from MCP text and structured output", () => {
|
||||||
|
const result = asToolResult({
|
||||||
|
rows: [
|
||||||
|
{ id: 1, archived: false, name: "Aktiv" },
|
||||||
|
{ id: 2, archived: true, name: "Archiviert" },
|
||||||
|
],
|
||||||
|
nested: {
|
||||||
|
current: { id: 3, archived: false },
|
||||||
|
previous: { id: 4, archived: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(result.structuredContent, {
|
||||||
|
rows: [{ id: 1, archived: false, name: "Aktiv" }],
|
||||||
|
nested: { current: { id: 3, archived: false } },
|
||||||
|
})
|
||||||
|
assert.deepEqual(JSON.parse(result.content[0].text), result.structuredContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not expose a singular archived record", () => {
|
||||||
|
const result = asToolResult({ task: { id: 1, archived: true } })
|
||||||
|
|
||||||
|
assert.deepEqual(result.structuredContent, {})
|
||||||
|
assert.deepEqual(JSON.parse(result.content[0].text), {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not advertise includeArchived on MCP tools", () => {
|
||||||
|
for (const tool of mcpTools) {
|
||||||
|
const properties = (tool.inputSchema.properties || {}) as Record<string, unknown>
|
||||||
|
assert.equal(properties.includeArchived, undefined, tool.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
245
backend/tests/mcpMasterdata.test.ts
Normal file
245
backend/tests/mcpMasterdata.test.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { getTableColumns, getTableName } from "drizzle-orm"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
import * as schema from "../db/schema"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
import { masterdataWriteConfigs, prepareMasterdataInput } from "../src/mcp/tools/masterdataWrites"
|
||||||
|
import { asToolResult } from "../src/mcp/result"
|
||||||
|
|
||||||
|
const tenantId = 7
|
||||||
|
const userId = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const parentId = "00000000-0000-4000-8000-000000000002"
|
||||||
|
const childId = "00000000-0000-4000-8000-000000000003"
|
||||||
|
const otherId = "00000000-0000-4000-8000-000000000004"
|
||||||
|
const dialect = new PgDialect()
|
||||||
|
|
||||||
|
/** Transactional fixture which evaluates actual Drizzle WHERE clauses, including tenant filters. */
|
||||||
|
function fixture(initial: Record<string, any[]> = {}) {
|
||||||
|
let state = structuredClone(initial)
|
||||||
|
const statements: string[] = []
|
||||||
|
let failHistory = false
|
||||||
|
const matches = (table: any, where: any, row: any): boolean => {
|
||||||
|
if (!where) return true
|
||||||
|
const query = dialect.sqlToQuery(where)
|
||||||
|
statements.push(query.sql)
|
||||||
|
const columns = getTableColumns(table) as Record<string, any>
|
||||||
|
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
|
||||||
|
const predicates = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)]
|
||||||
|
assert.ok(predicates.length, `Unimplemented fixture predicate: ${query.sql}`)
|
||||||
|
return predicates.every((match) => {
|
||||||
|
const value = row[keyFor(match[1])]
|
||||||
|
if (match[2]) return value === query.params[Number(match[2]) - 1]
|
||||||
|
return [...match[3].matchAll(/\$(\d+)/g)].some((parameter) => value === query.params[Number(parameter[1]) - 1])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const rowsFor = (table: any) => state[getTableName(table)] ||= []
|
||||||
|
const db: any = {
|
||||||
|
execute: async () => undefined,
|
||||||
|
transaction: async (callback: any) => {
|
||||||
|
const before = structuredClone(state)
|
||||||
|
try { return await callback(db) } catch (error) { state = before; throw error }
|
||||||
|
},
|
||||||
|
select: (selection?: Record<string, any>) => ({ from: (table: any) => {
|
||||||
|
let condition: any
|
||||||
|
let limit = Infinity
|
||||||
|
const query: any = {
|
||||||
|
where: (where: any) => { condition = where; return query },
|
||||||
|
limit: (value: number) => { limit = value; return query },
|
||||||
|
for: () => query,
|
||||||
|
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
||||||
|
const rows = rowsFor(table).filter((row) => matches(table, condition, row)).slice(0, limit)
|
||||||
|
const columns = getTableColumns(table)
|
||||||
|
return rows.map((row) => selection ? Object.fromEntries(Object.entries(selection).map(([key, column]) => {
|
||||||
|
const field = Object.keys(columns).find((name) => columns[name] === column)!
|
||||||
|
return [key, row[field]]
|
||||||
|
})) : { ...row })
|
||||||
|
}).then(resolve, reject),
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
} }),
|
||||||
|
insert: (table: any) => ({ values: (data: any) => {
|
||||||
|
let completed = false
|
||||||
|
let created: any
|
||||||
|
const run = () => {
|
||||||
|
if (!completed) {
|
||||||
|
if (failHistory && table === schema.historyitems) throw new Error("History unavailable")
|
||||||
|
const id = table === schema.costcentres ? otherId : Math.max(0, ...rowsFor(table).map((row) => typeof row.id === "number" ? row.id : 0)) + 1
|
||||||
|
created = { id, archived: false, ...data }
|
||||||
|
rowsFor(table).push(created)
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
return [created]
|
||||||
|
}
|
||||||
|
return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) }
|
||||||
|
} }),
|
||||||
|
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => {
|
||||||
|
let result: any[] | undefined
|
||||||
|
const run = () => {
|
||||||
|
if (!result) result = rowsFor(table).filter((row) => matches(table, where, row)).map((row) => { Object.assign(row, data); return { ...row } })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) }
|
||||||
|
} }) }),
|
||||||
|
}
|
||||||
|
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
|
||||||
|
return { context, rows: (table: any) => rowsFor(table), statements, failHistory: () => { failHistory = true } }
|
||||||
|
}
|
||||||
|
const call = async (context: any, name: string, args: Record<string, any>) => {
|
||||||
|
const tool = mcpToolMap.get(`masterdata.${name}`)
|
||||||
|
assert.ok(tool, name)
|
||||||
|
return await tool.handler(context, args) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of masterdataWriteConfigs) {
|
||||||
|
test(`${config.namespace}: create, update and archive are scoped and attribute changes to the actor`, async () => {
|
||||||
|
const f = fixture({ units: [{ id: 1 }], services: [], products: [], hourrates: [] })
|
||||||
|
const input: any = config.resource === "contacts" ? { firstName: "Ada", lastName: "Lovelace" } : { name: "Test" }
|
||||||
|
if (config.numberField) input[config.numberField] = "MCP-100"
|
||||||
|
if (config.resource === "products") input.unit = 1
|
||||||
|
const created = await call(f.context, `${config.namespace}.create`, input)
|
||||||
|
const record = created[config.resultKey]
|
||||||
|
assert.equal(record.tenant, tenantId)
|
||||||
|
assert.equal(record.archived, false)
|
||||||
|
assert.equal(record.updatedBy || record.updated_by, userId)
|
||||||
|
const update = config.resource === "contacts" ? { firstName: "Grace", lastName: "Hopper" } : { name: "Changed" }
|
||||||
|
const updated = await call(f.context, `${config.namespace}.update`, { id: record.id, ...update })
|
||||||
|
assert.equal(updated[config.resultKey][config.resource === "contacts" ? "fullName" : "name"], config.resource === "contacts" ? "Grace Hopper" : "Changed")
|
||||||
|
const confirmation = await call(f.context, `${config.namespace}.archive`, { id: record.id })
|
||||||
|
assert.deepEqual(asToolResult(confirmation).structuredContent, { id: record.id, success: true, action: "archived" })
|
||||||
|
assert.equal(f.rows(config.table)[0].archived, true)
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.update`, { id: record.id, ...update }), /archiviert/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${config.namespace}: rejects missing permissions, foreign records and injected system fields`, async () => {
|
||||||
|
const id = config.resource === "costcentres" ? parentId : 1
|
||||||
|
const f = fixture({ [getTableName(config.table)]: [{ id, tenant: 8, archived: false, name: "Other tenant" }] })
|
||||||
|
const fields = config.resource === "contacts" ? { firstName: "Changed" } : { name: "Changed" }
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.update`, { id, ...fields }), /nicht gefunden/)
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/)
|
||||||
|
assert.equal(f.rows(config.table)[0].archived, false)
|
||||||
|
for (const key of ["tenant", "createdAt", "updatedBy", "updated_by", "archived"]) {
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.update`, { id, [key]: "injected" }), /unbekanntes Feld/)
|
||||||
|
}
|
||||||
|
f.context.isAdmin = false
|
||||||
|
f.context.permissions = [`masterdata.${config.namespace}.read`]
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /Fehlende Berechtigung/)
|
||||||
|
f.context.permissions.push(`masterdata.${config.namespace}.write`)
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("numbered resources allocate numbers, skip occupied values and reject duplicates", async () => {
|
||||||
|
for (const config of masterdataWriteConfigs.filter((item) => item.numberField)) {
|
||||||
|
const f = fixture({
|
||||||
|
tenants: [{ id: tenantId, numberRanges: { [config.resource]: { prefix: "K-", nextNumber: 1 } } }],
|
||||||
|
[getTableName(config.table)]: [{ id: config.resource === "costcentres" ? parentId : 10, tenant: tenantId, [config.numberField!]: "K-1", archived: true }],
|
||||||
|
})
|
||||||
|
const result = await call(f.context, `${config.namespace}.create`, { name: "New" })
|
||||||
|
assert.equal(result[config.resultKey][config.numberField!], "K-2")
|
||||||
|
assert.equal(f.rows(schema.tenants)[0].numberRanges[config.resource].nextNumber, 3)
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.create`, { name: "Duplicate", [config.numberField!]: "K-1" }), /bereits vergeben/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cost centre hierarchy rejects self, descendants, foreign and archived parents", async () => {
|
||||||
|
const f = fixture({ costcentres: [
|
||||||
|
{ id: parentId, tenant: tenantId, name: "Parent", number: "1", parentCostcentre: null, archived: false },
|
||||||
|
{ id: childId, tenant: tenantId, name: "Child", number: "2", parentCostcentre: parentId, archived: false },
|
||||||
|
{ id: otherId, tenant: 8, name: "Foreign", archived: false },
|
||||||
|
] })
|
||||||
|
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: parentId }), /sich selbst/)
|
||||||
|
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /Zyklus/)
|
||||||
|
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: otherId }), /nicht verfügbar/)
|
||||||
|
f.rows(schema.costcentres)[1].archived = true
|
||||||
|
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /nicht verfügbar/)
|
||||||
|
const result = await call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: null })
|
||||||
|
assert.equal(result.costCentre.parentCostcentre, null)
|
||||||
|
assert.equal(f.rows(schema.costcentres)[1].parentCostcentre, parentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("all declared relationships reject foreign-tenant references before insert", async () => {
|
||||||
|
for (const config of masterdataWriteConfigs) {
|
||||||
|
for (const reference of config.references || []) {
|
||||||
|
if (reference.table === schema.units) continue
|
||||||
|
const refId = (reference.table[reference.idKey || "id"].columnType === "PgUUID") ? parentId : 99
|
||||||
|
const initial = { [getTableName(reference.table)]: [{ [reference.idKey || "id"]: refId, [reference.tenantKey || "tenant"]: 8, archived: false, active: true }], units: [{ id: 1 }] }
|
||||||
|
const f = fixture(initial)
|
||||||
|
const input: any = config.resource === "contacts" ? { fullName: "Contact" } : { name: "Record" }
|
||||||
|
if (config.resource === "products") input.unit = 1
|
||||||
|
if (config.numberField) input[config.numberField] = "N-1"
|
||||||
|
const parts = reference.field.split(".")
|
||||||
|
if (parts.length === 1) {
|
||||||
|
input[parts[0]] = ["profiles", "productcategories", "servicecategories"].includes(parts[0]) ? [refId] : refId
|
||||||
|
} else if (parts[0] === "infoData") input.infoData = { bankAccountIds: [refId] }
|
||||||
|
else input[parts[0]] = [{ [parts[1]]: refId }]
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.create`, input), /nicht verfügbar/, `${config.namespace}.${reference.field}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("partial updates preserve unrelated fields and nullable links can be cleared", async () => {
|
||||||
|
const f = fixture({ customers: [{ id: 1, tenant: tenantId, name: "Existing", archived: false, customerNumber: "C-1", infoData: { email: "old@example.org", city: "Berlin" } }] })
|
||||||
|
const result = await call(f.context, "customers.update", { id: 1, infoData: { email: "new@example.org" }, notes: null })
|
||||||
|
assert.deepEqual(result.customer.infoData, { email: "new@example.org", city: "Berlin" })
|
||||||
|
assert.equal(result.customer.name, "Existing")
|
||||||
|
assert.equal(result.customer.notes, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("failed history write rolls back record creation and number range consumption", async () => {
|
||||||
|
const f = fixture({ tenants: [{ id: tenantId, numberRanges: { customers: { prefix: "C-", nextNumber: 1 } } }] })
|
||||||
|
f.failHistory()
|
||||||
|
await assert.rejects(call(f.context, "customers.create", { name: "Rollback" }), /History unavailable/)
|
||||||
|
assert.equal(f.rows(schema.customers).length, 0)
|
||||||
|
assert.equal(f.rows(schema.tenants)[0].numberRanges.customers.nextNumber, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("product prices propagate to composed services in the same transaction", async () => {
|
||||||
|
const f = fixture({
|
||||||
|
products: [{ id: 1, tenant: tenantId, name: "Product", unit: 1, selling_price: 10, purchase_price: 5, archived: false }],
|
||||||
|
services: [{ id: 2, tenant: tenantId, name: "Service", archived: false, materialComposition: [{ product: 1, quantity: 2 }], personalComposition: [], priceUpdateLocked: false }],
|
||||||
|
hourrates: [],
|
||||||
|
})
|
||||||
|
await call(f.context, "products.update", { id: 1, selling_price: 20 })
|
||||||
|
assert.equal(f.rows(schema.services)[0].sellingPriceComposed.total, 40)
|
||||||
|
assert.equal(f.rows(schema.services)[0].purchasePriceComposed.total, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates scalar types, required values, dates and empty updates", () => {
|
||||||
|
const config = (name: string) => masterdataWriteConfigs.find((item) => item.namespace === name)!
|
||||||
|
assert.throws(() => prepareMasterdataInput(config("products"), "create", { name: "Article" }), /unit ist erforderlich/)
|
||||||
|
assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: "invalid", name: "Cost centre" }), /UUID/)
|
||||||
|
assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, name: " " }), /nicht leer/)
|
||||||
|
assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1 }), /Mindestens ein/)
|
||||||
|
for (const active of ["false", 0, null]) assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1, active }), /Boolean/)
|
||||||
|
for (const birthday of ["2026-02-30", "not a date"]) assert.throws(() => prepareMasterdataInput(config("contacts"), "update", { id: 1, birthday }), /Datum|Kalenderdatum/)
|
||||||
|
assert.throws(() => prepareMasterdataInput(config("products"), "update", { id: 1, selling_price: "20" }), /Zahl/)
|
||||||
|
assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, profiles: ["invalid"] }), /UUID/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("manual service price updates replace the previous composed total, including zero", async () => {
|
||||||
|
const f = fixture({ services: [{
|
||||||
|
id: 1, tenant: tenantId, name: "Manual", archived: false, sellingPrice: 10,
|
||||||
|
sellingPriceComposed: { total: 10 }, materialComposition: [], personalComposition: [],
|
||||||
|
}], products: [], hourrates: [] })
|
||||||
|
for (const price of [20, 0]) {
|
||||||
|
const result = await call(f.context, "services.update", { id: 1, sellingPrice: price })
|
||||||
|
assert.equal(result.service.sellingPrice, price)
|
||||||
|
assert.equal(result.service.sellingPriceComposed.total, price)
|
||||||
|
}
|
||||||
|
await assert.rejects(call(f.context, "services.update", { id: 1, sellingPrice: 20, sellingPriceComposed: { total: 30 } }), /widersprechen/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const config of masterdataWriteConfigs.filter((item) => item.addGet)) {
|
||||||
|
test(`${config.namespace}.get: returns only an active record from the current tenant`, async () => {
|
||||||
|
const f = fixture({ [getTableName(config.table)]: [
|
||||||
|
{ id: 1, tenant: tenantId, archived: false, name: "Active" },
|
||||||
|
{ id: 2, tenant: 8, archived: false, name: "Foreign" },
|
||||||
|
{ id: 3, tenant: tenantId, archived: true, name: "Archived" },
|
||||||
|
] })
|
||||||
|
assert.equal((await call(f.context, `${config.namespace}.get`, { id: 1 }))[config.resultKey].name, "Active")
|
||||||
|
for (const id of [2, 3]) await assert.rejects(call(f.context, `${config.namespace}.get`, { id }), /nicht gefunden/)
|
||||||
|
f.context.isAdmin = false
|
||||||
|
await assert.rejects(call(f.context, `${config.namespace}.get`, { id: 1 }), /Fehlende Berechtigung/)
|
||||||
|
})
|
||||||
|
}
|
||||||
63
backend/tests/mcpPagination.test.ts
Normal file
63
backend/tests/mcpPagination.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { getTableName } from "drizzle-orm"
|
||||||
|
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||||
|
import * as schema from "../db/schema"
|
||||||
|
import { mcpTools } from "../src/mcp/registry"
|
||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult } from "../src/mcp/pagination"
|
||||||
|
|
||||||
|
const tables = Object.values(schema).filter((value: any) => {
|
||||||
|
try { return typeof getTableName(value) === "string" } catch { return false }
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) {
|
||||||
|
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
|
||||||
|
const db = drizzle(async (sql, params) => {
|
||||||
|
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }
|
||||||
|
const tableName = sql.match(/from "(\w+)"/)?.[1]
|
||||||
|
const table = tables.find((table: any) => getTableName(table) === tableName) as any
|
||||||
|
assert.ok(table, sql)
|
||||||
|
assert.match(sql, /order by .*"id"/)
|
||||||
|
if (table.tenant || table.tenantId) {
|
||||||
|
assert.match(sql, /where .*"tenant(?:_id)?" = \$\d+/)
|
||||||
|
assert.ok(params.includes(7), "active tenant must be bound")
|
||||||
|
}
|
||||||
|
if (table.archived) assert.match(sql, /"archived" = \$\d+/)
|
||||||
|
const limitParam = sql.match(/limit \$(\d+)/)
|
||||||
|
const offsetParam = sql.match(/offset \$(\d+)/)
|
||||||
|
assert.ok(limitParam, "query must be bounded")
|
||||||
|
const limit = Number(params[Number(limitParam[1]) - 1])
|
||||||
|
const offset = offsetParam ? Number(params[Number(offsetParam[1]) - 1]) : 0
|
||||||
|
assert.equal(limit, 101, "fetch one extra row for hasMore")
|
||||||
|
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
|
||||||
|
const idIndex = selected.findIndex((column) => /"id"$/.test(column))
|
||||||
|
assert.ok(idIndex >= 0)
|
||||||
|
return { rows: Array.from({ length: Math.min(limit, Math.max(0, 205 - offset)) }, (_, i) => {
|
||||||
|
const values = selected.map(() => null as unknown)
|
||||||
|
values[idIndex] = offset + i + 1
|
||||||
|
return values
|
||||||
|
}) }
|
||||||
|
}, { schema })
|
||||||
|
const context = { server: { db }, tenantId: 7, userId: "test" } as any
|
||||||
|
const ids: unknown[] = []
|
||||||
|
let offset = 0
|
||||||
|
for (let page = 0; page < 3; page++) {
|
||||||
|
const result: any = await tool.handler(context, { id: 1, limit: 100, offset })
|
||||||
|
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
||||||
|
assert.equal(result.pagination.hasMore, page < 2)
|
||||||
|
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||||
|
ids.push(...result.rows.map((row: any) => Number(row.id)))
|
||||||
|
offset = result.pagination.nextOffset
|
||||||
|
}
|
||||||
|
assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => i + 1))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("pagination validates input and handles empty and exactly full final pages", () => {
|
||||||
|
for (const limit of [-1, 0, 101, 1.5, "10", Infinity]) assert.throws(() => limitFromArgs({ limit }))
|
||||||
|
for (const offset of [-1, 1.5, "10", Infinity]) assert.throws(() => offsetFromArgs({ offset }))
|
||||||
|
assert.equal(limitFromArgs({}), 25)
|
||||||
|
assert.equal(limitFromArgs({}, 50), 50)
|
||||||
|
assert.equal(paginatedResult([1, 2], { limit: 2 }).pagination.nextOffset, null)
|
||||||
|
assert.deepEqual(paginatedResult([], { offset: 300 }).pagination, { limit: 25, offset: 300, hasMore: false, nextOffset: null })
|
||||||
|
})
|
||||||
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
test("publishes the complete serial invoice configuration on create and update", () => {
|
||||||
|
for (const name of ["accounting.outgoing_documents.create", "accounting.outgoing_documents.update"]) {
|
||||||
|
const tool = mcpToolMap.get(name)
|
||||||
|
const properties = tool?.inputSchema.properties as Record<string, any>
|
||||||
|
|
||||||
|
assert.ok(properties.serialConfig, name)
|
||||||
|
assert.deepEqual(properties.serialConfig.properties.intervall.enum, [
|
||||||
|
"wöchentlich",
|
||||||
|
"2 - wöchentlich",
|
||||||
|
"monatlich",
|
||||||
|
"vierteljährlich",
|
||||||
|
"halbjährlich",
|
||||||
|
"jährlich",
|
||||||
|
])
|
||||||
|
assert.deepEqual(properties.serialConfig.properties.dateDirection.enum, ["Rückwirkend", "Im Voraus"])
|
||||||
|
assert.ok(properties.type.enum.includes("serialInvoices"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("registers tenant-scoped serial invoice execution tools", () => {
|
||||||
|
const execute = mcpToolMap.get("accounting.serial_invoices.execute")
|
||||||
|
const list = mcpToolMap.get("accounting.serial_invoice_executions.list")
|
||||||
|
const finish = mcpToolMap.get("accounting.serial_invoice_executions.finish")
|
||||||
|
|
||||||
|
assert.deepEqual(execute?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||||
|
assert.deepEqual(execute?.inputSchema.required, ["executionDate", "templateIds"])
|
||||||
|
assert.deepEqual(list?.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||||
|
assert.deepEqual(finish?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||||
|
assert.deepEqual(finish?.inputSchema.required, ["id"])
|
||||||
|
})
|
||||||
50
backend/tests/numberRangeAvailability.test.ts
Normal file
50
backend/tests/numberRangeAvailability.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { useNextNumberRangeNumber } from "../src/utils/functions"
|
||||||
|
|
||||||
|
test("number range skips identifiers that are already in use", async () => {
|
||||||
|
const tenant = {
|
||||||
|
id: 7,
|
||||||
|
numberRanges: {
|
||||||
|
customerinventoryitems: {
|
||||||
|
prefix: "KIA-",
|
||||||
|
suffix: "",
|
||||||
|
nextNumber: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let savedNumberRanges: any = null
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
execute: async () => undefined,
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: async () => [tenant],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: () => ({
|
||||||
|
set: (data: any) => ({
|
||||||
|
where: async () => {
|
||||||
|
savedNumberRanges = data.numberRanges
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const server = {
|
||||||
|
db: {
|
||||||
|
transaction: async (callback: (transaction: typeof tx) => Promise<any>) => callback(tx),
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
const occupied = new Set(["KIA-1000", "KIA-1001"])
|
||||||
|
|
||||||
|
const result = await useNextNumberRangeNumber(
|
||||||
|
server,
|
||||||
|
tenant.id,
|
||||||
|
"customerinventoryitems",
|
||||||
|
async (candidate) => !occupied.has(candidate)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(result.usedNumber, "KIA-1002")
|
||||||
|
assert.equal(savedNumberRanges.customerinventoryitems.nextNumber, 1003)
|
||||||
|
})
|
||||||
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { applyRowCostCentreAssignments } from "../src/modules/outgoing-document-cost-centres.service"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
test("changes only requested position cost centres", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "row-1", description: "Montage", price: 100, costCentre: "old-1" },
|
||||||
|
{ id: "row-2", description: "Material", price: 50, costCentre: "old-2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(applyRowCostCentreAssignments(rows, [
|
||||||
|
{ rowId: "row-1", costcentre: "new-1" },
|
||||||
|
]), [
|
||||||
|
{ id: "row-1", description: "Montage", price: 100, costCentre: "new-1" },
|
||||||
|
rows[1],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects assignments for unknown document positions", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => applyRowCostCentreAssignments([{ id: "row-1" }], [{ rowId: "missing", costcentre: null }]),
|
||||||
|
/Position missing.*nicht gefunden/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("registers a dedicated finalized-document cost centre tool", () => {
|
||||||
|
const tool = mcpToolMap.get("accounting.outgoing_documents.cost_centres.update")
|
||||||
|
|
||||||
|
assert.deepEqual(tool?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||||
|
assert.deepEqual(tool?.inputSchema.required, ["id"])
|
||||||
|
assert.ok(tool?.inputSchema.anyOf)
|
||||||
|
})
|
||||||
8
backend/tests/productUnitRelation.test.ts
Normal file
8
backend/tests/productUnitRelation.test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { resourceConfig } from "../src/utils/resource.config"
|
||||||
|
|
||||||
|
test("product details load their configured unit", () => {
|
||||||
|
assert.ok(resourceConfig.products.mtoLoad.includes("unit"))
|
||||||
|
})
|
||||||
119
backend/tests/selfhostUpdates.test.ts
Normal file
119
backend/tests/selfhostUpdates.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import routes from "../src/routes/selfhostUpdates";
|
||||||
|
import { createUpdaterClient } from "../src/modules/selfhost-updater";
|
||||||
|
import authPlugin from "../src/plugins/auth";
|
||||||
|
import { secrets } from "../src/utils/secrets";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const id = "12345678-1234-4123-a123-123456789abc";
|
||||||
|
const headers = { authorization: "Bearer verified-jwt-token" };
|
||||||
|
async function fixture(t: any, admin = true, authenticated = true) {
|
||||||
|
const app = Fastify();
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
let currentAdmin = admin;
|
||||||
|
// Mirror the parent auth plugin's preHandler order, not an earlier onRequest hook.
|
||||||
|
app.addHook("preHandler", async req => { if (authenticated) req.user = { user_id: id, is_admin: true } as any; });
|
||||||
|
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: currentAdmin }] }) }) }) });
|
||||||
|
await app.register(routes, { client: async (...args) => {
|
||||||
|
calls.push(args); return { status: 202, body: { status: "queued", requestId: id } };
|
||||||
|
} });
|
||||||
|
t.after(() => app.close());
|
||||||
|
return { app, calls, revoke: () => { currentAdmin = false; } };
|
||||||
|
}
|
||||||
|
test("all update endpoints require a current global admin, including after revocation", async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202);
|
||||||
|
f.revoke();
|
||||||
|
for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`],
|
||||||
|
["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) {
|
||||||
|
assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403);
|
||||||
|
}
|
||||||
|
assert.equal(f.calls.length, 1);
|
||||||
|
});
|
||||||
|
test("unauthenticated and cookie-only requests cannot start jobs", async t => {
|
||||||
|
const anonymous = await fixture(t, true, false);
|
||||||
|
assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401);
|
||||||
|
const f = await fixture(t);
|
||||||
|
for (const authorization of [undefined, "Bearer short", "bearer long-but-not-used-by-auth"]) {
|
||||||
|
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check",
|
||||||
|
headers: authorization ? { authorization } : {}, payload: { requestId: id } })).statusCode, 403);
|
||||||
|
}
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
});
|
||||||
|
test("fixed routes reject injected options, malformed IDs and unreviewed apply requests", async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
for (const payload of [{ requestId: id, directory: "/root" }, { requestId: id, version: "latest" },
|
||||||
|
{ requestId: "../../etc/passwd" }, { requestId: id, acceptComposeReplacement: true }]) {
|
||||||
|
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers, payload })).statusCode, 400);
|
||||||
|
}
|
||||||
|
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
|
||||||
|
payload: { requestId: id, version: "3.2.0" } })).statusCode, 400);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
|
||||||
|
payload: { requestId: id, version: "3.2.0", checkRequestId: "checked-request" } });
|
||||||
|
assert.equal(response.statusCode, 202);
|
||||||
|
assert.equal(response.headers["cache-control"], "no-store");
|
||||||
|
assert.deepEqual(f.calls[0], ["POST", "/v1/apply", { requestId: id, version: "3.2.0", checkRequestId: "checked-request" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real authentication requires a valid header JWT even when a valid cookie exists", async t => {
|
||||||
|
const app = Fastify();
|
||||||
|
t.after(() => app.close());
|
||||||
|
const previous = secrets.JWT_SECRET;
|
||||||
|
secrets.JWT_SECRET = "update-route-test-secret";
|
||||||
|
t.after(() => { secrets.JWT_SECRET = previous; });
|
||||||
|
const token = jwt.sign({ user_id: id, tenant_id: null, is_admin: true }, secrets.JWT_SECRET, { expiresIn: "5m" });
|
||||||
|
app.addHook("onRequest", async req => { req.cookies = { token }; });
|
||||||
|
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: true }] }) }) }) });
|
||||||
|
await app.register(authPlugin);
|
||||||
|
let forwarded = 0;
|
||||||
|
await app.register(routes, { client: async () => { forwarded++; return { status: 202, body: { status: "queued" } }; } });
|
||||||
|
for (const authorization of ["Bearer x", "bearer ignored-token-with-valid-cookie", "Bearer invalid-long-jwt-token"]) {
|
||||||
|
const result = await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization }, payload: { requestId: id } });
|
||||||
|
assert.ok([401, 403].includes(result.statusCode));
|
||||||
|
}
|
||||||
|
assert.equal(forwarded, 0);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/admin/updates/check",
|
||||||
|
headers: { authorization: `Bearer ${token}` }, payload: { requestId: id } })).statusCode, 202);
|
||||||
|
assert.equal(forwarded, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function socketFixture(t: any, handler: Parameters<typeof createServer>[0]) {
|
||||||
|
const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`);
|
||||||
|
const socket = `${directory}/control.sock`;
|
||||||
|
const server = createServer(handler);
|
||||||
|
await new Promise<void>(resolve => server.listen(socket, resolve));
|
||||||
|
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); await rm(directory, { recursive: true, force: true }); });
|
||||||
|
return createUpdaterClient(socket, 150);
|
||||||
|
}
|
||||||
|
test("Unix client strips private fields and never forwards credentials or raw error messages", async t => {
|
||||||
|
const client = await socketFixture(t, (req, res) => {
|
||||||
|
assert.equal(req.headers.authorization, undefined);
|
||||||
|
assert.equal(req.headers.cookie, undefined);
|
||||||
|
if (req.url === "/v1/status") res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true,
|
||||||
|
engine: { status: "idle", job: "/secret/path", error: "password" }, installed: {}, secret: "token" }));
|
||||||
|
else { res.writeHead(409); res.end(JSON.stringify({ error: { code: "PLAN_CHANGED", message: "password" } })); }
|
||||||
|
});
|
||||||
|
const response = await client("GET", "/v1/status");
|
||||||
|
assert.deepEqual(response.body, { apiVersion: 1, acceptingRequests: true, engine: { status: "idle" }, installed: {} });
|
||||||
|
await assert.rejects(client("POST", "/v1/apply", {}), { code: "PLAN_CHANGED", status: 409,
|
||||||
|
message: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen." });
|
||||||
|
await assert.rejects(client("GET", "http://example.com"), { code: "INVALID_REQUEST" });
|
||||||
|
});
|
||||||
|
test("Unix client bounds response size, deadline, and protocol shape", async t => {
|
||||||
|
for (const scenario of ["oversize", "timeout", "invalid"] as const) {
|
||||||
|
await t.test(scenario, async t => {
|
||||||
|
const client = await socketFixture(t, (_req, res) => {
|
||||||
|
if (scenario === "oversize") res.end("x".repeat(70000));
|
||||||
|
else if (scenario === "invalid") res.end('{"apiVersion":2}');
|
||||||
|
});
|
||||||
|
await assert.rejects(client("GET", "/v1/status"), (error: any) =>
|
||||||
|
["UPDATER_UNAVAILABLE", "UPDATER_PROTOCOL"].includes(error.code) && !error.message.includes("ENOENT"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
|
||||||
container_name: fedeo-minio
|
container_name: fedeo-minio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
@@ -66,13 +66,13 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
|
|
||||||
createbuckets:
|
createbuckets:
|
||||||
image: minio/mc:latest
|
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
|
||||||
container_name: fedeo-minio-init
|
container_name: fedeo-minio-init
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -ec "
|
||||||
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
|
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
|
||||||
mc mb --ignore-existing local/${MINIO_BUCKET};
|
mc mb --ignore-existing local/${MINIO_BUCKET};
|
||||||
mc anonymous set private local/${MINIO_BUCKET};
|
mc anonymous set private local/${MINIO_BUCKET};
|
||||||
@@ -83,9 +83,12 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
|
||||||
container_name: fedeo-backend
|
container_name: fedeo-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# Mount the directory so a service restart can replace the Unix socket.
|
||||||
|
- /run/fedeo-updater:/run/fedeo-updater:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -143,6 +146,12 @@ services:
|
|||||||
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
|
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
|
||||||
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
|
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
|
||||||
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '3100') + '/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 120s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
|
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
|
||||||
@@ -174,15 +183,22 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
|
image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
|
||||||
container_name: fedeo-frontend
|
container_name: fedeo-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
|
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
|
||||||
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
|
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 30s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
|
image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
|
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
|
||||||
@@ -22,7 +22,7 @@ services:
|
|||||||
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
image: git.federspiel.tech/flfeders/fedeo/docs:dev
|
image: git.federspiel.tech/flfeders/fedeo/docs:${FEDEO_VERSION:-dev}
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
@@ -43,7 +43,7 @@ services:
|
|||||||
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
|
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app/docs-site
|
WORKDIR /app/docs-site
|
||||||
|
|
||||||
COPY docs-site/package.json docs-site/package-lock.json ./
|
COPY docs-site/package.json docs-site/package-lock.json ./
|
||||||
@@ -7,9 +7,17 @@ RUN npm ci
|
|||||||
COPY docs-site ./
|
COPY docs-site ./
|
||||||
COPY docs /app/docs
|
COPY docs /app/docs
|
||||||
|
|
||||||
|
ARG FEDEO_VERSION=0.0.0-dev
|
||||||
|
ARG FEDEO_REVISION=unknown
|
||||||
|
ENV FEDEO_VERSION=$FEDEO_VERSION
|
||||||
|
ENV FEDEO_REVISION=$FEDEO_REVISION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
ARG FEDEO_VERSION=0.0.0-dev
|
||||||
|
ARG FEDEO_REVISION=unknown
|
||||||
|
ENV FEDEO_VERSION=$FEDEO_VERSION
|
||||||
|
ENV FEDEO_REVISION=$FEDEO_REVISION
|
||||||
WORKDIR /app/docs-site
|
WORKDIR /app/docs-site
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
|
|||||||
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
|
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
|
||||||
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
|
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
|
||||||
|
|
||||||
|
## Direkt auf ein Buchungskonto buchen
|
||||||
|
|
||||||
|
Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen.
|
||||||
|
|
||||||
|
- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um.
|
||||||
|
- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll.
|
||||||
|
- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle.
|
||||||
|
|
||||||
|
Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal.
|
||||||
|
|
||||||
|
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
||||||
|
|
||||||
## Gute Praxis
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
|
|||||||
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
|
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
|
||||||
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
|
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
|
||||||
|
|
||||||
|
## Direkt auf ein Buchungskonto buchen
|
||||||
|
|
||||||
|
Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen.
|
||||||
|
|
||||||
|
- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um.
|
||||||
|
- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll.
|
||||||
|
- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle.
|
||||||
|
|
||||||
|
Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal.
|
||||||
|
|
||||||
|
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
||||||
|
|
||||||
## Gute Praxis
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
148
docs/releases.md
Normal file
148
docs/releases.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Versionierung und Releases
|
||||||
|
|
||||||
|
## Normaler Ablauf
|
||||||
|
|
||||||
|
Entwicklung erfolgt auf `dev`. Ein Push nach `main` ist die bewusste Freigabe zum Veröffentlichen. Der Workflow `.gitea/workflows/build-push.yaml` startet automatisch, prüft die Commit-Nachrichten und Release-Tests, baut alle sechs Images und veröffentlicht bei relevanten Änderungen einen Gitea-Release.
|
||||||
|
|
||||||
|
Nach abgeschlossener Entwicklung in einem sauberen Checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch dev
|
||||||
|
git pull --ff-only origin dev
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm test
|
||||||
|
npm run release:preview
|
||||||
|
git push origin dev
|
||||||
|
git switch main
|
||||||
|
git pull --ff-only origin main
|
||||||
|
git merge --no-ff dev -m "chore(release): merge dev into main"
|
||||||
|
git push origin main
|
||||||
|
git switch dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ einen Pull Request von `dev` nach `main` mit einem normalen Merge übernehmen. Der Merge-Commit selbst wird bei der Versionsberechnung übersprungen; die enthaltenen `feat:`-/`fix:`-Commits bestimmen das Release. Bei Squash-Merges muss die Squash-Nachricht die größte Änderung einschließlich etwaiger Breaking Changes ausdrücken.
|
||||||
|
|
||||||
|
Es ist kein separater Versions-Commit erforderlich. `npm run release:preview` ist eine lokale, schreibfreie Vorschau; vorher mit `git fetch origin --tags` die Release-Tags aktualisieren. Ein Push, der nur Wartungsänderungen enthält, erzeugt nach dem ersten Release keine neue Version.
|
||||||
|
|
||||||
|
Ein manueller Start ist zusätzlich unter Gitea → Actions → Build and Release FEDEO → Run workflow möglich. Wähle `main` für einen Release und `dev` für einen Entwicklungsbuild. Dieser Start verwendet dieselbe Pipeline und dieselbe automatisch berechnete Version. `npm run release:ci` ist ausschließlich für die CI vorgesehen.
|
||||||
|
|
||||||
|
## Versionsregeln
|
||||||
|
|
||||||
|
Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer` und `@semantic-release/release-notes-generator` mit dem Conventional-Commits-Preset. Der Veröffentlichungsablauf ist auf Gitea 1.25.3 abgestimmt.
|
||||||
|
|
||||||
|
| Commit | Bedeutung | Release |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `fix(scope): …` | Fehlerkorrektur | Patch |
|
||||||
|
| `perf(scope): …` | Leistungsverbesserung | Patch |
|
||||||
|
| `feat(scope): …` | Neue Funktion | Minor |
|
||||||
|
| `feat(scope)!: …` / `BREAKING CHANGE: …` | Inkompatible Änderung | Major |
|
||||||
|
| `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, `style:` | Wartung ohne Breaking Change | Keiner |
|
||||||
|
| `revert: …` mit Git-Revert-Information | Zurücknahme bereits ausgelieferter Änderungen | Patch |
|
||||||
|
|
||||||
|
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
|
||||||
|
|
||||||
|
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
|
||||||
|
|
||||||
|
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.
|
||||||
|
|
||||||
|
## Versionen und Artefakte
|
||||||
|
|
||||||
|
Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemeinsamer FEDEO-Stand gebaut. Die Produktversion stammt aus Git und wird beim Build eingebettet. Backend-/Frontend-Paketversionen dienen nicht als Release-Quelle.
|
||||||
|
|
||||||
|
- Stabile Images: `…/backend:3.2.0`, `…/backend:v3.2.0` und `…/backend:sha-<vollständiger Commit>`; entsprechend für alle sechs Komponenten.
|
||||||
|
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release.
|
||||||
|
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>-<UUID>`-Images. Alle Komponenten eines erfolgreichen Laufs erhalten denselben Snapshot-Tag. Auch eine Wiederholung desselben Commits baut einen eigenen Snapshot. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
|
||||||
|
- Release-Builds erhalten zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
|
||||||
|
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh`, `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`, `SELFHOST-UPDATES.md` und `SHA256SUMS`.
|
||||||
|
|
||||||
|
Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank.
|
||||||
|
|
||||||
|
Mobile-App und Device-Agent behalten ihre eigenen bestehenden Versionsreihen. Ihre Paketversionen und die lokale iOS-Buildnummer werden im Manifest festgehalten. Die iOS-Buildnummer aus einer externen EAS-Ausführung kann davon abweichen. Ein Server-Release startet keine App-Store-Veröffentlichung und behauptet keine automatisch geprüfte Kompatibilität mit unabhängig veröffentlichten Clients. Inkompatible API-Änderungen gehören als Breaking Change mit betroffenen Client-Versionen und Upgrade-Anleitung in die Release Notes.
|
||||||
|
|
||||||
|
## Laufende Version abfragen
|
||||||
|
|
||||||
|
Im Frontend steht die Version unten in der Seitenleiste; der Tooltip enthält den Commit. Die Backend-Version ist öffentlich über diesen Endpunkt abrufbar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://app.example.com/backend/version
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel: `{"product":"FEDEO","version":"3.2.0","revision":"<Commit-ID>"}`. Die zentrale Instanzmeldung verwendet dieselbe Backend-Version. Bei lokalen Builds ohne Build-Argumente lautet die Version `0.0.0-dev`.
|
||||||
|
|
||||||
|
## Gitea einmalig vorbereiten
|
||||||
|
|
||||||
|
Der vorhandene Actions-Runner benötigt Docker mit Buildx und Netzwerkzugriff auf npm, Basis-Images und die Registry. Alle Images werden wie der bisherige Backend-Build für `linux/amd64` erzeugt. Eine zusätzliche ARM-Variante ist ein eigener Ausbau.
|
||||||
|
|
||||||
|
Unter Repository → Settings → Actions → Variables bzw. Secrets:
|
||||||
|
|
||||||
|
- `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die Registry-Anmeldung verwendet wie die bisherigen Build-Workflows vorrangig `vars.CI_TOKEN`; `secrets.CI_TOKEN` dient als Rückfallwert. Für eine spätere Umstellung auf ein Secret die gleichnamige Variable entfernen.
|
||||||
|
- Optional `RELEASE_TOKEN` als Secret oder Variable: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen des temporären Sperr-Branches `automation/release-lock`. Nur `main` benötigt diese Rechte. Die Reihenfolge für Git-Anmeldung und Release-API ist `secrets.RELEASE_TOKEN`, `vars.RELEASE_TOKEN`, `vars.CI_TOKEN`, `secrets.CI_TOKEN`. Ohne separates Release-Token benötigt der verwendete `CI_TOKEN` zusätzlich diese Rechte.
|
||||||
|
|
||||||
|
Auf `dev` nutzt der Checkout wie die bisherigen Workflows den normalen Job-Token zum Lesen. Entwicklungsbuilds pushen keine Git-Refs und rufen die Release-API nicht auf; der Registry-Token benötigt dafür keine Repository-Schreibrechte.
|
||||||
|
|
||||||
|
Der Release-Token-Benutzer muss die entsprechenden Repository-Rechte haben. Geschützte Tags `v*` müssen ihn zulassen; `automation/release-lock` muss durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`. Ein 403 beim Anlegen der Main-Sperre führt vor dem Image-Build zum Abbruch mit einem Hinweis auf die fehlenden Rechte.
|
||||||
|
|
||||||
|
`dev` mit der neuen Konfiguration zu pushen prüft zunächst den Entwicklungsbuild. Erst die bewusste Übernahme nach `main` erstellt den ersten stabilen Release. Release-Erstellung und Deployment haben eigene Auslöser: Server werden durch diese Pipeline nicht neu gestartet.
|
||||||
|
|
||||||
|
## Veröffentlichungsreihenfolge und Wiederaufnahme
|
||||||
|
|
||||||
|
Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner für Releases auf `main` atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Release-Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen.
|
||||||
|
|
||||||
|
Entwicklungsbuilds benötigen keine Git-Sperre. Parallele Läufe bauen unabhängige Snapshot-Tags. Nach dem vollständigen Build wird vor jeder Aktualisierung eines `dev`-Images geprüft, ob der Commit noch der aktuelle Stand von `origin/dev` ist. Ein erkennbar veralteter Lauf beendet die Aktualisierung. Diese Prüfung und die Registry-Aktualisierung sind nicht atomar; für einen festen Stand aller Komponenten denselben vollständigen Snapshot-Tag oder einen stabilen Release verwenden.
|
||||||
|
|
||||||
|
1. Vollständigen Commit und Tags laden, Commit-Nachrichten prüfen, Version berechnen.
|
||||||
|
2. Alle sechs Images aus diesem Checkout mit Lockfiles bauen. Der Backend-Build führt zusätzlich seine Tests aus. Vorher laufen die Release-Tests in einem eigenen Job.
|
||||||
|
3. Gitea-Entwurf erzeugen und Dateien hochladen, zuerst das Manifest mit den gebauten Digests.
|
||||||
|
4. Unveränderliche Image-Tags auf diese Digests setzen. Ein vorhandener abweichender Digest führt zum Abbruch.
|
||||||
|
5. Release veröffentlichen; Gitea erzeugt dabei den Git-Tag auf dem konkreten Commit.
|
||||||
|
6. `latest` und `main` auf die veröffentlichten Images umstellen, anschließend die Sperre entfernen.
|
||||||
|
|
||||||
|
Bei einem Release-Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests.
|
||||||
|
|
||||||
|
Ein anderer noch offener Release-Entwurf blockiert einen neuen Release. Zuerst den ursprünglichen Lauf wiederaufnehmen; anschließend den aktuellen Main-Lauf erneut starten. Releases oder Versionstags nicht löschen, um einen fehlgeschlagenen Lauf zu überspringen.
|
||||||
|
|
||||||
|
Nach einem hart abgebrochenen Runner kann die Sperre bestehen bleiben. Zuerst in Actions sicherstellen, dass der zugehörige Lauf wirklich beendet ist. Danach ausschließlich dessen Sperre mit dem zuvor gelesenen Commit als Lease entfernen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git ls-remote origin refs/heads/automation/release-lock
|
||||||
|
# Die ausgegebene Commit-ID anstelle von LOCK_SHA einsetzen:
|
||||||
|
git push --force-with-lease=refs/heads/automation/release-lock:LOCK_SHA origin :refs/heads/automation/release-lock
|
||||||
|
```
|
||||||
|
|
||||||
|
Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird. Entwicklungsbuilds erzeugen keine Sperre und können direkt erneut gestartet werden.
|
||||||
|
|
||||||
|
## Selfhosting und Updates
|
||||||
|
|
||||||
|
Für eine Installation eines bereits veröffentlichten Releases, hier beispielhaft `3.0.0`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/releases/download/v3.0.0/selfhost-install.sh -o /tmp/fedeo-install.sh
|
||||||
|
bash /tmp/fedeo-install.sh --simple --start
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser versionierte Installer lädt die Betriebsdateien vom zugehörigen Git-Tag und schreibt `FEDEO_VERSION=3.0.0` in die neue `.env`. Vorhandene Installationen behalten ihren bisherigen Kanal, bis `FEDEO_VERSION` bewusst geändert wird. Die Dateien im Entwicklungszweig verwenden weiterhin `dev` als Rückfallwert.
|
||||||
|
|
||||||
|
Der vollständige Host-Updater einschließlich Compose-Austausch und Backup ist in [Vollständige Selfhost-Updates](selfhost-updates.md) beschrieben. Er benötigt eine Zielversion mit gepinntem MinIO und Anwendungs-Healthchecks.
|
||||||
|
|
||||||
|
Manueller Ablauf für ältere Releases oder abweichende Installationen:
|
||||||
|
|
||||||
|
1. Release Notes und Migrationshinweise lesen. Konsistentes Datenbankbackup (z. B. `pg_dump`) sowie Datei-/Objektspeicher und Konfiguration sichern. Ein TAR des laufenden PostgreSQL-Datenverzeichnisses ist kein verlässliches Datenbankbackup.
|
||||||
|
2. Die benötigten Betriebsdateien vom gewünschten Release herunterladen; bestehende `.env` und Daten behalten. Downloads mit dessen `SHA256SUMS` überprüfen.
|
||||||
|
3. In `/opt/fedeo/.env` die feste Zielversion setzen, z. B. `FEDEO_VERSION=3.2.0`.
|
||||||
|
4. Images laden und Dienste neu erstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||||
|
curl https://app.example.com/backend/version
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Backend führt standardmäßig Vorwärtsmigrationen beim Start aus. Ein Downgrade der Images setzt die Datenbank nicht zurück. Zur vorherigen Version nur bei nachgewiesener Schema-Kompatibilität wechseln oder das passende Backup in einem geplanten Wiederherstellungsablauf einspielen.
|
||||||
|
|
||||||
|
## Technische Referenzen
|
||||||
|
|
||||||
|
- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
|
- [semantic-release Commit-Analyse](https://github.com/semantic-release/commit-analyzer)
|
||||||
|
- [Gitea 1.25 Actions-Einschränkungen](https://docs.gitea.com/1.25/usage/actions/comparison/)
|
||||||
|
- [Gitea 1.25 API](https://docs.gitea.com/api/1.25/)
|
||||||
|
- [Codex-Projektanweisungen mit AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
|
||||||
200
docs/selfhost-updates.md
Normal file
200
docs/selfhost-updates.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Vollständige Selfhost-Updates
|
||||||
|
|
||||||
|
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Das Backend verbindet diese lokale Schnittstelle mit dem Update-Bereich unter Administration → Systemstatus.
|
||||||
|
|
||||||
|
## Voraussetzungen und Umfang
|
||||||
|
|
||||||
|
- Linux/amd64, Python ab 3.10, Docker Compose ab 2.24, `tar` und `du`.
|
||||||
|
- Bestehendes Betriebsverzeichnis, standardmäßig `/opt/fedeo`, mit `.env` und `docker-compose.yml`; kein Git-Checkout.
|
||||||
|
- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`.
|
||||||
|
- Als root auf dem Server ausführen: Docker-Zugriff und Leserechte für die Daten- und Zertifikatsverzeichnisse werden benötigt. Ein entfernter Docker-Daemon wird abgewiesen.
|
||||||
|
- Die öffentliche HTTPS-Adresse muss vom Host erreichbar sein und ein gültiges Zertifikat haben.
|
||||||
|
- Eine Wartungsunterbrechung für alle Mandanten einplanen. Vor dem Backup werden alle laufenden Dienste außer PostgreSQL angehalten, auch der öffentliche Proxy und alle im Backend laufenden Hintergrundaufgaben. Eine separate Wartungsseite folgt mit der späteren Oberflächenintegration.
|
||||||
|
|
||||||
|
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
|
||||||
|
|
||||||
|
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die sieben FEDEO-Dienste enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
|
||||||
|
|
||||||
|
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
|
||||||
|
|
||||||
|
Die Stilllegung vor der Dateisicherung entspricht den Anforderungen an ein [PostgreSQL-Dateisystem-Backup](https://www.postgresql.org/docs/16/backup-file.html); die Container-Richtlinie wird mit [Docker `update --restart`](https://docs.docker.com/reference/cli/docker/container/update/) geändert.
|
||||||
|
|
||||||
|
Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf.
|
||||||
|
|
||||||
|
## Bereitstellung
|
||||||
|
|
||||||
|
Neue versionierte Installer laden das Skript und diese Anleitung bereits ins Betriebsverzeichnis und berücksichtigen Python 3 bei den Basispaketen. Bestehende Installationen können `selfhost-update.py` und `SELFHOST-UPDATES.md` aus den Assets eines entsprechenden veröffentlichten Releases übernehmen. Zuerst mit dessen `SHA256SUMS` prüfen, anschließend das Skript unter `/opt/fedeo/scripts/selfhost-update.py` ablegen. Für diese erstmalige Bereitstellung ist noch ein Serverzugriff erforderlich.
|
||||||
|
|
||||||
|
Ein bereits heruntergeladener Updater aus Release `3.1.0` enthält die Matrix-Stilllegung noch nicht. Dafür das Hostskript durch den korrigierten Stand aus einem neueren Release oder dem geprüften Repository-Commit ersetzen. Dieser kann weiterhin `3.1.0` als Zielrelease verwenden; dessen veröffentlichte Assets werden nicht verändert. Ein erneuter Download des alten `3.1.0`-Updater-Assets liefert weiterhin den alten Stand. Der Updater ersetzt sich beim Anwenden nicht selbst.
|
||||||
|
|
||||||
|
Downloads erfolgen ausschließlich vom konfigurierten FEDEO-Repository über HTTPS. Die Dateiprüfsummen werden gegen `SHA256SUMS`, das Manifest gegen den Git-Tag geprüft. Die Prüfsummen sind **keine unabhängige Signatur**; das Vertrauen liegt beim Gitea-Server und dessen Release-Veröffentlichung. Falls das Repository privat ist, `FEDEO_RELEASE_TOKEN` mit Leserechten nur für den Aufruf setzen. Registry-Anmeldung bei Bedarf vorher über `docker login git.federspiel.tech` im Konto des Updaters durchführen. Der Updater speichert den Release-Token nicht in Statusdateien und gibt ihn bei Weiterleitungen an andere Hosts nicht weiter.
|
||||||
|
|
||||||
|
## Prüfen, anwenden und Status lesen
|
||||||
|
|
||||||
|
Neueste stabile Version suchen oder eine bestimmte Version prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version 3.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`3.1.0` ist ein Beispiel und muss durch einen tatsächlich veröffentlichten Zielrelease ersetzt werden. Ein anderes Betriebsverzeichnis wird mit `--directory /pfad/zur/instanz` angegeben.
|
||||||
|
|
||||||
|
Die Prüfung lädt die Betriebsdateien und erstellt einen Plan einschließlich `compose.diff` und Release Notes unter `/opt/fedeo/.updates/<Auftrag>/`. Sie verändert die laufende Installation nicht und lädt noch keine Docker-Images. Die JSON-Ausgabe nennt Zielversion, Commit, betroffene Dienste, geschätzten Backup-Platz und die Pfade der Vorschau. `updateAvailable: false` bedeutet, dass die geprüfte Version nicht neuer als die installierte ist. Unveröffentlichte Entwürfe und Vorabversionen werden ignoriert.
|
||||||
|
|
||||||
|
Beim ersten Update kennt der Updater die Herkunft der vorhandenen Compose-Datei noch nicht. Die erzeugte `compose.diff` prüfen und eigene Anpassungen in die Override-Datei übertragen. Dann den Austausch ausdrücklich akzeptieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0 --accept-compose-replacement
|
||||||
|
```
|
||||||
|
|
||||||
|
Für spätere Updates unveränderter, vom Updater verwalteter Compose-Dateien:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.2.0
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine explizite Zielversion ist beim Anwenden erforderlich. `apply` prüft die Release-Dateien und die tatsächliche Installation nochmals. Beim Anwenden gleicher oder älterer stabiler Zielversionen wird abgebrochen. Änderungen an `.env`, Basis-Compose oder der Override-Datei während des Downloads führen ebenfalls vor der Wartungsphase zum Abbruch.
|
||||||
|
|
||||||
|
Direkte CLI-Aufrufe in einer persistenten Terminalsitzung ausführen. Alternativ übernimmt der folgende Host-Dienst die von der Verbindung unabhängige Ausführung. Ein Neustart des FEDEO-Stacks beendet diese Host-Prozesse nicht. Ein erzwungener Host-Neustart kann sie unterbrechen; beim normalen Dienst-Stop wartet der Dienst auf den laufenden Auftrag.
|
||||||
|
|
||||||
|
## Host-Dienst installieren
|
||||||
|
|
||||||
|
Benötigt zusätzlich einen Linux-Host mit systemd und Docker als `docker.service`. Die Dateien `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh` und `fedeo-updater.service` müssen aus demselben geprüften Stand im selben Verzeichnis liegen, beispielsweise `/opt/fedeo/scripts/`. Neuere Installer laden sie dorthin; neuere Releases enthalten alle vier Dateien samt Prüfsummen. Release `3.1.0` enthält den Dienst noch nicht. Zum Nachrüsten können die vier Dateien aus dem geprüften Repository-Commit kopiert werden, ohne die laufende FEDEO-Version zu ändern.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo
|
||||||
|
sudo systemctl status fedeo-updater.service
|
||||||
|
sudo curl --unix-socket /run/fedeo-updater/control.sock http://localhost/v1/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Installer legt die Python-Module root-eigen unter `/usr/local/lib/fedeo-updater/` ab, installiert die systemd-Unit und aktiviert den Dienst beim Systemstart. Bestehende FEDEO-Container werden durch die Dienstinstallation nicht neu gestartet. Bei einer erneuten Dienstinstallation wartet ein normales `systemctl stop` einen laufenden Auftrag ab; ein aktiver CLI-Updater blockiert den Dateiaustausch. Die Unit kann vorab ohne Installation angezeigt werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die Selfhost-Compose-Datei bindet `/run/fedeo-updater` schreibgeschützt unter demselben Pfad im Backend ein. Das Verzeichnis statt der einzelnen Socket-Datei bleibt auch bei einem Socket-Austausch durch Dienstneustarts nutzbar. Die Standard-Backend-Container laufen als root; bei einer eigenen unprivilegierten Konfiguration benötigt der Backend-Prozess zusätzlich die Host-GID von `fedeo-updater`. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Ohne installierten Dienst bleibt die Anwendung nutzbar; der Update-Bereich meldet den Dienst als nicht erreichbar.
|
||||||
|
|
||||||
|
Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen.
|
||||||
|
|
||||||
|
## Updates im Frontend und einmaliger Übergang
|
||||||
|
|
||||||
|
Systemadministratoren öffnen **Administration → Systemstatus → FEDEO-Updates**. „Nach Updates suchen“ prüft den neuesten stabilen Release. Die Oberfläche zeigt den laufenden Backend-Stand, die geprüfte Zielversion, den Commit, den geschätzten Backup-Platz und gegebenenfalls stillzulegende Matrix-Dienste. „Update auf … installieren“ öffnet die Bestätigung für die Wartungsunterbrechung aller Mandanten. Erst „Jetzt installieren“ nach Bestätigung startet den Auftrag.
|
||||||
|
|
||||||
|
Der Fortschritt wird automatisch abgefragt. Während des Stack-Neustarts ist die Verbindung vorübergehend unterbrochen; der Host arbeitet weiter. Nach Erfolg lädt „Neue Version laden“ die Oberfläche neu. Auftrags-IDs bleiben im Browser gespeichert und der Dienst liefert auch nach seinem Neustart den letzten Auftrag. Geht die Startantwort verloren, fragt die Oberfläche zuerst dieselbe ID ab. „Auftrag erneut senden“ verwendet dieselbe ID und denselben Inhalt; es wird kein zweites Update gestartet. Nach einer erneuten Anmeldung die Systemstatus-Seite wieder öffnen. Auch ohne Browser-Speicher lässt sich der aktuelle Auftrag über den Dienststatus verfolgen.
|
||||||
|
|
||||||
|
Für eine bestehende Instanz auf `3.1.0` ist noch ein einmaliger Übergang nötig, **nachdem ein Release mit diesem Update-Bereich veröffentlicht wurde**:
|
||||||
|
|
||||||
|
1. Die vier zusammengehörigen Host-Dateien aus dessen geprüften Release-Assets nach `/opt/fedeo/scripts/` übernehmen: `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`.
|
||||||
|
2. Den Dienstinstaller erneut ausführen, damit auch `/usr/local/lib/fedeo-updater/` den aktuellen Stand erhält.
|
||||||
|
3. Einmal per CLI auf diesen Release aktualisieren. `X.Y.Z` durch die veröffentlichte Version ersetzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version X.Y.Z
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte Standard-Socket-Verzeichnis am Backend. Änderungen an bestehenden Daten-Mounts sowie abweichende oder schreibbare neue Kontroll-Mounts bleiben gesperrt. Ältere Updater besitzen diese Übergangsregel noch nicht. Ein bereits erfolgreich verwalteter Compose-Stand benötigt keine erneute `--accept-compose-replacement`-Freigabe. Falls der Prüfplan sie verlangt, zunächst die Compose-Differenz auf dem Host prüfen wie oben beschrieben.
|
||||||
|
|
||||||
|
Danach laufen weitere Anwendungs-Releases einschließlich Compose und Migrationen über den Button. Änderungen am Host-Dienst selbst benötigen weiterhin die gemeinsame Aktualisierung der Host-Dateien und den Dienstinstaller.
|
||||||
|
|
||||||
|
Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host.
|
||||||
|
|
||||||
|
## Lokale Dienst-API
|
||||||
|
|
||||||
|
Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt.
|
||||||
|
|
||||||
|
| Aufruf | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag |
|
||||||
|
| `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` |
|
||||||
|
| `GET /v1/requests/<requestId>` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt |
|
||||||
|
| `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` |
|
||||||
|
|
||||||
|
Beispiel für eine Prüfung des neuesten Releases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CHECK_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
|
||||||
|
curl --unix-socket /run/fedeo-updater/control.sock \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"requestId\":\"$CHECK_ID\"}" http://localhost/v1/check
|
||||||
|
|
||||||
|
curl --unix-socket /run/fedeo-updater/control.sock \
|
||||||
|
"http://localhost/v1/requests/$CHECK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Aufträge antworten sofort mit `202`. `queued` und `running` bedeuten, dass die Arbeit noch läuft; `checked` bedeutet erfolgreiche Prüfung und `up_to_date`, dass keine neuere Version vorliegt. Der bereinigte Plan enthält Version, Commit, Platzbedarf und stillzulegende Dienste. Vollständige Release Notes und Compose-Diff bleiben auf dem Host, da insbesondere bestehende Compose-Dateien Geheimnisse enthalten können.
|
||||||
|
|
||||||
|
Nach `checked`, `updateAvailable: true` und Prüfung des Plans eine konkrete Zielversion einsetzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APPLY_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
|
||||||
|
TARGET_VERSION=X.Y.Z
|
||||||
|
curl --unix-socket /run/fedeo-updater/control.sock \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"requestId\":\"$APPLY_ID\",\"version\":\"$TARGET_VERSION\",\"checkRequestId\":\"$CHECK_ID\"}" \
|
||||||
|
http://localhost/v1/apply
|
||||||
|
|
||||||
|
curl --unix-socket /run/fedeo-updater/control.sock \
|
||||||
|
"http://localhost/v1/requests/$APPLY_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
`succeeded` bestätigt den Abschluss. Der Dienst bindet den Start an genau den geprüften Release und die geprüften Konfigurationsdateien. Bei zwischenzeitlichen Änderungen bricht er mit `PLAN_CHANGED` vor Image-Pull/Wartungsphase ab. `requiresComposeAdoption: true` benötigt zunächst eine manuell geprüfte Übernahme über die CLI; die API bietet dafür keinen Freigabeschalter. Ebenso bleiben `resolve`, freie Shell-Befehle, alternative Betriebsverzeichnisse und Download-Adressen außerhalb der API. Ein zweiter Auftrag bzw. ein laufender CLI-Updater führt zu `409 BUSY`, ein offener Wiederherstellungsfall zu `409 RECOVERY_REQUIRED`.
|
||||||
|
|
||||||
|
Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem harten Dienstabbruch werden offene Aufträge als `interrupted` markiert und nicht automatisch erneut gestartet. Bei normalem Stop/Restart nimmt der Dienst keine neue Arbeit mehr an und lässt den aktiven Auftrag fertiglaufen (`KillMode=mixed`, `TimeoutStopSec=infinity`). Auch ein reguläres Herunterfahren des Hosts kann dadurch bis zum Auftragsende warten. Nach erzwungenem Abbruch oder Host-Neustart den Status prüfen; unterbrochene Updates folgen den bestehenden Wiederherstellungsprüfungen. Auftragsspezifische Fehlerdetails stehen in `.updates/service/<requestId>.log`, Werkzeugfehler weiterhin im geschützten `commands.log` des Engine-Auftrags. Über die API werden nur feste Fehlermeldungen und erlaubte Statusfelder ausgegeben, keine Rohlogs, Konfiguration, Dateipfade oder Prüffingerprints.
|
||||||
|
|
||||||
|
## Ablauf und Speicherung
|
||||||
|
|
||||||
|
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
|
||||||
|
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
|
||||||
|
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
|
||||||
|
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images mit exakt gepinntem SHA-256-Digest nur laden, wenn sie lokal fehlen (`pull --policy missing`); vorhandene Tags ohne passenden Digest reichen dafür nicht aus. Bewegliche Infrastruktur-Tags weiterhin aktualisieren (`pull --policy always`). Ein fehlendes, nicht herunterladbares Image beendet den Auftrag vor der Wartung. Backup-Platz erneut prüfen. Während der anschließenden Infrastrukturstarts und Migrationen erfolgt kein weiterer Pull (`up`/`run --pull never`), auch nicht bei einer abweichenden Pull-Policy in einer Override-Datei.
|
||||||
|
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
|
||||||
|
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
|
||||||
|
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
|
||||||
|
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
|
||||||
|
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
|
||||||
|
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
|
||||||
|
|
||||||
|
Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `status.json` gespeichert. `events.jsonl` enthält den Verlauf, `commands.log` Fehlermeldungen der Werkzeuge. Das Verzeichnis hat Modus `0700`; Status- und Konfigurationsdateien werden mit `0600` angelegt. Backups und Werkzeugausgaben enthalten vertrauliche Daten und dürfen nicht ungefiltert an eine Oberfläche ausgeliefert werden. Backups werden nicht automatisch gelöscht.
|
||||||
|
|
||||||
|
## Fehler und Wiederherstellung
|
||||||
|
|
||||||
|
Bei einer nicht erreichbaren Registry können bereits lokal vorhandene, exakt passende Digest-Images weiterverwendet werden. Das hilft beispielsweise bei einem `401 Unauthorized` der MinIO-Registry, wenn sowohl MinIO als auch `mc` mit den Ziel-Digests bereits auf diesem Host liegen. Der Host-Updater muss diese Pull-Logik bereits enthalten; bestehende Dienstinstallationen nach Austausch des Hostskripts mit dem Dienstinstaller aktualisieren. Veröffentlichte Release-Assets werden nicht nachträglich verändert. Für Instanzen ohne diese Images bleibt eine geprüfte Bezugsquelle notwendig. Der lokale Cache ersetzt weder Sicherheitsupdates noch eine dauerhafte Versorgung mit gepflegten Images.
|
||||||
|
|
||||||
|
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
|
||||||
|
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
|
||||||
|
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
|
||||||
|
- **Prozess hart beendet oder Host neu gestartet:** `status` erkennt einen zurückgelassenen laufenden Auftrag als `interrupted`. Eine vor Beginn der Wartung gespeicherte Wiederherstellungssperre bleibt erhalten. Abgeschlossene Teiloperationen werden nicht als erfolgreiches Update ausgegeben.
|
||||||
|
|
||||||
|
Zur Diagnose `status` und die geschützten Auftragsdateien lesen. Falls ein benannter `fedeo-update-<Auftrag>`-Migrationscontainer zurückgeblieben ist, dessen Zustand und Logs zuerst prüfen. Er kann unabhängig vom abgebrochenen Compose-Prozess weiterlaufen; erst nach geklärtem Zustand anhalten/entfernen.
|
||||||
|
|
||||||
|
Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster:
|
||||||
|
|
||||||
|
1. Alle schreibenden Dienste und den Proxy angehalten lassen; keinen neuen Update-Auftrag starten.
|
||||||
|
2. Prüfsummen aus `backup/complete.json` prüfen. Ursprüngliche Compose-Datei, `.env` und gegebenenfalls Override aus `backup/config/` wiederherstellen. `backup/containers.json` dokumentiert die ursprünglichen Image-IDs; insbesondere bewegliche Infrastruktur-Tags vor dem Start auf die gesicherten Image-Stände festlegen.
|
||||||
|
3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten.
|
||||||
|
4. Wiederhergestellten Stack mit dessen Compose-Dateien starten und Funktionen prüfen. Alternativ den Fehler der Zielversion beheben und diese erfolgreich starten.
|
||||||
|
5. Erst nach erfolgreicher Wiederherstellung die Sperre aufheben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus demselben Backup verwenden. Falls nötig, das vollständige `matrix/`-Verzeichnis aus `files.tar` bei gestoppten Matrix-Diensten wiederherstellen, insbesondere den gesamten PostgreSQL-Cluster mit dem ursprünglichen Datenbank-Image. Vorhandene Container-Schichten und anonyme Volumes bleiben an den behaltenen Containern erhalten. Für jeden betroffenen Container die ursprüngliche `HostConfig.RestartPolicy` aus `backup/containers.json` wieder setzen, beispielsweise `docker update --restart=unless-stopped CONTAINER_ID`, wenn genau diese Richtlinie gespeichert wurde. Danach die zuvor laufenden Matrix-Dienste mit der ursprünglichen Compose-Konfiguration starten und prüfen. `resolve` für den alten Stand prüft auch deren Image, Laufzustand und Neustartrichtlinie. `resolve` für den Zielrelease verlangt weiterhin gestoppte Matrix-Container mit deaktiviertem automatischem Neustart. Es führt diese Schritte nicht selbst aus.
|
||||||
|
|
||||||
|
`resolve` prüft die angegebene Backend-Version, Backend-Erreichbarkeit, Frontend-Erreichbarkeit, laufende Kerndienste, passende Anwendungs-Images und das Fehlen eines zurückgebliebenen Migrationscontainers. Es stellt selbst **keine Daten wieder her**. Bei alten Entwicklungsinstallationen die tatsächlich von `/backend/version` gemeldete Entwicklungsversion angeben. Ein späteres Update kann nach einer manuellen Compose-Wiederherstellung erneut `--accept-compose-replacement` erfordern.
|
||||||
|
|
||||||
|
## Entwicklung und Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run commits:check
|
||||||
|
cd backend && node --import tsx --test tests/selfhostUpdates.test.ts
|
||||||
|
cd ../frontend && node --test tests/selfhost-updates.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.
|
||||||
64
docs/technik/mcp.md
Normal file
64
docs/technik/mcp.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# FEDEO MCP
|
||||||
|
|
||||||
|
## Listen und Pagination
|
||||||
|
|
||||||
|
Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination.
|
||||||
|
|
||||||
|
Die Antwort behält `rows` und ergänzt:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"pagination":{"limit":25,"offset":0,"hasMore":true,"nextOffset":25}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Für die nächste Seite dieselben Filter und `limit` mit `offset = pagination.nextOffset` verwenden. Bei `hasMore = false` ist `nextOffset = null`. Die Sortierung enthält eine eindeutige ID als Tie-Breaker. Gleichzeitiges Anlegen, Archivieren oder Ändern sortierrelevanter Felder kann wie bei jeder Offset-Pagination Seiten verschieben; die Abfragen bilden keinen gemeinsamen Datenbanksnapshot.
|
||||||
|
|
||||||
|
Auch `accounting.incoming_invoices.files.list` ist paginiert. Archivierte Einträge bleiben entsprechend der bisherigen MCP-Regel ausgeblendet.
|
||||||
|
|
||||||
|
## Ausgangsbeleg-Schemas
|
||||||
|
|
||||||
|
`tools/list` beschreibt die Unterfelder von `rows`, `address` und `report` für Beleg-Erstellung und -Bearbeitung sowie Positionen und Report beim Finalisieren. Positionen verwenden `costCentre`, der Beleg selbst `costcentre`. `unit` ist die numerische Einheiten-ID. Preise werden numerisch übergeben. Reportwerte liefert der Aufrufer; MCP berechnet sie nicht. Zusätzliche modulspezifische Objektfelder bleiben zulässig.
|
||||||
|
|
||||||
|
## Stammdaten und Kostenstellen
|
||||||
|
|
||||||
|
Die folgenden Namensräume besitzen jeweils `create`, `update` und `archive`:
|
||||||
|
|
||||||
|
| Namensraum nach `masterdata.` | Anlegen: Pflichtfelder | Automatischer Nummernkreis |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `customers` | `name` | `customerNumber` |
|
||||||
|
| `vendors` | `name` | `vendorNumber` |
|
||||||
|
| `contacts` | `fullName` oder `firstName`/`lastName` | – |
|
||||||
|
| `products` | `name`, `unit` | – |
|
||||||
|
| `services` | `name` | – |
|
||||||
|
| `cost_centres` | `name` | `number` |
|
||||||
|
| `branches` | `name` | `number` |
|
||||||
|
| `teams` | `name` | – |
|
||||||
|
| `vehicles` | `name` | – |
|
||||||
|
| `inventory_items` | `name` | `articleNumber` |
|
||||||
|
|
||||||
|
Nummernfelder sind beim Anlegen optional; ohne Angabe wird der bestehende Mandanten-Nummernkreis verwendet. Dieser muss in FEDEO eingerichtet sein. Bereits belegte Nummern werden übersprungen; explizite doppelte Nummern werden abgewiesen, auch wenn der vorhandene Eintrag archiviert ist.
|
||||||
|
|
||||||
|
`update` verlangt `id` und mindestens ein bearbeitbares Feld. Kostenstellen verwenden UUIDs, die übrigen Bereiche numerische IDs. Nur angegebene Felder werden geändert. Nullable Felder lassen sich mit `null` leeren, Arrays werden vollständig ersetzt. Die Unterfelder von `infoData` werden beim Update zusammengeführt. Systemfelder wie Mandant, ID beim Anlegen, Archivstatus und Änderungsautor sind nicht frei schreibbar.
|
||||||
|
|
||||||
|
Beispiel zum Anlegen einer Kostenstelle:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"masterdata.cost_centres.create","arguments":{"name":"Werkstatt","description":"Kosten der Werkstatt","branch":3}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Über `cost_centres.update` lassen sich `parentCostcentre`, `project`, `branch`, `vehicle`, `inventoryitem` und `profiles` pflegen. Eltern müssen zum aktiven Mandanten gehören und dürfen nicht archiviert sein; Selbstbezüge und neue Hierarchie-Zyklen werden abgewiesen. Die HTTP-Ressourcenroute und MCP verwenden denselben Hierarchieprüfer.
|
||||||
|
|
||||||
|
Fremdschlüssel und bekannte Verknüpfungen in JSON-Feldern werden auf Mandantenzugehörigkeit und Verfügbarkeit geprüft. Globale Mengeneinheiten bleiben lesbar; es werden keine Schreibwerkzeuge für diese mandantenübergreifenden Daten registriert.
|
||||||
|
|
||||||
|
Artikel- und Leistungsänderungen stoßen die bestehende Leistungspreisberechnung an. Bei zusammengesetzten Leistungen stammen Preise aus der Zusammensetzung; für eine manuelle Preisvorgabe die Leistung mit `priceUpdateLocked: true` sperren. Manuelle `sellingPrice`-Werte werden mit `sellingPriceComposed.total` synchronisiert.
|
||||||
|
|
||||||
|
Schreibvorgänge einschließlich Nummernvergabe und Preisberechnung laufen in einer Transaktion. Historieneinträge werden über den vorhandenen History-Service erstellt; für Niederlassungen und Teams unterstützt dieser derzeit keine entitätsbezogene Historie. Die Änderungsfelder enthalten auch dort den ausführenden Benutzer und Zeitpunkt. Archivieren liefert `{ "id": ..., "success": true, "action": "archived" }`; der archivierte Datensatz wird entsprechend der MCP-Regel nicht zurückgegeben.
|
||||||
|
|
||||||
|
Zusätzlich verfügbar: `masterdata.customers.search` sowie `get` für Kontakte, Niederlassungen, Teams und Fahrzeuge. Die vorhandene Kundensuche unter `organisation.customers.search` bleibt erhalten.
|
||||||
|
|
||||||
|
### Berechtigungen
|
||||||
|
|
||||||
|
Schreibwerkzeuge benötigen `masterdata.<Namensraum>.write`, lesende Werkzeuge `.read`. Die neuen Schreibrechte sind in den Bootstrap-Administratorrechten enthalten. Bestehende Nicht-Administratorrollen erhalten nicht automatisch Schreibzugriff; ihnen müssen die gewünschten Rechte ausdrücklich zugewiesen werden. Globale Administratoren verwenden die bestehende Admin-Ausnahme.
|
||||||
|
|
||||||
|
### Verifikation
|
||||||
|
|
||||||
|
Die MCP-Tests prüfen unter anderem seitenweisen Abruf, Tenant-Filter in den erzeugten SQL-Abfragen, Create/Update/Archive, unbekannte Systemfelder, Fremdmandanten-Verknüpfungen, Kostenstellen-Zyklen, Nummernkollisionen, Transaktions-Rollback und die Preisneuberechnung. Die Datenbanktests verwenden isolierte Fixtures bzw. den Drizzle-Proxy; sie schreiben keine produktiven Daten.
|
||||||
8
frontend/.dockerignore
Normal file
8
frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user