feat(mcp): add outgoing document status and calendar tools
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
statementAllocationUuidArg,
|
||||
} from "../statementAllocations"
|
||||
import { McpTool } from "../types"
|
||||
import { outgoingStatusTools } from "./outgoingStatus"
|
||||
|
||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||
|
||||
@@ -423,6 +424,7 @@ const assertStatementAllocationReferences = async (context: any, payload: Record
|
||||
}
|
||||
|
||||
export const accountingTools: McpTool[] = [
|
||||
...outgoingStatusTools,
|
||||
{
|
||||
name: "accounting.outgoing_documents.tax_types.list",
|
||||
title: "Steuertypen für Ausgangsbelege auflisten",
|
||||
|
||||
166
backend/src/mcp/tools/calendar.ts
Normal file
166
backend/src/mcp/tools/calendar.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { and, desc, eq, ilike, inArray, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { authProfiles, customers, events, inventoryitemgroups, inventoryitems, projects, tenants, vehicles, vendors } from "../../../db/schema"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { arrayField, booleanField, dateField, idField, InputField, nullable, objectField, textField, uuidField } from "../masterdataFields"
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { McpContext, McpTool } from "../types"
|
||||
|
||||
export const eventStates = ["Entwurf", "Final"]
|
||||
export const eventRepeatIntervals = ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"]
|
||||
const enumField = (values: string[]): InputField => ({
|
||||
schema: { type: "string", enum: values },
|
||||
parse(value, path) {
|
||||
if (typeof value !== "string" || !values.includes(value)) throw new Error(`${path}: ungültiger Wert`)
|
||||
return value
|
||||
},
|
||||
})
|
||||
export const eventDateTimeField: InputField = {
|
||||
schema: { type: "string", format: "date-time", description: "ISO-Zeitpunkt mit Zeitzone, z. B. 2026-10-15T09:00:00+02:00." },
|
||||
parse(value, path) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
|
||||
throw new Error(`${path}: ISO-Zeitpunkt mit Zeitzone erwartet`)
|
||||
}
|
||||
dateField.parse(value.slice(0, 10), path)
|
||||
if (Number(value.slice(11, 13)) > 23 || Number(value.slice(14, 16)) > 59 || Number(value.slice(17, 19)) > 59
|
||||
|| !Number.isFinite(Date.parse(value))) throw new Error(`${path}: ungültiger Zeitpunkt`)
|
||||
return new Date(value)
|
||||
},
|
||||
}
|
||||
const idsField = (field: InputField): InputField => ({
|
||||
schema: { ...arrayField(field).schema, maxItems: 1000, uniqueItems: true },
|
||||
parse(value, path) {
|
||||
if (!Array.isArray(value) || value.length > 1000) throw new Error(`${path}: maximal 1000 IDs erwartet`)
|
||||
return [...new Set(arrayField(field).parse(value, path))]
|
||||
},
|
||||
})
|
||||
const eventFields = {
|
||||
name: textField(true), startDate: eventDateTimeField, endDate: nullable(eventDateTimeField),
|
||||
eventtype: nullable(textField(true)), quick: booleanField, state: enumField(eventStates), color: nullable(textField()),
|
||||
repeatInterval: enumField(eventRepeatIntervals), notes: nullable(textField()), link: nullable(textField()),
|
||||
project: nullable(idField), customer: nullable(idField), vendor: nullable(idField),
|
||||
profiles: idsField(uuidField), vehicles: idsField(idField), inventoryitems: idsField(idField), inventoryitemgroups: idsField(uuidField),
|
||||
}
|
||||
const listFields = {
|
||||
query: textField(), project: idField, customer: idField, vendor: idField, eventtype: textField(true),
|
||||
from: eventDateTimeField, to: eventDateTimeField, state: enumField(eventStates), quick: booleanField,
|
||||
repeatInterval: enumField(eventRepeatIntervals), profile: uuidField, vehicle: idField, inventoryitem: idField, inventoryitemgroup: uuidField,
|
||||
includeRecurring: { ...booleanField, schema: { type: "boolean", default: true, description: "Bei from auch früher begonnene Wiederholungsserien liefern. Das Ergebnis enthält Serien-Stammsätze, keine einzelnen Vorkommen." } },
|
||||
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
|
||||
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
|
||||
}
|
||||
const eventScope = (tenantId: number, id: number) => and(eq(events.id, id), eq(events.tenant, tenantId), eq(events.archived, false))
|
||||
|
||||
export function prepareEventInput(action: "create" | "update" | "archive", args: Record<string, unknown>) {
|
||||
const input = objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).parse(args, "arguments")
|
||||
for (const key of action === "create" ? ["name", "startDate"] : ["id"]) {
|
||||
if (input[key] === undefined) throw new Error(`${key} ist erforderlich`)
|
||||
}
|
||||
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
|
||||
return input
|
||||
}
|
||||
|
||||
async function validateEventReferences(db: any, tenantId: number, input: Record<string, any>) {
|
||||
const references = [
|
||||
["project", projects], ["customer", customers], ["vendor", vendors], ["profiles", authProfiles],
|
||||
["vehicles", vehicles], ["inventoryitems", inventoryitems], ["inventoryitemgroups", inventoryitemgroups],
|
||||
] as const
|
||||
for (const [field, table] of references) {
|
||||
const value = input[field]
|
||||
const ids = value == null ? [] : Array.isArray(value) ? value : [value]
|
||||
if (!ids.length) continue
|
||||
const scope = table === authProfiles
|
||||
? and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal))
|
||||
: and(eq((table as any).tenant, tenantId), eq((table as any).archived, false))
|
||||
const found = await db.select({ id: table.id }).from(table).where(and(inArray(table.id, ids), scope)).for("share")
|
||||
if (found.length !== ids.length) throw new Error(`${field}: verknüpfter Datensatz im aktiven Mandanten nicht verfügbar`)
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateEvent(context: McpContext, action: "create" | "update" | "archive", args: Record<string, unknown>) {
|
||||
const { id, ...input } = prepareEventInput(action, args)
|
||||
return context.server.db.transaction(async tx => {
|
||||
let existing: typeof events.$inferSelect | undefined
|
||||
if (action !== "create") {
|
||||
;[existing] = await tx.select().from(events).where(eventScope(context.tenantId, id)).limit(1).for("update")
|
||||
if (!existing) throw new Error("Termin nicht gefunden oder archiviert")
|
||||
}
|
||||
const merged = { ...existing, ...input }
|
||||
if (action !== "archive") {
|
||||
if (merged.endDate && new Date(merged.endDate).getTime() < new Date(merged.startDate).getTime()) {
|
||||
throw new Error("endDate darf nicht vor startDate liegen")
|
||||
}
|
||||
await validateEventReferences(tx, context.tenantId, input)
|
||||
}
|
||||
const change = { ...(action === "archive" ? { archived: true } : input), updatedAt: new Date(), updatedBy: context.userId }
|
||||
const [event] = action === "create"
|
||||
? await tx.insert(events).values({ ...change, tenant: context.tenantId, name: input.name, startDate: input.startDate }).returning()
|
||||
: await tx.update(events).set(change).where(eventScope(context.tenantId, id)).returning()
|
||||
await insertHistoryItem({ ...context.server, db: tx } as any, { tenant_id: context.tenantId, created_by: context.userId,
|
||||
entity: "events", entityId: event.id, action: action === "create" ? "created" : action === "archive" ? "archived" : "updated",
|
||||
oldVal: existing, newVal: event })
|
||||
return action === "archive" ? { id: event.id, success: true, action: "archived" } : { event }
|
||||
})
|
||||
}
|
||||
|
||||
export const calendarTools: McpTool[] = [
|
||||
{
|
||||
name: "organisation.calendar.options", title: "Kalenderoptionen laden",
|
||||
description: "Lädt die konfigurierten Terminarten des Mandanten sowie erlaubte Status und Wiederholungen.",
|
||||
requiredPermissions: ["organisation.events.read"], inputSchema: objectField({}).schema,
|
||||
async handler(context, args) {
|
||||
objectField({}).parse(args, "arguments")
|
||||
const [tenant] = await context.server.db.select({ calendarConfig: tenants.calendarConfig }).from(tenants).where(eq(tenants.id, context.tenantId)).limit(1)
|
||||
if (!tenant) throw new Error("Mandant nicht gefunden")
|
||||
return { calendarConfig: tenant.calendarConfig, states: eventStates, repeatIntervals: eventRepeatIntervals }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.events.list", title: "Kalendertermine auflisten",
|
||||
description: "Listet aktive Termine und Serien-Stammsätze mit Text-, Zeitraum-, Status- und Ressourcenfiltern. from/to begrenzen Überlappungen (to exklusiv); früher begonnene Serien werden standardmäßig mitgeliefert, ohne einzelne Wiederholungen auszurechnen.",
|
||||
requiredPermissions: ["organisation.events.read"], inputSchema: objectField(listFields).schema,
|
||||
async handler(context, args) {
|
||||
const input = objectField(listFields).parse(args, "arguments")
|
||||
if (input.from && input.to && input.from >= input.to) throw new Error("from muss vor to liegen")
|
||||
const conditions = [eq(events.tenant, context.tenantId), eq(events.archived, false)]
|
||||
if (input.query) {
|
||||
const query = `%${input.query.replace(/[\\%_]/g, "\\$&")}%`
|
||||
conditions.push(or(ilike(events.name, query), ilike(events.notes, query), ilike(events.link, query)))
|
||||
}
|
||||
for (const key of ["project", "customer", "vendor", "eventtype", "state", "quick", "repeatInterval"]) {
|
||||
if (input[key] !== undefined) conditions.push(eq(events[key], input[key]))
|
||||
}
|
||||
for (const [key, column] of [["profile", events.profiles], ["vehicle", events.vehicles], ["inventoryitem", events.inventoryitems], ["inventoryitemgroup", events.inventoryitemgroups]] as const) {
|
||||
if (input[key] !== undefined) conditions.push(sql`${column} @> ${JSON.stringify([input[key]])}::jsonb`)
|
||||
}
|
||||
if (input.to) conditions.push(lt(events.startDate, input.to))
|
||||
if (input.from) conditions.push(or(
|
||||
sql`${events.endDate} > ${input.from.toISOString()}::timestamptz`,
|
||||
sql`${events.startDate} >= ${input.from.toISOString()}::timestamptz`,
|
||||
input.includeRecurring !== false ? inArray(events.repeatInterval, eventRepeatIntervals.slice(1)) : undefined,
|
||||
))
|
||||
const rows = await context.server.db.select().from(events).where(and(...conditions)).orderBy(desc(events.startDate), events.id)
|
||||
.limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
|
||||
return paginatedResult(rows, input)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.events.get", title: "Kalendertermin laden", description: "Lädt einen aktiven Termin einschließlich Wiederholung und Ressourcenzuordnungen.",
|
||||
requiredPermissions: ["organisation.events.read"], inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
||||
async handler(context, args) {
|
||||
const input = prepareEventInput("archive", args)
|
||||
const [event] = await context.server.db.select().from(events).where(eventScope(context.tenantId, input.id)).limit(1)
|
||||
if (!event) throw new Error("Termin nicht gefunden oder archiviert")
|
||||
return { event }
|
||||
},
|
||||
},
|
||||
...(["create", "update", "archive"] as const).map(action => ({
|
||||
name: `organisation.events.${action}`, title: action === "create" ? "Kalendertermin erstellen" : action === "update" ? "Kalendertermin bearbeiten" : "Kalendertermin archivieren",
|
||||
description: action === "create" ? "Erstellt einen Termin, standardmäßig Final und ohne Wiederholung. Zeitpunkte benötigen eine explizite Zeitzone."
|
||||
: action === "update" ? "Ändert nur angegebene Felder. Ressourcenlisten werden ersetzt; null leert optionale Felder. Bei Wiederholungen wird die gesamte Serie geändert."
|
||||
: "Archiviert einen Termin bzw. seine gesamte Wiederholungsserie.",
|
||||
requiredPermissions: ["organisation.events.write"],
|
||||
inputSchema: { ...objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).schema,
|
||||
required: action === "create" ? ["name", "startDate"] : ["id"] },
|
||||
handler: (context: McpContext, args: Record<string, unknown>) => mutateEvent(context, action, args),
|
||||
})),
|
||||
]
|
||||
@@ -1,9 +1,10 @@
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||
import { customers, plants, projects, tasks } from "../../../db/schema"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { activateProjectPhase } from "../projectPhases"
|
||||
import { McpTool } from "../types"
|
||||
import { calendarTools } from "./calendar"
|
||||
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
@@ -17,6 +18,7 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
}
|
||||
|
||||
export const organisationTools: McpTool[] = [
|
||||
...calendarTools,
|
||||
{
|
||||
name: "organisation.customers.search",
|
||||
title: "Kunden suchen",
|
||||
@@ -238,51 +240,6 @@ export const organisationTools: McpTool[] = [
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.events.list",
|
||||
title: "Termine auflisten",
|
||||
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
|
||||
requiredPermissions: ["organisation.events.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
eventtype: { type: "string" },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(events.tenant, context.tenantId)]
|
||||
const query = stringArg(args, "query")
|
||||
const project = numberArg(args, "project")
|
||||
const customer = numberArg(args, "customer")
|
||||
const eventtype = stringArg(args, "eventtype")
|
||||
|
||||
if (query) {
|
||||
conditions.push(or(
|
||||
ilike(events.name, `%${query}%`),
|
||||
ilike(events.notes, `%${query}%`),
|
||||
ilike(events.link, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (project) conditions.push(eq(events.project, project))
|
||||
if (customer) conditions.push(eq(events.customer, customer))
|
||||
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||
conditions.push(eq(events.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(events.startDate), events.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "organisation.tasks.list",
|
||||
title: "Aufgaben auflisten",
|
||||
|
||||
130
backend/src/mcp/tools/outgoingStatus.ts
Normal file
130
backend/src/mcp/tools/outgoingStatus.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { and, desc, eq, inArray, ne } from "drizzle-orm"
|
||||
import { createddocuments, statementallocations } from "../../../db/schema"
|
||||
import { booleanField, idField, InputField, objectField, textField } from "../masterdataFields"
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { McpContext, McpTool } from "../types"
|
||||
|
||||
const paymentStates = ["Offen", "Teilbezahlt", "Bezahlt", "Überbezahlt"]
|
||||
const paymentField: InputField = {
|
||||
schema: { type: "string", enum: paymentStates },
|
||||
parse(value) {
|
||||
if (typeof value !== "string" || !paymentStates.includes(value)) throw new Error("Unbekannter Zahlungsstatus")
|
||||
return value
|
||||
},
|
||||
}
|
||||
const listFields = {
|
||||
state: textField(true), type: textField(true), customer: idField, project: idField,
|
||||
cancelled: booleanField, paymentStatus: paymentField,
|
||||
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
|
||||
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
|
||||
}
|
||||
const money = (value: number) => Number(value.toFixed(2))
|
||||
|
||||
function documentAmount(document: any, advanceInvoices: any[]) {
|
||||
let net = 0, tax19 = 0, tax7 = 0, advanceAmount = 0
|
||||
for (const row of document.rows || []) {
|
||||
if (["pagebreak", "title", "text"].includes(row.mode)) continue
|
||||
const values = [row.quantity, row.price, row.discountPercent, row.taxPercent].map(value => Number(value ?? 0))
|
||||
if (!values.every(Number.isFinite)) throw new Error(`Beleg ${document.id}: ungültiger Positionsbetrag`)
|
||||
const [quantity, price, discount, tax] = values
|
||||
const rowNet = Number((quantity * price * (1 - discount / 100)).toFixed(3))
|
||||
net += rowNet
|
||||
if (tax === 19) tax19 += rowNet * 0.19
|
||||
if (tax === 7) tax7 += rowNet * 0.07
|
||||
}
|
||||
for (const id of document.usedAdvanceInvoices || []) {
|
||||
const row = advanceInvoices.find(invoice => invoice.id === id)?.rows?.find(row => row.advanceInvoiceData)
|
||||
if (row) advanceAmount += Number(row.price) * ((100 + Number(row.taxPercent)) / 100)
|
||||
}
|
||||
// Match the outgoing-document screen (useSum): round net, 19% and 7% separately.
|
||||
// Aggregating both tax buckets before rounding can falsely report a one-cent debt.
|
||||
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
||||
}
|
||||
|
||||
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
|
||||
const amount = documentAmount(document, advanceInvoices)
|
||||
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
||||
const openAmount = money(amount - paidAmount)
|
||||
const cancelled = cancellations.length > 0
|
||||
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
||||
// Signed invoices and refunds use the same payment-direction comparison.
|
||||
const direction = Math.sign(amount) || 1
|
||||
const paymentStatus = !applicable ? null : openAmount === 0 ? "Bezahlt"
|
||||
: openAmount * direction < 0 ? "Überbezahlt" : paidAmount * direction > 0 ? "Teilbezahlt" : "Offen"
|
||||
return {
|
||||
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
||||
state: document.state, customer: document.customer, project: document.project,
|
||||
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
||||
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
|
||||
}
|
||||
}
|
||||
|
||||
async function statusesFor(context: McpContext, documents: Array<typeof createddocuments.$inferSelect>) {
|
||||
if (!documents.length) return []
|
||||
const ids = documents.map(document => document.id)
|
||||
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[] : []))]
|
||||
const [allocations, cancellations, advances] = await Promise.all([
|
||||
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
|
||||
.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)
|
||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
||||
ne(createddocuments.state, "Entwurf"), inArray(createddocuments.createddocument, ids))).orderBy(createddocuments.id),
|
||||
advanceIds.length ? context.server.db.select().from(createddocuments)
|
||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
||||
])
|
||||
return documents.map(document => outgoingDocumentStatus(document,
|
||||
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
||||
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
|
||||
}
|
||||
|
||||
export const outgoingStatusTools: McpTool[] = [
|
||||
{
|
||||
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.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
||||
async handler(context, args) {
|
||||
const { id } = objectField({ id: idField }).parse(args, "arguments")
|
||||
if (id === undefined) throw new Error("id ist erforderlich")
|
||||
const documents = await context.server.db.select().from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false))).limit(1)
|
||||
if (!documents.length) throw new Error("Ausgangsbeleg nicht gefunden oder archiviert")
|
||||
const [status] = await statusesFor(context, documents)
|
||||
return { status }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.status.list", title: "Ausgangsbelege nach Status filtern",
|
||||
description: "Listet Status und Beträge aktiver Ausgangsbelege. Kombinierbare Filter: state (z. B. Entwurf/Gebucht), paymentStatus, cancelled, type, customer und project. Statusfilter werden vor der Pagination angewendet. Archivierte Zahlungen und Stornos zählen nicht.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: objectField(listFields).schema,
|
||||
async handler(context, args) {
|
||||
const input = objectField(listFields).parse(args, "arguments")
|
||||
const conditions = [eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)]
|
||||
for (const key of ["state", "type", "customer", "project"]) {
|
||||
if (input[key] !== undefined) conditions.push(eq(createddocuments[key], input[key]))
|
||||
}
|
||||
const limit = limitFromArgs(input)
|
||||
const offset = offsetFromArgs(input)
|
||||
const query = (size: number, start: number) => context.server.db.select().from(createddocuments).where(and(...conditions))
|
||||
.orderBy(desc(createddocuments.createdAt), createddocuments.id).limit(size).offset(start)
|
||||
if (input.paymentStatus === undefined && input.cancelled === undefined) {
|
||||
return paginatedResult(await statusesFor(context, await query(limit + 1, offset)), input)
|
||||
}
|
||||
// Derived statuses need related payments/credit documents. Scan bounded batches so filtering
|
||||
// happens before pagination without loading an entire tenant's documents into memory.
|
||||
const rows: Awaited<ReturnType<typeof statusesFor>> = []
|
||||
let matched = 0
|
||||
for (let start = 0; ; start += 100) {
|
||||
const documents = await query(100, start)
|
||||
for (const status of await statusesFor(context, documents)) {
|
||||
if (input.paymentStatus !== undefined && status.paymentStatus !== input.paymentStatus) continue
|
||||
if (input.cancelled !== undefined && status.cancelled !== input.cancelled) continue
|
||||
if (matched++ >= offset) rows.push(status)
|
||||
if (rows.length > limit) return paginatedResult(rows, input)
|
||||
}
|
||||
if (documents.length < 100) return paginatedResult(rows, input)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -57,6 +57,7 @@ const adminPermissions = [
|
||||
"organisation.projects.write",
|
||||
"organisation.plants.read",
|
||||
"organisation.events.read",
|
||||
"organisation.events.write",
|
||||
"organisation.tasks.read",
|
||||
"organisation.tasks.write",
|
||||
"wiki.read",
|
||||
|
||||
Reference in New Issue
Block a user