Compare commits
234 Commits
427c0580c4
...
v3.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4e05906556 | |||
| df2308d407 | |||
| 5b6579423b | |||
| b3dd46195d | |||
| 54294ce7e9 | |||
| 5141a1f3ac | |||
| 718ecefd46 | |||
| e90cf819e7 | |||
| 15e21a97e2 | |||
| 358e40d749 | |||
| 47bd8e80e4 | |||
| 92c354c1b9 | |||
| 5e1631e2d3 | |||
| 1c39b69513 | |||
| a60570e0eb | |||
| 41e4cad929 | |||
| 1890d1d97d | |||
| a09f770a63 | |||
| 2462418548 | |||
| a1e0ec6ac5 | |||
| ed2885084e | |||
| 252cbd496d | |||
| 23d2dc0ca8 | |||
| ca4cf119c0 | |||
| b89fdedeae | |||
| e4fcd79c8e | |||
| 2ebd856df0 | |||
| 60327fe928 | |||
| 8d8b07ce0d | |||
| 36969e272d | |||
| dd063fd648 | |||
| 2c7cd17e2b | |||
| 298f601564 | |||
| 384a0a53ef | |||
| 5c481b105f | |||
| 914b322805 | |||
| 2f306ca8a2 | |||
| 49b4c10e10 | |||
| f3384cfc74 | |||
| 4e4466ff11 | |||
| b5419bb5ea | |||
| 7c58b9021a | |||
| 62eef04a6b | |||
|
|
2ad4dada92 | ||
|
|
21fb8c6649 | ||
|
|
0bd862af55 | ||
|
|
2428230d94 | ||
|
|
98a8d1d061 | ||
|
|
92686ead9a | ||
|
|
95ca7a4aaf | ||
|
|
f26743154a | ||
|
|
b1785a35a9 | ||
|
|
405cc95b41 | ||
| a298712973 | |||
|
|
a84bd0c445 | ||
| 12130395e3 | |||
|
|
bcc72974b5 | ||
| 1ae76380a9 | |||
|
|
f1ca725d89 | ||
|
|
0250cc0dd7 | ||
|
|
e86e66a6e4 | ||
|
|
d90499a101 | ||
|
|
6afc799f69 | ||
|
|
02ab26771d | ||
| 40bd1769ee | |||
| 6b5d4f7f36 | |||
| afb9adb444 | |||
| 2dd333db1d | |||
| 67e4e05f01 | |||
| d0755020ad | |||
| 391375b3a7 | |||
| 060fde7bda | |||
| a71ea577b2 | |||
| 2917df6fa2 | |||
| 5181ecc9f3 | |||
| 0bd2c01591 | |||
| 3ed26225bb | |||
| 86e5d49795 | |||
| ee39ee59cc | |||
| 49cbc9314e | |||
| e5fab70056 | |||
| e7fe98da56 | |||
| e12ec4557d | |||
| 17717e8a1b | |||
| 7d3e9a383f | |||
| 7ad55aaa0b | |||
| 8301d8777e | |||
| 94008e8072 | |||
| 53463675ff | |||
| 6473adca6c | |||
| ae4ff9aad0 | |||
| 09431a87c7 | |||
| d0b76e0bab | |||
| 7d92726cf8 | |||
| e5246d45c8 | |||
| ecbfdaa0cb | |||
| 5dae5b6aa5 | |||
| ca91de2284 | |||
| ec4469e21f | |||
| 9a09c3f722 | |||
| 21fdbb1cf8 | |||
| 3d829c3bc0 | |||
| 93d2769f23 | |||
| 9822d90bdd | |||
| 519a90bdc1 | |||
| 75d7bfab38 | |||
| 9421c3221c | |||
| d2e0b4ca0f | |||
| 7677fe5d9d | |||
| 1caa3180af | |||
| cb37970074 | |||
| c66d04f0b5 | |||
| 5a05d2e9e8 | |||
| ec4e3f39d6 | |||
| 291f278d99 | |||
| e2fdfe9c0a | |||
| a2c5ad5dd9 | |||
| fd330b28c2 | |||
| f691e3db33 | |||
| 13936db100 | |||
| 78d56738e8 | |||
| c8a7f4f396 | |||
| f375d63655 | |||
| 52490ff734 | |||
| 7b705c7f40 | |||
| 4e533a47c9 | |||
| 8aee8e7882 | |||
| d18ba16f8a | |||
| de36589822 | |||
| b02466667a | |||
| a49b05dba6 | |||
| 28c5e8511f | |||
| 3755b4136a | |||
| 8b8e0c97d3 | |||
| d3ad53bcf0 | |||
| 4ab4c24b1a | |||
| e7107f151a | |||
| c222b793b0 | |||
| 5dcdc29a18 | |||
| fbea66b754 | |||
| 621243ba7b | |||
| dd2a9926dc | |||
| 3230fc6cf0 | |||
| b8220685d7 | |||
| b7b8a2077a | |||
| 5e7a1f9aee | |||
| 6c613857fa | |||
| 430ab74522 | |||
| 5585bd7012 | |||
| 657a4f9e85 | |||
| 769185cd82 | |||
| 37d08ee984 | |||
| 720c70845e | |||
| ae379b0f22 | |||
| 6a14b0c1ca | |||
| d8273e794d | |||
| 1711f975bb | |||
| 4e3f1e0c15 | |||
| a9b063aa89 | |||
| 473c92d2e4 | |||
| 3153a7a669 | |||
| d0f5a72350 | |||
| 4c03a96cbe | |||
| bf9e397e51 | |||
| 31ef408b66 | |||
| a945aaffe3 | |||
| 5f66f81ade | |||
| ee7d3d3afc | |||
| b3067e0e00 | |||
| e30722995e | |||
| bbef553e6b | |||
| 53632d25ac | |||
| 2eaa49bf05 | |||
| 14026181c2 | |||
| c54e9a51a5 | |||
| 3c4a75a858 | |||
| 0a7b0448ad | |||
| d67da7e02a | |||
| 3b3a879b02 | |||
| 365cec206d | |||
| 033e74adda | |||
| ccc66ebd0f | |||
| c660f62120 | |||
| ad74825781 | |||
| f1e0f36cca | |||
| 526ad966c4 | |||
| 99501fb924 | |||
| 2fdc89565c | |||
| 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
|
||||||
48
.env.example
48
.env.example
@@ -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
|
||||||
|
|
||||||
@@ -117,49 +119,3 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
|||||||
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||||
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||||
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||||
|
|
||||||
# FEDEO Matrix-Kommunikation
|
|
||||||
#
|
|
||||||
# Diese Werte werden von docker-compose.selfhost.yml für den integrierten
|
|
||||||
# Matrix-Stack gelesen. Für produktive Systeme müssen alle Geheimnisse ersetzt
|
|
||||||
# werden.
|
|
||||||
|
|
||||||
MATRIX_SERVER_NAME=app.example.com
|
|
||||||
|
|
||||||
MATRIX_POSTGRES_DB=synapse
|
|
||||||
MATRIX_POSTGRES_USER=synapse
|
|
||||||
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
|
||||||
|
|
||||||
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
|
||||||
|
|
||||||
LIVEKIT_KEY=fedeo-livekit
|
|
||||||
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
|
||||||
|
|
||||||
# Backend-Integration im Selfhost-Stack
|
|
||||||
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
|
|
||||||
MATRIX_RTC_HOST=app.example.com
|
|
||||||
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
|
|
||||||
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
|
|
||||||
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
|
|
||||||
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
|
||||||
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
|
|
||||||
|
|
||||||
# Lokale Matrix-Entwicklung
|
|
||||||
MATRIX_DEV_SYNAPSE_PORT=8008
|
|
||||||
MATRIX_DEV_ELEMENT_PORT=8080
|
|
||||||
MATRIX_DEV_RTC_JWT_PORT=8081
|
|
||||||
MATRIX_DEV_LIVEKIT_PORT=7880
|
|
||||||
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
|
|
||||||
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
|
|
||||||
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
|
|
||||||
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
|
|
||||||
MATRIX_DEV_TURN_PORT=3478
|
|
||||||
MATRIX_DEV_TURN_MIN_PORT=49160
|
|
||||||
MATRIX_DEV_TURN_MAX_PORT=49200
|
|
||||||
|
|
||||||
# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack
|
|
||||||
# MATRIX_HOMESERVER_URL=http://localhost:8008
|
|
||||||
# MATRIX_RTC_JWT_URL=http://localhost:8081
|
|
||||||
# MATRIX_LIVEKIT_URL=ws://localhost:7880
|
|
||||||
# MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
|
|
||||||
# NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080
|
|
||||||
|
|||||||
@@ -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,169 +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 }}
|
|
||||||
|
|||||||
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.
|
||||||
58
README.md
58
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:
|
||||||
@@ -146,7 +154,7 @@ Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration di
|
|||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Der schnelle One-Liner mit direktem Stack-Start:
|
Der One-Liner von der Webseite nutzt den schnellen Standardpfad und startet den Stack direkt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
|
||||||
@@ -225,6 +233,12 @@ DOKUBOX_IMAP_PASSWORD=change-this-imap-password
|
|||||||
OPENAI_API_KEY=replace-this
|
OPENAI_API_KEY=replace-this
|
||||||
STIRLING_API_KEY=replace-this
|
STIRLING_API_KEY=replace-this
|
||||||
|
|
||||||
|
# Optional: zentrale, durch den FEDEO-Betreiber bereitgestellte Dienste
|
||||||
|
FEDEO_CENTRAL_SERVICES_ENABLED=false
|
||||||
|
PUSH_SERVER_URL=https://services.fedeo.de
|
||||||
|
PUSH_SERVER_INSTANCE_ID=
|
||||||
|
PUSH_SERVER_SECRET=
|
||||||
|
|
||||||
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||||
|
|
||||||
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
@@ -233,33 +247,16 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
|||||||
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||||
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||||
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||||
|
|
||||||
MATRIX_SERVER_NAME=app.example.com
|
|
||||||
MATRIX_POSTGRES_DB=synapse
|
|
||||||
MATRIX_POSTGRES_USER=synapse
|
|
||||||
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
|
||||||
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
|
||||||
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
|
|
||||||
MATRIX_RTC_HOST=app.example.com
|
|
||||||
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
|
|
||||||
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
|
|
||||||
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
|
|
||||||
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
|
||||||
LIVEKIT_KEY=fedeo-livekit
|
|
||||||
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
|
||||||
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
|
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
|
||||||
|
|
||||||
## Docker Compose mit optionalem S3 und Matrix
|
## Docker Compose mit optionalem S3
|
||||||
|
|
||||||
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
||||||
|
|
||||||
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
|
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
|
||||||
|
|
||||||
Der Matrix-Stack ist im Selfhost-Compose direkt enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`.
|
|
||||||
|
|
||||||
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
|
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -457,7 +454,6 @@ Im Deploy-Verzeichnis:
|
|||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert.
|
|
||||||
|
|
||||||
Danach Status prufen:
|
Danach Status prufen:
|
||||||
|
|
||||||
@@ -491,28 +487,28 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Bei neuen Versionen:
|
Vollständige Release-Updates einschließlich Compose-Datei werden mit dem Host-Skript vorbereitet und ausgeführt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
|
||||||
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
|
`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.
|
||||||
|
|
||||||
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
## 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
|
||||||
- `./matrix/postgres` falls Matrix lokal betrieben wird
|
|
||||||
- `./matrix/synapse` falls Matrix lokal betrieben wird
|
|
||||||
- `./traefik/letsencrypt/acme.json`
|
- `./traefik/letsencrypt/acme.json`
|
||||||
- deine `.env`
|
- deine `.env`
|
||||||
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ dist
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
__pycache__
|
||||||
|
tests
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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_POSTPROCESS_PROFILE=document
|
FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||||
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
||||||
|
|||||||
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,8 @@ 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_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
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,41 @@ 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
|
||||||
@@ -13,6 +48,8 @@ brew install node sane-backends
|
|||||||
scanimage -L
|
scanimage -L
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten).
|
||||||
|
|
||||||
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -23,11 +60,52 @@ 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
|
||||||
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_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,10 +127,18 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## OpenCV-Nachbearbeitung
|
## 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 +156,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 OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt.
|
||||||
|
|
||||||
Profile:
|
Profile:
|
||||||
|
|
||||||
@@ -109,6 +195,17 @@ npm run build
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run test:python
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests benötigen Python 3 mit Pillow. Sie 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. 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/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}`)
|
||||||
|
}
|
||||||
111
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
111
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/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.'
|
||||||
|
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! python3 -c 'import PIL' >/dev/null 2>&1; then
|
||||||
|
if ! command -v apt-get >/dev/null; then
|
||||||
|
echo 'Bitte Node.js >= 20, SANE (scanimage), Python 3 und Pillow 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
|
||||||
|
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) }'
|
||||||
|
python3 -c 'import PIL'
|
||||||
|
|
||||||
|
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=' + unit_quote(agent_dir),
|
||||||
|
'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_document.py
Normal file
74
agents/fedeo-device-agent/scripts/scan_document.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False):
|
||||||
|
if not inputs:
|
||||||
|
raise ValueError("Keine Seiten vorhanden")
|
||||||
|
output = Path(output)
|
||||||
|
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
|
||||||
|
if not file_format:
|
||||||
|
raise ValueError("Unbekanntes Ausgabeformat")
|
||||||
|
if file_format != "PDF" and len(inputs) != 1:
|
||||||
|
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat")
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
partial = output.with_name(output.name + ".partial")
|
||||||
|
try:
|
||||||
|
for index, input_path in enumerate(inputs):
|
||||||
|
input_path = Path(input_path)
|
||||||
|
image_path = input_path
|
||||||
|
corrected = input_path.with_name(input_path.stem + ".corrected.png")
|
||||||
|
if processor:
|
||||||
|
try:
|
||||||
|
processor(input_path, corrected, profile)
|
||||||
|
image_path = corrected
|
||||||
|
except Exception:
|
||||||
|
if strict:
|
||||||
|
raise
|
||||||
|
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
|
||||||
|
|
||||||
|
# Only one decoded page is kept in memory, even for large duplex stacks.
|
||||||
|
with Image.open(image_path) as image:
|
||||||
|
with image.convert("RGB") as rgb:
|
||||||
|
if 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)
|
||||||
|
partial.replace(output)
|
||||||
|
finally:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return {"pageCount": len(inputs), "warnings": warnings}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
|
||||||
|
parser.add_argument("--inputs", nargs="+", required=True)
|
||||||
|
parser.add_argument("--output", required=True)
|
||||||
|
parser.add_argument("--resolution", type=float, required=True)
|
||||||
|
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
|
||||||
|
parser.add_argument("--postprocess", action="store_true")
|
||||||
|
parser.add_argument("--strict", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict)))
|
||||||
|
|
||||||
|
|
||||||
|
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,6 +60,7 @@ 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),
|
||||||
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(),
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#!/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 { AgentHeartbeat } from "./types.js"
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
@@ -25,11 +28,14 @@ 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,
|
||||||
print: printerNames.length > 0,
|
print: printerNames.length > 0,
|
||||||
@@ -44,7 +50,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 +69,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 +116,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,45 @@
|
|||||||
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> = {
|
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
|
||||||
".pdf": "application/pdf",
|
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
|
||||||
".png": "image/png",
|
try {
|
||||||
".tif": "image/tiff",
|
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
|
||||||
".tiff": "image/tiff",
|
if (result.code === 0) return
|
||||||
".jpg": "image/jpeg",
|
} catch {
|
||||||
".jpeg": "image/jpeg",
|
// Report missing runtimes before the scanner consumes any paper.
|
||||||
|
}
|
||||||
|
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.")
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
|
export const createScanDocument = async (
|
||||||
const ext = path.extname(filename)
|
|
||||||
if (ext) return filename
|
|
||||||
return `${filename}.${format}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => {
|
|
||||||
if (!await commandExists(config.postprocessPython)) return false
|
|
||||||
|
|
||||||
const result = await runCommand(config.postprocessPython, [
|
|
||||||
"-c",
|
|
||||||
"import cv2, PIL, numpy",
|
|
||||||
], { timeoutMs: 10_000 })
|
|
||||||
|
|
||||||
return result.code === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export const postprocessScan = async (
|
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
inputPath: string,
|
pages: string[],
|
||||||
outputFilename: string,
|
outputPath: string,
|
||||||
outputFormat: AgentConfig["scanFormat"],
|
resolution: number,
|
||||||
|
postprocess: boolean,
|
||||||
profile: AgentConfig["postprocessProfile"]
|
profile: AgentConfig["postprocessProfile"]
|
||||||
): Promise<ScanResult> => {
|
): Promise<ScanResult> => {
|
||||||
const filename = ensureOutputExtension(outputFilename, outputFormat)
|
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
|
||||||
const outputPath = path.join(config.workDir, filename)
|
if (postprocess) args.push("--postprocess")
|
||||||
|
if (config.postprocessStrict) args.push("--strict")
|
||||||
|
args.push("--inputs", ...pages)
|
||||||
|
|
||||||
const result = await runCommand(config.postprocessPython, [
|
const result = await runCommand(config.postprocessPython, args, {
|
||||||
postprocessScript,
|
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||||
"--input",
|
})
|
||||||
inputPath,
|
if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
|
||||||
"--output",
|
|
||||||
outputPath,
|
|
||||||
"--profile",
|
|
||||||
profile,
|
|
||||||
], { timeoutMs: 5 * 60 * 1000 })
|
|
||||||
|
|
||||||
if (result.code !== 0) {
|
const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
|
||||||
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = path.extname(outputPath).toLowerCase()
|
|
||||||
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,118 @@
|
|||||||
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 []
|
||||||
|
|
||||||
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
|
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_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
|
await checkScanRuntime(config, postprocess)
|
||||||
|
|
||||||
const args = [
|
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
|
||||||
"--format",
|
await mkdir(config.workDir, { recursive: true, mode: 0o700 })
|
||||||
scanFormat,
|
const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-"))
|
||||||
"--resolution",
|
const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/"))
|
||||||
String(resolution),
|
const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan"
|
||||||
"--mode",
|
const outputPath = path.join(jobDir, `${stem}.${format}`)
|
||||||
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`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldPostprocess) {
|
|
||||||
if (!await hasOpenCvPostprocessRuntime(config)) {
|
|
||||||
const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar"
|
|
||||||
if (config.postprocessStrict) throw new Error(message)
|
|
||||||
|
|
||||||
log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, {
|
|
||||||
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. Datei wird erstellt...`)
|
||||||
path: outputPath,
|
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
|
||||||
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,6 +9,7 @@ export type AgentConfig = {
|
|||||||
scanResolution: number
|
scanResolution: number
|
||||||
scanMode: string
|
scanMode: string
|
||||||
scanSource?: string
|
scanSource?: string
|
||||||
|
scanTimeoutSeconds: number
|
||||||
scanPostprocess: boolean
|
scanPostprocess: boolean
|
||||||
postprocessProfile: "document" | "receipt" | "raw"
|
postprocessProfile: "document" | "receipt" | "raw"
|
||||||
postprocessPython: string
|
postprocessPython: string
|
||||||
@@ -45,4 +46,6 @@ export type ScanResult = {
|
|||||||
path: string
|
path: string
|
||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
|
pageCount: number
|
||||||
|
warnings: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
81
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
81
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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 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'),
|
||||||
|
}
|
||||||
|
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 unit = await readFile(path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service'), 'utf8')
|
||||||
|
assert.ok(unit.includes('data with spaces %%'))
|
||||||
|
assert.ok(!unit.includes(token))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
154
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
154
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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 { 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
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
args = sys.argv[1:]
|
||||||
|
Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args))
|
||||||
|
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",
|
||||||
|
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("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("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)
|
||||||
|
})
|
||||||
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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" (
|
|||||||
"entity_type" text,
|
"entity_type" text,
|
||||||
"entity_id" bigint,
|
"entity_id" bigint,
|
||||||
"entity_uuid" uuid,
|
"entity_uuid" uuid,
|
||||||
"matrix_room_id" text,
|
|
||||||
"matrix_alias" text,
|
|
||||||
"parent_space_room_id" text,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"updated_at" timestamp with time zone,
|
"updated_at" timestamp with time zone,
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" (
|
|||||||
"entity_type" text,
|
"entity_type" text,
|
||||||
"entity_id" bigint,
|
"entity_id" bigint,
|
||||||
"entity_uuid" uuid,
|
"entity_uuid" uuid,
|
||||||
"matrix_room_id" text,
|
|
||||||
"matrix_alias" text,
|
|
||||||
"parent_space_room_id" text,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"updated_at" timestamp with time zone,
|
"updated_at" timestamp with time zone,
|
||||||
|
|||||||
19
backend/db/migrations/0054_tenant_export_jobs.sql
Normal file
19
backend/db/migrations/0054_tenant_export_jobs.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "tenant_export_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,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade,
|
||||||
|
"created_by" uuid REFERENCES "auth_users"("id"),
|
||||||
|
"status" text DEFAULT 'queued' NOT NULL,
|
||||||
|
"filename" text NOT NULL,
|
||||||
|
"storage_path" text,
|
||||||
|
"content_type" text DEFAULT 'application/zip' NOT NULL,
|
||||||
|
"file_size" bigint,
|
||||||
|
"error" text,
|
||||||
|
"files_total" integer DEFAULT 0 NOT NULL,
|
||||||
|
"files_done" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "tenant_export_jobs_tenant_status_idx"
|
||||||
|
ON "tenant_export_jobs" ("tenant_id", "status");
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
ALTER TABLE "tenant_export_jobs"
|
||||||
|
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
|
||||||
|
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema = 'public'
|
||||||
|
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
|
||||||
|
FOREIGN KEY ("locked_by_export_job_id")
|
||||||
|
REFERENCES "tenant_export_jobs"("id")
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal file
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "auth_refresh_tokens" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"tenant_id" bigint,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "auth_refresh_tokens_token_hash_unique" UNIQUE("token_hash"),
|
||||||
|
CONSTRAINT "auth_refresh_tokens_user_id_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE cascade ON UPDATE cascade,
|
||||||
|
CONSTRAINT "auth_refresh_tokens_tenant_id_tenants_id_fk"
|
||||||
|
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||||
|
ON DELETE set null ON UPDATE cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_user_id_idx"
|
||||||
|
ON "auth_refresh_tokens" ("user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_expires_at_idx"
|
||||||
|
ON "auth_refresh_tokens" ("expires_at");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "incominginvoices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "preparation_source" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "e_invoice_syntax" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "e_invoice_profile" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "e_invoice_validation" jsonb;
|
||||||
13742
backend/db/migrations/0058_global_reference_data.sql
Normal file
13742
backend/db/migrations/0058_global_reference_data.sql
Normal file
File diff suppressed because one or more lines are too long
1
backend/db/migrations/0059_bankstatement_notes.sql
Normal file
1
backend/db/migrations/0059_bankstatement_notes.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "bankstatements" ADD COLUMN "notes" text;
|
||||||
69
backend/db/migrations/0060_document_import_sources.sql
Normal file
69
backend/db/migrations/0060_document_import_sources.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
CREATE TABLE "document_import_sources" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"created_by" uuid,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"provider" text DEFAULT 'imap' NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"mailbox_address_encrypted" jsonb,
|
||||||
|
"password_encrypted" jsonb,
|
||||||
|
"imap_host_encrypted" jsonb,
|
||||||
|
"imap_port" integer DEFAULT 993 NOT NULL,
|
||||||
|
"imap_secure" boolean DEFAULT true NOT NULL,
|
||||||
|
"mailbox_path" text DEFAULT 'INBOX' NOT NULL,
|
||||||
|
"target_folder_id" uuid,
|
||||||
|
"default_filetype_id" uuid,
|
||||||
|
"mark_as_seen" boolean DEFAULT true NOT NULL,
|
||||||
|
"last_synced_at" timestamp with time zone,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "document_import_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"source_id" uuid NOT NULL,
|
||||||
|
"mailbox_path" text NOT NULL,
|
||||||
|
"uid_validity" bigint,
|
||||||
|
"highest_uid" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"delta_link_encrypted" jsonb,
|
||||||
|
"updated_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "document_import_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"source_id" uuid NOT NULL,
|
||||||
|
"remote_message_id" text NOT NULL,
|
||||||
|
"attachment_key" text NOT NULL,
|
||||||
|
"attachment_checksum" text NOT NULL,
|
||||||
|
"filename" text,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"error" text,
|
||||||
|
"file_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_target_folder_id_folders_id_fk" FOREIGN KEY ("target_folder_id") REFERENCES "public"."folders"("id") ON DELETE set null;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_default_filetype_id_filetags_id_fk" FOREIGN KEY ("default_filetype_id") REFERENCES "public"."filetags"("id") ON DELETE set null;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_states" ADD CONSTRAINT "document_import_states_source_id_document_import_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."document_import_sources"("id") ON DELETE cascade ON UPDATE cascade;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_items" ADD CONSTRAINT "document_import_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_items" ADD CONSTRAINT "document_import_items_source_id_document_import_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."document_import_sources"("id") ON DELETE cascade ON UPDATE cascade;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_import_items" ADD CONSTRAINT "document_import_items_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "document_import_sources_tenant_idx" ON "document_import_sources" USING btree ("tenant_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "document_import_states_source_mailbox_key" ON "document_import_states" USING btree ("source_id", "mailbox_path");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "document_import_items_remote_attachment_key" ON "document_import_items" USING btree ("source_id", "remote_message_id", "attachment_key");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "document_import_items_checksum_idx" ON "document_import_items" USING btree ("source_id", "attachment_checksum");
|
||||||
40
backend/db/migrations/0061_additional_skr03_accounts.sql
Normal file
40
backend/db/migrations/0061_additional_skr03_accounts.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
WITH requested_accounts ("number", "label") AS (
|
||||||
|
VALUES
|
||||||
|
('1576', 'Abziehbare Vorsteuer 19 %'),
|
||||||
|
('4110', 'Löhne'),
|
||||||
|
('4115', 'Löhne (Konto 4115)'),
|
||||||
|
('4120', 'Gehälter'),
|
||||||
|
('4130', 'Gesetzliche soziale Aufwendungen'),
|
||||||
|
('4140', 'Freiwillige soziale Aufwendungen (lohnsteuerfrei)')
|
||||||
|
)
|
||||||
|
UPDATE "accounts" AS account
|
||||||
|
SET "label" = requested."label",
|
||||||
|
"description" = NULL
|
||||||
|
FROM requested_accounts AS requested
|
||||||
|
WHERE account."accountChart" = 'skr03'
|
||||||
|
AND account."number" = requested."number";
|
||||||
|
--> statement-breakpoint
|
||||||
|
WITH requested_accounts ("number", "label") AS (
|
||||||
|
VALUES
|
||||||
|
('1576', 'Abziehbare Vorsteuer 19 %'),
|
||||||
|
('4110', 'Löhne'),
|
||||||
|
('4115', 'Löhne (Konto 4115)'),
|
||||||
|
('4120', 'Gehälter'),
|
||||||
|
('4130', 'Gesetzliche soziale Aufwendungen'),
|
||||||
|
('4140', 'Freiwillige soziale Aufwendungen (lohnsteuerfrei)')
|
||||||
|
)
|
||||||
|
INSERT INTO "accounts" ("number", "label", "description", "accountChart")
|
||||||
|
SELECT requested."number", requested."label", NULL, 'skr03'
|
||||||
|
FROM requested_accounts AS requested
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "accounts" AS account
|
||||||
|
WHERE account."accountChart" = 'skr03'
|
||||||
|
AND account."number" = requested."number"
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('accounts', 'id'),
|
||||||
|
GREATEST(COALESCE((SELECT MAX("id") FROM "accounts"), 1), 1),
|
||||||
|
true
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "incominginvoices" ADD COLUMN "tax_amount_override" numeric(12, 2);
|
||||||
20
backend/db/migrations/0063_skr03_output_tax_account.sql
Normal file
20
backend/db/migrations/0063_skr03_output_tax_account.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
UPDATE "accounts"
|
||||||
|
SET "label" = 'Umsatzsteuer 19 %',
|
||||||
|
"description" = NULL
|
||||||
|
WHERE "accountChart" = 'skr03'
|
||||||
|
AND "number" = '1776';
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "accounts" ("number", "label", "description", "accountChart")
|
||||||
|
SELECT '1776', 'Umsatzsteuer 19 %', NULL, 'skr03'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "accounts"
|
||||||
|
WHERE "accountChart" = 'skr03'
|
||||||
|
AND "number" = '1776'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('accounts', 'id'),
|
||||||
|
GREATEST(COALESCE((SELECT MAX("id") FROM "accounts"), 1), 1),
|
||||||
|
true
|
||||||
|
);
|
||||||
4
backend/db/migrations/0064_tenant_import_job_status.sql
Normal file
4
backend/db/migrations/0064_tenant_import_job_status.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "tenant_export_jobs"
|
||||||
|
ALTER COLUMN "tenant_id" DROP NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "status_message" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "import_result" jsonb;
|
||||||
16
backend/db/migrations/0065_document_templates.sql
Normal file
16
backend/db/migrations/0065_document_templates.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "documenttemplates" (
|
||||||
|
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"tenant" bigint NOT NULL REFERENCES "tenants"("id"),
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"document_type" text NOT NULL,
|
||||||
|
"template_data" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"default" boolean NOT NULL DEFAULT false,
|
||||||
|
"archived" boolean NOT NULL DEFAULT false,
|
||||||
|
"updated_at" timestamptz,
|
||||||
|
"updated_by" uuid REFERENCES "auth_users"("id"),
|
||||||
|
"created_by" uuid REFERENCES "auth_users"("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "documenttemplates_tenant_type_idx"
|
||||||
|
ON "documenttemplates" ("tenant", "document_type");
|
||||||
27
backend/db/migrations/0066_email_entity_links.sql
Normal file
27
backend/db/migrations/0066_email_entity_links.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "email_entity_links" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"message_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" bigint NOT NULL,
|
||||||
|
"linked_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "email_entity_links_tenant_id_tenants_id_fk"
|
||||||
|
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||||
|
ON DELETE cascade ON UPDATE cascade,
|
||||||
|
CONSTRAINT "email_entity_links_message_id_email_messages_id_fk"
|
||||||
|
FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id")
|
||||||
|
ON DELETE cascade ON UPDATE cascade,
|
||||||
|
CONSTRAINT "email_entity_links_linked_by_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("linked_by") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE set null ON UPDATE cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "email_entity_links_message_entity_key"
|
||||||
|
ON "email_entity_links" USING btree ("message_id", "entity_type", "entity_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "email_entity_links_entity_idx"
|
||||||
|
ON "email_entity_links" USING btree ("tenant_id", "entity_type", "entity_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "email_entity_links_message_idx"
|
||||||
|
ON "email_entity_links" USING btree ("message_id");
|
||||||
2
backend/db/migrations/0067_email_account_enabled.sql
Normal file
2
backend/db/migrations/0067_email_account_enabled.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "user_credentials"
|
||||||
|
ADD COLUMN IF NOT EXISTS "email_enabled" boolean DEFAULT true NOT NULL;
|
||||||
1
backend/db/migrations/0068_email_entity_suggestions.sql
Normal file
1
backend/db/migrations/0068_email_entity_suggestions.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "email_messages" ADD COLUMN "entity_suggestions" jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Recompute suggestions without recipient evidence on next opening. Existing links remain intact.
|
||||||
|
UPDATE "email_messages" SET "entity_suggestions" = NULL WHERE "entity_suggestions" IS NOT NULL;
|
||||||
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_room_id";
|
||||||
|
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_alias";
|
||||||
|
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "parent_space_room_id";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "communication_room_members" (
|
||||||
|
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||||
|
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "communication_room_members_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "communication_room_members_user_idx"
|
||||||
|
ON "communication_room_members" ("user_id");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "communication_messages" (
|
||||||
|
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade,
|
||||||
|
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||||
|
"author_user_id" uuid NOT NULL REFERENCES "auth_users"("id"),
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "communication_messages_room_message_idx"
|
||||||
|
ON "communication_messages" ("room_id", "id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "communication_messages_tenant_idx"
|
||||||
|
ON "communication_messages" ("tenant_id");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "communication_room_reads" (
|
||||||
|
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||||
|
"last_read_message_id" bigint,
|
||||||
|
"read_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "communication_room_reads_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "communication_room_members" ("room_id", "user_id")
|
||||||
|
SELECT room.id, tenant_user.user_id
|
||||||
|
FROM "communication_rooms" room
|
||||||
|
JOIN "auth_tenant_users" tenant_user ON tenant_user.tenant_id = room.tenant_id
|
||||||
|
WHERE room.type IN ('general', 'room')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -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;
|
||||||
@@ -358,6 +358,139 @@
|
|||||||
"when": 1780261200000,
|
"when": 1780261200000,
|
||||||
"tag": "0050_outgoing_document_costcentres",
|
"tag": "0050_outgoing_document_costcentres",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 51,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783247083934,
|
||||||
|
"tag": "0054_tenant_export_jobs",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 52,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784021669805,
|
||||||
|
"tag": "0055_tenant_export_maintenance_lock",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784548800000,
|
||||||
|
"tag": "0056_auth_refresh_tokens",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 54,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784635200000,
|
||||||
|
"tag": "0057_incoming_invoice_einvoice_metadata",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 55,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786023052994,
|
||||||
|
"tag": "0058_global_reference_data",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 56,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786082400000,
|
||||||
|
"tag": "0059_bankstatement_notes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 57,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786086000000,
|
||||||
|
"tag": "0060_document_import_sources",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 58,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786280400000,
|
||||||
|
"tag": "0061_additional_skr03_accounts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 59,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786284000000,
|
||||||
|
"tag": "0062_incoming_invoice_tax_override",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 60,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786287600000,
|
||||||
|
"tag": "0063_skr03_output_tax_account",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 61,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788202715000,
|
||||||
|
"tag": "0064_tenant_import_job_status",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 62,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788202715001,
|
||||||
|
"tag": "0065_document_templates",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 63,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788799700000,
|
||||||
|
"tag": "0066_email_entity_links",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 64,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788801000000,
|
||||||
|
"tag": "0067_email_account_enabled",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 65,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788802000000,
|
||||||
|
"tag": "0068_email_entity_suggestions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 66,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788803000000,
|
||||||
|
"tag": "0069_reset_email_entity_suggestions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 67,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788850800000,
|
||||||
|
"tag": "0070_native_communication_chat",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
20
backend/db/schema/auth_refresh_tokens.ts
Normal file
20
backend/db/schema/auth_refresh_tokens.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { pgTable, text, timestamp, uuid, bigint } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
|
||||||
|
export const authRefreshTokens = pgTable("auth_refresh_tokens", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.references(() => tenants.id, { onDelete: "set null", onUpdate: "cascade" }),
|
||||||
|
tokenHash: text("token_hash").notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AuthRefreshToken = typeof authRefreshTokens.$inferSelect
|
||||||
|
export type NewAuthRefreshToken = typeof authRefreshTokens.$inferInsert
|
||||||
@@ -34,6 +34,7 @@ export const bankstatements = pgTable("bankstatements", {
|
|||||||
credName: text("credName"),
|
credName: text("credName"),
|
||||||
|
|
||||||
text: text("text"),
|
text: text("text"),
|
||||||
|
notes: text("notes"),
|
||||||
amount: doublePrecision("amount").notNull(),
|
amount: doublePrecision("amount").notNull(),
|
||||||
|
|
||||||
tenant: bigint("tenant", { mode: "number" })
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
|
|||||||
34
backend/db/schema/communication_messages.ts
Normal file
34
backend/db/schema/communication_messages.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { communicationRooms } from "./communication_rooms"
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
|
||||||
|
export const communicationMessages = pgTable(
|
||||||
|
"communication_messages",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "number" })
|
||||||
|
.primaryKey()
|
||||||
|
.generatedByDefaultAsIdentity(),
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
roomId: uuid("room_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||||
|
authorUserId: uuid("author_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
roomMessageIdx: index("communication_messages_room_message_idx").on(table.roomId, table.id),
|
||||||
|
tenantIdx: index("communication_messages_tenant_idx").on(table.tenantId),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CommunicationMessage = typeof communicationMessages.$inferSelect
|
||||||
|
export type NewCommunicationMessage = typeof communicationMessages.$inferInsert
|
||||||
26
backend/db/schema/communication_room_members.ts
Normal file
26
backend/db/schema/communication_room_members.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { communicationRooms } from "./communication_rooms"
|
||||||
|
|
||||||
|
export const communicationRoomMembers = pgTable(
|
||||||
|
"communication_room_members",
|
||||||
|
{
|
||||||
|
roomId: uuid("room_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||||
|
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||||
|
userIdx: index("communication_room_members_user_idx").on(table.userId),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CommunicationRoomMember = typeof communicationRoomMembers.$inferSelect
|
||||||
|
export type NewCommunicationRoomMember = typeof communicationRoomMembers.$inferInsert
|
||||||
26
backend/db/schema/communication_room_reads.ts
Normal file
26
backend/db/schema/communication_room_reads.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { bigint, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { communicationRooms } from "./communication_rooms"
|
||||||
|
|
||||||
|
export const communicationRoomReads = pgTable(
|
||||||
|
"communication_room_reads",
|
||||||
|
{
|
||||||
|
roomId: uuid("room_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||||
|
lastReadMessageId: bigint("last_read_message_id", { mode: "number" }),
|
||||||
|
readAt: timestamp("read_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CommunicationRoomRead = typeof communicationRoomReads.$inferSelect
|
||||||
|
export type NewCommunicationRoomRead = typeof communicationRoomReads.$inferInsert
|
||||||
@@ -30,10 +30,6 @@ export const communicationRooms = pgTable(
|
|||||||
entityId: bigint("entity_id", { mode: "number" }),
|
entityId: bigint("entity_id", { mode: "number" }),
|
||||||
entityUuid: uuid("entity_uuid"),
|
entityUuid: uuid("entity_uuid"),
|
||||||
|
|
||||||
matrixRoomId: text("matrix_room_id"),
|
|
||||||
matrixAlias: text("matrix_alias"),
|
|
||||||
parentSpaceRoomId: text("parent_space_room_id"),
|
|
||||||
|
|
||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
|||||||
84
backend/db/schema/document_imports.ts
Normal file
84
backend/db/schema/document_imports.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { files } from "./files"
|
||||||
|
import { folders } from "./folders"
|
||||||
|
import { filetags } from "./filetags"
|
||||||
|
|
||||||
|
export const documentImportSources = pgTable("document_import_sources", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
createdBy: uuid("created_by").references(() => authUsers.id, { onDelete: "set null" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
provider: text("provider").notNull().default("imap"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
mailboxAddressEncrypted: jsonb("mailbox_address_encrypted"),
|
||||||
|
passwordEncrypted: jsonb("password_encrypted"),
|
||||||
|
imapHostEncrypted: jsonb("imap_host_encrypted"),
|
||||||
|
imapPort: integer("imap_port").notNull().default(993),
|
||||||
|
imapSecure: boolean("imap_secure").notNull().default(true),
|
||||||
|
mailboxPath: text("mailbox_path").notNull().default("INBOX"),
|
||||||
|
targetFolderId: uuid("target_folder_id").references(() => folders.id, { onDelete: "set null" }),
|
||||||
|
defaultFiletypeId: uuid("default_filetype_id").references(() => filetags.id, { onDelete: "set null" }),
|
||||||
|
markAsSeen: boolean("mark_as_seen").notNull().default(true),
|
||||||
|
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
}, (table) => ({
|
||||||
|
tenantIdx: index("document_import_sources_tenant_idx").on(table.tenantId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const documentImportStates = pgTable("document_import_states", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sourceId: uuid("source_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => documentImportSources.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
mailboxPath: text("mailbox_path").notNull(),
|
||||||
|
uidValidity: bigint("uid_validity", { mode: "number" }),
|
||||||
|
highestUid: bigint("highest_uid", { mode: "number" }).notNull().default(0),
|
||||||
|
deltaLinkEncrypted: jsonb("delta_link_encrypted"),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
}, (table) => ({
|
||||||
|
sourceMailboxKey: uniqueIndex("document_import_states_source_mailbox_key")
|
||||||
|
.on(table.sourceId, table.mailboxPath),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const documentImportItems = pgTable("document_import_items", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
sourceId: uuid("source_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => documentImportSources.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
remoteMessageId: text("remote_message_id").notNull(),
|
||||||
|
attachmentKey: text("attachment_key").notNull(),
|
||||||
|
attachmentChecksum: text("attachment_checksum").notNull(),
|
||||||
|
filename: text("filename"),
|
||||||
|
status: text("status").notNull(),
|
||||||
|
error: text("error"),
|
||||||
|
fileId: uuid("file_id").references(() => files.id, { onDelete: "set null" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
remoteAttachmentKey: uniqueIndex("document_import_items_remote_attachment_key")
|
||||||
|
.on(table.sourceId, table.remoteMessageId, table.attachmentKey),
|
||||||
|
checksumIdx: index("document_import_items_checksum_idx")
|
||||||
|
.on(table.sourceId, table.attachmentChecksum),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type DocumentImportSource = typeof documentImportSources.$inferSelect
|
||||||
39
backend/db/schema/documenttemplates.ts
Normal file
39
backend/db/schema/documenttemplates.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
jsonb,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const documenttemplates = pgTable("documenttemplates", {
|
||||||
|
id: bigint("id", { mode: "number" })
|
||||||
|
.primaryKey()
|
||||||
|
.generatedByDefaultAsIdentity(),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id),
|
||||||
|
|
||||||
|
name: text("name").notNull(),
|
||||||
|
documentType: text("document_type").notNull(),
|
||||||
|
templateData: jsonb("template_data").notNull().default({}),
|
||||||
|
default: boolean("default").notNull().default(false),
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DocumentTemplate = typeof documenttemplates.$inferSelect
|
||||||
|
export type NewDocumentTemplate = typeof documenttemplates.$inferInsert
|
||||||
@@ -87,6 +87,7 @@ export const emailMessages = pgTable(
|
|||||||
bcc: jsonb("bcc").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
bcc: jsonb("bcc").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
||||||
replyTo: jsonb("reply_to").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
replyTo: jsonb("reply_to").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
||||||
preview: text("preview"),
|
preview: text("preview"),
|
||||||
|
entitySuggestions: jsonb("entity_suggestions").$type<import("../../src/modules/email/email.entity-suggestions").EntitySuggestion[]>(),
|
||||||
flags: jsonb("flags").$type<string[]>(),
|
flags: jsonb("flags").$type<string[]>(),
|
||||||
seen: boolean("seen").notNull().default(false),
|
seen: boolean("seen").notNull().default(false),
|
||||||
flagged: boolean("flagged").notNull().default(false),
|
flagged: boolean("flagged").notNull().default(false),
|
||||||
@@ -155,6 +156,39 @@ export const emailAttachments = pgTable(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const emailEntityLinks = pgTable(
|
||||||
|
"email_entity_links",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
messageId: uuid("message_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
entityId: bigint("entity_id", { mode: "number" }).notNull(),
|
||||||
|
|
||||||
|
linkedBy: uuid("linked_by")
|
||||||
|
.references(() => authUsers.id, { onDelete: "set null", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
messageEntityKey: uniqueIndex("email_entity_links_message_entity_key")
|
||||||
|
.on(table.messageId, table.entityType, table.entityId),
|
||||||
|
entityIdx: index("email_entity_links_entity_idx")
|
||||||
|
.on(table.tenantId, table.entityType, table.entityId),
|
||||||
|
messageIdx: index("email_entity_links_message_idx")
|
||||||
|
.on(table.messageId),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const emailSyncState = pgTable(
|
export const emailSyncState = pgTable(
|
||||||
"email_sync_state",
|
"email_sync_state",
|
||||||
{
|
{
|
||||||
@@ -204,5 +238,7 @@ export type EmailMessageBody = typeof emailMessageBodies.$inferSelect
|
|||||||
export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert
|
export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert
|
||||||
export type EmailAttachment = typeof emailAttachments.$inferSelect
|
export type EmailAttachment = typeof emailAttachments.$inferSelect
|
||||||
export type NewEmailAttachment = typeof emailAttachments.$inferInsert
|
export type NewEmailAttachment = typeof emailAttachments.$inferInsert
|
||||||
|
export type EmailEntityLink = typeof emailEntityLinks.$inferSelect
|
||||||
|
export type NewEmailEntityLink = typeof emailEntityLinks.$inferInsert
|
||||||
export type EmailSyncState = typeof emailSyncState.$inferSelect
|
export type EmailSyncState = typeof emailSyncState.$inferSelect
|
||||||
export type NewEmailSyncState = typeof emailSyncState.$inferInsert
|
export type NewEmailSyncState = typeof emailSyncState.$inferInsert
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
boolean,
|
boolean,
|
||||||
jsonb,
|
jsonb,
|
||||||
|
numeric,
|
||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
@@ -40,6 +41,11 @@ export const incominginvoices = pgTable("incominginvoices", {
|
|||||||
|
|
||||||
paymentType: text("paymentType"),
|
paymentType: text("paymentType"),
|
||||||
|
|
||||||
|
preparationSource: text("preparation_source"),
|
||||||
|
eInvoiceSyntax: text("e_invoice_syntax"),
|
||||||
|
eInvoiceProfile: text("e_invoice_profile"),
|
||||||
|
eInvoiceValidation: jsonb("e_invoice_validation"),
|
||||||
|
|
||||||
accounts: jsonb("accounts").notNull().default([
|
accounts: jsonb("accounts").notNull().default([
|
||||||
{
|
{
|
||||||
account: null,
|
account: null,
|
||||||
@@ -50,6 +56,8 @@ export const incominginvoices = pgTable("incominginvoices", {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
taxAmountOverride: numeric("tax_amount_override", { precision: 12, scale: 2 }),
|
||||||
|
|
||||||
paid: boolean("paid").notNull().default(false),
|
paid: boolean("paid").notNull().default(false),
|
||||||
expense: boolean("expense").notNull().default(true),
|
expense: boolean("expense").notNull().default(true),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./auth_roles"
|
|||||||
export * from "./auth_tenant_users"
|
export * from "./auth_tenant_users"
|
||||||
export * from "./auth_user_roles"
|
export * from "./auth_user_roles"
|
||||||
export * from "./auth_users"
|
export * from "./auth_users"
|
||||||
|
export * from "./auth_refresh_tokens"
|
||||||
export * from "./bankaccounts"
|
export * from "./bankaccounts"
|
||||||
export * from "./bankrequisitions"
|
export * from "./bankrequisitions"
|
||||||
export * from "./bankstatements"
|
export * from "./bankstatements"
|
||||||
@@ -15,18 +16,23 @@ export * from "./checkexecutions"
|
|||||||
export * from "./checks"
|
export * from "./checks"
|
||||||
export * from "./citys"
|
export * from "./citys"
|
||||||
export * from "./communication_rooms"
|
export * from "./communication_rooms"
|
||||||
|
export * from "./communication_room_members"
|
||||||
|
export * from "./communication_messages"
|
||||||
|
export * from "./communication_room_reads"
|
||||||
export * from "./contacts"
|
export * from "./contacts"
|
||||||
export * from "./contracts"
|
export * from "./contracts"
|
||||||
export * from "./contracttypes"
|
export * from "./contracttypes"
|
||||||
export * from "./costcentres"
|
export * from "./costcentres"
|
||||||
export * from "./countrys"
|
export * from "./countrys"
|
||||||
export * from "./createddocuments"
|
export * from "./createddocuments"
|
||||||
|
export * from "./documenttemplates"
|
||||||
export * from "./createdletters"
|
export * from "./createdletters"
|
||||||
export * from "./customers"
|
export * from "./customers"
|
||||||
export * from "./customerspaces"
|
export * from "./customerspaces"
|
||||||
export * from "./customerinventoryitems"
|
export * from "./customerinventoryitems"
|
||||||
export * from "./devices"
|
export * from "./devices"
|
||||||
export * from "./documentboxes"
|
export * from "./documentboxes"
|
||||||
|
export * from "./document_imports"
|
||||||
export * from "./emails"
|
export * from "./emails"
|
||||||
export * from "./enums"
|
export * from "./enums"
|
||||||
export * from "./events"
|
export * from "./events"
|
||||||
@@ -82,6 +88,7 @@ export * from "./taxtypes"
|
|||||||
export * from "./telephony_calls"
|
export * from "./telephony_calls"
|
||||||
export * from "./telephony_extensions"
|
export * from "./telephony_extensions"
|
||||||
export * from "./telephony_trunks"
|
export * from "./telephony_trunks"
|
||||||
|
export * from "./tenant_export_jobs"
|
||||||
export * from "./tenants"
|
export * from "./tenants"
|
||||||
export * from "./texttemplates"
|
export * from "./texttemplates"
|
||||||
export * from "./units"
|
export * from "./units"
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
46
backend/db/schema/tenant_export_jobs.ts
Normal file
46
backend/db/schema/tenant_export_jobs.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
bigint,
|
||||||
|
timestamp,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { lockedTenantEnum } from "./enums"
|
||||||
|
|
||||||
|
export const tenantExportJobs = pgTable("tenant_export_jobs", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||||
|
|
||||||
|
operation: text("operation").notNull().default("export"),
|
||||||
|
previousTenantLocked: lockedTenantEnum("previous_tenant_locked"),
|
||||||
|
status: text("status").notNull().default("queued"),
|
||||||
|
filename: text("filename").notNull(),
|
||||||
|
storagePath: text("storage_path"),
|
||||||
|
contentType: text("content_type").notNull().default("application/zip"),
|
||||||
|
fileSize: bigint("file_size", { mode: "number" }),
|
||||||
|
error: text("error"),
|
||||||
|
statusMessage: text("status_message"),
|
||||||
|
importResult: jsonb("import_result"),
|
||||||
|
|
||||||
|
filesTotal: integer("files_total").notNull().default(0),
|
||||||
|
filesDone: integer("files_done").notNull().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TenantExportJob = typeof tenantExportJobs.$inferSelect
|
||||||
|
export type NewTenantExportJob = typeof tenantExportJobs.$inferInsert
|
||||||
@@ -60,6 +60,15 @@ export const tenants = pgTable(
|
|||||||
city: "",
|
city: "",
|
||||||
name: "",
|
name: "",
|
||||||
street: "",
|
street: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
website: "",
|
||||||
|
taxNumber: "",
|
||||||
|
vatId: "",
|
||||||
|
bankName: "",
|
||||||
|
bankAccountOwner: "",
|
||||||
|
iban: "",
|
||||||
|
bic: "",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
features: jsonb("features").default({
|
features: jsonb("features").default({
|
||||||
@@ -189,6 +198,7 @@ export const tenants = pgTable(
|
|||||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|
||||||
locked: lockedTenantEnum("locked"),
|
locked: lockedTenantEnum("locked"),
|
||||||
|
lockedByExportJobId: uuid("locked_by_export_job_id"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const userCredentials = pgTable("user_credentials", {
|
|||||||
smtpSsl: boolean("smtp_ssl"),
|
smtpSsl: boolean("smtp_ssl"),
|
||||||
|
|
||||||
type: credentialTypesEnum("type").notNull(),
|
type: credentialTypesEnum("type").notNull(),
|
||||||
|
emailEnabled: boolean("email_enabled").notNull().default(true),
|
||||||
|
|
||||||
imapPort: numeric("imap_port"),
|
imapPort: numeric("imap_port"),
|
||||||
imapSsl: boolean("imap_ssl"),
|
imapSsl: boolean("imap_ssl"),
|
||||||
|
|||||||
165
backend/package-lock.json
generated
165
backend/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.0",
|
||||||
|
"fast-xml-parser": "^5.10.1",
|
||||||
"fastify": "^5.5.0",
|
"fastify": "^5.5.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
@@ -5290,6 +5291,24 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||||
|
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strnum": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws/lambda-invoke-store": {
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
||||||
@@ -5775,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",
|
||||||
@@ -5850,6 +5907,18 @@
|
|||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@pdf-lib/standard-fonts": {
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
@@ -6881,6 +6950,18 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/anynum": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/archiver": {
|
"node_modules/archiver": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||||
@@ -8004,10 +8085,10 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "5.2.5",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
|
||||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8016,7 +8097,28 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"strnum": "^2.1.0"
|
"path-expression-matcher": "^1.6.2",
|
||||||
|
"xml-naming": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
|
||||||
|
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^3.0.0",
|
||||||
|
"fast-xml-builder": "^1.2.0",
|
||||||
|
"is-unsafe": "^2.0.0",
|
||||||
|
"path-expression-matcher": "^1.6.2",
|
||||||
|
"strnum": "^2.4.1",
|
||||||
|
"xml-naming": "^0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@@ -8517,6 +8619,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-unsafe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
@@ -9098,6 +9212,21 @@
|
|||||||
"url": "https://ko-fi.com/killymxi"
|
"url": "https://ko-fi.com/killymxi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -9940,16 +10069,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.2",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
|
||||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anynum": "^1.0.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-stream": {
|
"node_modules/tar-stream": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.7",
|
||||||
@@ -10351,6 +10483,21 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test:einvoice": "tsx --test tests/einvoice.test.ts",
|
||||||
"start": "node dist/src/index.js",
|
"start": "node dist/src/index.js",
|
||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
|
"invoices:delete-range": "tsx scripts/delete-createddocument-range.ts",
|
||||||
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
||||||
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
||||||
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.0",
|
||||||
|
"fast-xml-parser": "^5.10.1",
|
||||||
"fastify": "^5.5.0",
|
"fastify": "^5.5.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
|||||||
234
backend/scripts/apply-tenant-export-lock-migration.ts
Normal file
234
backend/scripts/apply-tenant-export-lock-migration.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
const maskConnectionString = (value: string) =>
|
||||||
|
value.replace(/:\/\/([^:@]+):([^@]+)@/, "://$1:***@")
|
||||||
|
|
||||||
|
const logStep = (message: string) => {
|
||||||
|
console.log(`[tenant-lock-migration] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminateIdleBlockers = process.argv.includes("--terminate-idle-blockers")
|
||||||
|
|
||||||
|
const relationLocksQuery = (relationName: string) => sql`
|
||||||
|
SELECT
|
||||||
|
activity.pid,
|
||||||
|
activity.state,
|
||||||
|
activity.wait_event_type,
|
||||||
|
activity.wait_event,
|
||||||
|
now() - activity.query_start AS query_age,
|
||||||
|
locks.mode,
|
||||||
|
locks.granted,
|
||||||
|
left(activity.query, 500) AS query
|
||||||
|
FROM pg_locks locks
|
||||||
|
JOIN pg_class relation ON relation.oid = locks.relation
|
||||||
|
LEFT JOIN pg_stat_activity activity ON activity.pid = locks.pid
|
||||||
|
WHERE relation.relname = ${relationName}
|
||||||
|
ORDER BY locks.granted ASC, activity.query_start ASC NULLS LAST;
|
||||||
|
`
|
||||||
|
|
||||||
|
const getRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||||
|
const result = await db.execute(relationLocksQuery(relationName))
|
||||||
|
return result.rows as Record<string, any>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const printRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||||
|
const rows = await getRelationLocks(db, relationName)
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
logStep(`Keine aktiven Locks fuer ${relationName} gefunden`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(`Aktive Locks fuer ${relationName}:`)
|
||||||
|
for (const row of rows) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
pid: row.pid,
|
||||||
|
state: row.state,
|
||||||
|
waitEventType: row.wait_event_type,
|
||||||
|
waitEvent: row.wait_event,
|
||||||
|
queryAge: row.query_age,
|
||||||
|
mode: row.mode,
|
||||||
|
granted: row.granted,
|
||||||
|
query: row.query,
|
||||||
|
}, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminateIdleRelationBlockers = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||||
|
const rows = await getRelationLocks(db, relationName)
|
||||||
|
const blockerPids = rows
|
||||||
|
.filter((row) =>
|
||||||
|
row.granted === true &&
|
||||||
|
row.state === "idle in transaction" &&
|
||||||
|
Number(row.pid) !== process.pid
|
||||||
|
)
|
||||||
|
.map((row) => Number(row.pid))
|
||||||
|
.filter((pid) => Number.isFinite(pid))
|
||||||
|
|
||||||
|
if (!blockerPids.length) {
|
||||||
|
logStep(`Keine idle-in-transaction Blocker fuer ${relationName} gefunden`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pid of blockerPids) {
|
||||||
|
logStep(`Beende idle-in-transaction DB-Session ${pid}`)
|
||||||
|
await db.execute(sql`SELECT pg_terminate_backend(${pid})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeStatement = async (
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
statement: ReturnType<typeof sql>,
|
||||||
|
) => {
|
||||||
|
await db.execute(statement)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLockError = async (
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
relationName: string,
|
||||||
|
) => {
|
||||||
|
logStep(`Konnte Lock fuer ${relationName} nicht rechtzeitig bekommen`)
|
||||||
|
await printRelationLocks(db, relationName)
|
||||||
|
|
||||||
|
if (!terminateIdleBlockers) return false
|
||||||
|
|
||||||
|
const terminated = await terminateIdleRelationBlockers(db, relationName)
|
||||||
|
if (terminated) {
|
||||||
|
logStep(`Retry nach beendeten idle-in-transaction Sessions fuer ${relationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return terminated
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeStep = async (
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
message: string,
|
||||||
|
statement: ReturnType<typeof sql>,
|
||||||
|
lockRelationName?: string,
|
||||||
|
) => {
|
||||||
|
logStep(message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeStatement(db, statement)
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err?.cause?.code || err?.code
|
||||||
|
if (!lockRelationName || !["55P03", "57014"].includes(code)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRetry = await handleLockError(db, lockRelationName)
|
||||||
|
if (!shouldRetry) throw err
|
||||||
|
|
||||||
|
await executeStatement(db, statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (terminateIdleBlockers) {
|
||||||
|
logStep("Option --terminate-idle-blockers aktiv")
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectionString = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
logStep("DATABASE_URL nicht in process.env gefunden, lade Secrets")
|
||||||
|
await loadSecrets()
|
||||||
|
connectionString = secrets.DATABASE_URL
|
||||||
|
} else {
|
||||||
|
logStep("DATABASE_URL aus process.env gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(`Verbinde mit ${maskConnectionString(connectionString)}`)
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
idleTimeoutMillis: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = drizzle(pool)
|
||||||
|
|
||||||
|
logStep("Teste DB-Verbindung")
|
||||||
|
await db.execute(sql`SELECT 1`)
|
||||||
|
|
||||||
|
logStep("Setze Lock- und Statement-Timeout")
|
||||||
|
await db.execute(sql`SET lock_timeout = '10s'`)
|
||||||
|
await db.execute(sql`SET statement_timeout = '2min'`)
|
||||||
|
|
||||||
|
await executeStep(db, "Erweitere tenant_export_jobs", sql`
|
||||||
|
ALTER TABLE "tenant_export_jobs"
|
||||||
|
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
|
||||||
|
`, "tenant_export_jobs")
|
||||||
|
|
||||||
|
await executeStep(db, "Erweitere tenants", sql`
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
|
||||||
|
`, "tenants")
|
||||||
|
|
||||||
|
await executeStep(db, "Pruefe Foreign Key", sql`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema = 'public'
|
||||||
|
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
|
||||||
|
FOREIGN KEY ("locked_by_export_job_id")
|
||||||
|
REFERENCES "tenant_export_jobs"("id")
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`, "tenants")
|
||||||
|
|
||||||
|
await executeStep(db, "Pruefe Drizzle-Schema", sql`
|
||||||
|
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await executeStep(db, "Pruefe Drizzle-Migrationstabelle", sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
await executeStep(db, "Setze Drizzle-Journal auf 0055", sql`
|
||||||
|
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
||||||
|
SELECT 'manual-0055_tenant_export_maintenance_lock', 1784021669805
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM drizzle.__drizzle_migrations
|
||||||
|
WHERE created_at >= 1784021669805
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
logStep("Fertig")
|
||||||
|
console.log("✅ Tenant export lock migration applied")
|
||||||
|
} finally {
|
||||||
|
logStep("Schließe DB-Verbindung")
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error("❌ Tenant export lock migration failed")
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
67
backend/scripts/dedupe-auth-tenant-users.ts
Normal file
67
backend/scripts/dedupe-auth-tenant-users.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
let connectionString = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
await loadSecrets()
|
||||||
|
connectionString = secrets.DATABASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = drizzle(pool)
|
||||||
|
|
||||||
|
const duplicates = await db.execute(sql`
|
||||||
|
SELECT tenant_id, user_id, count(*)::int AS count
|
||||||
|
FROM auth_tenant_users
|
||||||
|
GROUP BY tenant_id, user_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY count(*) DESC;
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log(`Gefundene doppelte Tenant-Zuordnungen: ${duplicates.rows.length}`)
|
||||||
|
for (const row of duplicates.rows as Record<string, any>[]) {
|
||||||
|
console.log(JSON.stringify(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.execute(sql`
|
||||||
|
DELETE FROM auth_tenant_users duplicate
|
||||||
|
USING auth_tenant_users keep
|
||||||
|
WHERE duplicate.tenant_id = keep.tenant_id
|
||||||
|
AND duplicate.user_id = keep.user_id
|
||||||
|
AND duplicate.ctid > keep.ctid;
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log(`Entfernte doppelte Zeilen: ${deleted.rowCount || 0}`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS auth_tenant_users_tenant_user_unique
|
||||||
|
ON auth_tenant_users (tenant_id, user_id);
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log("Unique Index auth_tenant_users_tenant_user_unique ist vorhanden")
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error("Dedupe auth_tenant_users failed")
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
359
backend/scripts/delete-createddocument-range.ts
Normal file
359
backend/scripts/delete-createddocument-range.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import { Pool, type PoolClient } from "pg"
|
||||||
|
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
tenantId?: number
|
||||||
|
fromNumber?: string
|
||||||
|
toNumber?: string
|
||||||
|
fromId?: number
|
||||||
|
toId?: number
|
||||||
|
types: string[]
|
||||||
|
execute: boolean
|
||||||
|
unlinkRelated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TargetDocument = {
|
||||||
|
id: number
|
||||||
|
documentNumber: string | null
|
||||||
|
type: string
|
||||||
|
title: string | null
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TYPES = ["invoices"]
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Löscht einen Bereich von Ausgangsbelegen und deren direkt verknüpfte DB-Dokumente.
|
||||||
|
|
||||||
|
Standardmäßig läuft das Skript als Dry-Run. Erst --execute löscht wirklich.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010
|
||||||
|
npm run invoices:delete-range -- --tenant 41 --from-id 1200 --to-id 1210 --execute
|
||||||
|
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 --type invoices --type advanceInvoices --execute
|
||||||
|
|
||||||
|
Optionen:
|
||||||
|
--tenant <id> Pflicht: Tenant-ID
|
||||||
|
--from <nummer> Erste Rechnungsnummer im Bereich
|
||||||
|
--to <nummer> Letzte Rechnungsnummer im Bereich
|
||||||
|
--from-id <id> Erste Beleg-ID im Bereich
|
||||||
|
--to-id <id> Letzte Beleg-ID im Bereich
|
||||||
|
--type <type> Belegtyp, mehrfach möglich. Standard: invoices
|
||||||
|
--execute Wirklich löschen. Ohne diese Option nur Vorschau.
|
||||||
|
--unlink-related Externe Belege, die auf Zielbelege zeigen, werden entkoppelt.
|
||||||
|
--help Hilfe anzeigen
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValue(args: string[], index: number, flag: string) {
|
||||||
|
const value = args[index + 1]
|
||||||
|
if (!value || value.startsWith("--")) {
|
||||||
|
throw new Error(`Für ${flag} fehlt ein Wert.`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(args: string[], index: number, flag: string) {
|
||||||
|
const value = Number(readValue(args, index, flag))
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${flag} muss eine positive Ganzzahl sein.`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Args {
|
||||||
|
const parsed: Args = {
|
||||||
|
types: [],
|
||||||
|
execute: false,
|
||||||
|
unlinkRelated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]
|
||||||
|
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
printUsage()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--tenant") {
|
||||||
|
parsed.tenantId = readNumber(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--from") {
|
||||||
|
parsed.fromNumber = readValue(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--to") {
|
||||||
|
parsed.toNumber = readValue(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--from-id") {
|
||||||
|
parsed.fromId = readNumber(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--to-id") {
|
||||||
|
parsed.toId = readNumber(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--type") {
|
||||||
|
parsed.types.push(readValue(argv, i, arg))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--execute") {
|
||||||
|
parsed.execute = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--unlink-related") {
|
||||||
|
parsed.unlinkRelated = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unbekannte Option: ${arg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.tenantId) throw new Error("--tenant ist Pflicht.")
|
||||||
|
|
||||||
|
const hasNumberRange = parsed.fromNumber !== undefined || parsed.toNumber !== undefined
|
||||||
|
const hasIdRange = parsed.fromId !== undefined || parsed.toId !== undefined
|
||||||
|
|
||||||
|
if (hasNumberRange && hasIdRange) {
|
||||||
|
throw new Error("Bitte entweder Rechnungsnummernbereich oder ID-Bereich verwenden, nicht beides.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNumberRange && (!parsed.fromNumber || !parsed.toNumber)) {
|
||||||
|
throw new Error("Für einen Rechnungsnummernbereich sind --from und --to Pflicht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIdRange && (!parsed.fromId || !parsed.toId)) {
|
||||||
|
throw new Error("Für einen ID-Bereich sind --from-id und --to-id Pflicht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNumberRange && !hasIdRange) {
|
||||||
|
throw new Error("Bitte einen Bereich mit --from/--to oder --from-id/--to-id angeben.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.fromNumber && parsed.toNumber && parsed.fromNumber > parsed.toNumber) {
|
||||||
|
throw new Error("--from darf bei Textvergleich nicht größer als --to sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.fromId && parsed.toId && parsed.fromId > parsed.toId) {
|
||||||
|
throw new Error("--from-id darf nicht größer als --to-id sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.types.length === 0) parsed.types = DEFAULT_TYPES
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConnectionString() {
|
||||||
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||||
|
|
||||||
|
await loadSecrets()
|
||||||
|
if (secrets.DATABASE_URL) return secrets.DATABASE_URL
|
||||||
|
|
||||||
|
throw new Error("DATABASE_URL ist nicht konfiguriert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetWhere(args: Args, startParam = 1) {
|
||||||
|
const values: unknown[] = [args.tenantId, args.types]
|
||||||
|
const clauses = [
|
||||||
|
`"tenant" = $${startParam}`,
|
||||||
|
`"type" = ANY($${startParam + 1}::text[])`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (args.fromNumber && args.toNumber) {
|
||||||
|
values.push(args.fromNumber, args.toNumber)
|
||||||
|
clauses.push(`"documentNumber" >= $${startParam + 2}`)
|
||||||
|
clauses.push(`"documentNumber" <= $${startParam + 3}`)
|
||||||
|
} else {
|
||||||
|
values.push(args.fromId, args.toId)
|
||||||
|
clauses.push(`"id" >= $${startParam + 2}`)
|
||||||
|
clauses.push(`"id" <= $${startParam + 3}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
whereSql: clauses.join(" AND "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTargetDocuments(client: PoolClient, args: Args) {
|
||||||
|
const target = buildTargetWhere(args)
|
||||||
|
const result = await client.query<TargetDocument>(
|
||||||
|
`
|
||||||
|
SELECT "id", "documentNumber", "type", "title", "state"
|
||||||
|
FROM "createddocuments"
|
||||||
|
WHERE ${target.whereSql}
|
||||||
|
ORDER BY "id"
|
||||||
|
`,
|
||||||
|
target.values
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
|
||||||
|
const result = await client.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::int AS "count" FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
return Number(result.rows[0]?.count || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findExternalLinkedDocuments(client: PoolClient, targetIds: number[]) {
|
||||||
|
const result = await client.query<TargetDocument>(
|
||||||
|
`
|
||||||
|
SELECT "id", "documentNumber", "type", "title", "state"
|
||||||
|
FROM "createddocuments"
|
||||||
|
WHERE "linkedDocument" = ANY($1::bigint[])
|
||||||
|
AND NOT ("id" = ANY($1::bigint[]))
|
||||||
|
ORDER BY "id"
|
||||||
|
`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
|
||||||
|
const result = await client.query(
|
||||||
|
`DELETE FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rowCount || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTargets(targets: TargetDocument[]) {
|
||||||
|
console.table(
|
||||||
|
targets.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
nummer: item.documentNumber,
|
||||||
|
typ: item.type,
|
||||||
|
status: item.state,
|
||||||
|
titel: item.title,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
const connectionString = await loadConnectionString()
|
||||||
|
const pool = new Pool({ connectionString, max: 1 })
|
||||||
|
const client = await pool.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN")
|
||||||
|
|
||||||
|
const targets = await selectTargetDocuments(client, args)
|
||||||
|
if (targets.length === 0) {
|
||||||
|
console.log("Keine passenden Rechnungen gefunden.")
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIds = targets.map((item) => item.id)
|
||||||
|
const [fileCount, historyCount, allocationCount, externalLinks] = await Promise.all([
|
||||||
|
countByTarget(client, "files", "createddocument", targetIds),
|
||||||
|
countByTarget(client, "historyitems", "createddocument", targetIds),
|
||||||
|
countByTarget(client, "statementallocations", "cd_id", targetIds),
|
||||||
|
findExternalLinkedDocuments(client, targetIds),
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`Tenant: ${args.tenantId}`)
|
||||||
|
console.log(`Belegtypen: ${args.types.join(", ")}`)
|
||||||
|
console.log(`Modus: ${args.execute ? "Löschen" : "Dry-Run"}`)
|
||||||
|
console.log("")
|
||||||
|
printTargets(targets)
|
||||||
|
console.log("")
|
||||||
|
console.log("Betroffene verknüpfte DB-Zeilen:")
|
||||||
|
console.log(`- Dateien: ${fileCount}`)
|
||||||
|
console.log(`- Historie: ${historyCount}`)
|
||||||
|
console.log(`- Bank-Zuordnungen: ${allocationCount}`)
|
||||||
|
|
||||||
|
if (externalLinks.length > 0 && !args.unlinkRelated) {
|
||||||
|
console.log("")
|
||||||
|
console.log("Abbruch: Andere Belege verweisen auf Zielbelege.")
|
||||||
|
console.log("Nutze --unlink-related, wenn diese Verknüpfungen vor dem Löschen entfernt werden sollen.")
|
||||||
|
printTargets(externalLinks)
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.execute) {
|
||||||
|
console.log("")
|
||||||
|
console.log("Dry-Run abgeschlossen. Es wurde nichts gelöscht. Zum Löschen --execute ergänzen.")
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalLinks.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE "createddocuments"
|
||||||
|
SET "linkedDocument" = NULL
|
||||||
|
WHERE "linkedDocument" = ANY($1::bigint[])
|
||||||
|
AND NOT ("id" = ANY($1::bigint[]))
|
||||||
|
`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "createddocuments" SET "linkedDocument" = NULL WHERE "id" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deletedFiles = await deleteByTarget(client, "files", "createddocument", targetIds)
|
||||||
|
const deletedHistory = await deleteByTarget(client, "historyitems", "createddocument", targetIds)
|
||||||
|
const deletedAllocations = await deleteByTarget(client, "statementallocations", "cd_id", targetIds)
|
||||||
|
|
||||||
|
const deletedDocuments = await client.query(
|
||||||
|
`DELETE FROM "createddocuments" WHERE "id" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.query("COMMIT")
|
||||||
|
|
||||||
|
console.log("")
|
||||||
|
console.log("Löschung abgeschlossen:")
|
||||||
|
console.log(`- Rechnungen: ${deletedDocuments.rowCount || 0}`)
|
||||||
|
console.log(`- Dateien: ${deletedFiles}`)
|
||||||
|
console.log(`- Historie: ${deletedHistory}`)
|
||||||
|
console.log(`- Bank-Zuordnungen: ${deletedAllocations}`)
|
||||||
|
if (externalLinks.length > 0) {
|
||||||
|
console.log(`- Entkoppelte externe Belege: ${externalLinks.length}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Fehler beim Löschen der Rechnungen:")
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -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";
|
||||||
@@ -35,6 +36,7 @@ import communicationRoutes from "./routes/communication";
|
|||||||
import telephonyRoutes from "./routes/telephony";
|
import telephonyRoutes from "./routes/telephony";
|
||||||
import instanceAgentRoutes from "./routes/instanceAgents";
|
import instanceAgentRoutes from "./routes/instanceAgents";
|
||||||
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
|
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
|
||||||
|
import documentImportRoutes from "./routes/documentImports";
|
||||||
|
|
||||||
//Public Links
|
//Public Links
|
||||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||||
@@ -60,7 +62,8 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
|||||||
import {initMailer} from "./utils/mailer"
|
import {initMailer} from "./utils/mailer"
|
||||||
import {initS3} from "./utils/s3";
|
import {initS3} from "./utils/s3";
|
||||||
import { runBootstrap } from "./modules/bootstrap.service";
|
import { runBootstrap } from "./modules/bootstrap.service";
|
||||||
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
|
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
||||||
|
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
||||||
|
|
||||||
|
|
||||||
//Services
|
//Services
|
||||||
@@ -86,7 +89,8 @@ async function main() {
|
|||||||
await app.register(dbPlugin);
|
await app.register(dbPlugin);
|
||||||
await app.register(servicesPlugin);
|
await app.register(servicesPlugin);
|
||||||
await runBootstrap(app);
|
await runBootstrap(app);
|
||||||
startMatrixPushWorker(app);
|
startCentralServicesHeartbeat(app);
|
||||||
|
startDocumentImportWorker(app);
|
||||||
|
|
||||||
app.addHook('preHandler', (req, reply, done) => {
|
app.addHook('preHandler', (req, reply, done) => {
|
||||||
console.log(req.method)
|
console.log(req.method)
|
||||||
@@ -95,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']
|
||||||
@@ -144,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);
|
||||||
@@ -165,6 +166,7 @@ async function main() {
|
|||||||
await subApp.register(communicationRoutes);
|
await subApp.register(communicationRoutes);
|
||||||
await subApp.register(telephonyRoutes);
|
await subApp.register(telephonyRoutes);
|
||||||
await subApp.register(instanceAgentRoutes);
|
await subApp.register(instanceAgentRoutes);
|
||||||
|
await subApp.register(documentImportRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|
||||||
|
|||||||
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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
88
backend/src/mcp/projectPhases.ts
Normal file
88
backend/src/mcp/projectPhases.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export type ProjectPhase = {
|
||||||
|
key?: string
|
||||||
|
label?: string
|
||||||
|
active?: boolean
|
||||||
|
optional?: boolean
|
||||||
|
activated_at?: string
|
||||||
|
activated_by?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhaseSelector = {
|
||||||
|
phaseKey?: string | null
|
||||||
|
phaseLabel?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activateProjectPhase = (
|
||||||
|
value: unknown,
|
||||||
|
selector: PhaseSelector,
|
||||||
|
userId: string,
|
||||||
|
activatedAt = new Date().toISOString(),
|
||||||
|
) => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
throw new Error("Das Projekt hat keine Phasen")
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases = value as ProjectPhase[]
|
||||||
|
const phaseKey = String(selector.phaseKey || "").trim()
|
||||||
|
const phaseLabel = String(selector.phaseLabel || "").trim()
|
||||||
|
|
||||||
|
if (Boolean(phaseKey) === Boolean(phaseLabel)) {
|
||||||
|
throw new Error("Genau phaseKey oder phaseLabel ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = phases
|
||||||
|
.map((phase, index) => ({ phase, index }))
|
||||||
|
.filter(({ phase }) => phaseKey ? phase.key === phaseKey : phase.label === phaseLabel)
|
||||||
|
|
||||||
|
if (matches.length === 0) throw new Error("Projektphase nicht gefunden")
|
||||||
|
if (matches.length > 1) throw new Error("Projektphase ist nicht eindeutig; bitte phaseKey verwenden")
|
||||||
|
|
||||||
|
const { phase: target, index: targetIndex } = matches[0]
|
||||||
|
const activeIndex = phases.findIndex((phase) => phase.active === true)
|
||||||
|
const active = activeIndex >= 0 ? phases[activeIndex] : null
|
||||||
|
|
||||||
|
if (target.active) throw new Error(`Projektphase „${target.label || target.key}“ ist bereits aktiv`)
|
||||||
|
if (target.activated_at) throw new Error(`Projektphase „${target.label || target.key}“ wurde bereits aktiviert`)
|
||||||
|
|
||||||
|
const completesProject = target.label === "Abgeschlossen"
|
||||||
|
if (!completesProject && activeIndex >= 0) {
|
||||||
|
if (targetIndex <= activeIndex) {
|
||||||
|
throw new Error("Eine bereits durchlaufene Projektphase kann nicht erneut aktiviert werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredSkippedPhase = phases
|
||||||
|
.slice(activeIndex + 1, targetIndex)
|
||||||
|
.find((candidate) => !candidate.optional)
|
||||||
|
|
||||||
|
if (requiredSkippedPhase) {
|
||||||
|
throw new Error(`Die Phase „${requiredSkippedPhase.label || requiredSkippedPhase.key}“ muss zuerst aktiviert werden`)
|
||||||
|
}
|
||||||
|
} else if (!completesProject && activeIndex < 0 && targetIndex !== 0) {
|
||||||
|
throw new Error(`Die Phase „${phases[0].label || phases[0].key}“ muss zuerst aktiviert werden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPhases = phases.map((phase, index) => {
|
||||||
|
if (index === targetIndex) {
|
||||||
|
return {
|
||||||
|
...phase,
|
||||||
|
active: true,
|
||||||
|
activated_at: activatedAt,
|
||||||
|
activated_by: userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (phase.active) {
|
||||||
|
return {
|
||||||
|
...phase,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...phase }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
phases: nextPhases,
|
||||||
|
activePhase: String(target.label || "").trim() || null,
|
||||||
|
previousPhase: active ? String(active.label || "").trim() || null : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { accountingTools } from "./tools/accounting"
|
import { accountingTools } from "./tools/accounting"
|
||||||
import { masterdataTools } from "./tools/masterdata"
|
import { masterdataTools } from "./tools/masterdata"
|
||||||
import { organisationTools } from "./tools/organisation"
|
import { organisationTools } from "./tools/organisation"
|
||||||
|
import { wikiTools } from "./tools/wiki"
|
||||||
|
|
||||||
export const mcpTools = [
|
export const mcpTools = [
|
||||||
...accountingTools,
|
...accountingTools,
|
||||||
...masterdataTools,
|
...masterdataTools,
|
||||||
...organisationTools,
|
...organisationTools,
|
||||||
|
...wikiTools,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||||
|
|||||||
@@ -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 {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
147
backend/src/mcp/statementAllocations.ts
Normal file
147
backend/src/mcp/statementAllocations.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details"
|
||||||
|
|
||||||
|
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||||
|
|
||||||
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = args[key]
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statementAllocationTargetFields = [
|
||||||
|
"createddocument",
|
||||||
|
"incominginvoice",
|
||||||
|
"account",
|
||||||
|
"ownaccount",
|
||||||
|
"customer",
|
||||||
|
"vendor",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const manualDebitFields = ["account", "customer", "vendor", "ownaccount"] as const
|
||||||
|
const manualCreditFields = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] as const
|
||||||
|
const bookingModes = new Set(["expense", "depreciation_single", "depreciation_bundle"])
|
||||||
|
const depreciationMethods = new Set(["linear", "degressive"])
|
||||||
|
|
||||||
|
const requiredNumber = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = Number(args[key])
|
||||||
|
if (!hasValue(args[key]) || !Number.isFinite(value)) throw new Error(`${key} muss eine gültige Zahl sein`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalNumericId = (args: Record<string, unknown>, key: string) => {
|
||||||
|
if (!hasValue(args[key])) return undefined
|
||||||
|
const value = Number(args[key])
|
||||||
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${key} muss eine gültige ID sein`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statementAllocationUuidArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
if (!hasValue(args[key])) return undefined
|
||||||
|
const value = String(args[key]).trim()
|
||||||
|
if (!/^[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(value)) {
|
||||||
|
throw new Error(`${key} muss eine gültige UUID sein`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDate = (value: string | null) =>
|
||||||
|
Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)))
|
||||||
|
|
||||||
|
export const prepareStatementAllocationInput = (args: Record<string, unknown>) => {
|
||||||
|
const payload: Record<string, any> = {}
|
||||||
|
const amount = requiredNumber(args, "amount")
|
||||||
|
const bankstatement = optionalNumericId(args, "bankstatement")
|
||||||
|
const bookingMode = stringArg(args, "bookingMode") || "expense"
|
||||||
|
|
||||||
|
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||||
|
|
||||||
|
payload.amount = amount
|
||||||
|
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||||
|
payload.bookingMode = bookingMode
|
||||||
|
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||||
|
|
||||||
|
for (const field of ["createddocument", "incominginvoice", "account", "customer", "vendor"] as const) {
|
||||||
|
const value = optionalNumericId(args, field)
|
||||||
|
if (value !== undefined) payload[field] = value
|
||||||
|
}
|
||||||
|
for (const field of ["ownaccount", "contraOwnaccount"] as const) {
|
||||||
|
const value = statementAllocationUuidArg(args, field)
|
||||||
|
if (value !== undefined) payload[field] = value
|
||||||
|
}
|
||||||
|
for (const field of ["contraAccount", "contraCustomer", "contraVendor"] as const) {
|
||||||
|
const value = optionalNumericId(args, field)
|
||||||
|
if (value !== undefined) payload[field] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bankstatement !== undefined) {
|
||||||
|
const selectedTargets = statementAllocationTargetFields.filter((field) => hasValue(payload[field]))
|
||||||
|
if (selectedTargets.length !== 1) {
|
||||||
|
throw new Error("Für eine Bankzuweisung muss genau ein Ziel ausgewählt werden")
|
||||||
|
}
|
||||||
|
if (amount === 0) throw new Error("Der Zuweisungsbetrag darf nicht 0 sein")
|
||||||
|
if (manualCreditFields.some((field) => hasValue(payload[field]))) {
|
||||||
|
throw new Error("Haben-Konten sind nur bei manuellen Buchungen zulässig")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.bankstatement = bankstatement
|
||||||
|
} else {
|
||||||
|
const manualBookingDate = stringArg(args, "manualBookingDate")
|
||||||
|
if (!validDate(manualBookingDate)) {
|
||||||
|
throw new Error("Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich")
|
||||||
|
}
|
||||||
|
if (amount <= 0) throw new Error("Für manuelle Buchungen muss der Betrag größer als 0 sein")
|
||||||
|
|
||||||
|
const manualInvoiceSide = stringArg(args, "manualInvoiceSide")
|
||||||
|
const hasInvoice = hasValue(payload.incominginvoice)
|
||||||
|
if (hasInvoice && manualInvoiceSide !== "debit" && manualInvoiceSide !== "credit") {
|
||||||
|
throw new Error("Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein")
|
||||||
|
}
|
||||||
|
if (!hasInvoice && manualInvoiceSide) {
|
||||||
|
throw new Error("manualInvoiceSide ist nur zusammen mit incominginvoice zulässig")
|
||||||
|
}
|
||||||
|
|
||||||
|
const debitCount = manualDebitFields.filter((field) => hasValue(payload[field])).length
|
||||||
|
+ (hasInvoice && manualInvoiceSide === "debit" ? 1 : 0)
|
||||||
|
const creditCount = manualCreditFields.filter((field) => hasValue(payload[field])).length
|
||||||
|
+ (hasInvoice && manualInvoiceSide === "credit" ? 1 : 0)
|
||||||
|
if (debitCount !== 1 || creditCount !== 1) {
|
||||||
|
throw new Error("Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.bankstatement = null
|
||||||
|
payload.manualBookingDate = manualBookingDate
|
||||||
|
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
|
||||||
|
|
||||||
|
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
||||||
|
const depreciationStartDate = stringArg(args, "depreciationStartDate")
|
||||||
|
const depreciationMethod = stringArg(args, "depreciationMethod") || "linear"
|
||||||
|
const residualValue = args.residualValue === undefined ? 0 : requiredNumber(args, "residualValue")
|
||||||
|
|
||||||
|
if (!Number.isInteger(depreciationMonths) || depreciationMonths <= 0) {
|
||||||
|
throw new Error("Die Abschreibungsdauer muss eine positive Anzahl Monate sein")
|
||||||
|
}
|
||||||
|
if (!validDate(depreciationStartDate)) throw new Error("Ein gültiger Abschreibungsbeginn ist erforderlich")
|
||||||
|
if (!depreciationMethods.has(depreciationMethod)) {
|
||||||
|
throw new Error(`Ungültige Abschreibungsmethode: ${depreciationMethod}`)
|
||||||
|
}
|
||||||
|
if (residualValue < 0) throw new Error("Der Restwert darf nicht negativ sein")
|
||||||
|
|
||||||
|
payload.depreciationMonths = depreciationMonths
|
||||||
|
payload.depreciationStartDate = depreciationStartDate
|
||||||
|
payload.depreciationMethod = depreciationMethod
|
||||||
|
if (args.depreciationLabel !== undefined) payload.depreciationLabel = stringArg(args, "depreciationLabel")
|
||||||
|
payload.depreciationGroup = bookingMode === "depreciation_bundle" ? stringArg(args, "depreciationGroup") : null
|
||||||
|
payload.residualValue = residualValue
|
||||||
|
|
||||||
|
if (bookingMode === "depreciation_bundle" && !payload.depreciationGroup) {
|
||||||
|
throw new Error("Für eine Sammelabschreibung ist eine Abschreibungsgruppe erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -1,24 +1,35 @@
|
|||||||
|
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,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
|
customers,
|
||||||
filetags,
|
filetags,
|
||||||
files,
|
files,
|
||||||
folders,
|
folders,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
|
ownaccounts,
|
||||||
|
serialExecutions,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
|
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 { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||||
|
import {
|
||||||
|
prepareStatementAllocationInput,
|
||||||
|
statementAllocationUuidArg,
|
||||||
|
} from "../statementAllocations"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
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]
|
||||||
@@ -33,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",
|
||||||
@@ -357,6 +384,44 @@ const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assertTenantEntityExists = async (
|
||||||
|
context: any,
|
||||||
|
table: any,
|
||||||
|
idColumn: any,
|
||||||
|
tenantColumn: any | null,
|
||||||
|
id: unknown,
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
if (!hasValue(id)) return
|
||||||
|
|
||||||
|
const [row] = await context.server.db
|
||||||
|
.select({ id: idColumn })
|
||||||
|
.from(table)
|
||||||
|
.where(tenantColumn
|
||||||
|
? and(eq(idColumn, id as any), eq(tenantColumn, context.tenantId))
|
||||||
|
: eq(idColumn, id as any))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) throw new Error(`${label} nicht gefunden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
||||||
|
await Promise.all([
|
||||||
|
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
|
||||||
|
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
||||||
|
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
||||||
|
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
|
||||||
|
assertTenantEntityExists(context, accounts, accounts.id, null, payload.account, "Sachkonto"),
|
||||||
|
assertTenantEntityExists(context, accounts, accounts.id, null, payload.contraAccount, "Haben-Sachkonto"),
|
||||||
|
assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.ownaccount, "Zusätzliches Konto"),
|
||||||
|
assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.contraOwnaccount, "Zusätzliches Haben-Konto"),
|
||||||
|
assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.customer, "Debitor"),
|
||||||
|
assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.contraCustomer, "Haben-Debitor"),
|
||||||
|
assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.vendor, "Kreditor"),
|
||||||
|
assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.contraVendor, "Haben-Kreditor"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export const accountingTools: McpTool[] = [
|
export const accountingTools: McpTool[] = [
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.tax_types.list",
|
name: "accounting.outgoing_documents.tax_types.list",
|
||||||
@@ -390,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) {
|
||||||
@@ -408,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -439,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")
|
||||||
@@ -455,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" },
|
||||||
@@ -477,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) {
|
||||||
@@ -509,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" },
|
||||||
@@ -532,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) {
|
||||||
@@ -567,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",
|
||||||
@@ -587,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" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -622,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",
|
||||||
@@ -661,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) {
|
||||||
@@ -691,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) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -707,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) {
|
||||||
@@ -717,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -748,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")
|
||||||
@@ -765,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) {
|
||||||
@@ -779,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1095,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) {
|
||||||
@@ -1104,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1135,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")
|
||||||
@@ -1152,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) {
|
||||||
@@ -1163,16 +1384,127 @@ 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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.statement_allocations.create",
|
||||||
|
title: "Bankzuweisung erstellen",
|
||||||
|
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||||
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["amount"],
|
||||||
|
properties: {
|
||||||
|
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
||||||
|
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
||||||
|
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||||
|
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||||
|
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||||
|
ownaccount: { type: "string", description: "UUID eines zusätzlichen Kontos beziehungsweise Soll-Kontos." },
|
||||||
|
customer: { type: "number", description: "Debitor-ID beziehungsweise Soll-Debitor." },
|
||||||
|
vendor: { type: "number", description: "Kreditor-ID beziehungsweise Soll-Kreditor." },
|
||||||
|
contraAccount: { type: "number", description: "Haben-Sachkonto für manuelle Buchungen." },
|
||||||
|
contraOwnaccount: { type: "string", description: "UUID eines zusätzlichen Haben-Kontos." },
|
||||||
|
contraCustomer: { type: "number", description: "Haben-Debitor für manuelle Buchungen." },
|
||||||
|
contraVendor: { type: "number", description: "Haben-Kreditor für manuelle Buchungen." },
|
||||||
|
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." },
|
||||||
|
description: { type: "string" },
|
||||||
|
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" },
|
||||||
|
depreciationMonths: { type: "number", minimum: 1 },
|
||||||
|
depreciationStartDate: { type: "string", format: "date" },
|
||||||
|
depreciationMethod: { type: "string", enum: ["linear", "degressive"], default: "linear" },
|
||||||
|
depreciationLabel: { type: "string" },
|
||||||
|
depreciationGroup: { type: "string" },
|
||||||
|
residualValue: { type: "number", minimum: 0, default: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const payload = prepareStatementAllocationInput(args)
|
||||||
|
await assertStatementAllocationReferences(context, payload)
|
||||||
|
|
||||||
|
const [created] = await context.server.db
|
||||||
|
.insert(statementallocations)
|
||||||
|
.values({
|
||||||
|
...payload,
|
||||||
|
tenant: context.tenantId,
|
||||||
|
updated_at: new Date(),
|
||||||
|
updated_by: context.userId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (created?.bankstatement) {
|
||||||
|
await insertHistoryItem(context.server, {
|
||||||
|
entity: "bankstatements",
|
||||||
|
entityId: Number(created.bankstatement),
|
||||||
|
action: "created",
|
||||||
|
created_by: context.userId,
|
||||||
|
tenant_id: context.tenantId,
|
||||||
|
oldVal: null,
|
||||||
|
newVal: created,
|
||||||
|
text: "Buchung über MCP erstellt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allocation: created }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.statement_allocations.delete",
|
||||||
|
title: "Bankzuweisung löschen",
|
||||||
|
description: "Löscht eine Bankzuweisung oder manuelle Soll/Haben-Buchung im aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "UUID der Bankzuweisung." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = statementAllocationUuidArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(statementallocations)
|
||||||
|
.where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Bankzuweisung nicht gefunden")
|
||||||
|
|
||||||
|
const [deleted] = await context.server.db
|
||||||
|
.delete(statementallocations)
|
||||||
|
.where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (existing.bankstatement) {
|
||||||
|
await insertHistoryItem(context.server, {
|
||||||
|
entity: "bankstatements",
|
||||||
|
entityId: Number(existing.bankstatement),
|
||||||
|
action: "deleted",
|
||||||
|
created_by: context.userId,
|
||||||
|
tenant_id: context.tenantId,
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: null,
|
||||||
|
text: "Buchung über MCP gelöscht",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true, allocation: deleted }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,12 +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 { 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]
|
||||||
@@ -28,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) {
|
||||||
@@ -45,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({
|
||||||
@@ -61,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,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) {
|
||||||
@@ -98,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -129,13 +127,84 @@ 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")
|
||||||
return { project: rows[0] }
|
return { project: rows[0] }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.projects.phase.update",
|
||||||
|
title: "Projektphase aktualisieren",
|
||||||
|
description: "Aktiviert eine Projektphase anhand ihres Schlüssels oder ihrer eindeutigen Bezeichnung. Pflichtphasen können nicht übersprungen werden; optionale Phasen und der direkte Abschluss entsprechen dem Verhalten der Oberfläche.",
|
||||||
|
requiredPermissions: ["organisation.projects.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
oneOf: [
|
||||||
|
{ required: ["phaseKey"] },
|
||||||
|
{ required: ["phaseLabel"] },
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Projekt-ID." },
|
||||||
|
phaseKey: { type: "string", description: "Technischer Schlüssel der zu aktivierenden Phase; alternativ zu phaseLabel." },
|
||||||
|
phaseLabel: { type: "string", description: "Eindeutige Bezeichnung der zu aktivierenden Phase, falls kein phaseKey bekannt ist." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Projekt nicht gefunden")
|
||||||
|
if (existing.archived) throw new Error("Die Phase eines archivierten Projekts kann nicht geändert werden")
|
||||||
|
|
||||||
|
const transition = activateProjectPhase(existing.phases, {
|
||||||
|
phaseKey: stringArg(args, "phaseKey"),
|
||||||
|
phaseLabel: stringArg(args, "phaseLabel"),
|
||||||
|
}, context.userId)
|
||||||
|
|
||||||
|
const [updated] = await context.server.db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
phases: transition.phases,
|
||||||
|
active_phase: transition.activePhase,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: context.userId,
|
||||||
|
})
|
||||||
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw new Error("Projekt nicht gefunden")
|
||||||
|
|
||||||
|
await insertHistoryItem(context.server, {
|
||||||
|
tenant_id: context.tenantId,
|
||||||
|
created_by: context.userId,
|
||||||
|
entity: "projects",
|
||||||
|
entityId: id,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: updated,
|
||||||
|
text: transition.previousPhase
|
||||||
|
? `Projektphase von „${transition.previousPhase}“ auf „${transition.activePhase}“ geändert`
|
||||||
|
: `Projektphase „${transition.activePhase}“ aktiviert`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: updated,
|
||||||
|
phaseTransition: {
|
||||||
|
previousPhase: transition.previousPhase,
|
||||||
|
activePhase: transition.activePhase,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "organisation.plants.list",
|
name: "organisation.plants.list",
|
||||||
title: "Anlagen auflisten",
|
title: "Anlagen auflisten",
|
||||||
@@ -146,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) {
|
||||||
@@ -157,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,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) {
|
||||||
@@ -202,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -225,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) {
|
||||||
@@ -244,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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -275,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")
|
||||||
|
|||||||
313
backend/src/mcp/tools/wiki.ts
Normal file
313
backend/src/mcp/tools/wiki.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
|
import { and, asc, eq, ilike, isNull } from "drizzle-orm"
|
||||||
|
import { wikiPages } from "../../../db/schema"
|
||||||
|
import { McpContext, McpTool } from "../types"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
type WikiScope = {
|
||||||
|
entityType: string | null
|
||||||
|
entityId: number | null
|
||||||
|
entityUuid: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = args[key]
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = stringArg(args, key)
|
||||||
|
if (!value || !UUID_PATTERN.test(value)) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIdArg = (args: Record<string, unknown>, key = "id") => {
|
||||||
|
const value = uuidArg(args, key)
|
||||||
|
if (!value) throw new Error(`${key} muss eine gültige UUID sein`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function prepareWikiScope(args: Record<string, unknown>): WikiScope {
|
||||||
|
const entityType = stringArg(args, "entityType")
|
||||||
|
const hasEntityId = args.entityId !== undefined && args.entityId !== null
|
||||||
|
const hasEntityUuid = args.entityUuid !== undefined && args.entityUuid !== null
|
||||||
|
|
||||||
|
if (!entityType && (hasEntityId || hasEntityUuid)) {
|
||||||
|
throw new Error("entityType ist für ein Entitäts-Wiki erforderlich")
|
||||||
|
}
|
||||||
|
if (entityType && !hasEntityId && !hasEntityUuid) {
|
||||||
|
throw new Error("Für ein Entitäts-Wiki ist entityId oder entityUuid erforderlich")
|
||||||
|
}
|
||||||
|
if (hasEntityId && hasEntityUuid) {
|
||||||
|
throw new Error("Bitte entweder entityId oder entityUuid angeben, nicht beides")
|
||||||
|
}
|
||||||
|
if (!entityType) {
|
||||||
|
return { entityType: null, entityId: null, entityUuid: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEntityId) {
|
||||||
|
const entityId = Number(args.entityId)
|
||||||
|
if (!Number.isSafeInteger(entityId) || entityId <= 0) {
|
||||||
|
throw new Error("entityId muss eine positive ganze Zahl sein")
|
||||||
|
}
|
||||||
|
return { entityType, entityId, entityUuid: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityUuid = uuidArg(args, "entityUuid")
|
||||||
|
if (!entityUuid) throw new Error("entityUuid muss eine gültige UUID sein")
|
||||||
|
return { entityType, entityId: null, entityUuid }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeConditions = (scope: WikiScope) => [
|
||||||
|
scope.entityType === null ? isNull(wikiPages.entityType) : eq(wikiPages.entityType, scope.entityType),
|
||||||
|
scope.entityId === null ? isNull(wikiPages.entityId) : eq(wikiPages.entityId, scope.entityId),
|
||||||
|
scope.entityUuid === null ? isNull(wikiPages.entityUuid) : eq(wikiPages.entityUuid, scope.entityUuid),
|
||||||
|
]
|
||||||
|
|
||||||
|
const scopeFromPage = (page: {
|
||||||
|
entityType: string | null
|
||||||
|
entityId: number | null
|
||||||
|
entityUuid: string | null
|
||||||
|
}): WikiScope => ({
|
||||||
|
entityType: page.entityType,
|
||||||
|
entityId: page.entityId,
|
||||||
|
entityUuid: page.entityUuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sameScope = (left: WikiScope, right: WikiScope) =>
|
||||||
|
left.entityType === right.entityType
|
||||||
|
&& left.entityId === right.entityId
|
||||||
|
&& left.entityUuid === right.entityUuid
|
||||||
|
|
||||||
|
async function loadPage(context: McpContext, id: string) {
|
||||||
|
const [page] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(wikiPages)
|
||||||
|
.where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!page) throw new Error("Wiki-Seite nicht gefunden")
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertValidParent(
|
||||||
|
context: McpContext,
|
||||||
|
parentId: string | null,
|
||||||
|
scope: WikiScope,
|
||||||
|
pageId?: string,
|
||||||
|
) {
|
||||||
|
if (!parentId) return
|
||||||
|
if (parentId === pageId) throw new Error("Eine Wiki-Seite kann nicht ihr eigener übergeordneter Eintrag sein")
|
||||||
|
|
||||||
|
const parent = await loadPage(context, parentId)
|
||||||
|
if (!parent.isFolder) throw new Error("Der übergeordnete Wiki-Eintrag muss ein Ordner sein")
|
||||||
|
if (!sameScope(scopeFromPage(parent), scope)) {
|
||||||
|
throw new Error("Übergeordneter Eintrag und Wiki-Seite müssen zum selben Wiki gehören")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityScopeProperties = {
|
||||||
|
entityType: { type: "string", description: "Technischer Entitätstyp, z. B. customers oder projects. Ohne Angabe wird das allgemeine Wiki verwendet." },
|
||||||
|
entityId: { type: "number", description: "Numerische ID der Entität; nur zusammen mit entityType und alternativ zu entityUuid." },
|
||||||
|
entityUuid: { type: "string", format: "uuid", description: "UUID der Entität; nur zusammen mit entityType und alternativ zu entityId." },
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageProperties = {
|
||||||
|
title: { type: "string", description: "Titel der Wiki-Seite oder des Ordners." },
|
||||||
|
content: { description: "Inhalt als JSON, üblicherweise ein Tiptap/ProseMirror-Dokument." },
|
||||||
|
parentId: { type: ["string", "null"], format: "uuid", description: "Übergeordneter Ordner; null verschiebt den Eintrag auf die oberste Ebene." },
|
||||||
|
sortOrder: { type: "integer", description: "Sortierreihenfolge innerhalb derselben Ebene." },
|
||||||
|
isFolder: { type: "boolean", description: "Kennzeichnet den Eintrag als Ordner." },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wikiTools: McpTool[] = [
|
||||||
|
{
|
||||||
|
name: "wiki.pages.list",
|
||||||
|
title: "Wiki-Seiten auflisten",
|
||||||
|
description: "Listet Seiten und Ordner des allgemeinen Wikis oder des Wikis einer einzelnen Entität. Der Inhalt wird nicht mitgeladen; dafür wiki.pages.get verwenden.",
|
||||||
|
requiredPermissions: ["wiki.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
...entityScopeProperties,
|
||||||
|
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." },
|
||||||
|
...paginationProperties,
|
||||||
|
limit: { ...paginationProperties.limit, default: 50 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const scope = prepareWikiScope(args)
|
||||||
|
const conditions = [eq(wikiPages.tenantId, context.tenantId), ...scopeConditions(scope)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
if (query) conditions.push(ilike(wikiPages.title, `%${query}%`))
|
||||||
|
if (Object.prototype.hasOwnProperty.call(args, "parentId")) {
|
||||||
|
if (args.parentId === null) conditions.push(isNull(wikiPages.parentId))
|
||||||
|
else conditions.push(eq(wikiPages.parentId, pageIdArg(args, "parentId")))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select({
|
||||||
|
id: wikiPages.id,
|
||||||
|
parentId: wikiPages.parentId,
|
||||||
|
title: wikiPages.title,
|
||||||
|
isFolder: wikiPages.isFolder,
|
||||||
|
sortOrder: wikiPages.sortOrder,
|
||||||
|
entityType: wikiPages.entityType,
|
||||||
|
entityId: wikiPages.entityId,
|
||||||
|
entityUuid: wikiPages.entityUuid,
|
||||||
|
createdAt: wikiPages.createdAt,
|
||||||
|
updatedAt: wikiPages.updatedAt,
|
||||||
|
})
|
||||||
|
.from(wikiPages)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title), wikiPages.id)
|
||||||
|
.limit(limitFromArgs(args, 50) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
|
return paginatedResult(rows, args, 50)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki.pages.get",
|
||||||
|
title: "Wiki-Seite laden",
|
||||||
|
description: "Lädt eine Wiki-Seite einschließlich ihres Inhalts anhand ihrer UUID.",
|
||||||
|
requiredPermissions: ["wiki.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "string", format: "uuid" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
return { page: await loadPage(context, pageIdArg(args)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki.pages.create",
|
||||||
|
title: "Wiki-Seite erstellen",
|
||||||
|
description: "Erstellt eine Seite oder einen Ordner im allgemeinen Wiki oder im Wiki einer einzelnen Entität.",
|
||||||
|
requiredPermissions: ["wiki.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["title"],
|
||||||
|
properties: {
|
||||||
|
...pageProperties,
|
||||||
|
...entityScopeProperties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const title = stringArg(args, "title")
|
||||||
|
if (!title) throw new Error("title ist erforderlich")
|
||||||
|
const scope = prepareWikiScope(args)
|
||||||
|
const parentId = args.parentId === undefined || args.parentId === null
|
||||||
|
? null
|
||||||
|
: pageIdArg(args, "parentId")
|
||||||
|
await assertValidParent(context, parentId, scope)
|
||||||
|
|
||||||
|
const sortOrder = args.sortOrder === undefined ? 0 : Number(args.sortOrder)
|
||||||
|
if (!Number.isInteger(sortOrder)) throw new Error("sortOrder muss eine ganze Zahl sein")
|
||||||
|
|
||||||
|
const [page] = await context.server.db
|
||||||
|
.insert(wikiPages)
|
||||||
|
.values({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
title,
|
||||||
|
content: args.content ?? null,
|
||||||
|
parentId,
|
||||||
|
sortOrder,
|
||||||
|
isFolder: args.isFolder === true,
|
||||||
|
...scope,
|
||||||
|
createdBy: context.userId,
|
||||||
|
updatedBy: context.userId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return { page }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki.pages.update",
|
||||||
|
title: "Wiki-Seite bearbeiten",
|
||||||
|
description: "Bearbeitet Titel, Inhalt, Position oder Ordnereigenschaften einer Wiki-Seite. Funktioniert für das allgemeine Wiki und für Entitäts-Wikis.",
|
||||||
|
requiredPermissions: ["wiki.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", format: "uuid" },
|
||||||
|
...pageProperties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = pageIdArg(args)
|
||||||
|
const existing = await loadPage(context, id)
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: context.userId,
|
||||||
|
}
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
if (args.title !== undefined) {
|
||||||
|
const title = stringArg(args, "title")
|
||||||
|
if (!title) throw new Error("title darf nicht leer sein")
|
||||||
|
update.title = title
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(args, "content")) {
|
||||||
|
update.content = args.content ?? null
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(args, "parentId")) {
|
||||||
|
const parentId = args.parentId === null ? null : pageIdArg(args, "parentId")
|
||||||
|
await assertValidParent(context, parentId, scopeFromPage(existing), id)
|
||||||
|
update.parentId = parentId
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (args.sortOrder !== undefined) {
|
||||||
|
const sortOrder = Number(args.sortOrder)
|
||||||
|
if (!Number.isInteger(sortOrder)) throw new Error("sortOrder muss eine ganze Zahl sein")
|
||||||
|
update.sortOrder = sortOrder
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (args.isFolder !== undefined) {
|
||||||
|
if (typeof args.isFolder !== "boolean") throw new Error("isFolder muss ein boolescher Wert sein")
|
||||||
|
update.isFolder = args.isFolder
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (!changed) throw new Error("Mindestens ein zu änderndes Feld ist erforderlich")
|
||||||
|
|
||||||
|
const [page] = await context.server.db
|
||||||
|
.update(wikiPages)
|
||||||
|
.set(update)
|
||||||
|
.where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!page) throw new Error("Wiki-Seite nicht gefunden")
|
||||||
|
return { page }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki.pages.delete",
|
||||||
|
title: "Wiki-Seite löschen",
|
||||||
|
description: "Löscht eine Wiki-Seite oder einen Wiki-Ordner samt untergeordneten Einträgen.",
|
||||||
|
requiredPermissions: ["wiki.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "string", format: "uuid" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = pageIdArg(args)
|
||||||
|
const deleted = await context.server.db
|
||||||
|
.delete(wikiPages)
|
||||||
|
.where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId)))
|
||||||
|
.returning({ id: wikiPages.id })
|
||||||
|
|
||||||
|
if (!deleted[0]) throw new Error("Wiki-Seite nicht gefunden")
|
||||||
|
return { deletedId: deleted[0].id }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
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.")
|
||||||
|
}
|
||||||
22
backend/src/modules/banking-institution.ts
Normal file
22
backend/src/modules/banking-institution.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export type BankingInstitution = {
|
||||||
|
bic?: unknown
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findBankInstitutionByBic = (
|
||||||
|
institutions: unknown,
|
||||||
|
bic: string,
|
||||||
|
): BankingInstitution | null => {
|
||||||
|
if (!Array.isArray(institutions)) return null
|
||||||
|
|
||||||
|
const normalizedBic = bic.trim().toUpperCase()
|
||||||
|
if (!normalizedBic) return null
|
||||||
|
|
||||||
|
const institution = institutions.find((candidate): candidate is BankingInstitution => {
|
||||||
|
if (!candidate || typeof candidate !== "object") return false
|
||||||
|
const candidateBic = (candidate as BankingInstitution).bic
|
||||||
|
return typeof candidateBic === "string" && candidateBic.trim().toUpperCase() === normalizedBic
|
||||||
|
})
|
||||||
|
|
||||||
|
return institution || null
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { and, eq } from "drizzle-orm"
|
|||||||
import bcrypt from "bcrypt"
|
import bcrypt from "bcrypt"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
accounts,
|
|
||||||
authProfiles,
|
authProfiles,
|
||||||
authRoles,
|
authRoles,
|
||||||
authRolePermissions,
|
authRolePermissions,
|
||||||
@@ -19,22 +18,31 @@ import {
|
|||||||
teams,
|
teams,
|
||||||
tenants,
|
tenants,
|
||||||
texttemplates,
|
texttemplates,
|
||||||
units,
|
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
const adminPermissions = [
|
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",
|
||||||
@@ -43,19 +51,16 @@ const adminPermissions = [
|
|||||||
"accounting.incoming_invoices.write",
|
"accounting.incoming_invoices.write",
|
||||||
"accounting.bank.read",
|
"accounting.bank.read",
|
||||||
"accounting.statement_allocations.read",
|
"accounting.statement_allocations.read",
|
||||||
|
"accounting.statement_allocations.write",
|
||||||
"organisation.customers.read",
|
"organisation.customers.read",
|
||||||
"organisation.projects.read",
|
"organisation.projects.read",
|
||||||
|
"organisation.projects.write",
|
||||||
"organisation.plants.read",
|
"organisation.plants.read",
|
||||||
"organisation.events.read",
|
"organisation.events.read",
|
||||||
"organisation.tasks.read",
|
"organisation.tasks.read",
|
||||||
"organisation.tasks.write",
|
"organisation.tasks.write",
|
||||||
]
|
"wiki.read",
|
||||||
|
"wiki.write",
|
||||||
const defaultUnits = [
|
|
||||||
{ name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" },
|
|
||||||
{ name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" },
|
|
||||||
{ name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" },
|
|
||||||
{ name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const defaultTaxTypes = [
|
const defaultTaxTypes = [
|
||||||
@@ -64,21 +69,7 @@ const defaultTaxTypes = [
|
|||||||
{ label: "Steuerfrei", percentage: 0 },
|
{ label: "Steuerfrei", percentage: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const defaultAccounts = [
|
|
||||||
{ number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" },
|
|
||||||
{ number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" },
|
|
||||||
{ number: "1200", label: "Bank", accountChart: "skr03" },
|
|
||||||
{ number: "1000", label: "Kasse", accountChart: "skr03" },
|
|
||||||
{ number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" },
|
|
||||||
{ number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
|
async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
|
||||||
for (const unit of defaultUnits) {
|
|
||||||
const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1)
|
|
||||||
if (!existing.length) await server.db.insert(units).values(unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const taxType of defaultTaxTypes) {
|
for (const taxType of defaultTaxTypes) {
|
||||||
const existing = await server.db
|
const existing = await server.db
|
||||||
.select({ id: taxTypes.id })
|
.select({ id: taxTypes.id })
|
||||||
@@ -95,20 +86,6 @@ async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const account of defaultAccounts) {
|
|
||||||
const existing = await server.db
|
|
||||||
.select({ id: accounts.id })
|
|
||||||
.from(accounts)
|
|
||||||
.where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!existing.length) {
|
|
||||||
await server.db.insert(accounts).values({
|
|
||||||
...account,
|
|
||||||
description: "FEDEO Standardkonto",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) {
|
async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) {
|
||||||
@@ -487,4 +464,5 @@ export async function runBootstrap(server: FastifyInstance) {
|
|||||||
|
|
||||||
await ensureTenantBaseData(server, tenant.id, adminUser.id)
|
await ensureTenantBaseData(server, tenant.id, adminUser.id)
|
||||||
console.log("✅ Bootstrap-Grunddaten geprüft")
|
console.log("✅ Bootstrap-Grunddaten geprüft")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/src/modules/central-services-heartbeat.service.ts
Normal file
56
backend/src/modules/central-services-heartbeat.service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
import { centralServicesClient } from "./push-server.client"
|
||||||
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { getBuildInfo } from "../utils/buildInfo"
|
||||||
|
|
||||||
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
function instanceBaseUrl() {
|
||||||
|
if (!secrets.API_BASE_URL) return undefined
|
||||||
|
try {
|
||||||
|
return new URL(secrets.API_BASE_URL).origin
|
||||||
|
} catch {
|
||||||
|
console.warn("⚠️ Central Services Heartbeat: API_BASE_URL ist keine gültige URL")
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCentralServicesHeartbeat(server: FastifyInstance) {
|
||||||
|
if (!centralServicesClient.configured()) {
|
||||||
|
console.log("ℹ️ Central Services Heartbeat deaktiviert: Instanzzugangsdaten fehlen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredInterval = Number(secrets.FEDEO_CENTRAL_HEARTBEAT_INTERVAL_MS)
|
||||||
|
const intervalMs = Number.isFinite(configuredInterval) && configuredInterval >= 30_000
|
||||||
|
? configuredInterval
|
||||||
|
: DEFAULT_HEARTBEAT_INTERVAL_MS
|
||||||
|
let timer: NodeJS.Timeout | undefined
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
const sendHeartbeat = async () => {
|
||||||
|
try {
|
||||||
|
const response = await centralServicesClient.heartbeat({
|
||||||
|
fedeoVersion: getBuildInfo().version,
|
||||||
|
baseUrl: instanceBaseUrl(),
|
||||||
|
})
|
||||||
|
console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Central Services Heartbeat fehlgeschlagen: ${error?.message || error}`)
|
||||||
|
} finally {
|
||||||
|
if (!stopped) {
|
||||||
|
timer = setTimeout(() => void sendHeartbeat(), intervalMs)
|
||||||
|
timer.unref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.addHook("onClose", async () => {
|
||||||
|
stopped = true
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`ℹ️ Central Services Heartbeat gestartet (${Math.round(intervalMs / 1000)} Sekunden)`)
|
||||||
|
void sendHeartbeat()
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// /services/bankStatementService.ts
|
// /services/bankStatementService.ts
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import dayjs from "dayjs"
|
import { centralServicesClient } from "../push-server.client"
|
||||||
import utc from "dayjs/plugin/utc.js"
|
|
||||||
import {secrets} from "../../utils/secrets"
|
import {secrets} from "../../utils/secrets"
|
||||||
import {FastifyInstance} from "fastify"
|
import {FastifyInstance} from "fastify"
|
||||||
|
|
||||||
@@ -14,16 +13,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
isNull,
|
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
dayjs.extend(utc)
|
|
||||||
|
|
||||||
interface BalanceAmount {
|
|
||||||
amount: string
|
|
||||||
currency: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookedTransaction {
|
interface BookedTransaction {
|
||||||
bookingDate: string
|
bookingDate: string
|
||||||
valueDate: string
|
valueDate: string
|
||||||
@@ -48,20 +39,44 @@ interface TransactionsResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BankStatementSyncResult {
|
||||||
|
accountsFound: number
|
||||||
|
accountsSynced: number
|
||||||
|
transactionsImported: number
|
||||||
|
errors: Array<{ accountId: string; message: string }>
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeDate = (val: any) => {
|
const normalizeDate = (val: any) => {
|
||||||
if (!val) return null
|
if (!val) return null
|
||||||
const d = new Date(val)
|
const d = new Date(val)
|
||||||
return isNaN(d.getTime()) ? null : d
|
return isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getBankAccountOwnerName = (account: any) => {
|
||||||
|
const ownerName = typeof account?.owner_name === "string" ? account.owner_name.trim() : ""
|
||||||
|
return ownerName || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isExpiredBankingError = (error: any) => {
|
||||||
|
const values = [
|
||||||
|
error?.response?.data?.summary,
|
||||||
|
error?.response?.data?.detail,
|
||||||
|
error?.response?.data?.message,
|
||||||
|
error?.message,
|
||||||
|
]
|
||||||
|
return values.some((value) => typeof value === "string" && value.toLowerCase().includes("expired"))
|
||||||
|
}
|
||||||
|
|
||||||
export function bankStatementService(server: FastifyInstance) {
|
export function bankStatementService(server: FastifyInstance) {
|
||||||
|
|
||||||
let accessToken: string | null = null
|
let accessToken: string | null = null
|
||||||
|
const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
|
||||||
|
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
// ✔ TOKEN LADEN
|
// ✔ TOKEN LADEN
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const getToken = async () => {
|
const getToken = async () => {
|
||||||
|
if (useCentralBanking) return
|
||||||
console.log("Fetching GoCardless token…")
|
console.log("Fetching GoCardless token…")
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
@@ -78,8 +93,9 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
// ✔ Salden laden
|
// ✔ Salden laden
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const getBalanceData = async (accountId: string): Promise<any | false> => {
|
const getBalanceData = async (accountId: string, tenantId: number): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId)
|
||||||
const {data} = await axios.get(
|
const {data} = await axios.get(
|
||||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`,
|
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`,
|
||||||
{
|
{
|
||||||
@@ -94,26 +110,46 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
server.log.error(err.response?.data ?? err)
|
server.log.error(err.response?.data ?? err)
|
||||||
|
|
||||||
const expired =
|
if (isExpiredBankingError(err)) {
|
||||||
err.response?.data?.summary?.includes("expired") ||
|
|
||||||
err.response?.data?.detail?.includes("expired")
|
|
||||||
|
|
||||||
if (expired) {
|
|
||||||
await server.db
|
await server.db
|
||||||
.update(bankaccounts)
|
.update(bankaccounts)
|
||||||
.set({expired: true})
|
.set({expired: true})
|
||||||
.where(eq(bankaccounts.accountId, accountId))
|
.where(and(
|
||||||
|
eq(bankaccounts.accountId, accountId),
|
||||||
|
eq(bankaccounts.tenant, tenantId),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------
|
||||||
|
// ✔ Kontoinhaber laden
|
||||||
|
// -----------------------------------------------
|
||||||
|
const getAccountData = async (accountId: string): Promise<any> => {
|
||||||
|
if (useCentralBanking) return await centralServicesClient.getBankingAccount(accountId)
|
||||||
|
const {data} = await axios.get(
|
||||||
|
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
// ✔ Transaktionen laden
|
// ✔ Transaktionen laden
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const getTransactionData = async (accountId: string) => {
|
const getTransactionData = async (accountId: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (useCentralBanking) {
|
||||||
|
const data = await centralServicesClient.getBankingTransactions(accountId)
|
||||||
|
return data.transactions.booked
|
||||||
|
}
|
||||||
const {data} = await axios.get<TransactionsResponse>(
|
const {data} = await axios.get<TransactionsResponse>(
|
||||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`,
|
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`,
|
||||||
{
|
{
|
||||||
@@ -127,15 +163,14 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
return data.transactions.booked
|
return data.transactions.booked
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
server.log.error(err.response?.data ?? err)
|
server.log.error(err.response?.data ?? err)
|
||||||
return null
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
// ✔ Haupt-Sync-Prozess
|
// ✔ Haupt-Sync-Prozess
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const syncAccounts = async (tenantId:number) => {
|
const syncAccounts = async (tenantId:number): Promise<BankStatementSyncResult> => {
|
||||||
try {
|
|
||||||
console.log("Starting account sync…")
|
console.log("Starting account sync…")
|
||||||
|
|
||||||
// 🟦 DB: Aktive Accounts
|
// 🟦 DB: Aktive Accounts
|
||||||
@@ -144,37 +179,61 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
.from(bankaccounts)
|
.from(bankaccounts)
|
||||||
.where(and(eq(bankaccounts.expired, false),eq(bankaccounts.tenant, tenantId)))
|
.where(and(eq(bankaccounts.expired, false),eq(bankaccounts.tenant, tenantId)))
|
||||||
|
|
||||||
if (!accounts.length) return
|
const result: BankStatementSyncResult = {
|
||||||
|
accountsFound: accounts.length,
|
||||||
|
accountsSynced: 0,
|
||||||
|
transactionsImported: 0,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accounts.length) return result
|
||||||
|
|
||||||
const allNewTransactions: any[] = []
|
const allNewTransactions: any[] = []
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 0. KONTOINHABER SYNC
|
||||||
|
// ---------------------------
|
||||||
|
try {
|
||||||
|
const accountData = await getAccountData(account.accountId)
|
||||||
|
const ownerName = getBankAccountOwnerName(accountData)
|
||||||
|
if (ownerName && ownerName !== account.ownerName) {
|
||||||
|
await server.db
|
||||||
|
.update(bankaccounts)
|
||||||
|
.set({ownerName})
|
||||||
|
.where(eq(bankaccounts.id, account.id))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.warn({err: error, accountId: account.accountId}, "Kontoinhaber konnte nicht synchronisiert werden")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// 1. BALANCE SYNC
|
// 1. BALANCE SYNC
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
const balData = await getBalanceData(account.accountId)
|
const balData = await getBalanceData(account.accountId, tenantId)
|
||||||
|
|
||||||
if (balData === false) break
|
|
||||||
|
|
||||||
if (balData) {
|
if (balData) {
|
||||||
const closing = balData.balances.find(
|
const closing = balData.balances.find(
|
||||||
(i: any) => i.balanceType === "closingBooked"
|
(i: any) => i.balanceType === "closingBooked"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (closing?.balanceAmount?.amount !== undefined) {
|
||||||
const bookedBal = Number(closing.balanceAmount.amount)
|
const bookedBal = Number(closing.balanceAmount.amount)
|
||||||
|
if (Number.isFinite(bookedBal)) {
|
||||||
await server.db
|
await server.db
|
||||||
.update(bankaccounts)
|
.update(bankaccounts)
|
||||||
.set({balance: bookedBal})
|
.set({balance: bookedBal})
|
||||||
.where(eq(bankaccounts.id, account.id))
|
.where(eq(bankaccounts.id, account.id))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// 2. TRANSACTIONS
|
// 2. TRANSACTIONS
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
let transactions = await getTransactionData(account.accountId)
|
let transactions = await getTransactionData(account.accountId)
|
||||||
if (!transactions) continue
|
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
transactions = transactions.map((item) => ({
|
transactions = transactions.map((item) => ({
|
||||||
@@ -209,6 +268,17 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
allNewTransactions.push(...filtered)
|
allNewTransactions.push(...filtered)
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(bankaccounts)
|
||||||
|
.set({syncedAt: new Date()})
|
||||||
|
.where(eq(bankaccounts.id, account.id))
|
||||||
|
result.accountsSynced++
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.message || String(error)
|
||||||
|
result.errors.push({accountId: account.accountId, message})
|
||||||
|
server.log.error({err: error, accountId: account.accountId}, "Bankkonto konnte nicht synchronisiert werden")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
@@ -216,38 +286,19 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
// ---------------------------
|
// ---------------------------
|
||||||
if (allNewTransactions.length > 0) {
|
if (allNewTransactions.length > 0) {
|
||||||
await server.db.insert(bankstatements).values(allNewTransactions)
|
await server.db.insert(bankstatements).values(allNewTransactions)
|
||||||
|
result.transactionsImported = allNewTransactions.length
|
||||||
const affectedAccounts = [
|
|
||||||
...new Set(allNewTransactions.map((t) => t.account)),
|
|
||||||
]
|
|
||||||
|
|
||||||
const normalizeDate = (val: any) => {
|
|
||||||
if (!val) return null
|
|
||||||
const d = new Date(val)
|
|
||||||
return isNaN(d.getTime()) ? null : d
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const accId of affectedAccounts) {
|
|
||||||
await server.db
|
|
||||||
.update(bankaccounts)
|
|
||||||
//@ts-ignore
|
|
||||||
.set({syncedAt: normalizeDate(dayjs())})
|
|
||||||
.where(eq(bankaccounts.id, accId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Bank statement sync completed.")
|
console.log("Bank statement sync completed.")
|
||||||
} catch (error) {
|
return result
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run: async (tenant) => {
|
run: async (tenant) => {
|
||||||
await getToken()
|
await getToken()
|
||||||
await syncAccounts(tenant)
|
const result = await syncAccounts(tenant)
|
||||||
console.log("Service: Bankstatement sync finished")
|
console.log("Service: Bankstatement sync finished")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
||||||
|
import { loadFileBuffer } from "../../utils/fileBuffer"
|
||||||
|
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
|
||||||
|
import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice"
|
||||||
|
import {
|
||||||
|
findElectronicInvoiceVendor,
|
||||||
|
mapElectronicInvoiceToIncomingInvoice,
|
||||||
|
} from "../einvoice/mapElectronicInvoice"
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => {
|
|||||||
return parts.join(" - ")
|
return parts.join(" - ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
||||||
|
const itemInfo: any = {
|
||||||
|
tenant: tenantId,
|
||||||
|
state: "Vorbereitet",
|
||||||
|
preparationSource: "gpt",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||||
|
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||||
|
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||||
|
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||||
|
|
||||||
|
const mapPayment: Record<string, string> = {
|
||||||
|
"Direct Debit": "Einzug",
|
||||||
|
"Transfer": "Überweisung",
|
||||||
|
"Credit Card": "Kreditkarte",
|
||||||
|
"Other": "Sonstiges",
|
||||||
|
}
|
||||||
|
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||||
|
|
||||||
|
if (data.invoice_items?.length > 0) {
|
||||||
|
itemInfo.accounts = data.invoice_items
|
||||||
|
.filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null)
|
||||||
|
.map((item: any) => {
|
||||||
|
const total = typeof item.total === "number" ? item.total : null
|
||||||
|
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||||
|
const amountTax = total !== null && totalWithoutTax !== null
|
||||||
|
? Number((total - totalWithoutTax).toFixed(2))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: item.account_id,
|
||||||
|
description: item.description,
|
||||||
|
amountNet: totalWithoutTax,
|
||||||
|
amountTax,
|
||||||
|
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||||
|
amountGross: total,
|
||||||
|
costCentre: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = ""
|
||||||
|
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||||
|
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||||
|
for (const item of data.invoice_items || []) {
|
||||||
|
const line = formatInvoiceItemDescription(item)
|
||||||
|
if (line) description += `${line}\n`
|
||||||
|
}
|
||||||
|
itemInfo.description = description.trim()
|
||||||
|
|
||||||
|
return itemInfo
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number) => {
|
||||||
console.log("▶ Starting Incoming Invoice Preparation")
|
console.log("▶ Starting Incoming Invoice Preparation")
|
||||||
@@ -85,76 +147,60 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
let fileData: Buffer
|
||||||
|
|
||||||
|
try {
|
||||||
|
fileData = await loadFileBuffer(file.path!)
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||||
|
|
||||||
|
let itemInfo: any = null
|
||||||
|
|
||||||
|
if (electronicInvoice) {
|
||||||
|
server.log.info({
|
||||||
|
fileId: file.id,
|
||||||
|
container: electronicInvoice.container,
|
||||||
|
syntax: electronicInvoice.syntax,
|
||||||
|
attachmentName: electronicInvoice.attachmentName,
|
||||||
|
}, "Strukturierte E-Rechnung erkannt.")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
|
||||||
|
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
|
||||||
|
itemInfo = mapElectronicInvoiceToIncomingInvoice(
|
||||||
|
parsedInvoice,
|
||||||
|
tenantId,
|
||||||
|
vendor?.id || null,
|
||||||
|
electronicInvoice.container,
|
||||||
|
electronicInvoice.attachmentName,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
||||||
|
|
||||||
|
if (electronicInvoice.container === "xml") continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemInfo) {
|
||||||
|
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||||
// 3.1 IncomingInvoice-Objekt vorbereiten
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
let itemInfo: any = {
|
|
||||||
tenant: tenantId,
|
|
||||||
state: "Vorbereitet"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
|
||||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
|
||||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
|
||||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
|
||||||
|
|
||||||
// Payment terms mapping
|
|
||||||
const mapPayment: any = {
|
|
||||||
"Direct Debit": "Einzug",
|
|
||||||
"Transfer": "Überweisung",
|
|
||||||
"Credit Card": "Kreditkarte",
|
|
||||||
"Other": "Sonstiges",
|
|
||||||
}
|
|
||||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
|
||||||
|
|
||||||
// 3.2 Positionszeilen konvertieren
|
|
||||||
if (data.invoice_items?.length > 0) {
|
|
||||||
itemInfo.accounts = data.invoice_items
|
|
||||||
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
|
||||||
.map(item => {
|
|
||||||
const total = typeof item.total === "number" ? item.total : null
|
|
||||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
|
||||||
const amountTax = total !== null && totalWithoutTax !== null
|
|
||||||
? Number((total - totalWithoutTax).toFixed(2))
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
|
||||||
account: item.account_id,
|
|
||||||
description: item.description,
|
|
||||||
amountNet: totalWithoutTax,
|
|
||||||
amountTax,
|
|
||||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
|
||||||
amountGross: total,
|
|
||||||
costCentre: null,
|
|
||||||
quantity: item.quantity,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.3 Beschreibung generieren
|
|
||||||
let description = ""
|
|
||||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
|
||||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
|
||||||
if (data.invoice_items) {
|
|
||||||
for (const item of data.invoice_items) {
|
|
||||||
const line = formatInvoiceItemDescription(item)
|
|
||||||
if (line) description += `${line}\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemInfo.description = description.trim()
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// 4️⃣ IncomingInvoice erstellen
|
// 4️⃣ IncomingInvoice erstellen
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
343
backend/src/modules/document-import/document-import.service.ts
Normal file
343
backend/src/modules/document-import/document-import.service.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { ImapFlow } from "imapflow"
|
||||||
|
import { simpleParser } from "mailparser"
|
||||||
|
|
||||||
|
import {
|
||||||
|
documentImportItems,
|
||||||
|
documentImportSources,
|
||||||
|
documentImportStates,
|
||||||
|
filetags,
|
||||||
|
folders,
|
||||||
|
} from "../../../db/schema"
|
||||||
|
import { decrypt } from "../../utils/crypt"
|
||||||
|
import { saveFile } from "../../utils/files"
|
||||||
|
|
||||||
|
type ImportSourceConnection = {
|
||||||
|
id: string
|
||||||
|
tenantId: number
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
enabled: boolean
|
||||||
|
mailboxAddress: string
|
||||||
|
password: string
|
||||||
|
imapHost: string
|
||||||
|
imapPort: number
|
||||||
|
imapSecure: boolean
|
||||||
|
mailboxPath: string
|
||||||
|
targetFolderId: string | null
|
||||||
|
defaultFiletypeId: string | null
|
||||||
|
markAsSeen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSyncs = new Set<string>()
|
||||||
|
const syncProgress = new Map<string, { processed: number, total: number, imported: number, duplicates: number }>()
|
||||||
|
const IMPORT_BATCH_SIZE = 25
|
||||||
|
|
||||||
|
const decryptString = (value: unknown) => value ? decrypt(value as any) : ""
|
||||||
|
|
||||||
|
export function documentImportService(server: FastifyInstance) {
|
||||||
|
const loadSource = async (tenantId: number, sourceId: string): Promise<ImportSourceConnection | null> => {
|
||||||
|
const [row] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(documentImportSources)
|
||||||
|
.where(and(
|
||||||
|
eq(documentImportSources.id, sourceId),
|
||||||
|
eq(documentImportSources.tenantId, tenantId),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
name: row.name,
|
||||||
|
provider: row.provider,
|
||||||
|
enabled: row.enabled,
|
||||||
|
mailboxAddress: decryptString(row.mailboxAddressEncrypted),
|
||||||
|
password: decryptString(row.passwordEncrypted),
|
||||||
|
imapHost: decryptString(row.imapHostEncrypted),
|
||||||
|
imapPort: row.imapPort,
|
||||||
|
imapSecure: row.imapSecure,
|
||||||
|
mailboxPath: row.mailboxPath,
|
||||||
|
targetFolderId: row.targetFolderId,
|
||||||
|
defaultFiletypeId: row.defaultFiletypeId,
|
||||||
|
markAsSeen: row.markAsSeen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createImapClient = (source: ImportSourceConnection) => new ImapFlow({
|
||||||
|
host: source.imapHost,
|
||||||
|
port: source.imapPort,
|
||||||
|
secure: source.imapSecure,
|
||||||
|
auth: { user: source.mailboxAddress, pass: source.password },
|
||||||
|
logger: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const classify = async (source: ImportSourceConnection, subject = "") => {
|
||||||
|
let folderId = source.targetFolderId
|
||||||
|
let filetypeId = source.defaultFiletypeId
|
||||||
|
|
||||||
|
if (!folderId && /(Rechnung|Beleg|Invoice|Quittung)/i.test(subject)) {
|
||||||
|
const [folder] = await server.db.select({ id: folders.id }).from(folders).where(and(
|
||||||
|
eq(folders.tenant, source.tenantId),
|
||||||
|
eq(folders.function, "incomingInvoices"),
|
||||||
|
// @ts-ignore Das bestehende Schema typisiert das Jahr numerisch, verwendet es aber als Zeichenfolge.
|
||||||
|
eq(folders.year, dayjs().format("YYYY")),
|
||||||
|
)).limit(1)
|
||||||
|
folderId = folder?.id || null
|
||||||
|
|
||||||
|
if (!filetypeId) {
|
||||||
|
const [tag] = await server.db.select({ id: filetags.id }).from(filetags).where(and(
|
||||||
|
eq(filetags.tenant, source.tenantId),
|
||||||
|
eq(filetags.incomingDocumentType, "invoices"),
|
||||||
|
)).limit(1)
|
||||||
|
filetypeId = tag?.id || null
|
||||||
|
}
|
||||||
|
} else if (!filetypeId && /(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/i.test(subject)) {
|
||||||
|
const [tag] = await server.db.select({ id: filetags.id }).from(filetags).where(and(
|
||||||
|
eq(filetags.tenant, source.tenantId),
|
||||||
|
eq(filetags.incomingDocumentType, "reminders"),
|
||||||
|
)).limit(1)
|
||||||
|
filetypeId = tag?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folderId) {
|
||||||
|
const [folder] = await server.db.select({ id: folders.id }).from(folders).where(and(
|
||||||
|
eq(folders.tenant, source.tenantId),
|
||||||
|
eq(folders.function, "deposit"),
|
||||||
|
)).limit(1)
|
||||||
|
folderId = folder?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { folderId, filetypeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const importAttachment = async (
|
||||||
|
source: ImportSourceConnection,
|
||||||
|
remoteMessageId: string,
|
||||||
|
subject: string,
|
||||||
|
attachment: any,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const content = Buffer.from(attachment.content)
|
||||||
|
const checksum = createHash("sha256").update(content).digest("hex")
|
||||||
|
const attachmentKey = `${index}:${attachment.filename || "Anhang"}`
|
||||||
|
|
||||||
|
const [existing] = await server.db.select({ status: documentImportItems.status }).from(documentImportItems)
|
||||||
|
.where(and(
|
||||||
|
eq(documentImportItems.sourceId, source.id),
|
||||||
|
eq(documentImportItems.remoteMessageId, remoteMessageId),
|
||||||
|
eq(documentImportItems.attachmentKey, attachmentKey),
|
||||||
|
)).limit(1)
|
||||||
|
if (existing?.status === "imported" || existing?.status === "duplicate") return "duplicate"
|
||||||
|
|
||||||
|
const [sameContent] = await server.db.select({ id: documentImportItems.id }).from(documentImportItems)
|
||||||
|
.where(and(
|
||||||
|
eq(documentImportItems.sourceId, source.id),
|
||||||
|
eq(documentImportItems.attachmentChecksum, checksum),
|
||||||
|
eq(documentImportItems.status, "imported"),
|
||||||
|
)).limit(1)
|
||||||
|
|
||||||
|
if (sameContent) {
|
||||||
|
await server.db.insert(documentImportItems).values({
|
||||||
|
tenantId: source.tenantId,
|
||||||
|
sourceId: source.id,
|
||||||
|
remoteMessageId,
|
||||||
|
attachmentKey,
|
||||||
|
attachmentChecksum: checksum,
|
||||||
|
filename: attachment.filename || null,
|
||||||
|
status: "duplicate",
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [documentImportItems.sourceId, documentImportItems.remoteMessageId, documentImportItems.attachmentKey],
|
||||||
|
set: { status: "duplicate", error: null },
|
||||||
|
})
|
||||||
|
return "duplicate"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = await classify(source, subject)
|
||||||
|
const saved = await saveFile(
|
||||||
|
server,
|
||||||
|
source.tenantId,
|
||||||
|
remoteMessageId,
|
||||||
|
attachment,
|
||||||
|
target.folderId,
|
||||||
|
target.filetypeId,
|
||||||
|
)
|
||||||
|
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||||
|
|
||||||
|
await server.db.insert(documentImportItems).values({
|
||||||
|
tenantId: source.tenantId,
|
||||||
|
sourceId: source.id,
|
||||||
|
remoteMessageId,
|
||||||
|
attachmentKey,
|
||||||
|
attachmentChecksum: checksum,
|
||||||
|
filename: attachment.filename || null,
|
||||||
|
status: "imported",
|
||||||
|
fileId: saved.id,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [documentImportItems.sourceId, documentImportItems.remoteMessageId, documentImportItems.attachmentKey],
|
||||||
|
set: { status: "imported", error: null, fileId: saved.id },
|
||||||
|
})
|
||||||
|
return "imported"
|
||||||
|
} catch (error: any) {
|
||||||
|
await server.db.insert(documentImportItems).values({
|
||||||
|
tenantId: source.tenantId,
|
||||||
|
sourceId: source.id,
|
||||||
|
remoteMessageId,
|
||||||
|
attachmentKey,
|
||||||
|
attachmentChecksum: checksum,
|
||||||
|
filename: attachment.filename || null,
|
||||||
|
status: "failed",
|
||||||
|
error: error?.message || "Import fehlgeschlagen",
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [documentImportItems.sourceId, documentImportItems.remoteMessageId, documentImportItems.attachmentKey],
|
||||||
|
set: { status: "failed", error: error?.message || "Import fehlgeschlagen" },
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConnection = async (tenantId: number, sourceId: string) => {
|
||||||
|
const source = await loadSource(tenantId, sourceId)
|
||||||
|
if (!source) throw new Error("Importquelle wurde nicht gefunden")
|
||||||
|
if (source.provider !== "imap") throw new Error("Dieser Provider wird noch nicht unterstützt")
|
||||||
|
const client = createImapClient(source)
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
const mailbox = await client.mailboxOpen(source.mailboxPath, { readOnly: true })
|
||||||
|
return { success: true, mailbox: source.mailboxPath, messages: mailbox.exists }
|
||||||
|
} finally {
|
||||||
|
await client.logout().catch(() => client.close())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncSource = async (tenantId: number, sourceId: string) => {
|
||||||
|
if (activeSyncs.has(sourceId)) throw new Error("Diese Importquelle wird bereits synchronisiert")
|
||||||
|
activeSyncs.add(sourceId)
|
||||||
|
let client: ImapFlow | null = null
|
||||||
|
let imported = 0
|
||||||
|
let duplicates = 0
|
||||||
|
let messages = 0
|
||||||
|
try {
|
||||||
|
const source = await loadSource(tenantId, sourceId)
|
||||||
|
if (!source) throw new Error("Importquelle wurde nicht gefunden")
|
||||||
|
if (!source.enabled) throw new Error("Importquelle ist deaktiviert")
|
||||||
|
if (source.provider !== "imap") throw new Error("Dieser Provider wird noch nicht unterstützt")
|
||||||
|
|
||||||
|
client = createImapClient(source)
|
||||||
|
await client.connect()
|
||||||
|
const lock = await client.getMailboxLock(source.mailboxPath)
|
||||||
|
try {
|
||||||
|
const opened: any = await client.mailboxOpen(source.mailboxPath)
|
||||||
|
const uidValidity = Number(opened.uidValidity || 0)
|
||||||
|
const [state] = await server.db.select().from(documentImportStates).where(and(
|
||||||
|
eq(documentImportStates.sourceId, source.id),
|
||||||
|
eq(documentImportStates.mailboxPath, source.mailboxPath),
|
||||||
|
)).limit(1)
|
||||||
|
const highestUid = state && Number(state.uidValidity) === uidValidity ? Number(state.highestUid) : 0
|
||||||
|
let processedHighestUid = highestUid
|
||||||
|
// Pro Lauf nur einen begrenzten Stapel laden. Der Worker verarbeitet verbliebene
|
||||||
|
// ungelesene Nachrichten in den folgenden Läufen weiter.
|
||||||
|
const unreadUids = await client.search({ seen: false }, { uid: true }) || []
|
||||||
|
const batchUids = unreadUids.slice(0, IMPORT_BATCH_SIZE)
|
||||||
|
syncProgress.set(sourceId, {
|
||||||
|
processed: 0,
|
||||||
|
total: batchUids.length,
|
||||||
|
imported: 0,
|
||||||
|
duplicates: 0,
|
||||||
|
})
|
||||||
|
const uidsToMarkSeen: number[] = []
|
||||||
|
|
||||||
|
for await (const message of client.fetch(batchUids, { uid: true, envelope: true, source: true }, { uid: true })) {
|
||||||
|
messages += 1
|
||||||
|
const parsed = await simpleParser(message.source)
|
||||||
|
const remoteMessageId = `${uidValidity}:${message.uid}`
|
||||||
|
let complete = true
|
||||||
|
for (const [index, attachment] of (parsed.attachments || []).entries()) {
|
||||||
|
if (attachment.contentDisposition === "inline" && !attachment.filename) continue
|
||||||
|
try {
|
||||||
|
const result = await importAttachment(source, remoteMessageId, parsed.subject || "", attachment, index)
|
||||||
|
if (result === "imported") imported += 1
|
||||||
|
else duplicates += 1
|
||||||
|
} catch {
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Während eines laufenden fetch dürfen keine weiteren IMAP-Befehle ausgeführt
|
||||||
|
// werden, da ImapFlow sonst auf das Ende des eigenen fetch wartet.
|
||||||
|
if (complete && source.markAsSeen) uidsToMarkSeen.push(message.uid)
|
||||||
|
if (complete) processedHighestUid = Math.max(processedHighestUid, Number(message.uid))
|
||||||
|
syncProgress.set(sourceId, {
|
||||||
|
processed: messages,
|
||||||
|
total: batchUids.length,
|
||||||
|
imported,
|
||||||
|
duplicates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (uidsToMarkSeen.length > 0) {
|
||||||
|
await client.messageFlagsAdd(uidsToMarkSeen, ["\\Seen"], { uid: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.db.insert(documentImportStates).values({
|
||||||
|
sourceId: source.id,
|
||||||
|
mailboxPath: source.mailboxPath,
|
||||||
|
uidValidity,
|
||||||
|
highestUid: processedHighestUid,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [documentImportStates.sourceId, documentImportStates.mailboxPath],
|
||||||
|
set: { uidValidity, highestUid: processedHighestUid, updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.db.update(documentImportSources).set({ lastSyncedAt: new Date(), lastError: null }).where(eq(documentImportSources.id, source.id))
|
||||||
|
return { success: true, messages, imported, duplicates }
|
||||||
|
} catch (error: any) {
|
||||||
|
await server.db.update(documentImportSources).set({ lastError: error?.message || "Synchronisierung fehlgeschlagen" }).where(eq(documentImportSources.id, sourceId))
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
activeSyncs.delete(sourceId)
|
||||||
|
syncProgress.delete(sourceId)
|
||||||
|
if (client?.usable) await client.logout().catch(() => client.close())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSync = (tenantId: number, sourceId: string) => {
|
||||||
|
if (activeSyncs.has(sourceId)) throw new Error("Diese Importquelle wird bereits synchronisiert")
|
||||||
|
void syncSource(tenantId, sourceId).catch((error: any) => {
|
||||||
|
server.log.error({ sourceId, error: error?.message }, "Manueller Dokumentenimport fehlgeschlagen")
|
||||||
|
})
|
||||||
|
return { success: true, started: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSyncStatus = (sourceId: string) => ({
|
||||||
|
isSyncing: activeSyncs.has(sourceId),
|
||||||
|
progress: syncProgress.get(sourceId) || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncAll = async () => {
|
||||||
|
const sources = await server.db.select({
|
||||||
|
id: documentImportSources.id,
|
||||||
|
tenantId: documentImportSources.tenantId,
|
||||||
|
}).from(documentImportSources).where(eq(documentImportSources.enabled, true))
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
for (const source of sources) {
|
||||||
|
try {
|
||||||
|
results.push({ sourceId: source.id, ...(await syncSource(source.tenantId, source.id)) })
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.error({ sourceId: source.id, error: error?.message }, "Dokumentenimport fehlgeschlagen")
|
||||||
|
results.push({ sourceId: source.id, success: false, error: error?.message || "Import fehlgeschlagen" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
return { testConnection, syncSource, startSync, getSyncStatus, syncAll }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
export function startDocumentImportWorker(server: FastifyInstance) {
|
||||||
|
const run = () => server.services.documentImports.syncAll().catch((error) => {
|
||||||
|
server.log.error({ error }, "Automatischer Dokumentenimport fehlgeschlagen")
|
||||||
|
})
|
||||||
|
|
||||||
|
const timer = setInterval(run, SYNC_INTERVAL_MS)
|
||||||
|
timer.unref()
|
||||||
|
server.addHook("onClose", async () => clearInterval(timer))
|
||||||
|
}
|
||||||
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
decodePDFRawStream,
|
||||||
|
PDFDict,
|
||||||
|
PDFDocument,
|
||||||
|
PDFHexString,
|
||||||
|
PDFName,
|
||||||
|
PDFRawStream,
|
||||||
|
PDFString,
|
||||||
|
} from "pdf-lib"
|
||||||
|
|
||||||
|
export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note"
|
||||||
|
|
||||||
|
export type ElectronicInvoiceDetection = {
|
||||||
|
container: "pdf" | "xml"
|
||||||
|
syntax: ElectronicInvoiceSyntax
|
||||||
|
xml: Buffer
|
||||||
|
attachmentName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileLike = {
|
||||||
|
name?: string | null
|
||||||
|
path?: string | null
|
||||||
|
mimeType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodePdfText = (value: unknown): string | null => {
|
||||||
|
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||||
|
return value.decodeText()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => {
|
||||||
|
const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8")
|
||||||
|
|
||||||
|
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) {
|
||||||
|
return "cii"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/<(?:[A-Za-z_][\w.-]*:)?Invoice(?:\s|>)/i.test(sample)
|
||||||
|
&& /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample)
|
||||||
|
) {
|
||||||
|
return "ubl-invoice"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/<(?:[A-Za-z_][\w.-]*:)?CreditNote(?:\s|>)/i.test(sample)
|
||||||
|
&& /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample)
|
||||||
|
) {
|
||||||
|
return "ubl-credit-note"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => {
|
||||||
|
const context = fileSpec.context
|
||||||
|
const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict)
|
||||||
|
|
||||||
|
if (!embeddedFiles) return null
|
||||||
|
|
||||||
|
const stream = context.lookup(
|
||||||
|
embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!(stream instanceof PDFRawStream)) return null
|
||||||
|
|
||||||
|
const name = decodePdfText(
|
||||||
|
context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))),
|
||||||
|
)
|
||||||
|
const contents = Buffer.from(decodePDFRawStream(stream).decode())
|
||||||
|
|
||||||
|
return { name, xml: contents }
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectEmbeddedInvoice = async (pdf: Buffer): Promise<ElectronicInvoiceDetection | null> => {
|
||||||
|
let document: PDFDocument
|
||||||
|
|
||||||
|
try {
|
||||||
|
document = await PDFDocument.load(pdf, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
updateMetadata: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, object] of document.context.enumerateIndirectObjects()) {
|
||||||
|
if (!(object instanceof PDFDict)) continue
|
||||||
|
|
||||||
|
const type = object.get(PDFName.of("Type"))
|
||||||
|
if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue
|
||||||
|
|
||||||
|
const embedded = extractXmlFromFileSpec(object)
|
||||||
|
if (!embedded) continue
|
||||||
|
|
||||||
|
const syntax = detectXmlSyntax(embedded.xml)
|
||||||
|
if (!syntax) continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
container: "pdf",
|
||||||
|
syntax,
|
||||||
|
xml: embedded.xml,
|
||||||
|
attachmentName: embedded.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPdfFileType = (file: FileLike, data: Buffer) => {
|
||||||
|
const filename = String(file.name || file.path || "").toLowerCase()
|
||||||
|
const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim()
|
||||||
|
|
||||||
|
return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detectElectronicInvoice = async (
|
||||||
|
data: Buffer,
|
||||||
|
file: FileLike,
|
||||||
|
): Promise<ElectronicInvoiceDetection | null> => {
|
||||||
|
if (hasPdfFileType(file, data)) {
|
||||||
|
return detectEmbeddedInvoice(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<")
|
||||||
|
const syntax = beginsLikeXml ? detectXmlSyntax(data) : null
|
||||||
|
|
||||||
|
if (syntax) {
|
||||||
|
return {
|
||||||
|
container: "xml",
|
||||||
|
syntax,
|
||||||
|
xml: data,
|
||||||
|
attachmentName: file.name || file.path?.split("/").pop() || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal file
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { vendors } from "../../../db/schema"
|
||||||
|
import type { ParsedElectronicInvoice } from "./types"
|
||||||
|
|
||||||
|
const normalizeIdentifier = (value: unknown) => String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
|
||||||
|
const normalizeName = (value: unknown) => String(value || "").normalize("NFKD").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
|
||||||
|
|
||||||
|
const paymentTypeByCode: Record<string, string> = {
|
||||||
|
"10": "Bar",
|
||||||
|
"30": "Überweisung",
|
||||||
|
"48": "Kreditkarte",
|
||||||
|
"49": "Lastschrift",
|
||||||
|
"57": "Sonstiges",
|
||||||
|
"58": "Überweisung",
|
||||||
|
"59": "Lastschrift",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findElectronicInvoiceVendor = async (
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
invoice: ParsedElectronicInvoice,
|
||||||
|
) => {
|
||||||
|
const candidates = await server.db.select().from(vendors).where(and(
|
||||||
|
eq(vendors.tenant, tenantId),
|
||||||
|
eq(vendors.archived, false),
|
||||||
|
))
|
||||||
|
|
||||||
|
const vatId = normalizeIdentifier(invoice.seller.vatId)
|
||||||
|
if (vatId) {
|
||||||
|
const vatMatches = candidates.filter((vendor: any) => normalizeIdentifier(vendor.infoData?.ustid) === vatId)
|
||||||
|
if (vatMatches.length === 1) return vatMatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellerName = normalizeName(invoice.seller.name)
|
||||||
|
if (sellerName) {
|
||||||
|
const nameMatches = candidates.filter((vendor) => normalizeName(vendor.name) === sellerName)
|
||||||
|
if (nameMatches.length === 1) return nameMatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapElectronicInvoiceToIncomingInvoice = (
|
||||||
|
invoice: ParsedElectronicInvoice,
|
||||||
|
tenantId: number,
|
||||||
|
vendorId: number | null,
|
||||||
|
container: "pdf" | "xml",
|
||||||
|
attachmentName: string | null,
|
||||||
|
) => {
|
||||||
|
const warnings = [...invoice.validation.warnings]
|
||||||
|
if (!vendorId) warnings.push("Rechnungssteller konnte keinem Lieferanten eindeutig zugeordnet werden.")
|
||||||
|
|
||||||
|
const state = invoice.validation.errors.length > 0 || !vendorId ? "Prüfung erforderlich" : "Vorbereitet"
|
||||||
|
const descriptionParts = [
|
||||||
|
invoice.buyerReference ? `Käuferreferenz: ${invoice.buyerReference}` : null,
|
||||||
|
...invoice.items.map((item) => item.description),
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenantId,
|
||||||
|
state,
|
||||||
|
vendor: vendorId,
|
||||||
|
reference: invoice.invoiceNumber,
|
||||||
|
date: invoice.invoiceDate,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
||||||
|
description: descriptionParts.join("\n"),
|
||||||
|
accounts: invoice.items.map((item) => ({
|
||||||
|
account: null,
|
||||||
|
description: item.description,
|
||||||
|
amountNet: item.netAmount,
|
||||||
|
amountTax: item.taxAmount,
|
||||||
|
taxType: item.taxCategory === "AE" ? "13B" : item.taxRate === 0 ? "null" : String(item.taxRate),
|
||||||
|
amountGross: item.grossAmount,
|
||||||
|
costCentre: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitCode: item.unitCode,
|
||||||
|
})),
|
||||||
|
preparationSource: "e-invoice",
|
||||||
|
eInvoiceSyntax: invoice.syntax,
|
||||||
|
eInvoiceProfile: invoice.profileId,
|
||||||
|
eInvoiceValidation: {
|
||||||
|
errors: invoice.validation.errors,
|
||||||
|
warnings,
|
||||||
|
totals: invoice.totals,
|
||||||
|
seller: invoice.seller,
|
||||||
|
invoiceType: invoice.invoiceType,
|
||||||
|
container,
|
||||||
|
attachmentName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal file
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { XMLParser } from "fast-xml-parser"
|
||||||
|
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||||
|
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true,
|
||||||
|
parseTagValue: false,
|
||||||
|
trimValues: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const array = <T>(value: T | T[] | null | undefined): T[] => value == null ? [] : Array.isArray(value) ? value : [value]
|
||||||
|
|
||||||
|
const text = (value: any): string | null => {
|
||||||
|
if (value == null) return null
|
||||||
|
if (typeof value === "string" || typeof value === "number") return String(value).trim() || null
|
||||||
|
if (typeof value === "object" && value["#text"] != null) return text(value["#text"])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = (value: any): number | null => {
|
||||||
|
const raw = text(value)
|
||||||
|
if (raw == null) return null
|
||||||
|
|
||||||
|
const parsed = Number(raw)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundMoney = (value: number) => Number(value.toFixed(2))
|
||||||
|
|
||||||
|
const date = (value: any): string | null => {
|
||||||
|
const raw = text(value?.DateTimeString ?? value)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
if (/^\d{8}$/.test(raw)) {
|
||||||
|
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = raw.match(/^\d{4}-\d{2}-\d{2}/)
|
||||||
|
return match?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const findTaxRegistration = (party: any, schemeId: string) => {
|
||||||
|
return text(array(party?.SpecifiedTaxRegistration).find((entry: any) => {
|
||||||
|
return String(entry?.ID?.["@_schemeID"] || "").toUpperCase() === schemeId
|
||||||
|
})?.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciiDescription = (line: any) => {
|
||||||
|
const product = line?.SpecifiedTradeProduct || {}
|
||||||
|
return [text(product.Name), text(product.Description)].filter(Boolean).join(" - ") || "Position"
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||||
|
const transaction = root?.SupplyChainTradeTransaction || {}
|
||||||
|
const agreement = transaction?.ApplicableHeaderTradeAgreement || {}
|
||||||
|
const settlement = transaction?.ApplicableHeaderTradeSettlement || {}
|
||||||
|
const monetary = settlement?.SpecifiedTradeSettlementHeaderMonetarySummation || {}
|
||||||
|
const seller = agreement?.SellerTradeParty || {}
|
||||||
|
const typeCode = text(root?.ExchangedDocument?.TypeCode)
|
||||||
|
const sign = typeCode === "381" ? -1 : 1
|
||||||
|
|
||||||
|
const items: ElectronicInvoiceItem[] = array(transaction?.IncludedSupplyChainTradeLineItem).map((line: any) => {
|
||||||
|
const delivery = line?.SpecifiedLineTradeDelivery || {}
|
||||||
|
const lineSettlement = line?.SpecifiedLineTradeSettlement || {}
|
||||||
|
const tradeTax = array(lineSettlement?.ApplicableTradeTax)[0] || {}
|
||||||
|
const quantity = number(delivery?.BilledQuantity)
|
||||||
|
const net = number(lineSettlement?.SpecifiedTradeSettlementLineMonetarySummation?.LineTotalAmount) || 0
|
||||||
|
const taxRate = number(tradeTax?.RateApplicablePercent) || 0
|
||||||
|
const signedNet = roundMoney(sign * net)
|
||||||
|
const taxAmount = roundMoney(signedNet * taxRate / 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: ciiDescription(line),
|
||||||
|
quantity,
|
||||||
|
unitCode: text(delivery?.BilledQuantity?.["@_unitCode"]),
|
||||||
|
netAmount: signedNet,
|
||||||
|
taxRate,
|
||||||
|
taxCategory: text(tradeTax?.CategoryCode),
|
||||||
|
taxAmount,
|
||||||
|
grossAmount: roundMoney(signedNet + taxAmount),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
||||||
|
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {}
|
||||||
|
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
||||||
|
|
||||||
|
return {
|
||||||
|
syntax: "cii",
|
||||||
|
profileId: text(guideline),
|
||||||
|
invoiceNumber: text(root?.ExchangedDocument?.ID),
|
||||||
|
invoiceDate: date(root?.ExchangedDocument?.IssueDateTime),
|
||||||
|
dueDate: date(paymentTerms?.DueDateDateTime),
|
||||||
|
currency: text(settlement?.InvoiceCurrencyCode),
|
||||||
|
invoiceType: typeCode === "381" ? "credit-note" : "invoice",
|
||||||
|
seller: {
|
||||||
|
name: text(seller?.Name),
|
||||||
|
vatId: findTaxRegistration(seller, "VA"),
|
||||||
|
taxNumber: findTaxRegistration(seller, "FC"),
|
||||||
|
iban: text(paymentMeans?.PayeePartyCreditorFinancialAccount?.IBANID),
|
||||||
|
bic: text(paymentMeans?.PayeeSpecifiedCreditorFinancialInstitution?.BICID),
|
||||||
|
},
|
||||||
|
buyerReference: text(agreement?.BuyerReference),
|
||||||
|
paymentMeansCode: text(paymentMeans?.TypeCode),
|
||||||
|
items,
|
||||||
|
totals: {
|
||||||
|
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
||||||
|
tax: number(monetary?.TaxTotalAmount) != null ? roundMoney(sign * number(monetary.TaxTotalAmount)!) : null,
|
||||||
|
gross: number(monetary?.GrandTotalAmount) != null ? roundMoney(sign * number(monetary.GrandTotalAmount)!) : null,
|
||||||
|
payable: number(monetary?.DuePayableAmount) != null ? roundMoney(sign * number(monetary.DuePayableAmount)!) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
||||||
|
|
||||||
|
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||||
|
const isCreditNote = syntax === "ubl-credit-note"
|
||||||
|
const sign = isCreditNote ? -1 : 1
|
||||||
|
const supplier = root?.AccountingSupplierParty?.Party || {}
|
||||||
|
const monetary = root?.LegalMonetaryTotal || {}
|
||||||
|
const paymentMeans = array(root?.PaymentMeans)[0] || {}
|
||||||
|
const sourceLines = array(isCreditNote ? root?.CreditNoteLine : root?.InvoiceLine)
|
||||||
|
|
||||||
|
const items: ElectronicInvoiceItem[] = sourceLines.map((line: any) => {
|
||||||
|
const quantityValue = isCreditNote ? line?.CreditedQuantity : line?.InvoicedQuantity
|
||||||
|
const taxCategory = line?.Item?.ClassifiedTaxCategory || {}
|
||||||
|
const net = number(line?.LineExtensionAmount) || 0
|
||||||
|
const taxRate = number(taxCategory?.Percent) || 0
|
||||||
|
const signedNet = roundMoney(sign * net)
|
||||||
|
const taxAmount = roundMoney(signedNet * taxRate / 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: [text(line?.Item?.Name), text(line?.Item?.Description)].filter(Boolean).join(" - ") || "Position",
|
||||||
|
quantity: number(quantityValue),
|
||||||
|
unitCode: text(quantityValue?.["@_unitCode"]),
|
||||||
|
netAmount: signedNet,
|
||||||
|
taxRate,
|
||||||
|
taxCategory: text(taxCategory?.ID),
|
||||||
|
taxAmount,
|
||||||
|
grossAmount: roundMoney(signedNet + taxAmount),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const taxSchemeIds = array(supplier?.PartyTaxScheme)
|
||||||
|
const vatId = text(taxSchemeIds.find((entry: any) => text(entry?.TaxScheme?.ID)?.toUpperCase() === "VAT")?.CompanyID)
|
||||||
|
|
||||||
|
return {
|
||||||
|
syntax,
|
||||||
|
profileId: text(root?.CustomizationID) || text(root?.ProfileID),
|
||||||
|
invoiceNumber: text(root?.ID),
|
||||||
|
invoiceDate: date(root?.IssueDate),
|
||||||
|
dueDate: date(root?.DueDate) || date(paymentMeans?.PaymentDueDate),
|
||||||
|
currency: text(root?.DocumentCurrencyCode),
|
||||||
|
invoiceType: isCreditNote ? "credit-note" : "invoice",
|
||||||
|
seller: {
|
||||||
|
name: ublPartyName(supplier),
|
||||||
|
vatId,
|
||||||
|
taxNumber: text(supplier?.PartyTaxScheme?.CompanyID),
|
||||||
|
iban: text(paymentMeans?.PayeeFinancialAccount?.ID),
|
||||||
|
bic: text(paymentMeans?.PayeeFinancialAccount?.FinancialInstitutionBranch?.ID),
|
||||||
|
},
|
||||||
|
buyerReference: text(root?.BuyerReference),
|
||||||
|
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
||||||
|
items,
|
||||||
|
totals: {
|
||||||
|
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
||||||
|
tax: number(root?.TaxTotal?.TaxAmount) != null ? roundMoney(sign * number(root.TaxTotal.TaxAmount)!) : null,
|
||||||
|
gross: number(monetary?.TaxInclusiveAmount) != null ? roundMoney(sign * number(monetary.TaxInclusiveAmount)!) : null,
|
||||||
|
payable: number(monetary?.PayableAmount) != null ? roundMoney(sign * number(monetary.PayableAmount)!) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = (invoice: Omit<ParsedElectronicInvoice, "validation">) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
const tolerance = 0.02
|
||||||
|
|
||||||
|
if (!invoice.invoiceNumber) errors.push("Rechnungsnummer fehlt.")
|
||||||
|
if (!invoice.invoiceDate) errors.push("Rechnungsdatum fehlt oder ist ungültig.")
|
||||||
|
if (!invoice.currency) errors.push("Rechnungswährung fehlt.")
|
||||||
|
if (!invoice.seller.name) errors.push("Name des Rechnungsstellers fehlt.")
|
||||||
|
if (invoice.items.length === 0) errors.push("Die E-Rechnung enthält keine Rechnungspositionen.")
|
||||||
|
|
||||||
|
const calculatedLineNet = roundMoney(invoice.items.reduce((sum, item) => sum + item.netAmount, 0))
|
||||||
|
const calculatedTax = roundMoney(invoice.items.reduce((sum, item) => sum + item.taxAmount, 0))
|
||||||
|
|
||||||
|
if (invoice.totals.lineNet != null && Math.abs(invoice.totals.lineNet - calculatedLineNet) > tolerance) {
|
||||||
|
errors.push(`Positionssumme ${calculatedLineNet.toFixed(2)} stimmt nicht mit der Nettosumme ${invoice.totals.lineNet.toFixed(2)} überein.`)
|
||||||
|
}
|
||||||
|
if (invoice.totals.tax != null && Math.abs(invoice.totals.tax - calculatedTax) > tolerance) {
|
||||||
|
warnings.push(`Berechnete Steuer ${calculatedTax.toFixed(2)} weicht von der Steuer-Gesamtsumme ${invoice.totals.tax.toFixed(2)} ab.`)
|
||||||
|
}
|
||||||
|
if (invoice.totals.gross != null && invoice.totals.lineNet != null && invoice.totals.tax != null) {
|
||||||
|
const expectedGross = roundMoney(invoice.totals.lineNet + invoice.totals.tax)
|
||||||
|
if (Math.abs(expectedGross - invoice.totals.gross) > tolerance) {
|
||||||
|
errors.push(`Bruttosumme ${invoice.totals.gross.toFixed(2)} stimmt nicht mit Netto plus Steuer ${expectedGross.toFixed(2)} überein.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!invoice.seller.vatId) warnings.push("Keine USt-ID des Rechnungsstellers enthalten.")
|
||||||
|
if (!invoice.dueDate) warnings.push("Kein Fälligkeitsdatum enthalten.")
|
||||||
|
for (const taxRate of new Set(invoice.items.map((item) => item.taxRate))) {
|
||||||
|
if (![0, 7, 19].includes(taxRate)) warnings.push(`Steuersatz ${taxRate}% ist in FEDEO noch keinem Standard-Steuerschlüssel zugeordnet.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseElectronicInvoice = (xml: Buffer, syntax: ElectronicInvoiceSyntax): ParsedElectronicInvoice => {
|
||||||
|
const parsed = parser.parse(xml.toString("utf8"))
|
||||||
|
const root = syntax === "cii"
|
||||||
|
? parsed.CrossIndustryInvoice
|
||||||
|
: syntax === "ubl-credit-note"
|
||||||
|
? parsed.CreditNote
|
||||||
|
: parsed.Invoice
|
||||||
|
|
||||||
|
if (!root) throw new Error(`XML-Wurzelelement für ${syntax} wurde nicht gefunden.`)
|
||||||
|
|
||||||
|
const invoice = syntax === "cii" ? parseCii(root) : parseUbl(root, syntax)
|
||||||
|
return { ...invoice, validation: validate(invoice) }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user