feat(mcp): Archivierte Einträge wiederherstellen und Kontostände abfragen
- archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag - accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ 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"
|
||||||
|
|
||||||
export const mcpTools = [
|
export const mcpTools = [
|
||||||
...accountingTools,
|
...accountingTools,
|
||||||
@@ -10,6 +11,7 @@ export const mcpTools = [
|
|||||||
...organisationTools,
|
...organisationTools,
|
||||||
...wikiTools,
|
...wikiTools,
|
||||||
...documentTools,
|
...documentTools,
|
||||||
|
...archiveTools,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
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 { 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 { loadBankBalances } from "../../modules/bank-balances.service"
|
||||||
|
import { dateField } from "../masterdataFields"
|
||||||
import {
|
import {
|
||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
@@ -1310,6 +1312,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",
|
||||||
|
|||||||
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
|
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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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 {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -33,9 +34,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")
|
||||||
|
|||||||
@@ -270,7 +270,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")),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user