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) { 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> = [] 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) } }, }, ]