Liquiditätsprognose für Auswertungen ergänzt

Erstellt einen KI-gestützten Backend-Endpunkt für die Liquiditätsprognose, ergänzt die Auswertungsseite mit Verlauf, offenen Belegen und regelmäßigen Bankbewegungen und verlinkt die Funktion in der Navigation.
This commit is contained in:
2026-04-23 15:52:41 +02:00
parent 81b4eee1e8
commit 75148b2718
6 changed files with 729 additions and 5 deletions

View File

@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -306,6 +307,15 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
})
server.get('/functions/liquidity-forecast', async (req, reply) => {
try {
return await generateLiquidityForecast(server, req.user.tenant_id)
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
}
})
server.post('/functions/services/backfillfiletext', async (req, reply) => {
const tenantId = req.user.tenant_id

View File

@@ -0,0 +1,441 @@
import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import {
bankaccounts,
bankstatements,
createddocuments,
incominginvoices,
statementallocations,
} from "../../db/schema";
import { secrets } from "./secrets";
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement";
type ForecastEvent = {
date: string;
amount: number;
label: string;
source: ForecastEventSource;
sourceId?: number | string | null;
confidence?: number;
};
type RecurringCandidate = {
label: string;
amount: number;
interval: "weekly" | "monthly" | "quarterly" | "yearly";
nextDate: string;
confidence: number;
evidence: string;
};
const AiRecurringFormat = z.object({
candidates: z.array(z.object({
label: z.string(),
amount: z.number(),
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
nextDate: z.string(),
confidence: z.number().min(0).max(1),
evidence: z.string(),
})),
});
const FORECAST_DAYS = 90;
const HISTORY_MONTHS = 12;
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
const normalizeText = (value: unknown) => String(value || "")
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
if (interval === "weekly") return date.add(1, "week");
if (interval === "quarterly") return date.add(3, "month");
if (interval === "yearly") return date.add(1, "year");
return date.add(1, "month");
};
const getStatementPartner = (statement: any) => {
return statement.amount < 0
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
};
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
let totalNet = 0;
let totalTax = 0;
(document.rows || []).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return;
const rowNet = Number(
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
);
const taxPercent = Number(row.taxPercent);
totalNet += rowNet;
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
});
let advancePayments = 0;
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
if (!advanceRow) return;
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
});
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
};
const getIncomingInvoiceSignedAmount = (invoice: any) => {
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
}, 0);
return roundMoney(invoice.expense === false ? amount : amount * -1);
};
const findCancellationDocumentIds = (documents: any[]) => {
return new Set(
documents
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
.filter(Boolean)
);
};
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
if (dates.length < 3) return null;
const gaps = dates
.slice(1)
.map((date, index) => date.diff(dates[index], "day"))
.filter((gap) => gap > 0);
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
if (averageGap >= 6 && averageGap <= 8) return "weekly";
if (averageGap >= 25 && averageGap <= 35) return "monthly";
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
if (averageGap >= 340 && averageGap <= 390) return "yearly";
return null;
};
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
const groups = new Map<string, any[]>();
statements.forEach((statement) => {
const parsedDate = dayjs(statement.valueDate || statement.date);
if (!parsedDate.isValid()) return;
const partner = normalizeText(getStatementPartner(statement));
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(statement);
});
return [...groups.values()]
.map((group) => {
const sorted = group
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
if (!interval) return null;
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
while (next.isBefore(dayjs(), "day")) {
next = addInterval(next, interval);
}
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
const partner = getStatementPartner(sorted[sorted.length - 1]);
return {
label: partner,
amount,
interval,
nextDate: next.format("YYYY-MM-DD"),
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
};
})
.filter(Boolean) as RecurringCandidate[];
};
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
const compactStatements = statements.slice(0, 220).map((statement) => ({
date: statement.valueDate || statement.date,
amount: roundMoney(Number(statement.amount || 0)),
partner: getStatementPartner(statement),
text: String(statement.text || "").slice(0, 160),
}));
try {
const completion = await openai.chat.completions.parse({
model: "gpt-4o",
store: true,
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
messages: [
{
role: "system",
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
},
{
role: "user",
content: JSON.stringify({
today: dayjs().format("YYYY-MM-DD"),
horizonDays: FORECAST_DAYS,
bankStatements: compactStatements,
rules: [
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
"nextDate muss in der Zukunft liegen.",
"Nutze keine einmaligen oder unsicheren Bewegungen.",
],
}),
},
],
});
return (completion.choices[0].message.parsed?.candidates || [])
.filter((candidate) => dayjs(candidate.nextDate).isValid())
.map((candidate) => ({
...candidate,
amount: roundMoney(candidate.amount),
confidence: Math.max(0, Math.min(1, candidate.confidence)),
}));
} catch (error) {
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
server.log.warn(error);
return [];
}
};
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
const merged = new Map<string, RecurringCandidate>();
[...heuristic, ...ai].forEach((candidate) => {
const key = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label).slice(0, 32),
candidate.interval,
].join("|");
const existing = merged.get(key);
if (!existing || candidate.confidence > existing.confidence) {
merged.set(key, candidate);
}
});
return [...merged.values()]
.filter((candidate) => Math.abs(candidate.amount) >= 1)
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
const events: ForecastEvent[] = [];
recurring.forEach((candidate) => {
let date = dayjs(candidate.nextDate);
let guard = 0;
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
events.push({
date: date.format("YYYY-MM-DD"),
amount: roundMoney(candidate.amount),
label: candidate.label,
source: "recurring_bankstatement",
confidence: candidate.confidence,
});
date = addInterval(date, candidate.interval);
guard += 1;
}
});
return events;
};
export const generateLiquidityForecast = async (server: FastifyInstance, tenantId: number) => {
const today = dayjs().startOf("day");
const endDate = today.add(FORECAST_DAYS, "day");
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
const [accounts, statements, documents, incomingInvoices, allocations] = await Promise.all([
server.db
.select()
.from(bankaccounts)
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
.orderBy(desc(bankstatements.valueDate)),
server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
server.db
.select()
.from(statementallocations)
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
]);
const startingBalance = roundMoney(
accounts
.filter((account) => !account.expired)
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
);
const allocationByDocument = new Map<number, number>();
const allocationByIncomingInvoice = new Map<number, number>();
allocations.forEach((allocation) => {
if (allocation.createddocument) {
allocationByDocument.set(
allocation.createddocument,
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
);
}
if (allocation.incominginvoice) {
allocationByIncomingInvoice.set(
allocation.incominginvoice,
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
);
}
});
const cancelledDocumentIds = findCancellationDocumentIds(documents);
const openEvents: ForecastEvent[] = [];
documents
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, documents);
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
if (openAmount <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
source: "open_createddocument",
sourceId: document.id,
confidence: 1,
});
});
incomingInvoices
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
.forEach((invoice) => {
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
const openAmount = roundMoney(signedAmount + (allocationByIncomingInvoice.get(invoice.id) || 0));
if (Math.abs(openAmount) <= 0.01) return;
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
? dayjs(invoice.dueDate || invoice.date)
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
source: "open_incominginvoice",
sourceId: invoice.id,
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
});
});
const heuristicRecurring = detectRecurringHeuristically(statements);
const aiRecurring = await detectRecurringWithAi(server, statements);
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring);
const events = [
...openEvents,
...expandRecurringEvents(recurring, endDate),
].filter((event) => {
const date = dayjs(event.date);
return date.isValid() && !date.isAfter(endDate, "day");
});
const dailyEvents = new Map<string, ForecastEvent[]>();
events.forEach((event) => {
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
dailyEvents.get(event.date)!.push(event);
});
let runningBalance = startingBalance;
const points = [];
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
const date = today.add(offset, "day").format("YYYY-MM-DD");
const dayEvents = dailyEvents.get(date) || [];
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
runningBalance = roundMoney(runningBalance + income + expense);
points.push({
date,
balance: runningBalance,
income,
expense,
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
});
}
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
return {
generatedAt: new Date().toISOString(),
horizonDays: FORECAST_DAYS,
startingBalance,
endingBalance: points[points.length - 1]?.balance || startingBalance,
lowestBalance: lowestPoint?.balance || startingBalance,
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
totalIncome,
totalExpense,
accounts: accounts.map((account) => ({
id: account.id,
name: account.name,
iban: account.iban,
balance: roundMoney(Number(account.balance || 0)),
expired: account.expired,
syncedAt: account.syncedAt,
})),
recurring,
events: events.sort((a, b) => a.date.localeCompare(b.date)),
points,
ai: {
enabled: Boolean(secrets.OPENAI_API_KEY),
candidates: aiRecurring.length,
},
};
};