Compare commits
107 Commits
uichange
...
34f537238e
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f537238e | |||
| 64df33f0fa | |||
| 94ab3350ec | |||
| aa162dcad3 | |||
| c42e57494a | |||
| 582af62fcb | |||
| 743c0e8772 | |||
| d4c39d7d44 | |||
| e60188f043 | |||
| ca4f1ba1c0 | |||
| 5fe823f52a | |||
| 1969610130 | |||
| 2bf52b35fe | |||
| f01881a6ce | |||
| a185c6eb11 | |||
| a8450fc0c6 | |||
| 0f5275b870 | |||
| 4f37811dcc | |||
| d7eced3e77 | |||
| 6a5c1e844d | |||
| 5dc44e571f | |||
| 2b1a9a456b | |||
| bf5d7aaed2 | |||
| e166248c0d | |||
| cba4ea52e8 | |||
| 0f14f7ac3d | |||
| 2d26cedaa3 | |||
| d5aed2140e | |||
| cfc5efb556 | |||
| 898a5459fa | |||
| 3b7bcb7940 | |||
| 2aaff0088e | |||
| e9bbc196f7 | |||
| 20818beb3a | |||
| 6aa69cb68b | |||
| a021d3d15c | |||
| bb61caed6d | |||
| d3ab03da7e | |||
| 5869f88c1a | |||
| 50c76b67c7 | |||
| f4edcc2d44 | |||
| 35ef3a7cf8 | |||
| 4783971000 | |||
| c085b1e4d5 | |||
| 46b08b29b9 | |||
| 5cc41f9a2d | |||
| edec670ee0 | |||
| 41e5a4021b | |||
| 9c608cbf71 | |||
| 543952dbf8 | |||
| 2f7819e309 | |||
| 7799cbce80 | |||
| 0284ea8726 | |||
| 743bf0660c | |||
| df4b591be4 | |||
| 86e0743cbb | |||
| aaf91ea15e | |||
| cb71e9d294 | |||
| 75148b2718 | |||
| 81b4eee1e8 | |||
| 0fbda27609 | |||
| 3562d55a12 | |||
| 6224a25c38 | |||
| 63b1c563c1 | |||
| 76f86e87c1 | |||
| 8c458f4953 | |||
| d704e343fc | |||
| 4882da0d35 | |||
| 1908a6441d | |||
| a4735818fb | |||
| 4fb3d3c8a0 | |||
| 30dc99e4e0 | |||
| 9fea18b215 | |||
| 75c15c14c4 | |||
| b27b00f59c | |||
| 1637d4bd91 | |||
| 8114a8c645 | |||
| 0b7d20d946 | |||
| 849e24092e | |||
| 6fcaf3f65c | |||
| dce0046e63 | |||
| 02b5769049 | |||
| f125617af0 | |||
| d9e5df07bf | |||
| 7996c746c3 | |||
| f679eb3624 | |||
| 7ad44544cf | |||
| 669bcd93ab | |||
| aee45e29fd | |||
| 42e0d7b35e | |||
| f6c9875320 | |||
| 05f3b678c4 | |||
| eb718021fd | |||
| 01b4d0f973 | |||
| c29494dc0d | |||
| 809a37a410 | |||
| 232e3f3260 | |||
| b2657f5d52 | |||
| cee0e1fa7d | |||
| 7dea2de7f3 | |||
| 4db753d34a | |||
| e0e99ba6f5 | |||
| ace2213cc4 | |||
| 7e6c5cc189 | |||
| 7c644c941a | |||
| 11a242d70d | |||
| 9f665fc3b8 |
@@ -1,5 +1,5 @@
|
||||
name: Build and Push Docker Images
|
||||
run-name: Build Backend & Frontend by @${{ github.actor }}
|
||||
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
|
||||
|
||||
on: [push]
|
||||
|
||||
@@ -8,12 +8,38 @@ env:
|
||||
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
|
||||
# Beispiel: gitea.deine-domain.de
|
||||
REGISTRY_HOST: git.federspiel.tech
|
||||
# Der Name des Repos (z.B. user/repo)
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# Der Name des Repos (z.B. user/repo).
|
||||
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
|
||||
IMAGE_NAME: flfeders/fedeo
|
||||
ACTOR: flfeders
|
||||
|
||||
jobs:
|
||||
# verify-docs-sync:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Check out repository code
|
||||
# uses: actions/checkout@v3
|
||||
#
|
||||
# - name: Prüfe Node-Version
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 20
|
||||
#
|
||||
# - name: Synchronisiere Funktionsdokumentation
|
||||
# run: node docs/scripts/sync-funktionsdoku.mjs
|
||||
#
|
||||
# - name: Breche ab, wenn Doku nicht aktuell committed ist
|
||||
# run: |
|
||||
# if [ -n "$(git status --porcelain docs/)" ]; then
|
||||
# echo "Die generierte Dokumentation ist nicht aktuell."
|
||||
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
|
||||
# echo "Danach die Änderungen committen."
|
||||
# git status --short docs/
|
||||
# exit 1
|
||||
# fi
|
||||
|
||||
build-backend:
|
||||
#needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
@@ -46,6 +72,7 @@ jobs:
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
|
||||
build-frontend:
|
||||
#needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
@@ -74,4 +101,37 @@ jobs:
|
||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
|
||||
build-docs:
|
||||
needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docs
|
||||
id: meta-docs
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docs
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./docs-site/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-docs.outputs.tags }}
|
||||
labels: ${{ steps.meta-docs.outputs.labels }}
|
||||
|
||||
@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
|
||||
|
||||
console.log("[DB INIT] 1. Suche Connection String...");
|
||||
|
||||
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
|
||||
|
||||
// Checken woher die URL kommt
|
||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
|
||||
if (connectionString) {
|
||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
|
||||
if (process.env.DATABASE_URL) {
|
||||
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
|
||||
} else if (secrets.DATABASE_URL) {
|
||||
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
|
||||
} else if (connectionString) {
|
||||
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
|
||||
} else {
|
||||
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
||||
}
|
||||
@@ -24,4 +30,4 @@ pool.query('SELECT NOW()')
|
||||
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
|
||||
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
37
backend/db/migrations/0024_tenant_branches.sql
Normal file
37
backend/db/migrations/0024_tenant_branches.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "branches" (
|
||||
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"number" text,
|
||||
"description" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth_profile_branches" (
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"branch_id" bigint NOT NULL,
|
||||
"created_by" uuid,
|
||||
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
|
||||
ADD COLUMN "depreciation_months" integer,
|
||||
ADD COLUMN "depreciation_start_date" text,
|
||||
ADD COLUMN "depreciation_label" text,
|
||||
ADD COLUMN "depreciation_group" text;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "depreciation_method" text,
|
||||
ADD COLUMN "residual_value" double precision;
|
||||
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "products"
|
||||
ADD COLUMN "supplierLink" text;
|
||||
31
backend/db/migrations/0028_teams.sql
Normal file
31
backend/db/migrations/0028_teams.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE "teams" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"branch" bigint,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth_profile_teams" (
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"team_id" bigint NOT NULL,
|
||||
"created_by" uuid,
|
||||
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
1
backend/db/migrations/0029_events_quick.sql
Normal file
1
backend/db/migrations/0029_events_quick.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL;
|
||||
10
backend/db/migrations/0030_manual_statementallocations.sql
Normal file
10
backend/db/migrations/0030_manual_statementallocations.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
|
||||
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
|
||||
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
|
||||
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
|
||||
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;
|
||||
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
|
||||
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;
|
||||
2
backend/db/migrations/0034_profile_availability_note.sql
Normal file
2
backend/db/migrations/0034_profile_availability_note.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth_profiles"
|
||||
ADD COLUMN "availability_note" text;
|
||||
3
backend/db/migrations/0035_contract_history.sql
Normal file
3
backend/db/migrations/0035_contract_history.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
1
backend/db/migrations/0036_allowed_contracttypes.sql
Normal file
1
backend/db/migrations/0036_allowed_contracttypes.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "contracts" ADD COLUMN "allowedContracttypes" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||
11813
backend/db/migrations/meta/0025_snapshot.json
Normal file
11813
backend/db/migrations/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -138,30 +138,121 @@
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1773489600000,
|
||||
"tag": "0019_custom_surcharge_percentage_decimal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1773572400000,
|
||||
"tag": "0020_file_extracted_text",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1773835200000,
|
||||
"tag": "0021_admin_user_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1773925200000,
|
||||
"tag": "0022_task_dependencies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774080000000,
|
||||
"tag": "0023_tax_evaluation_period",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1774393200000,
|
||||
"tag": "0024_tenant_branches",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1774393201000,
|
||||
"tag": "0025_statementallocation_depreciation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1774393202000,
|
||||
"tag": "0026_statementallocation_depreciation_method",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1774602000000,
|
||||
"tag": "0027_product_supplier_link",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1776124800000,
|
||||
"tag": "0028_teams",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1776211200000,
|
||||
"tag": "0029_events_quick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1776297600000,
|
||||
"tag": "0030_manual_statementallocations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1776298200000,
|
||||
"tag": "0031_manual_statementallocations_tax_key",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1776298800000,
|
||||
"tag": "0032_manual_statementallocations_invoice_side",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1777003200000,
|
||||
"tag": "0033_costcentres_parent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1778191200000,
|
||||
"tag": "0035_contract_history",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1778194800000,
|
||||
"tag": "0036_allowed_contracttypes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
30
backend/db/schema/auth_profile_branches.ts
Normal file
30
backend/db/schema/auth_profile_branches.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authProfiles } from "./auth_profiles"
|
||||
import { branches } from "./branches"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const authProfileBranches = pgTable(
|
||||
"auth_profile_branches",
|
||||
{
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
profile_id: uuid("profile_id")
|
||||
.notNull()
|
||||
.references(() => authProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
branch_id: bigint("branch_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: "cascade" }),
|
||||
|
||||
created_by: uuid("created_by").references(() => authUsers.id),
|
||||
},
|
||||
(table) => ({
|
||||
primaryKey: [table.profile_id, table.branch_id],
|
||||
})
|
||||
)
|
||||
|
||||
export type AuthProfileBranch = typeof authProfileBranches.$inferSelect
|
||||
export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert
|
||||
30
backend/db/schema/auth_profile_teams.ts
Normal file
30
backend/db/schema/auth_profile_teams.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authProfiles } from "./auth_profiles"
|
||||
import { teams } from "./teams"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const authProfileTeams = pgTable(
|
||||
"auth_profile_teams",
|
||||
{
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
profile_id: uuid("profile_id")
|
||||
.notNull()
|
||||
.references(() => authProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
team_id: bigint("team_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: "cascade" }),
|
||||
|
||||
created_by: uuid("created_by").references(() => authUsers.id),
|
||||
},
|
||||
(table) => ({
|
||||
primaryKey: [table.profile_id, table.team_id],
|
||||
})
|
||||
)
|
||||
|
||||
export type AuthProfileTeam = typeof authProfileTeams.$inferSelect
|
||||
export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const authProfiles = pgTable("auth_profiles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
|
||||
|
||||
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
|
||||
|
||||
branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id),
|
||||
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -71,6 +74,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
||||
contract_type: text("contract_type"),
|
||||
position: text("position"),
|
||||
qualification: text("qualification"),
|
||||
availability_note: text("availability_note"),
|
||||
|
||||
address_street: text("address_street"),
|
||||
address_zip: text("address_zip"),
|
||||
|
||||
37
backend/db/schema/branches.ts
Normal file
37
backend/db/schema/branches.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const branches = pgTable("branches", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
name: text("name").notNull(),
|
||||
number: text("number"),
|
||||
description: text("description"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type Branch = typeof branches.$inferSelect
|
||||
export type NewBranch = typeof branches.$inferInsert
|
||||
@@ -52,6 +52,7 @@ export const contracts = pgTable(
|
||||
contracttype: bigint("contracttype", { mode: "number" }).references(
|
||||
() => contracttypes.id
|
||||
),
|
||||
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
|
||||
|
||||
bankingIban: text("bankingIban"),
|
||||
bankingBIC: text("bankingBIC"),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
|
||||
import { projects } from "./projects"
|
||||
import { vehicles } from "./vehicles"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const costcentres = pgTable("costcentres", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -28,10 +29,14 @@ export const costcentres = pgTable("costcentres", {
|
||||
number: text("number").notNull(),
|
||||
name: text("name").notNull(),
|
||||
|
||||
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
|
||||
|
||||
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
||||
|
||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||
|
||||
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
|
||||
|
||||
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
||||
() => inventoryitems.id
|
||||
),
|
||||
|
||||
@@ -31,6 +31,7 @@ export const events = pgTable(
|
||||
endDate: timestamp("endDate", { withTimezone: true }),
|
||||
|
||||
eventtype: text("eventtype").default("Umsetzung"),
|
||||
quick: boolean("quick").notNull().default(false),
|
||||
|
||||
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {files} from "./files";
|
||||
import { memberrelations } from "./memberrelations";
|
||||
import { contracts } from "./contracts";
|
||||
|
||||
export const historyitems = pgTable("historyitems", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -52,6 +53,11 @@ export const historyitems = pgTable("historyitems", {
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
contract: bigint("contract", { mode: "number" }).references(
|
||||
() => contracts.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./accounts"
|
||||
export * from "./auth_profiles"
|
||||
export * from "./auth_profile_branches"
|
||||
export * from "./auth_profile_teams"
|
||||
export * from "./auth_role_permisssions"
|
||||
export * from "./auth_roles"
|
||||
export * from "./auth_tenant_users"
|
||||
@@ -8,6 +10,7 @@ export * from "./auth_users"
|
||||
export * from "./bankaccounts"
|
||||
export * from "./bankrequisitions"
|
||||
export * from "./bankstatements"
|
||||
export * from "./branches"
|
||||
export * from "./checkexecutions"
|
||||
export * from "./checks"
|
||||
export * from "./citys"
|
||||
@@ -67,6 +70,7 @@ export * from "./staff_time_entry_connects"
|
||||
export * from "./staff_zeitstromtimestamps"
|
||||
export * from "./statementallocations"
|
||||
export * from "./tasks"
|
||||
export * from "./teams"
|
||||
export * from "./taxtypes"
|
||||
export * from "./tenants"
|
||||
export * from "./texttemplates"
|
||||
|
||||
@@ -50,6 +50,7 @@ export const products = pgTable("products", {
|
||||
vendor_allocation: jsonb("vendorAllocation").default([]),
|
||||
|
||||
article_number: text("articleNumber"),
|
||||
supplier_link: text("supplierLink"),
|
||||
|
||||
barcodes: text("barcodes").array().notNull().default([]),
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
// foreign keys
|
||||
bankstatement: integer("bs_id")
|
||||
.notNull()
|
||||
.references(() => bankstatements.id),
|
||||
bankstatement: integer("bs_id").references(() => bankstatements.id),
|
||||
|
||||
createddocument: integer("cd_id").references(() => createddocuments.id),
|
||||
|
||||
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||
() => incominginvoices.id
|
||||
),
|
||||
manualInvoiceSide: text("manual_invoice_side"),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
@@ -43,20 +42,43 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
() => accounts.id
|
||||
),
|
||||
|
||||
contraAccount: bigint("contra_account", { mode: "number" }).references(
|
||||
() => accounts.id
|
||||
),
|
||||
|
||||
created_at: timestamp("created_at", {
|
||||
withTimezone: false,
|
||||
}).defaultNow(),
|
||||
|
||||
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
|
||||
|
||||
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
|
||||
|
||||
description: text("description"),
|
||||
|
||||
manualBookingDate: text("manual_booking_date"),
|
||||
datevTaxKey: text("datev_tax_key"),
|
||||
|
||||
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||
depreciationMonths: integer("depreciation_months"),
|
||||
depreciationStartDate: text("depreciation_start_date"),
|
||||
depreciationMethod: text("depreciation_method"),
|
||||
depreciationLabel: text("depreciation_label"),
|
||||
depreciationGroup: text("depreciation_group"),
|
||||
residualValue: doublePrecision("residual_value"),
|
||||
|
||||
customer: bigint("customer", { mode: "number" }).references(
|
||||
() => customers.id
|
||||
),
|
||||
|
||||
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
|
||||
|
||||
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
|
||||
() => customers.id
|
||||
),
|
||||
|
||||
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
|
||||
|
||||
updated_at: timestamp("updated_at", { withTimezone: true }),
|
||||
|
||||
updated_by: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
40
backend/db/schema/teams.ts
Normal file
40
backend/db/schema/teams.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const teams = pgTable("teams", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
|
||||
branch: bigint("branch", { mode: "number" })
|
||||
.references(() => branches.id),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type Team = typeof teams.$inferSelect
|
||||
export type NewTeam = typeof teams.$inferInsert
|
||||
@@ -92,6 +92,8 @@ export const tenants = pgTable(
|
||||
serialInvoice: true,
|
||||
incomingInvoices: true,
|
||||
costcentres: true,
|
||||
branches: true,
|
||||
teams: true,
|
||||
accounts: true,
|
||||
ownaccounts: true,
|
||||
banking: true,
|
||||
@@ -127,8 +129,11 @@ export const tenants = pgTable(
|
||||
customers: { prefix: "", suffix: "", nextNumber: 10000 },
|
||||
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
|
||||
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
|
||||
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
|
||||
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
|
||||
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
|
||||
deliveryNotes: { prefix: "LS-", suffix: "", nextNumber: 1000 },
|
||||
packingSlips: { prefix: "PS-", suffix: "", nextNumber: 1000 },
|
||||
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
|
||||
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
|
||||
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import "dotenv/config"
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
import {secrets} from "./src/utils/secrets";
|
||||
|
||||
const fallbackDatabaseUrl = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
|
||||
const databaseUrl = process.env.DATABASE_URL || fallbackDatabaseUrl
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./db/schema",
|
||||
out: "./db/migrations",
|
||||
dbCredentials: {
|
||||
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
|
||||
url: databaseUrl,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"migrate": "tsx scripts/migrate.ts",
|
||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||
"build": "tsc",
|
||||
@@ -12,6 +13,7 @@
|
||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
||||
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
||||
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
589
backend/scripts/import-mitarbeiterliste.ts
Normal file
589
backend/scripts/import-mitarbeiterliste.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import {
|
||||
authProfileBranches,
|
||||
authProfiles,
|
||||
authProfileTeams,
|
||||
branches,
|
||||
teams,
|
||||
tenants,
|
||||
} from "../db/schema"
|
||||
|
||||
type ImportRow = {
|
||||
rowNumber: number
|
||||
mitarbeiter: string
|
||||
betrieb: string
|
||||
anstellung: string
|
||||
position: string
|
||||
bereich: string
|
||||
stundenMonat: number | null
|
||||
urlaub: number | null
|
||||
}
|
||||
|
||||
type CliOptions = {
|
||||
workbookPath: string
|
||||
tenantId: number
|
||||
dryRun: boolean
|
||||
defaultBranchId: number | null
|
||||
branchMap: Record<string, number>
|
||||
}
|
||||
|
||||
type ImportAction = "create" | "update"
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles.
|
||||
|
||||
Verwendung:
|
||||
npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm
|
||||
|
||||
Optionen:
|
||||
--tenant-id=ID Pflicht. Tenant-ID für den Import.
|
||||
--branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId.
|
||||
--branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId.
|
||||
--default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe.
|
||||
--dry-run Führt keine Schreiboperationen aus.
|
||||
--help Zeigt diese Hilfe an.
|
||||
|
||||
Beispiel branch-map.json:
|
||||
{
|
||||
"Strandcafé": 10,
|
||||
"1848 Pütt": 11,
|
||||
"Oceans11": 12,
|
||||
"Winnys": 13
|
||||
}
|
||||
`.trim())
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions | null {
|
||||
const options: CliOptions = {
|
||||
workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm",
|
||||
tenantId: Number.NaN,
|
||||
dryRun: false,
|
||||
defaultBranchId: null,
|
||||
branchMap: {},
|
||||
}
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (arg === "--dry-run") {
|
||||
options.dryRun = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith("--tenant-id=")) {
|
||||
options.tenantId = Number(arg.slice("--tenant-id=".length))
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith("--default-branch-id=")) {
|
||||
options.defaultBranchId = Number(arg.slice("--default-branch-id=".length))
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith("--branch-map=")) {
|
||||
options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI")
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith("--branch-map-file=")) {
|
||||
const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length))
|
||||
options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!arg.startsWith("--")) {
|
||||
options.workbookPath = path.resolve(arg)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Unbekanntes Argument: ${arg}`)
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.tenantId)) {
|
||||
throw new Error("Bitte --tenant-id=... angeben.")
|
||||
}
|
||||
|
||||
if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) {
|
||||
throw new Error("--default-branch-id muss numerisch sein.")
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function parseBranchMap(raw: string, sourceLabel: string): Record<string, number> {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`)
|
||||
}
|
||||
|
||||
const normalizedEntries = Object.entries(parsed).map(([key, value]) => {
|
||||
const branchId = Number(value)
|
||||
if (!Number.isFinite(branchId)) {
|
||||
throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`)
|
||||
}
|
||||
return [normalizeKey(key), branchId] as const
|
||||
})
|
||||
|
||||
return Object.fromEntries(normalizedEntries)
|
||||
}
|
||||
|
||||
function normalizeKey(value: string) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLocaleLowerCase("de-DE")
|
||||
}
|
||||
|
||||
function decodeXmlText(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
||||
}
|
||||
|
||||
function getWorkbookXml(workbookPath: string, innerPath: string) {
|
||||
return execFileSync("unzip", ["-p", workbookPath, innerPath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
}
|
||||
|
||||
function readSharedStrings(workbookPath: string) {
|
||||
const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml")
|
||||
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
|
||||
const parts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1]))
|
||||
return parts.join("")
|
||||
})
|
||||
}
|
||||
|
||||
function readSheetRows(workbookPath: string, sharedStrings: string[]) {
|
||||
const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml")
|
||||
const rows: Record<string, string>[] = []
|
||||
|
||||
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
const cellMap: Record<string, string> = {}
|
||||
const rowXml = rowMatch[2]
|
||||
|
||||
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attrs = cellMatch[1]
|
||||
const cellXml = cellMatch[2]
|
||||
const refMatch = attrs.match(/r="([A-Z]+)\d+"/)
|
||||
if (!refMatch) continue
|
||||
|
||||
const column = refMatch[1]
|
||||
const typeMatch = attrs.match(/t="([^"]+)"/)
|
||||
const type = typeMatch?.[1] || ""
|
||||
const valueMatch = cellXml.match(/<v>([\s\S]*?)<\/v>/)
|
||||
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\s\S]*?)<\/t>/)
|
||||
|
||||
let value = ""
|
||||
if (type === "s" && valueMatch) {
|
||||
value = sharedStrings[Number(valueMatch[1])] || ""
|
||||
} else if (inlineTextMatch) {
|
||||
value = decodeXmlText(inlineTextMatch[1])
|
||||
} else if (valueMatch) {
|
||||
value = decodeXmlText(valueMatch[1])
|
||||
}
|
||||
|
||||
cellMap[column] = value.trim()
|
||||
}
|
||||
|
||||
rows.push(cellMap)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function parseNumber(value: string) {
|
||||
const normalized = String(value || "").trim().replace(",", ".")
|
||||
if (!normalized) return null
|
||||
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function parseWorkbook(workbookPath: string): ImportRow[] {
|
||||
const sharedStrings = readSharedStrings(workbookPath)
|
||||
const rows = readSheetRows(workbookPath, sharedStrings)
|
||||
|
||||
if (!rows.length) {
|
||||
throw new Error("Die Arbeitsmappe enthält keine Zeilen.")
|
||||
}
|
||||
|
||||
const header = rows[0]
|
||||
if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") {
|
||||
throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.")
|
||||
}
|
||||
|
||||
return rows
|
||||
.slice(1)
|
||||
.map((row, index) => ({
|
||||
rowNumber: index + 2,
|
||||
mitarbeiter: row.A || "",
|
||||
betrieb: row.B || "",
|
||||
anstellung: row.C || "",
|
||||
position: row.D || "",
|
||||
bereich: row.E || "",
|
||||
stundenMonat: parseNumber(row.F || ""),
|
||||
urlaub: parseNumber(row.G || ""),
|
||||
}))
|
||||
.filter((row) => row.mitarbeiter)
|
||||
}
|
||||
|
||||
function splitFullName(fullName: string) {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length <= 1) {
|
||||
return {
|
||||
firstName: fullName.trim(),
|
||||
lastName: "Unbekannt",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
firstName: parts.slice(0, -1).join(" "),
|
||||
lastName: parts[parts.length - 1],
|
||||
}
|
||||
}
|
||||
|
||||
function toWeeklyHours(monthlyHours: number | null) {
|
||||
if (monthlyHours == null) return null
|
||||
return Math.round(((monthlyHours * 12) / 52) * 100) / 100
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value == null) return "-"
|
||||
if (typeof value === "object") return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function mapEmploymentCategory(value: string) {
|
||||
const normalized = normalizeKey(value)
|
||||
if (normalized === "aushilfe") return "Aushilfen"
|
||||
if (normalized === "teilzeit" || normalized === "vollzeit") return "Festangestellte"
|
||||
return null
|
||||
}
|
||||
|
||||
function buildTeamName(bereich: string, anstellung: string) {
|
||||
const employmentCategory = mapEmploymentCategory(anstellung)
|
||||
const normalizedBereich = String(bereich || "").trim()
|
||||
if (!normalizedBereich || !employmentCategory) return null
|
||||
return `${normalizedBereich} ${employmentCategory}`
|
||||
}
|
||||
|
||||
function collectFieldChanges(existing: any, nextPayload: Record<string, unknown>) {
|
||||
if (!existing) return []
|
||||
|
||||
const changes: string[] = []
|
||||
for (const [field, nextValue] of Object.entries(nextPayload)) {
|
||||
const currentValue = existing[field]
|
||||
const currentFormatted = formatValue(currentValue)
|
||||
const nextFormatted = formatValue(nextValue)
|
||||
|
||||
if (currentFormatted !== nextFormatted) {
|
||||
changes.push(`${field}: ${currentFormatted} -> ${nextFormatted}`)
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
async function validateTenantAndBranches(
|
||||
db: any,
|
||||
tenantId: number,
|
||||
branchIds: number[]
|
||||
) {
|
||||
const [tenant] = await db
|
||||
.select({ id: tenants.id, name: tenants.name })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1)
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`)
|
||||
}
|
||||
|
||||
const branchRows = await db
|
||||
.select({ id: branches.id, name: branches.name })
|
||||
.from(branches)
|
||||
.where(eq(branches.tenant, tenantId))
|
||||
|
||||
const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id)))
|
||||
for (const branchId of branchIds) {
|
||||
if (!validBranchIds.has(branchId)) {
|
||||
throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tenant,
|
||||
branchRows,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenantTeams(db: any, tenantId: number) {
|
||||
const teamRows = await db
|
||||
.select({
|
||||
id: teams.id,
|
||||
name: teams.name,
|
||||
branch: teams.branch,
|
||||
archived: teams.archived,
|
||||
})
|
||||
.from(teams)
|
||||
.where(eq(teams.tenant, tenantId))
|
||||
|
||||
return new Map(
|
||||
teamRows
|
||||
.filter((team: any) => !team.archived)
|
||||
.map((team: any) => [`${team.branch}::${normalizeKey(team.name)}`, team])
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (!options) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(options.workbookPath)) {
|
||||
throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`)
|
||||
}
|
||||
|
||||
const rows = parseWorkbook(options.workbookPath)
|
||||
if (!rows.length) {
|
||||
throw new Error("Keine importierbaren Mitarbeiter gefunden.")
|
||||
}
|
||||
|
||||
const mappedBranchIds = [
|
||||
...new Set(
|
||||
Object.values(options.branchMap)
|
||||
.concat(options.defaultBranchId != null ? [options.defaultBranchId] : [])
|
||||
),
|
||||
]
|
||||
|
||||
const { db, pool } = await import("../db")
|
||||
|
||||
try {
|
||||
const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds)
|
||||
const teamByBranchAndName = await loadTenantTeams(db, options.tenantId)
|
||||
|
||||
const existingProfiles = await db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, options.tenantId))
|
||||
|
||||
const existingByName = new Map<string, any>()
|
||||
for (const profile of existingProfiles) {
|
||||
const key = normalizeKey(`${profile.first_name} ${profile.last_name}`)
|
||||
if (existingByName.has(key)) {
|
||||
throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`)
|
||||
}
|
||||
existingByName.set(key, profile)
|
||||
}
|
||||
|
||||
const duplicateImportNames = new Set<string>()
|
||||
const seenImportNames = new Set<string>()
|
||||
for (const row of rows) {
|
||||
const key = normalizeKey(row.mitarbeiter)
|
||||
if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter)
|
||||
seenImportNames.add(key)
|
||||
}
|
||||
|
||||
if (duplicateImportNames.size) {
|
||||
throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`)
|
||||
}
|
||||
|
||||
const missingBranchMappings = new Set<string>()
|
||||
const missingTeams = new Set<string>()
|
||||
const preparedRows = rows.map((row) => {
|
||||
const branchKey = normalizeKey(row.betrieb)
|
||||
const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null
|
||||
if (!branchId) {
|
||||
missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`)
|
||||
}
|
||||
|
||||
const teamName = buildTeamName(row.bereich, row.anstellung)
|
||||
const team = branchId && teamName
|
||||
? (teamByBranchAndName.get(`${branchId}::${normalizeKey(teamName)}`) as any) || null
|
||||
: null
|
||||
|
||||
if (branchId && teamName && !team) {
|
||||
missingTeams.add(`${row.betrieb} | ${teamName}`)
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
branchId,
|
||||
teamId: team?.id ?? null,
|
||||
teamName,
|
||||
weeklyHours: toWeeklyHours(row.stundenMonat),
|
||||
}
|
||||
})
|
||||
|
||||
if (missingBranchMappings.size) {
|
||||
throw new Error(
|
||||
`Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
if (missingTeams.size) {
|
||||
throw new Error(
|
||||
`Für folgende Niederlassung-/Bereich-Kombinationen fehlen Teams: ${[...missingTeams].join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
let createdProfiles = 0
|
||||
let updatedProfiles = 0
|
||||
const actionLogs: string[] = []
|
||||
|
||||
for (const row of preparedRows) {
|
||||
const nameParts = splitFullName(row.mitarbeiter)
|
||||
const nameKey = normalizeKey(row.mitarbeiter)
|
||||
const existing = existingByName.get(nameKey)
|
||||
|
||||
const tempConfig = {
|
||||
...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}),
|
||||
mitarbeiterImport: {
|
||||
betrieb: row.betrieb,
|
||||
bereich: row.bereich,
|
||||
stundenMonat: row.stundenMonat,
|
||||
urlaub: row.urlaub,
|
||||
quelle: path.basename(options.workbookPath),
|
||||
importiertAm: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tenant_id: options.tenantId,
|
||||
branch_id: row.branchId,
|
||||
first_name: nameParts.firstName,
|
||||
last_name: nameParts.lastName,
|
||||
contract_type: row.anstellung || null,
|
||||
position: row.position || null,
|
||||
qualification: row.bereich || null,
|
||||
weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0,
|
||||
annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null,
|
||||
temp_config: tempConfig,
|
||||
active: existing?.active ?? true,
|
||||
}
|
||||
|
||||
const action: ImportAction = existing ? "update" : "create"
|
||||
const fieldChanges = existing ? collectFieldChanges(existing, payload) : []
|
||||
const actionPrefix = action === "create" ? "ERSTELLEN" : "AKTUALISIEREN"
|
||||
const branchLabel = `${row.betrieb} -> ${row.branchId}`
|
||||
const teamLabel = row.teamName ? `${row.teamName} -> ${row.teamId}` : "-"
|
||||
|
||||
if (action === "create") {
|
||||
actionLogs.push(
|
||||
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Vertrag ${row.anstellung || "-"} | Position ${row.position || "-"}`
|
||||
)
|
||||
} else {
|
||||
actionLogs.push(
|
||||
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Änderungen: ${fieldChanges.length ? fieldChanges.join("; ") : "keine"}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
if (!options.dryRun) {
|
||||
const [created] = await db
|
||||
.insert(authProfiles)
|
||||
.values(payload)
|
||||
.returning()
|
||||
|
||||
if (!created) {
|
||||
throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`)
|
||||
}
|
||||
|
||||
await db.insert(authProfileBranches).values({
|
||||
profile_id: created.id,
|
||||
branch_id: row.branchId,
|
||||
})
|
||||
|
||||
if (row.teamId) {
|
||||
await db.insert(authProfileTeams).values({
|
||||
profile_id: created.id,
|
||||
team_id: row.teamId,
|
||||
})
|
||||
}
|
||||
|
||||
existingByName.set(nameKey, created)
|
||||
}
|
||||
|
||||
createdProfiles += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!options.dryRun) {
|
||||
await db
|
||||
.update(authProfiles)
|
||||
.set(payload)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, existing.id),
|
||||
eq(authProfiles.tenant_id, options.tenantId)
|
||||
)
|
||||
)
|
||||
|
||||
await db
|
||||
.delete(authProfileBranches)
|
||||
.where(eq(authProfileBranches.profile_id, existing.id))
|
||||
|
||||
await db.insert(authProfileBranches).values({
|
||||
profile_id: existing.id,
|
||||
branch_id: row.branchId,
|
||||
})
|
||||
|
||||
await db
|
||||
.delete(authProfileTeams)
|
||||
.where(eq(authProfileTeams.profile_id, existing.id))
|
||||
|
||||
if (row.teamId) {
|
||||
await db.insert(authProfileTeams).values({
|
||||
profile_id: existing.id,
|
||||
team_id: row.teamId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updatedProfiles += 1
|
||||
}
|
||||
|
||||
console.log("")
|
||||
console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`)
|
||||
console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`)
|
||||
console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`)
|
||||
console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`)
|
||||
console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`)
|
||||
console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`)
|
||||
if (actionLogs.length) {
|
||||
console.log("[IMPORT MITARBEITER] Details:")
|
||||
for (const logLine of actionLogs) {
|
||||
console.log(` ${logLine}`)
|
||||
}
|
||||
}
|
||||
console.log("")
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[IMPORT MITARBEITER] Fehler:", error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
47
backend/scripts/migrate.ts
Normal file
47
backend/scripts/migrate.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import "dotenv/config"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import { Pool } from "pg"
|
||||
|
||||
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
async function run() {
|
||||
let connectionString = process.env.DATABASE_URL
|
||||
|
||||
if (!connectionString) {
|
||||
await loadSecrets()
|
||||
connectionString = secrets.DATABASE_URL
|
||||
}
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL not configured")
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
const db = drizzle(pool)
|
||||
|
||||
await migrate(db, {
|
||||
migrationsFolder: path.resolve(__dirname, "../db/migrations"),
|
||||
})
|
||||
|
||||
console.log("✅ Drizzle-Migrationen erfolgreich angewendet")
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("❌ Migration fehlgeschlagen")
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
7
backend/scripts/mitarbeiterliste-tenant-41-branches.json
Normal file
7
backend/scripts/mitarbeiterliste-tenant-41-branches.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"1848 Pütt": 1,
|
||||
"Strandcafé": 3,
|
||||
"Oceans11": 4,
|
||||
"Oceans 11": 4,
|
||||
"Winnys": 5
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||
import userRoutes from "./routes/auth/user";
|
||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||
import wikiRoutes from "./routes/wiki";
|
||||
import portalContractRoutes from "./routes/portal/contracts";
|
||||
import mcpRoutes from "./routes/mcp";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -146,6 +148,8 @@ async function main() {
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
await subApp.register(resourceRoutes);
|
||||
await subApp.register(wikiRoutes);
|
||||
await subApp.register(portalContractRoutes);
|
||||
await subApp.register(mcpRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
|
||||
88
backend/src/mcp/authz.ts
Normal file
88
backend/src/mcp/authz.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||
import { and, eq, or, isNull, inArray } from "drizzle-orm"
|
||||
import {
|
||||
authRoles,
|
||||
authRolePermissions,
|
||||
authUserRoles,
|
||||
} from "../../db/schema"
|
||||
import { McpContext, McpTool } from "./types"
|
||||
|
||||
export async function loadTenantPermissions(
|
||||
server: FastifyInstance,
|
||||
userId: string,
|
||||
tenantId: number
|
||||
) {
|
||||
const roleRows = await server.db
|
||||
.select({
|
||||
roleId: authUserRoles.role_id,
|
||||
})
|
||||
.from(authUserRoles)
|
||||
.innerJoin(
|
||||
authRoles,
|
||||
and(
|
||||
eq(authRoles.id, authUserRoles.role_id),
|
||||
or(isNull(authRoles.tenant_id), eq(authRoles.tenant_id, tenantId))
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(authUserRoles.user_id, userId),
|
||||
eq(authUserRoles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
|
||||
const roleIds = Array.from(new Set(roleRows.map((row) => row.roleId)))
|
||||
|
||||
if (roleIds.length === 0) return []
|
||||
|
||||
const permissionRows = await server.db
|
||||
.select({
|
||||
permission: authRolePermissions.permission,
|
||||
})
|
||||
.from(authRolePermissions)
|
||||
.where(inArray(authRolePermissions.role_id, roleIds))
|
||||
|
||||
return Array.from(new Set(permissionRows.map((row) => row.permission)))
|
||||
}
|
||||
|
||||
export async function createMcpContext(
|
||||
server: FastifyInstance,
|
||||
request: FastifyRequest
|
||||
): Promise<McpContext> {
|
||||
const user = request.user
|
||||
|
||||
if (!user?.user_id) {
|
||||
throw Object.assign(new Error("Authentication required"), { statusCode: 401 })
|
||||
}
|
||||
|
||||
if (!user.tenant_id) {
|
||||
throw Object.assign(new Error("MCP benötigt einen aktiven Mandanten"), { statusCode: 403 })
|
||||
}
|
||||
|
||||
const permissions = await loadTenantPermissions(server, user.user_id, user.tenant_id)
|
||||
|
||||
return {
|
||||
server,
|
||||
request,
|
||||
tenantId: user.tenant_id,
|
||||
userId: user.user_id,
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
|
||||
export function assertToolPermission(context: McpContext, tool: McpTool) {
|
||||
if (context.isAdmin) return
|
||||
|
||||
const allowed = tool.requiredPermissions.every((permission) =>
|
||||
context.permissions.includes(permission)
|
||||
)
|
||||
|
||||
if (!allowed) {
|
||||
throw Object.assign(
|
||||
new Error(`Fehlende Berechtigung für ${tool.name}: ${tool.requiredPermissions.join(", ")}`),
|
||||
{ statusCode: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
11
backend/src/mcp/registry.ts
Normal file
11
backend/src/mcp/registry.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { accountingTools } from "./tools/accounting"
|
||||
import { masterdataTools } from "./tools/masterdata"
|
||||
import { organisationTools } from "./tools/organisation"
|
||||
|
||||
export const mcpTools = [
|
||||
...accountingTools,
|
||||
...masterdataTools,
|
||||
...organisationTools,
|
||||
]
|
||||
|
||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||
36
backend/src/mcp/result.ts
Normal file
36
backend/src/mcp/result.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { McpToolResult } from "./types"
|
||||
|
||||
export function asToolResult(payload: unknown): McpToolResult {
|
||||
const structuredContent =
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? payload as Record<string, unknown>
|
||||
: { result: payload }
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
},
|
||||
],
|
||||
structuredContent,
|
||||
}
|
||||
}
|
||||
|
||||
export function asToolError(error: unknown): McpToolResult {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
970
backend/src/mcp/tools/accounting.ts
Normal file
970
backend/src/mcp/tools/accounting.ts
Normal file
@@ -0,0 +1,970 @@
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import {
|
||||
accounts,
|
||||
bankstatements,
|
||||
createddocuments,
|
||||
files,
|
||||
incominginvoices,
|
||||
statementallocations,
|
||||
} from "../../../db/schema"
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||
import { McpTool } from "../types"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||
}
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = Number(args[key])
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||
|
||||
const allowedOutgoingDocumentTypes = new Set([
|
||||
"quotes",
|
||||
"costEstimates",
|
||||
"confirmationOrders",
|
||||
"deliveryNotes",
|
||||
"packingSlips",
|
||||
"invoices",
|
||||
"advanceInvoices",
|
||||
"cancellationInvoices",
|
||||
"serialInvoices",
|
||||
])
|
||||
|
||||
const allowedOutgoingDocumentTaxTypes = new Set([
|
||||
"Standard",
|
||||
"13b UStG",
|
||||
"19 UStG",
|
||||
"12.3 UStG",
|
||||
])
|
||||
|
||||
const outgoingDocumentTaxableTypes = new Set([
|
||||
"invoices",
|
||||
"cancellationInvoices",
|
||||
"advanceInvoices",
|
||||
"serialInvoices",
|
||||
"confirmationOrders",
|
||||
"quotes",
|
||||
"costEstimates",
|
||||
])
|
||||
|
||||
const documentTypeArg = (args: Record<string, unknown>, key = "type") => {
|
||||
const type = stringArg(args, key) || "invoices"
|
||||
if (!allowedOutgoingDocumentTypes.has(type)) {
|
||||
throw new Error(`Ungültige Belegart: ${type}`)
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
||||
const normalizeOutgoingDocumentTaxType = (value: unknown) => {
|
||||
const taxType = typeof value === "string" && value.trim() ? value.trim() : "Standard"
|
||||
if (!allowedOutgoingDocumentTaxTypes.has(taxType)) {
|
||||
throw new Error(`Ungültiger Dokument-Steuertyp: ${taxType}`)
|
||||
}
|
||||
return taxType
|
||||
}
|
||||
|
||||
const applyOutgoingDocumentTaxType = (
|
||||
payload: Record<string, unknown>,
|
||||
args: Record<string, unknown>,
|
||||
documentType: string,
|
||||
existingTaxType?: unknown,
|
||||
existingRows?: unknown
|
||||
) => {
|
||||
if (!outgoingDocumentTaxableTypes.has(documentType)) {
|
||||
payload.taxType = null
|
||||
return
|
||||
}
|
||||
|
||||
const taxType = args.taxType !== undefined
|
||||
? normalizeOutgoingDocumentTaxType(args.taxType)
|
||||
: existingTaxType
|
||||
? normalizeOutgoingDocumentTaxType(existingTaxType)
|
||||
: "Standard"
|
||||
|
||||
payload.taxType = taxType
|
||||
|
||||
if (["13b UStG", "19 UStG", "12.3 UStG"].includes(taxType)) {
|
||||
const rows = Array.isArray(payload.rows)
|
||||
? payload.rows
|
||||
: Array.isArray(existingRows)
|
||||
? existingRows
|
||||
: null
|
||||
|
||||
if (!rows) return
|
||||
|
||||
payload.rows = rows.map((row: any) => ({
|
||||
...row,
|
||||
taxPercent: 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const optionalObjectArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null
|
||||
}
|
||||
|
||||
const optionalArrayArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return Array.isArray(value) ? value : null
|
||||
}
|
||||
|
||||
const buildOutgoingDocumentPayload = (
|
||||
args: Record<string, unknown>,
|
||||
userId: string,
|
||||
tenantId: number,
|
||||
includeCreateDefaults = false
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
}
|
||||
|
||||
if (includeCreateDefaults) {
|
||||
payload.tenant = tenantId
|
||||
payload.createdBy = userId
|
||||
payload.created_by = userId
|
||||
payload.archived = false
|
||||
payload.state = stringArg(args, "state") || "Entwurf"
|
||||
payload.type = documentTypeArg(args)
|
||||
payload.rows = optionalArrayArg(args, "rows") || []
|
||||
}
|
||||
|
||||
const stringFields = [
|
||||
"state",
|
||||
"documentDate",
|
||||
"deliveryDate",
|
||||
"deliveryDateEnd",
|
||||
"deliveryDateType",
|
||||
"payment_type",
|
||||
"title",
|
||||
"description",
|
||||
"startText",
|
||||
"endText",
|
||||
]
|
||||
|
||||
for (const field of stringFields) {
|
||||
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
||||
}
|
||||
|
||||
for (const field of ["customer", "contact", "contract", "project", "plant", "letterhead", "createddocument"] as const) {
|
||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||
}
|
||||
|
||||
for (const field of ["paymentDays"] as const) {
|
||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||
}
|
||||
|
||||
if (args.type !== undefined) payload.type = documentTypeArg(args)
|
||||
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
|
||||
if (args.info !== undefined) payload.info = optionalObjectArg(args, "info")
|
||||
if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture")
|
||||
if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {}
|
||||
if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {}
|
||||
if (args.rows !== undefined) payload.rows = optionalArrayArg(args, "rows") || []
|
||||
if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || []
|
||||
if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal
|
||||
if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved
|
||||
if (args.customSurchargePercentage !== undefined) payload.customSurchargePercentage = numberArg(args, "customSurchargePercentage") || 0
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const assertNoManualDocumentNumber = (args: Record<string, unknown>) => {
|
||||
if (args.documentNumber !== undefined || args.assignDocumentNumber !== undefined) {
|
||||
throw new Error("Belegnummern werden nur beim Finalisieren vergeben")
|
||||
}
|
||||
}
|
||||
|
||||
const incomingInvoiceAccountsArg = (args: Record<string, unknown>) => {
|
||||
if (args.accounts === undefined) return undefined
|
||||
if (!Array.isArray(args.accounts)) throw new Error("accounts muss ein Array sein")
|
||||
return args.accounts
|
||||
}
|
||||
|
||||
const buildIncomingInvoicePayload = (
|
||||
args: Record<string, unknown>,
|
||||
userId: string,
|
||||
tenantId: number,
|
||||
includeCreateDefaults = false
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
}
|
||||
|
||||
if (includeCreateDefaults) {
|
||||
payload.tenant = tenantId
|
||||
payload.state = "Entwurf"
|
||||
payload.expense = args.expense !== undefined ? args.expense === true : true
|
||||
payload.paid = false
|
||||
payload.archived = false
|
||||
}
|
||||
|
||||
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
|
||||
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
||||
}
|
||||
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
|
||||
if (args.document !== undefined) payload.document = numberArg(args, "document")
|
||||
if (args.expense !== undefined) payload.expense = args.expense === true
|
||||
if (args.paid !== undefined) payload.paid = args.paid === true
|
||||
|
||||
const accountsPayload = incomingInvoiceAccountsArg(args)
|
||||
if (accountsPayload !== undefined) payload.accounts = accountsPayload
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const isDepreciationBookingMode = (value: unknown) =>
|
||||
["depreciation", "depreciation_bundle"].includes(String(value || ""))
|
||||
|
||||
const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
|
||||
const errors: Array<{ message: string; type: "breaking" | "warning" }> = []
|
||||
|
||||
if (!invoice.vendor) errors.push({ message: "Es ist kein Lieferant ausgewählt", type: "breaking" })
|
||||
if (!String(invoice.reference || "").trim()) errors.push({ message: "Es ist keine Referenz angegeben", type: "breaking" })
|
||||
if (!invoice.date) errors.push({ message: "Es ist kein Datum ausgewählt", type: "breaking" })
|
||||
if (!Array.isArray(invoice.accounts) || invoice.accounts.length === 0) {
|
||||
errors.push({ message: "Es ist keine Position vorhanden", type: "breaking" })
|
||||
}
|
||||
|
||||
;(Array.isArray(invoice.accounts) ? invoice.accounts : []).forEach((account: any, idx: number) => {
|
||||
const pos = idx + 1
|
||||
|
||||
if (!account?.account) errors.push({ message: `Pos ${pos}: Keine Kategorie`, type: "breaking" })
|
||||
if (!hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountGross)) {
|
||||
errors.push({ message: `Pos ${pos}: Kein gültiger Betrag`, type: "breaking" })
|
||||
}
|
||||
if (!account?.taxType) errors.push({ message: `Pos ${pos}: Kein Steuerschlüssel`, type: "breaking" })
|
||||
if (hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountTax)) {
|
||||
errors.push({ message: `Pos ${pos}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning" })
|
||||
}
|
||||
if (isDepreciationBookingMode(account?.bookingMode) && !Number(account?.depreciationMonths)) {
|
||||
errors.push({ message: `Pos ${pos}: Abschreibungsdauer fehlt`, type: "breaking" })
|
||||
}
|
||||
if (account?.bookingMode === "depreciation_bundle" && !String(account?.depreciationGroup || "").trim()) {
|
||||
errors.push({ message: `Pos ${pos}: Sammelposten benötigt einen Gruppennamen`, type: "breaking" })
|
||||
}
|
||||
})
|
||||
|
||||
const order = { breaking: 0, warning: 1 }
|
||||
errors.sort((a, b) => order[a.type] - order[b.type])
|
||||
|
||||
return {
|
||||
valid: errors.every((error) => error.type !== "breaking"),
|
||||
errors,
|
||||
blockingErrors: errors.filter((error) => error.type === "breaking"),
|
||||
warnings: errors.filter((error) => error.type === "warning"),
|
||||
}
|
||||
}
|
||||
|
||||
export const accountingTools: McpTool[] = [
|
||||
{
|
||||
name: "accounting.outgoing_documents.tax_types.list",
|
||||
title: "Steuertypen für Ausgangsbelege auflisten",
|
||||
description: "Listet die unterstützten Dokument-Steuertypen für Ausgangsbelege.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
async handler() {
|
||||
return {
|
||||
rows: [
|
||||
{ key: "Standard", label: "Standard", forcesZeroTaxPercent: false },
|
||||
{ key: "13b UStG", label: "13b UStG", forcesZeroTaxPercent: true },
|
||||
{ key: "19 UStG", label: "19 UStG Kleinunternehmer", forcesZeroTaxPercent: true },
|
||||
{ key: "12.3 UStG", label: "12.3 UStG", forcesZeroTaxPercent: true },
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.list",
|
||||
title: "Ausgangsbelege auflisten",
|
||||
description: "Listet Ausgangsbelege des aktiven Mandanten mit optionalen Filtern.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", description: "Belegart, z. B. invoices, quotes oder deliveryNotes." },
|
||||
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
||||
customer: { type: "number" },
|
||||
project: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(createddocuments.tenant, context.tenantId)]
|
||||
const type = stringArg(args, "type")
|
||||
const state = stringArg(args, "state")
|
||||
const customer = numberArg(args, "customer")
|
||||
const project = numberArg(args, "project")
|
||||
|
||||
if (type) {
|
||||
if (!allowedOutgoingDocumentTypes.has(type)) throw new Error(`Ungültige Belegart: ${type}`)
|
||||
conditions.push(eq(createddocuments.type, type))
|
||||
}
|
||||
if (state) conditions.push(eq(createddocuments.state, state))
|
||||
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
||||
if (project) conditions.push(eq(createddocuments.project, project))
|
||||
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(createddocuments.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.get",
|
||||
title: "Ausgangsbeleg laden",
|
||||
description: "Lädt einen Ausgangsbeleg des aktiven Mandanten anhand seiner ID.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
return { document: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.create",
|
||||
title: "Ausgangsbeleg erstellen",
|
||||
description: "Erstellt einen Ausgangsbeleg-Entwurf im aktiven Mandanten. Belegnummern werden erst beim Finalisieren vergeben.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["type"],
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
customer: { type: "number" },
|
||||
contact: { type: "number" },
|
||||
contract: { type: "number" },
|
||||
project: { type: "number" },
|
||||
plant: { type: "number" },
|
||||
documentDate: { type: "string" },
|
||||
deliveryDate: { type: "string" },
|
||||
deliveryDateEnd: { type: "string" },
|
||||
deliveryDateType: { type: "string" },
|
||||
paymentDays: { type: "number" },
|
||||
payment_type: { type: "string" },
|
||||
taxType: {
|
||||
type: "string",
|
||||
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
||||
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
||||
},
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
startText: { type: "string" },
|
||||
endText: { type: "string" },
|
||||
address: { type: "object" },
|
||||
rows: { type: "array" },
|
||||
letterhead: { type: "number" },
|
||||
availableInPortal: { type: "boolean" },
|
||||
customSurchargePercentage: { type: "number" },
|
||||
report: { type: "object" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
assertNoManualDocumentNumber(args)
|
||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
||||
payload.state = "Entwurf"
|
||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
||||
|
||||
const [created] = await context.server.db
|
||||
.insert(createddocuments)
|
||||
.values(payload as any)
|
||||
.returning()
|
||||
|
||||
return { document: created }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.update",
|
||||
title: "Ausgangsbeleg aktualisieren",
|
||||
description: "Aktualisiert einen Ausgangsbeleg im aktiven Mandanten, solange er noch nicht finalisiert ist.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
type: { type: "string" },
|
||||
state: { type: "string" },
|
||||
customer: { type: "number" },
|
||||
contact: { type: "number" },
|
||||
contract: { type: "number" },
|
||||
project: { type: "number" },
|
||||
plant: { type: "number" },
|
||||
documentDate: { type: "string" },
|
||||
deliveryDate: { type: "string" },
|
||||
deliveryDateEnd: { type: "string" },
|
||||
deliveryDateType: { type: "string" },
|
||||
paymentDays: { type: "number" },
|
||||
payment_type: { type: "string" },
|
||||
taxType: {
|
||||
type: "string",
|
||||
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
||||
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
||||
},
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
startText: { type: "string" },
|
||||
endText: { type: "string" },
|
||||
address: { type: "object" },
|
||||
rows: { type: "array" },
|
||||
letterhead: { type: "number" },
|
||||
availableInPortal: { type: "boolean" },
|
||||
customSurchargePercentage: { type: "number" },
|
||||
report: { type: "object" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
assertNoManualDocumentNumber(args)
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
|
||||
|
||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||
payload.state = "Entwurf"
|
||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(createddocuments)
|
||||
.set(payload as any)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.finalize",
|
||||
title: "Ausgangsbeleg finalisieren",
|
||||
description: "Finalisiert einen Ausgangsbeleg, setzt den Status auf Gebucht und vergibt dabei genau einmal eine Belegnummer.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
documentDate: { type: "string" },
|
||||
deliveryDate: { type: "string" },
|
||||
deliveryDateEnd: { type: "string" },
|
||||
paymentDays: { type: "number" },
|
||||
payment_type: { type: "string" },
|
||||
taxType: {
|
||||
type: "string",
|
||||
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
||||
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
||||
},
|
||||
rows: { type: "array" },
|
||||
report: { type: "object" },
|
||||
availableInPortal: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
assertNoManualDocumentNumber(args)
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
||||
|
||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
||||
|
||||
payload.state = "Gebucht"
|
||||
payload.documentNumber = result.usedNumber
|
||||
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(createddocuments)
|
||||
.set(payload as any)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.archive",
|
||||
title: "Ausgangsbeleg archivieren",
|
||||
description: "Archiviert einen Ausgangsbeleg im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(createddocuments)
|
||||
.set({
|
||||
archived: true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.accounts.search",
|
||||
title: "Konten suchen",
|
||||
description: "Sucht Sachkonten im aktiven Kontenrahmen des Mandanten.",
|
||||
requiredPermissions: ["accounting.accounts.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const tenantRows = await context.server.db.query.tenants.findMany({
|
||||
where: (tenant, { eq }) => eq(tenant.id, context.tenantId),
|
||||
columns: {
|
||||
accountChart: true,
|
||||
},
|
||||
limit: 1,
|
||||
})
|
||||
const accountChart = tenantRows[0]?.accountChart || "skr03"
|
||||
const query = stringArg(args, "query")
|
||||
const limit = limitFromArgs(args)
|
||||
|
||||
const whereCond = query
|
||||
? and(
|
||||
eq(accounts.accountChart, accountChart),
|
||||
or(
|
||||
ilike(accounts.number, `%${query}%`),
|
||||
ilike(accounts.label, `%${query}%`),
|
||||
ilike(accounts.description, `%${query}%`)
|
||||
)
|
||||
)
|
||||
: eq(accounts.accountChart, accountChart)
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(whereCond)
|
||||
.orderBy(accounts.number)
|
||||
.limit(limit)
|
||||
|
||||
return { accountChart, rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.list",
|
||||
title: "Eingangsrechnungen auflisten",
|
||||
description: "Listet Eingangsrechnungen des aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
state: { type: "string", description: "Optionaler Statusfilter." },
|
||||
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(incominginvoices.tenant, context.tenantId)]
|
||||
const state = stringArg(args, "state")
|
||||
|
||||
if (state) conditions.push(eq(incominginvoices.state, state))
|
||||
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
||||
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(incominginvoices.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.get",
|
||||
title: "Eingangsrechnung laden",
|
||||
description: "Lädt eine Eingangsrechnung des aktiven Mandanten anhand ihrer ID.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
||||
return { invoice: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.files.list",
|
||||
title: "Dateien eines Eingangsbelegs auflisten",
|
||||
description: "Listet Dateien, die mit einem Eingangsbeleg im aktiven Mandanten verknüpft sind.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(
|
||||
eq(files.incominginvoice, id),
|
||||
eq(files.tenant, context.tenantId),
|
||||
eq(files.archived, false)
|
||||
))
|
||||
.orderBy(desc(files.createdAt))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.validate",
|
||||
title: "Eingangsbeleg validieren",
|
||||
description: "Prüft einen Eingangsbeleg mit denselben Pflichtregeln wie die FEDEO-Oberfläche vor dem Buchen.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.create",
|
||||
title: "Eingangsbeleg erstellen",
|
||||
description: "Erstellt einen Eingangsbeleg-Entwurf im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
vendor: { type: "number" },
|
||||
reference: { type: "string" },
|
||||
date: { type: "string" },
|
||||
dueDate: { type: "string" },
|
||||
document: { type: "number" },
|
||||
description: { type: "string" },
|
||||
paymentType: { type: "string" },
|
||||
accounts: { type: "array" },
|
||||
expense: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId, true)
|
||||
payload.state = "Entwurf"
|
||||
|
||||
const [created] = await context.server.db
|
||||
.insert(incominginvoices)
|
||||
.values(payload as any)
|
||||
.returning()
|
||||
|
||||
return { invoice: created, validation: validateIncomingInvoiceData(created as Record<string, any>) }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.update",
|
||||
title: "Eingangsbeleg bearbeiten",
|
||||
description: "Bearbeitet einen noch nicht gebuchten Eingangsbeleg im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
vendor: { type: "number" },
|
||||
reference: { type: "string" },
|
||||
date: { type: "string" },
|
||||
dueDate: { type: "string" },
|
||||
document: { type: "number" },
|
||||
description: { type: "string" },
|
||||
paymentType: { type: "string" },
|
||||
accounts: { type: "array" },
|
||||
expense: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
if (existing.state === "Gebucht") throw new Error("Gebuchte Eingangsbelege können nicht über update geändert werden")
|
||||
|
||||
const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId)
|
||||
payload.state = existing.state || "Entwurf"
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(incominginvoices)
|
||||
.set(payload as any)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.book",
|
||||
title: "Eingangsbeleg buchen",
|
||||
description: "Validiert und bucht einen vorbereiteten oder als Entwurf gespeicherten Eingangsbeleg.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
if (existing.state === "Gebucht") return { invoice: existing, validation: validateIncomingInvoiceData(existing as Record<string, any>) }
|
||||
|
||||
const validation = validateIncomingInvoiceData(existing as Record<string, any>)
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
booked: false,
|
||||
invoice: existing,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(incominginvoices)
|
||||
.set({
|
||||
state: "Gebucht",
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
booked: true,
|
||||
invoice: updated,
|
||||
validation: validateIncomingInvoiceData(updated as Record<string, any>),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.incoming_invoices.archive",
|
||||
title: "Eingangsbeleg archivieren",
|
||||
description: "Archiviert einen Eingangsbeleg im aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(incominginvoices)
|
||||
.set({
|
||||
archived: true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
return { invoice: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.bank_statements.list",
|
||||
title: "Bankumsätze auflisten",
|
||||
description: "Listet Bankumsätze des aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.bank.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(bankstatements.tenant, context.tenantId)]
|
||||
const account = numberArg(args, "account")
|
||||
|
||||
if (account) conditions.push(eq(bankstatements.account, account))
|
||||
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.bank_statements.get",
|
||||
title: "Bankumsatz laden",
|
||||
description: "Lädt einen Bankumsatz des aktiven Mandanten anhand seiner ID.",
|
||||
requiredPermissions: ["accounting.bank.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
||||
return { bankStatement: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.statement_allocations.list",
|
||||
title: "Buchungszuordnungen auflisten",
|
||||
description: "Listet Buchungszuordnungen des aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.statement_allocations.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bankstatement: { type: "number" },
|
||||
incominginvoice: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(statementallocations.tenant, context.tenantId)]
|
||||
const bankstatement = numberArg(args, "bankstatement")
|
||||
const incominginvoice = numberArg(args, "incominginvoice")
|
||||
|
||||
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
||||
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
||||
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(statementallocations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(statementallocations.created_at))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
]
|
||||
571
backend/src/mcp/tools/masterdata.ts
Normal file
571
backend/src/mcp/tools/masterdata.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import {
|
||||
branches,
|
||||
contacts,
|
||||
costcentres,
|
||||
customers,
|
||||
inventoryitems,
|
||||
products,
|
||||
services,
|
||||
teams,
|
||||
units,
|
||||
vehicles,
|
||||
vendors,
|
||||
} from "../../../db/schema"
|
||||
import { McpTool } from "../types"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||
}
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = Number(args[key])
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
const uuidArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = stringArg(args, key)
|
||||
return value && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
export const masterdataTools: McpTool[] = [
|
||||
{
|
||||
name: "masterdata.customers.get",
|
||||
title: "Kunde laden",
|
||||
description: "Lädt einen Kunden des aktiven Mandanten anhand seiner ID.",
|
||||
requiredPermissions: ["masterdata.customers.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "number" } },
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
||||
return { customer: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.vendors.search",
|
||||
title: "Lieferanten suchen",
|
||||
description: "Sucht Lieferanten des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.vendors.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(vendors.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(vendors.name, `%${query}%`),
|
||||
ilike(vendors.vendorNumber, `%${query}%`),
|
||||
ilike(vendors.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vendors)
|
||||
.where(and(...conditions))
|
||||
.orderBy(vendors.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.vendors.get",
|
||||
title: "Lieferant laden",
|
||||
description: "Lädt einen Lieferanten des aktiven Mandanten anhand seiner ID.",
|
||||
requiredPermissions: ["masterdata.vendors.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "number" } },
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vendors)
|
||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
||||
return { vendor: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.contacts.search",
|
||||
title: "Kontakte suchen",
|
||||
description: "Sucht Kontakte des aktiven Mandanten, optional zu Kunde oder Lieferant.",
|
||||
requiredPermissions: ["masterdata.contacts.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||
customer: { type: "number" },
|
||||
vendor: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(contacts.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const customer = numberArg(args, "customer")
|
||||
const vendor = numberArg(args, "vendor")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(contacts.fullName, `%${query}%`),
|
||||
ilike(contacts.firstName, `%${query}%`),
|
||||
ilike(contacts.lastName, `%${query}%`),
|
||||
ilike(contacts.email, `%${query}%`),
|
||||
ilike(contacts.phoneMobile, `%${query}%`),
|
||||
ilike(contacts.phoneHome, `%${query}%`),
|
||||
ilike(contacts.role, `%${query}%`),
|
||||
ilike(contacts.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (customer) conditions.push(eq(contacts.customer, customer))
|
||||
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
||||
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(...conditions))
|
||||
.orderBy(contacts.fullName)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.products.search",
|
||||
title: "Artikel suchen",
|
||||
description: "Sucht Artikel und Materialstammdaten des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.products.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(products.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(products.name, `%${query}%`),
|
||||
ilike(products.article_number, `%${query}%`),
|
||||
ilike(products.manufacturer, `%${query}%`),
|
||||
ilike(products.manufacturer_number, `%${query}%`),
|
||||
ilike(products.ean, `%${query}%`),
|
||||
ilike(products.barcode, `%${query}%`),
|
||||
ilike(products.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(...conditions))
|
||||
.orderBy(products.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.products.get",
|
||||
title: "Artikel laden",
|
||||
description: "Lädt einen Artikel des aktiven Mandanten anhand seiner ID.",
|
||||
requiredPermissions: ["masterdata.products.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "number" } },
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
||||
return { product: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.services.search",
|
||||
title: "Leistungen suchen",
|
||||
description: "Sucht Leistungsstammdaten des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.services.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(services.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(services.name, `%${query}%`),
|
||||
ilike(services.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(...conditions))
|
||||
.orderBy(services.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.services.get",
|
||||
title: "Leistung laden",
|
||||
description: "Lädt eine Leistung des aktiven Mandanten anhand ihrer ID.",
|
||||
requiredPermissions: ["masterdata.services.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "number" } },
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
||||
return { service: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.cost_centres.list",
|
||||
title: "Kostenstellen auflisten",
|
||||
description: "Listet Kostenstellen des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.cost_centres.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||
branch: { type: "number" },
|
||||
project: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(costcentres.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const branch = numberArg(args, "branch")
|
||||
const project = numberArg(args, "project")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(costcentres.number, `%${query}%`),
|
||||
ilike(costcentres.name, `%${query}%`),
|
||||
ilike(costcentres.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (branch) conditions.push(eq(costcentres.branch, branch))
|
||||
if (project) conditions.push(eq(costcentres.project, project))
|
||||
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(costcentres)
|
||||
.where(and(...conditions))
|
||||
.orderBy(costcentres.number)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.cost_centres.get",
|
||||
title: "Kostenstelle laden",
|
||||
description: "Lädt eine Kostenstelle des aktiven Mandanten anhand ihrer UUID.",
|
||||
requiredPermissions: ["masterdata.cost_centres.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "string" } },
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = uuidArg(args, "id")
|
||||
if (!id) throw new Error("gültige id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(costcentres)
|
||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
||||
return { costCentre: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.branches.list",
|
||||
title: "Niederlassungen auflisten",
|
||||
description: "Listet Niederlassungen des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.branches.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(branches.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(branches.number, `%${query}%`),
|
||||
ilike(branches.name, `%${query}%`),
|
||||
ilike(branches.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(branches)
|
||||
.where(and(...conditions))
|
||||
.orderBy(branches.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.teams.list",
|
||||
title: "Teams auflisten",
|
||||
description: "Listet Teams des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.teams.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||
branch: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(teams.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const branch = numberArg(args, "branch")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(teams.name, `%${query}%`),
|
||||
ilike(teams.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (branch) conditions.push(eq(teams.branch, branch))
|
||||
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(teams)
|
||||
.where(and(...conditions))
|
||||
.orderBy(teams.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.vehicles.list",
|
||||
title: "Fahrzeuge auflisten",
|
||||
description: "Listet Fahrzeuge des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.vehicles.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(vehicles.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(vehicles.name, `%${query}%`),
|
||||
ilike(vehicles.license_plate, `%${query}%`),
|
||||
ilike(vehicles.vin, `%${query}%`),
|
||||
ilike(vehicles.color, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(vehicles.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.inventory_items.search",
|
||||
title: "Inventar suchen",
|
||||
description: "Sucht Inventar- und Geräte-Stammdaten des aktiven Mandanten.",
|
||||
requiredPermissions: ["masterdata.inventory_items.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||
vendor: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(inventoryitems.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const vendor = numberArg(args, "vendor")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(inventoryitems.name, `%${query}%`),
|
||||
ilike(inventoryitems.articleNumber, `%${query}%`),
|
||||
ilike(inventoryitems.serialNumber, `%${query}%`),
|
||||
ilike(inventoryitems.manufacturer, `%${query}%`),
|
||||
ilike(inventoryitems.manufacturerNumber, `%${query}%`),
|
||||
ilike(inventoryitems.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
||||
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(inventoryitems)
|
||||
.where(and(...conditions))
|
||||
.orderBy(inventoryitems.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.inventory_items.get",
|
||||
title: "Inventar laden",
|
||||
description: "Lädt einen Inventar- oder Geräte-Stammdatensatz anhand seiner ID.",
|
||||
requiredPermissions: ["masterdata.inventory_items.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "number" } },
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(inventoryitems)
|
||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
||||
return { inventoryItem: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "masterdata.units.list",
|
||||
title: "Einheiten auflisten",
|
||||
description: "Listet globale Mengeneinheiten.",
|
||||
requiredPermissions: ["masterdata.units.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(units)
|
||||
.where(query
|
||||
? or(
|
||||
ilike(units.name, `%${query}%`),
|
||||
ilike(units.single, `%${query}%`),
|
||||
ilike(units.multiple, `%${query}%`),
|
||||
ilike(units.short, `%${query}%`)
|
||||
)
|
||||
: undefined)
|
||||
.orderBy(units.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
407
backend/src/mcp/tools/organisation.ts
Normal file
407
backend/src/mcp/tools/organisation.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||
import { McpTool } from "../types"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||
}
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = Number(args[key])
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export const organisationTools: McpTool[] = [
|
||||
{
|
||||
name: "organisation.customers.search",
|
||||
title: "Kunden suchen",
|
||||
description: "Sucht aktive Kunden des aktiven Mandanten.",
|
||||
requiredPermissions: ["organisation.customers.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(customers.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(customers.name, `%${query}%`),
|
||||
ilike(customers.customerNumber, `%${query}%`),
|
||||
ilike(customers.firstname, `%${query}%`),
|
||||
ilike(customers.lastname, `%${query}%`),
|
||||
ilike(customers.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select({
|
||||
id: customers.id,
|
||||
customerNumber: customers.customerNumber,
|
||||
name: customers.name,
|
||||
firstname: customers.firstname,
|
||||
lastname: customers.lastname,
|
||||
type: customers.type,
|
||||
isCompany: customers.isCompany,
|
||||
active: customers.active,
|
||||
archived: customers.archived,
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(...conditions))
|
||||
.orderBy(customers.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.projects.list",
|
||||
title: "Projekte auflisten",
|
||||
description: "Listet Projekte des aktiven Mandanten mit optionalen Filtern.",
|
||||
requiredPermissions: ["organisation.projects.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||
customer: { type: "number" },
|
||||
activePhase: { type: "string" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(projects.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const customer = numberArg(args, "customer")
|
||||
const activePhase = stringArg(args, "activePhase")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(projects.name, `%${query}%`),
|
||||
ilike(projects.projectNumber, `%${query}%`),
|
||||
ilike(projects.customerRef, `%${query}%`),
|
||||
ilike(projects.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (customer) conditions.push(eq(projects.customer, customer))
|
||||
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
||||
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(projects.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.projects.get",
|
||||
title: "Projekt laden",
|
||||
description: "Lädt ein Projekt des aktiven Mandanten anhand seiner ID.",
|
||||
requiredPermissions: ["organisation.projects.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
||||
return { project: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.plants.list",
|
||||
title: "Anlagen auflisten",
|
||||
description: "Listet Anlagen des aktiven Mandanten mit optionalem Kundenfilter.",
|
||||
requiredPermissions: ["organisation.plants.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name." },
|
||||
customer: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(plants.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const customer = numberArg(args, "customer")
|
||||
|
||||
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
||||
if (customer) conditions.push(eq(plants.customer, customer))
|
||||
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(plants)
|
||||
.where(and(...conditions))
|
||||
.orderBy(plants.name)
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.events.list",
|
||||
title: "Termine auflisten",
|
||||
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
|
||||
requiredPermissions: ["organisation.events.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
eventtype: { type: "string" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(events.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const project = numberArg(args, "project")
|
||||
const customer = numberArg(args, "customer")
|
||||
const eventtype = stringArg(args, "eventtype")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(events.name, `%${query}%`),
|
||||
ilike(events.notes, `%${query}%`),
|
||||
ilike(events.link, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (project) conditions.push(eq(events.project, project))
|
||||
if (customer) conditions.push(eq(events.customer, customer))
|
||||
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(events.startDate))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.tasks.list",
|
||||
title: "Aufgaben auflisten",
|
||||
description: "Listet Aufgaben des aktiven Mandanten mit optionalen Filtern.",
|
||||
requiredPermissions: ["organisation.tasks.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(tasks.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const project = numberArg(args, "project")
|
||||
const customer = numberArg(args, "customer")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(tasks.name, `%${query}%`),
|
||||
ilike(tasks.description, `%${query}%`),
|
||||
ilike(tasks.categorie, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (project) conditions.push(eq(tasks.project, project))
|
||||
if (customer) conditions.push(eq(tasks.customer, customer))
|
||||
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(tasks.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.tasks.get",
|
||||
title: "Aufgabe laden",
|
||||
description: "Lädt eine Aufgabe des aktiven Mandanten anhand ihrer ID.",
|
||||
requiredPermissions: ["organisation.tasks.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
||||
return { task: rows[0] }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.tasks.create",
|
||||
title: "Aufgabe erstellen",
|
||||
description: "Erstellt eine neue Aufgabe im aktiven Mandanten.",
|
||||
requiredPermissions: ["organisation.tasks.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
categorie: { type: "string" },
|
||||
project: { type: "number" },
|
||||
plant: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
userId: { type: "string" },
|
||||
profiles: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const name = stringArg(args, "name")
|
||||
if (!name) throw new Error("name ist erforderlich")
|
||||
|
||||
const [created] = await context.server.db
|
||||
.insert(tasks)
|
||||
.values({
|
||||
name,
|
||||
description: stringArg(args, "description"),
|
||||
categorie: stringArg(args, "categorie"),
|
||||
tenant: context.tenantId,
|
||||
userId: stringArg(args, "userId") || context.userId,
|
||||
project: numberArg(args, "project"),
|
||||
plant: numberArg(args, "plant"),
|
||||
customer: numberArg(args, "customer"),
|
||||
profiles: Array.isArray(args.profiles) ? args.profiles : [],
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return { task: created }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.tasks.update",
|
||||
title: "Aufgabe aktualisieren",
|
||||
description: "Aktualisiert Felder einer Aufgabe im aktiven Mandanten.",
|
||||
requiredPermissions: ["organisation.tasks.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
categorie: { type: "string" },
|
||||
project: { type: "number" },
|
||||
plant: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
userId: { type: "string" },
|
||||
profiles: { type: "array", items: { type: "string" } },
|
||||
archived: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
}
|
||||
|
||||
for (const key of ["name", "description", "categorie", "userId"] as const) {
|
||||
if (args[key] !== undefined) update[key] = stringArg(args, key)
|
||||
}
|
||||
for (const key of ["project", "plant", "customer"] as const) {
|
||||
if (args[key] !== undefined) update[key] = numberArg(args, key)
|
||||
}
|
||||
if (Array.isArray(args.profiles)) update.profiles = args.profiles
|
||||
if (typeof args.archived === "boolean") update.archived = args.archived
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(tasks)
|
||||
.set(update)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw new Error("Aufgabe nicht gefunden")
|
||||
return { task: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.tasks.archive",
|
||||
title: "Aufgabe archivieren",
|
||||
description: "Archiviert eine Aufgabe im aktiven Mandanten.",
|
||||
requiredPermissions: ["organisation.tasks.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const [updated] = await context.server.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
archived: true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: context.userId,
|
||||
})
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw new Error("Aufgabe nicht gefunden")
|
||||
return { task: updated }
|
||||
},
|
||||
},
|
||||
]
|
||||
36
backend/src/mcp/types.ts
Normal file
36
backend/src/mcp/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||
|
||||
export type McpContext = {
|
||||
server: FastifyInstance
|
||||
request: FastifyRequest
|
||||
tenantId: number
|
||||
userId: string
|
||||
isAdmin: boolean
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export type McpToolResult = {
|
||||
content: Array<{
|
||||
type: "text"
|
||||
text: string
|
||||
}>
|
||||
structuredContent?: Record<string, unknown>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
requiredPermissions: string[]
|
||||
inputSchema: Record<string, unknown>
|
||||
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type JsonRpcRequest = {
|
||||
jsonrpc?: string
|
||||
id?: string | number | null
|
||||
method?: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ export type DerivedSpan = {
|
||||
sourceEventIds: string[];
|
||||
status: SpanStatus;
|
||||
statusActorId?: string;
|
||||
payload?: Record<string, any> | null;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type TimeEvent = {
|
||||
id: string;
|
||||
eventtype: string;
|
||||
eventtime: Date;
|
||||
payload?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
||||
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
let currentStart: Date | null = null;
|
||||
let currentType: DerivedSpan["type"] | null = null;
|
||||
let sourceEventIds: string[] = [];
|
||||
let currentPayload: Record<string, any> | null = null;
|
||||
|
||||
const closeSpan = (end: Date) => {
|
||||
if (!currentStart || !currentType) return;
|
||||
if (end.getTime() <= currentStart.getTime()) {
|
||||
currentStart = null;
|
||||
currentType = null;
|
||||
sourceEventIds = [];
|
||||
currentPayload = null;
|
||||
return;
|
||||
}
|
||||
|
||||
spans.push({
|
||||
type: currentType,
|
||||
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
endedAt: end,
|
||||
sourceEventIds: [...sourceEventIds],
|
||||
// Standardstatus ist "factual", wird später angereichert
|
||||
status: "factual"
|
||||
status: "factual",
|
||||
payload: currentPayload,
|
||||
description: currentPayload?.description || ""
|
||||
});
|
||||
|
||||
currentStart = null;
|
||||
currentType = null;
|
||||
sourceEventIds = [];
|
||||
currentPayload = null;
|
||||
};
|
||||
|
||||
const closeOpenSpanAsRunning = () => {
|
||||
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
endedAt: null,
|
||||
sourceEventIds: [...sourceEventIds],
|
||||
// Standardstatus ist "factual", wird später angereichert
|
||||
status: "factual"
|
||||
status: "factual",
|
||||
payload: currentPayload,
|
||||
description: currentPayload?.description || ""
|
||||
});
|
||||
|
||||
currentStart = null;
|
||||
currentType = null;
|
||||
sourceEventIds = [];
|
||||
currentPayload = null;
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
state = "WORKING";
|
||||
currentStart = event.eventtime;
|
||||
currentType = "work";
|
||||
currentPayload = event.payload || null;
|
||||
break;
|
||||
|
||||
case "pause_start":
|
||||
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
state = "PAUSED";
|
||||
currentStart = event.eventtime;
|
||||
currentType = "pause";
|
||||
currentPayload = event.payload || null;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
state = "WORKING";
|
||||
currentStart = event.eventtime;
|
||||
currentType = "work";
|
||||
currentPayload = event.payload || null;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
state = "ABSENT";
|
||||
currentStart = event.eventtime;
|
||||
currentType = newType;
|
||||
currentPayload = event.payload || null;
|
||||
break;
|
||||
|
||||
case "vacation_end":
|
||||
@@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/loadValidEvents.ts
|
||||
|
||||
import { stafftimeevents } from "../../../db/schema";
|
||||
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
|
||||
import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
||||
@@ -12,11 +12,43 @@ export type TimeEvent = {
|
||||
id: string;
|
||||
eventtype: string;
|
||||
eventtime: Date;
|
||||
actoruser_id: string;
|
||||
actoruser_id?: string;
|
||||
related_event_id: string | null;
|
||||
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
|
||||
payload?: Record<string, any> | null;
|
||||
created_at?: Date | null;
|
||||
};
|
||||
|
||||
const EVENT_TYPE_ORDER: Record<string, number> = {
|
||||
auto_stop: 10,
|
||||
work_end: 10,
|
||||
pause_end: 10,
|
||||
vacation_end: 10,
|
||||
sick_end: 10,
|
||||
overtime_compensation_end: 10,
|
||||
work_start: 20,
|
||||
pause_start: 20,
|
||||
vacation_start: 20,
|
||||
sick_start: 20,
|
||||
overtime_compensation_start: 20,
|
||||
submitted: 30,
|
||||
approved: 30,
|
||||
rejected: 30,
|
||||
invalidated: 40,
|
||||
};
|
||||
|
||||
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
|
||||
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
|
||||
if (eventTimeDiff !== 0) return eventTimeDiff;
|
||||
|
||||
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
|
||||
if (typeOrderDiff !== 0) return typeOrderDiff;
|
||||
|
||||
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
|
||||
if (createdAtDiff !== 0) return createdAtDiff;
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
export async function loadValidEvents(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
@@ -62,10 +94,9 @@ export async function loadValidEvents(
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
|
||||
baseEvents.eventtime,
|
||||
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
|
||||
baseEvents.id
|
||||
asc(baseEvents.eventtime),
|
||||
asc(baseEvents.created_at),
|
||||
asc(baseEvents.id)
|
||||
);
|
||||
|
||||
// Mapping auf den sauberen TimeEvent Typ
|
||||
@@ -73,8 +104,10 @@ export async function loadValidEvents(
|
||||
id: e.id,
|
||||
eventtype: e.eventtype,
|
||||
eventtime: e.eventtime,
|
||||
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
|
||||
// ...
|
||||
actoruser_id: e.actoruser_id,
|
||||
related_event_id: e.related_event_id,
|
||||
payload: e.payload,
|
||||
created_at: e.created_at,
|
||||
})) as TimeEvent[];
|
||||
}
|
||||
|
||||
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
|
||||
)
|
||||
)
|
||||
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
||||
.orderBy(stafftimeevents.eventtime);
|
||||
.orderBy(
|
||||
asc(stafftimeevents.eventtime),
|
||||
asc(stafftimeevents.created_at),
|
||||
asc(stafftimeevents.id)
|
||||
);
|
||||
|
||||
return adminEvents;
|
||||
}
|
||||
return adminEvents as TimeEvent[];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
authUsers,
|
||||
} from "../../db/schema"
|
||||
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { eq, and, inArray } from "drizzle-orm"
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
@@ -63,10 +63,12 @@ export default fp(async (server: FastifyInstance) => {
|
||||
const userId = req.user.user_id
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 3️⃣ Rolle des Nutzers im Tenant holen
|
||||
// 3️⃣ Rollen des Nutzers im Tenant holen
|
||||
// --------------------------------------------------------
|
||||
const roleRows = await server.db
|
||||
.select()
|
||||
.select({
|
||||
role_id: authUserRoles.role_id,
|
||||
})
|
||||
.from(authUserRoles)
|
||||
.where(
|
||||
and(
|
||||
@@ -74,7 +76,6 @@ export default fp(async (server: FastifyInstance) => {
|
||||
eq(authUserRoles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (roleRows.length === 0) {
|
||||
if (req.user.is_admin) {
|
||||
@@ -89,22 +90,22 @@ export default fp(async (server: FastifyInstance) => {
|
||||
.send({ error: "No role assigned for this tenant" })
|
||||
}
|
||||
|
||||
const roleId = roleRows[0].role_id
|
||||
const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id)))
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 4️⃣ Berechtigungen der Rolle laden
|
||||
// 4️⃣ Berechtigungen der Rollen laden
|
||||
// --------------------------------------------------------
|
||||
const permissionRows = await server.db
|
||||
.select()
|
||||
.from(authRolePermissions)
|
||||
.where(eq(authRolePermissions.role_id, roleId))
|
||||
.where(inArray(authRolePermissions.role_id, roleIds))
|
||||
|
||||
const permissions = permissionRows.map((p) => p.permission)
|
||||
const permissions = Array.from(new Set(permissionRows.map((p) => p.permission)))
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 5️⃣ An Request hängen für spätere Nutzung
|
||||
// --------------------------------------------------------
|
||||
req.role = roleId
|
||||
req.role = roleIds[0]
|
||||
req.permissions = permissions
|
||||
req.hasPermission = (perm: string) => permissions.includes(perm)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import {
|
||||
authTenantUsers,
|
||||
authProfiles,
|
||||
customers,
|
||||
authRoles,
|
||||
authUserRoles,
|
||||
authUsers,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||
import { sendMail } from "../utils/mailer";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
const deriveNameFromEmail = (email: string) => {
|
||||
@@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return currentUser;
|
||||
};
|
||||
|
||||
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||
const existingRoles = await server.db
|
||||
.select({
|
||||
id: authRoles.id,
|
||||
name: authRoles.name,
|
||||
})
|
||||
.from(authRoles)
|
||||
.where(eq(authRoles.tenant_id, tenantId));
|
||||
|
||||
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
|
||||
if (portalRole) return portalRole.id;
|
||||
|
||||
const [createdRole] = await server.db
|
||||
.insert(authRoles)
|
||||
.values({
|
||||
name: "Kundenportal",
|
||||
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
|
||||
tenant_id: tenantId,
|
||||
created_by: createdBy,
|
||||
})
|
||||
.returning({
|
||||
id: authRoles.id,
|
||||
});
|
||||
|
||||
return createdRole.id;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/overview
|
||||
// -------------------------------------------------------------
|
||||
@@ -422,6 +451,343 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/profiles/:profileId/create-user
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/profiles/:profileId/create-user", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const { profileId } = req.params as { profileId: string };
|
||||
const body = req.body as { email?: string };
|
||||
|
||||
const email = body.email?.trim().toLowerCase();
|
||||
if (!email) {
|
||||
return reply.code(400).send({ error: "email required" });
|
||||
}
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
tenant_id: authProfiles.tenant_id,
|
||||
user_id: authProfiles.user_id,
|
||||
first_name: authProfiles.first_name,
|
||||
last_name: authProfiles.last_name,
|
||||
email: authProfiles.email,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.id, profileId))
|
||||
.limit(1);
|
||||
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "Profile not found" });
|
||||
}
|
||||
|
||||
if (profile.user_id) {
|
||||
return reply.code(409).send({ error: "Profile already linked to a user" });
|
||||
}
|
||||
|
||||
const existingUsers = await server.db
|
||||
.select({ id: authUsers.id })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingUsers.length) {
|
||||
return reply.code(409).send({ error: "User with this email already exists" });
|
||||
}
|
||||
|
||||
const initialPassword = generateRandomPassword(14);
|
||||
const passwordHash = await hashPassword(initialPassword);
|
||||
|
||||
const result = await server.db.transaction(async (tx) => {
|
||||
const [createdUser] = await tx
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
is_admin: false,
|
||||
multiTenant: true,
|
||||
must_change_password: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
is_admin: authUsers.is_admin,
|
||||
multiTenant: authUsers.multiTenant,
|
||||
created_at: authUsers.created_at,
|
||||
});
|
||||
|
||||
await tx
|
||||
.insert(authTenantUsers)
|
||||
.values({
|
||||
tenant_id: profile.tenant_id,
|
||||
user_id: createdUser.id,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
|
||||
const [updatedProfile] = await tx
|
||||
.update(authProfiles)
|
||||
.set({
|
||||
user_id: createdUser.id,
|
||||
email,
|
||||
})
|
||||
.where(eq(authProfiles.id, profile.id))
|
||||
.returning({
|
||||
id: authProfiles.id,
|
||||
tenant_id: authProfiles.tenant_id,
|
||||
user_id: authProfiles.user_id,
|
||||
first_name: authProfiles.first_name,
|
||||
last_name: authProfiles.last_name,
|
||||
email: authProfiles.email,
|
||||
});
|
||||
|
||||
return {
|
||||
user: createdUser,
|
||||
profile: updatedProfile,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
initialPassword,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/profiles/:profileId/create-user:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const tenantId = Number(req.user?.tenant_id);
|
||||
const { customerId } = req.params as { customerId: string };
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const [tenantRecord] = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
portalDomain: tenants.portalDomain,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
const [customerRecord] = await server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
|
||||
.limit(1);
|
||||
|
||||
if (!customerRecord) {
|
||||
return reply.code(404).send({ error: "Customer not found" });
|
||||
}
|
||||
|
||||
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
|
||||
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return reply.code(400).send({ error: "Customer has no email address" });
|
||||
}
|
||||
|
||||
const generatedPassword = generateRandomPassword(14);
|
||||
const passwordHash = await hashPassword(generatedPassword);
|
||||
|
||||
const [existingUser] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1);
|
||||
|
||||
const derivedName = deriveNameFromEmail(email);
|
||||
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
|
||||
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
|
||||
|
||||
let userId = existingUser?.id || null;
|
||||
let createdNewUser = false;
|
||||
|
||||
if (existingUser) {
|
||||
const [existingProfile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
customer_for_portal: authProfiles.customer_for_portal,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.user_id, existingUser.id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.is_admin) {
|
||||
return reply.code(409).send({ error: "Email address is already used by an admin user" });
|
||||
}
|
||||
|
||||
if (!existingProfile) {
|
||||
return reply.code(409).send({ error: "Email address is already used by another user" });
|
||||
}
|
||||
|
||||
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
|
||||
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash,
|
||||
must_change_password: true,
|
||||
multiTenant: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(authUsers.id, existingUser.id));
|
||||
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
const [createdUser] = await server.db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
is_admin: false,
|
||||
multiTenant: false,
|
||||
must_change_password: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
});
|
||||
|
||||
userId = createdUser.id;
|
||||
createdNewUser = true;
|
||||
}
|
||||
|
||||
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
|
||||
|
||||
const existingMemberships = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(and(
|
||||
eq(authTenantUsers.user_id, userId!),
|
||||
eq(authTenantUsers.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingMemberships.length) {
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
.values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId!,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const existingPortalRoleAssignment = await server.db
|
||||
.select()
|
||||
.from(authUserRoles)
|
||||
.where(and(
|
||||
eq(authUserRoles.user_id, userId!),
|
||||
eq(authUserRoles.tenant_id, tenantId),
|
||||
eq(authUserRoles.role_id, portalRoleId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingPortalRoleAssignment.length) {
|
||||
await server.db
|
||||
.insert(authUserRoles)
|
||||
.values({
|
||||
user_id: userId!,
|
||||
tenant_id: tenantId,
|
||||
role_id: portalRoleId,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const [existingTenantProfile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
user_id: authProfiles.user_id,
|
||||
customer_for_portal: authProfiles.customer_for_portal,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.user_id, userId!),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingTenantProfile) {
|
||||
await server.db
|
||||
.update(authProfiles)
|
||||
.set({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
customer_for_portal: customerRecord.id,
|
||||
active: true,
|
||||
})
|
||||
.where(eq(authProfiles.id, existingTenantProfile.id));
|
||||
} else {
|
||||
await server.db
|
||||
.insert(authProfiles)
|
||||
.values({
|
||||
user_id: userId!,
|
||||
tenant_id: tenantId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
customer_for_portal: customerRecord.id,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
|
||||
|
||||
const mailResult = await sendMail(
|
||||
email,
|
||||
`FEDEO | Einladung ins Kundenportal`,
|
||||
`
|
||||
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
|
||||
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
|
||||
<p><strong>E-Mail:</strong> ${email}</p>
|
||||
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
||||
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
|
||||
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
|
||||
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
||||
`
|
||||
);
|
||||
|
||||
if (!mailResult.success) {
|
||||
return reply.code(500).send({ error: "Invitation email could not be sent" });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
createdNewUser,
|
||||
email,
|
||||
initialPassword: generatedPassword,
|
||||
portalUrl,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/tenants
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
authRolePermissions,
|
||||
} from "../../../db/schema"
|
||||
import { eq, and, or, isNull } from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||
|
||||
export default async function meRoutes(server: FastifyInstance) {
|
||||
server.get("/me", async (req, reply) => {
|
||||
@@ -51,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
hasActiveLicense: tenants.hasActiveLicense,
|
||||
locked: tenants.locked,
|
||||
features: tenants.features,
|
||||
extraModules: tenants.extraModules,
|
||||
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
profile = profileResult?.[0] ?? null
|
||||
const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
|
||||
profile = enrichedProfiles?.[0] ?? null
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
|
||||
@@ -10,11 +10,14 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
bankaccounts,
|
||||
bankstatements,
|
||||
accounts,
|
||||
createddocuments,
|
||||
customers,
|
||||
entitybankaccounts,
|
||||
incominginvoices,
|
||||
ownaccounts,
|
||||
statementallocations,
|
||||
vendors,
|
||||
} from "../../db/schema"
|
||||
@@ -22,10 +25,355 @@ import {
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
isNull,
|
||||
aliasedTable,
|
||||
desc,
|
||||
} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
||||
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
||||
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
||||
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
||||
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
|
||||
const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
|
||||
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
|
||||
|
||||
const normalizeManualSide = (payload: any, keys: string[]) =>
|
||||
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
|
||||
|
||||
const cashbookAccountFilter = (tenantId: number) => and(
|
||||
eq(bankaccounts.tenant, tenantId),
|
||||
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
|
||||
eq(bankaccounts.archived, false)
|
||||
)
|
||||
|
||||
const buildCashbookCounterPayload = (type: string, id: any) => {
|
||||
const numericId = type === "ownaccount" ? id : Number(id)
|
||||
if (!id || (type !== "ownaccount" && !Number.isFinite(numericId))) return null
|
||||
|
||||
if (type === "account") return { account: numericId }
|
||||
if (type === "customer") return { customer: numericId }
|
||||
if (type === "vendor") return { vendor: numericId }
|
||||
if (type === "ownaccount") return { ownaccount: numericId }
|
||||
if (type === "incominginvoice") return { incominginvoice: numericId }
|
||||
return null
|
||||
}
|
||||
|
||||
const prepareStatementAllocationPayload = (payload: any) => {
|
||||
const next = { ...payload }
|
||||
const isManualBooking = !next.bankstatement
|
||||
|
||||
if (!isManualBooking) {
|
||||
next.manualBookingDate = null
|
||||
next.contraAccount = null
|
||||
next.contraCustomer = null
|
||||
next.contraVendor = null
|
||||
next.contraOwnaccount = null
|
||||
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
|
||||
next.manualInvoiceSide = null
|
||||
return { data: next }
|
||||
}
|
||||
|
||||
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
|
||||
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
|
||||
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
|
||||
const debitSide = normalizeManualSide(next, debitKeys)
|
||||
const creditSide = normalizeManualSide(next, creditKeys)
|
||||
|
||||
if (hasManualInvoice) {
|
||||
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
|
||||
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
|
||||
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
|
||||
} else {
|
||||
next.manualInvoiceSide = null
|
||||
}
|
||||
|
||||
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
|
||||
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
|
||||
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
|
||||
}
|
||||
|
||||
if (debitSide.length !== 1 || creditSide.length !== 1) {
|
||||
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
|
||||
}
|
||||
|
||||
next.amount = Math.abs(Number(next.amount))
|
||||
next.bankstatement = null
|
||||
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
|
||||
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
|
||||
|
||||
return { data: next }
|
||||
}
|
||||
|
||||
server.get("/banking/cashbooks", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(bankaccounts)
|
||||
.where(cashbookAccountFilter(req.user.tenant_id))
|
||||
.orderBy(bankaccounts.name)
|
||||
|
||||
return reply.send(rows)
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to load cashbooks" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/banking/cashbooks", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const body = req.body as {
|
||||
name?: string
|
||||
datevNumber?: string
|
||||
openingBalance?: number
|
||||
}
|
||||
|
||||
const name = String(body.name || "").trim()
|
||||
const datevNumber = String(body.datevNumber || "").trim()
|
||||
const openingBalance = Number(body.openingBalance || 0)
|
||||
|
||||
if (!name) return reply.code(400).send({ error: "Bitte eine Bezeichnung für die Kasse angeben." })
|
||||
if (!datevNumber) return reply.code(400).send({ error: "Bitte eine Kontennummer für die Kasse angeben." })
|
||||
if (!Number.isFinite(openingBalance)) return reply.code(400).send({ error: "Der Anfangsbestand ist ungültig." })
|
||||
|
||||
const uniquePart = `${req.user.tenant_id}-${Date.now()}`
|
||||
const inserted = await server.db.insert(bankaccounts).values({
|
||||
name,
|
||||
iban: `CASH-${uniquePart}`,
|
||||
tenant: req.user.tenant_id,
|
||||
bankId: CASHBOOK_BANK_ID,
|
||||
ownerName: name,
|
||||
accountId: `cashbook-${uniquePart}`,
|
||||
balance: openingBalance,
|
||||
datevNumber,
|
||||
updatedBy: req.user.user_id,
|
||||
}).returning()
|
||||
|
||||
const createdRecord = inserted[0]
|
||||
return reply.send(createdRecord)
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to create cashbook" })
|
||||
}
|
||||
})
|
||||
|
||||
server.patch("/banking/cashbooks/:id/archive", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const updated = await server.db.update(bankaccounts)
|
||||
.set({
|
||||
archived: true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: req.user.user_id,
|
||||
})
|
||||
.where(and(
|
||||
eq(bankaccounts.id, Number(id)),
|
||||
eq(bankaccounts.tenant, req.user.tenant_id),
|
||||
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!updated[0]) return reply.code(404).send({ error: "Cashbook not found" })
|
||||
return reply.send(updated[0])
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to archive cashbook" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/banking/cashbooks/:id/bookings", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const cashbookId = Number(id)
|
||||
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
||||
|
||||
const rows = await server.db.select({
|
||||
statement: bankstatements,
|
||||
allocation: statementallocations,
|
||||
account: accounts,
|
||||
customer: customers,
|
||||
vendor: vendors,
|
||||
ownaccount: ownaccounts,
|
||||
incominginvoice: ManualInvoices,
|
||||
incominginvoiceVendor: ManualInvoiceVendors,
|
||||
})
|
||||
.from(bankstatements)
|
||||
.leftJoin(statementallocations, eq(statementallocations.bankstatement, bankstatements.id))
|
||||
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
|
||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
|
||||
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
|
||||
.where(and(
|
||||
eq(bankstatements.tenant, req.user.tenant_id),
|
||||
eq(bankstatements.account, cashbookId),
|
||||
eq(bankstatements.archived, false)
|
||||
))
|
||||
.orderBy(desc(bankstatements.date), desc(bankstatements.createdAt))
|
||||
|
||||
return reply.send(rows.map((row) => ({
|
||||
...row.statement,
|
||||
allocation: row.allocation,
|
||||
account: row.account,
|
||||
customer: row.customer,
|
||||
vendor: row.vendor,
|
||||
ownaccount: row.ownaccount,
|
||||
incominginvoice: row.incominginvoice ? {
|
||||
...row.incominginvoice,
|
||||
vendor: row.incominginvoiceVendor,
|
||||
} : null,
|
||||
})))
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to load cashbook bookings" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/banking/cashbooks/:id/bookings", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const cashbookId = Number(id)
|
||||
const body = req.body as {
|
||||
date?: string
|
||||
amount?: number
|
||||
direction?: "income" | "expense"
|
||||
counterType?: string
|
||||
counterId?: string | number
|
||||
description?: string
|
||||
datevTaxKey?: string | null
|
||||
}
|
||||
|
||||
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
||||
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." })
|
||||
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
|
||||
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
|
||||
|
||||
const cashbook = await server.db.select().from(bankaccounts).where(and(
|
||||
eq(bankaccounts.id, cashbookId),
|
||||
eq(bankaccounts.tenant, req.user.tenant_id),
|
||||
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
|
||||
eq(bankaccounts.archived, false)
|
||||
)).limit(1)
|
||||
|
||||
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
|
||||
|
||||
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
|
||||
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." })
|
||||
|
||||
const signedAmount = body.direction === "income"
|
||||
? Math.abs(Number(body.amount))
|
||||
: -Math.abs(Number(body.amount))
|
||||
const description = String(body.description || "").trim() || (body.direction === "income" ? "Bareinnahme" : "Barausgabe")
|
||||
|
||||
const created = await server.db.transaction(async (tx) => {
|
||||
const insertedStatements = await tx.insert(bankstatements).values({
|
||||
account: cashbookId,
|
||||
date: dayjs(body.date).format("YYYY-MM-DD"),
|
||||
amount: signedAmount,
|
||||
tenant: req.user.tenant_id,
|
||||
text: description,
|
||||
currency: "EUR",
|
||||
credName: body.direction === "income" ? cashbook[0].name : description,
|
||||
debName: body.direction === "expense" ? cashbook[0].name : description,
|
||||
updatedBy: req.user.user_id,
|
||||
}).returning()
|
||||
|
||||
const statement = insertedStatements[0]
|
||||
const insertedAllocations = await tx.insert(statementallocations).values({
|
||||
bankstatement: statement.id,
|
||||
amount: signedAmount,
|
||||
tenant: req.user.tenant_id,
|
||||
description,
|
||||
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
|
||||
...counterPayload,
|
||||
}).returning()
|
||||
|
||||
return {
|
||||
statement,
|
||||
allocation: insertedAllocations[0],
|
||||
}
|
||||
})
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(created.statement.id),
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: null,
|
||||
newVal: created,
|
||||
text: "Kassenbuchung erstellt",
|
||||
})
|
||||
|
||||
return reply.send(created)
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to create cashbook booking" })
|
||||
}
|
||||
})
|
||||
|
||||
server.delete("/banking/cashbook-bookings/:id", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const statementId = Number(id)
|
||||
if (!Number.isFinite(statementId)) return reply.code(400).send({ error: "Ungültige Buchung." })
|
||||
|
||||
const records = await server.db.select({
|
||||
statement: bankstatements,
|
||||
cashbook: bankaccounts,
|
||||
})
|
||||
.from(bankstatements)
|
||||
.innerJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
||||
.where(and(
|
||||
eq(bankstatements.id, statementId),
|
||||
eq(bankstatements.tenant, req.user.tenant_id),
|
||||
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!records[0]) return reply.code(404).send({ error: "Kassenbuchung nicht gefunden." })
|
||||
|
||||
await server.db.transaction(async (tx) => {
|
||||
await tx.delete(statementallocations).where(eq(statementallocations.bankstatement, statementId))
|
||||
await tx.delete(bankstatements).where(eq(bankstatements.id, statementId))
|
||||
})
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: statementId,
|
||||
action: "deleted",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: records[0].statement,
|
||||
newVal: null,
|
||||
text: "Kassenbuchung gelöscht",
|
||||
})
|
||||
|
||||
return reply.send({ success: true })
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to delete cashbook booking" })
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeIban = (value?: string | null) =>
|
||||
String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||
|
||||
@@ -677,6 +1025,64 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 📒 List Manual Statement Allocations
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/manual-bookings", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const rows = await server.db.select({
|
||||
allocation: statementallocations,
|
||||
account: accounts,
|
||||
customer: customers,
|
||||
vendor: vendors,
|
||||
ownaccount: ownaccounts,
|
||||
contraAccount: ContraAccounts,
|
||||
contraCustomer: ContraCustomers,
|
||||
contraVendor: ContraVendors,
|
||||
contraOwnaccount: ContraOwnaccounts,
|
||||
incominginvoice: ManualInvoices,
|
||||
incominginvoiceVendor: ManualInvoiceVendors,
|
||||
})
|
||||
.from(statementallocations)
|
||||
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
|
||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
|
||||
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
|
||||
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
|
||||
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
|
||||
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
|
||||
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
|
||||
.where(and(
|
||||
eq(statementallocations.tenant, req.user.tenant_id),
|
||||
eq(statementallocations.archived, false),
|
||||
isNull(statementallocations.bankstatement)
|
||||
))
|
||||
|
||||
return reply.send(rows.map((row) => ({
|
||||
...row.allocation,
|
||||
account: row.account,
|
||||
customer: row.customer,
|
||||
vendor: row.vendor,
|
||||
ownaccount: row.ownaccount,
|
||||
contraAccount: row.contraAccount,
|
||||
contraCustomer: row.contraCustomer,
|
||||
contraVendor: row.contraVendor,
|
||||
contraOwnaccount: row.contraOwnaccount,
|
||||
incominginvoice: row.incominginvoice ? {
|
||||
...row.incominginvoice,
|
||||
vendor: row.incominginvoiceVendor,
|
||||
} : null,
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to load manual bookings" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 💰 Create Statement Allocation
|
||||
@@ -686,9 +1092,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { data: payload } = req.body as { data: any }
|
||||
const prepared = prepareStatementAllocationPayload(payload)
|
||||
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||
|
||||
const inserted = await server.db.insert(statementallocations).values({
|
||||
...payload,
|
||||
...prepared.data,
|
||||
tenant: req.user.tenant_id
|
||||
}).returning()
|
||||
|
||||
@@ -720,16 +1128,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(createdRecord.bankstatement),
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: null,
|
||||
newVal: createdRecord,
|
||||
text: "Buchung erstellt",
|
||||
})
|
||||
if (createdRecord.bankstatement) {
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(createdRecord.bankstatement),
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: null,
|
||||
newVal: createdRecord,
|
||||
text: "Buchung erstellt",
|
||||
})
|
||||
}
|
||||
|
||||
return reply.send(createdRecord)
|
||||
|
||||
@@ -763,16 +1173,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
.delete(statementallocations)
|
||||
.where(eq(statementallocations.id, id))
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(old.bankstatement),
|
||||
action: "deleted",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: old,
|
||||
newVal: null,
|
||||
text: "Buchung gelöscht",
|
||||
})
|
||||
if (old.bankstatement) {
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: Number(old.bankstatement),
|
||||
action: "deleted",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: old,
|
||||
newVal: null,
|
||||
text: "Buchung gelöscht",
|
||||
})
|
||||
}
|
||||
|
||||
return reply.send({ success: true })
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import {
|
||||
authProfiles,
|
||||
files,
|
||||
createddocuments,
|
||||
customers
|
||||
@@ -18,6 +20,55 @@ import {
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
const getPortalCustomerId = async (req: any) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId || !userId) return null
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.user_id, userId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return profile?.customer_for_portal || null
|
||||
}
|
||||
|
||||
const loadSingleFileForRequest = async (req: any, id: string) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return null
|
||||
|
||||
const portalCustomerId = await getPortalCustomerId(req)
|
||||
|
||||
if (!portalCustomerId) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select({
|
||||
file: files,
|
||||
})
|
||||
.from(files)
|
||||
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
|
||||
.where(and(
|
||||
eq(files.id, id),
|
||||
eq(files.tenant, tenantId),
|
||||
eq(createddocuments.customer, portalCustomerId),
|
||||
eq(createddocuments.availableInPortal, true)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return rows[0]?.file || null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MULTIPART INIT
|
||||
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
// 🔹 EINZELNE DATEI
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return file
|
||||
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// 1️⃣ SINGLE DOWNLOAD
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// SINGLE FILE PRESIGNED URL
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const url = await getSignedUrl(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import dayjs from "dayjs";
|
||||
@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||
import { generateLiquidityForecast } from "../utils/liquidityForecast";
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
@@ -57,6 +58,42 @@ function resolveGitRoot() {
|
||||
return null
|
||||
}
|
||||
|
||||
function getDeploymentChangelogFallback() {
|
||||
const backendPackagePath = path.resolve(process.cwd(), "package.json")
|
||||
let version = "unbekannt"
|
||||
|
||||
if (existsSync(backendPackagePath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
|
||||
version = packageJson?.version || version
|
||||
} catch (err) {
|
||||
console.error("Could not read backend package.json for changelog fallback", err)
|
||||
}
|
||||
}
|
||||
|
||||
const commitHash =
|
||||
process.env.RAILWAY_GIT_COMMIT_SHA ||
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.GITHUB_SHA ||
|
||||
process.env.COMMIT_SHA ||
|
||||
process.env.SOURCE_COMMIT ||
|
||||
null
|
||||
|
||||
const committedAt =
|
||||
process.env.BUILD_DATE ||
|
||||
process.env.RENDER_GIT_COMMIT_DATE ||
|
||||
process.env.VERCEL_GIT_COMMIT_DATE ||
|
||||
new Date().toISOString()
|
||||
|
||||
return [{
|
||||
hash: commitHash || `version-${version}`,
|
||||
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
|
||||
subject: `Bereitgestellte Version ${version}`,
|
||||
authorName: "Deployment",
|
||||
committedAt
|
||||
}]
|
||||
}
|
||||
|
||||
export default async function functionRoutes(server: FastifyInstance) {
|
||||
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -201,7 +238,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
const gitRoot = resolveGitRoot()
|
||||
|
||||
if (!gitRoot) {
|
||||
return reply.code(500).send({ error: 'Git repository not found' })
|
||||
return reply.send({
|
||||
repositoryRoot: null,
|
||||
source: 'deployment',
|
||||
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -232,11 +273,16 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
|
||||
return reply.send({
|
||||
repositoryRoot: gitRoot,
|
||||
source: 'git',
|
||||
entries
|
||||
})
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: 'Failed to load changelog' })
|
||||
return reply.send({
|
||||
repositoryRoot: gitRoot,
|
||||
source: 'deployment',
|
||||
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -261,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||
})
|
||||
|
||||
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
||||
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
|
||||
const ignoredKeys = String(ignoredRecurringKeys || "")
|
||||
.split(",")
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
try {
|
||||
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
||||
const tenantId = req.user.tenant_id
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
|
||||
|
||||
const columnMap: Record<string, any> = {
|
||||
customers: historyitems.customer,
|
||||
contracts: historyitems.contract,
|
||||
members: historyitems.customer,
|
||||
vendors: historyitems.vendor,
|
||||
projects: historyitems.project,
|
||||
@@ -30,6 +31,7 @@ const columnMap: Record<string, any> = {
|
||||
|
||||
const insertFieldMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
contracts: "contract",
|
||||
members: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../../../db/schema"
|
||||
|
||||
import {and, eq, inArray} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
|
||||
|
||||
|
||||
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
@@ -53,7 +55,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
@@ -61,6 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
@@ -91,12 +95,13 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
const tenantId = req.params.id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
return data
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
return await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/profiles ERROR:", err)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
desc
|
||||
} from "drizzle-orm"
|
||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||
import {z} from "zod";
|
||||
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
].sort(compareTimeEvents);
|
||||
|
||||
// SCHRITT 5: Spans ableiten
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
|
||||
144
backend/src/routes/mcp.ts
Normal file
144
backend/src/routes/mcp.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { assertToolPermission, createMcpContext } from "../mcp/authz"
|
||||
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
||||
import { asToolError, asToolResult } from "../mcp/result"
|
||||
import { JsonRpcRequest } from "../mcp/types"
|
||||
|
||||
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
"2025-11-25",
|
||||
"2025-06-18",
|
||||
"2025-03-26",
|
||||
"2024-11-05",
|
||||
]
|
||||
|
||||
function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: id ?? null,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function selectProtocolVersion(clientVersion?: string) {
|
||||
if (clientVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
|
||||
return clientVersion
|
||||
}
|
||||
|
||||
return SUPPORTED_PROTOCOL_VERSIONS[0]
|
||||
}
|
||||
|
||||
export default async function mcpRoutes(server: FastifyInstance) {
|
||||
server.post("/mcp", async (req, reply) => {
|
||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||
const requests = Array.isArray(body) ? body : [body]
|
||||
const responses = []
|
||||
|
||||
for (const request of requests) {
|
||||
const id = request?.id
|
||||
|
||||
if (!request || request.jsonrpc !== "2.0" || !request.method) {
|
||||
responses.push(jsonRpcError(id, -32600, "Invalid JSON-RPC request"))
|
||||
continue
|
||||
}
|
||||
|
||||
if (request.method === "notifications/initialized") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (request.method === "initialize") {
|
||||
const clientVersion = request.params?.protocolVersion
|
||||
|
||||
responses.push(jsonRpcResult(id, {
|
||||
protocolVersion: selectProtocolVersion(clientVersion),
|
||||
capabilities: {
|
||||
tools: {
|
||||
listChanged: false,
|
||||
},
|
||||
},
|
||||
serverInfo: {
|
||||
name: "fedeo-mcp",
|
||||
version: "1.0.0",
|
||||
},
|
||||
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
if (request.method === "ping") {
|
||||
responses.push(jsonRpcResult(id, {}))
|
||||
continue
|
||||
}
|
||||
|
||||
if (request.method === "tools/list") {
|
||||
responses.push(jsonRpcResult(id, {
|
||||
tools: mcpTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
||||
},
|
||||
})),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
if (request.method === "tools/call") {
|
||||
const toolName = request.params?.name
|
||||
const tool = typeof toolName === "string" ? mcpToolMap.get(toolName) : null
|
||||
|
||||
if (!tool) {
|
||||
responses.push(jsonRpcError(id, -32602, `Unknown tool: ${toolName}`))
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await createMcpContext(server, req)
|
||||
assertToolPermission(context, tool)
|
||||
|
||||
const result = await tool.handler(context, request.params?.arguments || {})
|
||||
responses.push(jsonRpcResult(id, asToolResult(result)))
|
||||
} catch (error) {
|
||||
const statusCode = (error as any)?.statusCode
|
||||
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
responses.push(jsonRpcError(id, statusCode === 401 ? -32001 : -32003, error instanceof Error ? error.message : "Forbidden"))
|
||||
} else {
|
||||
responses.push(jsonRpcResult(id, asToolError(error)))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
responses.push(jsonRpcError(id, -32601, `Method not found: ${request.method}`))
|
||||
}
|
||||
|
||||
if (responses.length === 0) {
|
||||
return reply.code(204).send()
|
||||
}
|
||||
|
||||
return Array.isArray(body) ? responses : responses[0]
|
||||
})
|
||||
|
||||
server.get("/mcp", async (_req, reply) => {
|
||||
return reply.send({
|
||||
name: "fedeo-mcp",
|
||||
transport: "http-json-rpc",
|
||||
endpoint: "/api/mcp",
|
||||
tools: mcpTools.map((tool) => tool.name),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
230
backend/src/routes/portal/contracts.ts
Normal file
230
backend/src/routes/portal/contracts.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
|
||||
async function getPortalCustomerId(server: FastifyInstance, req: any) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId || !userId) return null
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.user_id, userId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return profile?.customer_for_portal || null
|
||||
}
|
||||
|
||||
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (!portalCustomerId) return null
|
||||
|
||||
const [contract] = await server.db
|
||||
.select({
|
||||
id: contracts.id,
|
||||
name: contracts.name,
|
||||
tenant: contracts.tenant,
|
||||
customer: contracts.customer,
|
||||
contracttype: contracts.contracttype,
|
||||
allowedContracttypes: contracts.allowedContracttypes,
|
||||
archived: contracts.archived,
|
||||
})
|
||||
.from(contracts)
|
||||
.where(and(
|
||||
eq(contracts.id, contractId),
|
||||
eq(contracts.tenant, req.user?.tenant_id),
|
||||
eq(contracts.customer, portalCustomerId),
|
||||
eq(contracts.archived, false)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return contract || null
|
||||
}
|
||||
|
||||
function normalizeMessage(message: unknown) {
|
||||
if (typeof message !== "string") return ""
|
||||
return message.trim()
|
||||
}
|
||||
|
||||
function appendMessage(text: string, message: string) {
|
||||
return message ? `${text} Nachricht: ${message}` : text
|
||||
}
|
||||
|
||||
function formatDateForHistory(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export default async function portalContractRoutes(server: FastifyInstance) {
|
||||
server.post<{
|
||||
Params: { id: string }
|
||||
Body: { contracttype?: number | string; message?: string }
|
||||
}>("/portal/contracts/:id/change-request", {
|
||||
schema: {
|
||||
tags: ["Portal"],
|
||||
summary: "Request contract type change from customer portal",
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["contracttype"],
|
||||
properties: {
|
||||
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
|
||||
message: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const contractId = Number(req.params.id)
|
||||
const requestedContracttypeId = Number(req.body.contracttype)
|
||||
|
||||
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
|
||||
return reply.code(400).send({ error: "Ungültige Anfrage" })
|
||||
}
|
||||
|
||||
const contract = await getPortalContract(server, req, contractId)
|
||||
if (!contract) {
|
||||
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
|
||||
}
|
||||
|
||||
const [requestedContracttype] = await server.db
|
||||
.select({
|
||||
id: contracttypes.id,
|
||||
name: contracttypes.name,
|
||||
})
|
||||
.from(contracttypes)
|
||||
.where(and(
|
||||
eq(contracttypes.id, requestedContracttypeId),
|
||||
eq(contracttypes.tenant, req.user?.tenant_id),
|
||||
eq(contracttypes.archived, false)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!requestedContracttype) {
|
||||
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
|
||||
}
|
||||
|
||||
const allowedContracttypes = Array.isArray(contract.allowedContracttypes)
|
||||
? contract.allowedContracttypes.map((id) => Number(id)).filter((id) => Number.isInteger(id))
|
||||
: []
|
||||
|
||||
if (!allowedContracttypes.includes(requestedContracttype.id)) {
|
||||
return reply.code(400).send({ error: "Dieser Vertragstyp steht für diesen Vertrag nicht zur Auswahl" })
|
||||
}
|
||||
|
||||
const [currentContracttype] = contract.contracttype
|
||||
? await server.db
|
||||
.select({
|
||||
id: contracttypes.id,
|
||||
name: contracttypes.name,
|
||||
})
|
||||
.from(contracttypes)
|
||||
.where(and(
|
||||
eq(contracttypes.id, contract.contracttype),
|
||||
eq(contracttypes.tenant, req.user?.tenant_id)
|
||||
))
|
||||
.limit(1)
|
||||
: []
|
||||
|
||||
const message = normalizeMessage(req.body.message)
|
||||
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
|
||||
const newName = requestedContracttype.name
|
||||
const text = appendMessage(
|
||||
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
|
||||
message
|
||||
)
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: req.user?.tenant_id,
|
||||
created_by: req.user?.user_id || null,
|
||||
entity: "contracts",
|
||||
entityId: contract.id,
|
||||
action: "unchanged",
|
||||
oldVal: { contracttype: contract.contracttype, name: oldName },
|
||||
newVal: { contracttype: requestedContracttype.id, name: newName },
|
||||
text,
|
||||
})
|
||||
|
||||
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
|
||||
})
|
||||
|
||||
server.post<{
|
||||
Params: { id: string }
|
||||
Body: { requestedEndDate?: string; message?: string }
|
||||
}>("/portal/contracts/:id/cancellation-request", {
|
||||
schema: {
|
||||
tags: ["Portal"],
|
||||
summary: "Request contract cancellation from customer portal",
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["requestedEndDate"],
|
||||
properties: {
|
||||
requestedEndDate: { type: "string" },
|
||||
message: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const contractId = Number(req.params.id)
|
||||
const requestedEndDate = typeof req.body.requestedEndDate === "string"
|
||||
? req.body.requestedEndDate.trim()
|
||||
: ""
|
||||
|
||||
if (!Number.isInteger(contractId) || !requestedEndDate) {
|
||||
return reply.code(400).send({ error: "Ungültige Anfrage" })
|
||||
}
|
||||
|
||||
const parsedDate = new Date(requestedEndDate)
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
|
||||
}
|
||||
|
||||
const contract = await getPortalContract(server, req, contractId)
|
||||
if (!contract) {
|
||||
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
|
||||
}
|
||||
|
||||
const message = normalizeMessage(req.body.message)
|
||||
const text = appendMessage(
|
||||
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
|
||||
message
|
||||
)
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: req.user?.tenant_id,
|
||||
created_by: req.user?.user_id || null,
|
||||
entity: "contracts",
|
||||
entityId: contract.id,
|
||||
action: "unchanged",
|
||||
newVal: { requestedEndDate },
|
||||
text,
|
||||
})
|
||||
|
||||
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
authProfiles,
|
||||
} from "../../db/schema";
|
||||
import {
|
||||
loadProfileWithBranches,
|
||||
resolveTenantBranchIds,
|
||||
syncProfileBranches,
|
||||
} from "../utils/profileBranches";
|
||||
import {
|
||||
enrichProfilesWithTeams,
|
||||
resolveTenantTeamIds,
|
||||
syncProfileTeams,
|
||||
} from "../utils/profileTeams";
|
||||
|
||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
|
||||
@@ -19,22 +29,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||
const [profile] = profileWithBranches
|
||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||
: [null]
|
||||
|
||||
if (!rows.length) {
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
return profile;
|
||||
|
||||
} catch (error) {
|
||||
console.error("GET /profiles/:id ERROR:", error);
|
||||
@@ -48,7 +52,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
// ❌ Systemfelder entfernen
|
||||
const forbidden = [
|
||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name"
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||
"branch"
|
||||
]
|
||||
forbidden.forEach(f => delete cleaned[f])
|
||||
|
||||
@@ -89,8 +94,32 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
// Clean + Normalize
|
||||
body = sanitizeProfileUpdate(body)
|
||||
|
||||
const { primaryBranchId, branchIds } = await resolveTenantBranchIds(
|
||||
server,
|
||||
tenantId,
|
||||
[
|
||||
...(Array.isArray(body.branch_ids) ? body.branch_ids : []),
|
||||
...(Array.isArray(body.branches) ? body.branches : []),
|
||||
],
|
||||
body.branch_id ?? body.branch?.id ?? null
|
||||
)
|
||||
const teamIds = await resolveTenantTeamIds(
|
||||
server,
|
||||
tenantId,
|
||||
[
|
||||
...(Array.isArray(body.team_ids) ? body.team_ids : []),
|
||||
...(Array.isArray(body.teams) ? body.teams : []),
|
||||
],
|
||||
)
|
||||
|
||||
delete body.branch_ids
|
||||
delete body.branches
|
||||
delete body.team_ids
|
||||
delete body.teams
|
||||
|
||||
const updateData = {
|
||||
...body,
|
||||
branch_id: primaryBranchId,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
}
|
||||
@@ -110,10 +139,23 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||
}
|
||||
|
||||
return updated[0]
|
||||
await syncProfileBranches(server, id, branchIds, userId)
|
||||
await syncProfileTeams(server, id, teamIds, userId)
|
||||
|
||||
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||
const [profile] = profileWithBranches
|
||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||
: [null]
|
||||
return profile || updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("PUT /profiles/:id ERROR:", err)
|
||||
if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) {
|
||||
return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" })
|
||||
}
|
||||
if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") {
|
||||
return reply.code(400).send({ error: "Ungültige Teamauswahl" })
|
||||
}
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import { authProfiles, costcentres } from "../../../db/schema";
|
||||
import { resourceConfig } from "../../utils/resource.config";
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||
@@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff";
|
||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
||||
import { decrypt, encrypt } from "../../utils/crypt";
|
||||
|
||||
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
|
||||
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
||||
// -------------------------------------------------------------
|
||||
@@ -130,12 +134,92 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
|
||||
return whereCond
|
||||
}
|
||||
|
||||
async function getPortalCustomerId(server: FastifyInstance, req: any) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId || !userId) return null
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.user_id, userId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return profile?.customer_for_portal || null
|
||||
}
|
||||
|
||||
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
|
||||
if (!portalCustomerId) return whereCond
|
||||
|
||||
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (resource === "customers") {
|
||||
return and(whereCond, eq(table.id, portalCustomerId))
|
||||
}
|
||||
|
||||
if (resource === "contracts") {
|
||||
return and(whereCond, eq(table.customer, portalCustomerId))
|
||||
}
|
||||
|
||||
if (resource === "createddocuments") {
|
||||
return and(
|
||||
whereCond,
|
||||
eq(table.customer, portalCustomerId),
|
||||
eq(table.availableInPortal, true),
|
||||
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
|
||||
)
|
||||
}
|
||||
|
||||
return whereCond
|
||||
}
|
||||
|
||||
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
|
||||
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
||||
|
||||
return {
|
||||
name: payload.name,
|
||||
firstname: payload.firstname,
|
||||
lastname: payload.lastname,
|
||||
salutation: payload.salutation,
|
||||
title: payload.title,
|
||||
nameAddition: payload.nameAddition,
|
||||
infoData: nextInfoData,
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantColumn(resource: string, table: any) {
|
||||
const config = resourceConfig[resource]
|
||||
const tenantKey = config?.tenantKey || "tenant"
|
||||
return table[tenantKey]
|
||||
}
|
||||
|
||||
function getRelationConfig(relation: string) {
|
||||
const candidateKeys = [
|
||||
relation,
|
||||
`${relation}s`,
|
||||
]
|
||||
|
||||
if (relation.endsWith("y")) {
|
||||
candidateKeys.push(`${relation.slice(0, -1)}ies`)
|
||||
}
|
||||
|
||||
if (/(s|x|z|ch|sh)$/.test(relation)) {
|
||||
candidateKeys.push(`${relation}es`)
|
||||
}
|
||||
|
||||
for (const key of candidateKeys) {
|
||||
if (resourceConfig[key]) return resourceConfig[key]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isDateLikeField(key: string) {
|
||||
if (key === "deliveryDateType") return false
|
||||
if (key.includes("_at") || key.endsWith("At")) return true
|
||||
@@ -176,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function validateCostCentreParent(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
costCentreId: string | null,
|
||||
parentCostcentreId: string | null
|
||||
) {
|
||||
if (!parentCostcentreId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hierarchyRows = await server.db
|
||||
.select({
|
||||
id: costcentres.id,
|
||||
parentCostcentre: costcentres.parentCostcentre,
|
||||
})
|
||||
.from(costcentres)
|
||||
.where(eq(costcentres.tenant, tenantId))
|
||||
|
||||
const hierarchyMap = new Map(
|
||||
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
|
||||
)
|
||||
|
||||
if (!hierarchyMap.has(parentCostcentreId)) {
|
||||
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
|
||||
}
|
||||
|
||||
if (costCentreId && parentCostcentreId === costCentreId) {
|
||||
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
|
||||
}
|
||||
|
||||
if (!costCentreId) {
|
||||
return null
|
||||
}
|
||||
|
||||
let currentParentId: string | null = parentCostcentreId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (currentParentId === costCentreId) {
|
||||
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
|
||||
}
|
||||
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
currentParentId = hierarchyMap.get(currentParentId) || null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function maskIban(iban: string) {
|
||||
if (!iban) return ""
|
||||
const cleaned = iban.replace(/\s+/g, "")
|
||||
@@ -250,18 +387,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" })
|
||||
}
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = config.table
|
||||
|
||||
const tenantColumn = getTenantColumn(resource, table)
|
||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
let q = server.db.select().from(table).$dynamic()
|
||||
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
|
||||
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
|
||||
const relConfig = getRelationConfig(rel)
|
||||
if (relConfig) {
|
||||
const relTable = relConfig.table
|
||||
|
||||
@@ -307,7 +449,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||
})
|
||||
for await (const rel of config.mtoLoad) {
|
||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConf = getRelationConfig(rel)
|
||||
if (!relConf) continue
|
||||
const relTab = relConf.table
|
||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
|
||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||
@@ -358,6 +501,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" });
|
||||
}
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = config.table;
|
||||
|
||||
const { queryConfig } = req;
|
||||
@@ -367,6 +514,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const tenantColumn = getTenantColumn(resource, table);
|
||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
||||
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
||||
const parsedFilters: Array<{ key: string; value: any }> = []
|
||||
@@ -376,7 +524,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConfig = getRelationConfig(rel)
|
||||
if (relConfig) {
|
||||
const relTable = relConfig.table;
|
||||
|
||||
@@ -457,7 +605,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConfig = getRelationConfig(rel)
|
||||
if (!relConfig) return;
|
||||
const relTable = relConfig.table;
|
||||
if (relTable !== table) {
|
||||
@@ -467,6 +615,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
}
|
||||
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
||||
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
|
||||
|
||||
if (search) {
|
||||
const searchCond = buildSearchCondition(searchCols, search.trim())
|
||||
@@ -496,7 +645,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||
});
|
||||
for await (const rel of config.mtoLoad) {
|
||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConf = getRelationConfig(rel)
|
||||
if (!relConf) continue
|
||||
const relTab = relConf.table;
|
||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
|
||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||
@@ -547,10 +697,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
|
||||
const projRows = await server.db
|
||||
.select()
|
||||
@@ -567,7 +722,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (resourceConfig[resource].mtoLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtoLoad) {
|
||||
if (data[relation]) {
|
||||
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
|
||||
const relConf = getRelationConfig(relation)
|
||||
if (!relConf) continue
|
||||
const relTable = relConf.table
|
||||
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
||||
data[relation] = relData[0] || null
|
||||
@@ -600,6 +756,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
try {
|
||||
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
|
||||
const { resource } = req.params as { resource: string };
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
if (resource === "accounts") {
|
||||
return reply.code(403).send({ error: "Accounts are read-only" })
|
||||
}
|
||||
@@ -623,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
createData = prepared.data!
|
||||
}
|
||||
|
||||
if (resource === "costcentres") {
|
||||
const validationError = await validateCostCentreParent(
|
||||
server,
|
||||
req.user.tenant_id,
|
||||
null,
|
||||
createData.parentCostcentre || null
|
||||
)
|
||||
|
||||
if (validationError) {
|
||||
return reply.code(400).send({ error: validationError })
|
||||
}
|
||||
}
|
||||
|
||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||
@@ -679,8 +852,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const body = req.body as Record<string, any>
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
|
||||
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
if (portalCustomerId && resource !== "customers") {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
|
||||
const table = resourceConfig[resource].table
|
||||
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||
@@ -688,13 +865,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const [oldRecord] = await server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
||||
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
|
||||
.limit(1)
|
||||
|
||||
if (!oldRecord) {
|
||||
return reply.code(404).send({ error: "Resource not found" })
|
||||
}
|
||||
|
||||
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
||||
//@ts-ignore
|
||||
delete data.updatedBy; delete data.updatedAt;
|
||||
|
||||
if (portalCustomerId) {
|
||||
data = {
|
||||
...sanitizePortalCustomerUpdate(data),
|
||||
updated_at: data.updated_at,
|
||||
updated_by: data.updated_by,
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === "members") {
|
||||
data = normalizeMemberPayload(data)
|
||||
const validationError = validateMemberPayload(data)
|
||||
@@ -713,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === "costcentres") {
|
||||
const validationError = await validateCostCentreParent(
|
||||
server,
|
||||
tenantId,
|
||||
oldRecord.id,
|
||||
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
|
||||
? data.parentCostcentre || null
|
||||
: oldRecord.parentCostcentre || null
|
||||
)
|
||||
|
||||
if (validationError) {
|
||||
return reply.code(400).send({ error: validationError })
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key]
|
||||
const shouldNormalize =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sortData } from "../utils/sort"
|
||||
|
||||
// Schema imports
|
||||
import { accounts, units, countrys, tenants } from "../../db/schema"
|
||||
import { defaultCountries } from "../utils/countries"
|
||||
|
||||
const TABLE_MAP: Record<string, any> = {
|
||||
accounts,
|
||||
@@ -96,6 +97,24 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
|
||||
|
||||
const data = await query
|
||||
|
||||
if (resource === "countrys") {
|
||||
const countryMap = new Map<string, any>()
|
||||
|
||||
for (const country of defaultCountries) {
|
||||
countryMap.set(country.toLocaleLowerCase("de"), { id: country, name: country })
|
||||
}
|
||||
|
||||
for (const country of data) {
|
||||
countryMap.set(country.name.toLocaleLowerCase("de"), country)
|
||||
}
|
||||
|
||||
return sortData(
|
||||
Array.from(countryMap.values()),
|
||||
sort || "name",
|
||||
sort ? ascQuery === "true" : true
|
||||
)
|
||||
}
|
||||
|
||||
// Falls sort clientseitig wie früher notwendig ist:
|
||||
const sorted = sortData(
|
||||
data,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
desc
|
||||
} from "drizzle-orm"
|
||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||
import {z} from "zod";
|
||||
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
].sort(compareTimeEvents);
|
||||
|
||||
// SCHRITT 5: Spans ableiten
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
].sort(compareTimeEvents);
|
||||
|
||||
// SCHRITT 4: Ableiten und Anreichern
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
@@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "../../db/schema"
|
||||
|
||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
@@ -123,7 +125,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
@@ -131,6 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
@@ -160,11 +164,13 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
return { data }
|
||||
|
||||
} catch (err) {
|
||||
|
||||
258
backend/src/utils/countries.ts
Normal file
258
backend/src/utils/countries.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
const COUNTRY_CODES = [
|
||||
"AF",
|
||||
"AX",
|
||||
"AL",
|
||||
"DZ",
|
||||
"AS",
|
||||
"AD",
|
||||
"AO",
|
||||
"AI",
|
||||
"AQ",
|
||||
"AG",
|
||||
"AR",
|
||||
"AM",
|
||||
"AW",
|
||||
"AU",
|
||||
"AT",
|
||||
"AZ",
|
||||
"BS",
|
||||
"BH",
|
||||
"BD",
|
||||
"BB",
|
||||
"BY",
|
||||
"BE",
|
||||
"BZ",
|
||||
"BJ",
|
||||
"BM",
|
||||
"BT",
|
||||
"BO",
|
||||
"BQ",
|
||||
"BA",
|
||||
"BW",
|
||||
"BV",
|
||||
"BR",
|
||||
"IO",
|
||||
"BN",
|
||||
"BG",
|
||||
"BF",
|
||||
"BI",
|
||||
"CV",
|
||||
"KH",
|
||||
"CM",
|
||||
"CA",
|
||||
"KY",
|
||||
"CF",
|
||||
"TD",
|
||||
"CL",
|
||||
"CN",
|
||||
"CX",
|
||||
"CC",
|
||||
"CO",
|
||||
"KM",
|
||||
"CG",
|
||||
"CD",
|
||||
"CK",
|
||||
"CR",
|
||||
"CI",
|
||||
"HR",
|
||||
"CU",
|
||||
"CW",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DJ",
|
||||
"DM",
|
||||
"DO",
|
||||
"EC",
|
||||
"EG",
|
||||
"SV",
|
||||
"GQ",
|
||||
"ER",
|
||||
"EE",
|
||||
"SZ",
|
||||
"ET",
|
||||
"FK",
|
||||
"FO",
|
||||
"FJ",
|
||||
"FI",
|
||||
"FR",
|
||||
"GF",
|
||||
"PF",
|
||||
"TF",
|
||||
"GA",
|
||||
"GM",
|
||||
"GE",
|
||||
"DE",
|
||||
"GH",
|
||||
"GI",
|
||||
"GR",
|
||||
"GL",
|
||||
"GD",
|
||||
"GP",
|
||||
"GU",
|
||||
"GT",
|
||||
"GG",
|
||||
"GN",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"HM",
|
||||
"VA",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IN",
|
||||
"ID",
|
||||
"IR",
|
||||
"IQ",
|
||||
"IE",
|
||||
"IM",
|
||||
"IL",
|
||||
"IT",
|
||||
"JM",
|
||||
"JP",
|
||||
"JE",
|
||||
"JO",
|
||||
"KZ",
|
||||
"KE",
|
||||
"KI",
|
||||
"KP",
|
||||
"KR",
|
||||
"KW",
|
||||
"KG",
|
||||
"LA",
|
||||
"LV",
|
||||
"LB",
|
||||
"LS",
|
||||
"LR",
|
||||
"LY",
|
||||
"LI",
|
||||
"LT",
|
||||
"LU",
|
||||
"MO",
|
||||
"MG",
|
||||
"MW",
|
||||
"MY",
|
||||
"MV",
|
||||
"ML",
|
||||
"MT",
|
||||
"MH",
|
||||
"MQ",
|
||||
"MR",
|
||||
"MU",
|
||||
"YT",
|
||||
"MX",
|
||||
"FM",
|
||||
"MD",
|
||||
"MC",
|
||||
"MN",
|
||||
"ME",
|
||||
"MS",
|
||||
"MA",
|
||||
"MZ",
|
||||
"MM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NP",
|
||||
"NL",
|
||||
"NC",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NE",
|
||||
"NG",
|
||||
"NU",
|
||||
"NF",
|
||||
"MK",
|
||||
"MP",
|
||||
"NO",
|
||||
"OM",
|
||||
"PK",
|
||||
"PW",
|
||||
"PS",
|
||||
"PA",
|
||||
"PG",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PN",
|
||||
"PL",
|
||||
"PT",
|
||||
"PR",
|
||||
"QA",
|
||||
"RE",
|
||||
"RO",
|
||||
"RU",
|
||||
"RW",
|
||||
"BL",
|
||||
"SH",
|
||||
"KN",
|
||||
"LC",
|
||||
"MF",
|
||||
"PM",
|
||||
"VC",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SA",
|
||||
"SN",
|
||||
"RS",
|
||||
"SC",
|
||||
"SL",
|
||||
"SG",
|
||||
"SX",
|
||||
"SK",
|
||||
"SI",
|
||||
"SB",
|
||||
"SO",
|
||||
"ZA",
|
||||
"GS",
|
||||
"SS",
|
||||
"ES",
|
||||
"LK",
|
||||
"SD",
|
||||
"SR",
|
||||
"SJ",
|
||||
"SE",
|
||||
"CH",
|
||||
"SY",
|
||||
"TW",
|
||||
"TJ",
|
||||
"TZ",
|
||||
"TH",
|
||||
"TL",
|
||||
"TG",
|
||||
"TK",
|
||||
"TO",
|
||||
"TT",
|
||||
"TN",
|
||||
"TR",
|
||||
"TM",
|
||||
"TC",
|
||||
"TV",
|
||||
"UG",
|
||||
"UA",
|
||||
"AE",
|
||||
"GB",
|
||||
"UM",
|
||||
"US",
|
||||
"UY",
|
||||
"UZ",
|
||||
"VU",
|
||||
"VE",
|
||||
"VN",
|
||||
"VG",
|
||||
"VI",
|
||||
"WF",
|
||||
"EH",
|
||||
"YE",
|
||||
"ZM",
|
||||
"ZW",
|
||||
] as const
|
||||
|
||||
const countryNames = new Intl.DisplayNames(["de"], { type: "region" })
|
||||
|
||||
export const defaultCountries = COUNTRY_CODES
|
||||
.map((code) => countryNames.of(code))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.sort((a, b) => a.localeCompare(b, "de"))
|
||||
@@ -8,7 +8,7 @@ import { s3 } from "../s3";
|
||||
import { secrets } from "../secrets";
|
||||
|
||||
// Drizzle Core Imports
|
||||
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
|
||||
import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
|
||||
|
||||
// Tabellen Imports (keine Relations nötig!)
|
||||
import {
|
||||
@@ -136,6 +136,10 @@ export async function buildExportZip(
|
||||
|
||||
const CdCustomer = aliasedTable(customers, "cd_customer");
|
||||
const IiVendor = aliasedTable(vendors, "ii_vendor");
|
||||
const ContraAccount = aliasedTable(accounts, "contra_account");
|
||||
const ContraVendor = aliasedTable(vendors, "contra_vendor");
|
||||
const ContraCustomer = aliasedTable(customers, "contra_customer");
|
||||
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
|
||||
|
||||
const allocRaw = await server.db.select({
|
||||
allocation: statementallocations,
|
||||
@@ -148,11 +152,15 @@ export async function buildExportZip(
|
||||
acc: accounts,
|
||||
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
||||
direct_cust: customers, // Direkte Zuordnung an Debitor
|
||||
own: ownaccounts
|
||||
own: ownaccounts,
|
||||
contra_acc: ContraAccount,
|
||||
contra_vend: ContraVendor,
|
||||
contra_cust: ContraCustomer,
|
||||
contra_own: ContraOwnaccount
|
||||
})
|
||||
.from(statementallocations)
|
||||
// JOIN 1: Bankstatement (Pflicht, für Datum Filter)
|
||||
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
|
||||
// JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
|
||||
.leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
|
||||
// JOIN 2: Bankaccount (für DATEV Nummer)
|
||||
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
||||
|
||||
@@ -169,13 +177,25 @@ export async function buildExportZip(
|
||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
|
||||
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
|
||||
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
|
||||
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
|
||||
|
||||
.where(and(
|
||||
eq(statementallocations.tenant, tenantId),
|
||||
eq(statementallocations.archived, false),
|
||||
// Datum Filter direkt auf dem Bankstatement
|
||||
gte(bankstatements.date, startDate),
|
||||
lte(bankstatements.date, endDate)
|
||||
or(
|
||||
and(
|
||||
gte(bankstatements.date, startDate),
|
||||
lte(bankstatements.date, endDate)
|
||||
),
|
||||
and(
|
||||
isNull(statementallocations.bankstatement),
|
||||
gte(statementallocations.manualBookingDate, startDate),
|
||||
lte(statementallocations.manualBookingDate, endDate)
|
||||
)
|
||||
)
|
||||
));
|
||||
|
||||
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
||||
@@ -196,7 +216,11 @@ export async function buildExportZip(
|
||||
account: r.acc,
|
||||
vendor: r.direct_vend,
|
||||
customer: r.direct_cust,
|
||||
ownaccount: r.own
|
||||
ownaccount: r.own,
|
||||
contraAccount: r.contra_acc,
|
||||
contraVendor: r.contra_vend,
|
||||
contraCustomer: r.contra_cust,
|
||||
contraOwnaccount: r.contra_own
|
||||
}));
|
||||
|
||||
// --- D) Stammdaten Accounts ---
|
||||
@@ -311,8 +335,42 @@ export async function buildExportZip(
|
||||
});
|
||||
|
||||
// Bank
|
||||
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
|
||||
const prefix = side === "credit" ? "contra" : "";
|
||||
const account = side === "credit" ? alloc.contraAccount : alloc.account;
|
||||
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
|
||||
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
|
||||
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
|
||||
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
|
||||
|
||||
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
|
||||
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
|
||||
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
|
||||
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
|
||||
if (incominginvoice) {
|
||||
return {
|
||||
number: incominginvoice.vendor?.vendorNumber || "",
|
||||
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
|
||||
type: "Eingangsbeleg",
|
||||
reference: incominginvoice.reference || "",
|
||||
};
|
||||
}
|
||||
return { number: "", name: "", type: prefix };
|
||||
};
|
||||
|
||||
statementallocationsList.forEach(alloc => {
|
||||
const bs = alloc.bankstatement; // durch Mapping verfügbar
|
||||
|
||||
if(!bs && alloc.manualBookingDate) {
|
||||
const debit = getManualBookingSide(alloc, "debit");
|
||||
const credit = getManualBookingSide(alloc, "credit");
|
||||
const dateManual = dayjs(alloc.manualBookingDate).format("DDMM");
|
||||
const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY");
|
||||
const belegnummer = debit.reference || credit.reference || "";
|
||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!bs) return;
|
||||
|
||||
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
|
||||
@@ -425,4 +483,4 @@ export async function buildExportZip(
|
||||
console.error("DATEV Export Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
|
||||
tenantId: number,
|
||||
numberRange: string
|
||||
) => {
|
||||
const numberRangeFallbacks: Record<string, string> = {
|
||||
costEstimates: "quotes",
|
||||
packingSlips: "deliveryNotes",
|
||||
advanceInvoices: "invoices",
|
||||
cancellationInvoices: "invoices",
|
||||
}
|
||||
|
||||
const [tenant] = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
|
||||
|
||||
const numberRanges = tenant.numberRanges || {}
|
||||
|
||||
if (!numberRanges[numberRange]) {
|
||||
const resolvedNumberRange = numberRanges[numberRange]
|
||||
? numberRange
|
||||
: numberRangeFallbacks[numberRange]
|
||||
|
||||
if (!resolvedNumberRange || !numberRanges[resolvedNumberRange]) {
|
||||
throw new Error(`Number range '${numberRange}' not found`)
|
||||
}
|
||||
|
||||
const current = numberRanges[numberRange]
|
||||
const current = numberRanges[resolvedNumberRange]
|
||||
|
||||
const usedNumber =
|
||||
(current.prefix || "") +
|
||||
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
|
||||
const updatedRanges = {
|
||||
// @ts-ignore
|
||||
...numberRanges,
|
||||
[numberRange]: {
|
||||
[resolvedNumberRange]: {
|
||||
...current,
|
||||
nextNumber: current.nextNumber + 1,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
|
||||
|
||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
customers: "Kunden",
|
||||
contracts: "Verträge",
|
||||
members: "Mitglieder",
|
||||
vendors: "Lieferanten",
|
||||
projects: "Projekte",
|
||||
@@ -32,6 +33,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
incominginvoices: "Eingangsrechnungen",
|
||||
files: "Dateien",
|
||||
memberrelations: "Mitgliedsverhältnisse",
|
||||
teams: "Teams",
|
||||
}
|
||||
|
||||
export function getHistoryEntityLabel(entity: string) {
|
||||
@@ -62,6 +64,7 @@ export async function insertHistoryItem(
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
contracts: "contract",
|
||||
members: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
|
||||
839
backend/src/utils/liquidityForecast.ts
Normal file
839
backend/src/utils/liquidityForecast.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
import dayjs from "dayjs";
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { and, desc, eq, gte } from "drizzle-orm";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
bankaccounts,
|
||||
bankstatements,
|
||||
createddocuments,
|
||||
incominginvoices,
|
||||
statementallocations,
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
import { secrets } from "./secrets";
|
||||
|
||||
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";
|
||||
|
||||
type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";
|
||||
|
||||
type ForecastEvent = {
|
||||
date: string;
|
||||
amount: number;
|
||||
label: string;
|
||||
source: ForecastEventSource;
|
||||
sourceId?: number | string | null;
|
||||
recurringKey?: string;
|
||||
confidence?: number;
|
||||
};
|
||||
|
||||
type TaxBreakdown = {
|
||||
net19: number;
|
||||
tax19: number;
|
||||
net7: number;
|
||||
tax7: number;
|
||||
net0: number;
|
||||
};
|
||||
|
||||
type TaxForecastPeriod = {
|
||||
key: string;
|
||||
label: string;
|
||||
range: string;
|
||||
dueDate: string;
|
||||
outputTax: number;
|
||||
inputTax: number;
|
||||
balance: number;
|
||||
outputCount: number;
|
||||
inputCount: number;
|
||||
};
|
||||
|
||||
type RecurringCandidate = {
|
||||
key?: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
interval: "weekly" | "monthly" | "quarterly" | "yearly";
|
||||
nextDate: string;
|
||||
confidence: number;
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
const AiRecurringFormat = z.object({
|
||||
candidates: z.array(z.object({
|
||||
label: z.string(),
|
||||
amount: z.number(),
|
||||
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
|
||||
nextDate: z.string(),
|
||||
confidence: z.number().min(0).max(1),
|
||||
evidence: z.string(),
|
||||
})),
|
||||
});
|
||||
|
||||
const FORECAST_DAYS = 90;
|
||||
const HISTORY_MONTHS = 12;
|
||||
|
||||
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
|
||||
const createZeroTaxBreakdown = (): TaxBreakdown => ({
|
||||
net19: 0,
|
||||
tax19: 0,
|
||||
net7: 0,
|
||||
tax7: 0,
|
||||
net0: 0,
|
||||
});
|
||||
|
||||
const normalizeText = (value: unknown) => String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9äöüß]+/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
|
||||
if (value === "quarterly" || value === "yearly") return value;
|
||||
return "monthly";
|
||||
};
|
||||
|
||||
const isTaxFreeDocument = (taxType?: string | null) => {
|
||||
return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
|
||||
};
|
||||
|
||||
const getTaxEvaluationPeriodBounds = (
|
||||
referenceDate: dayjs.ConfigType,
|
||||
period: TaxEvaluationPeriod
|
||||
) => {
|
||||
const base = dayjs(referenceDate);
|
||||
|
||||
if (period === "yearly") {
|
||||
return {
|
||||
start: base.startOf("year"),
|
||||
end: base.endOf("year"),
|
||||
};
|
||||
}
|
||||
|
||||
if (period === "quarterly") {
|
||||
const quarterStartMonth = Math.floor(base.month() / 3) * 3;
|
||||
const start = base.month(quarterStartMonth).startOf("month");
|
||||
|
||||
return {
|
||||
start,
|
||||
end: start.add(2, "month").endOf("month"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start: base.startOf("month"),
|
||||
end: base.endOf("month"),
|
||||
};
|
||||
};
|
||||
|
||||
const shiftTaxEvaluationPeriodStart = (
|
||||
periodStart: dayjs.ConfigType,
|
||||
period: TaxEvaluationPeriod,
|
||||
offset: number
|
||||
) => {
|
||||
const base = dayjs(periodStart);
|
||||
|
||||
if (period === "yearly") return base.add(offset, "year").startOf("year");
|
||||
if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
|
||||
return base.add(offset, "month").startOf("month");
|
||||
};
|
||||
|
||||
const formatTaxEvaluationPeriodLabel = (
|
||||
periodStart: dayjs.ConfigType,
|
||||
period: TaxEvaluationPeriod
|
||||
) => {
|
||||
const { start } = getTaxEvaluationPeriodBounds(periodStart, period);
|
||||
|
||||
if (period === "yearly") {
|
||||
return start.format("YYYY");
|
||||
}
|
||||
|
||||
if (period === "quarterly") {
|
||||
return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
|
||||
}
|
||||
|
||||
return start.format("MMMM YYYY");
|
||||
};
|
||||
|
||||
const formatTaxEvaluationPeriodRange = (
|
||||
periodStart: dayjs.ConfigType,
|
||||
period: TaxEvaluationPeriod
|
||||
) => {
|
||||
const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
|
||||
return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
|
||||
};
|
||||
|
||||
const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
|
||||
return periodEnd.add(1, "month").date(10).startOf("day");
|
||||
};
|
||||
|
||||
const getRecurringKey = (candidate: RecurringCandidate) => {
|
||||
const rawKey = [
|
||||
Math.sign(candidate.amount),
|
||||
Math.round(Math.abs(candidate.amount) * 100),
|
||||
normalizeText(candidate.label),
|
||||
candidate.interval,
|
||||
].join("|");
|
||||
|
||||
return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
|
||||
};
|
||||
|
||||
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
|
||||
if (interval === "weekly") return date.add(1, "week");
|
||||
if (interval === "quarterly") return date.add(3, "month");
|
||||
if (interval === "yearly") return date.add(1, "year");
|
||||
return date.add(1, "month");
|
||||
};
|
||||
|
||||
const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
|
||||
if (interval === "wöchentlich") return date.add(1, "week");
|
||||
if (interval === "2 - wöchentlich") return date.add(2, "week");
|
||||
if (interval === "vierteljährlich") return date.add(3, "month");
|
||||
if (interval === "halbjährlich") return date.add(6, "month");
|
||||
if (interval === "jährlich") return date.add(1, "year");
|
||||
return date.add(1, "month");
|
||||
};
|
||||
|
||||
const getStatementPartner = (statement: any) => {
|
||||
return statement.amount < 0
|
||||
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
|
||||
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
|
||||
};
|
||||
|
||||
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
|
||||
let totalNet = 0;
|
||||
let totalTax = 0;
|
||||
|
||||
(document.rows || []).forEach((row: any) => {
|
||||
if (["pagebreak", "title", "text"].includes(row.mode)) return;
|
||||
|
||||
const rowNet = Number(
|
||||
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
|
||||
);
|
||||
const taxPercent = Number(row.taxPercent);
|
||||
|
||||
totalNet += rowNet;
|
||||
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
|
||||
});
|
||||
|
||||
let advancePayments = 0;
|
||||
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
|
||||
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
|
||||
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
|
||||
if (!advanceRow) return;
|
||||
|
||||
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
|
||||
});
|
||||
|
||||
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
|
||||
};
|
||||
|
||||
const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
|
||||
const breakdown = createZeroTaxBreakdown();
|
||||
|
||||
if (!document || isTaxFreeDocument(document.taxType)) {
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
(document.rows || []).forEach((row: any) => {
|
||||
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;
|
||||
|
||||
const quantity = Number(row.quantity || 0);
|
||||
const price = Number(row.price || 0);
|
||||
const discountPercent = Number(row.discountPercent || 0);
|
||||
const taxPercent = Number(row.taxPercent || 0);
|
||||
const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));
|
||||
|
||||
if (!Number.isFinite(net) || net === 0) return;
|
||||
|
||||
if (taxPercent === 19) {
|
||||
breakdown.net19 += net;
|
||||
breakdown.tax19 += Number((net * 0.19).toFixed(2));
|
||||
} else if (taxPercent === 7) {
|
||||
breakdown.net7 += net;
|
||||
breakdown.tax7 += Number((net * 0.07).toFixed(2));
|
||||
} else {
|
||||
breakdown.net0 += net;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
net19: roundMoney(breakdown.net19),
|
||||
tax19: roundMoney(breakdown.tax19),
|
||||
net7: roundMoney(breakdown.net7),
|
||||
tax7: roundMoney(breakdown.tax7),
|
||||
net0: roundMoney(breakdown.net0),
|
||||
};
|
||||
};
|
||||
|
||||
const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
|
||||
const breakdown = createZeroTaxBreakdown();
|
||||
|
||||
(invoice?.accounts || []).forEach((account: any) => {
|
||||
const taxType = String(account?.taxType || "");
|
||||
const amountNet = Number(account?.amountNet || 0);
|
||||
const amountTax = Number(account?.amountTax || 0);
|
||||
|
||||
if (taxType === "19") {
|
||||
breakdown.net19 += amountNet;
|
||||
breakdown.tax19 += amountTax;
|
||||
} else if (taxType === "7") {
|
||||
breakdown.net7 += amountNet;
|
||||
breakdown.tax7 += amountTax;
|
||||
} else {
|
||||
breakdown.net0 += amountNet;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
net19: roundMoney(breakdown.net19),
|
||||
tax19: roundMoney(breakdown.tax19),
|
||||
net7: roundMoney(breakdown.net7),
|
||||
tax7: roundMoney(breakdown.tax7),
|
||||
net0: roundMoney(breakdown.net0),
|
||||
};
|
||||
};
|
||||
|
||||
const getIncomingInvoiceSignedAmount = (invoice: any) => {
|
||||
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
|
||||
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
|
||||
}, 0);
|
||||
|
||||
return roundMoney(invoice.expense === false ? amount : amount * -1);
|
||||
};
|
||||
|
||||
const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
|
||||
const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
|
||||
if (remainingAbsolute <= 0.01) return 0;
|
||||
return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
|
||||
};
|
||||
|
||||
const findCancellationDocumentIds = (documents: any[]) => {
|
||||
return new Set(
|
||||
documents
|
||||
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
|
||||
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
|
||||
.filter(Boolean)
|
||||
);
|
||||
};
|
||||
|
||||
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
|
||||
if (dates.length < 3) return null;
|
||||
|
||||
const gaps = dates
|
||||
.slice(1)
|
||||
.map((date, index) => date.diff(dates[index], "day"))
|
||||
.filter((gap) => gap > 0);
|
||||
|
||||
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
|
||||
|
||||
if (averageGap >= 6 && averageGap <= 8) return "weekly";
|
||||
if (averageGap >= 25 && averageGap <= 35) return "monthly";
|
||||
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
|
||||
if (averageGap >= 340 && averageGap <= 390) return "yearly";
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
|
||||
const groups = new Map<string, any[]>();
|
||||
|
||||
statements.forEach((statement) => {
|
||||
const parsedDate = dayjs(statement.valueDate || statement.date);
|
||||
if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;
|
||||
|
||||
const partner = normalizeText(getStatementPartner(statement));
|
||||
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
|
||||
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
|
||||
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
|
||||
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(statement);
|
||||
});
|
||||
|
||||
return [...groups.values()]
|
||||
.map((group) => {
|
||||
const sorted = group
|
||||
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
|
||||
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
|
||||
|
||||
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
|
||||
if (!interval) return null;
|
||||
|
||||
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
|
||||
while (next.isBefore(dayjs(), "day")) {
|
||||
next = addInterval(next, interval);
|
||||
}
|
||||
|
||||
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
|
||||
const partner = getStatementPartner(sorted[sorted.length - 1]);
|
||||
|
||||
return {
|
||||
label: partner,
|
||||
amount,
|
||||
interval,
|
||||
nextDate: next.format("YYYY-MM-DD"),
|
||||
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
|
||||
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as RecurringCandidate[];
|
||||
};
|
||||
|
||||
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
|
||||
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
|
||||
|
||||
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
|
||||
const compactStatements = statements.slice(0, 220).map((statement) => ({
|
||||
date: statement.valueDate || statement.date,
|
||||
amount: roundMoney(Number(statement.amount || 0)),
|
||||
partner: getStatementPartner(statement),
|
||||
text: String(statement.text || "").slice(0, 160),
|
||||
}));
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.parse({
|
||||
model: "gpt-4o",
|
||||
store: true,
|
||||
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
today: dayjs().format("YYYY-MM-DD"),
|
||||
horizonDays: FORECAST_DAYS,
|
||||
bankStatements: compactStatements,
|
||||
rules: [
|
||||
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
|
||||
"nextDate muss in der Zukunft liegen.",
|
||||
"Nutze keine einmaligen oder unsicheren Bewegungen.",
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (completion.choices[0].message.parsed?.candidates || [])
|
||||
.filter((candidate) => dayjs(candidate.nextDate).isValid())
|
||||
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
amount: roundMoney(candidate.amount),
|
||||
confidence: Math.max(0, Math.min(1, candidate.confidence)),
|
||||
}));
|
||||
} catch (error) {
|
||||
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
|
||||
server.log.warn(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
|
||||
const merged = new Map<string, RecurringCandidate>();
|
||||
|
||||
[...heuristic, ...ai].forEach((candidate) => {
|
||||
const key = [
|
||||
Math.sign(candidate.amount),
|
||||
Math.round(Math.abs(candidate.amount) * 100),
|
||||
normalizeText(candidate.label).slice(0, 32),
|
||||
candidate.interval,
|
||||
].join("|");
|
||||
const existing = merged.get(key);
|
||||
|
||||
if (!existing || candidate.confidence > existing.confidence) {
|
||||
merged.set(key, candidate);
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.values()]
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
key: getRecurringKey(candidate),
|
||||
}))
|
||||
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
||||
.filter((candidate) => Math.abs(candidate.amount) >= 1)
|
||||
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
|
||||
};
|
||||
|
||||
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
|
||||
const events: ForecastEvent[] = [];
|
||||
|
||||
recurring.forEach((candidate) => {
|
||||
let date = dayjs(candidate.nextDate);
|
||||
let guard = 0;
|
||||
|
||||
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
|
||||
events.push({
|
||||
date: date.format("YYYY-MM-DD"),
|
||||
amount: roundMoney(candidate.amount),
|
||||
label: candidate.label,
|
||||
source: "recurring_bankstatement",
|
||||
recurringKey: candidate.key,
|
||||
confidence: candidate.confidence,
|
||||
});
|
||||
date = addInterval(date, candidate.interval);
|
||||
guard += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return events;
|
||||
};
|
||||
|
||||
const buildTaxForecastPeriods = (
|
||||
documents: any[],
|
||||
incomingInvoices: any[],
|
||||
periodType: TaxEvaluationPeriod,
|
||||
today: dayjs.Dayjs,
|
||||
endDate: dayjs.Dayjs
|
||||
) => {
|
||||
const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
|
||||
const periods: TaxForecastPeriod[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < 12) {
|
||||
const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
|
||||
const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
|
||||
const dueDate = getTaxSettlementDate(bounds.end);
|
||||
|
||||
if (dueDate.isAfter(endDate, "day")) break;
|
||||
|
||||
const outputDocs = documents.filter((document) => {
|
||||
if (document?.state !== "Gebucht") return false;
|
||||
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;
|
||||
|
||||
const date = dayjs(document.documentDate);
|
||||
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
|
||||
});
|
||||
|
||||
const inputDocs = incomingInvoices.filter((invoice) => {
|
||||
if (invoice?.state !== "Gebucht" || !invoice?.date) return false;
|
||||
|
||||
const date = dayjs(invoice.date);
|
||||
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
|
||||
});
|
||||
|
||||
const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
|
||||
const breakdown = getCreatedDocumentTaxBreakdown(document);
|
||||
return sum + breakdown.tax19 + breakdown.tax7;
|
||||
}, 0));
|
||||
|
||||
const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
|
||||
const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
|
||||
return sum + breakdown.tax19 + breakdown.tax7;
|
||||
}, 0));
|
||||
|
||||
const balance = roundMoney(outputTax - inputTax);
|
||||
|
||||
periods.push({
|
||||
key: bounds.start.format("YYYY-MM-DD"),
|
||||
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
|
||||
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
|
||||
dueDate: dueDate.format("YYYY-MM-DD"),
|
||||
outputTax,
|
||||
inputTax,
|
||||
balance,
|
||||
outputCount: outputDocs.length,
|
||||
inputCount: inputDocs.length,
|
||||
});
|
||||
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
return periods;
|
||||
};
|
||||
|
||||
const buildSerialTemplateEvents = (
|
||||
documents: any[],
|
||||
today: dayjs.Dayjs,
|
||||
endDate: dayjs.Dayjs
|
||||
) => {
|
||||
const events: ForecastEvent[] = [];
|
||||
|
||||
documents
|
||||
.filter((document) => document.type === "serialInvoices")
|
||||
.filter((document) => document.serialConfig?.active)
|
||||
.forEach((document) => {
|
||||
const firstExecution = dayjs(document.serialConfig?.firstExecution);
|
||||
const executionUntil = dayjs(document.serialConfig?.executionUntil);
|
||||
|
||||
if (!firstExecution.isValid() || !executionUntil.isValid()) return;
|
||||
|
||||
const amount = getCreatedDocumentGrossAmount(document, documents);
|
||||
if (amount <= 0.01) return;
|
||||
|
||||
let executionDate = firstExecution.startOf("day");
|
||||
let guard = 0;
|
||||
|
||||
while (executionDate.isBefore(today, "day") && guard < 240) {
|
||||
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
|
||||
guard += 1;
|
||||
}
|
||||
|
||||
while (
|
||||
executionDate.isValid()
|
||||
&& !executionDate.isAfter(executionUntil, "day")
|
||||
&& !executionDate.isAfter(endDate, "day")
|
||||
&& guard < 400
|
||||
) {
|
||||
const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");
|
||||
|
||||
events.push({
|
||||
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||
amount,
|
||||
label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
|
||||
source: "serial_template",
|
||||
sourceId: document.id,
|
||||
confidence: 0.75,
|
||||
});
|
||||
|
||||
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
|
||||
guard += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return events.sort((a, b) => a.date.localeCompare(b.date));
|
||||
};
|
||||
|
||||
export const generateLiquidityForecast = async (
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
ignoredRecurringKeys: string[] = []
|
||||
) => {
|
||||
const today = dayjs().startOf("day");
|
||||
const endDate = today.add(FORECAST_DAYS, "day");
|
||||
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
|
||||
|
||||
const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
|
||||
server.db
|
||||
.select()
|
||||
.from(bankaccounts)
|
||||
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
|
||||
server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
|
||||
.orderBy(desc(bankstatements.valueDate)),
|
||||
server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
|
||||
server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
|
||||
server.db
|
||||
.select()
|
||||
.from(statementallocations)
|
||||
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
|
||||
server.db
|
||||
.select({
|
||||
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
const activeAccounts = accounts.filter((account) => !account.archived);
|
||||
const activeStatements = statements.filter((statement) => !statement.archived);
|
||||
const activeDocuments = documents.filter((document) => !document.archived);
|
||||
const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
|
||||
const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
|
||||
const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
|
||||
const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
|
||||
const activeAllocations = allocations.filter((allocation) => {
|
||||
if (allocation.archived) return false;
|
||||
if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
|
||||
if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const startingBalance = roundMoney(
|
||||
activeAccounts
|
||||
.filter((account) => !account.expired)
|
||||
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
|
||||
);
|
||||
const allocationByDocument = new Map<number, number>();
|
||||
const allocationByIncomingInvoice = new Map<number, number>();
|
||||
|
||||
activeAllocations.forEach((allocation) => {
|
||||
if (allocation.createddocument) {
|
||||
allocationByDocument.set(
|
||||
allocation.createddocument,
|
||||
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
|
||||
);
|
||||
}
|
||||
|
||||
if (allocation.incominginvoice) {
|
||||
allocationByIncomingInvoice.set(
|
||||
allocation.incominginvoice,
|
||||
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
|
||||
const openEvents: ForecastEvent[] = [];
|
||||
const draftEvents: ForecastEvent[] = [];
|
||||
|
||||
activeDocuments
|
||||
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
|
||||
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
|
||||
.forEach((document) => {
|
||||
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
|
||||
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
|
||||
if (openAmount <= 0.01) return;
|
||||
|
||||
const dueDate = dayjs(document.documentDate).isValid()
|
||||
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
|
||||
: today;
|
||||
|
||||
openEvents.push({
|
||||
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||
amount: openAmount,
|
||||
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
|
||||
source: "open_createddocument",
|
||||
sourceId: document.id,
|
||||
confidence: 1,
|
||||
});
|
||||
});
|
||||
|
||||
activeDocuments
|
||||
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
|
||||
.filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
|
||||
.forEach((document) => {
|
||||
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
|
||||
if (total <= 0.01) return;
|
||||
|
||||
const dueDate = dayjs(document.documentDate).isValid()
|
||||
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
|
||||
: today;
|
||||
|
||||
draftEvents.push({
|
||||
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||
amount: total,
|
||||
label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
|
||||
source: "draft_createddocument",
|
||||
sourceId: document.id,
|
||||
confidence: 0.35,
|
||||
});
|
||||
});
|
||||
|
||||
activeIncomingInvoices
|
||||
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
|
||||
.filter((invoice) => !invoice.paid)
|
||||
.forEach((invoice) => {
|
||||
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
|
||||
const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
|
||||
if (Math.abs(openAmount) <= 0.01) return;
|
||||
|
||||
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
|
||||
? dayjs(invoice.dueDate || invoice.date)
|
||||
: today;
|
||||
|
||||
openEvents.push({
|
||||
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||
amount: openAmount,
|
||||
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
|
||||
source: "open_incominginvoice",
|
||||
sourceId: invoice.id,
|
||||
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
|
||||
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
|
||||
const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
|
||||
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
|
||||
.filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
|
||||
const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
|
||||
const taxEvents: ForecastEvent[] = taxPeriods
|
||||
.filter((period) => Math.abs(period.balance) > 0.01)
|
||||
.map((period) => ({
|
||||
date: period.dueDate,
|
||||
amount: roundMoney(period.balance * -1),
|
||||
label: `USt ${period.label}`,
|
||||
source: "tax_settlement" as const,
|
||||
sourceId: period.key,
|
||||
confidence: 0.95,
|
||||
}));
|
||||
const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
|
||||
const events = [
|
||||
...openEvents,
|
||||
...taxEvents,
|
||||
...serialTemplateEvents,
|
||||
...expandRecurringEvents(recurring, endDate),
|
||||
].filter((event) => {
|
||||
const date = dayjs(event.date);
|
||||
return date.isValid() && !date.isAfter(endDate, "day");
|
||||
});
|
||||
|
||||
const dailyEvents = new Map<string, ForecastEvent[]>();
|
||||
events.forEach((event) => {
|
||||
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
|
||||
dailyEvents.get(event.date)!.push(event);
|
||||
});
|
||||
|
||||
let runningBalance = startingBalance;
|
||||
const points = [];
|
||||
|
||||
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
|
||||
const date = today.add(offset, "day").format("YYYY-MM-DD");
|
||||
const dayEvents = dailyEvents.get(date) || [];
|
||||
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
|
||||
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
|
||||
|
||||
runningBalance = roundMoney(runningBalance + income + expense);
|
||||
|
||||
points.push({
|
||||
date,
|
||||
balance: runningBalance,
|
||||
income,
|
||||
expense,
|
||||
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
|
||||
});
|
||||
}
|
||||
|
||||
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
|
||||
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
|
||||
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
|
||||
const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
horizonDays: FORECAST_DAYS,
|
||||
startingBalance,
|
||||
endingBalance: points[points.length - 1]?.balance || startingBalance,
|
||||
lowestBalance: lowestPoint?.balance || startingBalance,
|
||||
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
accounts: activeAccounts.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
iban: account.iban,
|
||||
balance: roundMoney(Number(account.balance || 0)),
|
||||
expired: account.expired,
|
||||
syncedAt: account.syncedAt,
|
||||
})),
|
||||
recurring,
|
||||
events: events.sort((a, b) => a.date.localeCompare(b.date)),
|
||||
draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
|
||||
draftIncome,
|
||||
tax: {
|
||||
periodType: taxPeriodType,
|
||||
periods: taxPeriods,
|
||||
totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
|
||||
},
|
||||
points,
|
||||
ai: {
|
||||
enabled: Boolean(secrets.OPENAI_API_KEY),
|
||||
candidates: aiRecurring.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -58,6 +58,8 @@ const getDuration = (time) => {
|
||||
|
||||
|
||||
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
||||
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||
|
||||
const genPDF = async (invoiceData, backgroundSourceBuffer) => {
|
||||
const pdfDoc = await PDFDocument.create()
|
||||
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (isPackingSlip) {
|
||||
pages[pageCounter - 1].drawText("Check", {
|
||||
...getCoordinatesForPDFLib(180, 137, page1),
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
}
|
||||
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawText("Steuer", {
|
||||
...getCoordinatesForPDFLib(135, 137, page1),
|
||||
size: 12,
|
||||
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
maxWidth: 240
|
||||
})
|
||||
|
||||
if (isPackingSlip) {
|
||||
pages[pageCounter - 1].drawRectangle({
|
||||
...getCoordinatesForPDFLib(182, rowHeight + 1, page1),
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: 0.8,
|
||||
opacity: 1,
|
||||
borderOpacity: 1,
|
||||
})
|
||||
}
|
||||
|
||||
let rowTextLines = 0
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||
size: 10,
|
||||
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
rowTextLines = splitStringBySpace(row.text, 35).length
|
||||
|
||||
} else {
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 80).join("\n"), {
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, isPackingSlip ? 68 : 80).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
rowTextLines = splitStringBySpace(row.text, 80).length
|
||||
rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
|
||||
}
|
||||
|
||||
let rowDescriptionLines = 0
|
||||
|
||||
if (row.descriptionText) {
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
})
|
||||
|
||||
} else {
|
||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), {
|
||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
@@ -466,7 +492,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
}
|
||||
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
|
||||
...getCoordinatesForPDFLib(135, rowHeight, page1),
|
||||
size: 10,
|
||||
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (isPackingSlip) {
|
||||
page.drawText("Check", {
|
||||
...getCoordinatesForPDFLib(180, 22, page1),
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
}
|
||||
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
page.drawText("Steuer", {
|
||||
...getCoordinatesForPDFLib(135, 22, page1),
|
||||
size: 12,
|
||||
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
let endTextDiff = 35
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawLine({
|
||||
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
||||
end: getCoordinatesForPDFLib(198, rowHeight, page1),
|
||||
@@ -864,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
opacity: 1,
|
||||
maxWidth: 500
|
||||
})
|
||||
|
||||
return await pdfDoc.saveAsBase64()
|
||||
}
|
||||
|
||||
return await pdfDoc.saveAsBase64()
|
||||
|
||||
}
|
||||
|
||||
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
|
||||
@@ -1138,4 +1176,4 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
||||
console.log(error)
|
||||
throw error; // Fehler weiterwerfen, damit er oben ankommt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
backend/src/utils/profileBranches.ts
Normal file
142
backend/src/utils/profileBranches.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import { authProfileBranches, authProfiles, branches } from "../../db/schema"
|
||||
|
||||
function normalizeBranchIds(values: any[]): number[] {
|
||||
return [...new Set(
|
||||
values
|
||||
.map((value) => {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "string" && value.trim()) return Number(value)
|
||||
if (value && typeof value === "object" && "id" in value) return Number(value.id)
|
||||
return NaN
|
||||
})
|
||||
.filter((value) => Number.isFinite(value))
|
||||
)]
|
||||
}
|
||||
|
||||
export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) {
|
||||
if (!profiles.length) return profiles
|
||||
|
||||
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
|
||||
if (!profileIds.length) return profiles
|
||||
|
||||
const profileBranchRows = await server.db
|
||||
.select()
|
||||
.from(authProfileBranches)
|
||||
.where(inArray(authProfileBranches.profile_id, profileIds))
|
||||
|
||||
const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))]
|
||||
const branchRows = branchIds.length
|
||||
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
|
||||
: []
|
||||
|
||||
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
|
||||
const branchIdsByProfile = new Map<string, number[]>()
|
||||
|
||||
for (const row of profileBranchRows) {
|
||||
const current = branchIdsByProfile.get(row.profile_id) || []
|
||||
current.push(row.branch_id)
|
||||
branchIdsByProfile.set(row.profile_id, current)
|
||||
}
|
||||
|
||||
return profiles.map((profile) => {
|
||||
const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])]
|
||||
return {
|
||||
...profile,
|
||||
branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null,
|
||||
branches: assignedBranchIds
|
||||
.map((branchId) => branchMap.get(branchId))
|
||||
.filter(Boolean),
|
||||
branch_ids: assignedBranchIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, profileId),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!rows.length) return null
|
||||
|
||||
const [profile] = await enrichProfilesWithBranches(server, rows)
|
||||
return profile
|
||||
}
|
||||
|
||||
export async function resolveTenantBranchIds(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
values: any[],
|
||||
primaryBranchId?: any
|
||||
) {
|
||||
const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === ""
|
||||
? null
|
||||
: Number(primaryBranchId)
|
||||
|
||||
const requestedBranchIds = normalizeBranchIds([
|
||||
...values,
|
||||
normalizedPrimaryBranchId,
|
||||
])
|
||||
|
||||
if (!requestedBranchIds.length) {
|
||||
return {
|
||||
primaryBranchId: normalizedPrimaryBranchId,
|
||||
branchIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const validBranches = await server.db
|
||||
.select({ id: branches.id })
|
||||
.from(branches)
|
||||
.where(
|
||||
and(
|
||||
eq(branches.tenant, tenantId),
|
||||
inArray(branches.id, requestedBranchIds)
|
||||
)
|
||||
)
|
||||
|
||||
const validBranchIds = validBranches.map((branch) => branch.id)
|
||||
|
||||
if (validBranchIds.length !== requestedBranchIds.length) {
|
||||
throw new Error("INVALID_BRANCH_SELECTION")
|
||||
}
|
||||
|
||||
if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) {
|
||||
throw new Error("INVALID_PRIMARY_BRANCH")
|
||||
}
|
||||
|
||||
return {
|
||||
primaryBranchId: normalizedPrimaryBranchId,
|
||||
branchIds: validBranchIds,
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncProfileBranches(
|
||||
server: FastifyInstance,
|
||||
profileId: string,
|
||||
branchIds: number[],
|
||||
userId?: string | null
|
||||
) {
|
||||
await server.db
|
||||
.delete(authProfileBranches)
|
||||
.where(eq(authProfileBranches.profile_id, profileId))
|
||||
|
||||
if (!branchIds.length) return
|
||||
|
||||
await server.db
|
||||
.insert(authProfileBranches)
|
||||
.values(branchIds.map((branchId) => ({
|
||||
profile_id: profileId,
|
||||
branch_id: branchId,
|
||||
created_by: userId || null,
|
||||
})))
|
||||
}
|
||||
117
backend/src/utils/profileTeams.ts
Normal file
117
backend/src/utils/profileTeams.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import { authProfileTeams, branches, teams } from "../../db/schema"
|
||||
|
||||
function normalizeTeamIds(values: any[]): number[] {
|
||||
return [...new Set(
|
||||
values
|
||||
.map((value) => {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "string" && value.trim()) return Number(value)
|
||||
if (value && typeof value === "object" && "id" in value) return Number(value.id)
|
||||
return NaN
|
||||
})
|
||||
.filter((value) => Number.isFinite(value))
|
||||
)]
|
||||
}
|
||||
|
||||
export async function enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) {
|
||||
if (!profiles.length) return profiles
|
||||
|
||||
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
|
||||
if (!profileIds.length) return profiles
|
||||
|
||||
const profileTeamRows = await server.db
|
||||
.select()
|
||||
.from(authProfileTeams)
|
||||
.where(inArray(authProfileTeams.profile_id, profileIds))
|
||||
|
||||
const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))]
|
||||
const teamRows = teamIds.length
|
||||
? await server.db.select().from(teams).where(inArray(teams.id, teamIds))
|
||||
: []
|
||||
|
||||
const branchIds = [...new Set(teamRows.map((team) => team.branch).filter(Boolean))]
|
||||
const branchRows = branchIds.length
|
||||
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
|
||||
: []
|
||||
|
||||
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
|
||||
const teamMap = new Map(teamRows.map((team) => [
|
||||
team.id,
|
||||
{
|
||||
...team,
|
||||
branch: team.branch ? branchMap.get(team.branch) || null : null,
|
||||
},
|
||||
]))
|
||||
const teamIdsByProfile = new Map<string, number[]>()
|
||||
|
||||
for (const row of profileTeamRows) {
|
||||
const current = teamIdsByProfile.get(row.profile_id) || []
|
||||
current.push(row.team_id)
|
||||
teamIdsByProfile.set(row.profile_id, current)
|
||||
}
|
||||
|
||||
return profiles.map((profile) => {
|
||||
const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])]
|
||||
return {
|
||||
...profile,
|
||||
teams: assignedTeamIds
|
||||
.map((teamId) => teamMap.get(teamId))
|
||||
.filter(Boolean),
|
||||
team_ids: assignedTeamIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveTenantTeamIds(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
values: any[],
|
||||
) {
|
||||
const requestedTeamIds = normalizeTeamIds(values)
|
||||
|
||||
if (!requestedTeamIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const validTeams = await server.db
|
||||
.select({ id: teams.id })
|
||||
.from(teams)
|
||||
.where(
|
||||
and(
|
||||
eq(teams.tenant, tenantId),
|
||||
inArray(teams.id, requestedTeamIds)
|
||||
)
|
||||
)
|
||||
|
||||
const validTeamIds = validTeams.map((team) => team.id)
|
||||
|
||||
if (validTeamIds.length !== requestedTeamIds.length) {
|
||||
throw new Error("INVALID_TEAM_SELECTION")
|
||||
}
|
||||
|
||||
return validTeamIds
|
||||
}
|
||||
|
||||
export async function syncProfileTeams(
|
||||
server: FastifyInstance,
|
||||
profileId: string,
|
||||
teamIds: number[],
|
||||
userId?: string | null
|
||||
) {
|
||||
await server.db
|
||||
.delete(authProfileTeams)
|
||||
.where(eq(authProfileTeams.profile_id, profileId))
|
||||
|
||||
if (!teamIds.length) return
|
||||
|
||||
await server.db
|
||||
.insert(authProfileTeams)
|
||||
.values(teamIds.map((teamId) => ({
|
||||
profile_id: profileId,
|
||||
team_id: teamId,
|
||||
created_by: userId || null,
|
||||
})))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
bankaccounts,
|
||||
bankrequisitions,
|
||||
bankstatements,
|
||||
branches,
|
||||
entitybankaccounts,
|
||||
events,
|
||||
contacts,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
spaces,
|
||||
statementallocations,
|
||||
tasks,
|
||||
teams,
|
||||
texttemplates,
|
||||
units,
|
||||
vehicles,
|
||||
@@ -162,9 +164,23 @@ export const resourceConfig = {
|
||||
costcentres: {
|
||||
table: costcentres,
|
||||
searchColumns: ["name","number","description"],
|
||||
mtoLoad: ["vehicle","project","inventoryitem"],
|
||||
mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
|
||||
numberRangeHolder: "number",
|
||||
},
|
||||
parentCostcentre: {
|
||||
table: costcentres,
|
||||
searchColumns: ["name", "number", "description"],
|
||||
},
|
||||
branches: {
|
||||
table: branches,
|
||||
searchColumns: ["name","number","description"],
|
||||
numberRangeHolder: "number",
|
||||
},
|
||||
teams: {
|
||||
table: teams,
|
||||
searchColumns: ["name", "description"],
|
||||
mtoLoad: ["branch"],
|
||||
},
|
||||
tasks: {
|
||||
table: tasks,
|
||||
},
|
||||
@@ -186,7 +202,7 @@ export const resourceConfig = {
|
||||
table: createddocuments,
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
|
||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||
mtmListLoad: ["statementallocations"],
|
||||
mtmListLoad: ["statementallocations", "files"],
|
||||
},
|
||||
texttemplates: {
|
||||
table: texttemplates
|
||||
|
||||
9
docker-compose.docs.yml
Normal file
9
docker-compose.docs.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
docs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docs-site/Dockerfile
|
||||
container_name: fedeo-docs
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3205:3000"
|
||||
@@ -17,10 +17,35 @@ services:
|
||||
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-frontend.priority=1"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
||||
docs:
|
||||
image: git.federspiel.tech/flfeders/fedeo/docs:dev
|
||||
restart: always
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=3000"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||
- "traefik.http.routers.fedeo-docs.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-docs.priority=120"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip"
|
||||
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||
restart: always
|
||||
@@ -90,4 +115,4 @@ services:
|
||||
- traefik
|
||||
networks:
|
||||
traefik:
|
||||
external: false
|
||||
external: false
|
||||
|
||||
3
docs-site/.dockerignore
Normal file
3
docs-site/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
3
docs-site/.gitignore
vendored
Normal file
3
docs-site/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
19
docs-site/Dockerfile
Normal file
19
docs-site/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app/docs-site
|
||||
|
||||
COPY docs-site/package.json docs-site/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY docs-site ./
|
||||
COPY docs /app/docs
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app/docs-site
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/docs-site/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
35
docs-site/README.md
Normal file
35
docs-site/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# FEDEO Docs Site (Nuxt UI + Nuxt Content)
|
||||
|
||||
Diese Docs-App nutzt den Standardstil des offiziellen Nuxt-UI-Docs-Templates und rendert Inhalte aus `docs/`.
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
Im Ordner `docs-site` ausführen:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Danach ist die App unter `http://localhost:3005` erreichbar.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Production-Deploy
|
||||
|
||||
Das Docker-Image startet einen Nuxt Node-Server auf Port `3000`.
|
||||
In der Haupt-`docker-compose.yml` ist der Service hinter Traefik unter `/docs` veröffentlicht.
|
||||
|
||||
## Content-Synchronisierung
|
||||
|
||||
Vor `dev` und `build` wird automatisch synchronisiert:
|
||||
|
||||
- Quelle: `../docs/**/*.md`
|
||||
- Ziel: `docs-site/content`
|
||||
|
||||
Dabei wird `docs/README.md` zu `content/index.md` gemappt.
|
||||
56
docs-site/app/app.config.ts
Normal file
56
docs-site/app/app.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'slate'
|
||||
},
|
||||
footer: {
|
||||
slots: {
|
||||
root: 'border-t border-default',
|
||||
left: 'text-sm text-muted'
|
||||
}
|
||||
}
|
||||
},
|
||||
seo: {
|
||||
siteName: 'FEDEO Bedienung'
|
||||
},
|
||||
header: {
|
||||
title: 'FEDEO Bedienung',
|
||||
to: '/bedienung',
|
||||
search: true,
|
||||
colorMode: true,
|
||||
links: [
|
||||
{
|
||||
icon: 'i-simple-icons-github',
|
||||
to: 'https://git.federspiel.tech/flfeders/FEDEO',
|
||||
target: '_blank',
|
||||
'aria-label': 'Repository'
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
credits: `Built with Nuxt UI • © ${new Date().getFullYear()} FEDEO`,
|
||||
colorMode: false,
|
||||
links: [
|
||||
{
|
||||
icon: 'i-simple-icons-github',
|
||||
to: 'https://git.federspiel.tech/flfeders/FEDEO',
|
||||
target: '_blank',
|
||||
'aria-label': 'Repository'
|
||||
}
|
||||
]
|
||||
},
|
||||
toc: {
|
||||
title: 'Inhaltsverzeichnis',
|
||||
bottom: {
|
||||
title: 'Links',
|
||||
links: [
|
||||
{
|
||||
icon: 'i-lucide-book-open',
|
||||
label: 'Bedienung',
|
||||
to: '/bedienung'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
44
docs-site/app/app.vue
Normal file
44
docs-site/app/app.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
const { seo } = useAppConfig()
|
||||
|
||||
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
|
||||
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
|
||||
server: false
|
||||
})
|
||||
|
||||
useHead({
|
||||
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
|
||||
htmlAttrs: { lang: 'de' }
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
titleTemplate: `%s - ${seo?.siteName}`,
|
||||
ogSiteName: seo?.siteName,
|
||||
twitterCard: 'summary_large_image'
|
||||
})
|
||||
|
||||
provide('navigation', navigation)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<NuxtLoadingIndicator />
|
||||
|
||||
<AppHeader />
|
||||
|
||||
<UMain>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UMain>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
<ClientOnly>
|
||||
<LazyUContentSearch
|
||||
:files="files"
|
||||
:navigation="navigation"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</UApp>
|
||||
</template>
|
||||
25
docs-site/app/assets/css/main.css
Normal file
25
docs-site/app/assets/css/main.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@source "../../../content/**/*";
|
||||
|
||||
@theme static {
|
||||
--container-8xl: 90rem;
|
||||
--font-sans: 'Public Sans', sans-serif;
|
||||
|
||||
--color-green-50: #EFFDF5;
|
||||
--color-green-100: #D9FBE8;
|
||||
--color-green-200: #B3F5D1;
|
||||
--color-green-300: #75EDAE;
|
||||
--color-green-400: #00DC82;
|
||||
--color-green-500: #00C16A;
|
||||
--color-green-600: #00A155;
|
||||
--color-green-700: #007F45;
|
||||
--color-green-800: #016538;
|
||||
--color-green-900: #0A5331;
|
||||
--color-green-950: #052E16;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ui-container: var(--container-8xl);
|
||||
}
|
||||
23
docs-site/app/components/AppFooter.vue
Normal file
23
docs-site/app/components/AppFooter.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const { footer } = useAppConfig()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UFooter>
|
||||
<template #left>
|
||||
{{ footer.credits }}
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UColorModeButton v-if="footer?.colorMode" />
|
||||
|
||||
<template v-if="footer?.links">
|
||||
<UButton
|
||||
v-for="(link, index) of footer?.links"
|
||||
:key="index"
|
||||
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</UFooter>
|
||||
</template>
|
||||
49
docs-site/app/components/AppHeader.vue
Normal file
49
docs-site/app/components/AppHeader.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContentNavigationItem } from '@nuxt/content'
|
||||
|
||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
||||
const { header } = useAppConfig()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UHeader
|
||||
:ui="{ center: 'flex-1' }"
|
||||
:to="header?.to || '/'"
|
||||
>
|
||||
<UContentSearchButton
|
||||
v-if="header?.search"
|
||||
:collapsed="false"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<template #left>
|
||||
<NuxtLink :to="header?.to || '/'">
|
||||
<AppLogo class="w-auto h-6 shrink-0" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UContentSearchButton
|
||||
v-if="header?.search"
|
||||
class="lg:hidden"
|
||||
/>
|
||||
|
||||
<UColorModeButton v-if="header?.colorMode" />
|
||||
|
||||
<template v-if="header?.links">
|
||||
<UButton
|
||||
v-for="(link, index) of header.links"
|
||||
:key="index"
|
||||
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UContentNavigation
|
||||
highlight
|
||||
:navigation="navigation"
|
||||
/>
|
||||
</template>
|
||||
</UHeader>
|
||||
</template>
|
||||
3
docs-site/app/components/AppLogo.vue
Normal file
3
docs-site/app/components/AppLogo.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<span class="font-semibold text-primary">FEDEO Docs</span>
|
||||
</template>
|
||||
23
docs-site/app/components/PageHeaderLinks.vue
Normal file
23
docs-site/app/components/PageHeaderLinks.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
|
||||
const repoBase = 'https://git.federspiel.tech/flfeders/FEDEO/src/branch/dev/docs'
|
||||
|
||||
const sourceUrl = computed(() => {
|
||||
if (route.path === '/') {
|
||||
return `${repoBase}/README.md`
|
||||
}
|
||||
return `${repoBase}${route.path}.md`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-file-text"
|
||||
label="Quellseite"
|
||||
:to="sourceUrl"
|
||||
target="_blank"
|
||||
/>
|
||||
</template>
|
||||
31
docs-site/app/error.vue
Normal file
31
docs-site/app/error.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
defineProps<{
|
||||
error: NuxtError
|
||||
}>()
|
||||
|
||||
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
|
||||
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
|
||||
server: false
|
||||
})
|
||||
|
||||
provide('navigation', navigation)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<AppHeader />
|
||||
|
||||
<UError :error="error" />
|
||||
|
||||
<AppFooter />
|
||||
|
||||
<ClientOnly>
|
||||
<LazyUContentSearch
|
||||
:files="files"
|
||||
:navigation="navigation"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</UApp>
|
||||
</template>
|
||||
22
docs-site/app/layouts/docs.vue
Normal file
22
docs-site/app/layouts/docs.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContentNavigationItem } from '@nuxt/content'
|
||||
|
||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContainer>
|
||||
<UPage>
|
||||
<template #left>
|
||||
<UPageAside>
|
||||
<UContentNavigation
|
||||
highlight
|
||||
:navigation="navigation"
|
||||
/>
|
||||
</UPageAside>
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
</UPage>
|
||||
</UContainer>
|
||||
</template>
|
||||
97
docs-site/app/pages/[...slug].vue
Normal file
97
docs-site/app/pages/[...slug].vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContentNavigationItem } from '@nuxt/content'
|
||||
import { findPageHeadline } from '@nuxt/content/utils'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'docs'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { toc } = useAppConfig()
|
||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
||||
|
||||
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
|
||||
if (!page.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Seite nicht gefunden', fatal: true })
|
||||
}
|
||||
|
||||
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
|
||||
return queryCollectionItemSurroundings('docs', route.path, {
|
||||
fields: ['description']
|
||||
})
|
||||
})
|
||||
|
||||
const title = page.value.seo?.title || page.value.title
|
||||
const description = page.value.seo?.description || page.value.description
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
ogTitle: title,
|
||||
description,
|
||||
ogDescription: description
|
||||
})
|
||||
|
||||
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
|
||||
const links = computed(() => [...(toc?.bottom?.links || [])].filter(Boolean))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage v-if="page">
|
||||
<UPageHeader
|
||||
:title="page.title"
|
||||
:description="page.description"
|
||||
:headline="headline"
|
||||
>
|
||||
<template #links>
|
||||
<UButton
|
||||
v-for="(link, index) in page.links"
|
||||
:key="index"
|
||||
v-bind="link"
|
||||
/>
|
||||
|
||||
<PageHeaderLinks />
|
||||
</template>
|
||||
</UPageHeader>
|
||||
|
||||
<UPageBody>
|
||||
<ContentRenderer
|
||||
v-if="page"
|
||||
:value="page"
|
||||
/>
|
||||
|
||||
<USeparator v-if="surround?.length" />
|
||||
|
||||
<UContentSurround :surround="surround" />
|
||||
</UPageBody>
|
||||
|
||||
<template
|
||||
v-if="page?.body?.toc?.links?.length"
|
||||
#right
|
||||
>
|
||||
<UContentToc
|
||||
:title="toc?.title"
|
||||
:links="page.body?.toc?.links"
|
||||
>
|
||||
<template
|
||||
v-if="toc?.bottom"
|
||||
#bottom
|
||||
>
|
||||
<div
|
||||
class="hidden lg:block space-y-6"
|
||||
:class="{ 'mt-6!': page.body?.toc?.links?.length }"
|
||||
>
|
||||
<USeparator
|
||||
v-if="page.body?.toc?.links?.length"
|
||||
type="dashed"
|
||||
/>
|
||||
|
||||
<UPageLinks
|
||||
:title="toc.bottom.title"
|
||||
:links="links"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UContentToc>
|
||||
</template>
|
||||
</UPage>
|
||||
</template>
|
||||
7
docs-site/app/pages/index.vue
Normal file
7
docs-site/app/pages/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo('/bedienung', { redirectCode: 302 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
18
docs-site/content.config.ts
Normal file
18
docs-site/content.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
|
||||
|
||||
export default defineContentConfig({
|
||||
collections: {
|
||||
docs: defineCollection({
|
||||
type: 'page',
|
||||
source: '**',
|
||||
schema: z.object({
|
||||
links: z.array(z.object({
|
||||
label: z.string(),
|
||||
icon: z.string(),
|
||||
to: z.string(),
|
||||
target: z.string().optional()
|
||||
})).optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
42
docs-site/content/bedienung/ausgangsbelege.md
Normal file
42
docs-site/content/bedienung/ausgangsbelege.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Ausgangsbelege erstellen und bearbeiten
|
||||
|
||||
Diese Anleitung hilft dir beim Erstellen von Rechnungen, Angeboten, Lieferscheinen und ähnlichen Belegen.
|
||||
|
||||
## Typischer Ablauf
|
||||
|
||||
1. Neuen Beleg anlegen.
|
||||
2. Belegart wählen (z. B. Rechnung oder Angebot).
|
||||
3. Kunden- und Adressdaten prüfen.
|
||||
4. Positionen eintragen.
|
||||
5. Daten wie Belegdatum und Zahlungsziel prüfen.
|
||||
6. Beleg speichern und bei Bedarf buchen.
|
||||
|
||||
## Wichtige Eingaben einfach erklärt
|
||||
|
||||
- `Belegart`: Legt fest, welche Art von Dokument erstellt wird.
|
||||
- `Kunde`: Empfänger des Belegs.
|
||||
- `Ansprechpartner`: Person beim Kunden für Rückfragen.
|
||||
- `Adresse`: Zieladresse auf dem Dokument.
|
||||
- `Belegnummer`: Eindeutige Nummer zur Wiedererkennung.
|
||||
- `Belegdatum`: Offizielles Ausstellungsdatum.
|
||||
- `Liefer-/Leistungsdatum`: Zeitraum oder Datum der Leistung.
|
||||
- `Zahlungsziel`: Frist für den Zahlungseingang.
|
||||
- `Zahlungsart`: Überweisung oder Lastschrift.
|
||||
- `Positionen`: Leistungen oder Artikel mit Menge, Preis und Steuersatz.
|
||||
|
||||
## Empfehlungen für fehlerfreie Belege
|
||||
|
||||
- Vor dem Buchen immer Kunde, Datum und Belegnummer prüfen.
|
||||
- Bei Rechnungen Zahlungsziel und Zahlungsart kontrollieren.
|
||||
- Bei Zeiträumen Start- und Enddatum vollständig setzen.
|
||||
- Vorschau prüfen, bevor der Beleg verschickt wird.
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
### Warum kann ich nicht buchen?
|
||||
|
||||
Meist fehlt eine Pflichtangabe wie Kunde, Briefpapier, Datum oder eine gültige Position.
|
||||
|
||||
### Wann ist ein Beleg im Kundenportal sichtbar?
|
||||
|
||||
Nur wenn die Freigabe aktiv ist und der Beleg nicht mehr im Entwurfsstatus steht.
|
||||
41
docs-site/content/bedienung/bankportal.md
Normal file
41
docs-site/content/bedienung/bankportal.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Bankportal nutzen
|
||||
|
||||
Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordnung zu Belegen.
|
||||
|
||||
## Ziele im Bankportal
|
||||
|
||||
- Kontobewegungen aktuell halten
|
||||
- Offene Zahlungsein- und -ausgänge schneller zuordnen
|
||||
- Buchhaltungsprozesse vorbereiten
|
||||
|
||||
## Typischer Arbeitsablauf
|
||||
|
||||
1. Kontoverbindung prüfen oder aktualisieren.
|
||||
2. Neue Umsätze abrufen.
|
||||
3. Offene Bewegungen sichten.
|
||||
4. Vorschläge zur Zuordnung prüfen.
|
||||
5. Passende Belege oder Konten zuweisen.
|
||||
6. Ergebnis kontrollieren.
|
||||
|
||||
## Wichtige Bereiche
|
||||
|
||||
- `Umsatzliste`: Zeigt alle importierten Bankbewegungen.
|
||||
- `Filter/Suche`: Hilft beim schnellen Finden einzelner Vorgänge.
|
||||
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
|
||||
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
|
||||
|
||||
## Gute Praxis
|
||||
|
||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||
- Unklare Buchungen zeitnah klären.
|
||||
- Bei wiederkehrenden Zahlungen auf konsistente Bezeichnung achten.
|
||||
|
||||
## Häufige Probleme
|
||||
|
||||
### Ein Umsatz wird nicht automatisch zugeordnet
|
||||
|
||||
Prüfe Betrag, Datum, Verwendungszweck und ob ein passender Beleg im System vorhanden ist.
|
||||
|
||||
### Es erscheinen doppelte oder fehlende Umsätze
|
||||
|
||||
Kontoverbindung aktualisieren und den Zeitraum der Synchronisation prüfen.
|
||||
13
docs-site/content/bedienung/index.md
Normal file
13
docs-site/content/bedienung/index.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Bedienung
|
||||
|
||||
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
|
||||
|
||||
## Bereiche
|
||||
|
||||
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
|
||||
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
|
||||
- [Bankportal nutzen](./bankportal.md)
|
||||
|
||||
## Für wen ist diese Anleitung?
|
||||
|
||||
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.
|
||||
31
docs-site/content/bedienung/serienrechnungen.md
Normal file
31
docs-site/content/bedienung/serienrechnungen.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Serienrechnungen planen und ausführen
|
||||
|
||||
Mit Serienrechnungen kannst du wiederkehrende Abrechnungen automatisieren.
|
||||
|
||||
## Wofür nutzt man Serienrechnungen?
|
||||
|
||||
- Regelmäßige Leistungen (z. B. monatliche Betreuung)
|
||||
- Verträge mit festen Abständen
|
||||
- Einheitliche Abrechnung ohne manuelles Neuerstellen jedes Belegs
|
||||
|
||||
## So legst du eine Serienrechnung an
|
||||
|
||||
1. Neue Serienrechnung öffnen.
|
||||
2. Kunde, Leistungen und Preise eintragen.
|
||||
3. Intervall festlegen (z. B. monatlich, quartalsweise).
|
||||
4. Start- und Endzeitraum definieren.
|
||||
5. Vorlage aktivieren und speichern.
|
||||
|
||||
## Manuelle Ausführung eines Laufs
|
||||
|
||||
1. Serienrechnungsübersicht öffnen.
|
||||
2. Ausführungsdatum setzen.
|
||||
3. Gewünschte Vorlagen auswählen.
|
||||
4. Lauf starten.
|
||||
5. Ergebnis prüfen und ggf. abschließen.
|
||||
|
||||
## Wichtige Hinweise
|
||||
|
||||
- Das Ausführungsdatum wirkt sich auf den Leistungszeitraum aus.
|
||||
- Vor dem Lauf prüfen, ob alle Vorlagen aktiv und vollständig sind.
|
||||
- Bei ungewöhnlichen Ergebnissen zuerst Beträge und Intervalle der Vorlage prüfen.
|
||||
7
docs-site/content/index.md
Normal file
7
docs-site/content/index.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Bedienungsanleitung
|
||||
|
||||
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
|
||||
## Einstieg
|
||||
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
27
docs-site/nuxt.config.ts
Normal file
27
docs-site/nuxt.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxt/image',
|
||||
'@nuxt/ui',
|
||||
'@nuxt/content'
|
||||
],
|
||||
css: ['~/assets/css/main.css'],
|
||||
content: {
|
||||
build: {
|
||||
markdown: {
|
||||
toc: {
|
||||
searchDepth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
experimental: {
|
||||
asyncContext: true
|
||||
},
|
||||
compatibilityDate: '2024-07-11',
|
||||
nitro: {
|
||||
preset: 'node-server'
|
||||
},
|
||||
icon: {
|
||||
provider: 'iconify'
|
||||
}
|
||||
})
|
||||
16365
docs-site/package-lock.json
generated
Normal file
16365
docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
docs-site/package.json
Normal file
26
docs-site/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "fedeo-docs-site",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node ./scripts/sync-content.mjs && nuxt build",
|
||||
"dev": "node ./scripts/sync-content.mjs && nuxt dev --host 0.0.0.0 --port 3005",
|
||||
"preview": "nuxt preview --host 0.0.0.0 --port 3005",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.102",
|
||||
"@iconify-json/simple-icons": "^1.2.78",
|
||||
"@nuxt/content": "^3.12.0",
|
||||
"@nuxt/image": "^2.0.0",
|
||||
"@nuxt/ui": "^4.6.1",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"nuxt": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
}
|
||||
62
docs-site/scripts/sync-content.mjs
Executable file
62
docs-site/scripts/sync-content.mjs
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = process.cwd()
|
||||
const DOCS_SOURCE = path.resolve(ROOT, '../docs')
|
||||
const CONTENT_TARGET = path.resolve(ROOT, 'content')
|
||||
|
||||
async function ensureDir(dirPath) {
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
async function clearDir(dirPath) {
|
||||
await fs.rm(dirPath, { recursive: true, force: true })
|
||||
await ensureDir(dirPath)
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const out = []
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...(await walk(full)))
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(full)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await clearDir(CONTENT_TARGET)
|
||||
const files = await walk(DOCS_SOURCE)
|
||||
|
||||
for (const file of files) {
|
||||
const rel = toPosix(path.relative(DOCS_SOURCE, file))
|
||||
let targetRel = rel
|
||||
|
||||
if (rel === 'README.md') {
|
||||
targetRel = 'index.md'
|
||||
} else if (rel.endsWith('/README.md')) {
|
||||
targetRel = `${rel.slice(0, -'/README.md'.length)}/index.md`
|
||||
}
|
||||
|
||||
const target = path.join(CONTENT_TARGET, targetRel)
|
||||
await ensureDir(path.dirname(target))
|
||||
await fs.copyFile(file, target)
|
||||
}
|
||||
|
||||
console.log(`Nuxt-Content Synchronisierung abgeschlossen: ${files.length} Dateien`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fehler bei der Content-Synchronisierung', err)
|
||||
process.exit(1)
|
||||
})
|
||||
3
docs-site/tsconfig.json
Normal file
3
docs-site/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
7
docs/README.md
Normal file
7
docs/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Bedienungsanleitung
|
||||
|
||||
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
|
||||
## Einstieg
|
||||
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
13
docs/bedienung/README.md
Normal file
13
docs/bedienung/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Bedienung
|
||||
|
||||
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
|
||||
|
||||
## Bereiche
|
||||
|
||||
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
|
||||
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
|
||||
- [Bankportal nutzen](./bankportal.md)
|
||||
|
||||
## Für wen ist diese Anleitung?
|
||||
|
||||
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user