Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 468f8b51f6 | |||
| e9697ef197 | |||
| 549b469a41 | |||
| bd768e404d | |||
| af4a283eb5 | |||
| 28f24aa998 | |||
| 05b0c12841 | |||
| c46c50a2a3 | |||
| 72ff7addaa | |||
| d4c4a2b9c1 | |||
| 6ae8b7ad9e | |||
| 544d607088 | |||
| c10bfac23b | |||
| 1b617c7910 | |||
| 9e003125c2 | |||
| 76e82eae4f | |||
| f814689ddd | |||
| 755f1b7e0d | |||
| c6fd53307c | |||
| 0777315d1d | |||
| 9676a270da | |||
| 36dffc057a | |||
| ea31a15eac | |||
| f0775ad52a | |||
| e0a94f5e29 | |||
| da84e98b8a | |||
| bfba29b15e | |||
| 7a1986bbb8 | |||
| f3b4fac4c0 | |||
| f56a07f8e3 | |||
| 7b61857fb8 | |||
| 697f538b6e | |||
| e933782731 | |||
| ee8f16056f | |||
| 683be7761d | |||
| 42242390f3 | |||
| 4ffafef3f1 | |||
| 5dc9e686fc | |||
| 6eea3c0687 | |||
| 89185b4b08 | |||
| aa08f5c358 | |||
| 2c1047e624 |
3
backend/db/migrations/0082_archive_users.sql
Normal file
3
backend/db/migrations/0082_archive_users.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "auth_users" ADD COLUMN "archived_at" timestamp with time zone;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profiles" ADD COLUMN "archived_at" timestamp with time zone;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
INSERT INTO notifications_event_types (event_key, display_name, description, category, allowed_channels) VALUES
|
||||||
|
('collaboration.message.new', 'Zusammenarbeit: Chatnachrichten', 'Neue Nachrichten in freigegebenen Unterräumen und föderierten Chats.', 'collaboration', '["inapp","email","push"]'),
|
||||||
|
('collaboration.room.invited', 'Zusammenarbeit: Einladungen', 'Persönliche Einladungen in freigegebene Unterräume und föderierte Chats.', 'collaboration', '["inapp","email","push"]')
|
||||||
|
ON CONFLICT (event_key) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX notifications_collaboration_queued ON notifications_items(created_at)
|
||||||
|
WHERE status='queued' AND event_type IN ('collaboration.message.new','collaboration.room.invited');
|
||||||
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "tenants" ADD COLUMN "cashDiscountAccounts" jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_amount" double precision;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_split" jsonb;
|
||||||
15
backend/db/migrations/0085_cash_discount_terms.sql
Normal file
15
backend/db/migrations/0085_cash_discount_terms.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
ALTER TABLE "createddocuments" ADD COLUMN "cashDiscountPercent" double precision;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "createddocuments" ADD COLUMN "cashDiscountDays" smallint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "incominginvoices" ADD COLUMN "cashDiscountPercent" double precision;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "incominginvoices" ADD COLUMN "cashDiscountDueDate" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "customCashDiscountPercent" double precision;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "customCashDiscountDays" smallint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "vendors" ADD COLUMN "cashDiscountPercent" double precision;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "vendors" ADD COLUMN "cashDiscountDays" smallint;
|
||||||
@@ -554,6 +554,34 @@
|
|||||||
"when": 1791244800000,
|
"when": 1791244800000,
|
||||||
"tag": "0081_chat_media_reactions",
|
"tag": "0081_chat_media_reactions",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 79,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792022400000,
|
||||||
|
"tag": "0082_archive_users",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 80,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792022400001,
|
||||||
|
"tag": "0083_collaboration_notifications",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 81,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792108800001,
|
||||||
|
"tag": "0084_cash_discounts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 82,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792195200001,
|
||||||
|
"tag": "0085_cash_discount_terms",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
address_city: text("address_city"),
|
address_city: text("address_city"),
|
||||||
|
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
|
archived_at: timestamp("archived_at", { withTimezone: true }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AuthProfile = typeof authProfiles.$inferSelect
|
export type AuthProfile = typeof authProfiles.$inferSelect
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const authUsers = pgTable("auth_users", {
|
|||||||
multiTenant: boolean("multi_tenant").notNull().default(true),
|
multiTenant: boolean("multi_tenant").notNull().default(true),
|
||||||
must_change_password: boolean("must_change_password").notNull().default(false),
|
must_change_password: boolean("must_change_password").notNull().default(false),
|
||||||
is_admin: boolean("is_admin").notNull().default(false),
|
is_admin: boolean("is_admin").notNull().default(false),
|
||||||
|
archived_at: timestamp("archived_at", { withTimezone: true }),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export const createddocuments = pgTable("createddocuments", {
|
|||||||
|
|
||||||
deliveryDateType: text("deliveryDateType"),
|
deliveryDateType: text("deliveryDateType"),
|
||||||
paymentDays: smallint("paymentDays"),
|
paymentDays: smallint("paymentDays"),
|
||||||
|
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
|
||||||
|
cashDiscountDays: smallint("cashDiscountDays"),
|
||||||
deliveryDate: text("deliveryDate"),
|
deliveryDate: text("deliveryDate"),
|
||||||
|
|
||||||
contactPerson: uuid("contactPerson"),
|
contactPerson: uuid("contactPerson"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
smallint,
|
smallint,
|
||||||
uuid,
|
uuid,
|
||||||
|
doublePrecision,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
@@ -41,6 +42,8 @@ export const customers = pgTable(
|
|||||||
profiles: jsonb("profiles").notNull().default([]),
|
profiles: jsonb("profiles").notNull().default([]),
|
||||||
|
|
||||||
customPaymentDays: smallint("customPaymentDays"),
|
customPaymentDays: smallint("customPaymentDays"),
|
||||||
|
customCashDiscountPercent: doublePrecision("customCashDiscountPercent"),
|
||||||
|
customCashDiscountDays: smallint("customCashDiscountDays"),
|
||||||
|
|
||||||
firstname: text("firstname"),
|
firstname: text("firstname"),
|
||||||
lastname: text("lastname"),
|
lastname: text("lastname"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
numeric,
|
numeric,
|
||||||
uuid,
|
uuid,
|
||||||
|
doublePrecision,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { vendors } from "./vendors"
|
import { vendors } from "./vendors"
|
||||||
@@ -36,6 +37,8 @@ export const incominginvoices = pgTable("incominginvoices", {
|
|||||||
document: bigint("document", { mode: "number" }),
|
document: bigint("document", { mode: "number" }),
|
||||||
|
|
||||||
dueDate: text("dueDate"),
|
dueDate: text("dueDate"),
|
||||||
|
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
|
||||||
|
cashDiscountDueDate: text("cashDiscountDueDate"),
|
||||||
|
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
text,
|
text,
|
||||||
jsonb,
|
jsonb,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
index,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
@@ -48,7 +50,8 @@ export const notificationsItems = pgTable("notifications_items", {
|
|||||||
|
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||||
readAt: timestamp("read_at", { withTimezone: true }),
|
readAt: timestamp("read_at", { withTimezone: true }),
|
||||||
})
|
}, t => ({ collaborationQueued: index("notifications_collaboration_queued").on(t.createdAt)
|
||||||
|
.where(sql`${t.status}='queued' AND ${t.eventType} IN ('collaboration.message.new','collaboration.room.invited')`) }))
|
||||||
|
|
||||||
export type NotificationItem = typeof notificationsItems.$inferSelect
|
export type NotificationItem = typeof notificationsItems.$inferSelect
|
||||||
export type NewNotificationItem = typeof notificationsItems.$inferInsert
|
export type NewNotificationItem = typeof notificationsItems.$inferInsert
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
doublePrecision,
|
doublePrecision,
|
||||||
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -30,6 +31,10 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
|
|
||||||
amount: doublePrecision("amount").notNull().default(0),
|
amount: doublePrecision("amount").notNull().default(0),
|
||||||
|
|
||||||
|
// Skonto mit dem Vorzeichen der Zahlung; amount bleibt der tatsächlich gezahlte Betrag.
|
||||||
|
cashDiscountAmount: doublePrecision("cash_discount_amount"),
|
||||||
|
cashDiscountSplit: jsonb("cash_discount_split"),
|
||||||
|
|
||||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||||
() => incominginvoices.id
|
() => incominginvoices.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export const tenants = pgTable(
|
|||||||
}),
|
}),
|
||||||
accountChart: text("accountChart").notNull().default("skr03"),
|
accountChart: text("accountChart").notNull().default("skr03"),
|
||||||
|
|
||||||
|
cashDiscountAccounts: jsonb("cashDiscountAccounts").notNull().default({}),
|
||||||
|
|
||||||
standardEmailForInvoices: text("standardEmailForInvoices"),
|
standardEmailForInvoices: text("standardEmailForInvoices"),
|
||||||
|
|
||||||
extraModules: jsonb("extraModules").notNull().default([]),
|
extraModules: jsonb("extraModules").notNull().default([]),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
jsonb,
|
jsonb,
|
||||||
uuid,
|
uuid,
|
||||||
|
doublePrecision,
|
||||||
|
smallint,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -36,6 +38,8 @@ export const vendors = pgTable("vendors", {
|
|||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
defaultPaymentMethod: text("defaultPaymentMethod"),
|
defaultPaymentMethod: text("defaultPaymentMethod"),
|
||||||
|
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
|
||||||
|
cashDiscountDays: smallint("cashDiscountDays"),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test:einvoice": "tsx --test tests/einvoice.test.ts",
|
"test:einvoice": "tsx --test tests/einvoice.test.ts tests/zugferdInvoice.test.ts",
|
||||||
"start": "node dist/src/index.js",
|
"start": "node dist/src/index.js",
|
||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export const outgoingRowsSchema = {
|
|||||||
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
||||||
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
||||||
text: { type: "string" },
|
text: { type: "string" },
|
||||||
descriptionText: { type: "string" },
|
description: { type: "string", description: "Positionsbeschreibung. Ohne Angabe wird sie bei mode=normal/service aus Artikel bzw. Leistung übernommen, ebenso fehlender Name, Einheit, Preis und USt-Satz." },
|
||||||
|
descriptionText: { type: "string", description: "Veraltet; wird als description übernommen, wenn description fehlt." },
|
||||||
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
||||||
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
||||||
quantity: { type: "number", default: 1 },
|
quantity: { type: "number", default: 1 },
|
||||||
|
|||||||
93
backend/src/mcp/fileUploads.ts
Normal file
93
backend/src/mcp/fileUploads.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { assertToolPermission } from "./authz"
|
||||||
|
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
|
||||||
|
import { McpContext, McpTool } from "./types"
|
||||||
|
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
|
||||||
|
import { uploadIncomingInvoiceFile } from "./tools/accounting"
|
||||||
|
import { secrets } from "../utils/secrets"
|
||||||
|
|
||||||
|
export const MCP_UPLOAD_PATH = "/api/mcp/files"
|
||||||
|
export const uploadTargets = ["inbox", "incoming_invoice"] as const
|
||||||
|
export type UploadTarget = typeof uploadTargets[number]
|
||||||
|
|
||||||
|
export type McpFileUpload = {
|
||||||
|
target: UploadTarget
|
||||||
|
filename: string
|
||||||
|
body: Buffer
|
||||||
|
mimeType?: string | null
|
||||||
|
invoiceId?: number | null
|
||||||
|
prepare?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mcpUploadUrl() {
|
||||||
|
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
|
||||||
|
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
|
||||||
|
|
||||||
|
/** Reads the query options of the binary upload endpoint. */
|
||||||
|
export function parseUploadQuery(query: Record<string, unknown>) {
|
||||||
|
const target = (query.target ?? "inbox") as UploadTarget
|
||||||
|
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
|
||||||
|
let invoiceId: number | null = null
|
||||||
|
if (query.invoiceId !== undefined && query.invoiceId !== "") {
|
||||||
|
invoiceId = Number(query.invoiceId)
|
||||||
|
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
|
||||||
|
}
|
||||||
|
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
|
||||||
|
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
|
||||||
|
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
|
||||||
|
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
|
||||||
|
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
|
||||||
|
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
|
||||||
|
if (upload.target === "inbox") {
|
||||||
|
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
|
||||||
|
await assertInternalDocumentAccess(context)
|
||||||
|
const mimeType = validateScannerFile(upload.filename, upload.body)
|
||||||
|
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
|
||||||
|
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
|
||||||
|
}
|
||||||
|
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
|
||||||
|
return uploadIncomingInvoiceFile(context, {
|
||||||
|
filename: upload.filename,
|
||||||
|
buffer: upload.body,
|
||||||
|
mimeType: upload.mimeType || "application/octet-stream",
|
||||||
|
invoiceId: upload.invoiceId,
|
||||||
|
prepare: upload.prepare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadUrlTool: McpTool = {
|
||||||
|
name: "documents.files.upload_url",
|
||||||
|
title: "Upload-Adresse für Dateien",
|
||||||
|
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
|
||||||
|
// Informational only; the endpoint checks the permission of the chosen target.
|
||||||
|
requiredPermissions: [],
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
|
async handler() {
|
||||||
|
const url = mcpUploadUrl()
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
method: "POST",
|
||||||
|
authentication: "Authorization: Bearer <derselbe MCP-Token>",
|
||||||
|
bodies: {
|
||||||
|
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
|
||||||
|
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
|
||||||
|
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
|
||||||
|
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
|
||||||
|
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
|
||||||
|
},
|
||||||
|
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
|
||||||
|
examples: [
|
||||||
|
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
|
||||||
|
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { InvoicePdfLayout } from "../utils/pdf"
|
||||||
|
|
||||||
|
/** Typical letterheads reserve the bottom ~35 mm of A4 for their footer. */
|
||||||
|
export const DEFAULT_CONTENT_BOTTOM_MM = 262
|
||||||
|
|
||||||
|
type Warning = { type: string, page: number, message: string, insertPagebreakBeforeIndex?: number }
|
||||||
|
|
||||||
|
const round = (value: number) => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarises where rows land and suggests page breaks. FEDEO never breaks pages automatically,
|
||||||
|
* so content below the usable area runs into the letterhead footer or off the page.
|
||||||
|
*/
|
||||||
|
export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxContentBottomMm = DEFAULT_CONTENT_BOTTOM_MM) {
|
||||||
|
const warnings: Warning[] = []
|
||||||
|
const blocks = [
|
||||||
|
...layout.rows.filter((row) => row.mode !== "pagebreak").map((row) => ({ page: row.page, bottomMm: row.bottomMm })),
|
||||||
|
...(layout.totals ? [layout.totals] : []),
|
||||||
|
...(layout.endText ? [layout.endText] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const pages = Array.from({ length: layout.pageCount }, (_, offset) => {
|
||||||
|
const page = offset + 1
|
||||||
|
const rows = layout.rows.filter((row) => row.page === page && row.mode !== "pagebreak")
|
||||||
|
const contentBottomMm = Math.max(0, ...blocks.filter((block) => block.page === page).map((block) => block.bottomMm))
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
rows: rows.map((row) => ({ index: row.index, pos: row.pos, mode: row.mode, text: row.text, topMm: round(row.topMm), bottomMm: round(row.bottomMm) })),
|
||||||
|
contentBottomMm: round(contentBottomMm),
|
||||||
|
freeMm: round(maxContentBottomMm - contentBottomMm),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const overflowing = page.rows.find((row) => row.bottomMm > maxContentBottomMm)
|
||||||
|
if (overflowing) {
|
||||||
|
// Keep headings with their first position instead of leaving them at the page end.
|
||||||
|
let before = page.rows.indexOf(overflowing)
|
||||||
|
while (before > 0 && page.rows[before - 1].mode === "title") before -= 1
|
||||||
|
const target = page.rows[before]
|
||||||
|
warnings.push({
|
||||||
|
type: "overflow",
|
||||||
|
page: page.page,
|
||||||
|
message: `Position ${overflowing.pos || overflowing.index + 1} reicht bis ${overflowing.bottomMm} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
|
||||||
|
...(target.index > 0 ? { insertPagebreakBeforeIndex: target.index } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const last = page.rows[page.rows.length - 1]
|
||||||
|
if (last?.mode === "title" && page.page < layout.pageCount) {
|
||||||
|
warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index })
|
||||||
|
}
|
||||||
|
if (!page.rows.length && page.page > 1 && page.page !== layout.paymentQr?.page && !blocks.some((block) => block.page === page.page)) {
|
||||||
|
warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The renderer moves the payment QR code to an extra page when the amounts reach its spot.
|
||||||
|
const qr = layout.paymentQr
|
||||||
|
const qrAlone = qr && !blocks.some((block) => block.page === qr.page)
|
||||||
|
if (qr && qrAlone) {
|
||||||
|
const previous = pages[qr.page - 2]
|
||||||
|
const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm)
|
||||||
|
let before = reaching ? previous.rows.indexOf(reaching) : -1
|
||||||
|
while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1
|
||||||
|
warnings.push({
|
||||||
|
type: "paymentQrOwnPage",
|
||||||
|
page: qr.page,
|
||||||
|
message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`,
|
||||||
|
insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].index : layout.rows.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) {
|
||||||
|
if (!block || block.bottomMm <= maxContentBottomMm || pages[block.page - 1]?.rows.some((row) => row.bottomMm > maxContentBottomMm)) continue
|
||||||
|
warnings.push({
|
||||||
|
type: "overflow",
|
||||||
|
page: block.page,
|
||||||
|
message: `${name} reicht bis ${round(block.bottomMm)} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
|
||||||
|
insertPagebreakBeforeIndex: layout.rows.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
pageHeightMm: round(layout.pageHeightMm),
|
||||||
|
maxContentBottomMm,
|
||||||
|
pages,
|
||||||
|
totals: layout.totals && { page: layout.totals.page, topMm: round(layout.totals.topMm), bottomMm: round(layout.totals.bottomMm) },
|
||||||
|
endText: layout.endText && { page: layout.endText.page, topMm: round(layout.endText.topMm), bottomMm: round(layout.endText.bottomMm) },
|
||||||
|
paymentQr: layout.paymentQr && { page: layout.paymentQr.page, topMm: round(layout.paymentQr.topMm), leftMm: round(layout.paymentQr.leftMm) },
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
hint: "Seitenumbruch setzen: an der Stelle insertPagebreakBeforeIndex ein Objekt {\"mode\":\"pagebreak\"} in rows einfügen (Index 0-basiert; rows.length = vor Summenblock). Zuerst mit rows in dieser Vorschau prüfen, dann mit accounting.outgoing_documents.update speichern. Die Bilder sind maßgeblich; die mm-Werte sind Schätzungen des Renderers.",
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/mcp/outgoingRowDefaults.ts
Normal file
62
backend/src/mcp/outgoingRowDefaults.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { products, services } from "../../db/schema"
|
||||||
|
import { McpContext } from "./types"
|
||||||
|
|
||||||
|
const isMissing = (value: unknown) => value === undefined || value === null || value === ""
|
||||||
|
const ids = (rows: Record<string, any>[], mode: string, key: string) =>
|
||||||
|
[...new Set(rows.filter((row) => row.mode === mode && Number.isSafeInteger(Number(row[key])) && Number(row[key]) > 0).map((row) => Number(row[key])))]
|
||||||
|
|
||||||
|
const parseRow = (row: unknown) => {
|
||||||
|
if (typeof row !== "string") return row
|
||||||
|
try { return JSON.parse(row) } catch { return row }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the document editor: selecting a product or service fills name, description, unit,
|
||||||
|
* price, tax rate and (for services) cost centre. Values passed by the caller always win.
|
||||||
|
*/
|
||||||
|
export async function applyOutgoingRowCatalogDefaults(context: Pick<McpContext, "server" | "tenantId">, rows: unknown) {
|
||||||
|
if (!Array.isArray(rows)) return rows
|
||||||
|
const parsed = rows.map(parseRow)
|
||||||
|
const objects = parsed.filter((row): row is Record<string, any> => !!row && typeof row === "object" && !Array.isArray(row))
|
||||||
|
const productIds = ids(objects, "normal", "product")
|
||||||
|
const serviceIds = ids(objects, "service", "service")
|
||||||
|
const [productRows, serviceRows] = await Promise.all([
|
||||||
|
productIds.length ? context.server.db.select().from(products)
|
||||||
|
.where(and(eq(products.tenant, context.tenantId), eq(products.archived, false), inArray(products.id, productIds))) : [],
|
||||||
|
serviceIds.length ? context.server.db.select().from(services)
|
||||||
|
.where(and(eq(services.tenant, context.tenantId), eq(services.archived, false), inArray(services.id, serviceIds))) : [],
|
||||||
|
])
|
||||||
|
const productMap = new Map<number, any>(productRows.map((row: any) => [row.id, row] as const))
|
||||||
|
const serviceMap = new Map<number, any>(serviceRows.map((row: any) => [row.id, row] as const))
|
||||||
|
|
||||||
|
return parsed.map((row, index) => {
|
||||||
|
if (!row || typeof row !== "object" || Array.isArray(row)) return row
|
||||||
|
const result = { ...(row as Record<string, any>) }
|
||||||
|
let defaults: Record<string, unknown> | null = null
|
||||||
|
if (result.mode === "normal" && !isMissing(result.product)) {
|
||||||
|
const product = productMap.get(Number(result.product))
|
||||||
|
if (!product) throw new Error(`Position ${index + 1}: Artikel ${result.product} nicht gefunden oder archiviert`)
|
||||||
|
defaults = { text: product.name, description: product.description, unit: product.unit, price: product.selling_price, taxPercent: product.tax_percentage }
|
||||||
|
} else if (result.mode === "service" && !isMissing(result.service)) {
|
||||||
|
const service = serviceMap.get(Number(result.service))
|
||||||
|
if (!service) throw new Error(`Position ${index + 1}: Leistung ${result.service} nicht gefunden oder archiviert`)
|
||||||
|
defaults = {
|
||||||
|
text: service.name, description: service.description, unit: service.unit,
|
||||||
|
price: service.sellingPriceComposed?.total || service.sellingPrice, taxPercent: service.taxPercentage, costCentre: service.costcentre,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The editor stores the position text in description; descriptionText is only derived for the PDF.
|
||||||
|
if (isMissing(result.description) && !isMissing(result.descriptionText)) result.description = result.descriptionText
|
||||||
|
if (!defaults) return result
|
||||||
|
for (const key of ["text", "description", "unit", "costCentre"]) {
|
||||||
|
if (isMissing(result[key]) && !isMissing(defaults[key])) result[key] = defaults[key]
|
||||||
|
}
|
||||||
|
if (isMissing(result.price) && isMissing(result.inputPrice) && !isMissing(defaults.price)) {
|
||||||
|
result.inputPrice = Number(defaults.price)
|
||||||
|
result.price = Number(defaults.price)
|
||||||
|
}
|
||||||
|
if (isMissing(result.taxPercent) && !isMissing(defaults.taxPercent)) result.taxPercent = Number(defaults.taxPercent)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { masterdataTools } from "./tools/masterdata"
|
|||||||
import { organisationTools } from "./tools/organisation"
|
import { organisationTools } from "./tools/organisation"
|
||||||
import { wikiTools } from "./tools/wiki"
|
import { wikiTools } from "./tools/wiki"
|
||||||
import { documentTools } from "./tools/documents"
|
import { documentTools } from "./tools/documents"
|
||||||
|
import { archiveTools } from "./tools/archive"
|
||||||
|
import { uploadUrlTool } from "./fileUploads"
|
||||||
|
|
||||||
export const mcpTools = [
|
export const mcpTools = [
|
||||||
...accountingTools,
|
...accountingTools,
|
||||||
@@ -10,6 +12,8 @@ export const mcpTools = [
|
|||||||
...organisationTools,
|
...organisationTools,
|
||||||
...wikiTools,
|
...wikiTools,
|
||||||
...documentTools,
|
...documentTools,
|
||||||
|
...archiveTools,
|
||||||
|
uploadUrlTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { McpToolResult } from "./types"
|
|||||||
|
|
||||||
const OMIT_ARCHIVED = Symbol("omit-archived")
|
const OMIT_ARCHIVED = Symbol("omit-archived")
|
||||||
|
|
||||||
|
/** Attach images to a tool payload; they become MCP image content next to the JSON text. */
|
||||||
|
export const MCP_IMAGES = Symbol("mcp-images")
|
||||||
|
export type McpImage = { data: string, mimeType: string }
|
||||||
|
export const withImages = <T extends Record<string, unknown>>(payload: T, images: McpImage[]) =>
|
||||||
|
Object.assign(payload, { [MCP_IMAGES]: images })
|
||||||
|
|
||||||
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -32,12 +38,15 @@ export function asToolResult(payload: unknown): McpToolResult {
|
|||||||
? resultPayload as Record<string, unknown>
|
? resultPayload as Record<string, unknown>
|
||||||
: { result: resultPayload }
|
: { result: resultPayload }
|
||||||
|
|
||||||
|
const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(resultPayload, null, 2),
|
text: JSON.stringify(resultPayload, null, 2),
|
||||||
},
|
},
|
||||||
|
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
|
||||||
],
|
],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
|||||||
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||||
|
|
||||||
payload.amount = amount
|
payload.amount = amount
|
||||||
|
if (hasValue(args.cashDiscountAmount)) {
|
||||||
|
const cashDiscountAmount = Number(args.cashDiscountAmount)
|
||||||
|
if (!Number.isFinite(cashDiscountAmount)) throw new Error("cashDiscountAmount muss eine gültige Zahl sein")
|
||||||
|
if (cashDiscountAmount !== 0) {
|
||||||
|
if (!hasValue(args.bankstatement)) throw new Error("Skonto ist nur zusammen mit einer Bankzahlung möglich")
|
||||||
|
payload.cashDiscountAmount = cashDiscountAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||||
payload.bookingMode = bookingMode
|
payload.bookingMode = bookingMode
|
||||||
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
||||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, inArray, or } from "drizzle-orm"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
||||||
import {
|
import {
|
||||||
@@ -22,12 +22,22 @@ import { saveFile } from "../../utils/files"
|
|||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||||
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||||
|
import { updateIncomingInvoiceCostCentres } from "../../modules/incoming-invoice-cost-centres.service"
|
||||||
|
import { loadBankBalances } from "../../modules/bank-balances.service"
|
||||||
|
import { dateField } from "../masterdataFields"
|
||||||
import {
|
import {
|
||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
} from "../statementAllocations"
|
} from "../statementAllocations"
|
||||||
import { McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
|
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
|
||||||
|
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
|
||||||
|
import { withImages } from "../result"
|
||||||
|
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
||||||
|
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
|
||||||
|
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
||||||
import { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
@@ -238,6 +248,8 @@ const buildOutgoingDocumentPayload = (
|
|||||||
for (const field of ["paymentDays"] as const) {
|
for (const field of ["paymentDays"] as const) {
|
||||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||||
}
|
}
|
||||||
|
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
|
||||||
|
if (args.cashDiscountDays !== undefined) payload.cashDiscountDays = normalizeCashDiscountDays(args.cashDiscountDays)
|
||||||
|
|
||||||
if (args.type !== undefined) payload.type = documentTypeArg(args)
|
if (args.type !== undefined) payload.type = documentTypeArg(args)
|
||||||
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
|
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
|
||||||
@@ -309,6 +321,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoiceFileUpload = {
|
||||||
|
filename: string
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
invoiceId?: number | null
|
||||||
|
invoiceArgs?: Record<string, unknown> | null
|
||||||
|
prepare?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
|
||||||
|
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
|
||||||
|
const { filename, buffer, mimeType } = upload
|
||||||
|
const invoiceId = upload.invoiceId || null
|
||||||
|
const invoiceArgs = upload.invoiceArgs || null
|
||||||
|
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
|
||||||
|
if (!buffer.length) throw new Error("Datei ist leer")
|
||||||
|
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
||||||
|
|
||||||
|
let linkedInvoice: any = null
|
||||||
|
|
||||||
|
if (invoiceId) {
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||||
|
linkedInvoice = existing
|
||||||
|
} else if (invoiceArgs) {
|
||||||
|
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
||||||
|
payload.state = "Entwurf"
|
||||||
|
|
||||||
|
const [createdInvoice] = await context.server.db
|
||||||
|
.insert(incominginvoices)
|
||||||
|
.values(payload as any)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
linkedInvoice = createdInvoice
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
||||||
|
const saved = await saveFile(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
content: buffer,
|
||||||
|
contentType: mimeType,
|
||||||
|
},
|
||||||
|
defaults.folderId,
|
||||||
|
defaults.fileTagId,
|
||||||
|
{
|
||||||
|
incominginvoice: linkedInvoice?.id || null,
|
||||||
|
createdBy: context.userId,
|
||||||
|
updatedBy: context.userId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||||
|
|
||||||
|
if (shouldPrepare) {
|
||||||
|
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
|
||||||
|
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [file] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (file?.incominginvoice && !linkedInvoice) {
|
||||||
|
const [preparedInvoice] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
linkedInvoice = preparedInvoice || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
invoice: linkedInvoice,
|
||||||
|
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
||||||
|
prepared: Boolean(shouldPrepare && linkedInvoice),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildIncomingInvoicePayload = (
|
const buildIncomingInvoicePayload = (
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -331,6 +435,8 @@ const buildIncomingInvoicePayload = (
|
|||||||
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
|
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
|
||||||
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
||||||
}
|
}
|
||||||
|
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
|
||||||
|
if (args.cashDiscountDueDate !== undefined) payload.cashDiscountDueDate = stringArg(args, "cashDiscountDueDate")
|
||||||
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
|
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
|
||||||
if (args.document !== undefined) payload.document = numberArg(args, "document")
|
if (args.document !== undefined) payload.document = numberArg(args, "document")
|
||||||
if (args.expense !== undefined) payload.expense = args.expense === true
|
if (args.expense !== undefined) payload.expense = args.expense === true
|
||||||
@@ -534,6 +640,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
deliveryDateEnd: { type: "string" },
|
deliveryDateEnd: { type: "string" },
|
||||||
deliveryDateType: { type: "string" },
|
deliveryDateType: { type: "string" },
|
||||||
paymentDays: { type: "number" },
|
paymentDays: { type: "number" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
|
||||||
|
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
|
||||||
payment_type: { type: "string" },
|
payment_type: { type: "string" },
|
||||||
taxType: {
|
taxType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -555,6 +663,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
assertNoManualDocumentNumber(args)
|
assertNoManualDocumentNumber(args)
|
||||||
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
||||||
payload.state = "Entwurf"
|
payload.state = "Entwurf"
|
||||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
||||||
@@ -590,6 +699,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
deliveryDateEnd: { type: "string" },
|
deliveryDateEnd: { type: "string" },
|
||||||
deliveryDateType: { type: "string" },
|
deliveryDateType: { type: "string" },
|
||||||
paymentDays: { type: "number" },
|
paymentDays: { type: "number" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
|
||||||
|
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
|
||||||
payment_type: { type: "string" },
|
payment_type: { type: "string" },
|
||||||
taxType: {
|
taxType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -623,6 +734,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
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")
|
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
|
||||||
|
|
||||||
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||||
payload.state = "Entwurf"
|
payload.state = "Entwurf"
|
||||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
||||||
@@ -702,6 +814,69 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document }
|
return { document }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.outgoing_documents.preview",
|
||||||
|
title: "Ausgangsbeleg-Vorschau",
|
||||||
|
description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." },
|
||||||
|
pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." },
|
||||||
|
dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." },
|
||||||
|
images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." },
|
||||||
|
maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." },
|
||||||
|
includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
const dpi = args.dpi === undefined ? 80 : Number(args.dpi)
|
||||||
|
if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen")
|
||||||
|
const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm)
|
||||||
|
if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen")
|
||||||
|
if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10
|
||||||
|
|| args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) {
|
||||||
|
throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
const document: Record<string, any> = { ...existing }
|
||||||
|
if (args.rows !== undefined) {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])),
|
||||||
|
}
|
||||||
|
applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows)
|
||||||
|
document.rows = payload.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document)
|
||||||
|
const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm)
|
||||||
|
const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1)
|
||||||
|
const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount)
|
||||||
|
const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi)
|
||||||
|
|
||||||
|
return withImages({
|
||||||
|
document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title },
|
||||||
|
previewOnly: args.rows !== undefined,
|
||||||
|
pageCount: layout.pageCount,
|
||||||
|
imagePages: images.map((image) => image.page),
|
||||||
|
...analysis,
|
||||||
|
...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}),
|
||||||
|
}, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.finalize",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
title: "Ausgangsbeleg finalisieren",
|
||||||
@@ -716,6 +891,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
deliveryDate: { type: "string" },
|
deliveryDate: { type: "string" },
|
||||||
deliveryDateEnd: { type: "string" },
|
deliveryDateEnd: { type: "string" },
|
||||||
paymentDays: { type: "number" },
|
paymentDays: { type: "number" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
|
||||||
|
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
|
||||||
payment_type: { type: "string" },
|
payment_type: { type: "string" },
|
||||||
taxType: {
|
taxType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -741,12 +918,15 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||||
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
||||||
|
|
||||||
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||||
|
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
||||||
|
// Pflichtangaben der E-Rechnung vor der Nummernvergabe durchsetzen
|
||||||
|
await assertOutgoingEInvoiceRequirements(context.server, context.tenantId, { ...existing, ...payload })
|
||||||
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
||||||
|
|
||||||
payload.state = "Gebucht"
|
payload.state = "Gebucht"
|
||||||
payload.documentNumber = result.usedNumber
|
payload.documentNumber = result.usedNumber
|
||||||
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
|
||||||
|
|
||||||
const [updated] = await context.server.db
|
const [updated] = await context.server.db
|
||||||
.update(createddocuments)
|
.update(createddocuments)
|
||||||
@@ -1011,7 +1191,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.files.upload",
|
name: "accounting.incoming_invoices.files.upload",
|
||||||
title: "Datei für Eingangsbeleg hochladen",
|
title: "Datei für Eingangsbeleg hochladen",
|
||||||
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
|
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
|
||||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1034,6 +1214,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
reference: { type: "string" },
|
reference: { type: "string" },
|
||||||
date: { type: "string" },
|
date: { type: "string" },
|
||||||
dueDate: { type: "string" },
|
dueDate: { type: "string" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"] },
|
||||||
|
cashDiscountDueDate: { type: ["string", "null"] },
|
||||||
document: { type: "number" },
|
document: { type: "number" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
paymentType: { type: "string" },
|
paymentType: { type: "string" },
|
||||||
@@ -1047,83 +1229,14 @@ export const accountingTools: McpTool[] = [
|
|||||||
const filename = stringArg(args, "filename")
|
const filename = stringArg(args, "filename")
|
||||||
if (!filename) throw new Error("filename ist erforderlich")
|
if (!filename) throw new Error("filename ist erforderlich")
|
||||||
|
|
||||||
const buffer = base64BufferArg(args, "contentBase64")
|
return uploadIncomingInvoiceFile(context, {
|
||||||
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
|
filename,
|
||||||
const invoiceId = numberArg(args, "invoiceId")
|
buffer: base64BufferArg(args, "contentBase64"),
|
||||||
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
|
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
|
||||||
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
|
invoiceId: numberArg(args, "invoiceId"),
|
||||||
|
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
|
||||||
let linkedInvoice: any = null
|
prepare: args.prepare !== false,
|
||||||
|
})
|
||||||
if (invoiceId) {
|
|
||||||
const [existing] = await context.server.db
|
|
||||||
.select()
|
|
||||||
.from(incominginvoices)
|
|
||||||
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
|
||||||
linkedInvoice = existing
|
|
||||||
} else if (invoiceArgs) {
|
|
||||||
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
|
||||||
payload.state = "Entwurf"
|
|
||||||
|
|
||||||
const [createdInvoice] = await context.server.db
|
|
||||||
.insert(incominginvoices)
|
|
||||||
.values(payload as any)
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
linkedInvoice = createdInvoice
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
|
||||||
const saved = await saveFile(
|
|
||||||
context.server,
|
|
||||||
context.tenantId,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
filename,
|
|
||||||
content: buffer,
|
|
||||||
contentType: mimeType,
|
|
||||||
},
|
|
||||||
defaults.folderId,
|
|
||||||
defaults.fileTagId,
|
|
||||||
{
|
|
||||||
incominginvoice: linkedInvoice?.id || null,
|
|
||||||
createdBy: context.userId,
|
|
||||||
updatedBy: context.userId,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
|
||||||
|
|
||||||
if (shouldPrepare) {
|
|
||||||
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [file] = await context.server.db
|
|
||||||
.select()
|
|
||||||
.from(files)
|
|
||||||
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (file?.incominginvoice && !linkedInvoice) {
|
|
||||||
const [preparedInvoice] = await context.server.db
|
|
||||||
.select()
|
|
||||||
.from(incominginvoices)
|
|
||||||
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
linkedInvoice = preparedInvoice || null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
file,
|
|
||||||
invoice: linkedInvoice,
|
|
||||||
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
|
||||||
prepared: Boolean(shouldPrepare && linkedInvoice),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1152,6 +1265,62 @@ export const accountingTools: McpTool[] = [
|
|||||||
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
|
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.incoming_invoices.prepare",
|
||||||
|
title: "Belegvorbereitung starten",
|
||||||
|
description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.",
|
||||||
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fileIds: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", format: "uuid" },
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 50,
|
||||||
|
description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
let fileIds: string[] | undefined
|
||||||
|
if (args.fileIds !== undefined) {
|
||||||
|
if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) {
|
||||||
|
throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten")
|
||||||
|
}
|
||||||
|
if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) {
|
||||||
|
throw new Error("fileIds darf nur gültige UUIDs enthalten")
|
||||||
|
}
|
||||||
|
fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds })
|
||||||
|
if (result.error && !result.filesFound) throw new Error(result.error)
|
||||||
|
|
||||||
|
const invoiceIds = result.created.map((entry) => entry.invoiceId)
|
||||||
|
const invoices = invoiceIds.length
|
||||||
|
? await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds)))
|
||||||
|
: []
|
||||||
|
const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
filesFound: result.filesFound,
|
||||||
|
created: result.created.map((entry) => {
|
||||||
|
const invoice = invoices.find((row) => row.id === entry.invoiceId) || null
|
||||||
|
return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record<string, any>) : null }
|
||||||
|
}),
|
||||||
|
skipped: [
|
||||||
|
...result.skipped,
|
||||||
|
...(fileIds || []).filter((id) => !handled.has(id))
|
||||||
|
.map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.create",
|
name: "accounting.incoming_invoices.create",
|
||||||
title: "Eingangsbeleg erstellen",
|
title: "Eingangsbeleg erstellen",
|
||||||
@@ -1164,6 +1333,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
reference: { type: "string" },
|
reference: { type: "string" },
|
||||||
date: { type: "string" },
|
date: { type: "string" },
|
||||||
dueDate: { type: "string" },
|
dueDate: { type: "string" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
|
||||||
|
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
|
||||||
document: { type: "number" },
|
document: { type: "number" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
paymentType: { type: "string" },
|
paymentType: { type: "string" },
|
||||||
@@ -1197,6 +1368,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
reference: { type: "string" },
|
reference: { type: "string" },
|
||||||
date: { type: "string" },
|
date: { type: "string" },
|
||||||
dueDate: { type: "string" },
|
dueDate: { type: "string" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
|
||||||
|
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
|
||||||
document: { type: "number" },
|
document: { type: "number" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
paymentType: { type: "string" },
|
paymentType: { type: "string" },
|
||||||
@@ -1229,6 +1402,44 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
|
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.incoming_invoices.cost_centres.update",
|
||||||
|
title: "Kostenstellen eines Eingangsbelegs ändern",
|
||||||
|
description: "Ändert ausschließlich die Positionskostenstellen eines Eingangsbelegs, auch wenn er gebucht oder einer Bankbuchung zugewiesen ist.",
|
||||||
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "accountCostCentres"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
accountCostCentres: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["index", "costcentre"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer", minimum: 0, description: "Nullbasierter Index der Position in accounts." },
|
||||||
|
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const invoice = await updateIncomingInvoiceCostCentres(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
context.userId,
|
||||||
|
id,
|
||||||
|
{ accountCostCentres: args.accountCostCentres as any },
|
||||||
|
)
|
||||||
|
return { invoice }
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.book",
|
name: "accounting.incoming_invoices.book",
|
||||||
title: "Eingangsbeleg buchen",
|
title: "Eingangsbeleg buchen",
|
||||||
@@ -1310,6 +1521,26 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { invoice: updated }
|
return { invoice: updated }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.bank_accounts.balances",
|
||||||
|
title: "Kontostände abfragen",
|
||||||
|
description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.",
|
||||||
|
requiredPermissions: ["accounting.bank.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." },
|
||||||
|
asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const account = numberArg(args, "account")
|
||||||
|
const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf")
|
||||||
|
const result = await loadBankBalances(context.server, context.tenantId, { account, asOf })
|
||||||
|
if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden")
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.bank_statements.list",
|
name: "accounting.bank_statements.list",
|
||||||
title: "Bankumsätze auflisten",
|
title: "Bankumsätze auflisten",
|
||||||
@@ -1402,7 +1633,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: "accounting.statement_allocations.create",
|
name: "accounting.statement_allocations.create",
|
||||||
title: "Bankzuweisung erstellen",
|
title: "Bankzuweisung erstellen",
|
||||||
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
|
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, Skonto, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||||
requiredPermissions: ["accounting.statement_allocations.write"],
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1410,6 +1641,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
||||||
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
||||||
|
cashDiscountAmount: { type: "number", description: "Skonto (brutto) zu einem Ausgangs- oder Eingangsbeleg, zusätzlich zum gezahlten amount. Nur mit bankstatement; das Vorzeichen wird an amount angeglichen. Wird anteilig nach Steuersätzen auf die Skontokonten verteilt." },
|
||||||
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||||
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||||
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||||
@@ -1435,8 +1667,9 @@ export const accountingTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
const payload = prepareStatementAllocationInput(args)
|
const prepared = prepareStatementAllocationInput(args)
|
||||||
await assertStatementAllocationReferences(context, payload)
|
await assertStatementAllocationReferences(context, prepared)
|
||||||
|
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
|
||||||
|
|
||||||
const [created] = await context.server.db
|
const [created] = await context.server.db
|
||||||
.insert(statementallocations)
|
.insert(statementallocations)
|
||||||
|
|||||||
150
backend/src/mcp/tools/archive.ts
Normal file
150
backend/src/mcp/tools/archive.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm"
|
||||||
|
import * as tables from "../../../db/schema"
|
||||||
|
import { assertToolPermission } from "../authz"
|
||||||
|
import { assertInternalDocumentAccess } from "../documentFiles"
|
||||||
|
import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields"
|
||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
|
import { McpContext, McpTool } from "../types"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
|
||||||
|
|
||||||
|
type ArchiveEntity = {
|
||||||
|
label: string
|
||||||
|
table: any
|
||||||
|
/** Key used by the history log and the generic resource routes. */
|
||||||
|
resource: string
|
||||||
|
permission: string
|
||||||
|
searchColumns: string[]
|
||||||
|
uuid?: boolean
|
||||||
|
internalDocuments?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity =>
|
||||||
|
({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid })
|
||||||
|
|
||||||
|
/** Every entity the MCP can archive; restore checks the matching write permission. */
|
||||||
|
export const archiveEntities: Record<string, ArchiveEntity> = {
|
||||||
|
customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]),
|
||||||
|
vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]),
|
||||||
|
contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]),
|
||||||
|
products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]),
|
||||||
|
services: masterdata("Leistung", tables.services, "services", "services", ["name"]),
|
||||||
|
cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true),
|
||||||
|
branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]),
|
||||||
|
teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]),
|
||||||
|
vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]),
|
||||||
|
inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]),
|
||||||
|
projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] },
|
||||||
|
events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] },
|
||||||
|
tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] },
|
||||||
|
outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] },
|
||||||
|
incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] },
|
||||||
|
files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityField: InputField = {
|
||||||
|
schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." },
|
||||||
|
parse(value, path) {
|
||||||
|
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`)
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const entityIdField: InputField = {
|
||||||
|
schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." },
|
||||||
|
parse: (value) => value,
|
||||||
|
}
|
||||||
|
const searchField: InputField = {
|
||||||
|
schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." },
|
||||||
|
parse(value, path) {
|
||||||
|
const text = textField().parse(value, path)
|
||||||
|
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
|
||||||
|
return text
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const listInput = objectField({
|
||||||
|
entity: entityField,
|
||||||
|
search: searchField,
|
||||||
|
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
|
||||||
|
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
|
||||||
|
})
|
||||||
|
const restoreInput = objectField({ entity: entityField, id: entityIdField })
|
||||||
|
|
||||||
|
const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table))
|
||||||
|
|
||||||
|
/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */
|
||||||
|
const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record<string, any>) => row
|
||||||
|
|
||||||
|
async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") {
|
||||||
|
assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] })
|
||||||
|
if (entity.internalDocuments) await assertInternalDocumentAccess(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareArchiveRestoreInput(args: Record<string, unknown>) {
|
||||||
|
const input = restoreInput.parse(args, "arguments")
|
||||||
|
if (!input.entity) throw new Error("entity ist erforderlich")
|
||||||
|
if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich")
|
||||||
|
const entity = archiveEntities[input.entity]
|
||||||
|
return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTool: McpTool = {
|
||||||
|
name: "archive.entities.list",
|
||||||
|
title: "Archivierte Einträge auflisten",
|
||||||
|
description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.",
|
||||||
|
// Checked per entity in the handler.
|
||||||
|
requiredPermissions: [],
|
||||||
|
inputSchema: { ...listInput.schema, required: ["entity"] },
|
||||||
|
async handler(context, args) {
|
||||||
|
const input = listInput.parse(args, "arguments")
|
||||||
|
if (!input.entity) throw new Error("entity ist erforderlich")
|
||||||
|
const entity = archiveEntities[input.entity]
|
||||||
|
await authorize(context, listTool, entity, "read")
|
||||||
|
const table = entity.table
|
||||||
|
const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)]
|
||||||
|
if (input.search) {
|
||||||
|
const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%`
|
||||||
|
conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern))))
|
||||||
|
}
|
||||||
|
const updatedAt = columnKey(table, "updatedAt", "updated_at")!
|
||||||
|
const rows = await context.server.db.select().from(table).where(and(...conditions))
|
||||||
|
.orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
|
||||||
|
const result = paginatedResult(rows, input)
|
||||||
|
return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreTool: McpTool = {
|
||||||
|
name: "archive.entities.restore",
|
||||||
|
title: "Archivierten Eintrag wiederherstellen",
|
||||||
|
description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`,
|
||||||
|
requiredPermissions: [],
|
||||||
|
readOnly: false,
|
||||||
|
inputSchema: { ...restoreInput.schema, required: ["entity", "id"] },
|
||||||
|
async handler(context, args) {
|
||||||
|
const { key, entity, id } = prepareArchiveRestoreInput(args)
|
||||||
|
await authorize(context, restoreTool, entity, "write")
|
||||||
|
const table = entity.table
|
||||||
|
return context.server.db.transaction(async (tx) => {
|
||||||
|
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
|
||||||
|
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId))
|
||||||
|
const [existing] = await tx.select().from(table).where(scope).limit(1).for("update")
|
||||||
|
if (!existing) throw new Error(`${entity.label} nicht gefunden`)
|
||||||
|
if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`)
|
||||||
|
const [result] = await tx.update(table).set({
|
||||||
|
archived: false,
|
||||||
|
[columnKey(table, "updatedAt", "updated_at")!]: new Date(),
|
||||||
|
[columnKey(table, "updatedBy", "updated_by")!]: context.userId,
|
||||||
|
}).where(scope).returning()
|
||||||
|
if (["products", "services"].includes(entity.resource)) {
|
||||||
|
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
|
||||||
|
}
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id,
|
||||||
|
action: "restored", oldVal: existing, newVal: result,
|
||||||
|
})
|
||||||
|
return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const archiveTools: McpTool[] = [listTool, restoreTool]
|
||||||
@@ -135,7 +135,7 @@ export function createDocumentTools(dependencies: Dependencies = {}): McpTool[]
|
|||||||
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
||||||
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
||||||
}),
|
}),
|
||||||
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
||||||
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
||||||
["filename", "contentBase64"], async (context, args) => {
|
["filename", "contentBase64"], async (context, args) => {
|
||||||
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ export const masterdataWriteConfigs: Config[] = [
|
|||||||
makeConfig({
|
makeConfig({
|
||||||
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
|
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
|
||||||
required: ["name"], numberField: "customerNumber",
|
required: ["name"], numberField: "customerNumber",
|
||||||
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation",
|
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation customCashDiscountPercent customCashDiscountDays",
|
||||||
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
|
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
|
||||||
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
|
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
|
||||||
}),
|
}),
|
||||||
makeConfig({
|
makeConfig({
|
||||||
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
|
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
|
||||||
required: ["name"], numberField: "vendorNumber",
|
required: ["name"], numberField: "vendorNumber",
|
||||||
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod",
|
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod cashDiscountPercent cashDiscountDays",
|
||||||
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
|
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
|
||||||
references: [commonProfiles, bankAccounts],
|
references: [commonProfiles, bankAccounts],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ function documentAmount(document: any, advanceInvoices: any[]) {
|
|||||||
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
|
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[], cashDiscountAmount = 0) {
|
||||||
const amount = documentAmount(document, advanceInvoices)
|
const amount = documentAmount(document, advanceInvoices)
|
||||||
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
if (![amount, paidAmount, cashDiscountAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
||||||
const openAmount = money(amount - paidAmount)
|
const openAmount = money(amount - paidAmount - cashDiscountAmount)
|
||||||
const cancelled = cancellations.length > 0
|
const cancelled = cancellations.length > 0
|
||||||
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
||||||
// Signed invoices and refunds use the same payment-direction comparison.
|
// Signed invoices and refunds use the same payment-direction comparison.
|
||||||
@@ -55,7 +55,7 @@ export function outgoingDocumentStatus(document: any, paidAmount: number, cancel
|
|||||||
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
||||||
state: document.state, customer: document.customer, project: document.project,
|
state: document.state, customer: document.customer, project: document.project,
|
||||||
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
||||||
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
|
paymentStatus, amount, paidAmount: money(paidAmount), cashDiscountAmount: money(cashDiscountAmount), openAmount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
|||||||
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
||||||
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
||||||
const [allocations, cancellations, advances] = await Promise.all([
|
const [allocations, cancellations, advances] = await Promise.all([
|
||||||
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
|
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations)
|
||||||
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
|
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
|
||||||
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
|
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
|
||||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
||||||
@@ -73,15 +73,19 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
|||||||
advanceIds.length ? context.server.db.select().from(createddocuments)
|
advanceIds.length ? context.server.db.select().from(createddocuments)
|
||||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
])
|
])
|
||||||
return documents.map(document => outgoingDocumentStatus(document,
|
return documents.map(document => {
|
||||||
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
const documentAllocations = allocations.filter(allocation => allocation.createddocument === document.id)
|
||||||
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
|
return outgoingDocumentStatus(document,
|
||||||
|
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
||||||
|
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances,
|
||||||
|
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.cashDiscountAmount || 0), 0))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const outgoingStatusTools: McpTool[] = [
|
export const outgoingStatusTools: McpTool[] = [
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
|
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
|
||||||
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
|
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen, gebuchtem Skonto und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
|
||||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type McpToolResult = {
|
|||||||
content: Array<{
|
content: Array<{
|
||||||
type: "text"
|
type: "text"
|
||||||
text: string
|
text: string
|
||||||
|
} | {
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
mimeType: string
|
||||||
}>
|
}>
|
||||||
structuredContent?: Record<string, unknown>
|
structuredContent?: Record<string, unknown>
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
@@ -23,6 +27,8 @@ export type McpTool = {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
requiredPermissions: string[]
|
requiredPermissions: string[]
|
||||||
|
/** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */
|
||||||
|
readOnly?: boolean
|
||||||
inputSchema: Record<string, unknown>
|
inputSchema: Record<string, unknown>
|
||||||
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|||||||
71
backend/src/modules/bank-balances.service.ts
Normal file
71
backend/src/modules/bank-balances.service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
|
import { bankaccounts, bankstatements } from "../../db/schema"
|
||||||
|
|
||||||
|
export const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
||||||
|
|
||||||
|
type BalanceAccount = Pick<typeof bankaccounts.$inferSelect, "id" | "name" | "iban" | "ownerName" | "bankId" | "datevNumber" | "balance" | "expired" | "syncedAt">
|
||||||
|
|
||||||
|
const roundMoney = (value: number) => Math.round(value * 100) / 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank accounts store the last synced booked balance; cashbooks store their opening balance
|
||||||
|
* and derive the current balance from their bookings. For a past date, bank balances are
|
||||||
|
* reconstructed by reverting the statements booked after that date.
|
||||||
|
*/
|
||||||
|
export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) {
|
||||||
|
const cashbook = account.bankId === CASHBOOK_BANK_ID
|
||||||
|
const stored = Number(account.balance || 0)
|
||||||
|
const balance = cashbook ? stored + statementSum : stored - statementSum
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
type: cashbook ? "cashbook" : "bank",
|
||||||
|
name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"),
|
||||||
|
iban: cashbook ? null : account.iban,
|
||||||
|
datevNumber: account.datevNumber,
|
||||||
|
expired: account.expired,
|
||||||
|
balance: roundMoney(balance),
|
||||||
|
currency: "EUR",
|
||||||
|
...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }),
|
||||||
|
source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) {
|
||||||
|
const asOf = options.asOf || null
|
||||||
|
const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)]
|
||||||
|
if (options.account) conditions.push(eq(bankaccounts.id, options.account))
|
||||||
|
const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id)
|
||||||
|
|
||||||
|
const sums = async (dateCondition: any) => {
|
||||||
|
const rows = await server.db
|
||||||
|
.select({ account: bankstatements.account, total: sql<string>`coalesce(sum(${bankstatements.amount}), 0)` })
|
||||||
|
.from(bankstatements)
|
||||||
|
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : [])))
|
||||||
|
.groupBy(bankstatements.account)
|
||||||
|
return new Map(rows.map((row) => [row.account, Number(row.total)]))
|
||||||
|
}
|
||||||
|
// Synced statements may carry a full timestamp; compare the calendar day only.
|
||||||
|
const bookingDay = sql`left(${bankstatements.date}, 10)`
|
||||||
|
// Cashbooks need bookings up to the date, bank accounts only those after it.
|
||||||
|
const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null)
|
||||||
|
const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map<number, number>()
|
||||||
|
|
||||||
|
const balances = accounts.map((account) => calculateAccountBalance(
|
||||||
|
account,
|
||||||
|
(account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0,
|
||||||
|
asOf,
|
||||||
|
))
|
||||||
|
const active = balances.filter((account) => !account.expired)
|
||||||
|
return {
|
||||||
|
asOf,
|
||||||
|
accounts: balances,
|
||||||
|
totals: {
|
||||||
|
balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)),
|
||||||
|
bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)),
|
||||||
|
cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)),
|
||||||
|
currency: "EUR",
|
||||||
|
note: "Summen ohne abgelaufene Bankverbindungen.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
378
backend/src/modules/cash-discount.ts
Normal file
378
backend/src/modules/cash-discount.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { createddocuments, incominginvoices, statementallocations, tenants } from "../../db/schema"
|
||||||
|
|
||||||
|
// Skonto wird an der Zahlungszuordnung gespeichert. amount bleibt der tatsächlich gezahlte Betrag,
|
||||||
|
// cashDiscountAmount trägt dasselbe Vorzeichen und schließt zusammen mit amount den Beleg aus.
|
||||||
|
|
||||||
|
export type CashDiscountRate = "19" | "7" | "0"
|
||||||
|
export type CashDiscountSide = "received" | "granted"
|
||||||
|
export type CashDiscountAccountConfig = Record<CashDiscountSide, Record<CashDiscountRate, string>>
|
||||||
|
export type CashDiscountSplitItem = { rate: number, account: string, gross: number, net: number, tax: number }
|
||||||
|
|
||||||
|
export const CASH_DISCOUNT_RATES: CashDiscountRate[] = ["19", "7", "0"]
|
||||||
|
|
||||||
|
// SKR03: Erhaltene Skonti (Eingangsbelege) und gewährte Skonti (Ausgangsbelege), jeweils Automatikkonten.
|
||||||
|
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS: CashDiscountAccountConfig = {
|
||||||
|
received: { "19": "3736", "7": "3731", "0": "3730" },
|
||||||
|
granted: { "19": "8736", "7": "8731", "0": "8730" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weicht der Skonto um mehr als diesen Anteil vom Beleg ab, liegt sehr wahrscheinlich ein Eingabefehler vor.
|
||||||
|
export const MAX_CASH_DISCOUNT_SHARE = 0.1
|
||||||
|
|
||||||
|
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
|
||||||
|
const money = (value: number) => Number(value.toFixed(2))
|
||||||
|
const sameSign = (value: number, sign: number) => sign === 0 || Math.sign(value) === 0 || Math.sign(value) === sign
|
||||||
|
|
||||||
|
export function resolveCashDiscountAccounts(config: any): CashDiscountAccountConfig {
|
||||||
|
const result = {
|
||||||
|
received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received },
|
||||||
|
granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted },
|
||||||
|
}
|
||||||
|
for (const side of ["received", "granted"] as const) {
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const value = config?.[side]?.[rate]
|
||||||
|
if (typeof value === "string" && value.trim()) result[side][rate] = value.trim()
|
||||||
|
else if (typeof value === "number" && Number.isFinite(value)) result[side][rate] = String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiert die Einstellung aus den Firmeneinstellungen. Leere Felder fallen auf SKR03 zurück.
|
||||||
|
export function normalizeCashDiscountAccountsInput(input: any): Partial<CashDiscountAccountConfig> {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.")
|
||||||
|
}
|
||||||
|
const result: Partial<CashDiscountAccountConfig> = {}
|
||||||
|
for (const side of ["received", "granted"] as const) {
|
||||||
|
const entries: Partial<Record<CashDiscountRate, string>> = {}
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const raw = input?.[side]?.[rate]
|
||||||
|
if (raw === undefined || raw === null || String(raw).trim() === "") continue
|
||||||
|
const value = String(raw).trim()
|
||||||
|
if (!/^\d{4,9}$/.test(value)) {
|
||||||
|
throw invalidInput(`Das Skontokonto ${value} muss aus 4 bis 9 Ziffern bestehen.`)
|
||||||
|
}
|
||||||
|
entries[rate] = value
|
||||||
|
}
|
||||||
|
if (Object.keys(entries).length) result[side] = entries as Record<CashDiscountRate, string>
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) {
|
||||||
|
return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrossByRate = Record<CashDiscountRate, number>
|
||||||
|
const emptyGross = (): GrossByRate => ({ "19": 0, "7": 0, "0": 0 })
|
||||||
|
|
||||||
|
// Bruttobeträge eines Eingangsbelegs je Steuersatz. Vorzeichen: Ausgaben negativ (wie die Bankzuordnung).
|
||||||
|
export function getIncomingInvoiceGrossByRate(invoice: any): GrossByRate {
|
||||||
|
const gross = emptyGross()
|
||||||
|
const accounts = Array.isArray(invoice?.accounts) ? invoice.accounts : []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const taxType = account?.taxType === null || account?.taxType === undefined ? "null" : String(account.taxType)
|
||||||
|
const amount = Number(account?.amountNet || 0) + Number(account?.amountTax || 0)
|
||||||
|
if (!Number.isFinite(amount)) throw invalidInput("Der Eingangsbeleg enthält ungültige Beträge.")
|
||||||
|
if (amount === 0) continue
|
||||||
|
if (taxType === "19" || taxType === "7") gross[taxType] += amount
|
||||||
|
else if (taxType === "null" || taxType === "" || taxType === "0") gross["0"] += amount
|
||||||
|
else {
|
||||||
|
throw invalidInput("Skonto auf Belege mit innergemeinschaftlichem Erwerb oder §13b UStG bitte manuell buchen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = invoice?.taxAmountOverride
|
||||||
|
if (override !== null && override !== undefined && override !== "") {
|
||||||
|
const calculatedTax = accounts.reduce((sum: number, account: any) => sum + Number(account?.amountTax || 0), 0)
|
||||||
|
const correction = Number(override) - calculatedTax
|
||||||
|
if (Number.isFinite(correction) && Math.abs(correction) >= 0.005) {
|
||||||
|
if (gross["19"] !== 0) gross["19"] += correction
|
||||||
|
else if (gross["7"] !== 0) gross["7"] += correction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = invoice?.expense === false ? 1 : -1
|
||||||
|
return {
|
||||||
|
"19": money(gross["19"] * sign),
|
||||||
|
"7": money(gross["7"] * sign),
|
||||||
|
"0": money(gross["0"] * sign),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsGrossByRate = (document: any, taxFree: boolean): GrossByRate => {
|
||||||
|
const net = emptyGross()
|
||||||
|
for (const row of Array.isArray(document?.rows) ? document.rows : []) {
|
||||||
|
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) continue
|
||||||
|
const rowNet = Number((Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3))
|
||||||
|
if (!Number.isFinite(rowNet)) throw invalidInput(`Beleg ${document.id}: ungültiger Positionsbetrag`)
|
||||||
|
const taxPercent = Number(row.taxPercent)
|
||||||
|
if (!taxFree && taxPercent === 19) net["19"] += rowNet
|
||||||
|
else if (!taxFree && taxPercent === 7) net["7"] += rowNet
|
||||||
|
else net["0"] += rowNet
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"19": money(money(net["19"]) + money(net["19"] * 0.19)),
|
||||||
|
"7": money(money(net["7"]) + money(net["7"] * 0.07)),
|
||||||
|
"0": money(net["0"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdDocumentAmounts = (document: any, advanceInvoices: any[]) => {
|
||||||
|
if (document?.taxType === "13b UStG") {
|
||||||
|
throw invalidInput("Skonto auf Ausgangsbelege nach §13b UStG bitte manuell buchen.")
|
||||||
|
}
|
||||||
|
const taxFree = ["19 UStG", "12.3 UStG"].includes(String(document?.taxType || ""))
|
||||||
|
const gross = rowsGrossByRate(document, taxFree)
|
||||||
|
const advances = emptyGross()
|
||||||
|
for (const id of Array.isArray(document?.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) {
|
||||||
|
const row = advanceInvoices.find((invoice) => invoice.id === id)?.rows?.find((row: any) => row?.advanceInvoiceData)
|
||||||
|
if (!row) continue
|
||||||
|
const rate = String(Number(row.taxPercent)) as CashDiscountRate
|
||||||
|
const amount = Number(row.price) * ((100 + Number(row.taxPercent || 0)) / 100)
|
||||||
|
advances[!taxFree && (rate === "19" || rate === "7") ? rate : "0"] += amount
|
||||||
|
}
|
||||||
|
return { gross, advances }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bruttobeträge eines Ausgangsbelegs je Steuersatz. Bei Schlussrechnungen werden die Abschläge mit ihren
|
||||||
|
// ursprünglich berechneten Beträgen abgezogen, damit sich der Skonto auf die Restforderung verteilt.
|
||||||
|
export function getCreatedDocumentGrossByRate(document: any, advanceInvoices: any[] = []): GrossByRate {
|
||||||
|
const { gross, advances } = createdDocumentAmounts(document, advanceInvoices)
|
||||||
|
const total = gross["19"] + gross["7"] + gross["0"]
|
||||||
|
const sign = Math.sign(total) || 1
|
||||||
|
const remaining = emptyGross()
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const value = money(gross[rate] - advances[rate])
|
||||||
|
remaining[rate] = sameSign(value, sign) ? value : 0
|
||||||
|
}
|
||||||
|
return remaining["19"] || remaining["7"] || remaining["0"] ? remaining : gross
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offene Gesamtforderung des Ausgangsbelegs: Bruttobetrag abzüglich der berechneten Abschläge.
|
||||||
|
export function getCreatedDocumentGrossTotal(document: any, advanceInvoices: any[] = []) {
|
||||||
|
const { gross, advances } = createdDocumentAmounts(document, advanceInvoices)
|
||||||
|
return money(gross["19"] + gross["7"] + gross["0"] - advances["19"] - advances["7"] - advances["0"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verteilt den Skonto anteilig auf die Steuersätze; Rundungsdifferenzen gehen an den größten Anteil.
|
||||||
|
export function splitCashDiscount(amount: number, grossByRate: GrossByRate, accounts: Record<CashDiscountRate, string>): CashDiscountSplitItem[] {
|
||||||
|
const discount = money(amount)
|
||||||
|
if (!discount) return []
|
||||||
|
const weights = CASH_DISCOUNT_RATES
|
||||||
|
.map((rate) => ({ rate, weight: Math.abs(Number(grossByRate[rate] || 0)) }))
|
||||||
|
.filter((item) => item.weight > 0)
|
||||||
|
if (!weights.length) weights.push({ rate: "0", weight: 1 })
|
||||||
|
|
||||||
|
const totalWeight = weights.reduce((sum, item) => sum + item.weight, 0)
|
||||||
|
const items = weights.map((item) => ({ rate: item.rate, gross: money(discount * item.weight / totalWeight) }))
|
||||||
|
const difference = money(discount - items.reduce((sum, item) => sum + item.gross, 0))
|
||||||
|
if (difference) {
|
||||||
|
const largest = items.reduce((max, item) => Math.abs(item.gross) > Math.abs(max.gross) ? item : max, items[0])
|
||||||
|
largest.gross = money(largest.gross + difference)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => item.gross !== 0).map((item) => {
|
||||||
|
const rate = Number(item.rate)
|
||||||
|
const net = money(item.gross / (1 + rate / 100))
|
||||||
|
return { rate, account: accounts[item.rate], gross: item.gross, net, tax: money(item.gross - net) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereitet Skonto für eine neue Bankzuordnung vor: Vorzeichen, Plausibilität und Steueraufteilung.
|
||||||
|
export async function prepareCashDiscountAllocation(db: any, tenantId: number, payload: Record<string, any>) {
|
||||||
|
const requested = Number(payload.cashDiscountAmount || 0)
|
||||||
|
if (!Number.isFinite(requested)) throw invalidInput("Der Skontobetrag muss eine gültige Zahl sein.")
|
||||||
|
if (Math.abs(requested) < 0.005) {
|
||||||
|
return { ...payload, cashDiscountAmount: null, cashDiscountSplit: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(payload.amount || 0)
|
||||||
|
if (!payload.bankstatement) throw invalidInput("Skonto kann nur zusammen mit einer Bankzahlung gebucht werden.")
|
||||||
|
const hasDocument = Boolean(payload.createddocument)
|
||||||
|
const hasInvoice = Boolean(payload.incominginvoice)
|
||||||
|
if (hasDocument === hasInvoice) {
|
||||||
|
throw invalidInput("Skonto ist nur bei der Zuordnung genau eines Ausgangs- oder Eingangsbelegs möglich.")
|
||||||
|
}
|
||||||
|
if (!amount) throw invalidInput("Für Skonto muss ein Zahlbetrag zugeordnet werden.")
|
||||||
|
|
||||||
|
const discount = money(Math.sign(amount) * Math.abs(requested))
|
||||||
|
const [tenant] = await db.select({ cashDiscountAccounts: tenants.cashDiscountAccounts })
|
||||||
|
.from(tenants).where(eq(tenants.id, tenantId)).limit(1)
|
||||||
|
const accounts = resolveCashDiscountAccounts(tenant?.cashDiscountAccounts)
|
||||||
|
|
||||||
|
let grossByRate: GrossByRate
|
||||||
|
let total: number
|
||||||
|
let existing: any[]
|
||||||
|
|
||||||
|
if (hasDocument) {
|
||||||
|
const documentId = Number(payload.createddocument)
|
||||||
|
const [document] = await db.select().from(createddocuments).where(and(
|
||||||
|
eq(createddocuments.id, documentId),
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
)).limit(1)
|
||||||
|
if (!document || document.archived) throw invalidInput("Der Ausgangsbeleg wurde nicht gefunden.")
|
||||||
|
if (document.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Ausgangsbelege möglich.")
|
||||||
|
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : [])
|
||||||
|
.filter((id: any) => Number.isSafeInteger(id) && id > 0)
|
||||||
|
const advanceInvoices = advanceIds.length
|
||||||
|
? await db.select().from(createddocuments).where(and(
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
inArray(createddocuments.id, advanceIds),
|
||||||
|
))
|
||||||
|
: []
|
||||||
|
grossByRate = getCreatedDocumentGrossByRate(document, advanceInvoices)
|
||||||
|
total = getCreatedDocumentGrossTotal(document, advanceInvoices)
|
||||||
|
existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount })
|
||||||
|
.from(statementallocations).where(and(
|
||||||
|
eq(statementallocations.createddocument, documentId),
|
||||||
|
eq(statementallocations.tenant, tenantId),
|
||||||
|
eq(statementallocations.archived, false),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
const invoiceId = Number(payload.incominginvoice)
|
||||||
|
const [invoice] = await db.select().from(incominginvoices).where(and(
|
||||||
|
eq(incominginvoices.id, invoiceId),
|
||||||
|
eq(incominginvoices.tenant, tenantId),
|
||||||
|
)).limit(1)
|
||||||
|
if (!invoice || invoice.archived) throw invalidInput("Der Eingangsbeleg wurde nicht gefunden.")
|
||||||
|
if (invoice.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Eingangsbelege möglich.")
|
||||||
|
grossByRate = getIncomingInvoiceGrossByRate(invoice)
|
||||||
|
total = money(grossByRate["19"] + grossByRate["7"] + grossByRate["0"])
|
||||||
|
existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount })
|
||||||
|
.from(statementallocations).where(and(
|
||||||
|
eq(statementallocations.incominginvoice, invoiceId),
|
||||||
|
eq(statementallocations.tenant, tenantId),
|
||||||
|
eq(statementallocations.archived, false),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCashDiscount({ amount, discount, total, settled: existing.reduce((sum, item) => sum + settledAllocationAmount(item), 0) })
|
||||||
|
|
||||||
|
const side: CashDiscountSide = hasDocument ? "granted" : "received"
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
cashDiscountAmount: discount,
|
||||||
|
cashDiscountSplit: splitCashDiscount(discount, grossByRate, accounts[side]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCashDiscount({ amount, discount, total, settled }: { amount: number, discount: number, total: number, settled: number }) {
|
||||||
|
if (!total) throw invalidInput("Der Beleg hat keinen Betrag, auf den Skonto gebucht werden kann.")
|
||||||
|
if (Math.sign(amount) !== Math.sign(total)) {
|
||||||
|
throw invalidInput("Skonto ist nur bei Zahlungen in Richtung des Belegs möglich.")
|
||||||
|
}
|
||||||
|
const open = money(total - settled)
|
||||||
|
if (Math.abs(amount + discount) > Math.abs(open) + 0.02 || Math.sign(open) !== Math.sign(total)) {
|
||||||
|
throw invalidInput(`Zahlung und Skonto übersteigen den offenen Betrag von ${Math.abs(open).toFixed(2).replace(".", ",")} €.`)
|
||||||
|
}
|
||||||
|
if (Math.abs(discount) > Math.abs(total) * MAX_CASH_DISCOUNT_SHARE + 0.005) {
|
||||||
|
throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skonto-Bedingungen auf Belegen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CashDiscountTerms = {
|
||||||
|
percent: number
|
||||||
|
days: number | null
|
||||||
|
dueDate: string | null
|
||||||
|
discount: number
|
||||||
|
payable: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEuro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value)
|
||||||
|
const formatGermanDate = (value: string) => {
|
||||||
|
const [year, month, day] = value.split("-")
|
||||||
|
return `${day}.${month}.${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const addDays = (date: string, days: number) => {
|
||||||
|
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null
|
||||||
|
parsed.setUTCDate(parsed.getUTCDate() + days)
|
||||||
|
return parsed.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCashDiscountPercent(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") return null
|
||||||
|
const percent = Number(String(value).replace(",", "."))
|
||||||
|
if (!Number.isFinite(percent) || percent < 0 || percent > 10) {
|
||||||
|
throw invalidInput("Der Skontosatz muss zwischen 0 und 10 % liegen.")
|
||||||
|
}
|
||||||
|
return percent === 0 ? null : Number(percent.toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCashDiscountDays(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") return null
|
||||||
|
const days = Number(value)
|
||||||
|
if (!Number.isInteger(days) || days < 0 || days > 365) {
|
||||||
|
throw invalidInput("Die Skontofrist muss zwischen 0 und 365 Tagen liegen.")
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnet Frist, Skonto und Zahlbetrag aus Satz, Tagen und Belegdatum.
|
||||||
|
export function buildCashDiscountTerms({ percent, days, documentDate, dueDate, amount }: {
|
||||||
|
percent: unknown, days?: unknown, documentDate?: string | null, dueDate?: string | null, amount: number
|
||||||
|
}): CashDiscountTerms | null {
|
||||||
|
const normalizedPercent = Number(percent)
|
||||||
|
if (!Number.isFinite(normalizedPercent) || normalizedPercent <= 0 || !Number.isFinite(amount) || !amount) return null
|
||||||
|
const normalizedDays = days === null || days === undefined || days === "" ? null : Number(days)
|
||||||
|
const computedDueDate = dueDate
|
||||||
|
? String(dueDate).slice(0, 10)
|
||||||
|
: documentDate && normalizedDays !== null && Number.isFinite(normalizedDays) ? addDays(documentDate, normalizedDays) : null
|
||||||
|
const discount = money(amount * normalizedPercent / 100)
|
||||||
|
return {
|
||||||
|
percent: normalizedPercent,
|
||||||
|
days: normalizedDays !== null && Number.isFinite(normalizedDays) ? normalizedDays : null,
|
||||||
|
dueDate: computedDueDate,
|
||||||
|
discount,
|
||||||
|
payable: money(amount - discount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCashDiscountText(terms: CashDiscountTerms | null) {
|
||||||
|
if (!terms) return ""
|
||||||
|
const percent = String(terms.percent).replace(".", ",")
|
||||||
|
const deadline = terms.dueDate
|
||||||
|
? `bis zum ${formatGermanDate(terms.dueDate)}`
|
||||||
|
: terms.days !== null ? `innerhalb von ${terms.days} Tagen` : "bei sofortiger Zahlung"
|
||||||
|
return `Bei Zahlung ${deadline} gewähren wir ${percent} % Skonto (${formatEuro(Math.abs(terms.discount))}), Zahlbetrag ${formatEuro(Math.abs(terms.payable))}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontextvariablen für Textvorlagen; der Satz wird automatisch ergänzt, wenn die Vorlage keine Skontovariable nutzt.
|
||||||
|
export function getCashDiscountTemplateContext(terms: CashDiscountTerms | null) {
|
||||||
|
if (!terms) return {}
|
||||||
|
return {
|
||||||
|
skonto_prozent: String(terms.percent).replace(".", ","),
|
||||||
|
skonto_tage: terms.days,
|
||||||
|
skonto_datum: terms.dueDate ? formatGermanDate(terms.dueDate) : null,
|
||||||
|
skonto_betrag: formatEuro(Math.abs(terms.discount)),
|
||||||
|
zahlbetrag_mit_skonto: formatEuro(Math.abs(terms.payable)),
|
||||||
|
skonto_hinweis: formatCashDiscountText(terms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendCashDiscountText(renderedText: string, template: string | null | undefined, terms: CashDiscountTerms | null) {
|
||||||
|
if (!terms || /skonto_/i.test(String(template || ""))) return renderedText
|
||||||
|
const sentence = formatCashDiscountText(terms)
|
||||||
|
return renderedText ? `${sentence}\n\n${renderedText}` : sentence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest Skontobedingungen aus E-Rechnungen: XRechnung "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext.
|
||||||
|
export function parseCashDiscountPaymentTerms(text: unknown): { percent: number, days: number } | null {
|
||||||
|
const source = String(text || "")
|
||||||
|
const matches = [...source.matchAll(/#SKONTO#([^\n]*?)#(?:\s|$)/gi)]
|
||||||
|
for (const match of matches) {
|
||||||
|
const segment = match[1]
|
||||||
|
const days = Number(segment.match(/TAGE=(\d+)/i)?.[1])
|
||||||
|
const percent = Number(segment.match(/PROZENT=(\d+(?:[.,]\d+)?)/i)?.[1]?.replace(",", "."))
|
||||||
|
if (Number.isInteger(days) && Number.isFinite(percent) && percent > 0) return { percent, days }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -207,6 +207,7 @@ export class FederationService {
|
|||||||
if (updated) {
|
if (updated) {
|
||||||
await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`)
|
await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`)
|
||||||
await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`)
|
await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`)
|
||||||
|
await this.service.options.notifications?.remoteInvitation(db, principal, updated.id, invitationId, snapshot.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { received: true }
|
return { received: true }
|
||||||
@@ -241,9 +242,11 @@ export class FederationService {
|
|||||||
for (const b of bindings) {
|
for (const b of bindings) {
|
||||||
if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true })
|
if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true })
|
||||||
if (b.status === "active") {
|
if (b.status === "active") {
|
||||||
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
|
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count,
|
||||||
|
(SELECT payload->>'createdAt' FROM collaboration_remote_messages WHERE binding_id=${b.id} ORDER BY seq DESC LIMIT 1) AS "lastActivityAt"
|
||||||
|
FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
|
||||||
result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic,
|
result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic,
|
||||||
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count })
|
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count, lastActivityAt: unread.lastActivityAt || null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -322,6 +325,11 @@ export class FederationService {
|
|||||||
for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload)
|
for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload)
|
||||||
VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb)
|
VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb)
|
||||||
ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`)
|
ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`)
|
||||||
|
if (snapshot) {
|
||||||
|
const unreadAfter = Math.max(Number(current.sync_seq), Number(current.last_read_seq), snapshot.lastReadSeq)
|
||||||
|
const incoming = projected.filter(m => !m.own && m.seq >= from && m.seq > unreadAfter)
|
||||||
|
await this.service.options.notifications?.remoteMessages(db, current, snapshot.name, incoming)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) {
|
async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) {
|
||||||
|
|||||||
139
backend/src/modules/collaboration/notifications.ts
Normal file
139
backend/src/modules/collaboration/notifications.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
|
||||||
|
import { authUsers, notificationsItems } from "../../../db/schema"
|
||||||
|
import { NotificationService } from "../notification.service"
|
||||||
|
import { CollaborationService, rows } from "./service"
|
||||||
|
import type { FederationService } from "./federation"
|
||||||
|
import type { Principal } from "./types"
|
||||||
|
|
||||||
|
const MESSAGE = "collaboration.message.new"
|
||||||
|
const INVITATION = "collaboration.room.invited"
|
||||||
|
|
||||||
|
/** Queue inside the source transaction; external delivery never holds up a chat command. */
|
||||||
|
export class CollaborationNotifications {
|
||||||
|
private working = false
|
||||||
|
private deferredUntil = new Map<string, number>()
|
||||||
|
constructor(readonly server: FastifyInstance, readonly service: CollaborationService, readonly federation: FederationService) {}
|
||||||
|
|
||||||
|
delivery(db: any) {
|
||||||
|
return new NotificationService({ db, log: this.server.log } as FastifyInstance, async (server, userId) => {
|
||||||
|
const [user] = await server.db.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).limit(1)
|
||||||
|
return user || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(db: any, principal: Principal, name: string, payload: Record<string, unknown>, message?: { body: string; senderName: string }) {
|
||||||
|
if (principal.kind !== "user" || principal.disabled) return
|
||||||
|
try { await this.service.assertPrincipal(principal, db) }
|
||||||
|
catch (error: any) { if ([403, 404].includes(error.statusCode)) return; throw error }
|
||||||
|
await this.delivery(db).enqueue({
|
||||||
|
tenantId: Number(principal.tenant_id), userId: principal.user_id!,
|
||||||
|
eventType: message ? MESSAGE : INVITATION,
|
||||||
|
title: message ? `Neue Nachricht in ${name}` : "Einladung zur Zusammenarbeit",
|
||||||
|
message: message ? `${message.senderName}: ${message.body || "Bild gesendet"}` : `Du wurdest in den Chat „${name}“ eingeladen.`,
|
||||||
|
channels: ["inapp", "email", "push"],
|
||||||
|
payload: { ...payload, principalId: principal.id,
|
||||||
|
link: message ? `/communication/chat?room=${encodeURIComponent(`collaboration:${payload.bindingId || payload.roomId}`)}` : "/communication/chat" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async localMessage(db: any, room: any, sender: Principal, message: any) {
|
||||||
|
const recipients = await rows(db, sql`SELECT p.* FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id
|
||||||
|
WHERE m.room_id=${room.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
|
||||||
|
AND m.history_from_seq<=${message.seq} AND m.last_read_seq<${message.seq}
|
||||||
|
AND p.kind='user' AND NOT p.disabled AND p.id<>${sender.id}`)
|
||||||
|
for (const principal of recipients) await this.enqueue(db, principal, room.name,
|
||||||
|
{ roomId: room.id, messageId: message.id, seq: message.seq }, { body: message.body, senderName: sender.display_name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async localInvitation(db: any, room: any, target: Principal, invitationId: string) {
|
||||||
|
await this.enqueue(db, target, room.name, { roomId: room.id, invitationId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteInvitation(db: any, principal: Principal, bindingId: string, invitationId: string, name: string) {
|
||||||
|
await this.enqueue(db, principal, name, { bindingId, invitationId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async remoteMessages(db: any, binding: any, name: string, messages: any[]) {
|
||||||
|
if (!messages.length) return
|
||||||
|
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${binding.principal_id}`)
|
||||||
|
if (!principal) return
|
||||||
|
for (const message of new Map(messages.map(message => [message.id, message])).values()) await this.enqueue(db, principal, name,
|
||||||
|
{ bindingId: binding.id, invitationId: binding.invitation_id, messageId: message.id, seq: message.seq }, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async eligible(db: any, item: typeof notificationsItems.$inferSelect): Promise<boolean | "defer"> {
|
||||||
|
const payload = item.payload as any
|
||||||
|
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${payload.principalId}`)
|
||||||
|
if (!principal || principal.kind !== "user" || principal.user_id !== item.userId || Number(principal.tenant_id) !== item.tenantId) return false
|
||||||
|
try {
|
||||||
|
await this.service.assertPrincipal(principal, db)
|
||||||
|
if (payload.bindingId) {
|
||||||
|
if (!this.federation.options.enabled) return "defer"
|
||||||
|
const ctx = { principal, userId: item.userId, tenantId: item.tenantId, manage: false }
|
||||||
|
const binding = await this.federation.binding(ctx, payload.bindingId)
|
||||||
|
if (!binding || binding.invitation_id !== payload.invitationId) return false
|
||||||
|
await this.federation.allowed(item.tenantId, binding.origin, db)
|
||||||
|
if (item.eventType === INVITATION) return binding.status === "pending" && new Date(binding.invitation.expiresAt).getTime() > Date.now()
|
||||||
|
if (binding.status !== "active") return false
|
||||||
|
// Do not send cached content while current access cannot be confirmed by its owner.
|
||||||
|
const detail = await this.federation.sync(ctx, binding)
|
||||||
|
if (detail.offline) return "defer"
|
||||||
|
const current = await this.federation.binding(ctx, binding.id)
|
||||||
|
return current?.status === "active" && current.invitation_id === payload.invitationId
|
||||||
|
&& payload.seq >= detail.historyFromSeq && (item.channel === "inapp" || payload.seq > Number(current.last_read_seq))
|
||||||
|
}
|
||||||
|
const room = await this.service.roomRow(db, payload.roomId)
|
||||||
|
if (item.eventType === INVITATION) {
|
||||||
|
this.service.assertOpen(room, true)
|
||||||
|
const [invite] = await rows(db, sql`SELECT id FROM collaboration_invitations
|
||||||
|
WHERE id=${payload.invitationId} AND room_id=${room.id} AND target_principal_id=${principal.id}
|
||||||
|
AND status='pending' AND expires_at>now() AND (access_expires_at IS NULL OR access_expires_at>now())`)
|
||||||
|
return Boolean(invite)
|
||||||
|
}
|
||||||
|
const member = await this.service.membership(db, room, principal)
|
||||||
|
return payload.seq >= Number(member.history_from_seq) && (item.channel === "inapp" || payload.seq > Number(member.last_read_seq))
|
||||||
|
} catch (error: any) {
|
||||||
|
if ([401, 403, 404, 409, 410].includes(error.statusCode)) return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async tick() {
|
||||||
|
if (this.working) return
|
||||||
|
this.working = true
|
||||||
|
try {
|
||||||
|
for (const [id, until] of this.deferredUntil) if (until <= Date.now()) this.deferredUntil.delete(id)
|
||||||
|
const pending = await this.server.db.select({ id: notificationsItems.id }).from(notificationsItems)
|
||||||
|
.where(and(eq(notificationsItems.status, "queued"), inArray(notificationsItems.eventType, [MESSAGE, INVITATION]),
|
||||||
|
this.deferredUntil.size ? notInArray(notificationsItems.id, [...this.deferredUntil.keys()]) : undefined))
|
||||||
|
.orderBy(asc(notificationsItems.createdAt)).limit(20)
|
||||||
|
for (const { id } of pending) {
|
||||||
|
try {
|
||||||
|
await this.server.db.transaction(async db => {
|
||||||
|
const [item] = await db.select().from(notificationsItems)
|
||||||
|
.where(and(eq(notificationsItems.id, id), eq(notificationsItems.status, "queued")))
|
||||||
|
.for("update", { skipLocked: true })
|
||||||
|
if (!item) return
|
||||||
|
const eligible = await this.eligible(db, item)
|
||||||
|
if (eligible === "defer") {
|
||||||
|
this.deferredUntil.set(id, Date.now() + 30000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const delivery = this.delivery(db)
|
||||||
|
const enabled = item.channel === "inapp" || (await delivery.preferences(item.tenantId, item.userId)).events
|
||||||
|
.some(event => event.eventKey === item.eventType && event.channels[item.channel] === true)
|
||||||
|
if (!eligible || !enabled) {
|
||||||
|
await db.delete(notificationsItems).where(eq(notificationsItems.id, item.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await delivery.deliver(item)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.deferredUntil.set(id, Date.now() + 30000)
|
||||||
|
this.server.log.error({ err: error, notificationId: id }, "Collaboration notification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { this.working = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { CollaborationService } from "./service"
|
|||||||
import { FederationService } from "./federation"
|
import { FederationService } from "./federation"
|
||||||
import { encrypt, decrypt } from "../../utils/crypt"
|
import { encrypt, decrypt } from "../../utils/crypt"
|
||||||
import { collaborationConfig } from "./config"
|
import { collaborationConfig } from "./config"
|
||||||
|
import { CollaborationNotifications } from "./notifications"
|
||||||
|
|
||||||
export function collaborationRuntime(server: FastifyInstance) {
|
export function collaborationRuntime(server: FastifyInstance) {
|
||||||
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
|
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
|
||||||
@@ -16,12 +17,17 @@ export function collaborationRuntime(server: FastifyInstance) {
|
|||||||
sendMail,
|
sendMail,
|
||||||
})
|
})
|
||||||
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
|
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
|
||||||
|
const notifications = new CollaborationNotifications(server, service, federation)
|
||||||
|
service.options.notifications = notifications
|
||||||
service.options.changed = (roomId, db) => federation.changed(roomId, db)
|
service.options.changed = (roomId, db) => federation.changed(roomId, db)
|
||||||
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
||||||
let timer: ReturnType<typeof setInterval>
|
let timer: ReturnType<typeof setInterval>
|
||||||
server.addHook("onReady", async () => {
|
server.addHook("onReady", async () => {
|
||||||
if (federation.options.enabled) await federation.keys()
|
if (federation.options.enabled) await federation.keys()
|
||||||
timer = setInterval(() => federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed")), 5000)
|
timer = setInterval(() => {
|
||||||
|
void federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed"))
|
||||||
|
void notifications.tick().catch(error => server.log.error({ err: error }, "Collaboration notification worker failed"))
|
||||||
|
}, 5000)
|
||||||
timer.unref()
|
timer.unref()
|
||||||
})
|
})
|
||||||
server.addHook("onClose", async () => { clearInterval(timer) })
|
server.addHook("onClose", async () => { clearInterval(timer) })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"
|
|||||||
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
|
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
|
||||||
|
|
||||||
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
|
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
|
||||||
|
import type { CollaborationNotifications } from "./notifications"
|
||||||
|
|
||||||
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
|
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
|
||||||
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
|
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
|
||||||
@@ -13,6 +14,7 @@ export type CollaborationOptions = {
|
|||||||
sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>;
|
sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>;
|
||||||
changed?: (roomId: string, db: any) => Promise<void>;
|
changed?: (roomId: string, db: any) => Promise<void>;
|
||||||
invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>;
|
invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>;
|
||||||
|
notifications?: CollaborationNotifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Owner-side authority. All membership changes and sends serialize on the room row. */
|
/** Owner-side authority. All membership changes and sends serialize on the room row. */
|
||||||
@@ -142,6 +144,8 @@ export class CollaborationService {
|
|||||||
t.name AS "ownerName", m.overview_allowed AS "overviewAllowed",
|
t.name AS "ownerName", m.overview_allowed AS "overviewAllowed",
|
||||||
(SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id
|
(SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id
|
||||||
AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread
|
AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread
|
||||||
|
,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id
|
||||||
|
AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt"
|
||||||
FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id
|
FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id
|
||||||
LEFT JOIN projects p ON p.id=r.project_id
|
LEFT JOIN projects p ON p.id=r.project_id
|
||||||
WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
|
WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
|
||||||
@@ -201,6 +205,7 @@ export class CollaborationService {
|
|||||||
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image)
|
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image)
|
||||||
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
|
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
|
||||||
await this.options.changed?.(room.id, db)
|
await this.options.changed?.(room.id, db)
|
||||||
|
await this.options.notifications?.localMessage(db, room, principal, message)
|
||||||
return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind }
|
return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -302,6 +307,7 @@ export class CollaborationService {
|
|||||||
if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503)
|
if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503)
|
||||||
await this.options.invited(ctx, room.id, target, invite.id, db)
|
await this.options.invited(ctx, room.id, target, invite.id, db)
|
||||||
}
|
}
|
||||||
|
await this.options.notifications?.localInvitation(db, room, target, invite.id)
|
||||||
return invite
|
return invite
|
||||||
})
|
})
|
||||||
return { ...result, token }
|
return { ...result, token }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
incominginvoices,
|
incominginvoices,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import { eq, and, isNull, not } from "drizzle-orm"
|
import { eq, and, inArray, isNull, not } from "drizzle-orm"
|
||||||
|
|
||||||
const formatInvoiceItemDescription = (item: any) => {
|
const formatInvoiceItemDescription = (item: any) => {
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -31,7 +31,7 @@ const formatInvoiceItemDescription = (item: any) => {
|
|||||||
return parts.join(" - ")
|
return parts.join(" - ")
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
export const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
||||||
const itemInfo: any = {
|
const itemInfo: any = {
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
state: "Vorbereitet",
|
state: "Vorbereitet",
|
||||||
@@ -43,6 +43,17 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
|||||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||||
|
|
||||||
|
// Skonto nur übernehmen, wenn die Rechnung ihn ausdrücklich nennt (siehe Prompt).
|
||||||
|
const cashDiscountPercent = Number(data.cash_discount_percent)
|
||||||
|
if (Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 && cashDiscountPercent <= 10) {
|
||||||
|
itemInfo.cashDiscountPercent = Number(cashDiscountPercent.toFixed(2))
|
||||||
|
if (data.cash_discount_due_date && dayjs(data.cash_discount_due_date).isValid()) {
|
||||||
|
itemInfo.cashDiscountDueDate = dayjs(data.cash_discount_due_date).format("YYYY-MM-DD")
|
||||||
|
} else if (Number.isInteger(data.cash_discount_days) && data.invoice_date && dayjs(data.invoice_date).isValid()) {
|
||||||
|
itemInfo.cashDiscountDueDate = dayjs(data.invoice_date).add(data.cash_discount_days, "day").format("YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mapPayment: Record<string, string> = {
|
const mapPayment: Record<string, string> = {
|
||||||
"Direct Debit": "Einzug",
|
"Direct Debit": "Einzug",
|
||||||
"Transfer": "Überweisung",
|
"Transfer": "Überweisung",
|
||||||
@@ -86,8 +97,25 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
|||||||
return itemInfo
|
return itemInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoicePreparationResult = {
|
||||||
|
filesFound: number
|
||||||
|
created: Array<{ fileId: string, invoiceId: number, source: string | null }>
|
||||||
|
skipped: Array<{ fileId: string, reason: string }>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoicePreparationStatus = {
|
||||||
|
running: boolean
|
||||||
|
startedAt: string | null
|
||||||
|
finishedAt: string | null
|
||||||
|
result: IncomingInvoicePreparationResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileAlreadyPreparedError extends Error {}
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number, fileIds?: string[]) => {
|
||||||
|
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
|
||||||
console.log("▶ Starting Incoming Invoice Preparation")
|
console.log("▶ Starting Incoming Invoice Preparation")
|
||||||
|
|
||||||
const tenantsRes = await server.db
|
const tenantsRes = await server.db
|
||||||
@@ -98,7 +126,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (!tenantsRes.length) {
|
if (!tenantsRes.length) {
|
||||||
console.log("No tenants with autoPrepareIncomingInvoices = true")
|
console.log("No tenants with autoPrepareIncomingInvoices = true")
|
||||||
return
|
return { ...result, error: "Mandant nicht gefunden" }
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
|
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
|
||||||
@@ -124,6 +152,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
const invoiceFileTag = tagRes?.[0]?.id
|
const invoiceFileTag = tagRes?.[0]?.id
|
||||||
if (!invoiceFileTag) {
|
if (!invoiceFileTag) {
|
||||||
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
|
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
|
||||||
|
result.error = "Dateityp für Eingangsrechnungen fehlt"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +166,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
eq(files.type, invoiceFileTag),
|
eq(files.type, invoiceFileTag),
|
||||||
isNull(files.incominginvoice),
|
isNull(files.incominginvoice),
|
||||||
eq(files.archived, false),
|
eq(files.archived, false),
|
||||||
not(isNull(files.path))
|
not(isNull(files.path)),
|
||||||
|
...(fileIds ? [inArray(files.id, fileIds)] : [])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result.filesFound += filesRes.length
|
||||||
|
|
||||||
if (!filesRes.length) {
|
if (!filesRes.length) {
|
||||||
console.log(`No invoice files for tenant ${tenantId}`)
|
console.log(`No invoice files for tenant ${tenantId}`)
|
||||||
continue
|
continue
|
||||||
@@ -150,95 +182,152 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
|
||||||
|
|
||||||
let fileData: Buffer
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fileData = await loadFileBuffer(file.path!)
|
await processFile(tenantId, file, result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
if (error instanceof FileAlreadyPreparedError) {
|
||||||
continue
|
result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
|
||||||
}
|
|
||||||
|
|
||||||
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
|
||||||
|
|
||||||
let itemInfo: any = null
|
|
||||||
|
|
||||||
if (electronicInvoice) {
|
|
||||||
server.log.info({
|
|
||||||
fileId: file.id,
|
|
||||||
container: electronicInvoice.container,
|
|
||||||
syntax: electronicInvoice.syntax,
|
|
||||||
attachmentName: electronicInvoice.attachmentName,
|
|
||||||
}, "Strukturierte E-Rechnung erkannt.")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
|
|
||||||
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
|
|
||||||
itemInfo = mapElectronicInvoiceToIncomingInvoice(
|
|
||||||
parsedInvoice,
|
|
||||||
tenantId,
|
|
||||||
vendor?.id || null,
|
|
||||||
electronicInvoice.container,
|
|
||||||
electronicInvoice.attachmentName,
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
|
||||||
|
|
||||||
if (electronicInvoice.container === "xml") continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemInfo) {
|
|
||||||
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
|
||||||
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
return result
|
||||||
// 4️⃣ IncomingInvoice erstellen
|
}
|
||||||
// ---------------------------------------------------------
|
|
||||||
const inserted = await server.db
|
|
||||||
.insert(incominginvoices)
|
|
||||||
.values(itemInfo)
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
const newInvoice = inserted?.[0]
|
const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
|
||||||
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
if (!newInvoice) {
|
let fileData: Buffer
|
||||||
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
|
|
||||||
continue
|
try {
|
||||||
|
fileData = await loadFileBuffer(file.path!)
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||||
|
|
||||||
|
let itemInfo: any = null
|
||||||
|
|
||||||
|
if (electronicInvoice) {
|
||||||
|
server.log.info({
|
||||||
|
fileId: file.id,
|
||||||
|
container: electronicInvoice.container,
|
||||||
|
syntax: electronicInvoice.syntax,
|
||||||
|
attachmentName: electronicInvoice.attachmentName,
|
||||||
|
}, "Strukturierte E-Rechnung erkannt.")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
|
||||||
|
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
|
||||||
|
itemInfo = mapElectronicInvoiceToIncomingInvoice(
|
||||||
|
parsedInvoice,
|
||||||
|
tenantId,
|
||||||
|
vendor?.id || null,
|
||||||
|
electronicInvoice.container,
|
||||||
|
electronicInvoice.attachmentName,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
||||||
|
|
||||||
|
if (electronicInvoice.container === "xml") {
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
if (!itemInfo) {
|
||||||
// 5️⃣ Datei mit incominginvoice-ID verbinden
|
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
|
||||||
// ---------------------------------------------------------
|
|
||||||
await server.db
|
|
||||||
.update(files)
|
|
||||||
.set({ incominginvoice: newInvoice.id })
|
|
||||||
.where(eq(files.id, file.id))
|
|
||||||
|
|
||||||
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
if (!data) {
|
||||||
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
|
||||||
|
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
|
||||||
|
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
const newInvoice = await server.db.transaction(async (tx) => {
|
||||||
|
const [invoice] = await tx
|
||||||
|
.insert(incominginvoices)
|
||||||
|
.values(itemInfo)
|
||||||
|
.returning()
|
||||||
|
|
||||||
return
|
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
|
||||||
|
|
||||||
|
const linked = await tx
|
||||||
|
.update(files)
|
||||||
|
.set({ incominginvoice: invoice.id })
|
||||||
|
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
|
||||||
|
.returning({ id: files.id })
|
||||||
|
|
||||||
|
if (!linked.length) throw new FileAlreadyPreparedError()
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
})
|
||||||
|
|
||||||
|
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
|
||||||
|
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
|
||||||
run: async (tenant:number) => {
|
const running = new Map<number, Promise<unknown>>()
|
||||||
await processInvoices(tenant)
|
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
|
||||||
|
|
||||||
|
const run = async (tenant:number, options: { fileIds?: string[] } = {}) => {
|
||||||
|
const previous = running.get(tenant) || Promise.resolve()
|
||||||
|
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
|
||||||
|
running.set(tenant, current)
|
||||||
|
try {
|
||||||
|
const result = await current
|
||||||
console.log("Incoming Invoice Preparation Completed.")
|
console.log("Incoming Invoice Preparation Completed.")
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
if (running.get(tenant) === current) running.delete(tenant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = (tenant: number): IncomingInvoicePreparationStatus =>
|
||||||
|
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
|
||||||
|
|
||||||
|
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
|
||||||
|
const start = (tenant: number): IncomingInvoicePreparationStatus => {
|
||||||
|
const current = status(tenant)
|
||||||
|
if (current.running) return current
|
||||||
|
|
||||||
|
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
|
||||||
|
backgroundRuns.set(tenant, next)
|
||||||
|
|
||||||
|
run(tenant)
|
||||||
|
.then((result) => {
|
||||||
|
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
|
||||||
|
backgroundRuns.set(tenant, {
|
||||||
|
...next,
|
||||||
|
running: false,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return { run, start, status }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
374
backend/src/modules/einvoice/buildZugferdInvoice.ts
Normal file
374
backend/src/modules/einvoice/buildZugferdInvoice.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import dayjs from "dayjs"
|
||||||
|
import { buildCashDiscountTerms } from "../cash-discount"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt das strukturierte CII-XML (ZUGFeRD 2.x / Factur-X, Profil EN 16931) für Ausgangsrechnungen.
|
||||||
|
* Grundlage ist der gespeicherte Beleg, nicht die formatierten PDF-Daten, damit alle Beträge numerisch bleiben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ZUGFERD_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"]
|
||||||
|
export const ZUGFERD_GUIDELINE_ID = "urn:cen.eu:en16931:2017"
|
||||||
|
export const ZUGFERD_FILENAME = "factur-x.xml"
|
||||||
|
|
||||||
|
export type ZugferdInvoiceContext = {
|
||||||
|
tenant: any
|
||||||
|
customer: any
|
||||||
|
contact?: any
|
||||||
|
contactPerson?: any
|
||||||
|
project?: any
|
||||||
|
contract?: any
|
||||||
|
linkedDocument?: any
|
||||||
|
sepaMandate?: any
|
||||||
|
units: any[]
|
||||||
|
products: any[]
|
||||||
|
services: any[]
|
||||||
|
bankAccounts: any[]
|
||||||
|
advanceInvoices: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZugferdInvoiceResult = {
|
||||||
|
xml: string
|
||||||
|
issues: ZugferdIssue[]
|
||||||
|
totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaxCategory = "S" | "Z" | "E" | "AE"
|
||||||
|
type TaxGroup = { category: TaxCategory, rate: number, basis: number, exemptionReason?: string, exemptionReasonCode?: string }
|
||||||
|
|
||||||
|
const round = (value: number, digits = 2) => {
|
||||||
|
const factor = 10 ** digits
|
||||||
|
const rounded = Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor
|
||||||
|
return Object.is(rounded, -0) ? 0 : rounded
|
||||||
|
}
|
||||||
|
const amount = (value: number) => round(value).toFixed(2)
|
||||||
|
const decimal = (value: number, maxDigits = 4) => String(round(value, maxDigits))
|
||||||
|
|
||||||
|
const escapeXml = (value: unknown) => String(value ?? "")
|
||||||
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
|
||||||
|
const text = (value: unknown) => String(value ?? "").trim()
|
||||||
|
const el = (name: string, value: unknown, attributes = "") => {
|
||||||
|
const content = text(value)
|
||||||
|
return content ? `<${name}${attributes}>${escapeXml(content)}</${name}>` : ""
|
||||||
|
}
|
||||||
|
const date102 = (value: unknown) => {
|
||||||
|
if (!value) return ""
|
||||||
|
const parsed = dayjs(value as any)
|
||||||
|
return parsed.isValid() ? parsed.format("YYYYMMDD") : ""
|
||||||
|
}
|
||||||
|
const dateElement = (name: string, value: unknown) => {
|
||||||
|
const formatted = date102(value)
|
||||||
|
return formatted ? `<${name}><udt:DateTimeString format="102">${formatted}</udt:DateTimeString></${name}>` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryCodes: Record<string, string> = {
|
||||||
|
deutschland: "DE", germany: "DE", österreich: "AT", oesterreich: "AT", austria: "AT", schweiz: "CH", switzerland: "CH",
|
||||||
|
liechtenstein: "LI", frankreich: "FR", france: "FR", italien: "IT", italy: "IT", niederlande: "NL", netherlands: "NL",
|
||||||
|
belgien: "BE", belgium: "BE", luxemburg: "LU", luxembourg: "LU", dänemark: "DK", daenemark: "DK", denmark: "DK",
|
||||||
|
polen: "PL", poland: "PL", tschechien: "CZ", "tschechische republik": "CZ", spanien: "ES", spain: "ES",
|
||||||
|
portugal: "PT", schweden: "SE", sweden: "SE", norwegen: "NO", norway: "NO", finnland: "FI", finland: "FI",
|
||||||
|
ungarn: "HU", hungary: "HU", slowakei: "SK", slowenien: "SI", kroatien: "HR", rumänien: "RO", bulgarien: "BG",
|
||||||
|
griechenland: "GR", irland: "IE", ireland: "IE", "vereinigtes königreich": "GB", großbritannien: "GB", england: "GB",
|
||||||
|
"united kingdom": "GB", usa: "US", "vereinigte staaten": "US", estland: "EE", lettland: "LV", litauen: "LT",
|
||||||
|
malta: "MT", zypern: "CY", türkei: "TR",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countryCode(value: unknown) {
|
||||||
|
const normalized = text(value).toLocaleLowerCase("de")
|
||||||
|
if (!normalized) return "DE"
|
||||||
|
if (/^[a-z]{2}$/.test(normalized)) return normalized.toUpperCase()
|
||||||
|
return countryCodes[normalized] || "DE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UN/ECE Recommendation 20/21, abgebildet über das Kürzel der FEDEO-Einheit.
|
||||||
|
const unitCodes: Record<string, string> = {
|
||||||
|
stk: "H87", stück: "H87", m: "MTR", meter: "MTR", km: "KMT", kilometer: "KMT", tag: "DAY", tage: "DAY",
|
||||||
|
std: "HUR", h: "HUR", stunde: "HUR", stunden: "HUR", min: "MIN", minute: "MIN", minuten: "MIN",
|
||||||
|
pausch: "LS", pauschal: "LS", psch: "LS", mon: "MON", monat: "MON", jahr: "ANN", woche: "WEE",
|
||||||
|
pck: "PK", packung: "PK", pers: "IE", person: "IE", l: "LTR", liter: "LTR", ha: "HAR", hektar: "HAR",
|
||||||
|
"m²": "MTK", m2: "MTK", qm: "MTK", quadratmeter: "MTK", "m³": "MTQ", m3: "MTQ", kubikmeter: "MTQ",
|
||||||
|
kg: "KGM", kilogramm: "KGM", g: "GRM", t: "TNE", tonne: "TNE", gb: "E34", gigabyte: "E34", kwp: "KWT", kwh: "KWH",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unitCode(unit: any) {
|
||||||
|
for (const candidate of [unit?.short, unit?.single, unit?.name]) {
|
||||||
|
const code = unitCodes[text(candidate).toLocaleLowerCase("de")]
|
||||||
|
if (code) return code
|
||||||
|
}
|
||||||
|
return "C62"
|
||||||
|
}
|
||||||
|
|
||||||
|
const isItemRow = (row: any) => !["pagebreak", "title", "text"].includes(row?.mode) && !row?.optional && !row?.alternative
|
||||||
|
|
||||||
|
function taxGroupFor(taxType: string | null | undefined, rate: number): Omit<TaxGroup, "basis"> {
|
||||||
|
if (taxType === "13b UStG") return {
|
||||||
|
category: "AE", rate: 0, exemptionReasonCode: "VATEX-EU-AE",
|
||||||
|
exemptionReason: "Steuerschuldnerschaft des Leistungsempfängers gemäß § 13b UStG",
|
||||||
|
}
|
||||||
|
if (taxType === "19 UStG") return {
|
||||||
|
category: "E", rate: 0,
|
||||||
|
exemptionReason: "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG",
|
||||||
|
}
|
||||||
|
if (taxType === "12.3 UStG") return { category: "Z", rate: 0 }
|
||||||
|
return rate > 0 ? { category: "S", rate } : { category: "Z", rate: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceInvoiceGross(invoice: any) {
|
||||||
|
const row = (invoice?.rows || []).find((item: any) => item.advanceInvoiceData)
|
||||||
|
return row ? Number(row.price) * ((100 + Number(row.taxPercent)) / 100) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeCode(type: string) {
|
||||||
|
if (type === "advanceInvoices") return "386"
|
||||||
|
if (type === "cancellationInvoices") return "381"
|
||||||
|
return "380"
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyAddress(address: { zip?: unknown, street?: unknown, special?: unknown, city?: unknown, country?: unknown }) {
|
||||||
|
return `<ram:PostalTradeAddress>${el("ram:PostcodeCode", address.zip)}${el("ram:LineOne", address.street)}${el("ram:LineTwo", address.special)}${el("ram:CityName", address.city)}<ram:CountryID>${countryCode(address.country)}</ram:CountryID></ram:PostalTradeAddress>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeContact(name: unknown, phone?: unknown, email?: unknown) {
|
||||||
|
if (!text(name) && !text(phone) && !text(email)) return ""
|
||||||
|
return `<ram:DefinedTradeContact>${el("ram:PersonName", name)}${text(phone) ? `<ram:TelephoneUniversalCommunication>${el("ram:CompleteNumber", phone)}</ram:TelephoneUniversalCommunication>` : ""}${text(email) ? `<ram:EmailURIUniversalCommunication>${el("ram:URIID", email)}</ram:EmailURIUniversalCommunication>` : ""}</ram:DefinedTradeContact>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxRegistrations(vatId: unknown, taxNumber?: unknown) {
|
||||||
|
return [
|
||||||
|
text(vatId) ? `<ram:SpecifiedTaxRegistration><ram:ID schemeID="VA">${escapeXml(text(vatId).replace(/\s+/g, ""))}</ram:ID></ram:SpecifiedTaxRegistration>` : "",
|
||||||
|
text(taxNumber) ? `<ram:SpecifiedTaxRegistration><ram:ID schemeID="FC">${escapeXml(text(taxNumber))}</ram:ID></ram:SpecifiedTaxRegistration>` : "",
|
||||||
|
].join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZugferdIssue = { message: string, type: "breaking" | "info" }
|
||||||
|
|
||||||
|
const sellerIban = (context: ZugferdInvoiceContext) => {
|
||||||
|
const businessInfo = context.tenant?.businessInfo || {}
|
||||||
|
const account = context.bankAccounts.find((item) => !item.archived && !item.expired && item.iban)
|
||||||
|
return text(businessInfo.iban || account?.iban).replace(/\s+/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pflichtangaben einer E-Rechnung (EN 16931 / § 14 UStG). "breaking" verhindert das Fertigstellen,
|
||||||
|
* "info" ist ein Hinweis. requireNumber prüft zusätzlich Nummer und Datum, die erst beim Fertigstellen entstehen.
|
||||||
|
*/
|
||||||
|
export function checkZugferdRequirements(document: any, context: ZugferdInvoiceContext, options: { requireNumber?: boolean } = {}): ZugferdIssue[] {
|
||||||
|
const issues: ZugferdIssue[] = []
|
||||||
|
const breaking = (message: string) => issues.push({ message: `E-Rechnung: ${message}`, type: "breaking" })
|
||||||
|
const info = (message: string) => issues.push({ message: `E-Rechnung: ${message}`, type: "info" })
|
||||||
|
const businessInfo = context.tenant?.businessInfo || {}
|
||||||
|
const customer = context.customer || {}
|
||||||
|
const customerInfo = customer.infoData || {}
|
||||||
|
const address = document.address || {}
|
||||||
|
|
||||||
|
if (options.requireNumber && !text(document.documentNumber)) breaking("Die Rechnungsnummer fehlt")
|
||||||
|
if (options.requireNumber && !date102(document.documentDate)) breaking("Das Rechnungsdatum fehlt")
|
||||||
|
if (!text(businessInfo.name || context.tenant?.name)) breaking("Der Firmenname fehlt in den Firmendaten (Einstellungen > Firmendaten)")
|
||||||
|
if (!text(businessInfo.street) || !text(businessInfo.zip) || !text(businessInfo.city)) breaking("Die Firmenanschrift ist in den Firmendaten unvollständig (Straße, PLZ, Ort)")
|
||||||
|
if (!text(businessInfo.vatId) && !text(businessInfo.taxNumber)) breaking("In den Firmendaten ist weder eine USt-IdNr. noch eine Steuernummer hinterlegt")
|
||||||
|
if (document.customer && !text(customer.name)) breaking("Der Kunde hat keinen Namen")
|
||||||
|
if (document.customer && (!text(address.street ?? customerInfo.street) || !text(address.zip ?? customerInfo.zip) || !text(address.city ?? customerInfo.city))) {
|
||||||
|
breaking("Die Anschrift des Kunden ist unvollständig (Straße, PLZ, Ort)")
|
||||||
|
}
|
||||||
|
if (document.taxType === "13b UStG") {
|
||||||
|
if (document.customer && !text(customerInfo.ustid)) breaking("Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein")
|
||||||
|
if (!text(businessInfo.vatId)) breaking("Für § 13b UStG muss in den Firmendaten eine USt-IdNr. hinterlegt sein")
|
||||||
|
}
|
||||||
|
if (!(document.rows || []).some(isItemRow)) breaking("Der Beleg enthält keine abrechenbaren Positionen")
|
||||||
|
if (document.payment_type === "direct-debit") {
|
||||||
|
if (!text(context.tenant?.creditorId)) breaking("Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt")
|
||||||
|
if (!text(context.sepaMandate?.reference)) breaking("Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt")
|
||||||
|
} else if ((document.payment_type === "transfer" || !document.payment_type) && !sellerIban(context)) {
|
||||||
|
breaking("Für Überweisung ist keine IBAN hinterlegt (Firmendaten oder Bankkonto)")
|
||||||
|
}
|
||||||
|
if (text(document.deliveryDateType) === "Kein Lieferdatum anzeigen") info("Es wird kein Liefer- oder Leistungsdatum angegeben (§ 14 UStG)")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceContext): ZugferdInvoiceResult {
|
||||||
|
const businessInfo = context.tenant?.businessInfo || {}
|
||||||
|
const customer = context.customer || {}
|
||||||
|
const customerInfo = customer.infoData || {}
|
||||||
|
const address = document.address || {}
|
||||||
|
// Stornorechnungen tragen negative Preise; als Gutschrift (381) werden die Beträge positiv ausgewiesen.
|
||||||
|
const sign = document.type === "cancellationInvoices" ? -1 : 1
|
||||||
|
|
||||||
|
const issues = checkZugferdRequirements(document, context, { requireNumber: true })
|
||||||
|
|
||||||
|
const groups = new Map<string, TaxGroup>()
|
||||||
|
const lines: string[] = []
|
||||||
|
let lineIndex = 0
|
||||||
|
|
||||||
|
for (const row of document.rows || []) {
|
||||||
|
if (!isItemRow(row)) continue
|
||||||
|
lineIndex++
|
||||||
|
const discountPercent = Number(row.discountPercent) || 0
|
||||||
|
const grossUnitPrice = sign * (Number(row.price) || 0)
|
||||||
|
let netUnitPrice = grossUnitPrice * (1 - discountPercent / 100)
|
||||||
|
let quantity = Number(row.quantity) || 0
|
||||||
|
let grossPrice = grossUnitPrice
|
||||||
|
// EN 16931 verlangt nicht-negative Einzelpreise (BR-27); Minderungen laufen über eine negative Menge.
|
||||||
|
if (netUnitPrice < 0 || (netUnitPrice === 0 && grossPrice < 0)) {
|
||||||
|
netUnitPrice = -netUnitPrice
|
||||||
|
grossPrice = -grossPrice
|
||||||
|
quantity = -quantity
|
||||||
|
}
|
||||||
|
const lineTotal = round(quantity * netUnitPrice)
|
||||||
|
const rate = Number(row.taxPercent) || 0
|
||||||
|
const group = taxGroupFor(document.taxType, rate)
|
||||||
|
const key = `${group.category}:${group.rate}`
|
||||||
|
const existing = groups.get(key)
|
||||||
|
if (existing) existing.basis = round(existing.basis + lineTotal)
|
||||||
|
else groups.set(key, { ...group, basis: lineTotal })
|
||||||
|
|
||||||
|
const product = row.mode === "normal" ? context.products.find((item) => item.id === row.product) : null
|
||||||
|
const service = row.mode === "service" ? context.services.find((item) => item.id === row.service) : null
|
||||||
|
const name = text(product?.name || service?.name || row.text) || `Position ${row.pos ?? lineIndex}`
|
||||||
|
const sellerId = product?.article_number
|
||||||
|
const description = text(row.agriculture?.description || row.description)
|
||||||
|
const unit = context.units.find((item) => item.id === row.unit)
|
||||||
|
const allowance = round(grossPrice - netUnitPrice, 4)
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`<ram:IncludedSupplyChainTradeLineItem>` +
|
||||||
|
`<ram:AssociatedDocumentLineDocument><ram:LineID>${escapeXml(text(row.pos) || String(lineIndex))}</ram:LineID></ram:AssociatedDocumentLineDocument>` +
|
||||||
|
`<ram:SpecifiedTradeProduct>${el("ram:SellerAssignedID", sellerId)}<ram:Name>${escapeXml(name)}</ram:Name>${el("ram:Description", description)}</ram:SpecifiedTradeProduct>` +
|
||||||
|
`<ram:SpecifiedLineTradeAgreement>` +
|
||||||
|
(allowance > 0
|
||||||
|
? `<ram:GrossPriceProductTradePrice><ram:ChargeAmount>${decimal(grossPrice)}</ram:ChargeAmount><ram:AppliedTradeAllowanceCharge><ram:ChargeIndicator><udt:Indicator>false</udt:Indicator></ram:ChargeIndicator><ram:ActualAmount>${decimal(allowance)}</ram:ActualAmount></ram:AppliedTradeAllowanceCharge></ram:GrossPriceProductTradePrice>`
|
||||||
|
: "") +
|
||||||
|
`<ram:NetPriceProductTradePrice><ram:ChargeAmount>${decimal(netUnitPrice)}</ram:ChargeAmount></ram:NetPriceProductTradePrice>` +
|
||||||
|
`</ram:SpecifiedLineTradeAgreement>` +
|
||||||
|
`<ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="${unitCode(unit)}">${decimal(quantity)}</ram:BilledQuantity></ram:SpecifiedLineTradeDelivery>` +
|
||||||
|
`<ram:SpecifiedLineTradeSettlement><ram:ApplicableTradeTax><ram:TypeCode>VAT</ram:TypeCode><ram:CategoryCode>${group.category}</ram:CategoryCode><ram:RateApplicablePercent>${decimal(group.rate)}</ram:RateApplicablePercent></ram:ApplicableTradeTax>` +
|
||||||
|
`<ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>${amount(lineTotal)}</ram:LineTotalAmount></ram:SpecifiedTradeSettlementLineMonetarySummation></ram:SpecifiedLineTradeSettlement>` +
|
||||||
|
`</ram:IncludedSupplyChainTradeLineItem>`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxGroups = [...groups.values()]
|
||||||
|
const lineNet = round(taxGroups.reduce((sum, group) => sum + group.basis, 0))
|
||||||
|
const taxTotal = round(taxGroups.reduce((sum, group) => sum + round(group.basis * group.rate / 100), 0))
|
||||||
|
const grossTotal = round(lineNet + taxTotal)
|
||||||
|
const prepaid = ["invoices", "cancellationInvoices"].includes(document.type)
|
||||||
|
? round(context.advanceInvoices.reduce((sum, invoice) => sum + advanceInvoiceGross(invoice), 0))
|
||||||
|
: 0
|
||||||
|
const payable = round(grossTotal - prepaid)
|
||||||
|
|
||||||
|
// Zahlungsinformationen
|
||||||
|
const paymentAccount = context.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
|
||||||
|
const iban = text(businessInfo.iban || paymentAccount?.iban).replace(/\s+/g, "")
|
||||||
|
const bic = text(businessInfo.bic || paymentAccount?.bic).replace(/\s+/g, "")
|
||||||
|
const accountName = text(businessInfo.bankAccountOwner || paymentAccount?.ownerName || businessInfo.name)
|
||||||
|
let paymentMeans = ""
|
||||||
|
if (document.payment_type === "direct-debit") {
|
||||||
|
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>59</ram:TypeCode><ram:Information>SEPA-Lastschrift</ram:Information></ram:SpecifiedTradeSettlementPaymentMeans>`
|
||||||
|
} else if (document.payment_type === "transfer" || !document.payment_type) {
|
||||||
|
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>58</ram:TypeCode><ram:Information>SEPA-Überweisung</ram:Information>` +
|
||||||
|
(iban ? `<ram:PayeePartyCreditorFinancialAccount><ram:IBANID>${escapeXml(iban)}</ram:IBANID>${el("ram:AccountName", accountName)}</ram:PayeePartyCreditorFinancialAccount>` : "") +
|
||||||
|
(iban && bic ? `<ram:PayeeSpecifiedCreditorFinancialInstitution><ram:BICID>${escapeXml(bic)}</ram:BICID></ram:PayeeSpecifiedCreditorFinancialInstitution>` : "") +
|
||||||
|
`</ram:SpecifiedTradeSettlementPaymentMeans>`
|
||||||
|
} else {
|
||||||
|
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>1</ram:TypeCode></ram:SpecifiedTradeSettlementPaymentMeans>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDays = document.paymentDays === null || document.paymentDays === undefined || document.paymentDays === "" ? null : Number(document.paymentDays)
|
||||||
|
const dueDate = date102(document.documentDate) && paymentDays !== null && Number.isFinite(paymentDays)
|
||||||
|
? dayjs(document.documentDate).add(paymentDays, "day")
|
||||||
|
: null
|
||||||
|
const cashDiscount = ["invoices", "advanceInvoices"].includes(document.type)
|
||||||
|
? buildCashDiscountTerms({ percent: document.cashDiscountPercent, days: document.cashDiscountDays, documentDate: document.documentDate, amount: payable })
|
||||||
|
: null
|
||||||
|
const termsDescription = [
|
||||||
|
payable <= 0 ? "" : dueDate ? `Zahlbar bis zum ${dueDate.format("DD.MM.YYYY")} ohne Abzug.` : "Zahlbar sofort ohne Abzug.",
|
||||||
|
// Strukturierte Skontoangabe nach XRechnung-Konvention, die von gängigen Empfängern ausgewertet wird.
|
||||||
|
cashDiscount && cashDiscount.days !== null
|
||||||
|
? `#SKONTO#TAGE=${cashDiscount.days}#PROZENT=${cashDiscount.percent.toFixed(2)}#BASISBETRAG=${amount(payable)}#`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
|
const paymentTerms = termsDescription || dueDate || context.sepaMandate?.reference
|
||||||
|
? `<ram:SpecifiedTradePaymentTerms>${el("ram:Description", termsDescription)}${dueDate ? dateElement("ram:DueDateDateTime", dueDate.toDate()) : ""}${el("ram:DirectDebitMandateID", document.payment_type === "direct-debit" ? context.sepaMandate?.reference : "")}</ram:SpecifiedTradePaymentTerms>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const taxElements = taxGroups.map((group) =>
|
||||||
|
`<ram:ApplicableTradeTax><ram:CalculatedAmount>${amount(group.basis * group.rate / 100)}</ram:CalculatedAmount><ram:TypeCode>VAT</ram:TypeCode>${el("ram:ExemptionReason", group.exemptionReason)}<ram:BasisAmount>${amount(group.basis)}</ram:BasisAmount><ram:CategoryCode>${group.category}</ram:CategoryCode>${el("ram:ExemptionReasonCode", group.exemptionReasonCode)}<ram:RateApplicablePercent>${decimal(group.rate)}</ram:RateApplicablePercent></ram:ApplicableTradeTax>`,
|
||||||
|
).join("")
|
||||||
|
|
||||||
|
// Leistungsdatum bzw. Leistungszeitraum
|
||||||
|
const deliveryType = text(document.deliveryDateType)
|
||||||
|
const isPeriod = ["Lieferzeitraum", "Leistungszeitraum"].includes(deliveryType)
|
||||||
|
const showDelivery = deliveryType !== "Kein Lieferdatum anzeigen"
|
||||||
|
const deliveryEvent = showDelivery && !isPeriod && date102(document.deliveryDate)
|
||||||
|
? `<ram:ActualDeliverySupplyChainEvent>${dateElement("ram:OccurrenceDateTime", document.deliveryDate)}</ram:ActualDeliverySupplyChainEvent>`
|
||||||
|
: ""
|
||||||
|
const billingPeriod = showDelivery && isPeriod && (date102(document.deliveryDate) || date102(document.deliveryDateEnd))
|
||||||
|
? `<ram:BillingSpecifiedPeriod>${dateElement("ram:StartDateTime", document.deliveryDate)}${dateElement("ram:EndDateTime", document.deliveryDateEnd)}</ram:BillingSpecifiedPeriod>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const contactPerson = context.contactPerson || {}
|
||||||
|
const sellerContact = tradeContact(contactPerson.full_name, document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel || businessInfo.phone, contactPerson.email || businessInfo.email)
|
||||||
|
const seller = `<ram:SellerTradeParty>${el("ram:Name", businessInfo.name || context.tenant?.name)}${sellerContact}` +
|
||||||
|
partyAddress({ zip: businessInfo.zip, street: businessInfo.street, city: businessInfo.city, country: businessInfo.country }) +
|
||||||
|
(text(businessInfo.email) ? `<ram:URIUniversalCommunication><ram:URIID schemeID="EM">${escapeXml(text(businessInfo.email))}</ram:URIID></ram:URIUniversalCommunication>` : "") +
|
||||||
|
taxRegistrations(businessInfo.vatId, businessInfo.taxNumber) +
|
||||||
|
`</ram:SellerTradeParty>`
|
||||||
|
|
||||||
|
const buyerEmail = text(customerInfo.invoiceEmail || customerInfo.email)
|
||||||
|
const contact = context.contact
|
||||||
|
const buyer = `<ram:BuyerTradeParty>${el("ram:ID", typeof customer.customerNumber === "object" ? "" : customer.customerNumber)}${el("ram:Name", customer.name)}` +
|
||||||
|
(contact ? tradeContact([contact.firstName, contact.lastName].filter(Boolean).join(" "), contact.phoneHome || contact.phoneMobile, contact.email) : "") +
|
||||||
|
partyAddress({
|
||||||
|
zip: address.zip ?? customerInfo.zip,
|
||||||
|
street: address.street ?? customerInfo.street,
|
||||||
|
special: address.special ?? customerInfo.special,
|
||||||
|
city: address.city ?? customerInfo.city,
|
||||||
|
country: address.country ?? customerInfo.country,
|
||||||
|
}) +
|
||||||
|
(buyerEmail ? `<ram:URIUniversalCommunication><ram:URIID schemeID="EM">${escapeXml(buyerEmail)}</ram:URIID></ram:URIUniversalCommunication>` : "") +
|
||||||
|
taxRegistrations(customerInfo.ustid) +
|
||||||
|
`</ram:BuyerTradeParty>`
|
||||||
|
|
||||||
|
const contractReference = el("ram:IssuerAssignedID", context.contract?.contractNumber)
|
||||||
|
const projectReference = context.project?.name
|
||||||
|
? `<ram:SpecifiedProcuringProject><ram:ID>${escapeXml(text(context.project.projectNumber || context.project.id))}</ram:ID><ram:Name>${escapeXml(text(context.project.name))}</ram:Name></ram:SpecifiedProcuringProject>`
|
||||||
|
: ""
|
||||||
|
const precedingInvoice = document.type === "cancellationInvoices" && text(context.linkedDocument?.documentNumber)
|
||||||
|
? `<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>${escapeXml(text(context.linkedDocument.documentNumber))}</ram:IssuerAssignedID>${date102(context.linkedDocument.documentDate) ? `<ram:FormattedIssueDateTime><qdt:DateTimeString format="102">${date102(context.linkedDocument.documentDate)}</qdt:DateTimeString></ram:FormattedIssueDateTime>` : ""}</ram:InvoiceReferencedDocument>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const notes = [
|
||||||
|
text(document.title),
|
||||||
|
text(document.description),
|
||||||
|
].filter(Boolean).map((note) => `<ram:IncludedNote><ram:Content>${escapeXml(note)}</ram:Content></ram:IncludedNote>`).join("")
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">` +
|
||||||
|
`<rsm:ExchangedDocumentContext><ram:GuidelineSpecifiedDocumentContextParameter><ram:ID>${ZUGFERD_GUIDELINE_ID}</ram:ID></ram:GuidelineSpecifiedDocumentContextParameter></rsm:ExchangedDocumentContext>` +
|
||||||
|
`<rsm:ExchangedDocument><ram:ID>${escapeXml(text(document.documentNumber))}</ram:ID><ram:TypeCode>${typeCode(document.type)}</ram:TypeCode>` +
|
||||||
|
`<ram:IssueDateTime><udt:DateTimeString format="102">${date102(document.documentDate) || dayjs().format("YYYYMMDD")}</udt:DateTimeString></ram:IssueDateTime>${notes}</rsm:ExchangedDocument>` +
|
||||||
|
`<rsm:SupplyChainTradeTransaction>${lines.join("")}` +
|
||||||
|
`<ram:ApplicableHeaderTradeAgreement>${el("ram:BuyerReference", context.project?.customerRef)}${seller}${buyer}` +
|
||||||
|
(contractReference ? `<ram:ContractReferencedDocument>${contractReference}</ram:ContractReferencedDocument>` : "") +
|
||||||
|
`${projectReference}</ram:ApplicableHeaderTradeAgreement>` +
|
||||||
|
`<ram:ApplicableHeaderTradeDelivery>${deliveryEvent}</ram:ApplicableHeaderTradeDelivery>` +
|
||||||
|
`<ram:ApplicableHeaderTradeSettlement>` +
|
||||||
|
(document.payment_type === "direct-debit" ? el("ram:CreditorReferenceID", context.tenant?.creditorId) : "") +
|
||||||
|
el("ram:PaymentReference", document.documentNumber) +
|
||||||
|
`<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>${paymentMeans}${taxElements}${billingPeriod}${paymentTerms}` +
|
||||||
|
`<ram:SpecifiedTradeSettlementHeaderMonetarySummation>` +
|
||||||
|
`<ram:LineTotalAmount>${amount(lineNet)}</ram:LineTotalAmount>` +
|
||||||
|
`<ram:TaxBasisTotalAmount>${amount(lineNet)}</ram:TaxBasisTotalAmount>` +
|
||||||
|
`<ram:TaxTotalAmount currencyID="EUR">${amount(taxTotal)}</ram:TaxTotalAmount>` +
|
||||||
|
`<ram:GrandTotalAmount>${amount(grossTotal)}</ram:GrandTotalAmount>` +
|
||||||
|
(prepaid ? `<ram:TotalPrepaidAmount>${amount(prepaid)}</ram:TotalPrepaidAmount>` : "") +
|
||||||
|
`<ram:DuePayableAmount>${amount(payable)}</ram:DuePayableAmount>` +
|
||||||
|
`</ram:SpecifiedTradeSettlementHeaderMonetarySummation>${precedingInvoice}` +
|
||||||
|
`</ram:ApplicableHeaderTradeSettlement></rsm:SupplyChainTradeTransaction></rsm:CrossIndustryInvoice>`
|
||||||
|
|
||||||
|
return { xml, issues, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } }
|
||||||
|
}
|
||||||
80
backend/src/modules/einvoice/embedZugferd.ts
Normal file
80
backend/src/modules/einvoice/embedZugferd.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { AFRelationship, PDFDocument, PDFName } from "pdf-lib"
|
||||||
|
import { ZUGFERD_FILENAME } from "./buildZugferdInvoice"
|
||||||
|
|
||||||
|
const escapeXml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
|
||||||
|
function buildXmp(title: string, now: Date) {
|
||||||
|
const date = now.toISOString()
|
||||||
|
return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:format>application/pdf</dc:format>
|
||||||
|
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">${escapeXml(title)}</rdf:li></rdf:Alt></dc:title>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
|
||||||
|
<xmp:CreatorTool>FEDEO</xmp:CreatorTool>
|
||||||
|
<xmp:CreateDate>${date}</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>${date}</xmp:ModifyDate>
|
||||||
|
<xmp:MetadataDate>${date}</xmp:MetadataDate>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
|
||||||
|
<pdf:Producer>FEDEO</pdf:Producer>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
|
||||||
|
<fx:DocumentType>INVOICE</fx:DocumentType>
|
||||||
|
<fx:DocumentFileName>${ZUGFERD_FILENAME}</fx:DocumentFileName>
|
||||||
|
<fx:Version>1.0</fx:Version>
|
||||||
|
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||||
|
<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:property><rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description></rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description></rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description></rdf:li>
|
||||||
|
</rdf:Seq></pdfaSchema:property>
|
||||||
|
</rdf:li></rdf:Bag></pdfaExtension:schemas>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
<?xpacket end="w"?>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bettet das CII-XML als factur-x.xml (AFRelationship Alternative) in die PDF ein und ergänzt die
|
||||||
|
* Factur-X/ZUGFeRD-XMP-Metadaten. Ergebnis ist eine hybride ZUGFeRD-Rechnung (PDF + strukturierte Daten).
|
||||||
|
*/
|
||||||
|
export async function embedZugferdXml(pdfBytes: Uint8Array | Buffer, xml: string, options: { title?: string } = {}) {
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes, { updateMetadata: false })
|
||||||
|
const now = new Date()
|
||||||
|
const title = options.title || "Rechnung"
|
||||||
|
|
||||||
|
await pdfDoc.attach(Buffer.from(xml, "utf8"), ZUGFERD_FILENAME, {
|
||||||
|
mimeType: "text/xml",
|
||||||
|
description: "Factur-X/ZUGFeRD Rechnung",
|
||||||
|
creationDate: now,
|
||||||
|
modificationDate: now,
|
||||||
|
afRelationship: AFRelationship.Alternative,
|
||||||
|
})
|
||||||
|
|
||||||
|
pdfDoc.setTitle(title, { showInWindowTitleBar: true })
|
||||||
|
pdfDoc.setProducer("FEDEO")
|
||||||
|
pdfDoc.setCreator("FEDEO")
|
||||||
|
pdfDoc.setCreationDate(now)
|
||||||
|
pdfDoc.setModificationDate(now)
|
||||||
|
pdfDoc.setLanguage("de-DE")
|
||||||
|
|
||||||
|
const metadata = pdfDoc.context.stream(Buffer.from(buildXmp(title, now), "utf8"), {
|
||||||
|
Type: "Metadata",
|
||||||
|
Subtype: "XML",
|
||||||
|
})
|
||||||
|
pdfDoc.catalog.set(PDFName.of("Metadata"), pdfDoc.context.register(metadata))
|
||||||
|
pdfDoc.setSubject("ZUGFeRD / Factur-X EN 16931")
|
||||||
|
|
||||||
|
return Buffer.from(await pdfDoc.save({ useObjectStreams: false }))
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ export const findElectronicInvoiceVendor = async (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addDaysToDate = (date: string, days: number) => {
|
||||||
|
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null
|
||||||
|
parsed.setUTCDate(parsed.getUTCDate() + days)
|
||||||
|
return parsed.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
export const mapElectronicInvoiceToIncomingInvoice = (
|
export const mapElectronicInvoiceToIncomingInvoice = (
|
||||||
invoice: ParsedElectronicInvoice,
|
invoice: ParsedElectronicInvoice,
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
@@ -64,6 +71,10 @@ export const mapElectronicInvoiceToIncomingInvoice = (
|
|||||||
reference: invoice.invoiceNumber,
|
reference: invoice.invoiceNumber,
|
||||||
date: invoice.invoiceDate,
|
date: invoice.invoiceDate,
|
||||||
dueDate: invoice.dueDate,
|
dueDate: invoice.dueDate,
|
||||||
|
cashDiscountPercent: invoice.cashDiscount?.percent ?? null,
|
||||||
|
cashDiscountDueDate: invoice.cashDiscount && invoice.invoiceDate
|
||||||
|
? addDaysToDate(invoice.invoiceDate, invoice.cashDiscount.days)
|
||||||
|
: null,
|
||||||
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
||||||
description: descriptionParts.join("\n"),
|
description: descriptionParts.join("\n"),
|
||||||
accounts: invoice.items.map((item) => ({
|
accounts: invoice.items.map((item) => ({
|
||||||
|
|||||||
106
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal file
106
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import {
|
||||||
|
authProfiles,
|
||||||
|
bankaccounts,
|
||||||
|
contacts,
|
||||||
|
contracts,
|
||||||
|
createddocuments,
|
||||||
|
customers,
|
||||||
|
outgoingsepamandates,
|
||||||
|
products,
|
||||||
|
projects,
|
||||||
|
services,
|
||||||
|
tenants,
|
||||||
|
units,
|
||||||
|
} from "../../../db/schema"
|
||||||
|
import { buildZugferdInvoiceXml, checkZugferdRequirements, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext, ZugferdIssue } from "./buildZugferdInvoice"
|
||||||
|
import { embedZugferdXml } from "./embedZugferd"
|
||||||
|
|
||||||
|
export const isZugferdDocumentType = (type: unknown) => ZUGFERD_DOCUMENT_TYPES.includes(String(type))
|
||||||
|
export const isZugferdDocument = (document: any) => Boolean(document && isZugferdDocumentType(document.type) && document.documentNumber)
|
||||||
|
|
||||||
|
export class EInvoiceRequirementsError extends Error {
|
||||||
|
statusCode = 422
|
||||||
|
code = "EINVOICE_REQUIREMENTS"
|
||||||
|
constructor(public issues: ZugferdIssue[]) {
|
||||||
|
super(`Die E-Rechnung kann nicht erstellt werden: ${issues.map((issue) => issue.message.replace(/^E-Rechnung: /, "")).join("; ")}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const breakingIssues = (issues: ZugferdIssue[]) => issues.filter((issue) => issue.type === "breaking")
|
||||||
|
|
||||||
|
/** Prüft die Pflichtangaben eines (auch ungespeicherten) Belegs. Andere Belegarten liefern keine Hinweise. */
|
||||||
|
export async function checkOutgoingEInvoice(server: FastifyInstance, tenantId: number, document: any, options: { requireNumber?: boolean } = {}) {
|
||||||
|
if (!document || !isZugferdDocumentType(document.type)) return [] as ZugferdIssue[]
|
||||||
|
const context = await loadZugferdContext(server, tenantId, document)
|
||||||
|
return checkZugferdRequirements(document, context, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wirft EInvoiceRequirementsError, wenn Pflichtangaben fehlen. */
|
||||||
|
export async function assertOutgoingEInvoiceRequirements(server: FastifyInstance, tenantId: number, document: any, options: { requireNumber?: boolean } = {}) {
|
||||||
|
const blocking = breakingIssues(await checkOutgoingEInvoice(server, tenantId, document, options))
|
||||||
|
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
|
||||||
|
if (id === null || id === undefined || id === "") return null
|
||||||
|
const [row] = await server.db.select().from(table).where(and(eq(table.id, Number(id)), eq(table.tenant, tenantId))).limit(1)
|
||||||
|
return row || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadZugferdContext(server: FastifyInstance, tenantId: number, document: any): Promise<ZugferdInvoiceContext> {
|
||||||
|
const rows = document.rows || []
|
||||||
|
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
|
||||||
|
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
|
||||||
|
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
|
||||||
|
const profileUser = document.contactPerson || document.created_by
|
||||||
|
|
||||||
|
const [[tenant], customer, contact, project, contract, linkedDocument, sepaMandate, unitRows, productRows, serviceRows, bankAccountRows, advanceRows, profileRows] = await Promise.all([
|
||||||
|
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
|
||||||
|
byId(server, customers, document.customer, tenantId),
|
||||||
|
byId(server, contacts, document.contact, tenantId),
|
||||||
|
byId(server, projects, document.project, tenantId),
|
||||||
|
byId(server, contracts, document.contract, tenantId),
|
||||||
|
byId(server, createddocuments, document.createddocument ?? document.linkedDocument, tenantId),
|
||||||
|
byId(server, outgoingsepamandates, document.outgoingsepamandate, tenantId),
|
||||||
|
server.db.select().from(units),
|
||||||
|
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
|
||||||
|
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
|
||||||
|
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
|
||||||
|
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
|
profileUser
|
||||||
|
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, profileUser), eq(authProfiles.tenant_id, tenantId))).limit(1)
|
||||||
|
: [],
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant,
|
||||||
|
customer,
|
||||||
|
contact,
|
||||||
|
contactPerson: (profileRows as any[])[0] || null,
|
||||||
|
project,
|
||||||
|
contract,
|
||||||
|
linkedDocument,
|
||||||
|
sepaMandate,
|
||||||
|
units: unitRows,
|
||||||
|
products: productRows as any[],
|
||||||
|
services: serviceRows as any[],
|
||||||
|
bankAccounts: bankAccountRows,
|
||||||
|
advanceInvoices: advanceIds.map((id) => (advanceRows as any[]).find((invoice) => invoice.id === id)).filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehlende Pflichtangaben führen zu
|
||||||
|
* EInvoiceRequirementsError, damit keine Rechnung ohne gültige E-Rechnung abgelegt wird.
|
||||||
|
*/
|
||||||
|
export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) {
|
||||||
|
if (!isZugferdDocument(document)) return { pdf, issues: [] as ZugferdIssue[], einvoice: false }
|
||||||
|
const context = await loadZugferdContext(server, tenantId, document)
|
||||||
|
const { xml, issues } = buildZugferdInvoiceXml(document, context)
|
||||||
|
const blocking = breakingIssues(issues)
|
||||||
|
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
|
||||||
|
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
|
||||||
|
return { pdf: result, issues, einvoice: true }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { XMLParser } from "fast-xml-parser"
|
import { XMLParser } from "fast-xml-parser"
|
||||||
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||||
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
||||||
|
import { parseCashDiscountPaymentTerms } from "../cash-discount"
|
||||||
|
|
||||||
const parser = new XMLParser({
|
const parser = new XMLParser({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
@@ -83,7 +84,9 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
||||||
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {}
|
const paymentTermsList = array(settlement?.SpecifiedTradePaymentTerms)
|
||||||
|
const paymentTerms = paymentTermsList[0] || {}
|
||||||
|
const cashDiscount = ciiCashDiscount(paymentTermsList)
|
||||||
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -103,6 +106,7 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
|||||||
},
|
},
|
||||||
buyerReference: text(agreement?.BuyerReference),
|
buyerReference: text(agreement?.BuyerReference),
|
||||||
paymentMeansCode: text(paymentMeans?.TypeCode),
|
paymentMeansCode: text(paymentMeans?.TypeCode),
|
||||||
|
cashDiscount,
|
||||||
items,
|
items,
|
||||||
totals: {
|
totals: {
|
||||||
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
||||||
@@ -113,6 +117,18 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skonto steht in XRechnung als "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext,
|
||||||
|
// in ZUGFeRD EXTENDED zusätzlich strukturiert als ApplicableTradePaymentDiscountTerms.
|
||||||
|
const ciiCashDiscount = (paymentTermsList: any[]) => {
|
||||||
|
for (const terms of paymentTermsList) {
|
||||||
|
const discountTerms = array(terms?.ApplicableTradePaymentDiscountTerms)[0]
|
||||||
|
const percent = number(discountTerms?.CalculationPercent)
|
||||||
|
const days = number(discountTerms?.BasisPeriodMeasure)
|
||||||
|
if (percent && percent > 0 && days !== null && Number.isInteger(days)) return { percent, days }
|
||||||
|
}
|
||||||
|
return parseCashDiscountPaymentTerms(paymentTermsList.flatMap((terms: any) => array(terms?.Description).map(text)).join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
||||||
|
|
||||||
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||||
@@ -163,6 +179,7 @@ const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<Pa
|
|||||||
},
|
},
|
||||||
buyerReference: text(root?.BuyerReference),
|
buyerReference: text(root?.BuyerReference),
|
||||||
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
||||||
|
cashDiscount: parseCashDiscountPaymentTerms(array(root?.PaymentTerms).flatMap((terms: any) => array(terms?.Note).map(text)).join("\n")),
|
||||||
items,
|
items,
|
||||||
totals: {
|
totals: {
|
||||||
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type ParsedElectronicInvoice = {
|
|||||||
}
|
}
|
||||||
buyerReference: string | null
|
buyerReference: string | null
|
||||||
paymentMeansCode: string | null
|
paymentMeansCode: string | null
|
||||||
|
cashDiscount?: { percent: number, days: number } | null
|
||||||
items: ElectronicInvoiceItem[]
|
items: ElectronicInvoiceItem[]
|
||||||
totals: {
|
totals: {
|
||||||
lineNet: number | null
|
lineNet: number | null
|
||||||
|
|||||||
126
backend/src/modules/incoming-invoice-cost-centres.service.ts
Normal file
126
backend/src/modules/incoming-invoice-cost-centres.service.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { costcentres, incominginvoices } from "../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../utils/history"
|
||||||
|
|
||||||
|
export type AccountCostCentreAssignment = {
|
||||||
|
index: number
|
||||||
|
costcentre: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoiceCostCentreUpdate = {
|
||||||
|
accountCostCentres?: AccountCostCentreAssignment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceError = (message: string, statusCode = 400) =>
|
||||||
|
Object.assign(new Error(message), { statusCode })
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
export function normalizeAccountCostCentreAssignments(input: unknown): AccountCostCentreAssignment[] {
|
||||||
|
if (!Array.isArray(input)) throw serviceError("accountCostCentres muss ein Array sein")
|
||||||
|
if (!input.length) throw serviceError("Mindestens eine Positionskostenstelle ist erforderlich")
|
||||||
|
|
||||||
|
const seen = new Set<number>()
|
||||||
|
return input.map((assignment) => {
|
||||||
|
const index = assignment?.index
|
||||||
|
const costcentre = assignment?.costcentre
|
||||||
|
if (!Number.isInteger(index) || index < 0) throw serviceError("Jede Positionszuordnung benötigt einen gültigen index")
|
||||||
|
if (seen.has(index)) throw serviceError(`Position ${index + 1} ist mehrfach angegeben`)
|
||||||
|
seen.add(index)
|
||||||
|
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||||
|
throw serviceError(`Ungültige Kostenstelle für Position ${index + 1}`)
|
||||||
|
}
|
||||||
|
return { index, costcentre }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAccountCostCentreAssignments(
|
||||||
|
accounts: unknown,
|
||||||
|
assignments: AccountCostCentreAssignment[] = [],
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(accounts)) throw serviceError("Der Eingangsbeleg enthält keine Positionen")
|
||||||
|
|
||||||
|
for (const { index } of assignments) {
|
||||||
|
const account = accounts[index]
|
||||||
|
if (!account || typeof account !== "object" || Array.isArray(account)) {
|
||||||
|
throw serviceError(`Position ${index + 1} wurde im Eingangsbeleg nicht gefunden`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentMap = new Map(assignments.map((assignment) => [assignment.index, assignment.costcentre]))
|
||||||
|
return accounts.map((account, index) => {
|
||||||
|
if (!assignmentMap.has(index)) return account
|
||||||
|
const { costcentre: _legacyCostCentre, ...rest } = account as Record<string, unknown>
|
||||||
|
return { ...rest, costCentre: assignmentMap.get(index) ?? null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIncomingInvoiceCostCentres(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
invoiceId: number,
|
||||||
|
input: IncomingInvoiceCostCentreUpdate,
|
||||||
|
) {
|
||||||
|
const assignments = normalizeAccountCostCentreAssignments(input?.accountCostCentres)
|
||||||
|
|
||||||
|
const [existing] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(
|
||||||
|
eq(incominginvoices.id, invoiceId),
|
||||||
|
eq(incominginvoices.tenant, tenantId),
|
||||||
|
eq(incominginvoices.archived, false),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw serviceError("Eingangsbeleg nicht gefunden", 404)
|
||||||
|
|
||||||
|
const referencedCostCentreIds = [...new Set(
|
||||||
|
assignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
|
||||||
|
)]
|
||||||
|
|
||||||
|
if (referencedCostCentreIds.length) {
|
||||||
|
const validCostCentres = await server.db
|
||||||
|
.select({ id: costcentres.id })
|
||||||
|
.from(costcentres)
|
||||||
|
.where(and(
|
||||||
|
eq(costcentres.tenant, tenantId),
|
||||||
|
eq(costcentres.archived, false),
|
||||||
|
inArray(costcentres.id, referencedCostCentreIds),
|
||||||
|
))
|
||||||
|
|
||||||
|
if (validCostCentres.length !== referencedCostCentreIds.length) {
|
||||||
|
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(incominginvoices)
|
||||||
|
.set({
|
||||||
|
accounts: applyAccountCostCentreAssignments(existing.accounts, assignments),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(incominginvoices.id, invoiceId),
|
||||||
|
eq(incominginvoices.tenant, tenantId),
|
||||||
|
eq(incominginvoices.archived, false),
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw serviceError("Eingangsbeleg nicht gefunden", 404)
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: userId,
|
||||||
|
entity: "incominginvoices",
|
||||||
|
entityId: invoiceId,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: updated,
|
||||||
|
text: "Kostenstellen des Eingangsbelegs geändert",
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
@@ -56,6 +56,24 @@ export class NotificationService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async trigger(input: TriggerInput) {
|
async trigger(input: TriggerInput) {
|
||||||
|
const { items, skipped } = await this.createItems(input)
|
||||||
|
if (skipped !== undefined) return { success: true, created: 0, delivered: 0, skipped }
|
||||||
|
const results = []
|
||||||
|
for (const item of items) results.push(await this.deliver(item))
|
||||||
|
return {
|
||||||
|
success: results.every((result) => result.success),
|
||||||
|
created: results.length,
|
||||||
|
delivered: results.filter((result) => result.success).length,
|
||||||
|
failed: results.filter((result) => !result.success).length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist first; callers using a transaction can deliver after it commits. */
|
||||||
|
async enqueue(input: TriggerInput) {
|
||||||
|
return (await this.createItems(input)).items
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createItems(input: TriggerInput): Promise<{ items: (typeof notificationsItems.$inferSelect)[]; skipped?: number }> {
|
||||||
const tenantId = input.tenantId
|
const tenantId = input.tenantId
|
||||||
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
|
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
|
||||||
|
|
||||||
@@ -68,7 +86,7 @@ export class NotificationService {
|
|||||||
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
|
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
|
||||||
|
|
||||||
if (!channels.length) {
|
if (!channels.length) {
|
||||||
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
|
return { items: [], skipped: userIds.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = []
|
const results = []
|
||||||
@@ -99,16 +117,11 @@ export class NotificationService {
|
|||||||
const item = itemRows[0]
|
const item = itemRows[0]
|
||||||
if (!item) continue
|
if (!item) continue
|
||||||
|
|
||||||
results.push(await this.deliver(item))
|
results.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { items: results }
|
||||||
success: results.every((result) => result.success),
|
|
||||||
created: results.length,
|
|
||||||
delivered: results.filter((result) => result.success).length,
|
|
||||||
failed: results.filter((result) => !result.success).length,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async preferences(tenantId: number, userId: string) {
|
async preferences(tenantId: number, userId: string) {
|
||||||
@@ -295,11 +308,12 @@ export class NotificationService {
|
|||||||
const defaultPref = defaults.find((pref) => pref.channel === channel)
|
const defaultPref = defaults.find((pref) => pref.channel === channel)
|
||||||
if (defaultPref) return defaultPref.enabled
|
if (defaultPref) return defaultPref.enabled
|
||||||
|
|
||||||
|
if (input.eventType === "collaboration.message.new") return channel === "inapp"
|
||||||
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
|
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deliver(item: typeof notificationsItems.$inferSelect) {
|
async deliver(item: typeof notificationsItems.$inferSelect) {
|
||||||
if (item.channel === "inapp") {
|
if (item.channel === "inapp") {
|
||||||
await this.markSent(item.id)
|
await this.markSent(item.id)
|
||||||
return { success: true, id: item.id, channel: item.channel }
|
return { success: true, id: item.id, channel: item.channel }
|
||||||
|
|||||||
287
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
287
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import {
|
||||||
|
authProfiles,
|
||||||
|
bankaccounts,
|
||||||
|
contacts,
|
||||||
|
contracts,
|
||||||
|
createddocuments,
|
||||||
|
customers,
|
||||||
|
letterheads,
|
||||||
|
plants,
|
||||||
|
products,
|
||||||
|
projects,
|
||||||
|
services,
|
||||||
|
tenants,
|
||||||
|
units,
|
||||||
|
} from "../../db/schema"
|
||||||
|
import { documentTemplateHandlebars } from "../utils/handlebars"
|
||||||
|
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
|
||||||
|
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
||||||
|
|
||||||
|
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
|
||||||
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
|
const invoiceLikeDocumentTypes = ["invoices", "advanceInvoices", "serialInvoices", "cancellationInvoices"]
|
||||||
|
|
||||||
|
/** Same format as useCurrency in the frontend. */
|
||||||
|
export const renderCurrency = (value: any, currency = "€") =>
|
||||||
|
`${Number(value).toFixed(2).replace(".", ",")} ${currency}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".")
|
||||||
|
|
||||||
|
const rowSum = (row: any) => Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)
|
||||||
|
|
||||||
|
export function documentNumberTitle(type: string) {
|
||||||
|
if (invoiceLikeDocumentTypes.includes(type)) return "Rechnungsnummer"
|
||||||
|
if (quoteLikeDocumentTypes.includes(type)) return type === "costEstimates" ? "Kostenschätzungsnummer" : "Angebotsnummer"
|
||||||
|
if (deliveryNoteLikeDocumentTypes.includes(type)) return type === "packingSlips" ? "Packscheinnummer" : "Lieferscheinnummer"
|
||||||
|
if (type === "confirmationOrders") return "Auftragsbestätigungsnr."
|
||||||
|
return "Belegnummer"
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumberLikeValue = (value: any) => {
|
||||||
|
if (value === null || value === undefined || value === "") return "-"
|
||||||
|
if (typeof value === "string" || typeof value === "number") return String(value)
|
||||||
|
const directValue = value.value ?? value.number ?? value.currentNumber ?? value.customerNumber ?? value.id
|
||||||
|
if (directValue !== null && directValue !== undefined && directValue !== "") return String(directValue)
|
||||||
|
if (value.prefix !== undefined || value.nextNumber !== undefined || value.suffix !== undefined) {
|
||||||
|
return `${value.prefix || ""}${value.nextNumber || ""}${value.suffix || ""}` || "-"
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of documentTotal from the document editor. */
|
||||||
|
export function calculateOutgoingDocumentTotals(document: any, advanceInvoices: any[]) {
|
||||||
|
let totalNet = 0, total19 = 0, totalNet19 = 0, total7 = 0, totalNet7 = 0, totalNet0 = 0
|
||||||
|
const rows = document.rows || []
|
||||||
|
rows.filter((row: any) => !row.optional && !row.alternative).forEach((row: any) => {
|
||||||
|
if (["pagebreak", "title", "text"].includes(row.mode)) return
|
||||||
|
const rowPrice = Number(rowSum(row).toFixed(3))
|
||||||
|
totalNet += rowPrice
|
||||||
|
if (row.taxPercent === 19) { total19 += rowPrice * 0.19; totalNet19 += rowPrice }
|
||||||
|
else if (row.taxPercent === 7) { total7 += rowPrice * 0.07; totalNet7 += rowPrice }
|
||||||
|
else if (row.taxPercent === 0) totalNet0 += rowPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleSums: Record<string, number> = {}
|
||||||
|
const titleSumsTransfer: Record<string, number> = {}
|
||||||
|
let activeTitleKeys: string[] = []
|
||||||
|
let transferCounter = 0
|
||||||
|
rows.forEach((row: any) => {
|
||||||
|
if (row.mode === "title") {
|
||||||
|
const title = `${row.pos} - ${row.text}`
|
||||||
|
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
|
||||||
|
titleSums[title] = 0
|
||||||
|
if (activeTitleKeys.some(Boolean)) titleSumsTransfer[title] = transferCounter
|
||||||
|
activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1)
|
||||||
|
activeTitleKeys[titleLevel - 1] = title
|
||||||
|
activeTitleKeys = activeTitleKeys.slice(0, titleLevel)
|
||||||
|
} else if (!["pagebreak", "text"].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) {
|
||||||
|
const sum = rowSum(row)
|
||||||
|
activeTitleKeys.forEach((key) => { if (key) titleSums[key] = Number(titleSums[key]) + sum })
|
||||||
|
transferCounter += sum
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalGross = Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
|
||||||
|
const totalGrossAlreadyPaid = advanceInvoices.reduce((sum, invoice) => {
|
||||||
|
const advanceRow = (invoice?.rows || []).find((row: any) => row.advanceInvoiceData)
|
||||||
|
return advanceRow ? sum + advanceRow.price * ((100 + advanceRow.taxPercent) / 100) : sum
|
||||||
|
}, 0)
|
||||||
|
const totalSumToPay = document.type === "invoices" ? totalGross - totalGrossAlreadyPaid
|
||||||
|
: document.type === "cancellationInvoices" ? totalGross + totalGrossAlreadyPaid : 0
|
||||||
|
|
||||||
|
return { titleSums, titleSumsTransfer, totalNet, total19, totalNet19, total7, totalNet7, total0: 0, totalNet0, totalGross, totalGrossAlreadyPaid, totalSumToPay }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutgoingDocumentPdfContext = {
|
||||||
|
tenant: any
|
||||||
|
customer: any
|
||||||
|
contact?: any
|
||||||
|
contactPerson?: any
|
||||||
|
plant?: any
|
||||||
|
project?: any
|
||||||
|
contract?: any
|
||||||
|
units: any[]
|
||||||
|
products: any[]
|
||||||
|
services: any[]
|
||||||
|
bankAccounts: any[]
|
||||||
|
advanceInvoices: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of getDocumentData from the document editor, so MCP previews match the UI preview. */
|
||||||
|
export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocumentPdfContext) {
|
||||||
|
const document = structuredClone(source)
|
||||||
|
document.rows = document.rows || []
|
||||||
|
const address = document.address || {}
|
||||||
|
const customer = data.customer || {}
|
||||||
|
const contact = data.contact || null
|
||||||
|
const contactPerson = data.contactPerson || {}
|
||||||
|
const businessInfo = data.tenant.businessInfo || {}
|
||||||
|
const paymentQrBankAccount = data.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
|
||||||
|
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
|
||||||
|
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
|
||||||
|
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || data.tenant.name
|
||||||
|
|
||||||
|
if ((data.tenant.extraModules || []).includes("agriculture")) {
|
||||||
|
document.rows.forEach((row: any) => {
|
||||||
|
if (row.agriculture?.dieselUsage) {
|
||||||
|
row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices)
|
||||||
|
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
|
||||||
|
const cashDiscountTerms = ["invoices", "advanceInvoices"].includes(document.type)
|
||||||
|
? buildCashDiscountTerms({
|
||||||
|
percent: document.cashDiscountPercent,
|
||||||
|
days: document.cashDiscountDays,
|
||||||
|
documentDate: document.documentDate,
|
||||||
|
amount: document.type === "invoices" ? totals.totalSumToPay : totals.totalGross,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const rows = document.rows.map((row: any) => {
|
||||||
|
if (!["pagebreak", "title"].includes(row.mode)) {
|
||||||
|
if (row.agriculture?.description) row.descriptionText = row.agriculture.description
|
||||||
|
else if (row.description) row.descriptionText = row.description
|
||||||
|
else delete row.descriptionText
|
||||||
|
}
|
||||||
|
if (["pagebreak", "title", "text"].includes(row.mode)) return row
|
||||||
|
if (row.mode === "normal") row.text = data.products.find((item) => item.id === row.product)?.name || ""
|
||||||
|
if (row.mode === "service") row.text = data.services.find((item) => item.id === row.service)?.name || ""
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
rowAmount: renderCurrency(rowSum(row)),
|
||||||
|
quantity: String(row.quantity).replace(".", ","),
|
||||||
|
unit: data.units.find((item) => item.id === row.unit)?.short || "",
|
||||||
|
pos: String(row.pos),
|
||||||
|
price: renderCurrency(row.price),
|
||||||
|
discountText: `(Rabatt: ${row.discountPercent} %)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
lohnkosten: document.report?.totalHoursSellingPrice ? renderCurrency(document.report.totalHoursSellingPrice) : null,
|
||||||
|
anrede: contact?.salutation || customer.salutation,
|
||||||
|
titel: contact?.title || customer.title,
|
||||||
|
vorname: contact?.firstName || customer.firstname,
|
||||||
|
nachname: contact?.lastName || customer.lastname,
|
||||||
|
kundenname: customer.name,
|
||||||
|
zahlungsziel_in_tagen: document.paymentDays,
|
||||||
|
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
||||||
|
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
|
||||||
|
...getCashDiscountTemplateContext(cashDiscountTerms),
|
||||||
|
}
|
||||||
|
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
|
||||||
|
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)]))
|
||||||
|
const recipientCountry = String(address.country ?? customer.infoData?.country ?? "").trim()
|
||||||
|
const contactTel = document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: document.type,
|
||||||
|
taxType: document.taxType,
|
||||||
|
adressLine: `${businessInfo.name}, ${businessInfo.street}, ${businessInfo.zip} ${businessInfo.city}`,
|
||||||
|
recipient: [
|
||||||
|
customer.name,
|
||||||
|
...(customer.nameAddition ? [customer.nameAddition] : []),
|
||||||
|
...(contact ? [`${contact.firstName} ${contact.lastName}`] : []),
|
||||||
|
address.street,
|
||||||
|
...(address.special ? [address.special] : []),
|
||||||
|
`${address.zip} ${address.city}`,
|
||||||
|
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
|
||||||
|
],
|
||||||
|
info: [
|
||||||
|
{ label: documentNumberTitle(document.type), content: document.documentNumber || "XXXX" },
|
||||||
|
{ label: "Kundennummer", content: formatNumberLikeValue(customer.customerNumber) },
|
||||||
|
{ label: "Belegdatum", content: formatDate(document.documentDate) },
|
||||||
|
...(document.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{
|
||||||
|
label: document.deliveryDateType,
|
||||||
|
content: !["Lieferzeitraum", "Leistungszeitraum"].includes(document.deliveryDateType)
|
||||||
|
? formatDate(document.deliveryDate)
|
||||||
|
: `${formatDate(document.deliveryDate)} - ${formatDate(document.deliveryDateEnd)}`,
|
||||||
|
}] : []),
|
||||||
|
{ label: "Ansprechpartner", content: contactPerson.full_name },
|
||||||
|
...(contactTel ? [{ label: "Telefon", content: contactTel }] : []),
|
||||||
|
...(contactPerson.email ? [{ label: "E-Mail", content: contactPerson.email }] : []),
|
||||||
|
...(document.plant ? [{ label: "Objekt", content: data.plant?.name || "Objekt nicht gefunden" }] : []),
|
||||||
|
...(document.project ? [{ label: "Projekt", content: data.project?.name || "Projekt nicht gefunden" }] : []),
|
||||||
|
...(document.contract ? [{ label: "Vertrag", content: data.contract?.contractNumber || "Vertrag nicht gefunden" }] : []),
|
||||||
|
],
|
||||||
|
title: document.title,
|
||||||
|
description: document.description,
|
||||||
|
endText: appendCashDiscountText(documentTemplateHandlebars.compile(document.endText || "")(context), document.endText, cashDiscountTerms),
|
||||||
|
startText: documentTemplateHandlebars.compile(document.startText || "")(context),
|
||||||
|
rows,
|
||||||
|
totalArray: [
|
||||||
|
{ label: "Nettobetrag", content: renderCurrency(totals.totalNet) },
|
||||||
|
...[19, 7, 0].flatMap((rate) => rows.find((row: any) => row.taxPercent === rate) && document.taxType !== "13b UStG" ? [{
|
||||||
|
label: `zzgl. ${rate}% USt auf ${renderCurrency((totals as any)[`totalNet${rate}`])}`,
|
||||||
|
content: renderCurrency((totals as any)[`total${rate}`]),
|
||||||
|
}] : []),
|
||||||
|
{ label: "Gesamtbetrag", content: renderCurrency(totals.totalGross) },
|
||||||
|
],
|
||||||
|
total: {
|
||||||
|
totalNet: renderCurrency(totals.totalNet),
|
||||||
|
total19: renderCurrency(totals.total19),
|
||||||
|
total0: renderCurrency(totals.total0),
|
||||||
|
totalGross: renderCurrency(totals.totalGross),
|
||||||
|
totalGrossAlreadyPaid: renderCurrency(totals.totalGrossAlreadyPaid),
|
||||||
|
totalSumToPay: renderCurrency(totals.totalSumToPay),
|
||||||
|
titleSums: formatSums(totals.titleSums),
|
||||||
|
titleSumsTransfer: formatSums(totals.titleSumsTransfer),
|
||||||
|
},
|
||||||
|
agriculture: document.agriculture,
|
||||||
|
usedAdvanceInvoices: data.advanceInvoices,
|
||||||
|
paymentQr: paymentQrIban && document.payment_type === "transfer" ? {
|
||||||
|
iban: paymentQrIban,
|
||||||
|
bic: paymentQrBic,
|
||||||
|
recipientName: paymentQrRecipient,
|
||||||
|
amount: totals.totalSumToPay,
|
||||||
|
remittance: document.documentNumber || document.title,
|
||||||
|
} : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const one = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
|
||||||
|
if (id === null || id === undefined) return null
|
||||||
|
const [row] = await server.db.select().from(table).where(and(eq(table.id, id as any), eq(table.tenant, tenantId))).limit(1)
|
||||||
|
return row || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderOutgoingDocumentPdf(server: FastifyInstance, tenantId: number, document: any) {
|
||||||
|
if (!document.letterhead) throw Object.assign(new Error("Es ist kein Briefpapier ausgewählt"), { statusCode: 400 })
|
||||||
|
if (!document.customer) throw Object.assign(new Error("Es ist kein Kunde ausgewählt"), { statusCode: 400 })
|
||||||
|
const rows = document.rows || []
|
||||||
|
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
|
||||||
|
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
|
||||||
|
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
|
||||||
|
|
||||||
|
const [[tenant], letterhead, customer, contact, plant, project, contract, unitRows, productRows, serviceRows, bankAccountRows, advanceInvoices, [contactPerson]] = await Promise.all([
|
||||||
|
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
|
||||||
|
one(server, letterheads, document.letterhead, tenantId),
|
||||||
|
one(server, customers, document.customer, tenantId),
|
||||||
|
one(server, contacts, document.contact, tenantId),
|
||||||
|
one(server, plants, document.plant, tenantId),
|
||||||
|
one(server, projects, document.project, tenantId),
|
||||||
|
one(server, contracts, document.contract, tenantId),
|
||||||
|
server.db.select().from(units),
|
||||||
|
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
|
||||||
|
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
|
||||||
|
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
|
||||||
|
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
|
document.created_by
|
||||||
|
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, document.created_by), eq(authProfiles.tenant_id, tenantId))).limit(1)
|
||||||
|
: [null],
|
||||||
|
])
|
||||||
|
if (!letterhead?.path) throw Object.assign(new Error("Briefpapier nicht gefunden"), { statusCode: 400 })
|
||||||
|
if (!customer) throw Object.assign(new Error("Kunde nicht gefunden"), { statusCode: 400 })
|
||||||
|
|
||||||
|
const pdfData = buildOutgoingDocumentPdfData(document, {
|
||||||
|
tenant, customer, contact, contactPerson, plant, project, contract,
|
||||||
|
units: unitRows, products: productRows, services: serviceRows, bankAccounts: bankAccountRows,
|
||||||
|
advanceInvoices: advanceIds.map((id) => advanceInvoices.find((invoice: any) => invoice.id === id)).filter(Boolean),
|
||||||
|
})
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 297, rows: [], pageCount: 0 }
|
||||||
|
const pdf: any = await createInvoicePDF(server, "base64", pdfData, letterhead.path, layout)
|
||||||
|
return { pdf: Buffer.from(pdf.base64, "base64"), layout }
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import { saveFile } from "../utils/files";
|
|||||||
import {FastifyInstance} from "fastify";
|
import {FastifyInstance} from "fastify";
|
||||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||||
|
import { assertOutgoingEInvoiceRequirements, attachZugferdToPdf, EInvoiceRequirementsError } from "./einvoice/outgoingZugferd.service";
|
||||||
import { documentTemplateHandlebars } from "../utils/handlebars";
|
import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||||
|
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
||||||
|
|
||||||
dayjs.extend(quarterOfYear);
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
@@ -200,6 +202,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
const einvoiceErrors: string[] = [];
|
||||||
|
|
||||||
// 5. Loop durch Dokumente
|
// 5. Loop durch Dokumente
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
@@ -215,6 +218,9 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
|
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Pflichtangaben vor der Nummernvergabe prüfen, damit keine Lücken im Nummernkreis entstehen
|
||||||
|
await assertOutgoingEInvoiceRequirements(server, tenantId, doc);
|
||||||
|
|
||||||
const pdfData = await getCloseData(
|
const pdfData = await getCloseData(
|
||||||
server,
|
server,
|
||||||
doc,
|
doc,
|
||||||
@@ -232,8 +238,14 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
console.log(pdfBase64);
|
console.log(pdfBase64);
|
||||||
|
|
||||||
// E. Datei speichern
|
// E. Datei speichern
|
||||||
// @ts-ignore
|
// E-Rechnung (ZUGFeRD) in die PDF einbetten; Grundlage ist der Beleg mit der neuen Nummer
|
||||||
const fileBuffer = Buffer.from(pdfBase64.base64, "base64");
|
const { pdf: fileBuffer } = await attachZugferdToPdf(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
{ ...doc, state: "Gebucht", documentNumber: pdfData.documentNumber, title: pdfData.title },
|
||||||
|
// @ts-ignore
|
||||||
|
Buffer.from(pdfBase64.base64, "base64"),
|
||||||
|
);
|
||||||
const filename = `${pdfData.documentNumber}.pdf`;
|
const filename = `${pdfData.documentNumber}.pdf`;
|
||||||
|
|
||||||
await saveFile(
|
await saveFile(
|
||||||
@@ -266,6 +278,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
} catch (innerErr) {
|
} catch (innerErr) {
|
||||||
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
if (innerErr instanceof EInvoiceRequirementsError) einvoiceErrors.push(`Beleg ${doc.id}: ${innerErr.message}`);
|
||||||
// Optional: Status des einzelnen Dokuments auf Error setzen
|
// Optional: Status des einzelnen Dokuments auf Error setzen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +288,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
|
|
||||||
|
|
||||||
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
|
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
|
||||||
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
|
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.${einvoiceErrors.length ? ` ${einvoiceErrors.join(" ")}` : ""}`);
|
||||||
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
|
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
|
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
|
||||||
@@ -421,6 +434,8 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate:
|
|||||||
deliveryDate: firstDate,
|
deliveryDate: firstDate,
|
||||||
deliveryDateEnd: lastDate,
|
deliveryDateEnd: lastDate,
|
||||||
paymentDays: item.paymentDays,
|
paymentDays: item.paymentDays,
|
||||||
|
cashDiscountPercent: item.cashDiscountPercent,
|
||||||
|
cashDiscountDays: item.cashDiscountDays,
|
||||||
payment_type: item.payment_type,
|
payment_type: item.payment_type,
|
||||||
deliveryDateType: item.deliveryDateType,
|
deliveryDateType: item.deliveryDateType,
|
||||||
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
|
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
|
||||||
@@ -646,6 +661,12 @@ export function getDocumentDataBackend(
|
|||||||
|
|
||||||
|
|
||||||
// --- 5. Handlebars Context ---
|
// --- 5. Handlebars Context ---
|
||||||
|
const cashDiscountTerms = buildCashDiscountTerms({
|
||||||
|
percent: itemInfo.cashDiscountPercent,
|
||||||
|
days: itemInfo.cashDiscountDays,
|
||||||
|
documentDate: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("YYYY-MM-DD") : null,
|
||||||
|
amount: totals.totalGross,
|
||||||
|
});
|
||||||
const generateContext = () => {
|
const generateContext = () => {
|
||||||
return {
|
return {
|
||||||
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
|
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
|
||||||
@@ -656,7 +677,8 @@ export function getDocumentDataBackend(
|
|||||||
kundenname: customerData && customerData.name,
|
kundenname: customerData && customerData.name,
|
||||||
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
||||||
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
||||||
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null
|
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null,
|
||||||
|
...getCashDiscountTemplateContext(cashDiscountTerms),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -756,7 +778,7 @@ export function getDocumentDataBackend(
|
|||||||
title: itemInfo.title,
|
title: itemInfo.title,
|
||||||
description: itemInfo.description,
|
description: itemInfo.description,
|
||||||
// Handlebars Compilation ausführen
|
// Handlebars Compilation ausführen
|
||||||
endText: templateEndText(generateContext()),
|
endText: appendCashDiscountText(templateEndText(generateContext()), itemInfo.endText, cashDiscountTerms),
|
||||||
startText: templateStartText(generateContext()),
|
startText: templateStartText(generateContext()),
|
||||||
rows: rows,
|
rows: rows,
|
||||||
totalArray: totalArray,
|
totalArray: totalArray,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import fp from "fastify-plugin";
|
import fp from "fastify-plugin";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { authUsers, m2mApiKeys } from "../../db/schema";
|
import { authUsers, m2mApiKeys } from "../../db/schema";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
@@ -44,7 +44,8 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
|
|||||||
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(m2mApiKeys.keyHash, keyHash),
|
eq(m2mApiKeys.keyHash, keyHash),
|
||||||
eq(m2mApiKeys.active, true)
|
eq(m2mApiKeys.active, true),
|
||||||
|
isNull(authUsers.archived_at)
|
||||||
))
|
))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
|
|||||||
const users = await server.db
|
const users = await server.db
|
||||||
.select({ email: authUsers.email })
|
.select({ email: authUsers.email })
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.id, userId))
|
.where(and(eq(authUsers.id, userId), isNull(authUsers.archived_at)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!users[0]) {
|
if (!users[0]) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
tenants,
|
tenants,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import { eq, and, inArray } from "drizzle-orm"
|
import { eq, and, inArray, isNull } from "drizzle-orm"
|
||||||
|
|
||||||
export default fp(async (server: FastifyInstance) => {
|
export default fp(async (server: FastifyInstance) => {
|
||||||
const hashApiKey = (apiKey: string) =>
|
const hashApiKey = (apiKey: string) =>
|
||||||
@@ -56,7 +56,8 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(m2mApiKeys.keyHash, keyHash),
|
eq(m2mApiKeys.keyHash, keyHash),
|
||||||
eq(m2mApiKeys.active, true)
|
eq(m2mApiKeys.active, true),
|
||||||
|
isNull(authUsers.archived_at)
|
||||||
))
|
))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
@@ -141,22 +142,16 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
return reply.code(401).send({ error: "Invalid token" })
|
return reply.code(401).send({ error: "Invalid token" })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload an Request hängen
|
// Check current account state even when a JWT carries cached admin claims.
|
||||||
req.user = payload
|
const [currentUser] = await server.db
|
||||||
|
.select({ is_admin: authUsers.is_admin })
|
||||||
if (typeof payload.is_admin === "boolean") {
|
.from(authUsers)
|
||||||
req.user.is_admin = payload.is_admin
|
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at)))
|
||||||
} else {
|
.limit(1)
|
||||||
const [currentUser] = await server.db
|
if (!currentUser) {
|
||||||
.select({
|
return reply.code(401).send({ error: "Invalid or expired token" })
|
||||||
is_admin: authUsers.is_admin,
|
|
||||||
})
|
|
||||||
.from(authUsers)
|
|
||||||
.where(eq(authUsers.id, payload.user_id))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
|
||||||
}
|
}
|
||||||
|
req.user = { ...payload, is_admin: Boolean(currentUser.is_admin) }
|
||||||
|
|
||||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||||
if (!req.user.tenant_id) {
|
if (!req.user.tenant_id) {
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
|||||||
import { buildSystemStatus } from "../modules/system-status.service";
|
import { buildSystemStatus } from "../modules/system-status.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
|
import adminUserArchiveRoutes from "./adminUserArchive";
|
||||||
import { getPortalLoginUrl } from "../utils/portalUrl";
|
import { getPortalLoginUrl } from "../utils/portalUrl";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
|
await adminUserArchiveRoutes(server);
|
||||||
|
|
||||||
await server.register(multipart, {
|
await server.register(multipart, {
|
||||||
limits: { fileSize: 1024 * 1024 * 1024 }
|
limits: { fileSize: 1024 * 1024 * 1024 }
|
||||||
});
|
});
|
||||||
@@ -602,6 +605,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
const currentUser = await requireAdmin(req, reply);
|
const currentUser = await requireAdmin(req, reply);
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true";
|
||||||
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
|
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
|
||||||
server.db
|
server.db
|
||||||
.select({
|
.select({
|
||||||
@@ -621,8 +625,9 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
multiTenant: authUsers.multiTenant,
|
multiTenant: authUsers.multiTenant,
|
||||||
must_change_password: authUsers.must_change_password,
|
must_change_password: authUsers.must_change_password,
|
||||||
is_admin: authUsers.is_admin,
|
is_admin: authUsers.is_admin,
|
||||||
|
archived_at: authUsers.archived_at,
|
||||||
})
|
})
|
||||||
.from(authUsers),
|
.from(authUsers).where(includeArchived ? undefined : isNull(authUsers.archived_at)),
|
||||||
server.db
|
server.db
|
||||||
.select({
|
.select({
|
||||||
id: authProfiles.id,
|
id: authProfiles.id,
|
||||||
@@ -633,6 +638,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
full_name: authProfiles.full_name,
|
full_name: authProfiles.full_name,
|
||||||
email: authProfiles.email,
|
email: authProfiles.email,
|
||||||
active: authProfiles.active,
|
active: authProfiles.active,
|
||||||
|
archived_at: authProfiles.archived_at,
|
||||||
})
|
})
|
||||||
.from(authProfiles),
|
.from(authProfiles),
|
||||||
server.db
|
server.db
|
||||||
@@ -681,16 +687,17 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeUserIds = new Set(userRows.filter((user) => !user.archived_at).map((user) => user.id));
|
||||||
const tenantsWithCounts = tenantRows.map((tenant) => ({
|
const tenantsWithCounts = tenantRows.map((tenant) => ({
|
||||||
...tenant,
|
...tenant,
|
||||||
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
|
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id && activeUserIds.has(membership.user_id)).length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users,
|
users,
|
||||||
tenants: tenantsWithCounts,
|
tenants: tenantsWithCounts,
|
||||||
roles: roleRows,
|
roles: roleRows,
|
||||||
unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
|
unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active),
|
||||||
memberships: uniqueMembershipRows,
|
memberships: uniqueMembershipRows,
|
||||||
roleAssignments: roleAssignmentRows,
|
roleAssignments: roleAssignmentRows,
|
||||||
};
|
};
|
||||||
@@ -809,7 +816,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
email: authProfiles.email,
|
email: authProfiles.email,
|
||||||
})
|
})
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.id, profileId))
|
.where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
@@ -951,11 +958,16 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
id: authUsers.id,
|
id: authUsers.id,
|
||||||
email: authUsers.email,
|
email: authUsers.email,
|
||||||
is_admin: authUsers.is_admin,
|
is_admin: authUsers.is_admin,
|
||||||
|
archived_at: authUsers.archived_at,
|
||||||
})
|
})
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.email, email))
|
.where(eq(authUsers.email, email))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser?.archived_at) {
|
||||||
|
return reply.code(409).send({ error: "Dieses Benutzerkonto ist dauerhaft archiviert." });
|
||||||
|
}
|
||||||
|
|
||||||
const derivedName = deriveNameFromEmail(email);
|
const derivedName = deriveNameFromEmail(email);
|
||||||
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
|
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
|
||||||
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
|
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
|
||||||
@@ -1187,6 +1199,12 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
const { user_id } = req.params as { user_id: string };
|
const { user_id } = req.params as { user_id: string };
|
||||||
|
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
|
||||||
|
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
|
||||||
|
if (!account) return reply.code(404).send({ error: "User not found" });
|
||||||
|
if (account.archived_at) {
|
||||||
|
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
|
||||||
|
}
|
||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
email?: string;
|
email?: string;
|
||||||
multiTenant?: boolean;
|
multiTenant?: boolean;
|
||||||
@@ -1236,6 +1254,12 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
const { user_id } = req.params as { user_id: string };
|
const { user_id } = req.params as { user_id: string };
|
||||||
|
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
|
||||||
|
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
|
||||||
|
if (!account) return reply.code(404).send({ error: "User not found" });
|
||||||
|
if (account.archived_at) {
|
||||||
|
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
|
||||||
|
}
|
||||||
const body = (req.body || {}) as { password?: string };
|
const body = (req.body || {}) as { password?: string };
|
||||||
const requestedPassword = body.password?.trim();
|
const requestedPassword = body.password?.trim();
|
||||||
|
|
||||||
@@ -1636,6 +1660,12 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
const { user_id } = req.params as { user_id: string };
|
const { user_id } = req.params as { user_id: string };
|
||||||
|
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
|
||||||
|
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
|
||||||
|
if (!account) return reply.code(404).send({ error: "User not found" });
|
||||||
|
if (account.archived_at) {
|
||||||
|
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
|
||||||
|
}
|
||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
tenant_ids?: number[];
|
tenant_ids?: number[];
|
||||||
role_assignments?: { tenant_id: number; role_id: string }[];
|
role_assignments?: { tenant_id: number; role_id: string }[];
|
||||||
@@ -1712,7 +1742,9 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(authProfiles.tenant_id, tenantIds),
|
inArray(authProfiles.tenant_id, tenantIds),
|
||||||
isNull(authProfiles.user_id)
|
isNull(authProfiles.user_id),
|
||||||
|
isNull(authProfiles.archived_at),
|
||||||
|
eq(authProfiles.active, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
@@ -1835,6 +1867,10 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [targetUser] = await server.db.select({ id: authUsers.id })
|
||||||
|
.from(authUsers).where(and(eq(authUsers.id, body.user_id), isNull(authUsers.archived_at))).limit(1);
|
||||||
|
if (!targetUser) return reply.code(404).send({ error: "Aktiver Benutzer nicht gefunden." });
|
||||||
|
|
||||||
const mode = body.mode ?? "multi";
|
const mode = body.mode ?? "multi";
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
|
|||||||
51
backend/src/routes/adminUserArchive.ts
Normal file
51
backend/src/routes/adminUserArchive.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { authProfiles, authRefreshTokens, authUsers, m2mApiKeys } from "../../db/schema"
|
||||||
|
import { requireSystemAdmin } from "../utils/requireSystemAdmin"
|
||||||
|
|
||||||
|
export default async function adminUserArchiveRoutes(server: FastifyInstance) {
|
||||||
|
server.post("/admin/users/:user_id/archive", {
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["user_id"],
|
||||||
|
properties: { user_id: { type: "string", format: "uuid" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const currentUser = await requireSystemAdmin(server, req, reply)
|
||||||
|
if (!currentUser) return
|
||||||
|
|
||||||
|
const { user_id } = req.params as { user_id: string }
|
||||||
|
if (user_id === currentUser.id) {
|
||||||
|
return reply.code(409).send({ error: "Das eigene Benutzerkonto kann nicht archiviert werden." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await server.db.transaction(async (tx) => {
|
||||||
|
// Serialize repeated archive requests and keep the original archive date.
|
||||||
|
const [target] = await tx.select({ id: authUsers.id, archived_at: authUsers.archived_at })
|
||||||
|
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1).for("update")
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
const archivedAt = target.archived_at || new Date()
|
||||||
|
const [archivedUser] = await tx.update(authUsers)
|
||||||
|
.set({ archived_at: archivedAt, updatedAt: archivedAt })
|
||||||
|
.where(eq(authUsers.id, user_id))
|
||||||
|
.returning({ id: authUsers.id, archived_at: authUsers.archived_at })
|
||||||
|
|
||||||
|
// Keep links intact so existing documents and time records retain their authors.
|
||||||
|
await tx.update(authProfiles)
|
||||||
|
.set({ active: false, archived_at: archivedAt, token_id: null, calendar_subscription_token: null })
|
||||||
|
.where(eq(authProfiles.user_id, user_id))
|
||||||
|
await tx.update(authRefreshTokens).set({ revokedAt: archivedAt })
|
||||||
|
.where(eq(authRefreshTokens.userId, user_id))
|
||||||
|
await tx.update(m2mApiKeys).set({ active: false, updatedAt: archivedAt })
|
||||||
|
.where(eq(m2mApiKeys.userId, user_id))
|
||||||
|
|
||||||
|
return archivedUser
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) return reply.code(404).send({ error: "Benutzer nicht gefunden." })
|
||||||
|
return { user }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { sendMail } from "../../utils/mailer";
|
|||||||
import { authUsers } from "../../../db/schema";
|
import { authUsers } from "../../../db/schema";
|
||||||
import { authTenantUsers } from "../../../db/schema";
|
import { authTenantUsers } from "../../../db/schema";
|
||||||
import { tenants } from "../../../db/schema";
|
import { tenants } from "../../../db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { resolveLoginTenant } from "../../utils/loginTenant";
|
import { resolveLoginTenant } from "../../utils/loginTenant";
|
||||||
import {
|
import {
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
@@ -112,7 +112,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
const [found] = await server.db
|
const [found] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.email, body.email.toLowerCase()))
|
.where(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
@@ -216,7 +216,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
mustChangePassword: authUsers.must_change_password,
|
mustChangePassword: authUsers.must_change_password,
|
||||||
})
|
})
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.email, email.toLowerCase()))
|
.where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
features: tenants.features,
|
features: tenants.features,
|
||||||
extraModules: tenants.extraModules,
|
extraModules: tenants.extraModules,
|
||||||
businessInfo: tenants.businessInfo,
|
businessInfo: tenants.businessInfo,
|
||||||
|
portalConfig: tenants.portalConfig,
|
||||||
numberRanges: tenants.numberRanges,
|
numberRanges: tenants.numberRanges,
|
||||||
accountChart: tenants.accountChart,
|
accountChart: tenants.accountChart,
|
||||||
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { eq, and } from "drizzle-orm"
|
import { eq, and, isNull } from "drizzle-orm"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authUsers,
|
authUsers,
|
||||||
@@ -83,7 +83,8 @@ export default async function userRoutes(server: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(authProfiles.user_id, id),
|
eq(authProfiles.user_id, id),
|
||||||
eq(authProfiles.tenant_id, req.user.tenant_id)
|
eq(authProfiles.tenant_id, req.user.tenant_id),
|
||||||
|
isNull(authProfiles.archived_at)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ export default async function userRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = { ...data }
|
const updateData: any = { ...data }
|
||||||
|
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete updateData[key]
|
||||||
|
|
||||||
// bekannte Date-Felder prüfen
|
// bekannte Date-Felder prüfen
|
||||||
if (data.entry_date !== undefined)
|
if (data.entry_date !== undefined)
|
||||||
@@ -116,9 +118,10 @@ export default async function userRoutes(server: FastifyInstance) {
|
|||||||
const [updatedProfile] = await server.db
|
const [updatedProfile] = await server.db
|
||||||
.update(authProfiles)
|
.update(authProfiles)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
.where(eq(authProfiles.id, profile.id))
|
.where(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at)))
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
if (!updatedProfile) return reply.code(404).send({ error: "Profile not found in tenant" })
|
||||||
return { profile: updatedProfile }
|
return { profile: updatedProfile }
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
|||||||
import { centralServicesClient } from "../modules/push-server.client"
|
import { centralServicesClient } from "../modules/push-server.client"
|
||||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||||
|
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
||||||
|
import { prepareCashDiscountAllocation, settledAllocationAmount } from "../modules/cash-discount"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -33,9 +35,7 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
export default async function bankingRoutes(server: FastifyInstance) {
|
export default async function bankingRoutes(server: FastifyInstance) {
|
||||||
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
|
||||||
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
||||||
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
||||||
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
||||||
@@ -57,7 +57,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
eq(bankstatements.id, statementId),
|
eq(bankstatements.id, statementId),
|
||||||
eq(bankstatements.tenant, tenantId)
|
eq(bankstatements.tenant, tenantId)
|
||||||
)).limit(1),
|
)).limit(1),
|
||||||
server.db.select({ amount: statementallocations.amount }).from(statementallocations).where(and(
|
server.db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations).where(and(
|
||||||
eq(statementallocations.incominginvoice, invoiceId),
|
eq(statementallocations.incominginvoice, invoiceId),
|
||||||
eq(statementallocations.tenant, tenantId),
|
eq(statementallocations.tenant, tenantId),
|
||||||
eq(statementallocations.archived, false)
|
eq(statementallocations.archived, false)
|
||||||
@@ -78,10 +78,9 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
|
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
|
||||||
? Number(invoice.taxAmountOverride)
|
? Number(invoice.taxAmountOverride)
|
||||||
: accountTotals.tax
|
: accountTotals.tax
|
||||||
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax)
|
const invoiceSettled = invoiceAllocations.reduce((sum, item) => sum + Math.abs(settledAllocationAmount(item)), 0)
|
||||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) - invoiceSettled)
|
||||||
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax)
|
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) - invoiceSettled)
|
||||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
|
||||||
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
||||||
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
||||||
const requestedAmount = Number(allocation.amount || 0)
|
const requestedAmount = Number(allocation.amount || 0)
|
||||||
@@ -1169,7 +1168,10 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||||
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
||||||
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
||||||
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
if (!Number(prepared.data.cashDiscountAmount || 0)) {
|
||||||
|
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
||||||
|
}
|
||||||
|
prepared.data = await prepareCashDiscountAllocation(server.db, req.user.tenant_id, prepared.data)
|
||||||
|
|
||||||
const inserted = await server.db.insert(statementallocations).values({
|
const inserted = await server.db.insert(statementallocations).values({
|
||||||
...prepared.data,
|
...prepared.data,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
authProfiles,
|
authProfiles,
|
||||||
authTenantUsers,
|
authTenantUsers,
|
||||||
@@ -80,7 +80,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
eq(authProfiles.user_id, authTenantUsers.user_id),
|
eq(authProfiles.user_id, authTenantUsers.user_id),
|
||||||
eq(authProfiles.tenant_id, tenantId)
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
))
|
))
|
||||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
|
||||||
|
|
||||||
const addMembers = async (roomId: string, userIds: string[]) => {
|
const addMembers = async (roomId: string, userIds: string[]) => {
|
||||||
const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean)))
|
const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean)))
|
||||||
@@ -122,7 +122,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
.where(eq(communicationRooms.id, room.id)).returning()
|
.where(eq(communicationRooms.id, room.id)).returning()
|
||||||
}
|
}
|
||||||
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
|
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
|
||||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
|
||||||
|
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
|
||||||
if (users.length) await tx.insert(communicationRoomMembers)
|
if (users.length) await tx.insert(communicationRoomMembers)
|
||||||
.values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId })))
|
.values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId })))
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
@@ -157,6 +158,16 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
entityUuid: room.entityUuid,
|
entityUuid: room.entityUuid,
|
||||||
exists: true,
|
exists: true,
|
||||||
})
|
})
|
||||||
|
// Rooms without messages fall back to their creation date so new rooms still sort by recency.
|
||||||
|
const withActivity = async <T extends { room: typeof communicationRooms.$inferSelect }>(rows: T[]) => {
|
||||||
|
const ids = rows.map(({ room }) => room.id)
|
||||||
|
const latest = ids.length ? await server.db.select({
|
||||||
|
roomId: communicationMessages.roomId,
|
||||||
|
at: sql<Date>`max(${communicationMessages.createdAt})`.mapWith(communicationMessages.createdAt),
|
||||||
|
}).from(communicationMessages).where(inArray(communicationMessages.roomId, ids)).groupBy(communicationMessages.roomId) : []
|
||||||
|
const byRoom = new Map(latest.map((row) => [row.roomId, row.at]))
|
||||||
|
return (room: typeof communicationRooms.$inferSelect) => (byRoom.get(room.id) || room.createdAt).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
|
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
|
||||||
const conditions = [eq(communicationMessages.roomId, roomId)]
|
const conditions = [eq(communicationMessages.roomId, roomId)]
|
||||||
@@ -294,7 +305,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
eq(communicationRooms.archived, false),
|
eq(communicationRooms.archived, false),
|
||||||
eq(communicationRoomMembers.userId, req.user.user_id)
|
eq(communicationRoomMembers.userId, req.user.user_id)
|
||||||
)).orderBy(asc(communicationRooms.name))
|
)).orderBy(asc(communicationRooms.name))
|
||||||
return { rooms: rows.map(({ room }) => roomToApi(room)) }
|
const activity = await withActivity(rows)
|
||||||
|
return { rooms: rows.map(({ room }) => ({ ...roomToApi(room), lastActivityAt: activity(room) })) }
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post("/communication/chat/rooms", async (req: any, reply) => {
|
server.post("/communication/chat/rooms", async (req: any, reply) => {
|
||||||
@@ -334,9 +346,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
projectChatAccess(server.db, req),
|
projectChatAccess(server.db, req),
|
||||||
])
|
])
|
||||||
const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room]))
|
const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room]))
|
||||||
|
const activity = await withActivity(roomRows)
|
||||||
return { rooms: projectRows.map((project) => {
|
return { rooms: projectRows.map((project) => {
|
||||||
const room = roomsByProject.get(project.id)
|
const room = roomsByProject.get(project.id)
|
||||||
return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : {
|
return room ? { ...roomToApi(room), lastActivityAt: activity(room), projectId: project.id, projectNumber: project.projectNumber } : {
|
||||||
key: `project_${project.id}`,
|
key: `project_${project.id}`,
|
||||||
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
|
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
|
||||||
topic: `Projektkommunikation zu ${project.name}`,
|
topic: `Projektkommunikation zu ${project.name}`,
|
||||||
@@ -372,7 +385,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
const profileIds = (project.profiles || []) as string[]
|
const profileIds = (project.profiles || []) as string[]
|
||||||
const memberProfiles = profileIds.length
|
const memberProfiles = profileIds.length
|
||||||
? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles)
|
? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles)
|
||||||
.where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds)))
|
.where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds), eq(authProfiles.active, true)))
|
||||||
: []
|
: []
|
||||||
const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[]
|
const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[]
|
||||||
await addMembers(room.id, [req.user.user_id, ...projectMemberIds])
|
await addMembers(room.id, [req.user.user_id, ...projectMemberIds])
|
||||||
@@ -386,11 +399,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
|
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
|
||||||
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id)))
|
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id)))
|
||||||
const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room]))
|
const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room]))
|
||||||
|
const activity = await withActivity(existingRows)
|
||||||
return { rooms: users.map((user) => {
|
return { rooms: users.map((user) => {
|
||||||
const key = directRoomKey(req.user.user_id, user.userId)
|
const key = directRoomKey(req.user.user_id, user.userId)
|
||||||
const room = roomsByKey.get(key)
|
const room = roomsByKey.get(key)
|
||||||
return room ? {
|
return room ? {
|
||||||
...roomToApi(room),
|
...roomToApi(room),
|
||||||
|
lastActivityAt: activity(room),
|
||||||
name: displayName(user),
|
name: displayName(user),
|
||||||
topic: `Direktnachricht mit ${displayName(user)}`,
|
topic: `Direktnachricht mit ${displayName(user)}`,
|
||||||
entityUuid: user.userId,
|
entityUuid: user.userId,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(authProfiles.tenant_id, device.tenant),
|
eq(authProfiles.tenant_id, device.tenant),
|
||||||
eq(authProfiles.token_id, rfid_id)
|
eq(authProfiles.token_id, rfid_id),
|
||||||
|
eq(authProfiles.active, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
|||||||
import duration from "dayjs/plugin/duration.js";
|
import duration from "dayjs/plugin/duration.js";
|
||||||
import timezone from "dayjs/plugin/timezone.js";
|
import timezone from "dayjs/plugin/timezone.js";
|
||||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||||
import {citys, files} from "../../db/schema";
|
import {citys, createddocuments, files} from "../../db/schema";
|
||||||
|
import { attachZugferdToPdf, checkOutgoingEInvoice, EInvoiceRequirementsError } from "../modules/einvoice/outgoingZugferd.service";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
|
import { updateIncomingInvoiceCostCentres } from "../modules/incoming-invoice-cost-centres.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
@@ -109,6 +111,8 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
data: any
|
data: any
|
||||||
backgroundPath?: string
|
backgroundPath?: string
|
||||||
|
// Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird
|
||||||
|
createdDocumentId?: number
|
||||||
}
|
}
|
||||||
const {type} = req.params as {type:string}
|
const {type} = req.params as {type:string}
|
||||||
|
|
||||||
@@ -124,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
body.data,
|
body.data,
|
||||||
body.backgroundPath
|
body.backgroundPath
|
||||||
)
|
)
|
||||||
|
if (body.createdDocumentId) {
|
||||||
|
const tenantId = (req as any).user.tenant_id
|
||||||
|
const [document] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(eq(createddocuments.id, Number(body.createdDocumentId)), eq(createddocuments.tenant, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
|
||||||
|
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
|
||||||
|
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceIssues: result.issues }
|
||||||
|
}
|
||||||
} else if(type === "timesheet") {
|
} else if(type === "timesheet") {
|
||||||
pdf = await createTimeSheetPDF(
|
pdf = await createTimeSheetPDF(
|
||||||
server,
|
server,
|
||||||
@@ -135,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
return pdf // Fastify wandelt automatisch in JSON
|
return pdf // Fastify wandelt automatisch in JSON
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof EInvoiceRequirementsError) {
|
||||||
|
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
|
||||||
|
}
|
||||||
console.log(err)
|
console.log(err)
|
||||||
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
||||||
if (unsupportedCharacterMessage) {
|
if (unsupportedCharacterMessage) {
|
||||||
@@ -148,6 +166,14 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Prüft die Pflichtangaben der E-Rechnung für den aktuellen (ggf. ungespeicherten) Stand aus dem Editor
|
||||||
|
server.post("/functions/einvoice/check", async (req, reply) => {
|
||||||
|
const { document } = (req.body || {}) as { document?: any }
|
||||||
|
if (!document || typeof document !== "object") return reply.code(400).send({ error: "document fehlt" })
|
||||||
|
const issues = await checkOutgoingEInvoice(server, (req as any).user.tenant_id, document)
|
||||||
|
return { issues }
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
||||||
const { numberrange } = req.params as { numberrange: string };
|
const { numberrange } = req.params as { numberrange: string };
|
||||||
const tenant = (req as any).user.tenant_id
|
const tenant = (req as any).user.tenant_id
|
||||||
@@ -334,6 +360,26 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.put('/functions/incoming-invoices/:id/cost-centres', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const invoiceId = Number(id)
|
||||||
|
if (!Number.isFinite(invoiceId)) return reply.code(400).send({ error: "Ungültige Eingangsbeleg-ID" })
|
||||||
|
|
||||||
|
const invoice = await updateIncomingInvoiceCostCentres(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
invoiceId,
|
||||||
|
req.body as any,
|
||||||
|
)
|
||||||
|
return { invoice }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||||
const result = await server.services.bankStatements.run(req.user.tenant_id);
|
const result = await server.services.bankStatements.run(req.user.tenant_id);
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
@@ -348,8 +394,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
||||||
|
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
|
||||||
|
})
|
||||||
|
|
||||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
server.get('/functions/services/prepareincominginvoices', async (req) => {
|
||||||
|
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
tenants
|
tenants
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import {and, eq, inArray} from "drizzle-orm"
|
import {and, eq, inArray, isNull} from "drizzle-orm"
|
||||||
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||||
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
|
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
const users = await server.db
|
const users = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(inArray(authUsers.id, userIds))
|
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
|
||||||
|
|
||||||
// 3) auth_profiles pro Tenant laden
|
// 3) auth_profiles pro Tenant laden
|
||||||
const profileRows = await server.db
|
const profileRows = await server.db
|
||||||
@@ -61,7 +61,9 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(authProfiles.tenant_id, tenantId),
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
inArray(authProfiles.user_id, userIds)
|
inArray(authProfiles.user_id, userIds),
|
||||||
|
eq(authProfiles.active, true),
|
||||||
|
isNull(authProfiles.archived_at)
|
||||||
))
|
))
|
||||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
@@ -98,7 +100,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
const profileRows = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.tenant_id, tenantId))
|
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at)))
|
||||||
|
|
||||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
return await enrichProfilesWithTeams(server, profilesWithBranches)
|
return await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { s3 } from "../utils/s3"
|
|||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
import { sanitizeFilename } from "../utils/filename"
|
import { sanitizeFilename } from "../utils/filename"
|
||||||
|
import multipart from "@fastify/multipart"
|
||||||
|
import { MCP_FILE_LIMIT } from "../mcp/documentFiles"
|
||||||
|
import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads"
|
||||||
|
|
||||||
|
const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"]
|
||||||
|
|
||||||
const SUPPORTED_PROTOCOL_VERSIONS = [
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
@@ -62,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
|
|||||||
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function mcpRoutes(server: FastifyInstance) {
|
type McpRouteOptions = {
|
||||||
|
/** Replaceable in tests to avoid object storage. */
|
||||||
|
storeUpload?: typeof storeMcpFileUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) {
|
||||||
|
const storeUpload = options.storeUpload || storeMcpFileUpload
|
||||||
server.get("/mcp/tokens", async (req, reply) => {
|
server.get("/mcp/tokens", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
requireMcpTokenManagementPermission(req)
|
requireMcpTokenManagementPermission(req)
|
||||||
@@ -221,6 +232,37 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected.
|
||||||
|
await server.register(async (uploads) => {
|
||||||
|
await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } })
|
||||||
|
uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body))
|
||||||
|
|
||||||
|
uploads.post("/mcp/files", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const context = await createMcpContext(server, req)
|
||||||
|
const query = parseUploadQuery(req.query as Record<string, unknown>)
|
||||||
|
let body: Buffer
|
||||||
|
let filename = query.filename
|
||||||
|
let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null
|
||||||
|
if (req.isMultipart()) {
|
||||||
|
const part = await req.file()
|
||||||
|
if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." })
|
||||||
|
body = await part.toBuffer()
|
||||||
|
filename ||= part.filename
|
||||||
|
mimeType = part.mimetype
|
||||||
|
} else if (Buffer.isBuffer(req.body)) {
|
||||||
|
body = req.body
|
||||||
|
} else {
|
||||||
|
return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` })
|
||||||
|
}
|
||||||
|
if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." })
|
||||||
|
return await storeUpload(context, { ...query, filename, body, mimeType })
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
|
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
|
||||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||||
const requests = Array.isArray(body) ? body : [body]
|
const requests = Array.isArray(body) ? body : [body]
|
||||||
@@ -270,7 +312,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
description: tool.description,
|
description: tool.description,
|
||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
annotations: {
|
annotations: {
|
||||||
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authProfiles,
|
authProfiles,
|
||||||
@@ -57,7 +57,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const forbidden = [
|
const forbidden = [
|
||||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||||
"branch", "calendar_subscription_token",
|
"branch", "archived_at", "calendar_subscription_token",
|
||||||
"calendar_subscription_path", "calendar_subscription_url"
|
"calendar_subscription_path", "calendar_subscription_url"
|
||||||
]
|
]
|
||||||
forbidden.forEach(f => delete cleaned[f])
|
forbidden.forEach(f => delete cleaned[f])
|
||||||
@@ -135,7 +135,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(authProfiles.id, id),
|
eq(authProfiles.id, id),
|
||||||
eq(authProfiles.tenant_id, tenantId)
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
isNull(authProfiles.archived_at)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning()
|
.returning()
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
and,
|
and,
|
||||||
count,
|
count,
|
||||||
inArray,
|
inArray,
|
||||||
|
isNull,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authProfiles,
|
authProfiles,
|
||||||
|
authUsers,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
customers,
|
customers,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
@@ -138,7 +140,10 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue:
|
|||||||
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
|
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyResourceWhereFilters(resource: string, table: any, whereCond: any) {
|
function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) {
|
||||||
|
if (list && table === authProfiles) {
|
||||||
|
return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at))
|
||||||
|
}
|
||||||
if (resource === "members") {
|
if (resource === "members") {
|
||||||
return and(whereCond, eq(table.type, "Mitglied"))
|
return and(whereCond, eq(table.type, "Mitglied"))
|
||||||
}
|
}
|
||||||
@@ -502,6 +507,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
id: statementallocations.id,
|
id: statementallocations.id,
|
||||||
createddocument: statementallocations.createddocument,
|
createddocument: statementallocations.createddocument,
|
||||||
amount: statementallocations.amount,
|
amount: statementallocations.amount,
|
||||||
|
cashDiscountAmount: statementallocations.cashDiscountAmount,
|
||||||
})
|
})
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -570,7 +576,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const tenantColumn = getTenantColumn(resource, table)
|
const tenantColumn = getTenantColumn(resource, table)
|
||||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
|
||||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||||
let q = server.db.select().from(table).$dynamic()
|
let q = server.db.select().from(table).$dynamic()
|
||||||
|
|
||||||
@@ -688,7 +694,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const tenantColumn = getTenantColumn(resource, table);
|
const tenantColumn = getTenantColumn(resource, table);
|
||||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
|
||||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
||||||
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
||||||
@@ -789,7 +795,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||||
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true)
|
||||||
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
|
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -878,7 +884,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const table = resourceConfig[resource].table
|
const table = resourceConfig[resource].table
|
||||||
|
|
||||||
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||||
|
|
||||||
@@ -943,6 +949,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const table = config.table;
|
const table = config.table;
|
||||||
|
|
||||||
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
|
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
|
||||||
|
if (table === authProfiles) {
|
||||||
|
delete createData.archived_at;
|
||||||
|
createData.tenant_id = req.user.tenant_id;
|
||||||
|
if (createData.user_id) {
|
||||||
|
const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers)
|
||||||
|
.where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1);
|
||||||
|
if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (resource === "projects") {
|
if (resource === "projects") {
|
||||||
const error = projectChatSettingError(req, createData)
|
const error = projectChatSettingError(req, createData)
|
||||||
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||||
@@ -1093,7 +1108,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const [oldRecord] = await server.db
|
const [oldRecord] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(table)
|
.from(table)
|
||||||
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
|
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!oldRecord) {
|
if (!oldRecord) {
|
||||||
@@ -1105,7 +1120,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (table === authProfiles && oldRecord.archived_at) {
|
||||||
|
return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." })
|
||||||
|
}
|
||||||
let data: Record<string, any> = { ...body }
|
let data: Record<string, any> = { ...body }
|
||||||
|
if (table === authProfiles) {
|
||||||
|
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key]
|
||||||
|
}
|
||||||
delete data.updatedAt
|
delete data.updatedAt
|
||||||
delete data.updated_at
|
delete data.updated_at
|
||||||
delete data.updatedBy
|
delete data.updatedBy
|
||||||
@@ -1194,7 +1215,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
|
||||||
|
if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at))
|
||||||
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
|
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
|
||||||
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
|
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
|
||||||
|
|
||||||
@@ -1203,7 +1225,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
.update(table)
|
.update(table)
|
||||||
.set({ defaultMandate: false })
|
.set({ defaultMandate: false })
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(table.tenant, tenantId),
|
eq(getTenantColumn(resource, table), tenantId),
|
||||||
eq(table.customer, updated.customer),
|
eq(table.customer, updated.customer),
|
||||||
sql`${table.id} <> ${updated.id}`
|
sql`${table.id} <> ${updated.id}`
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
m2mApiKeys
|
m2mApiKeys
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
import {and, desc, eq, inArray, isNull} from "drizzle-orm"
|
||||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||||
import { rotateRefreshToken } from "../utils/authTokens"
|
import { rotateRefreshToken } from "../utils/authTokens"
|
||||||
|
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
@@ -152,7 +153,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
const users = await server.db
|
const users = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(inArray(authUsers.id, userIds))
|
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
|
||||||
|
|
||||||
// 3) auth_profiles pro Tenant laden
|
// 3) auth_profiles pro Tenant laden
|
||||||
const profileRows = await server.db
|
const profileRows = await server.db
|
||||||
@@ -161,7 +162,9 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(authProfiles.tenant_id, tenantId),
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
inArray(authProfiles.user_id, userIds)
|
inArray(authProfiles.user_id, userIds),
|
||||||
|
eq(authProfiles.active, true),
|
||||||
|
isNull(authProfiles.archived_at)
|
||||||
))
|
))
|
||||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
@@ -194,10 +197,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"
|
||||||
const profileRows = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.tenant_id, tenantId))
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)])
|
||||||
|
))
|
||||||
|
|
||||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
|
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
@@ -271,6 +278,24 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (!data) return reply.code(400).send({ error: "data required" })
|
if (!data) return reply.code(400).send({ error: "data required" })
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, "portalConfig")) {
|
||||||
|
const config = data.portalConfig
|
||||||
|
if (!config || typeof config !== "object" || Array.isArray(config)
|
||||||
|
|| typeof config.primaryColor !== "string" || !/^#[0-9a-f]{6}$/i.test(config.primaryColor.trim())) {
|
||||||
|
return reply.code(400).send({ error: "Die Portal-Akzentfarbe muss ein Hex-Farbwert wie #69c350 sein." })
|
||||||
|
}
|
||||||
|
data.portalConfig = { ...config, primaryColor: config.primaryColor.trim().toLowerCase() }
|
||||||
|
delete data.portalConfig.primayColor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, "cashDiscountAccounts")) {
|
||||||
|
try {
|
||||||
|
data.cashDiscountAccounts = normalizeCashDiscountAccountsInput(data.cashDiscountAccounts)
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(400).send({ error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await server.db
|
const updated = await server.db
|
||||||
.update(tenants)
|
.update(tenants)
|
||||||
.set(data)
|
.set(data)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export async function rotateRefreshToken(
|
|||||||
const [user] = await server.db
|
const [user] = await server.db
|
||||||
.select({ id: authUsers.id, email: authUsers.email })
|
.select({ id: authUsers.id, email: authUsers.email })
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.id, session.userId))
|
.where(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
import { and, asc, eq, sql } from "drizzle-orm"
|
import { and, asc, eq, isNull, sql } from "drizzle-orm"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
import { authProfiles, events } from "../../db/schema"
|
import { authProfiles, events } from "../../db/schema"
|
||||||
@@ -105,7 +105,8 @@ export async function generateProfileCalendarSubscriptionToken(server: FastifyIn
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(authProfiles.id, profileId),
|
eq(authProfiles.id, profileId),
|
||||||
eq(authProfiles.tenant_id, tenantId)
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
isNull(authProfiles.archived_at)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning()
|
.returning()
|
||||||
|
|||||||
@@ -104,6 +104,51 @@ export const getBankAccountBookingLine = (alloc: any) => {
|
|||||||
return columns.join(";");
|
return columns.join(";");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skonto als eigene Buchung je Steuersatz: Skontokonto (Automatikkonto) gegen Debitor bzw. Kreditor.
|
||||||
|
// Positiver Skonto (Zahlungseingang) steht im Soll des Skontokontos, negativer (Zahlungsausgang) im Haben.
|
||||||
|
export const getCashDiscountBookingLines = (alloc: any) => {
|
||||||
|
const split = Array.isArray(alloc?.cashDiscountSplit) ? alloc.cashDiscountSplit : [];
|
||||||
|
if (!split.length) return [];
|
||||||
|
|
||||||
|
const bs = alloc.bankstatement;
|
||||||
|
const cd = alloc.createddocument;
|
||||||
|
const ii = alloc.incominginvoice;
|
||||||
|
const partner = cd ? cd.customer : ii?.vendor;
|
||||||
|
const partnerNumber = cd ? partner?.customerNumber : partner?.vendorNumber;
|
||||||
|
const reference = cd ? cd.documentNumber : ii?.reference;
|
||||||
|
const prefix = cd ? "Skonto AR" : "Skonto ER";
|
||||||
|
const date = bs?.date || alloc.manualBookingDate;
|
||||||
|
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
|
||||||
|
|
||||||
|
return split
|
||||||
|
.filter((item: any) => Number(item?.gross) && item?.account)
|
||||||
|
.map((item: any) => {
|
||||||
|
const columns = Array(124).fill("");
|
||||||
|
columns[0] = displayCurrency(Number(item.gross), true);
|
||||||
|
columns[1] = quote(Number(item.gross) > 0 ? "S" : "H");
|
||||||
|
columns[6] = item.account;
|
||||||
|
columns[7] = partnerNumber || "";
|
||||||
|
columns[8] = quote("");
|
||||||
|
columns[9] = formatDatevDate(date, "DDMM");
|
||||||
|
columns[10] = quote(reference || "");
|
||||||
|
columns[13] = quote(`${prefix} ${reference || ""} ${item.rate} % - ${partner?.name || ""}`.substring(0, 59));
|
||||||
|
columns[20] = quote("Geschäftspartner");
|
||||||
|
columns[21] = quote(partner?.name || "");
|
||||||
|
columns[22] = quote("Kundennummer");
|
||||||
|
columns[23] = quote(partnerNumber || "");
|
||||||
|
columns[24] = quote("Belegnummer");
|
||||||
|
columns[25] = quote(reference || "");
|
||||||
|
columns[28] = quote("Belegdatum");
|
||||||
|
columns[29] = quote(formatDatevDate(date, "DD.MM.YYYY"));
|
||||||
|
if (bs?.id) {
|
||||||
|
columns[47] = "Bank-Id";
|
||||||
|
columns[48] = bs.id;
|
||||||
|
}
|
||||||
|
columns[112] = "0";
|
||||||
|
return columns.join(";");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
||||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||||
|
|
||||||
@@ -479,6 +524,10 @@ export async function buildExportZip(
|
|||||||
const ii = alloc.incominginvoice;
|
const ii = alloc.incominginvoice;
|
||||||
const vend = ii.vendor;
|
const vend = ii.vendor;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if((alloc.createddocument && alloc.createddocument.customer) || (alloc.incominginvoice && alloc.incominginvoice.vendor)) {
|
||||||
|
bookingLines.push(...getCashDiscountBookingLines(alloc));
|
||||||
} else if(alloc.account) {
|
} else if(alloc.account) {
|
||||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||||
} else if(alloc.vendor) {
|
} else if(alloc.vendor) {
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ const InstructionFormat = z.object({
|
|||||||
tax: nullableNumber,
|
tax: nullableNumber,
|
||||||
total: nullableNumber,
|
total: nullableNumber,
|
||||||
terms: nullableString,
|
terms: nullableString,
|
||||||
|
cash_discount_percent: nullableNumber,
|
||||||
|
cash_discount_days: nullableNumber,
|
||||||
|
cash_discount_due_date: nullableString,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -190,6 +193,7 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
"Do not merge summary totals into fabricated invoice_items.\n" +
|
"Do not merge summary totals into fabricated invoice_items.\n" +
|
||||||
"Match issuer by name to vendor.id.\n" +
|
"Match issuer by name to vendor.id.\n" +
|
||||||
"Match invoice items to account id based on label/number.\n" +
|
"Match invoice items to account id based on label/number.\n" +
|
||||||
|
"Set cash_discount_percent, cash_discount_days and cash_discount_due_date only when the invoice explicitly offers Skonto/cash discount (e.g. '2 % Skonto bei Zahlung innerhalb 10 Tagen'); otherwise null.\n" +
|
||||||
"Convert dates to YYYY-MM-DD.\n" +
|
"Convert dates to YYYY-MM-DD.\n" +
|
||||||
"Keep invoice items in original order.\n",
|
"Keep invoice items in original order.\n",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function insertHistoryItem(
|
|||||||
created_by: string | null
|
created_by: string | null
|
||||||
entity: string
|
entity: string
|
||||||
entityId: string | number
|
entityId: string | number
|
||||||
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
|
action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored"
|
||||||
oldVal?: any
|
oldVal?: any
|
||||||
newVal?: any
|
newVal?: any
|
||||||
text?: string
|
text?: string
|
||||||
@@ -62,6 +62,7 @@ export async function insertHistoryItem(
|
|||||||
updated: `Eintrag in ${entityLabel} geändert`,
|
updated: `Eintrag in ${entityLabel} geändert`,
|
||||||
unchanged: `Eintrag in ${entityLabel} unverändert`,
|
unchanged: `Eintrag in ${entityLabel} unverändert`,
|
||||||
archived: `Eintrag in ${entityLabel} archiviert`,
|
archived: `Eintrag in ${entityLabel} archiviert`,
|
||||||
|
restored: `Eintrag in ${entityLabel} wiederhergestellt`,
|
||||||
deleted: `Eintrag in ${entityLabel} gelöscht`
|
deleted: `Eintrag in ${entityLabel} gelöscht`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -685,14 +685,14 @@ export const generateLiquidityForecast = async (
|
|||||||
if (allocation.createddocument) {
|
if (allocation.createddocument) {
|
||||||
allocationByDocument.set(
|
allocationByDocument.set(
|
||||||
allocation.createddocument,
|
allocation.createddocument,
|
||||||
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
|
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allocation.incominginvoice) {
|
if (allocation.incominginvoice) {
|
||||||
allocationByIncomingInvoice.set(
|
allocationByIncomingInvoice.set(
|
||||||
allocation.incominginvoice,
|
allocation.incominginvoice,
|
||||||
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
|
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,17 @@ const getDuration = (time) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
/** Positions in mm from the top edge, recorded while rendering for layout checks (e.g. page breaks). */
|
||||||
|
export type InvoicePdfLayout = {
|
||||||
|
pageHeightMm: number
|
||||||
|
rows: Array<{ index: number, id?: string, pos?: string, mode: string, text: string, page: number, topMm: number, bottomMm: number }>
|
||||||
|
totals?: { page: number, topMm: number, bottomMm: number }
|
||||||
|
endText?: { page: number, topMm: number, bottomMm: number }
|
||||||
|
paymentQr?: { page: number, topMm: number, leftMm: number }
|
||||||
|
pageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string, layout?: InvoicePdfLayout) => {
|
||||||
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
const isPackingSlip = invoiceData?.type === "packingSlips"
|
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||||
|
|
||||||
@@ -153,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
- paymentQrLayout.sizeMm * mm
|
- paymentQrLayout.sizeMm * mm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top edge (mm from the top) of the QR label at its fixed bottom-right spot.
|
||||||
|
const getPaymentQrTopMm = (page: any) => page.getHeight() / mm
|
||||||
|
- (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm
|
||||||
|
// Set once the amounts are placed: the QR moves to its own page if it would cover them.
|
||||||
|
let paymentQrOnNewPage = false
|
||||||
|
|
||||||
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
||||||
if (!paymentQrCode) return 500
|
if (!paymentQrCode || paymentQrOnNewPage) return 500
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
240,
|
240,
|
||||||
@@ -496,6 +512,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
invoiceData.rows.forEach((row, index) => {
|
invoiceData.rows.forEach((row, index) => {
|
||||||
|
const layoutTop = rowHeight
|
||||||
|
|
||||||
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
|
|
||||||
@@ -881,10 +898,18 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(rowHeight)
|
console.log(rowHeight)
|
||||||
|
layout?.rows.push({
|
||||||
|
index, id: row.id, pos: row.pos, mode: row.mode, text: String(row.text || "").slice(0, 80),
|
||||||
|
// rowHeight is the baseline of the first text line; shift to the visible cap height.
|
||||||
|
page: pageCounter, topMm: row.mode === "pagebreak" ? 30 : layoutTop - 3, bottomMm: row.mode === "pagebreak" ? 30 : rowHeight - 3,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
let endTextDiff = 35
|
let endTextDiff = 35
|
||||||
|
const layoutContentEnd = rowHeight
|
||||||
|
// Visible bottom of the last row (rowHeight is the next baseline); totals extend it below.
|
||||||
|
let amountsBottomMm = rowHeight - 3
|
||||||
|
|
||||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawLine({
|
pages[pageCounter - 1].drawLine({
|
||||||
@@ -999,8 +1024,23 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below.
|
||||||
|
const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0)
|
||||||
|
amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1
|
||||||
|
paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1])
|
||||||
|
|
||||||
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
|
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
|
||||||
|
|
||||||
|
if (layout) {
|
||||||
|
const maxWidth = getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x)
|
||||||
|
const lines = String(invoiceData.endText || "").split("\n")
|
||||||
|
.reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0)
|
||||||
|
const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8
|
||||||
|
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm }
|
||||||
|
// drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm.
|
||||||
|
layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
||||||
...endTextPosition,
|
...endTextPosition,
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -1012,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (paymentQrCode) {
|
if (paymentQrCode) {
|
||||||
|
// Invoice-like documents decide this before placing the end text.
|
||||||
|
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
|
paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1])
|
||||||
|
}
|
||||||
|
if (paymentQrOnNewPage) {
|
||||||
|
// Positions or totals reach the QR area; never draw the code over amounts.
|
||||||
|
const qrPage = pdfDoc.addPage()
|
||||||
|
qrPage.drawPage(secondPageBackground, { x: 0, y: 0 })
|
||||||
|
pages.push(qrPage)
|
||||||
|
}
|
||||||
const lastPage = pages[pages.length - 1]
|
const lastPage = pages[pages.length - 1]
|
||||||
const qrSize = paymentQrLayout.sizeMm * mm
|
const qrSize = paymentQrLayout.sizeMm * mm
|
||||||
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
||||||
@@ -1030,6 +1080,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold,
|
font: fontBold,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (layout) layout.paymentQr = {
|
||||||
|
page: pages.length,
|
||||||
|
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
|
||||||
|
leftMm: qrX / mm,
|
||||||
|
}
|
||||||
|
|
||||||
lastPage.drawImage(paymentQrCode, {
|
lastPage.drawImage(paymentQrCode, {
|
||||||
x: qrX,
|
x: qrX,
|
||||||
y: qrY,
|
y: qrY,
|
||||||
@@ -1038,6 +1094,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (layout) {
|
||||||
|
layout.pageCount = pages.length
|
||||||
|
layout.pageHeightMm = page1.getHeight() / mm
|
||||||
|
}
|
||||||
|
|
||||||
return await pdfDoc.saveAsBase64()
|
return await pdfDoc.saveAsBase64()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
backend/src/utils/pdfPreview.ts
Normal file
30
backend/src/utils/pdfPreview.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { execFile } from "node:child_process"
|
||||||
|
import { promisify } from "node:util"
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
/** Renders selected PDF pages (1-based) to PNG with poppler's pdftoppm. */
|
||||||
|
export async function renderPdfPagesAsPng(pdf: Buffer, pages: number[], dpi: number) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-preview-"))
|
||||||
|
try {
|
||||||
|
const input = path.join(dir, "document.pdf")
|
||||||
|
await fs.writeFile(input, pdf)
|
||||||
|
const images: Array<{ page: number, png: Buffer }> = []
|
||||||
|
for (const page of pages) {
|
||||||
|
const prefix = path.join(dir, `page-${page}`)
|
||||||
|
try {
|
||||||
|
await execFileAsync("pdftoppm", ["-png", "-r", String(dpi), "-f", String(page), "-l", String(page), "-singlefile", input, prefix], { timeout: 30_000 })
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") throw Object.assign(new Error("Die Seitenvorschau benötigt pdftoppm (poppler-utils) auf dem Server."), { statusCode: 501 })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
images.push({ page, png: await fs.readFile(`${prefix}.png`) })
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { authUsers } from "../../db/schema/auth_users";
|
import { authUsers } from "../../db/schema/auth_users";
|
||||||
|
|
||||||
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||||
@@ -9,7 +9,7 @@ export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRe
|
|||||||
}
|
}
|
||||||
// Read the current global role; tenant roles and stale JWT claims do not grant access.
|
// Read the current global role; tenant roles and stale JWT claims do not grant access.
|
||||||
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
|
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
|
||||||
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
|
.from(authUsers).where(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1);
|
||||||
if (!user?.is_admin) {
|
if (!user?.is_admin) {
|
||||||
reply.code(403).send({ error: "Admin access required" });
|
reply.code(403).send({ error: "Admin access required" });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
24
backend/tests/bankBalances.test.ts
Normal file
24
backend/tests/bankBalances.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service"
|
||||||
|
|
||||||
|
const syncedAt = new Date("2026-10-20T08:00:00Z")
|
||||||
|
const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt }
|
||||||
|
const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 }
|
||||||
|
|
||||||
|
test("bank accounts report the synced balance and revert later statements for a past date", () => {
|
||||||
|
assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1)
|
||||||
|
assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync")
|
||||||
|
const past = calculateAccountBalance(bank, -200.05, "2026-10-01")
|
||||||
|
assert.equal(past.balance, 1200.15)
|
||||||
|
assert.equal(past.syncedBalance, 1000.1)
|
||||||
|
assert.equal(past.source, "bank_sync_reconstructed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cashbooks add their bookings to the opening balance", () => {
|
||||||
|
const result = calculateAccountBalance(cashbook, 12.345, null)
|
||||||
|
assert.equal(result.type, "cashbook")
|
||||||
|
assert.equal(result.balance, 62.35)
|
||||||
|
assert.equal(result.openingBalance, 50)
|
||||||
|
assert.equal(result.iban, null)
|
||||||
|
})
|
||||||
141
backend/tests/cashDiscount.test.ts
Normal file
141
backend/tests/cashDiscount.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import {
|
||||||
|
getCreatedDocumentGrossByRate,
|
||||||
|
getCreatedDocumentGrossTotal,
|
||||||
|
getIncomingInvoiceGrossByRate,
|
||||||
|
normalizeCashDiscountAccountsInput,
|
||||||
|
resolveCashDiscountAccounts,
|
||||||
|
splitCashDiscount,
|
||||||
|
validateCashDiscount,
|
||||||
|
} from "../src/modules/cash-discount"
|
||||||
|
import { getCashDiscountBookingLines } from "../src/utils/export/datev"
|
||||||
|
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||||||
|
|
||||||
|
const defaults = resolveCashDiscountAccounts({})
|
||||||
|
|
||||||
|
test("splits incoming invoice discount by tax rate onto received discount accounts", () => {
|
||||||
|
const gross = getIncomingInvoiceGrossByRate({
|
||||||
|
expense: true,
|
||||||
|
accounts: [
|
||||||
|
{ taxType: "19", amountNet: 100, amountTax: 19 },
|
||||||
|
{ taxType: "7", amountNet: 100, amountTax: 7 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.deepEqual(gross, { "19": -119, "7": -107, "0": 0 })
|
||||||
|
|
||||||
|
const split = splitCashDiscount(-4.52, gross, defaults.received)
|
||||||
|
assert.deepEqual(split, [
|
||||||
|
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||||||
|
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assigns rounding differences to the largest share", () => {
|
||||||
|
const split = splitCashDiscount(1, { "19": 1, "7": 1, "0": 1 }, defaults.granted)
|
||||||
|
assert.equal(split.reduce((sum, item) => sum + item.gross, 0).toFixed(2), "1.00")
|
||||||
|
assert.equal(split.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects discounts on intra-community acquisitions", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => getIncomingInvoiceGrossByRate({ accounts: [{ taxType: "19I", amountNet: 100, amountTax: 0 }] }),
|
||||||
|
/manuell buchen/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("final invoices spread discounts over the remaining claim after advance invoices", () => {
|
||||||
|
const advance = { id: 7, rows: [{ advanceInvoiceData: true, price: 1000, taxPercent: 19 }] }
|
||||||
|
const final = {
|
||||||
|
id: 8,
|
||||||
|
usedAdvanceInvoices: [7],
|
||||||
|
rows: [
|
||||||
|
{ mode: "normal", quantity: 1, price: 2000, discountPercent: 0, taxPercent: 19 },
|
||||||
|
{ mode: "normal", quantity: 1, price: 1000, discountPercent: 0, taxPercent: 7 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert.deepEqual(getCreatedDocumentGrossByRate(final, [advance]), { "19": 1190, "7": 1070, "0": 0 })
|
||||||
|
assert.equal(getCreatedDocumentGrossTotal(final, [advance]), 2260)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tax exempt small business invoices use the account without tax", () => {
|
||||||
|
const gross = getCreatedDocumentGrossByRate({
|
||||||
|
taxType: "19 UStG",
|
||||||
|
rows: [{ mode: "normal", quantity: 1, price: 100, discountPercent: 0, taxPercent: 19 }],
|
||||||
|
})
|
||||||
|
assert.deepEqual(gross, { "19": 0, "7": 0, "0": 100 })
|
||||||
|
assert.deepEqual(splitCashDiscount(2, gross, defaults.granted), [{ rate: 0, account: "8730", gross: 2, net: 2, tax: 0 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates payment plus discount against the open amount", () => {
|
||||||
|
assert.doesNotThrow(() => validateCashDiscount({ amount: 98, discount: 2, total: 100, settled: 0 }))
|
||||||
|
assert.doesNotThrow(() => validateCashDiscount({ amount: -98, discount: -2, total: -100, settled: 0 }))
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: 99, discount: 2, total: 100, settled: 0 }), /offenen Betrag/)
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: 50, discount: 2, total: 100, settled: 50 }), /offenen Betrag/)
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: 80, discount: 20, total: 100, settled: 0 }), /höher als 10 %/)
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: -98, discount: -2, total: 100, settled: 0 }), /Richtung/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes configured discount accounts and keeps SKR03 defaults", () => {
|
||||||
|
const input = normalizeCashDiscountAccountsInput({ received: { "19": " 3737 ", "7": "" } })
|
||||||
|
assert.deepEqual(input, { received: { "19": "3737" } })
|
||||||
|
assert.equal(resolveCashDiscountAccounts(input).received["19"], "3737")
|
||||||
|
assert.equal(resolveCashDiscountAccounts(input).received["7"], "3731")
|
||||||
|
assert.throws(() => normalizeCashDiscountAccountsInput({ granted: { "19": "87A6" } }), /Ziffern/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exports discount bookings per tax rate against the business partner", () => {
|
||||||
|
const lines = getCashDiscountBookingLines({
|
||||||
|
bankstatement: { id: 42, date: "2026-10-05" },
|
||||||
|
incominginvoice: { reference: "RE-1", vendor: { vendorNumber: "70001", name: "Lieferant" } },
|
||||||
|
cashDiscountSplit: [
|
||||||
|
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||||||
|
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||||||
|
],
|
||||||
|
}).map(line => line.split(";"))
|
||||||
|
|
||||||
|
assert.equal(lines.length, 2)
|
||||||
|
assert.equal(lines[0].length, 124)
|
||||||
|
assert.deepEqual([lines[0][0], lines[0][1], lines[0][6], lines[0][7], lines[0][9], lines[0][10]],
|
||||||
|
["2,38", '"H"', "3736", "70001", "0510", '"RE-1"'])
|
||||||
|
|
||||||
|
const [granted] = getCashDiscountBookingLines({
|
||||||
|
bankstatement: { id: 43, date: "2026-10-05" },
|
||||||
|
createddocument: { documentNumber: "RE-100", customer: { customerNumber: "10001", name: "Kunde" } },
|
||||||
|
cashDiscountSplit: [{ rate: 19, account: "8736", gross: 2.38, net: 2, tax: 0.38 }],
|
||||||
|
}).map(line => line.split(";"))
|
||||||
|
assert.deepEqual([granted[0], granted[1], granted[6], granted[7]], ["2,38", '"S"', "8736", "10001"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("MCP allocation input accepts cash discounts only with bank statements", () => {
|
||||||
|
const payload = prepareStatementAllocationInput({ bankstatement: 1, createddocument: 2, amount: 98, cashDiscountAmount: 2 })
|
||||||
|
assert.equal(payload.cashDiscountAmount, 2)
|
||||||
|
assert.throws(() => prepareStatementAllocationInput({
|
||||||
|
amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2,
|
||||||
|
}), /Bankzahlung/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("builds cash discount terms and the PDF sentence", async () => {
|
||||||
|
const { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext, parseCashDiscountPaymentTerms } = await import("../src/modules/cash-discount")
|
||||||
|
const terms = buildCashDiscountTerms({ percent: 2, days: 10, documentDate: "2026-11-04", amount: 1190 })
|
||||||
|
assert.deepEqual(terms, { percent: 2, days: 10, dueDate: "2026-11-14", discount: 23.8, payable: 1166.2 })
|
||||||
|
assert.equal(formatCashDiscountText(terms).replace(/ /g, " "),
|
||||||
|
"Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.")
|
||||||
|
assert.equal(getCashDiscountTemplateContext(terms).skonto_datum, "14.11.2026")
|
||||||
|
assert.match(appendCashDiscountText("Vielen Dank.", "Vielen Dank.", terms), /^Bei Zahlung .*\n\nVielen Dank\.$/)
|
||||||
|
assert.equal(appendCashDiscountText("Eigener Text", "{{skonto_hinweis}}", terms), "Eigener Text")
|
||||||
|
assert.equal(buildCashDiscountTerms({ percent: null, days: 10, documentDate: "2026-11-04", amount: 100 }), null)
|
||||||
|
|
||||||
|
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=7#PROZENT=3.00#\n#SKONTO#TAGE=14#PROZENT=2.00#"), { percent: 3, days: 7 })
|
||||||
|
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=14#PROZENT=2,5#BASISBETRAG=100.00#"), { percent: 2.5, days: 14 })
|
||||||
|
assert.equal(parseCashDiscountPaymentTerms("Zahlbar ohne Abzug"), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps explicit AI cash discount data onto incoming invoices", async () => {
|
||||||
|
const { mapGptDataToIncomingInvoice } = await import("../src/modules/cron/prepareIncomingInvoices")
|
||||||
|
const mapped = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 2, cash_discount_days: 10 }, 1)
|
||||||
|
assert.equal(mapped.cashDiscountPercent, 2)
|
||||||
|
assert.equal(mapped.cashDiscountDueDate, "2026-11-11")
|
||||||
|
const ignored = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 25 }, 1)
|
||||||
|
assert.equal(ignored.cashDiscountPercent, undefined)
|
||||||
|
})
|
||||||
201
backend/tests/collaborationNotifications.test.ts
Normal file
201
backend/tests/collaborationNotifications.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { notificationsItems } from "../db/schema"
|
||||||
|
import { CollaborationService } from "../src/modules/collaboration/service"
|
||||||
|
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
|
||||||
|
import { CollaborationNotifications } from "../src/modules/collaboration/notifications"
|
||||||
|
import type { Transport } from "../src/modules/collaboration/transport"
|
||||||
|
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
|
||||||
|
|
||||||
|
const MESSAGE = "collaboration.message.new", INVITATION = "collaboration.room.invited"
|
||||||
|
|
||||||
|
test("collaboration notifications use real preferences, transactions and signed federation routes", { skip: !testDatabaseUrl }, async t => {
|
||||||
|
const servers = new Map<string, ReturnType<typeof Fastify>>()
|
||||||
|
let offline = false
|
||||||
|
const transport: Transport = async (url, method, headers, body) => {
|
||||||
|
if (offline) throw Object.assign(new Error("Offline"), { statusCode: 503 })
|
||||||
|
const origin = [...servers.keys()].find(value => url.startsWith(value + "/"))!
|
||||||
|
const response = await servers.get(origin)!.inject({ method, url: url.slice(origin.length), headers, ...(body ? { payload: body } : {}) })
|
||||||
|
if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode })
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
const make = async (name: string) => {
|
||||||
|
const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"])
|
||||||
|
const original = await readFile(new URL("../db/migrations/0000_brief_dark_beast.sql", import.meta.url), "utf8")
|
||||||
|
for (const type of ["notification_channel", "notification_status", "notification_severity"]) {
|
||||||
|
const definition = original.match(new RegExp(`CREATE TYPE "public"\\."${type}" AS ENUM\\([^;]+;`))![0]
|
||||||
|
await fixture.pool.query(definition.replace('"public".', ""))
|
||||||
|
}
|
||||||
|
for (const table of ["notifications_event_types", "notifications_items", "notifications_preferences", "notifications_preferences_defaults"]) {
|
||||||
|
await fixture.pool.query(original.match(new RegExp(`CREATE TABLE "${table}" \\([\\s\\S]*?\\n\\);`))![0])
|
||||||
|
}
|
||||||
|
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences(tenant_id,user_id,event_type,channel)")
|
||||||
|
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences_defaults(tenant_id,event_key,channel)")
|
||||||
|
await fixture.pool.query(await readFile(new URL("../db/migrations/0083_collaboration_notifications.sql", import.meta.url), "utf8"))
|
||||||
|
const userId = randomUUID()
|
||||||
|
await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null,null),(2,'Other tenant',null,null)", [name])
|
||||||
|
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`])
|
||||||
|
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId])
|
||||||
|
const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) })
|
||||||
|
const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport,
|
||||||
|
seal: value => ({ key: value }), unseal: value => value.key })
|
||||||
|
service.options.changed = (id, db) => federation.changed(id, db)
|
||||||
|
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
||||||
|
const app = Fastify()
|
||||||
|
app.decorate("db", fixture.db)
|
||||||
|
const notifications = new CollaborationNotifications(app, service, federation)
|
||||||
|
service.options.notifications = notifications
|
||||||
|
const delivered: any[] = []
|
||||||
|
const delivery = notifications.delivery.bind(notifications)
|
||||||
|
// Keep persistence and preference resolution real; replace only external channel delivery.
|
||||||
|
notifications.delivery = db => {
|
||||||
|
const sender = delivery(db)
|
||||||
|
sender.deliver = async item => {
|
||||||
|
delivered.push(item)
|
||||||
|
await db.update(notificationsItems).set({ status: "sent", sentAt: new Date() }).where(eq(notificationsItems.id, item.id))
|
||||||
|
return { success: true, id: item.id, channel: item.channel }
|
||||||
|
}
|
||||||
|
return sender
|
||||||
|
}
|
||||||
|
await app.register(federationRoutes, { federation })
|
||||||
|
await app.ready()
|
||||||
|
servers.set(federation.origin, app)
|
||||||
|
return { ...fixture, service, federation, notifications, app, delivered, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2),
|
||||||
|
items: async () => (await fixture.pool.query("SELECT * FROM notifications_items ORDER BY created_at,id")).rows }
|
||||||
|
}
|
||||||
|
const a = await make("alpha"), b = await make("beta")
|
||||||
|
try {
|
||||||
|
await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true })
|
||||||
|
await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true })
|
||||||
|
const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal))
|
||||||
|
const room = await a.service.createRoom(a.ctx, { name: "Gemeinsamer Chat" })
|
||||||
|
const before = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" })
|
||||||
|
let bindingId: string
|
||||||
|
const jobFor = async (invitationId: string) => (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitationId}`])).rows[0]
|
||||||
|
const itemsFor = async (instance: typeof a, id: string) => (await instance.items()).filter(item => item.payload.messageId === id)
|
||||||
|
const enable = async (instance: typeof a, tenantId = 1, enabled = true) => instance.notifications.delivery(instance.db).savePreferences(tenantId, instance.ctx.userId,
|
||||||
|
["email", "push"].map(channel => ({ eventType: MESSAGE, channel, enabled })))
|
||||||
|
|
||||||
|
await t.test("new matrix events have quiet message defaults and per-tenant overrides", async () => {
|
||||||
|
const preferences = await b.notifications.delivery(b.db).preferences(1, b.ctx.userId)
|
||||||
|
assert.deepEqual(preferences.events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
|
||||||
|
assert.deepEqual(preferences.events.find(e => e.eventKey === INVITATION)!.channels, { email: false, push: true })
|
||||||
|
await enable(a); await enable(b)
|
||||||
|
assert.deepEqual((await b.notifications.delivery(b.db).preferences(2, b.ctx.userId)).events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
|
||||||
|
})
|
||||||
|
await t.test("signed invitations notify only their named recipient, once despite redelivery", async () => {
|
||||||
|
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
|
||||||
|
const job = await jobFor(invitation.id)
|
||||||
|
await a.federation.deliver(job)
|
||||||
|
bindingId = (await b.federation.list(b.ctx)).invitations[0].id
|
||||||
|
await a.pool.query("UPDATE collaboration_outbox SET status='pending' WHERE id=$1", [job.id])
|
||||||
|
await a.federation.deliver(job)
|
||||||
|
const items = await b.items()
|
||||||
|
assert.equal(items.length, 2)
|
||||||
|
assert.deepEqual(items.map(i => i.channel).sort(), ["inapp", "push"])
|
||||||
|
assert.ok(items.every(i => i.user_id === b.ctx.userId && Number(i.tenant_id) === 1 && i.event_type === INVITATION))
|
||||||
|
assert.equal((await a.items()).length, 0)
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.equal(b.delivered.length, 2)
|
||||||
|
await b.federation.accept(b.ctx, bindingId)
|
||||||
|
assert.equal((await itemsFor(b, before.id)).length, 0)
|
||||||
|
})
|
||||||
|
await t.test("new remote messages notify once with the local room link, including simultaneous syncs", async () => {
|
||||||
|
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Hallo Beta" })
|
||||||
|
await Promise.all([b.federation.memberRequest(b.ctx, bindingId, "messages"), b.federation.memberRequest(b.ctx, bindingId, "messages")])
|
||||||
|
const items = await itemsFor(b, message.id)
|
||||||
|
assert.equal(items.length, 3)
|
||||||
|
assert.ok(items.every(i => i.message.includes("Hallo Beta") && i.payload.link === `/communication/chat?room=collaboration%3A${bindingId}`))
|
||||||
|
assert.equal((await itemsFor(a, message.id)).length, 0)
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "messages", { before: message.seq + 1 })
|
||||||
|
assert.equal((await itemsFor(b, message.id)).length, 3)
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 3)
|
||||||
|
})
|
||||||
|
await t.test("remote replies notify the owner's local members, never the sender or retry twice", async () => {
|
||||||
|
const input = { clientId: randomUUID(), text: "Hallo Alpha" }
|
||||||
|
const reply = (await b.federation.memberRequest(b.ctx, bindingId, "send", input)).result
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "send", input)
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||||
|
assert.equal((await itemsFor(a, reply.id)).length, 3)
|
||||||
|
assert.equal((await itemsFor(b, reply.id)).length, 0)
|
||||||
|
assert.ok((await itemsFor(a, reply.id)).every(i => Number(i.tenant_id) === 1 && i.user_id === a.ctx.userId))
|
||||||
|
await a.notifications.tick()
|
||||||
|
})
|
||||||
|
await t.test("reading before delivery suppresses email and push, and new preferences take effect", async () => {
|
||||||
|
const read = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Already read" })
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "read", { seq: read.seq })
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === read.id).map(i => i.channel), ["inapp"])
|
||||||
|
const muted = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Muted before delivery" })
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||||
|
await enable(b, 1, false)
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === muted.id).map(i => i.channel), ["inapp"])
|
||||||
|
await enable(b)
|
||||||
|
})
|
||||||
|
await t.test("offline jobs survive a worker restart and concurrent workers deliver each item once", async () => {
|
||||||
|
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Wait for access confirmation" })
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||||
|
offline = true
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
|
||||||
|
assert.ok((await itemsFor(b, message.id)).every(i => i.status === "queued"))
|
||||||
|
offline = false
|
||||||
|
const restarted = new CollaborationNotifications(b.app, b.service, b.federation)
|
||||||
|
restarted.delivery = b.notifications.delivery
|
||||||
|
await Promise.all([b.notifications.tick(), restarted.tick()])
|
||||||
|
const delivered = b.delivered.filter(i => i.payload.messageId === message.id)
|
||||||
|
assert.equal(delivered.length, 3)
|
||||||
|
assert.equal(new Set(delivered.map(i => i.id)).size, 3)
|
||||||
|
})
|
||||||
|
await t.test("revoked access cancels queued remote messages and reinvitation cannot replay old ones", async () => {
|
||||||
|
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Revoked before delivery" })
|
||||||
|
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||||
|
const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id)
|
||||||
|
await a.service.revoke(a.ctx, room.id, member.id)
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
|
||||||
|
assert.equal((await itemsFor(b, message.id)).length, 0)
|
||||||
|
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
|
||||||
|
await a.federation.deliver(await jobFor(invitation.id))
|
||||||
|
await b.federation.accept(b.ctx, bindingId)
|
||||||
|
await b.notifications.tick()
|
||||||
|
assert.equal((await itemsFor(b, message.id)).length, 0)
|
||||||
|
})
|
||||||
|
await t.test("local cross-tenant invitations and messages use recipient scope and honor expiry", async () => {
|
||||||
|
const local = await a.service.createRoom(a.ctx, { name: "Tenant exchange" })
|
||||||
|
const invitation = await a.service.invite(a.ctx, local.id, a.other.principal, {})
|
||||||
|
const invites = (await a.items()).filter(i => i.payload.invitationId === invitation.id)
|
||||||
|
assert.equal(invites.length, 2)
|
||||||
|
assert.ok(invites.every(i => Number(i.tenant_id) === 2))
|
||||||
|
await a.service.accept(a.other.principal, invitation.id)
|
||||||
|
const first = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Only tenant two" })
|
||||||
|
assert.deepEqual((await itemsFor(a, first.id)).map(i => [Number(i.tenant_id), i.channel]), [[2, "inapp"]])
|
||||||
|
await a.pool.query("UPDATE collaboration_members SET expires_at=now()-interval '1 second' WHERE room_id=$1 AND principal_id=$2", [local.id, a.other.principal.id])
|
||||||
|
await a.notifications.tick()
|
||||||
|
assert.equal(a.delivered.filter(i => i.payload.messageId === first.id).length, 0)
|
||||||
|
const second = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Expired member" })
|
||||||
|
assert.equal((await itemsFor(a, second.id)).length, 0)
|
||||||
|
})
|
||||||
|
await t.test("message and notification records roll back together", async () => {
|
||||||
|
const hook = a.notifications.localMessage.bind(a.notifications)
|
||||||
|
a.notifications.localMessage = async (...args) => { await hook(...args); throw new Error("Rollback after enqueue") }
|
||||||
|
const clientId = randomUUID(), count = (await a.items()).length
|
||||||
|
try {
|
||||||
|
await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", { actorId: b.ctx.principal.id, roomId: room.id,
|
||||||
|
input: { clientId, text: "Must roll back", revision: Number((await b.federation.binding(b.ctx, bindingId)).revision) } }), /Rollback/)
|
||||||
|
} finally { a.notifications.localMessage = hook }
|
||||||
|
assert.equal((await a.items()).length, count)
|
||||||
|
assert.equal((await a.pool.query("SELECT id FROM collaboration_messages WHERE client_id=$1", [clientId])).rowCount, 0)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await a.app.close(); await b.app.close()
|
||||||
|
await a.close(); await b.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -66,3 +66,27 @@ test("markiert Summenabweichungen als Prüfungsfehler", () => {
|
|||||||
|
|
||||||
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
|
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reads XRechnung cash discount terms from CII payment terms", async () => {
|
||||||
|
const xml = Buffer.from(ciiInvoice.toString().replace(
|
||||||
|
"<ram:SpecifiedTradePaymentTerms>",
|
||||||
|
"<ram:SpecifiedTradePaymentTerms><ram:Description>#SKONTO#TAGE=10#PROZENT=2.00#\n</ram:Description>",
|
||||||
|
))
|
||||||
|
const detection = await detectElectronicInvoice(xml, { name: "rechnung.xml" })
|
||||||
|
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
|
||||||
|
assert.deepEqual(parsed.cashDiscount, { percent: 2, days: 10 })
|
||||||
|
|
||||||
|
const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 1, 5, "xml", null)
|
||||||
|
assert.equal(mapped.cashDiscountPercent, 2)
|
||||||
|
assert.equal(mapped.cashDiscountDueDate, "2026-07-31")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads cash discount terms from UBL payment terms notes", async () => {
|
||||||
|
const xml = Buffer.from(ublInvoice.toString().replace(
|
||||||
|
"<cac:PaymentMeans>",
|
||||||
|
"<cac:PaymentTerms><cbc:Note>#SKONTO#TAGE=14#PROZENT=3.00#</cbc:Note></cac:PaymentTerms><cac:PaymentMeans>",
|
||||||
|
))
|
||||||
|
const detection = await detectElectronicInvoice(xml, { name: "xrechnung.xml" })
|
||||||
|
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
|
||||||
|
assert.deepEqual(parsed.cashDiscount, { percent: 3, days: 14 })
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
|
|||||||
const columns = Object.values(getTableColumns(table)).map((column: any) => {
|
const columns = Object.values(getTableColumns(table)).map((column: any) => {
|
||||||
let suffix = column.primary ? " PRIMARY KEY" : ""
|
let suffix = column.primary ? " PRIMARY KEY" : ""
|
||||||
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
|
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
|
||||||
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
|
if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()"
|
||||||
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
|
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
|
||||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||||
})
|
})
|
||||||
|
|||||||
48
backend/tests/incomingInvoiceCostCentres.test.ts
Normal file
48
backend/tests/incomingInvoiceCostCentres.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import {
|
||||||
|
applyAccountCostCentreAssignments,
|
||||||
|
normalizeAccountCostCentreAssignments,
|
||||||
|
} from "../src/modules/incoming-invoice-cost-centres.service"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
const CENTRE = "8f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"
|
||||||
|
|
||||||
|
test("changes only requested account cost centres", () => {
|
||||||
|
const accounts = [
|
||||||
|
{ account: 1, amountNet: 100, taxType: "19", costCentre: "old-1" },
|
||||||
|
{ account: 2, amountNet: 50, taxType: "7", costCentre: "old-2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(applyAccountCostCentreAssignments(accounts, [
|
||||||
|
{ index: 1, costcentre: CENTRE },
|
||||||
|
]), [
|
||||||
|
accounts[0],
|
||||||
|
{ account: 2, amountNet: 50, taxType: "7", costCentre: CENTRE },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects assignments for unknown invoice positions", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => applyAccountCostCentreAssignments([{ account: 1 }], [{ index: 3, costcentre: null }]),
|
||||||
|
/Position 4.*nicht gefunden/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates cost centre assignments", () => {
|
||||||
|
assert.throws(() => normalizeAccountCostCentreAssignments([]), /Mindestens eine/)
|
||||||
|
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: -1, costcentre: null }]), /gültigen index/)
|
||||||
|
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: "x" }]), /Ungültige Kostenstelle/)
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: null }, { index: 0, costcentre: CENTRE }]),
|
||||||
|
/mehrfach/,
|
||||||
|
)
|
||||||
|
assert.deepEqual(normalizeAccountCostCentreAssignments([{ index: 0, costcentre: CENTRE }]), [{ index: 0, costcentre: CENTRE }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("registers a dedicated incoming invoice cost centre tool", () => {
|
||||||
|
const tool = mcpToolMap.get("accounting.incoming_invoices.cost_centres.update")
|
||||||
|
|
||||||
|
assert.deepEqual(tool?.requiredPermissions, ["accounting.incoming_invoices.write"])
|
||||||
|
assert.deepEqual(tool?.inputSchema.required, ["id", "accountCostCentres"])
|
||||||
|
})
|
||||||
45
backend/tests/incomingInvoicePreparation.test.ts
Normal file
45
backend/tests/incomingInvoicePreparation.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
|
||||||
|
|
||||||
|
function server(tenantLookups: { count: number }) {
|
||||||
|
let release: () => void = () => undefined
|
||||||
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||||
|
const db: any = {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: async () => {
|
||||||
|
tenantLookups.count += 1
|
||||||
|
await gate
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
|
||||||
|
return { server: { db, log } as any, release: () => release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("start runs the preparation in the background and does not start a second run", async () => {
|
||||||
|
const lookups = { count: 0 }
|
||||||
|
const { server: srv, release } = server(lookups)
|
||||||
|
const service = prepareIncomingInvoices(srv)
|
||||||
|
|
||||||
|
const first = service.start(7)
|
||||||
|
const second = service.start(7)
|
||||||
|
assert.equal(first.running, true)
|
||||||
|
assert.equal(second.startedAt, first.startedAt)
|
||||||
|
assert.equal(service.status(8).running, false)
|
||||||
|
|
||||||
|
release()
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
|
||||||
|
const status = service.status(7)
|
||||||
|
assert.equal(lookups.count, 1)
|
||||||
|
assert.equal(status.running, false)
|
||||||
|
assert.ok(status.finishedAt)
|
||||||
|
assert.equal(status.result?.error, "Mandant nicht gefunden")
|
||||||
|
})
|
||||||
99
backend/tests/mcpArchiveRestore.test.ts
Normal file
99
backend/tests/mcpArchiveRestore.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { getTableColumns, getTableName } from "drizzle-orm"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
import * as schema from "../db/schema"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
import { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
|
||||||
|
import { asToolResult } from "../src/mcp/result"
|
||||||
|
|
||||||
|
const tenantId = 7
|
||||||
|
const userId = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const uuid = "00000000-0000-4000-8000-000000000002"
|
||||||
|
const dialect = new PgDialect()
|
||||||
|
|
||||||
|
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
|
||||||
|
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
|
||||||
|
const state = structuredClone(initial)
|
||||||
|
const rowsFor = (table: any) => state[getTableName(table)] ||= []
|
||||||
|
const matches = (table: any, where: any, row: any) => {
|
||||||
|
const query = dialect.sqlToQuery(where)
|
||||||
|
const columns = getTableColumns(table) as Record<string, any>
|
||||||
|
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
|
||||||
|
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
|
||||||
|
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
|
||||||
|
}
|
||||||
|
const listed: string[] = []
|
||||||
|
const db: any = {
|
||||||
|
transaction: async (callback: any) => callback(db),
|
||||||
|
select: () => ({ from: (table: any) => {
|
||||||
|
let condition: any
|
||||||
|
const query: any = {
|
||||||
|
where: (where: any) => { condition = where; return query },
|
||||||
|
orderBy: () => query,
|
||||||
|
offset: () => query,
|
||||||
|
limit: () => query,
|
||||||
|
for: () => query,
|
||||||
|
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
||||||
|
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
|
||||||
|
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
|
||||||
|
}).then(resolve, reject),
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
} }),
|
||||||
|
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
|
||||||
|
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
|
||||||
|
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
|
||||||
|
}) }) }),
|
||||||
|
}
|
||||||
|
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
|
||||||
|
return { context, rows: rowsFor, listed }
|
||||||
|
}
|
||||||
|
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
|
||||||
|
|
||||||
|
for (const [key, entity] of Object.entries(archiveEntities)) {
|
||||||
|
test(`${key}: restore unarchives within the tenant and records history`, async () => {
|
||||||
|
const id = entity.uuid ? uuid : 1
|
||||||
|
const f = fixture({
|
||||||
|
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
|
||||||
|
// Restoring documents requires an internal (non-portal) profile.
|
||||||
|
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
|
||||||
|
})
|
||||||
|
const result = await call(f.context, "restore", { entity: key, id })
|
||||||
|
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
|
||||||
|
assert.equal(result.record.archived, undefined)
|
||||||
|
const [row] = f.rows(entity.table)
|
||||||
|
assert.equal(row.archived, false)
|
||||||
|
assert.equal(row.updatedBy ?? row.updated_by, userId)
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
|
||||||
|
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
|
||||||
|
assert.equal(f.rows(schema.customers)[0].archived, true)
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
|
||||||
|
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
|
||||||
|
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
|
||||||
|
f.context.isAdmin = false
|
||||||
|
f.context.permissions = ["organisation.tasks.read"]
|
||||||
|
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
|
||||||
|
f.context.permissions.push("organisation.tasks.write")
|
||||||
|
await call(f.context, "restore", { entity: "tasks", id: 2 })
|
||||||
|
assert.equal(f.rows(schema.tasks)[0].archived, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("list returns archived rows visibly and requires the read permission", async () => {
|
||||||
|
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
|
||||||
|
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
|
||||||
|
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
|
||||||
|
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
|
||||||
|
f.context.isAdmin = false
|
||||||
|
f.context.permissions = []
|
||||||
|
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("restore is advertised as a write tool", () => {
|
||||||
|
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
|
||||||
|
})
|
||||||
@@ -151,6 +151,7 @@ test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without e
|
|||||||
return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] }
|
return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] }
|
||||||
}
|
}
|
||||||
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
||||||
|
if (sql.includes('from "auth_users"')) return { rows: [[admin]] }
|
||||||
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
||||||
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
|
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
|
||||||
if (sql.includes('from "auth_role_permissions"')) return { rows: [] }
|
if (sql.includes('from "auth_role_permissions"')) return { rows: [] }
|
||||||
|
|||||||
106
backend/tests/mcpFileUploads.test.ts
Normal file
106
backend/tests/mcpFileUploads.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||||
|
import mcpRoutes from "../src/routes/mcp"
|
||||||
|
import authPlugin from "../src/plugins/auth"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads"
|
||||||
|
import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
|
||||||
|
import { secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
const id = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
|
||||||
|
const bearer = "fedeo_mcp_test_upload_token"
|
||||||
|
const headers = { authorization: `Bearer ${bearer}` }
|
||||||
|
|
||||||
|
async function app(t: any) {
|
||||||
|
const uploads: any[] = []
|
||||||
|
const db = drizzle(async (sql, params) => {
|
||||||
|
if (sql.includes('from "m2m_api_keys"')) {
|
||||||
|
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
|
||||||
|
return { rows: [[id, 7, id, true, null, "test@example.invalid", true]] }
|
||||||
|
}
|
||||||
|
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
||||||
|
if (sql.includes('from "auth_users"')) return { rows: [[true]] }
|
||||||
|
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
||||||
|
return { rows: [] }
|
||||||
|
})
|
||||||
|
const server = Fastify()
|
||||||
|
t.after(() => server.close())
|
||||||
|
server.decorate("db", db as any)
|
||||||
|
await server.register(authPlugin)
|
||||||
|
await server.register(mcpRoutes, {
|
||||||
|
prefix: "/api",
|
||||||
|
storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } },
|
||||||
|
})
|
||||||
|
return { server, uploads }
|
||||||
|
}
|
||||||
|
|
||||||
|
const multipartBody = (filename: string, content: Buffer, field = "file") => {
|
||||||
|
const boundary = "----fedeo-test"
|
||||||
|
const payload = Buffer.concat([
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`),
|
||||||
|
content,
|
||||||
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||||
|
])
|
||||||
|
return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => {
|
||||||
|
const { server, uploads } = await app(t)
|
||||||
|
const multipart = multipartBody("rechnung.pdf", pdf)
|
||||||
|
const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart })
|
||||||
|
assert.equal(first.statusCode, 200, first.body)
|
||||||
|
assert.deepEqual(first.json(), { stored: true })
|
||||||
|
assert.equal(uploads[0].tenantId, 7)
|
||||||
|
assert.equal(uploads[0].target, "incoming_invoice")
|
||||||
|
assert.equal(uploads[0].filename, "rechnung.pdf")
|
||||||
|
assert.equal(uploads[0].invoiceId, 5)
|
||||||
|
assert.equal(uploads[0].prepare, false)
|
||||||
|
assert.deepEqual(uploads[0].body, pdf)
|
||||||
|
|
||||||
|
const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf })
|
||||||
|
assert.equal(raw.statusCode, 200, raw.body)
|
||||||
|
assert.equal(uploads[1].target, "inbox")
|
||||||
|
assert.equal(uploads[1].mimeType, "application/pdf")
|
||||||
|
assert.deepEqual(uploads[1].body, pdf)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => {
|
||||||
|
const { server, uploads } = await app(t)
|
||||||
|
const raw = { "content-type": "application/pdf" }
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "<p>" })).statusCode, 415)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
|
||||||
|
const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413)
|
||||||
|
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413)
|
||||||
|
assert.equal(uploads.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("query parsing keeps invoice options to the incoming invoice target", () => {
|
||||||
|
assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null })
|
||||||
|
assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/)
|
||||||
|
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/)
|
||||||
|
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stored uploads require the permission of the chosen target", async () => {
|
||||||
|
const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] }
|
||||||
|
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/)
|
||||||
|
await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/)
|
||||||
|
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("upload_url tool returns the absolute endpoint with examples", async (t) => {
|
||||||
|
const apiBase = secrets.API_BASE_URL
|
||||||
|
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
|
||||||
|
t.after(() => { secrets.API_BASE_URL = apiBase })
|
||||||
|
const result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {})
|
||||||
|
assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files")
|
||||||
|
assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/)
|
||||||
|
})
|
||||||
41
backend/tests/mcpIncomingInvoicePreparation.test.ts
Normal file
41
backend/tests/mcpIncomingInvoicePreparation.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
const fileA = "00000000-0000-4000-8000-00000000000a"
|
||||||
|
const fileB = "00000000-0000-4000-8000-00000000000b"
|
||||||
|
const tool = mcpToolMap.get("accounting.incoming_invoices.prepare")!
|
||||||
|
|
||||||
|
function context(result: any, invoices: any[] = []) {
|
||||||
|
const calls: any[] = []
|
||||||
|
const db: any = {
|
||||||
|
select: () => ({ from: () => ({ where: async () => invoices }) }),
|
||||||
|
}
|
||||||
|
const services = { prepareIncomingInvoices: { run: async (tenantId: number, options: any) => { calls.push({ tenantId, options }); return result } } }
|
||||||
|
return { calls, context: { server: { db, services }, tenantId: 7, userId: "user", isAdmin: true, permissions: [] } as any }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prepare runs for the active tenant and returns created drafts with validation", async () => {
|
||||||
|
const invoice = { id: 11, tenant: 7, state: "Entwurf", archived: false }
|
||||||
|
const { calls, context: ctx } = context({ filesFound: 1, created: [{ fileId: fileA, invoiceId: 11, source: "gpt" }], skipped: [] }, [invoice])
|
||||||
|
const result: any = await tool.handler(ctx, {})
|
||||||
|
assert.deepEqual(calls, [{ tenantId: 7, options: { fileIds: undefined } }])
|
||||||
|
assert.equal(result.created[0].invoice.id, 11)
|
||||||
|
assert.ok(result.created[0].validation)
|
||||||
|
assert.deepEqual(result.skipped, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepare limits to given files and reports files it could not handle", async () => {
|
||||||
|
const { calls, context: ctx } = context({ filesFound: 1, created: [], skipped: [{ fileId: fileA, reason: "Keine Belegdaten erkannt" }] })
|
||||||
|
const result: any = await tool.handler(ctx, { fileIds: [fileA, fileB.toUpperCase(), fileB] })
|
||||||
|
assert.deepEqual(calls[0].options.fileIds, [fileA, fileB])
|
||||||
|
assert.deepEqual(result.skipped.map((entry: any) => entry.fileId), [fileA, fileB])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepare validates input and surfaces tenant configuration errors", async () => {
|
||||||
|
const { context: ctx } = context({ filesFound: 0, created: [], skipped: [], error: "Dateityp für Eingangsrechnungen fehlt" })
|
||||||
|
await assert.rejects(tool.handler(ctx, { fileIds: [] }), /1 bis 50/)
|
||||||
|
await assert.rejects(tool.handler(ctx, { fileIds: ["abc"] }), /UUID/)
|
||||||
|
await assert.rejects(tool.handler(ctx, {}), /Dateityp für Eingangsrechnungen fehlt/)
|
||||||
|
assert.deepEqual(tool.requiredPermissions, ["accounting.incoming_invoices.write"])
|
||||||
|
})
|
||||||
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
import { PDFDocument } from "pdf-lib"
|
||||||
|
import { initS3, s3 } from "../src/utils/s3"
|
||||||
|
import { createInvoicePDF, InvoicePdfLayout } from "../src/utils/pdf"
|
||||||
|
import { buildOutgoingDocumentPdfData, calculateOutgoingDocumentTotals } from "../src/modules/outgoing-document-pdf.service"
|
||||||
|
import { analyzeOutgoingDocumentLayout } from "../src/mcp/outgoingDocumentLayout"
|
||||||
|
import { renderPdfPagesAsPng } from "../src/utils/pdfPreview"
|
||||||
|
import { asToolResult, withImages } from "../src/mcp/result"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
const hasPdftoppm = (() => { try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true } catch { return false } })()
|
||||||
|
|
||||||
|
const document = (rows: any[]) => ({
|
||||||
|
id: 1, type: "invoices", taxType: "Standard", state: "Entwurf", title: "Rechnung-Nr. XXXX", documentDate: "2026-10-28",
|
||||||
|
deliveryDateType: "Lieferdatum", deliveryDate: "2026-10-27", address: { street: "Weg 1", zip: "12345", city: "Ort" },
|
||||||
|
startText: "Hallo {{vorname}},", endText: "Zahlbar in {{zahlungsziel_in_tagen}} Tagen.", paymentDays: 14, payment_type: "transfer",
|
||||||
|
usedAdvanceInvoices: [], report: {}, rows,
|
||||||
|
})
|
||||||
|
const context = {
|
||||||
|
tenant: { name: "Firma", businessInfo: { name: "Firma", street: "Str. 1", zip: "1", city: "Stadt", iban: "DE02120300000000202051" }, extraModules: [] },
|
||||||
|
customer: { name: "Kunde", customerNumber: "K-1", firstname: "Ada" },
|
||||||
|
contactPerson: { full_name: "Bea Beispiel", email: "bea@example.invalid" },
|
||||||
|
units: [{ id: 1, short: "Stk" }], products: [{ id: 3, name: "Kabel" }], services: [], bankAccounts: [], advanceInvoices: [],
|
||||||
|
}
|
||||||
|
const positions = (count: number) => Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: `r${index}`, pos: String(index + 1), mode: index % 2 ? "normal" : "free", product: 3, text: `Position ${index + 1}`,
|
||||||
|
description: "Zweizeilige\nBeschreibung", quantity: 2, unit: 1, price: 10.5, taxPercent: 19, discountPercent: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("pdf data matches the editor: title sums, transfers, optional rows and handlebars", () => {
|
||||||
|
const rows = [
|
||||||
|
{ pos: "1", mode: "title", text: "Bau", titleLevel: 1 },
|
||||||
|
{ pos: "1.1", mode: "free", text: "A", quantity: 2, price: 10, discountPercent: 0, taxPercent: 19 },
|
||||||
|
{ pos: "1.2", mode: "free", text: "B", quantity: 1, price: 5, discountPercent: 0, taxPercent: 19, optional: true },
|
||||||
|
{ mode: "pagebreak" },
|
||||||
|
{ pos: "2", mode: "title", text: "Elektro", titleLevel: 1 },
|
||||||
|
{ pos: "2.1", mode: "normal", product: 3, quantity: 1, price: 100, discountPercent: 10, taxPercent: 7, unit: 1 },
|
||||||
|
]
|
||||||
|
const totals = calculateOutgoingDocumentTotals({ type: "invoices", rows }, [])
|
||||||
|
assert.equal(totals.totalNet, 110)
|
||||||
|
assert.deepEqual(totals.titleSums, { "1 - Bau": 20, "2 - Elektro": 90 })
|
||||||
|
assert.deepEqual(totals.titleSumsTransfer, { "2 - Elektro": 20 })
|
||||||
|
const data: any = buildOutgoingDocumentPdfData(document(rows), context)
|
||||||
|
assert.equal(data.startText, "Hallo Ada,")
|
||||||
|
assert.equal(data.endText, "Zahlbar in 14 Tagen.")
|
||||||
|
assert.equal(data.rows[5].text, "Kabel")
|
||||||
|
assert.equal(data.rows[5].unit, "Stk")
|
||||||
|
assert.equal(data.total.titleSumsTransfer["2 - Elektro"], "20,00 €")
|
||||||
|
assert.deepEqual(data.totalArray.map((item: any) => item.label), ["Nettobetrag", "zzgl. 19% USt auf 20,00 €", "zzgl. 7% USt auf 90,00 €", "Gesamtbetrag"])
|
||||||
|
assert.equal(data.info[0].label, "Rechnungsnummer")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("layout analysis flags overflowing rows and suggests a page break that keeps titles", () => {
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 297, pageCount: 1, rows: [
|
||||||
|
{ index: 0, pos: "1", mode: "free", text: "A", page: 1, topMm: 200, bottomMm: 240 },
|
||||||
|
{ index: 1, pos: "2", mode: "title", text: "T", page: 1, topMm: 240, bottomMm: 250 },
|
||||||
|
{ index: 2, pos: "2.1", mode: "free", text: "B", page: 1, topMm: 250, bottomMm: 270 },
|
||||||
|
], totals: { page: 1, topMm: 270, bottomMm: 300 } }
|
||||||
|
const { warnings, layout: summary } = analyzeOutgoingDocumentLayout(layout, 262)
|
||||||
|
assert.equal(warnings.length, 1)
|
||||||
|
assert.equal(warnings[0].insertPagebreakBeforeIndex, 1)
|
||||||
|
assert.equal(summary.pages[0].freeMm, -38)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rendered preview records rows per page and rasterizes pages", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
|
||||||
|
const letterhead = await PDFDocument.create()
|
||||||
|
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
|
||||||
|
const background = Buffer.from(await letterhead.save())
|
||||||
|
await initS3()
|
||||||
|
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
|
||||||
|
|
||||||
|
const rows: any[] = positions(30)
|
||||||
|
rows.splice(12, 0, { mode: "pagebreak" })
|
||||||
|
const data = buildOutgoingDocumentPdfData(document(rows), context)
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
||||||
|
const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout)
|
||||||
|
// Rows overflow page 2, so the payment QR code gets an extra third page.
|
||||||
|
assert.equal(layout.pageCount, 3)
|
||||||
|
assert.equal(layout.paymentQr!.page, 3)
|
||||||
|
assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2)
|
||||||
|
assert.ok(layout.endText && layout.totals)
|
||||||
|
const analysis = analyzeOutgoingDocumentLayout(layout)
|
||||||
|
assert.ok(analysis.warnings.some((warning) => warning.type === "overflow" && warning.page === 2), JSON.stringify(analysis.warnings))
|
||||||
|
|
||||||
|
const images = await renderPdfPagesAsPng(Buffer.from(pdf.base64, "base64"), [1, 2], 50)
|
||||||
|
assert.deepEqual(images.map((image) => image.page), [1, 2])
|
||||||
|
assert.ok(images[0].png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
||||||
|
const result = asToolResult(withImages({ pageCount: 2 }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" }))))
|
||||||
|
assert.deepEqual(result.content.map((item) => item.type), ["text", "image", "image"])
|
||||||
|
assert.deepEqual(result.structuredContent, { pageCount: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preview tool is read-only for outgoing documents", () => {
|
||||||
|
assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
|
||||||
|
const letterhead = await PDFDocument.create()
|
||||||
|
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
|
||||||
|
const background = Buffer.from(await letterhead.save())
|
||||||
|
await initS3()
|
||||||
|
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
|
||||||
|
const render = async (count: number) => {
|
||||||
|
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
||||||
|
await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout)
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
const short = await render(3)
|
||||||
|
assert.equal(short.pageCount, 1)
|
||||||
|
assert.equal(short.paymentQr!.page, 1)
|
||||||
|
assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm)
|
||||||
|
|
||||||
|
const long = await render(6)
|
||||||
|
assert.equal(long.pageCount, 2)
|
||||||
|
assert.equal(long.paymentQr!.page, 2)
|
||||||
|
assert.ok(long.rows.every((row) => row.page === 1))
|
||||||
|
const analysis = analyzeOutgoingDocumentLayout(long)
|
||||||
|
const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage")
|
||||||
|
assert.ok(warning, JSON.stringify(analysis.warnings))
|
||||||
|
assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage"))
|
||||||
|
})
|
||||||
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal file
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { getTableName } from "drizzle-orm"
|
||||||
|
import { applyOutgoingRowCatalogDefaults } from "../src/mcp/outgoingRowDefaults"
|
||||||
|
|
||||||
|
const catalog: Record<string, any[]> = {
|
||||||
|
products: [{ id: 1, name: "Kabel", description: "NYM-J 3x1,5", unit: 2, selling_price: 1.25, tax_percentage: 19 }],
|
||||||
|
services: [{ id: 5, name: "Montage", description: "Montage vor Ort", unit: 3, sellingPrice: 50, sellingPriceComposed: { total: 55 }, taxPercentage: 7, costcentre: "cc" }],
|
||||||
|
}
|
||||||
|
const context: any = { tenantId: 7, server: { db: { select: () => ({ from: (table: any) => ({ where: async () => catalog[getTableName(table)] }) }) } } }
|
||||||
|
|
||||||
|
test("selected products and services bring their description and editor defaults", async () => {
|
||||||
|
const rows: any = await applyOutgoingRowCatalogDefaults(context, [
|
||||||
|
{ mode: "normal", product: 1, quantity: 3 },
|
||||||
|
JSON.stringify({ mode: "service", service: 5 }),
|
||||||
|
{ mode: "free", text: "Frei", descriptionText: "Alt" },
|
||||||
|
])
|
||||||
|
assert.deepEqual(rows[0], { mode: "normal", product: 1, quantity: 3, text: "Kabel", description: "NYM-J 3x1,5", unit: 2, inputPrice: 1.25, price: 1.25, taxPercent: 19 })
|
||||||
|
assert.equal(rows[1].description, "Montage vor Ort")
|
||||||
|
assert.equal(rows[1].price, 55)
|
||||||
|
assert.equal(rows[1].costCentre, "cc")
|
||||||
|
assert.equal(rows[2].description, "Alt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caller values win and unknown catalog entries are rejected", async () => {
|
||||||
|
const [row]: any = await applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 1, description: "Eigene", price: 2, taxPercent: 7 }])
|
||||||
|
assert.equal(row.description, "Eigene")
|
||||||
|
assert.equal(row.price, 2)
|
||||||
|
assert.equal(row.taxPercent, 7)
|
||||||
|
await assert.rejects(applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 9 }]), /Artikel 9 nicht gefunden/)
|
||||||
|
})
|
||||||
@@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
|
|||||||
return values
|
return values
|
||||||
}) }
|
}) }
|
||||||
}, { schema })
|
}, { schema })
|
||||||
const context = { server: { db }, tenantId: 7, userId: "test" } as any
|
const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any
|
||||||
|
const properties = tool.inputSchema.properties as any
|
||||||
const ids: unknown[] = []
|
const ids: unknown[] = []
|
||||||
let offset = 0
|
let offset = 0
|
||||||
for (let page = 0; page < 3; page++) {
|
for (let page = 0; page < 3; page++) {
|
||||||
const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset })
|
const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset })
|
||||||
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
||||||
assert.equal(result.pagination.hasMore, page < 2)
|
assert.equal(result.pagination.hasMore, page < 2)
|
||||||
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||||
|
|||||||
233
backend/tests/userArchive.test.ts
Normal file
233
backend/tests/userArchive.test.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import cookie from "@fastify/cookie"
|
||||||
|
import bcrypt from "bcrypt"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import authRoutes from "../src/routes/auth/auth"
|
||||||
|
import userRoutes from "../src/routes/auth/user"
|
||||||
|
import authPlugin from "../src/plugins/auth"
|
||||||
|
import m2mAuthPlugin from "../src/plugins/auth.m2m"
|
||||||
|
import tenantRoutes from "../src/routes/tenant"
|
||||||
|
import internalTenantRoutes from "../src/routes/internal/tenant"
|
||||||
|
import profileRoutes from "../src/routes/profiles"
|
||||||
|
import resourceRoutes from "../src/routes/resources/main"
|
||||||
|
import { secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
const adminId = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const userId = "00000000-0000-4000-8000-000000000002"
|
||||||
|
const profileId = "00000000-0000-4000-8000-000000000003"
|
||||||
|
const otherProfileId = "00000000-0000-4000-8000-000000000004"
|
||||||
|
const passwordHash = bcrypt.hashSync("archive-test-password", 4)
|
||||||
|
type Row = Record<string, any>
|
||||||
|
|
||||||
|
// Execute the generated SQL against a small fixture, including its archive filters.
|
||||||
|
// This keeps the HTTP/authentication tests independent of a configured production DB.
|
||||||
|
function matches(where: string, params: any[], row: Row) {
|
||||||
|
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" = \$(\d+)/g)) {
|
||||||
|
if (String(row[match[1]]) !== String(params[Number(match[2]) - 1])) return false
|
||||||
|
}
|
||||||
|
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" is null/g)) {
|
||||||
|
if (row[match[1]] != null) return false
|
||||||
|
}
|
||||||
|
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" in \(([^)]+)\)/g)) {
|
||||||
|
const values = [...match[2].matchAll(/\$(\d+)/g)].map(value => params[Number(value[1]) - 1])
|
||||||
|
if (!values.includes(row[match[1]])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected(columns: string, row: Row) {
|
||||||
|
return columns.split(", ").map(column => {
|
||||||
|
const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1]
|
||||||
|
return row[name!] ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(t: any) {
|
||||||
|
const data: Record<string, Row[]> = {
|
||||||
|
auth_users: [
|
||||||
|
{ id: adminId, email: "admin@example.test", is_admin: true, archived_at: null },
|
||||||
|
{ id: userId, email: "former@example.test", password_hash: passwordHash, is_admin: false, archived_at: null },
|
||||||
|
],
|
||||||
|
auth_profiles: [
|
||||||
|
{ id: profileId, user_id: userId, tenant_id: 7, active: true, archived_at: null, first_name: "Former", last_name: "User", full_name: "Former User", token_id: "rfid", calendar_subscription_token: "calendar" },
|
||||||
|
{ id: otherProfileId, user_id: userId, tenant_id: 8, active: false, archived_at: null },
|
||||||
|
{ id: adminId, user_id: adminId, tenant_id: 7, active: true, archived_at: null },
|
||||||
|
],
|
||||||
|
auth_tenant_users: [{ tenant_id: 7, user_id: userId }, { tenant_id: 8, user_id: userId }, { tenant_id: 7, user_id: adminId }],
|
||||||
|
auth_refresh_tokens: [{ id: userId, user_id: userId, tenant_id: 7, token_hash: "old", revoked_at: null }],
|
||||||
|
m2m_api_keys: [{ id: userId, user_id: userId, tenant_id: 7, active: true, key_hash: createHash("sha256").update("fedeo_mcp_archive_test").digest("hex") }],
|
||||||
|
tenants: [{ id: 7, name: "First" }, { id: 8, name: "Second" }],
|
||||||
|
}
|
||||||
|
const queries: string[] = []
|
||||||
|
let inTransaction = false
|
||||||
|
const db = drizzle(async (sql, params) => {
|
||||||
|
queries.push(sql)
|
||||||
|
if (sql.startsWith("select ")) {
|
||||||
|
const table = sql.match(/from "([^"]+)"/)![1]
|
||||||
|
const source = table === "m2m_api_keys"
|
||||||
|
? data[table].map(row => ({ ...row, ...data.auth_users.find(user => user.id === row.user_id) }))
|
||||||
|
: data[table] || []
|
||||||
|
const rows = source.filter(row => matches(sql.split(" where ")[1] || "", params, row))
|
||||||
|
if (sql.startsWith("select count(")) return { rows: [[rows.length]] }
|
||||||
|
return { rows: rows.map(row => selected(sql.slice(7, sql.indexOf(" from ")), row)) }
|
||||||
|
}
|
||||||
|
if (sql.startsWith("update ")) {
|
||||||
|
const table = sql.match(/update "([^"]+)"/)![1]
|
||||||
|
const rows = (data[table] || []).filter(row => matches(sql.split(" where ")[1] || "", params, row))
|
||||||
|
if (sql.includes('"archived_at" =')) assert.ok(inTransaction, "Archiving must be atomic")
|
||||||
|
const assignments = sql.slice(sql.indexOf(" set "), sql.indexOf(" where "))
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const match of assignments.matchAll(/"(\w+)" = \$(\d+)/g)) row[match[1]] = params[Number(match[2]) - 1]
|
||||||
|
}
|
||||||
|
return { rows: sql.includes(" returning ") ? rows.map(row => selected(sql.split(" returning ")[1], row)) : [] }
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected mutation: ${sql}`)
|
||||||
|
})
|
||||||
|
;(db as any).transaction = async (fn: any) => {
|
||||||
|
inTransaction = true
|
||||||
|
try { return await fn(db) } finally { inTransaction = false }
|
||||||
|
}
|
||||||
|
const app = Fastify()
|
||||||
|
app.decorate("db", db as any)
|
||||||
|
app.addHook("preHandler", async (req: any) => {
|
||||||
|
req.user = { user_id: req.headers["x-test-user"] || adminId, tenant_id: 7 }
|
||||||
|
req.queryConfig = { pagination: { limit: 100, offset: 0 }, filters: {}, sort: [] }
|
||||||
|
})
|
||||||
|
// adminRoutes imports the export utility, whose legacy DB module probes on import.
|
||||||
|
const queryMock = t.mock.method(Pool.prototype, "query", async () => ({ rows: [{ now: "test" }] }))
|
||||||
|
const { default: adminRoutes } = await import("../src/routes/admin")
|
||||||
|
queryMock.mock.restore()
|
||||||
|
await app.register(adminRoutes)
|
||||||
|
await app.register(tenantRoutes)
|
||||||
|
await app.register(profileRoutes)
|
||||||
|
await app.register(userRoutes)
|
||||||
|
await app.register(resourceRoutes)
|
||||||
|
await app.register(internalTenantRoutes, { prefix: "/internal" })
|
||||||
|
t.after(() => app.close())
|
||||||
|
const archive = (id = userId, caller = adminId) => app.inject({ method: "POST", url: `/admin/users/${id}/archive`, headers: { "x-test-user": caller } })
|
||||||
|
return { app, db, data, queries, archive }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("archiving preserves history, archives every tenant profile and revokes credentials atomically", async t => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const memberships = structuredClone(f.data.auth_tenant_users)
|
||||||
|
const response = await f.archive()
|
||||||
|
assert.equal(response.statusCode, 200, response.body)
|
||||||
|
const archivedAt = response.json().user.archived_at
|
||||||
|
assert.ok(archivedAt)
|
||||||
|
for (const profile of f.data.auth_profiles.filter(profile => profile.user_id === userId)) {
|
||||||
|
assert.equal(profile.active, false)
|
||||||
|
assert.equal(profile.archived_at, archivedAt)
|
||||||
|
assert.equal(profile.token_id, null)
|
||||||
|
assert.equal(profile.calendar_subscription_token, null)
|
||||||
|
}
|
||||||
|
assert.equal(f.data.auth_profiles[2].active, true)
|
||||||
|
assert.equal(f.data.auth_users[0].archived_at, null)
|
||||||
|
assert.equal(f.data.auth_refresh_tokens[0].revoked_at, archivedAt)
|
||||||
|
assert.equal(f.data.m2m_api_keys[0].active, false)
|
||||||
|
assert.deepEqual(f.data.auth_tenant_users, memberships)
|
||||||
|
assert.equal((await f.archive()).json().user.archived_at, archivedAt)
|
||||||
|
assert.ok(f.queries.some(sql => sql.endsWith("for update")))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only an active system admin can archive another existing user", async t => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
assert.equal((await f.archive(adminId, userId)).statusCode, 403)
|
||||||
|
assert.equal((await f.archive(adminId)).statusCode, 409)
|
||||||
|
assert.equal((await f.archive(profileId)).statusCode, 404)
|
||||||
|
assert.equal((await f.archive("invalid-id")).statusCode, 400)
|
||||||
|
f.data.auth_users[0].archived_at = new Date().toISOString()
|
||||||
|
assert.equal((await f.archive()).statusCode, 403)
|
||||||
|
assert.equal(f.data.auth_users[1].archived_at, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("archived users disappear from active administration and tenant lists but retain a readable archive", async t => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.archive()
|
||||||
|
const overview = (await f.app.inject("/admin/overview")).json()
|
||||||
|
assert.deepEqual(overview.users.map((row: Row) => row.id), [adminId])
|
||||||
|
assert.equal(overview.tenants.find((row: Row) => row.id === 7).user_count, 1)
|
||||||
|
const archived = (await f.app.inject("/admin/overview?includeArchived=true")).json().users.find((row: Row) => row.id === userId)
|
||||||
|
assert.ok(archived.archived_at)
|
||||||
|
assert.equal(archived.profiles.length, 2)
|
||||||
|
for (const url of ["/tenant/users", "/internal/tenant/users"]) {
|
||||||
|
const response = await f.app.inject(url)
|
||||||
|
assert.equal(response.statusCode, 200, response.body)
|
||||||
|
assert.deepEqual(response.json().users.map((row: Row) => row.id), [adminId])
|
||||||
|
}
|
||||||
|
for (const url of ["/tenant/profiles", "/internal/tenant/7/profiles", "/resource/profiles", "/resource/authProfile", "/resource/profiles/paginated"]) {
|
||||||
|
const response = await f.app.inject(url)
|
||||||
|
assert.equal(response.statusCode, 200, response.body)
|
||||||
|
const body = response.json()
|
||||||
|
assert.deepEqual((body.data || body).map((row: Row) => row.id), [adminId], url)
|
||||||
|
}
|
||||||
|
const historic = (await f.app.inject("/tenant/profiles?includeArchived=true")).json().data
|
||||||
|
assert.equal(historic.find((row: Row) => row.id === profileId).full_name, "Former User")
|
||||||
|
const detail = await f.app.inject(`/resource/profiles/${profileId}`)
|
||||||
|
assert.equal(detail.statusCode, 200, detail.body)
|
||||||
|
assert.equal(detail.json().full_name, "Former User")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("archived accounts and profiles cannot be edited, reassigned or reactivated", async t => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.archive()
|
||||||
|
for (const [method, url, payload, status] of [
|
||||||
|
["PUT", `/admin/users/${userId}`, { archived_at: null, is_admin: true }, 409],
|
||||||
|
["PUT", `/admin/users/${userId}/access`, { tenant_ids: [9] }, 409],
|
||||||
|
["POST", `/admin/users/${userId}/reset-password`, {}, 409],
|
||||||
|
["POST", "/admin/add-user-to-tenant", { user_id: userId, tenant_id: 9 }, 404],
|
||||||
|
["PUT", `/user/${userId}/profile`, { data: { active: true, archived_at: null, user_id: adminId } }, 404],
|
||||||
|
["PUT", `/profiles/${profileId}`, { active: true, archived_at: null }, 404],
|
||||||
|
["PUT", `/resource/profiles/${profileId}`, { active: true, archived_at: null, user_id: adminId }, 409],
|
||||||
|
["POST", `/profiles/${profileId}/calendar-subscription-token`, {}, 404],
|
||||||
|
["POST", "/resource/profiles", { user_id: userId, first_name: "New", last_name: "Profile" }, 409],
|
||||||
|
] as const) {
|
||||||
|
const response = await f.app.inject({ method, url, payload })
|
||||||
|
assert.equal(response.statusCode, status, `${url}: ${response.body}`)
|
||||||
|
}
|
||||||
|
assert.equal(f.data.auth_profiles[0].active, false)
|
||||||
|
assert.equal(f.data.auth_profiles[0].user_id, userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("archiving blocks password login, refresh, existing JWTs and legacy API keys", async t => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const oldSecret = secrets.JWT_SECRET, oldM2mKey = secrets.M2M_API_KEY
|
||||||
|
secrets.JWT_SECRET = "archive-test-secret"
|
||||||
|
secrets.M2M_API_KEY = "archive-test-legacy-key"
|
||||||
|
t.after(() => { secrets.JWT_SECRET = oldSecret; secrets.M2M_API_KEY = oldM2mKey })
|
||||||
|
const app = Fastify()
|
||||||
|
app.decorate("db", f.db as any)
|
||||||
|
await app.register(cookie)
|
||||||
|
await app.register(authRoutes)
|
||||||
|
await app.register(async protectedApp => {
|
||||||
|
await protectedApp.register(authPlugin)
|
||||||
|
protectedApp.get("/check", async () => ({ success: true }))
|
||||||
|
protectedApp.get("/mcp", async () => ({ success: true }))
|
||||||
|
}, { prefix: "/api" })
|
||||||
|
await app.register(async internal => {
|
||||||
|
await internal.register(m2mAuthPlugin)
|
||||||
|
internal.get("/check", async () => ({ success: true }))
|
||||||
|
}, { prefix: "/internal" })
|
||||||
|
t.after(() => app.close())
|
||||||
|
const token = jwt.sign({ user_id: userId, tenant_id: null, is_admin: true }, secrets.JWT_SECRET)
|
||||||
|
const headers = { authorization: `Bearer ${token}` }
|
||||||
|
assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 200)
|
||||||
|
await f.archive()
|
||||||
|
assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 401)
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/auth/login", payload: { email: "former@example.test", password: "archive-test-password" } })).statusCode, 401)
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/auth/password/reset", payload: { email: "former@example.test" } })).statusCode, 404)
|
||||||
|
// Even a session issued concurrently with archiving must fail the current-user check.
|
||||||
|
f.data.auth_refresh_tokens[0].revoked_at = null
|
||||||
|
const { hashRefreshToken } = await import("../src/utils/authTokens")
|
||||||
|
f.data.auth_refresh_tokens[0].token_hash = hashRefreshToken("old-refresh-token")
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: "/auth/refresh", payload: { refreshToken: "old-refresh-token" } })).statusCode, 401)
|
||||||
|
f.data.m2m_api_keys[0].active = true
|
||||||
|
assert.equal((await app.inject({ url: "/api/mcp", headers: { authorization: "Bearer fedeo_mcp_archive_test" } })).statusCode, 401)
|
||||||
|
assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": "fedeo_mcp_archive_test" } })).statusCode, 401)
|
||||||
|
assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": secrets.M2M_API_KEY, "x-user-id": userId, "x-tenant-id": "7" } })).statusCode, 401)
|
||||||
|
})
|
||||||
209
backend/tests/zugferdInvoice.test.ts
Normal file
209
backend/tests/zugferdInvoice.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { PDFDocument, StandardFonts } from "pdf-lib"
|
||||||
|
import { buildZugferdInvoiceXml, checkZugferdRequirements, countryCode, unitCode, ZugferdInvoiceContext } from "../src/modules/einvoice/buildZugferdInvoice"
|
||||||
|
import { embedZugferdXml } from "../src/modules/einvoice/embedZugferd"
|
||||||
|
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
|
||||||
|
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
|
||||||
|
|
||||||
|
const context = (overrides: Partial<ZugferdInvoiceContext> = {}): ZugferdInvoiceContext => ({
|
||||||
|
tenant: {
|
||||||
|
name: "Muster GmbH",
|
||||||
|
creditorId: "DE98ZZZ09999999999",
|
||||||
|
businessInfo: {
|
||||||
|
name: "Muster GmbH", street: "Hauptstraße 1", zip: "12345", city: "Musterstadt",
|
||||||
|
vatId: "DE123456789", taxNumber: "12/345/67890", email: "rechnung@muster.de",
|
||||||
|
iban: "DE02120300000000202051", bic: "BYLADEM1001", bankAccountOwner: "Muster GmbH",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Kunde & Söhne KG", customerNumber: "10001",
|
||||||
|
infoData: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt", country: "Deutschland", ustid: "DE987654321", invoiceEmail: "buchhaltung@kunde.de" },
|
||||||
|
},
|
||||||
|
contactPerson: { full_name: "Erika Muster", email: "erika@muster.de" },
|
||||||
|
units: [{ id: 1, short: "Stk" }, { id: 6, short: "Std" }],
|
||||||
|
products: [{ id: 7, name: "Kabel NYM 3x1,5", article_number: "A-7" }],
|
||||||
|
services: [{ id: 3, name: "Montage" }],
|
||||||
|
bankAccounts: [],
|
||||||
|
advanceInvoices: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const invoice = (overrides: any = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
type: "invoices",
|
||||||
|
customer: 5,
|
||||||
|
documentNumber: "RE-2026-0042",
|
||||||
|
documentDate: "2026-11-04",
|
||||||
|
deliveryDateType: "Lieferdatum",
|
||||||
|
deliveryDate: "2026-11-02",
|
||||||
|
paymentDays: 14,
|
||||||
|
payment_type: "transfer",
|
||||||
|
taxType: "Standard",
|
||||||
|
title: "Rechnung-Nr. RE-2026-0042",
|
||||||
|
address: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt" },
|
||||||
|
rows: [
|
||||||
|
{ pos: "1", mode: "normal", product: 7, quantity: 10, unit: 1, price: 2.345, discountPercent: 0, taxPercent: 19 },
|
||||||
|
{ pos: "2", mode: "service", service: 3, quantity: 1.5, unit: 6, price: 60, discountPercent: 10, taxPercent: 19 },
|
||||||
|
{ pos: "3", mode: "free", text: "Fachbuch", quantity: 1, unit: 1, price: 20, discountPercent: 0, taxPercent: 7 },
|
||||||
|
{ mode: "title", text: "Optionales" },
|
||||||
|
{ pos: "4", mode: "free", text: "Optional", quantity: 1, unit: 1, price: 999, discountPercent: 0, taxPercent: 19, optional: true },
|
||||||
|
{ pos: "5", mode: "free", text: "Gutschrift Altmaterial", quantity: 1, unit: 1, price: -5, discountPercent: 0, taxPercent: 19 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`<ram:${name}(?:\\s[^>]*)?>([^<]*)</ram:${name}>`, "g"))].map((match) => match[1])
|
||||||
|
|
||||||
|
test("erzeugt eine EN-16931-Rechnung mit konsistenten Summen", () => {
|
||||||
|
const { xml, issues, totals } = buildZugferdInvoiceXml(invoice(), context())
|
||||||
|
|
||||||
|
assert.deepEqual(issues, [])
|
||||||
|
// 23,45 + 81,00 + 20,00 - 5,00 (optionale Position ausgeschlossen)
|
||||||
|
assert.equal(totals.lineNet, 119.45)
|
||||||
|
// 19 %: 99,45 -> 18,90; 7 %: 20,00 -> 1,40
|
||||||
|
assert.equal(totals.tax, 20.3)
|
||||||
|
assert.equal(totals.gross, 139.75)
|
||||||
|
assert.equal(totals.payable, 139.75)
|
||||||
|
assert.match(xml, /<ram:ID>urn:cen.eu:en16931:2017<\/ram:ID>/)
|
||||||
|
assert.match(xml, /<ram:TypeCode>380<\/ram:TypeCode>/)
|
||||||
|
assert.match(xml, /Kunde & Söhne KG/)
|
||||||
|
assert.match(xml, /<ram:IBANID>DE02120300000000202051<\/ram:IBANID>/)
|
||||||
|
assert.match(xml, /<ram:ID schemeID="VA">DE987654321<\/ram:ID>/)
|
||||||
|
assert.match(xml, /<ram:SellerAssignedID>A-7<\/ram:SellerAssignedID>/)
|
||||||
|
assert.match(xml, /<ram:DueDateDateTime><udt:DateTimeString format="102">20261118<\/udt:DateTimeString>/)
|
||||||
|
assert.match(xml, /<ram:OccurrenceDateTime><udt:DateTimeString format="102">20261102/)
|
||||||
|
// Negative Preise werden als negative Menge mit positivem Nettopreis abgebildet (BR-27).
|
||||||
|
assert.match(xml, /<ram:ChargeAmount>5<\/ram:ChargeAmount><\/ram:NetPriceProductTradePrice><\/ram:SpecifiedLineTradeAgreement><ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="H87">-1</)
|
||||||
|
assert.ok(tag(xml, "ChargeAmount").every((value) => Number(value) >= 0))
|
||||||
|
// vier Positionen plus zwei Steuergruppen (19 % und 7 %)
|
||||||
|
assert.deepEqual(tag(xml, "CategoryCode"), ["S", "S", "S", "S", "S", "S"])
|
||||||
|
assert.deepEqual(tag(xml, "RateApplicablePercent").slice(-2), ["19", "7"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("das eigene XML wird vom FEDEO-Parser ohne Fehler gelesen", async () => {
|
||||||
|
const { xml } = buildZugferdInvoiceXml(invoice(), context())
|
||||||
|
const parsed = parseElectronicInvoice(Buffer.from(xml), "cii")
|
||||||
|
|
||||||
|
assert.deepEqual(parsed.validation.errors, [])
|
||||||
|
assert.equal(parsed.invoiceNumber, "RE-2026-0042")
|
||||||
|
assert.equal(parsed.seller.vatId, "DE123456789")
|
||||||
|
assert.equal(parsed.totals.gross, 139.75)
|
||||||
|
assert.equal(parsed.items.length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("bettet das XML als factur-x.xml in die PDF ein", async () => {
|
||||||
|
const source = await PDFDocument.create()
|
||||||
|
const page = source.addPage()
|
||||||
|
page.drawText("Rechnung", { font: await source.embedFont(StandardFonts.Helvetica), x: 50, y: 700 })
|
||||||
|
const { xml } = buildZugferdInvoiceXml(invoice(), context())
|
||||||
|
|
||||||
|
const pdf = await embedZugferdXml(await source.save(), xml, { title: "Rechnung RE-2026-0042" })
|
||||||
|
const detection = await detectElectronicInvoice(pdf, { name: "RE-2026-0042.pdf", mimeType: "application/pdf" })
|
||||||
|
|
||||||
|
assert.equal(detection?.container, "pdf")
|
||||||
|
assert.equal(detection?.syntax, "cii")
|
||||||
|
assert.equal(detection?.attachmentName, "factur-x.xml")
|
||||||
|
assert.equal(detection?.xml.toString("utf8"), xml)
|
||||||
|
const raw = pdf.toString("latin1")
|
||||||
|
assert.match(raw, /\/AFRelationship \/Alternative/)
|
||||||
|
assert.match(raw, /<fx:ConformanceLevel>EN 16931<\/fx:ConformanceLevel>/)
|
||||||
|
assert.equal((await PDFDocument.load(pdf)).getPageCount(), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Stornorechnung wird als Gutschrift mit positiven Beträgen und Bezug zur Rechnung ausgegeben", () => {
|
||||||
|
const { xml, totals } = buildZugferdInvoiceXml(invoice({
|
||||||
|
type: "cancellationInvoices",
|
||||||
|
documentNumber: "RE-2026-0043",
|
||||||
|
rows: [{ pos: "1", mode: "free", text: "Montage", quantity: 2, unit: 6, price: -50, discountPercent: 0, taxPercent: 19 }],
|
||||||
|
}), context({ linkedDocument: { documentNumber: "RE-2026-0042", documentDate: "2026-11-04" } }))
|
||||||
|
|
||||||
|
assert.match(xml, /<ram:TypeCode>381<\/ram:TypeCode>/)
|
||||||
|
assert.equal(totals.gross, 119)
|
||||||
|
assert.match(xml, /<ram:BilledQuantity unitCode="HUR">2<\/ram:BilledQuantity>/)
|
||||||
|
assert.match(xml, /<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>RE-2026-0042<\/ram:IssuerAssignedID>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Schlussrechnung zieht Abschläge als bereits gezahlten Betrag ab", () => {
|
||||||
|
const { xml, totals } = buildZugferdInvoiceXml(invoice({
|
||||||
|
rows: [{ pos: "1", mode: "free", text: "Gesamtleistung", quantity: 1, unit: 1, price: 1000, discountPercent: 0, taxPercent: 19 }],
|
||||||
|
}), context({ advanceInvoices: [{ rows: [{ advanceInvoiceData: {}, price: 500, taxPercent: 19 }] }] }))
|
||||||
|
|
||||||
|
assert.equal(totals.gross, 1190)
|
||||||
|
assert.equal(totals.prepaid, 595)
|
||||||
|
assert.equal(totals.payable, 595)
|
||||||
|
assert.match(xml, /<ram:TotalPrepaidAmount>595.00<\/ram:TotalPrepaidAmount><ram:DuePayableAmount>595.00<\/ram:DuePayableAmount>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("§ 13b und Kleinunternehmer verwenden die passenden Steuerkategorien", () => {
|
||||||
|
const reverseCharge = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG", rows: [{ pos: "1", mode: "free", text: "Bauleistung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context())
|
||||||
|
assert.match(reverseCharge.xml, /<ram:CategoryCode>AE<\/ram:CategoryCode><ram:ExemptionReasonCode>VATEX-EU-AE<\/ram:ExemptionReasonCode>/)
|
||||||
|
assert.equal(reverseCharge.totals.tax, 0)
|
||||||
|
|
||||||
|
const smallBusiness = buildZugferdInvoiceXml(invoice({ taxType: "19 UStG", rows: [{ pos: "1", mode: "free", text: "Beratung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context())
|
||||||
|
assert.match(smallBusiness.xml, /<ram:ExemptionReason>Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/)
|
||||||
|
assert.match(smallBusiness.xml, /<ram:CategoryCode>E<\/ram:CategoryCode>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Leistungszeitraum, Lastschrift und Skonto werden strukturiert übertragen", () => {
|
||||||
|
const { xml, issues } = buildZugferdInvoiceXml(invoice({
|
||||||
|
deliveryDateType: "Leistungszeitraum", deliveryDate: "2026-10-01", deliveryDateEnd: "2026-10-31",
|
||||||
|
payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7,
|
||||||
|
}), context({ sepaMandate: { reference: "MANDAT-1" } }))
|
||||||
|
|
||||||
|
assert.deepEqual(issues, [])
|
||||||
|
assert.match(xml, /<ram:BillingSpecifiedPeriod><ram:StartDateTime><udt:DateTimeString format="102">20261001/)
|
||||||
|
assert.match(xml, /<ram:CreditorReferenceID>DE98ZZZ09999999999<\/ram:CreditorReferenceID>/)
|
||||||
|
assert.match(xml, /<ram:TypeCode>59<\/ram:TypeCode>/)
|
||||||
|
assert.match(xml, /<ram:DirectDebitMandateID>MANDAT-1<\/ram:DirectDebitMandateID>/)
|
||||||
|
assert.match(xml, /#SKONTO#TAGE=7#PROZENT=2.00#BASISBETRAG=139.75#/)
|
||||||
|
assert.doesNotMatch(xml, /ActualDeliverySupplyChainEvent/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ordnet Länder und Einheiten den Codelisten zu", () => {
|
||||||
|
assert.equal(countryCode("Österreich"), "AT")
|
||||||
|
assert.equal(countryCode(""), "DE")
|
||||||
|
assert.equal(countryCode("ch"), "CH")
|
||||||
|
assert.equal(unitCode({ short: "Std" }), "HUR")
|
||||||
|
assert.equal(unitCode({ short: "m²" }), "MTK")
|
||||||
|
assert.equal(unitCode({ short: "unbekannt" }), "C62")
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakingMessages = (document: any, ctx: ZugferdInvoiceContext, requireNumber = false) =>
|
||||||
|
checkZugferdRequirements(document, ctx, { requireNumber }).filter((issue) => issue.type === "breaking").map((issue) => issue.message)
|
||||||
|
|
||||||
|
test("Pflichtangaben: vollständiger Beleg hat keine blockierenden Fehler", () => {
|
||||||
|
assert.deepEqual(breakingMessages(invoice(), context(), true), [])
|
||||||
|
assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context()), [])
|
||||||
|
assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context(), true), ["E-Rechnung: Die Rechnungsnummer fehlt"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Pflichtangaben: fehlende Firmendaten, Kundenanschrift und IBAN blockieren", () => {
|
||||||
|
const ctx = context({
|
||||||
|
tenant: { businessInfo: { name: "Muster GmbH", street: "", zip: "12345", city: "Musterstadt" } },
|
||||||
|
})
|
||||||
|
const messages = breakingMessages(invoice({ address: { street: "Kundenweg 5", zip: "", city: "Kundenstadt" } }), ctx)
|
||||||
|
|
||||||
|
assert.ok(messages.some((message) => message.includes("Firmenanschrift")))
|
||||||
|
assert.ok(messages.some((message) => message.includes("weder eine USt-IdNr. noch eine Steuernummer")))
|
||||||
|
assert.ok(messages.some((message) => message.includes("Anschrift des Kunden")))
|
||||||
|
assert.ok(messages.some((message) => message.includes("keine IBAN")))
|
||||||
|
// Ein Bankkonto mit IBAN genügt anstelle der IBAN in den Firmendaten
|
||||||
|
const withAccount = context({ bankAccounts: [{ iban: "DE02120300000000202051" }] })
|
||||||
|
withAccount.tenant.businessInfo.iban = ""
|
||||||
|
assert.deepEqual(breakingMessages(invoice(), withAccount), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Pflichtangaben: § 13b und Lastschrift verlangen ihre Zusatzangaben", () => {
|
||||||
|
const reverseCharge = breakingMessages(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Bau AG", infoData: {} } }))
|
||||||
|
assert.deepEqual(reverseCharge, ["E-Rechnung: Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein"])
|
||||||
|
|
||||||
|
const directDebit = breakingMessages(invoice({ payment_type: "direct-debit" }), context({ tenant: { ...context().tenant, creditorId: "" } }))
|
||||||
|
assert.deepEqual(directDebit, [
|
||||||
|
"E-Rechnung: Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt",
|
||||||
|
"E-Rechnung: Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt",
|
||||||
|
])
|
||||||
|
|
||||||
|
const noDate = checkZugferdRequirements(invoice({ deliveryDateType: "Kein Lieferdatum anzeigen" }), context())
|
||||||
|
assert.deepEqual(noDate.map((issue) => issue.type), ["info"])
|
||||||
|
})
|
||||||
@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
|
|||||||
|
|
||||||
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
||||||
|
|
||||||
|
## Zahlung mit Skonto ausgleichen
|
||||||
|
|
||||||
|
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
|
||||||
|
|
||||||
|
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
|
||||||
|
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
|
||||||
|
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
|
||||||
|
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
|
||||||
|
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
|
||||||
|
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
|
||||||
|
|
||||||
|
### Skontobedingungen auf Belegen
|
||||||
|
|
||||||
|
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
|
||||||
|
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
|
||||||
|
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
|
||||||
|
|
||||||
## Gute Praxis
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
|
|||||||
|
|
||||||
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
||||||
|
|
||||||
|
## Zahlung mit Skonto ausgleichen
|
||||||
|
|
||||||
|
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
|
||||||
|
|
||||||
|
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
|
||||||
|
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
|
||||||
|
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
|
||||||
|
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
|
||||||
|
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
|
||||||
|
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
|
||||||
|
|
||||||
|
### Skontobedingungen auf Belegen
|
||||||
|
|
||||||
|
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
|
||||||
|
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
|
||||||
|
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
|
||||||
|
|
||||||
## Gute Praxis
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -328,18 +328,21 @@ Datenbank und `ENCRYPTION_KEY` gemeinsam sichern; der private Instanzschlüssel
|
|||||||
|
|
||||||
### Prüfung und verbleibender Ausbau
|
### Prüfung und verbleibender Ausbau
|
||||||
|
|
||||||
Geprüft wurden 21 Tests ohne Überspringen auf einer isolierten PostgreSQL-17-Testdatenbank. Darunter sind die Absicherung bestehender Chatgrenzen, die Trennung von Gast- und regulären Sitzungen, benachbarte Unterräume, der Historienbeginn, abgelaufene und widerrufene Einladungen sowie zwei unabhängige Instanzdatenbanken mit echten Fastify-Routen und Signaturprüfung. Der Transport zwischen diesen Instanzen ist im Test simuliert: Verbindungsfehler, verlorene Bestätigungen, doppelte Zustellung, veraltete Teilnahmeversionen, verspätete Widerrufe und erneute Einladungen werden gezielt erzeugt. HTTPS/DNS-Einschränkungen und Signaturmanipulationen besitzen zusätzliche Tests. Eine Verbindung zwischen zwei produktiven HTTPS-Installationen wurde nicht ausgeführt.
|
Geprüft wurden 50 Tests ohne Überspringen auf einer isolierten PostgreSQL-17-Testdatenbank. Darunter sind die Absicherung bestehender Chatgrenzen, die Trennung von Gast- und regulären Sitzungen, benachbarte Unterräume, der Historienbeginn, abgelaufene und widerrufene Einladungen sowie zwei unabhängige Instanzdatenbanken mit echten Fastify-Routen und Signaturprüfung. Die Benachrichtigungstests prüfen zusätzlich Empfängermandanten, persönliche Kanalwahl, Lesestatus, wiederholte Synchronisation, konkurrierende Worker, Neustarts und gemeinsame Transaktions-Rollbacks. Der Transport zwischen diesen Instanzen und die externe Benachrichtigungszustellung sind im Test simuliert: Verbindungsfehler, verlorene Bestätigungen, doppelte Zustellung, veraltete Teilnahmeversionen, verspätete Widerrufe und erneute Einladungen werden gezielt erzeugt. HTTPS/DNS-Einschränkungen und Signaturmanipulationen besitzen zusätzliche Tests. Eine Verbindung zwischen zwei produktiven HTTPS-Installationen wurde nicht ausgeführt.
|
||||||
|
|
||||||
Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrlichen Datenbank namens `collaboration_test`:
|
Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrlichen Datenbank namens `collaboration_test`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
|
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
|
||||||
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
|
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
|
||||||
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts
|
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts \
|
||||||
|
tests/collaborationNotifications.test.ts tests/notificationPreferences.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen.
|
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen.
|
||||||
|
|
||||||
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
|
Nachrichten und persönliche Einladungen aus der Zusammenarbeit unterstützen jetzt In-App-, E-Mail- und Push-Benachrichtigungen. Die Einstellungen gelten im Heimatmandanten des Empfängers. Die Migration `0083_collaboration_notifications.sql` muss dafür eingespielt sein. Der Versand erfolgt nach dem Speichern über den Hintergrundworker und prüft die aktuellen Zugriffsrechte; wiederholte Synchronisationen erzeugen keine doppelten Benachrichtigungen. Details und Standardwerte stehen in [Persönliche Benachrichtigungen](notification-preferences.md). Die Zustellung auf realen Endgeräten hängt weiterhin von der jeweiligen Push-Konfiguration ab.
|
||||||
|
|
||||||
|
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
|
||||||
|
|
||||||
Die automatische Einrichtung wurde zusätzlich mit sechs Konfigurationstests und 58 Updater-Tests geprüft. Die Update-Regressionen verwenden echte Compose-Auswertung mit simuliertem Containerbetrieb und prüfen sowohl Installationen ohne Föderationsvariablen als auch den Erhalt einer ausdrücklichen Abschaltung und eigener URL-Vorgaben.
|
Die automatische Einrichtung wurde zusätzlich mit sechs Konfigurationstests und 58 Updater-Tests geprüft. Die Update-Regressionen verwenden echte Compose-Auswertung mit simuliertem Containerbetrieb und prüfen sowohl Installationen ohne Föderationsvariablen als auch den Erhalt einer ausdrücklichen Abschaltung und eigener URL-Vorgaben.
|
||||||
|
|||||||
309
docs/fahrtenbuch-konzept.md
Normal file
309
docs/fahrtenbuch-konzept.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Konzept: Fahrtenbuch mit direkter Teltonika-Anbindung
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Die Teltonika-OBD-II-Tracker senden ihre AVL-Daten direkt an FEDEO. Traccar entfällt vollständig aus der produktiven Kette.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Teltonika-Tracker
|
||||||
|
│ TCP/TLS, Codec 8 Extended
|
||||||
|
▼
|
||||||
|
FEDEO Tracking-Ingest
|
||||||
|
│ atomare Speicherung, danach ACK
|
||||||
|
▼
|
||||||
|
PostgreSQL ──► Fahrtbildungs-Worker ──► Fahrtenbuch-API ──► Nuxt-Oberfläche/PDF-Export
|
||||||
|
```
|
||||||
|
|
||||||
|
Die empfohlene Lösung besteht aus einem kleinen, eigenständigen Dienst `tracking-server`, den bestehenden FEDEO-Diensten und PostgreSQL. Der Empfangsdienst darf nicht Teil des HTTP-Prozesses sein: Teltonika verwendet langlebige TCP-Verbindungen und ein Binärprotokoll, während das bestehende Backend Fastify/HTTP bedient. Die getrennten Prozesse können unabhängig neu gestartet und skaliert werden.
|
||||||
|
|
||||||
|
Ein zusätzlicher Message-Broker ist für die derzeit erkennbare Größenordnung nicht nötig. Der Tracker puffert Daten bei Verbindungsproblemen; PostgreSQL übernimmt die dauerhafte Übergabe an den Fahrtbildungs-Worker.
|
||||||
|
|
||||||
|
## Annahmen und noch zu bestätigende Angaben
|
||||||
|
|
||||||
|
- Mit „Teltonika OBD2“ ist voraussichtlich ein Gerät der FMx003-Familie gemeint, zum Beispiel FMB003, FMC003 oder FMM003. Vor der Implementierung müssen exaktes Modell, Firmwarestand und Mobilfunkvariante feststehen.
|
||||||
|
- Die Geräte werden auf **Codec 8 Extended über TCP/TLS** eingestellt. Teltonika empfiehlt Codec 8 Extended, weil nur damit auch AVL-IDs oberhalb von 255 und variable I/O-Werte vollständig übertragen werden können.
|
||||||
|
- Das Fahrtenbuch soll nicht nur eine Streckenübersicht, sondern bei Bedarf ein steuerlich nutzbares elektronisches Fahrtenbuch für Deutschland sein.
|
||||||
|
- Ein OBD-Tracker erkennt das Fahrzeug, aber nicht automatisch die fahrende Person. Ohne zusätzlichen BLE-/iButton-Fahrerschlüssel wird der im Fahrzeug hinterlegte Standardfahrer vorgeschlagen und muss gegebenenfalls geändert werden.
|
||||||
|
|
||||||
|
## 1. Tracking-Ingest
|
||||||
|
|
||||||
|
### Aufgabe
|
||||||
|
|
||||||
|
Ein neues Paket beziehungsweise ein neuer Dienst `tracking-server/` wird in TypeScript implementiert. Er verwendet `node:tls` beziehungsweise während der lokalen Entwicklung `node:net` und übernimmt ausschließlich:
|
||||||
|
|
||||||
|
1. TCP-Verbindung und IMEI-Handshake,
|
||||||
|
2. Prüfung, ob die IMEI einem aktiven FEDEO-Fahrzeug zugeordnet ist,
|
||||||
|
3. Framing und Parsen von Codec 8 (`0x08`) und Codec 8 Extended (`0x8E`),
|
||||||
|
4. Prüfung von Paketlänge, doppelter Datensatzanzahl und CRC-16/IBM,
|
||||||
|
5. normalisierte Speicherung aller AVL-Datensätze in einer Datenbanktransaktion,
|
||||||
|
6. Teltonika-ACK erst nach erfolgreichem Commit,
|
||||||
|
7. Aktualisierung von `last_seen_at` und einfachen Betriebsmetriken.
|
||||||
|
|
||||||
|
Der produktive Gerätevertrag ist Codec 8 Extended. Codec 8 sollte der Parser trotzdem beherrschen, damit ein falsch oder noch nicht umkonfiguriertes Gerät sichtbar diagnostiziert werden kann.
|
||||||
|
|
||||||
|
Bei Datenbankfehlern wird **kein** positiver ACK gesendet. Das Gerät behält die Datensätze und versucht die Übertragung erneut. Doppelte Zustellungen müssen durch einen fachlichen Idempotenzschlüssel wirkungslos bleiben. Unbekannte IMEIs werden abgewiesen und nur als begrenztes Sicherheitsereignis protokolliert.
|
||||||
|
|
||||||
|
### Parser-Grenzen
|
||||||
|
|
||||||
|
Der Binärparser darf nicht mit Fachlogik vermischt werden. Sein Ergebnis ist ein neutrales Objekt:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AvlRecord = {
|
||||||
|
recordedAt: Date
|
||||||
|
priority: number
|
||||||
|
longitude: number
|
||||||
|
latitude: number
|
||||||
|
altitude: number
|
||||||
|
angle: number
|
||||||
|
satellites: number
|
||||||
|
speedKmh: number
|
||||||
|
eventIoId: number
|
||||||
|
io: Record<number, bigint | Buffer>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Für die Fachlogik gibt es danach ein modell- und firmwareabhängiges I/O-Mapping. Relevante Standardwerte sind typischerweise:
|
||||||
|
|
||||||
|
- AVL 239: Zündung,
|
||||||
|
- AVL 240: Bewegung,
|
||||||
|
- AVL 16: Teltonika-Gesamtkilometerzähler,
|
||||||
|
- AVL 389: OEM-Gesamtkilometerstand,
|
||||||
|
- AVL 256: VIN,
|
||||||
|
- AVL 134: Fahrtdistanz.
|
||||||
|
|
||||||
|
Einheiten und Verfügbarkeit werden nicht im Parser fest verdrahtet, sondern über getestete Definitionen je Gerätefamilie normalisiert. Insbesondere der OEM-Kilometerstand ist vom unterstützten Fahrzeug und der auf das Gerät geladenen OBD-Konfiguration abhängig.
|
||||||
|
|
||||||
|
### Sicherheit und Betrieb
|
||||||
|
|
||||||
|
- Eigener TCP-Port, zum Beispiel `5027`, mit TLS 1.2; die konkrete TLS-Unterstützung muss für Modell und Firmware geprüft werden.
|
||||||
|
- IMEI-Zulassungsliste aus der Datenbank, Rate-Limit pro Quell-IP und harte Grenzen für Paketgröße und Verbindungsdauer.
|
||||||
|
- Keine Befehlssteuerung in Version 1. Codec 12/14 kann später getrennt ergänzt werden.
|
||||||
|
- Metriken: aktive Verbindungen, akzeptierte/abgelehnte Pakete, CRC-Fehler, unbekannte IMEIs, Verzögerung `received_at - recorded_at`, letzter Datensatz je Tracker und offene Fahrten.
|
||||||
|
- Alarm, wenn ein aktiver Tracker länger als eine konfigurierbare Zeit keine Daten liefert.
|
||||||
|
|
||||||
|
## 2. Datenmodell
|
||||||
|
|
||||||
|
Die vorhandene Tabelle `vehicles` bleibt die fachliche Fahrzeugquelle. Die generische Tabelle `devices` sollte nicht mit GPS-Sonderfeldern überladen werden.
|
||||||
|
|
||||||
|
### `vehicle_trackers`
|
||||||
|
|
||||||
|
Zuordnung und Gerätekonfiguration:
|
||||||
|
|
||||||
|
- `id uuid`, `tenant bigint`, `vehicle bigint`
|
||||||
|
- `imei text unique`, `model text`, `firmware text`
|
||||||
|
- `protocol text`, `enabled boolean`
|
||||||
|
- `installed_at`, `removed_at`, `last_seen_at`
|
||||||
|
- `io_profile_version text`
|
||||||
|
- eindeutige aktive Zuordnung eines Trackers zu einem Fahrzeug
|
||||||
|
|
||||||
|
Eine zeitliche Zuordnung ist wichtig: Beim Umbau eines Trackers dürfen alte Positionen nicht nachträglich dem neuen Fahrzeug zugerechnet werden.
|
||||||
|
|
||||||
|
### `tracking_packets`
|
||||||
|
|
||||||
|
Technische Nachvollziehbarkeit und gezielte Fehleranalyse:
|
||||||
|
|
||||||
|
- Tracker, Empfangszeit, Codec, CRC, Datensatzanzahl
|
||||||
|
- SHA-256 des vollständigen Frames als Idempotenzschlüssel
|
||||||
|
- optionaler Rohframe als `bytea` mit kurzer, konfigurierbarer Aufbewahrung, zum Beispiel 30 oder 90 Tage
|
||||||
|
- Parse-Status und Fehlercode
|
||||||
|
|
||||||
|
### `tracking_positions`
|
||||||
|
|
||||||
|
Unveränderliche normalisierte Messpunkte:
|
||||||
|
|
||||||
|
- Mandant, Tracker und die zum Messzeitpunkt gültige Fahrzeugzuordnung
|
||||||
|
- Gerätezeit `recorded_at` und Serverzeit `received_at`
|
||||||
|
- Koordinaten, Höhe, Richtung, Geschwindigkeit, Satelliten, Priorität
|
||||||
|
- normalisierte Werte `ignition`, `movement`, `odometer_m`, `oem_odometer_m`
|
||||||
|
- `event_io_id` und vollständige I/O-Werte als `jsonb`
|
||||||
|
- Qualitätsmerkmale wie `valid_gnss`, Zeitabweichung und Kilometerstandsquelle
|
||||||
|
|
||||||
|
Eindeutigkeit entsteht aus Tracker, Gerätezeit und einem stabilen Datensatz-Fingerabdruck. Für größere Datenmengen kann die Tabelle später monatsweise nach `recorded_at` partitioniert werden. Indizes werden mindestens auf `(vehicle, recorded_at)` und `(tracker, recorded_at)` benötigt.
|
||||||
|
|
||||||
|
### `tracking_trips`
|
||||||
|
|
||||||
|
Das fachliche Fahrtenbuch:
|
||||||
|
|
||||||
|
- Fahrzeug, Fahrer, Start/Ende und Start-/Zielkoordinaten
|
||||||
|
- Start-/Zieladresse als zum Abschlusszeitpunkt gespeicherter Text
|
||||||
|
- Kilometerstand Beginn/Ende, berechnete Distanz und jeweilige Quelle
|
||||||
|
- Klassifizierung `betrieblich`, `privat` oder `wohnung_erste_taetigkeitsstaette`
|
||||||
|
- Reisezweck, Kunde/Geschäftspartner sowie optionale FEDEO-Verknüpfungen zu Kunde, Projekt und Termin
|
||||||
|
- Status `offen`, `zu_pruefen`, `bestaetigt`, `gesperrt`
|
||||||
|
- Qualitätsstatus mit konkreten Hinweisen, zum Beispiel fehlender OEM-Kilometerstand, GPS-Lücke oder unplausibler Sprung
|
||||||
|
- Referenzen auf ersten und letzten Messpunkt
|
||||||
|
|
||||||
|
### `tracking_trip_revisions` und `tracking_logbook_exports`
|
||||||
|
|
||||||
|
Fahrten werden nie unbemerkt überschrieben. Jede Änderung erzeugt eine unveränderliche Revision mit altem und neuem Wert, Benutzer, Zeit, Änderungsgrund sowie einer Hash-Verkettung zur vorherigen Revision. Auch Zusammenführen, Teilen und Löschen sind fachliche Revisionen; ein physisches Löschen bestätigter Fahrten ist nicht erlaubt.
|
||||||
|
|
||||||
|
Ein abgeschlossener Monats- oder Jahresexport speichert PDF, maschinenlesbare CSV/JSON-Daten und ein Manifest mit SHA-256-Prüfsummen. Der Export enthält die Änderungshistorie in gewöhnlich einsehbarer Form. „Gesperrt“ bedeutet: weitere Korrekturen erzeugen eine sichtbare Folgerevision und gegebenenfalls eine neue Exportversion.
|
||||||
|
|
||||||
|
## 3. Fahrtbildung
|
||||||
|
|
||||||
|
Die Fahrtbildung läuft als wiederholbarer Worker auf Ereigniszeit, nicht auf Empfangszeit. Das ist wichtig, weil Tracker bei Funklöchern ältere Datensätze nachliefern.
|
||||||
|
|
||||||
|
### Zustandsautomat je Tracker
|
||||||
|
|
||||||
|
```text
|
||||||
|
STEHT
|
||||||
|
└─ Zündung an + Bewegung/Geschwindigkeit ─► FÄHRT
|
||||||
|
|
||||||
|
FÄHRT
|
||||||
|
├─ weitere Punkte ────────────────────────► FÄHRT
|
||||||
|
└─ Zündung aus + Nachlaufzeit ────────────► STEHT + Fahrt abschließen
|
||||||
|
```
|
||||||
|
|
||||||
|
- Primäres Start-/Endesignal ist AVL 239 (Zündung), plausibilisiert mit Bewegung und Geschwindigkeit.
|
||||||
|
- Eine konfigurierbare Nachlaufzeit von zunächst 120 Sekunden verhindert Fahrtteilungen an Ampeln oder bei kurzen Signalwechseln.
|
||||||
|
- Eine Fahrt wird nicht sofort endgültig: Ein Watermark, zum Beispiel zehn Minuten nach dem letzten bekannten Gerätezeitpunkt, erlaubt verspätete Datensätze. Betroffene offene Zeitfenster werden deterministisch neu berechnet.
|
||||||
|
- Bei fehlendem Zündungssignal greift ein klar markierter Fallback aus Bewegung, Geschwindigkeit und Stillstandszeit.
|
||||||
|
- Kilometerstandsquelle in absteigender Qualität: OEM-Gesamtkilometerstand (AVL 389), kalibrierter Teltonika-Gesamtkilometerzähler (AVL 16), Fahrtdistanz/Positionssumme. FEDEO speichert Quelle und Abweichung, statt Werte still zu vermischen.
|
||||||
|
- Unplausible GNSS-Punkte werden nicht für die Distanz verwendet, bleiben aber unverändert gespeichert.
|
||||||
|
- Zeitrücksprünge, Kilometerzählersprünge, GPS-Lücken und Tracker-Ausbau erzeugen Prüffälle und niemals stillschweigend „saubere“ Fahrten.
|
||||||
|
|
||||||
|
Die Zählerwerte müssen im Pilotfahrzeug gegen den Tachostand geprüft werden. Liefert ein Fahrzeug keinen OEM-Gesamtkilometerstand, wird einmalig ein dokumentierter Tachostand mit dem Teltonika-Zähler verknüpft. Der daraus entstehende Offset bleibt versioniert.
|
||||||
|
|
||||||
|
## 4. Bedienung in FEDEO
|
||||||
|
|
||||||
|
Der vorhandene, bislang nur konfigurierte Ressourcenschlüssel `trackingtrips` kann weiterverwendet werden. Für das Fahrtenbuch ist dennoch eine eigene Seite sinnvoller als die generische Standard-Entity-Maske.
|
||||||
|
|
||||||
|
### Ansichten
|
||||||
|
|
||||||
|
- **Fahrten:** chronologische Liste mit Fahrzeug, Fahrer, Start/Ziel, Zeit, Kilometerständen, Distanz, Klassifizierung, Zweck und Prüfstatus.
|
||||||
|
- **Prüfen:** schnelle Stapelbearbeitung aller offenen Fahrten; Vorschläge aus Kalender, Kunde/Projekt und wiederkehrenden Zielen.
|
||||||
|
- **Karte:** Route einer ausgewählten Fahrt, nicht standardmäßig die vollständige Mitarbeiterhistorie.
|
||||||
|
- **Fahrzeug:** Trackerstatus, letzter Empfang, Kilometerstandsqualität und offene Warnungen.
|
||||||
|
- **Export:** Monats-/Jahresabschluss, PDF-Vorschau und Prüfdateien.
|
||||||
|
|
||||||
|
Eine betriebliche Fahrt verlangt Ziel, Reisezweck und Geschäftspartner beziehungsweise einen konkreten betrieblichen Vorgang. Privatfahrten speichern für normale Benutzer nur die steuerlich erforderlichen Werte; die Sichtbarkeit genauer Privat-Routen sollte über eine eigene Berechtigung begrenzt werden.
|
||||||
|
|
||||||
|
### API-Schnittstellen
|
||||||
|
|
||||||
|
Spezialisierte, mandantengeprüfte Routen sind der generischen Ressourcen-API vorzuziehen:
|
||||||
|
|
||||||
|
- `GET /api/tracking/trips`
|
||||||
|
- `GET /api/tracking/trips/:id`
|
||||||
|
- `PATCH /api/tracking/trips/:id/classification`
|
||||||
|
- `POST /api/tracking/trips/:id/confirm`
|
||||||
|
- `POST /api/tracking/trips/:id/split`
|
||||||
|
- `POST /api/tracking/trips/merge`
|
||||||
|
- `GET /api/tracking/vehicles/:id/status`
|
||||||
|
- `POST /api/tracking/exports`
|
||||||
|
- `GET /api/tracking/exports/:id`
|
||||||
|
|
||||||
|
Ändernde Aufrufe verlangen einen Änderungsgrund, sobald eine Fahrt bereits bestätigt war. Berechtigungen sollten mindestens `tracking.view`, `tracking.edit_own`, `tracking.edit_all`, `tracking.view_private_routes`, `tracking.manage_devices`, `tracking.lock` und `tracking.export` unterscheiden.
|
||||||
|
|
||||||
|
## 5. Steuerliche Nachvollziehbarkeit und Datenschutz
|
||||||
|
|
||||||
|
Für ein deutsches elektronisches Fahrtenbuch reichen GPS-Punkte allein nicht. Nach der Rechtsprechung müssen die Aufzeichnungen zeitnah, fortlaufend und in geschlossener Form vorliegen. Änderungen müssen technisch ausgeschlossen oder in ihrer Reichweite direkt im Fahrtenbuch erkennbar sein. Für betriebliche Fahrten gehören grundsätzlich Datum, Kilometerstand zu Beginn und Ende, Reiseziel, Reisezweck und aufgesuchte Geschäftspartner dazu.
|
||||||
|
|
||||||
|
Deshalb sind folgende Punkte keine spätere Zusatzfunktion, sondern Bestandteil der ersten fachlich nutzbaren Version:
|
||||||
|
|
||||||
|
- append-only Messdaten und Änderungshistorie,
|
||||||
|
- sichtbare Revisionsangaben im Fahrtenbuch und im Export,
|
||||||
|
- Erinnerung und Eskalation für zeitnahe Klassifizierung,
|
||||||
|
- Vollständigkeits- und Plausibilitätsprüfung pro Fahrzeug,
|
||||||
|
- reproduzierbare, versionierte Monatsabschlüsse,
|
||||||
|
- dokumentierte Verfahrensbeschreibung und Wiederherstellungstests.
|
||||||
|
|
||||||
|
Das Konzept ist eine technische Auslegung, keine Zusicherung der steuerlichen Anerkennung. Export und Verfahrensdokumentation sollten vor dem produktiven steuerlichen Einsatz mit der Steuerberatung abgestimmt werden.
|
||||||
|
|
||||||
|
Standortdaten von Beschäftigten erfordern zudem ein Rollen- und Löschkonzept, Zweckbindung, transparente Information und gegebenenfalls Beteiligung des Betriebsrats. Rohpunkte sollten nur so lange wie fachlich und rechtlich erforderlich gespeichert werden. Reverse-Geocoding erfolgt nur für Start und Ziel, asynchron und mit Cache; ein selbst betriebener Dienst oder ein vertraglich geprüfter Anbieter vermeidet unnötige Weitergabe vollständiger Bewegungsprofile.
|
||||||
|
|
||||||
|
## 6. Gerätekonfiguration
|
||||||
|
|
||||||
|
Für einen Pilot-Tracker wird ein versioniertes Teltonika-Konfigurationsprofil erstellt:
|
||||||
|
|
||||||
|
- Hauptserver: FEDEO-Domain und Tracking-Port
|
||||||
|
- Transport: TCP mit TLS, sofern Modell/Firmware dies unterstützen
|
||||||
|
- Datenprotokoll: Codec 8 Extended
|
||||||
|
- Datenerfassung in Bewegung zunächst alle 30 Sekunden oder 100 Meter sowie bei relevanter Richtungsänderung
|
||||||
|
- Ereignisdatensatz bei Zündung an/aus
|
||||||
|
- AVL 239, 240, 16, 389, 256 und 134 aktivieren, soweit vom Gerät unterstützt
|
||||||
|
- lokale Pufferung und älteste Datensätze zuerst senden
|
||||||
|
- Serverbestätigung auf AVL-Ebene
|
||||||
|
|
||||||
|
Das Standardintervall von mehreren Minuten ist für belastbare Start-/Zielpunkte und Routen zu grob. Die endgültigen Intervalle werden im Pilot gegen Datenvolumen, SIM-Tarif und Fahrzeugbatterie getestet.
|
||||||
|
|
||||||
|
## 7. Einführung ohne Datenverlust
|
||||||
|
|
||||||
|
### Phase 0 – Geräte-Spike
|
||||||
|
|
||||||
|
Ein reales Gerät und Fahrzeug werden festgelegt. Modell, Firmware, IMEI, empfangene AVL-IDs, Zündungslogik und Kilometerstandsquellen werden dokumentiert. Ein aufgezeichneter Binärdatenbestand aus definierten Testfahrten bildet anschließend einen dauerhaft nutzbaren Parser- und Fahrtbildungs-Testvektor.
|
||||||
|
|
||||||
|
### Phase 1 – Shadow-Ingest
|
||||||
|
|
||||||
|
Teltonika unterstützt einen zweiten Server und einen Duplikatmodus. Während der Pilotphase sendet das Gerät an Traccar **und** FEDEO. FEDEO bestätigt und speichert Daten, beeinflusst aber noch kein produktives Fahrtenbuch. Über mehrere Wochen werden Paketvollständigkeit, Kilometerstände und erkannte Fahrten verglichen.
|
||||||
|
|
||||||
|
### Phase 2 – Fahrtenbuch-Pilot
|
||||||
|
|
||||||
|
Fahrtbildung, Prüfansicht, Klassifizierung, Revisionen und Export werden für wenige Fahrzeuge aktiviert. Kriterien für die Freigabe:
|
||||||
|
|
||||||
|
- kein Paketverlust bei Neustart oder Datenbankunterbrechung,
|
||||||
|
- idempotente Verarbeitung wiederholter Pakete,
|
||||||
|
- reproduzierbare Fahrtbildung bei ungeordneter Zustellung,
|
||||||
|
- Kilometerstandsabweichungen erklärt und sichtbar,
|
||||||
|
- vollständiger Export inklusive Änderungen,
|
||||||
|
- Rechte- und Mandantentrennung getestet.
|
||||||
|
|
||||||
|
### Phase 3 – Umschaltung
|
||||||
|
|
||||||
|
Nach erfolgreichem Parallelbetrieb wird FEDEO Hauptserver. Traccar bleibt zunächst als Teltonika-Backupserver konfiguriert, anschließend wird es entfernt. Der Rückfall besteht in der ersten Zeit lediglich aus dem Zurückschalten des Geräteprofils; ein Datenbank-Rollback ist nicht erforderlich.
|
||||||
|
|
||||||
|
## 8. Empfohlener Implementierungsumfang
|
||||||
|
|
||||||
|
### Iteration 1: belastbarer Empfang
|
||||||
|
|
||||||
|
- Schemata `vehicle_trackers`, `tracking_packets`, `tracking_positions`
|
||||||
|
- Codec-8/8E-Parser mit CRC- und offiziellen Beispielvektoren
|
||||||
|
- TCP/TLS-Dienst, IMEI-Zuordnung, ACK nach Commit
|
||||||
|
- Trackerstatus im Backend und technische Metriken
|
||||||
|
- Docker-Compose-Erweiterung und Betriebsdokumentation
|
||||||
|
|
||||||
|
### Iteration 2: Fahrten
|
||||||
|
|
||||||
|
- Schemata `tracking_trips` und Qualitätsmeldungen
|
||||||
|
- deterministischer Fahrtbildungs-Worker
|
||||||
|
- Listen-, Detail- und Kartenansicht
|
||||||
|
- manuelle Klassifizierung und Fahrerzuordnung
|
||||||
|
|
||||||
|
### Iteration 3: belastbares Fahrtenbuch
|
||||||
|
|
||||||
|
- unveränderliche Revisionen und Hash-Verkettung
|
||||||
|
- zeitnahe Erinnerungen, Bestätigung und Monatsabschluss
|
||||||
|
- PDF/CSV/JSON-Export mit Prüfmanifest
|
||||||
|
- Projekt-/Kunden-/Kalender-Vorschläge
|
||||||
|
- Rechte-, Datenschutz- und Verfahrensdokumentation
|
||||||
|
|
||||||
|
## 9. Tests, die vor Abschaltung von Traccar bestehen müssen
|
||||||
|
|
||||||
|
- fragmentierte und zusammengefasste TCP-Frames
|
||||||
|
- ungültige Länge, CRC, Codec-ID, IMEI und Datensatzanzahl
|
||||||
|
- Disconnect vor und nach ACK
|
||||||
|
- dasselbe Paket mehrfach und gleichzeitig zugestellt
|
||||||
|
- mehrere AVL-Datensätze pro Paket
|
||||||
|
- negative Koordinaten und variable I/O-Felder aus Codec 8 Extended
|
||||||
|
- Offline-Nachlieferung über mehrere Stunden in falscher Empfangsreihenfolge
|
||||||
|
- Zündung an ohne Bewegung, kurze Stopps, Tunnel, GPS-Sprung und Mitternacht
|
||||||
|
- fehlender oder zurückgesetzter Kilometerzähler
|
||||||
|
- Trackerwechsel zwischen Fahrzeugen
|
||||||
|
- Mandantentrennung jeder API-Route
|
||||||
|
- Änderung einer bestätigten und einer gesperrten Fahrt
|
||||||
|
- Export-Reproduzierbarkeit und Prüfung der Hash-Kette
|
||||||
|
|
||||||
|
## Empfehlung
|
||||||
|
|
||||||
|
Zuerst sollte **nicht** die Oberfläche gebaut werden, sondern ein zwei- bis dreitägiger Geräte-Spike mit einem konkreten Tracker. Danach ist Iteration 1 überschaubar und risikoarm. Der direkte Teltonika-Empfang selbst ist technisch klar definiert; die eigentliche Sorgfalt liegt in verspäteten Daten, Kilometerstandsqualität, Fahreridentität und der unveränderlich sichtbaren Bearbeitungshistorie.
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- [Teltonika: Codec 8 und Codec 8 Extended](https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols)
|
||||||
|
- [Teltonika: Empfehlung für Codec 8 Extended](https://wiki.teltonika-gps.com/view/Which_data_protocol_to_choose_Codec_8_or_Codec_8_Extended)
|
||||||
|
- [Teltonika: FMC003 Data Sending Parameters](https://wiki.teltonika-gps.com/view/FMC003_Teltonika_Data_Sending_Parameters_ID)
|
||||||
|
- [Teltonika: FMB003 Trip-/Odometer-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_Trip/Odometer_settings)
|
||||||
|
- [Teltonika: FMB003 GPRS-, Zweitserver- und TLS-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_GPRS_settings)
|
||||||
|
- [Teltonika: OEM-Gesamtkilometerstand und Fahrzeugkompatibilität](https://wiki.teltonika-gps.com/view/Pay_as_You_Drive)
|
||||||
|
- [Bundesfinanzhof: Anforderungen an ein elektronisches Fahrtenbuch, VI B 37/23](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE202450011/)
|
||||||
|
- [Bundesfinanzhof: Inhalt und geschlossene Form des Fahrtenbuchs, VI R 33/10](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE201210116/)
|
||||||
|
- [BMF: Einkommensteuer-Handbuch 2025, Anhang 16 III](https://lsth.bundesfinanzministerium.de/esth/2025/B-Anhaenge/Anhang-16/III/inhalt.html)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Persönliche Benachrichtigungen
|
# Persönliche Benachrichtigungen
|
||||||
|
|
||||||
Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil.
|
Unter Einstellungen → Benachrichtigungen (`/settings/notifications`) im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Der Web-Menüeintrag ist für alle angemeldeten Mitarbeiter unabhängig von Administrationsrechten sichtbar. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil.
|
||||||
|
|
||||||
## Ereignisse
|
## Ereignisse
|
||||||
|
|
||||||
@@ -10,16 +10,23 @@ Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in
|
|||||||
| `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an |
|
| `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an |
|
||||||
| `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus |
|
| `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus |
|
||||||
| `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus |
|
| `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus |
|
||||||
|
| `collaboration.message.new` | Neue Nachricht in einem freigegebenen Unterraum oder föderierten Chat, ohne Absender | aus | aus |
|
||||||
|
| `collaboration.room.invited` | Persönliche Einladung in einen Unterraum oder föderierten Chat | aus | an |
|
||||||
|
|
||||||
Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume; föderierte Zusammenarbeit wird hier noch nicht erfasst.
|
Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume. Die beiden Ereignisse unter „Zusammenarbeit“ gelten für lokale Unterräume, mandantenübergreifende Teilnahmen und föderierte Chats. Sie erscheinen automatisch in den Einstellungen im Web und in der App.
|
||||||
|
|
||||||
Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten und Erwähnungen aktiv; die neuen optionalen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix.
|
Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten, Erwähnungen und die Zusammenarbeit aktiv; die optionalen nativen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix.
|
||||||
|
|
||||||
|
Für die Zusammenarbeit zählt immer der Heimatmandant des empfangenden Benutzers, auch wenn der Raum einem anderen Mandanten oder einer Partnerinstanz gehört. Benachrichtigt werden nur aktive, berechtigte FEDEO-Benutzer. Gäste ohne Benutzerkonto behalten ihren gesonderten Einladungsversand und ihre Anmeldung per E-Mail-Code.
|
||||||
|
|
||||||
|
Nachrichten und Einladungen werden mit ihren Benachrichtigungen atomar gespeichert. Ein Worker verarbeitet die vorgemerkten Kanäle alle fünf Sekunden unabhängig von einem geöffneten Browser. Wiederholte Nachrichtenbefehle, Einladungslieferungen und Synchronisationen erzeugen keine zusätzlichen Einträge; eigene Nachrichten und rückwärts nachgeladener Verlauf lösen keine Benachrichtigung aus. Bei Partnerausfällen wartet der Versand auf eine erneute Zugriffsprüfung. Widerrufene oder abgelaufene Teilnahmen und erledigte Einladungen werden vor dem Versand verworfen. Bereits gelesene Nachrichten lösen keine ausstehenden E-Mails oder Pushs mehr aus. Die persönlichen Einstellungen werden vor dem Versand erneut geprüft. Fehler des eigentlichen E-Mail-/Push-Zustellers werden wie bisher als fehlgeschlagen gespeichert; dafür gibt es noch keinen automatischen Wiederholungsversand.
|
||||||
|
|
||||||
## API und Migration
|
## API und Migration
|
||||||
|
|
||||||
- `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session.
|
- `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session.
|
||||||
- `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht.
|
- `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht.
|
||||||
- Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten.
|
- Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten.
|
||||||
|
- Migration `0083_collaboration_notifications` registriert die beiden Zusammenarbeitsereignisse und indiziert deren wartende Zustellungen. Es werden keine historischen lokalen Nachrichten oder Einladungen nachträglich verschickt. Bei der nächsten Synchronisation können bisher noch nicht abgeholte, ungelesene Partnernachrichten Benachrichtigungen auslösen.
|
||||||
- E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus.
|
- E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus.
|
||||||
|
|
||||||
## Sinnvolle nächste Auslöser
|
## Sinnvolle nächste Auslöser
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer`
|
|||||||
|
|
||||||
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
|
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
|
||||||
|
|
||||||
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
|
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Außerdem ist der alte Mobile-Zweig (`89185b4b…`, Stand vor dem Ausgangscommit) eingetragen, der mit `7a1986bb` nach `dev` übernommen wurde. Alle festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
|
||||||
|
|
||||||
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.
|
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
|
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
|
||||||
const emit = defineEmits(['changed', 'unauthorized'])
|
const emit = defineEmits(['changed', 'unauthorized'])
|
||||||
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('')
|
const room = ref(null), messages = ref([]), members = ref([]), error = ref('')
|
||||||
|
// Guest access has separate credentials and must never reuse an internal user's drafts.
|
||||||
|
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => `collaboration:${props.roomId}`, { persist: () => props.base === '/api/collaboration' })
|
||||||
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
||||||
let generation = 0, timer, polling = false, pending = null
|
let generation = 0, timer, polling = false
|
||||||
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
||||||
const merge = (incoming) => {
|
const merge = (incoming) => {
|
||||||
const byId = new Map(messages.value.map(m => [m.id, m]))
|
const byId = new Map(messages.value.map(m => [m.id, m]))
|
||||||
@@ -20,7 +22,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
|
|||||||
function failed(e) {
|
function failed(e) {
|
||||||
error.value = messageError(e)
|
error.value = messageError(e)
|
||||||
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
||||||
room.value = null; messages.value = []; members.value = []; draftImage.value = null
|
room.value = null; messages.value = []; members.value = []
|
||||||
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,7 @@ async function read(id, version) {
|
|||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const version = ++generation, id = props.roomId
|
const version = ++generation, id = props.roomId
|
||||||
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
|
room.value = null; messages.value = []; members.value = []; error.value = ''
|
||||||
loading.value = true; sending.value = false; olderLoading.value = false
|
loading.value = true; sending.value = false; olderLoading.value = false
|
||||||
try {
|
try {
|
||||||
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
||||||
@@ -78,15 +80,18 @@ async function send() {
|
|||||||
const text = draft.value.trim(), image = draftImage.value
|
const text = draft.value.trim(), image = draftImage.value
|
||||||
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
||||||
const version = generation, id = props.roomId
|
const version = generation, id = props.roomId
|
||||||
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() }
|
const savedDraft = startDraftSend()
|
||||||
|
if (!savedDraft) return
|
||||||
|
const pending = { text, image, clientId: savedDraft.value.clientId }
|
||||||
sending.value = true; error.value = ''
|
sending.value = true; error.value = ''
|
||||||
try {
|
try {
|
||||||
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
||||||
|
clearDraft(savedDraft)
|
||||||
if (version !== generation) return
|
if (version !== generation) return
|
||||||
merge([message]); draft.value = ''; draftImage.value = null; pending = null
|
merge([message])
|
||||||
await scroll(); await read(id, version)
|
await scroll(); await read(id, version)
|
||||||
} catch (e) { if (version === generation) failed(e) }
|
} catch (e) { if (version === generation) failed(e) }
|
||||||
finally { if (version === generation) sending.value = false }
|
finally { finishDraftSend(savedDraft); if (version === generation) sending.value = false }
|
||||||
}
|
}
|
||||||
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
watch(() => props.roomId, load, { immediate: true })
|
watch(() => props.roomId, load, { immediate: true })
|
||||||
@@ -126,6 +131,6 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
|||||||
</div>
|
</div>
|
||||||
</ChatReactions>
|
</ChatReactions>
|
||||||
</div>
|
</div>
|
||||||
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
57
frontend/components/ColorInput.vue
Normal file
57
frontend/components/ColorInput.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Farbe'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const pickerValue = computed(() => {
|
||||||
|
const value = props.modelValue?.trim() || ''
|
||||||
|
if (/^#[\da-f]{6}$/i.test(value)) return value
|
||||||
|
if (/^#[\da-f]{3}$/i.test(value)) {
|
||||||
|
return `#${value.slice(1).split('').map(character => character.repeat(2)).join('')}`
|
||||||
|
}
|
||||||
|
return '#2563eb'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<InputGroup class="w-full">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="pickerValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="`${label} auswählen`"
|
||||||
|
class="h-9 w-14 shrink-0 cursor-pointer rounded border border-default bg-transparent p-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@input="emit('update:modelValue', $event.target.value)"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="`${label} als Farbwert`"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
placeholder="Standardfarbe"
|
||||||
|
@update:model-value="emit('update:modelValue', $event || null)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || !modelValue"
|
||||||
|
:aria-label="`${label} zurücksetzen`"
|
||||||
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
icon="i-heroicons-x-mark"
|
||||||
|
@click="emit('update:modelValue', null)"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</template>
|
||||||
@@ -385,6 +385,8 @@ const getEntityModalCreateQuery = (datapoint) => {
|
|||||||
const getPostSaveRoute = () => {
|
const getPostSaveRoute = () => {
|
||||||
if (type !== "events") return null
|
if (type !== "events") return null
|
||||||
|
|
||||||
|
if (route.query.returnTo === "events") return "/events"
|
||||||
|
|
||||||
if (route.query.returnTo === "plantafel") {
|
if (route.query.returnTo === "plantafel") {
|
||||||
return {
|
return {
|
||||||
path: "/organisation/plantafel",
|
path: "/organisation/plantafel",
|
||||||
@@ -475,25 +477,21 @@ const updateItem = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<UDashboardNavbar
|
<UDashboardNavbar
|
||||||
v-if="!props.inModal"
|
v-if="!props.inModal"
|
||||||
|
:title="item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`"
|
||||||
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
|
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
|
||||||
>
|
>
|
||||||
<template #toggle>
|
<template #toggle>
|
||||||
<div v-if="platform === 'mobile'"></div>
|
<div v-if="platform === 'mobile'"></div>
|
||||||
</template>
|
</template>
|
||||||
<template #left>
|
<template #leading>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-chevron-left"
|
icon="i-heroicons-chevron-left"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
aria-label="Zurück"
|
||||||
@click="router.back()"
|
@click="router.back()"
|
||||||
>
|
>
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
<template #center>
|
|
||||||
<h1
|
|
||||||
v-if="item"
|
|
||||||
:class="['text-xl','font-medium', 'text-center']"
|
|
||||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
|
||||||
</template>
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
color="error"
|
color="error"
|
||||||
@@ -517,7 +515,7 @@ const updateItem = async () => {
|
|||||||
Erstellen
|
Erstellen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`)"
|
@click="router.push(getPostSaveRoute() || (item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`))"
|
||||||
color="red"
|
color="red"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
>
|
>
|
||||||
@@ -527,14 +525,9 @@ const updateItem = async () => {
|
|||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
<UDashboardNavbar
|
<UDashboardNavbar
|
||||||
v-else
|
v-else
|
||||||
|
:title="props.floatingWindow ? '' : (item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`)"
|
||||||
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
|
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
|
||||||
>
|
>
|
||||||
<template #center>
|
|
||||||
<h1
|
|
||||||
v-if="item && !props.floatingWindow"
|
|
||||||
:class="['text-xl', 'font-medium']"
|
|
||||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
|
||||||
</template>
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="item.id"
|
v-if="item.id"
|
||||||
@@ -586,7 +579,14 @@ const updateItem = async () => {
|
|||||||
:item="item"
|
:item="item"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
|
<ColorInput
|
||||||
|
v-if="datapoint.inputType === 'color'"
|
||||||
|
:model-value="getDatapointValue(datapoint)"
|
||||||
|
:label="datapoint.label"
|
||||||
|
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||||
|
@update:model-value="(value) => setDatapointValue(datapoint, value)"
|
||||||
|
/>
|
||||||
|
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
|
||||||
<UInput
|
<UInput
|
||||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||||
class="flex-auto"
|
class="flex-auto"
|
||||||
@@ -833,7 +833,14 @@ const updateItem = async () => {
|
|||||||
:item="item"
|
:item="item"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
|
<ColorInput
|
||||||
|
v-if="datapoint.inputType === 'color'"
|
||||||
|
:model-value="getDatapointValue(datapoint)"
|
||||||
|
:label="datapoint.label"
|
||||||
|
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||||
|
@update:model-value="(value) => setDatapointValue(datapoint, value)"
|
||||||
|
/>
|
||||||
|
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
|
||||||
<UInput
|
<UInput
|
||||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||||
class="flex-auto"
|
class="flex-auto"
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ const invoiceDeliveryNotes = () => {
|
|||||||
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
|
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createAdvanceInvoice = () => {
|
||||||
|
if (props.topLevelType !== 'projects' || !props.item.id) return
|
||||||
|
|
||||||
|
const query = new URLSearchParams(props.queryStringData)
|
||||||
|
query.set('type', 'advanceInvoices')
|
||||||
|
query.set('loadMode', 'advanceInvoice')
|
||||||
|
query.set('project', props.item.id)
|
||||||
|
router.push(`/createDocument/edit?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
const showFinalInvoiceConfig = ref(false)
|
const showFinalInvoiceConfig = ref(false)
|
||||||
const referenceDocument = ref(null)
|
const referenceDocument = ref(null)
|
||||||
const advanceInvoicesToAdd = ref([])
|
const advanceInvoicesToAdd = ref([])
|
||||||
@@ -169,6 +179,13 @@ const selectItem = (item) => {
|
|||||||
>
|
>
|
||||||
Dokument aus Vorlage
|
Dokument aus Vorlage
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="props.topLevelType === 'projects'"
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
@click="createAdvanceInvoice"
|
||||||
|
>
|
||||||
|
Abschlagsrechnung
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="showFinalInvoiceConfig = true"
|
@click="showFinalInvoiceConfig = true"
|
||||||
v-if="props.topLevelType === 'projects'"
|
v-if="props.topLevelType === 'projects'"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ const isRouteActive = (to) => {
|
|||||||
|
|
||||||
const links = computed(() => {
|
const links = computed(() => {
|
||||||
const organisationChildren = [
|
const organisationChildren = [
|
||||||
|
has("events") && featureEnabled("calendar") ? {
|
||||||
|
label: "Termine",
|
||||||
|
to: "/events",
|
||||||
|
icon: "i-heroicons-calendar-days",
|
||||||
|
active: route.path.startsWith("/standardEntity/events")
|
||||||
|
} : null,
|
||||||
has("tasks") && featureEnabled("tasks") ? {
|
has("tasks") && featureEnabled("tasks") ? {
|
||||||
label: "Aufgaben",
|
label: "Aufgaben",
|
||||||
to: "/tasks",
|
to: "/tasks",
|
||||||
@@ -318,6 +324,11 @@ const links = computed(() => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const settingsChildren = [
|
const settingsChildren = [
|
||||||
|
{
|
||||||
|
label: "Benachrichtigungen",
|
||||||
|
to: "/settings/notifications",
|
||||||
|
icon: "i-heroicons-bell",
|
||||||
|
},
|
||||||
featureEnabled("settingsNumberRanges") ? {
|
featureEnabled("settingsNumberRanges") ? {
|
||||||
label: "Nummernkreise",
|
label: "Nummernkreise",
|
||||||
to: "/settings/numberRanges",
|
to: "/settings/numberRanges",
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["updateNeeded","returnData"])
|
const emit = defineEmits(["updateNeeded","returnData"])
|
||||||
|
|
||||||
const documentTypeToUse = ref(props.type)
|
const documentTypeToUse = ref(props.type === 'advanceInvoices' ? null : props.type)
|
||||||
const documentTypeItems = computed(() => {
|
const documentTypeItems = computed(() => {
|
||||||
return Object.keys(dataStore.documentTypesForCreation).map((key) => ({
|
return Object.keys(dataStore.documentTypesForCreation).filter(key => key !== 'advanceInvoices').map((key) => ({
|
||||||
...dataStore.documentTypesForCreation[key],
|
...dataStore.documentTypesForCreation[key],
|
||||||
key
|
key
|
||||||
}))
|
}))
|
||||||
@@ -83,6 +83,8 @@ const mappings = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const startImport = () => {
|
const startImport = () => {
|
||||||
|
if (!selectedDocumentType.value) return
|
||||||
|
|
||||||
router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`)
|
router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`)
|
||||||
modal.close()
|
modal.close()
|
||||||
}
|
}
|
||||||
@@ -147,7 +149,7 @@ const startImport = () => {
|
|||||||
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton @click="startImport">
|
<UButton :disabled="!selectedDocumentType" @click="startImport">
|
||||||
Kopieren
|
Kopieren
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,10 +119,13 @@ const reportRows = computed(() => {
|
|||||||
relevantCostCentreIds.value.has(account.costCentre)
|
relevantCostCentreIds.value.has(account.costCentre)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Expenses reduce the cost centre result; incoming documents marked as income increase it.
|
||||||
|
const sign = invoice.expense === false ? 1 : -1
|
||||||
|
|
||||||
return matchingAccounts.map((account, index) => {
|
return matchingAccounts.map((account, index) => {
|
||||||
const amountNet = Number(account.amountNet || 0)
|
const amountNet = Number(account.amountNet || 0) * sign
|
||||||
const amountTax = Number(account.amountTax || 0)
|
const amountTax = Number(account.amountTax || 0) * sign
|
||||||
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
|
const amountGross = Number(account.amountGross || Number(account.amountNet || 0) + Number(account.amountTax || 0) || 0) * sign
|
||||||
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
|
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -196,8 +199,7 @@ const reportRows = computed(() => {
|
|||||||
const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre))
|
const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre))
|
||||||
const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount
|
const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount
|
||||||
const amounts = getBankAllocationAmounts(allocation)
|
const amounts = getBankAllocationAmounts(allocation)
|
||||||
// As for incoming documents, expenses are positive and refunds negative.
|
// The bank amount is already signed: outflows (expenses) negative, inflows positive.
|
||||||
const sign = amounts.kind === "input" || (amounts.kind === "none" && amounts.amountGross < 0) ? -1 : 1
|
|
||||||
return [{
|
return [{
|
||||||
id: `bank-${allocation.id}`,
|
id: `bank-${allocation.id}`,
|
||||||
sourceLabel: "Bankbuchung",
|
sourceLabel: "Bankbuchung",
|
||||||
@@ -209,9 +211,9 @@ const reportRows = computed(() => {
|
|||||||
accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-",
|
accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-",
|
||||||
costCentreName: centre ? `${centre.number} - ${centre.name}` : "-",
|
costCentreName: centre ? `${centre.number} - ${centre.name}` : "-",
|
||||||
description: allocation.description || statement.text || "-",
|
description: allocation.description || statement.text || "-",
|
||||||
amountNet: amounts.amountNet * sign,
|
amountNet: amounts.amountNet,
|
||||||
amountTax: amounts.amountTax * sign,
|
amountTax: amounts.amountTax,
|
||||||
amountGross: amounts.amountGross * sign,
|
amountGross: amounts.amountGross,
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
return [...incomingRows, ...outgoingRows, ...bankRows]
|
return [...incomingRows, ...outgoingRows, ...bankRows]
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
|
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
|
||||||
.filter(([key]) => key !== 'serialInvoices')
|
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key))
|
||||||
.map(([key, value]) => ({ key, ...value })))
|
.map(([key, value]) => ({ key, ...value })))
|
||||||
const visibleTemplates = computed(() => templates.value.filter(template => {
|
const visibleTemplates = computed(() => templates.value.filter(template => {
|
||||||
return !selectedType.value || template.documentType === selectedType.value
|
return template.documentType !== 'advanceInvoices'
|
||||||
|
&& (!selectedType.value || template.documentType === selectedType.value)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const loadTemplates = async () => {
|
const loadTemplates = async () => {
|
||||||
@@ -25,6 +26,8 @@ const loadTemplates = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectTemplate = (template) => {
|
const selectTemplate = (template) => {
|
||||||
|
if (template.documentType === 'advanceInvoices') return
|
||||||
|
|
||||||
const query = new URLSearchParams(props.queryStringData)
|
const query = new URLSearchParams(props.queryStringData)
|
||||||
query.set('templateId', template.id)
|
query.set('templateId', template.id)
|
||||||
query.set('type', template.documentType)
|
query.set('type', template.documentType)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user