Compare commits
1 Commits
v3.15.0
...
0777315d1d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0777315d1d |
@@ -2,18 +2,37 @@
|
||||
name: 🐛 Bug Report
|
||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||
title: '[BUG] '
|
||||
labels: Problem
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Beschreibung**
|
||||
|
||||
Eine klare und prägnante Beschreibung des Fehlers.
|
||||
|
||||
**Reproduktion**
|
||||
Schritte, um den Fehler zu reproduzieren:
|
||||
|
||||
Entweder:
|
||||
1. Gehe zu '...'
|
||||
2. Klicke auf '...'
|
||||
3. Scrolle runter zu '...'
|
||||
4. Siehe Fehler
|
||||
|
||||
Oder Link zur Seite
|
||||
|
||||
**Erwartetes Verhalten**
|
||||
Eine klare Beschreibung dessen, was du erwartet hast.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
|
||||
|
||||
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
||||
|
||||
**Umgebung:**
|
||||
- Betriebssystem: [z.B. Windows, macOS, Linux]
|
||||
- Browser / Version (falls relevant): [z.B. Chrome 120]
|
||||
- Projekt-Version: [z.B. v1.0.2]
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Füge hier alle anderen Informationen zum Problem hinzu.
|
||||
@@ -2,16 +2,19 @@
|
||||
name: ✨ Feature Request
|
||||
about: Schlage eine Idee für dieses Projekt vor.
|
||||
title: '[FEATURE] '
|
||||
labels: Funktionswunsch
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||
|
||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
||||
|
||||
**Lösungsvorschlag**
|
||||
|
||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
||||
|
||||
**Alternativen**
|
||||
Hast du über alternative Lösungen oder Workarounds nachgedacht?
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.
|
||||
@@ -3,6 +3,7 @@ import { masterdataTools } from "./tools/masterdata"
|
||||
import { organisationTools } from "./tools/organisation"
|
||||
import { wikiTools } from "./tools/wiki"
|
||||
import { documentTools } from "./tools/documents"
|
||||
import { archiveTools } from "./tools/archive"
|
||||
|
||||
export const mcpTools = [
|
||||
...accountingTools,
|
||||
@@ -10,6 +11,7 @@ export const mcpTools = [
|
||||
...organisationTools,
|
||||
...wikiTools,
|
||||
...documentTools,
|
||||
...archiveTools,
|
||||
]
|
||||
|
||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||
|
||||
@@ -22,6 +22,8 @@ import { saveFile } from "../../utils/files"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||
import { loadBankBalances } from "../../modules/bank-balances.service"
|
||||
import { dateField } from "../masterdataFields"
|
||||
import {
|
||||
prepareStatementAllocationInput,
|
||||
statementAllocationUuidArg,
|
||||
@@ -1310,6 +1312,26 @@ export const accountingTools: McpTool[] = [
|
||||
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",
|
||||
title: "Bankumsätze auflisten",
|
||||
|
||||
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]
|
||||
@@ -23,6 +23,8 @@ export type McpTool = {
|
||||
title: string
|
||||
description: string
|
||||
requiredPermissions: string[]
|
||||
/** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */
|
||||
readOnly?: boolean
|
||||
inputSchema: Record<string, 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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||
import { centralServicesClient } from "../modules/push-server.client"
|
||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
@@ -33,9 +34,7 @@ import {
|
||||
desc,
|
||||
} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
||||
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
||||
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
||||
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
||||
|
||||
@@ -270,7 +270,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
||||
readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function insertHistoryItem(
|
||||
created_by: string | null
|
||||
entity: string
|
||||
entityId: string | number
|
||||
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
|
||||
action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored"
|
||||
oldVal?: any
|
||||
newVal?: any
|
||||
text?: string
|
||||
@@ -62,6 +62,7 @@ export async function insertHistoryItem(
|
||||
updated: `Eintrag in ${entityLabel} geändert`,
|
||||
unchanged: `Eintrag in ${entityLabel} unverändert`,
|
||||
archived: `Eintrag in ${entityLabel} archiviert`,
|
||||
restored: `Eintrag in ${entityLabel} wiederhergestellt`,
|
||||
deleted: `Eintrag in ${entityLabel} gelöscht`
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
})
|
||||
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)
|
||||
})
|
||||
@@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
|
||||
return values
|
||||
}) }
|
||||
}, { 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[] = []
|
||||
let offset = 0
|
||||
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.pagination.hasMore, page < 2)
|
||||
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||
|
||||
Reference in New Issue
Block a user