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:
@@ -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
|
||||
|
||||
|
||||
441
backend/src/utils/liquidityForecast.ts
Normal file
441
backend/src/utils/liquidityForecast.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -141,7 +141,7 @@ const links = computed(() => {
|
||||
to: "/accounting/depreciation",
|
||||
icon: "i-heroicons-calendar-days",
|
||||
} : null,
|
||||
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? {
|
||||
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres") || featureEnabled("banking")) ? {
|
||||
label: "Auswertungen",
|
||||
icon: "i-heroicons-chart-pie",
|
||||
defaultOpen: false,
|
||||
@@ -156,6 +156,11 @@ const links = computed(() => {
|
||||
to: "/accounting/bwa",
|
||||
icon: "i-heroicons-chart-bar-square",
|
||||
} : null,
|
||||
featureEnabled("banking") ? {
|
||||
label: "Liquidität",
|
||||
to: "/accounting/liquidity",
|
||||
icon: "i-heroicons-banknotes",
|
||||
} : null,
|
||||
featureEnabled("costcentres") ? {
|
||||
label: "Kostenstellen",
|
||||
to: "/standardEntity/costcentres",
|
||||
|
||||
@@ -97,5 +97,9 @@ export const useFunctions = () => {
|
||||
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
||||
}
|
||||
|
||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF}
|
||||
const useLiquidityForecast = async () => {
|
||||
return await useNuxtApp().$api("/api/functions/liquidity-forecast")
|
||||
}
|
||||
|
||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF}
|
||||
}
|
||||
|
||||
264
frontend/pages/accounting/liquidity.vue
Normal file
264
frontend/pages/accounting/liquidity.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs"
|
||||
import { Line } from "vue-chartjs"
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const forecast = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref("")
|
||||
|
||||
const sourceLabels = {
|
||||
open_createddocument: "Offene Ausgangsrechnung",
|
||||
open_incominginvoice: "Offener Eingangsbeleg",
|
||||
recurring_bankstatement: "Regelmäßige Bankbewegung"
|
||||
}
|
||||
|
||||
const intervalLabels = {
|
||||
weekly: "wöchentlich",
|
||||
monthly: "monatlich",
|
||||
quarterly: "quartalsweise",
|
||||
yearly: "jährlich"
|
||||
}
|
||||
|
||||
const loadForecast = async () => {
|
||||
loading.value = true
|
||||
error.value = ""
|
||||
|
||||
try {
|
||||
forecast.value = await useFunctions().useLiquidityForecast()
|
||||
} catch (err) {
|
||||
error.value = "Die Liquiditätsprognose konnte nicht geladen werden."
|
||||
toast.add({
|
||||
title: "Fehler",
|
||||
description: error.value,
|
||||
color: "error"
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (value) => dayjs(value).format("DD.MM.YYYY")
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
|
||||
datasets: [
|
||||
{
|
||||
label: "Prognostizierter Kontostand",
|
||||
borderColor: "#0f766e",
|
||||
backgroundColor: "rgba(15, 118, 110, 0.12)",
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 8,
|
||||
tension: 0.28,
|
||||
fill: true,
|
||||
data: (forecast.value?.points || []).map((point) => point.balance)
|
||||
},
|
||||
{
|
||||
label: "Warnschwelle 0 €",
|
||||
borderColor: "#ef4444",
|
||||
borderDash: [6, 6],
|
||||
pointRadius: 0,
|
||||
data: (forecast.value?.points || []).map(() => 0)
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value) => useCurrency(Number(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const upcomingEvents = computed(() => {
|
||||
return [...(forecast.value?.events || [])]
|
||||
.sort((a, b) => a.date.localeCompare(b.date) || Math.abs(b.amount) - Math.abs(a.amount))
|
||||
.slice(0, 18)
|
||||
})
|
||||
|
||||
const riskTone = computed(() => {
|
||||
if (!forecast.value) return "neutral"
|
||||
if (forecast.value.lowestBalance < 0) return "danger"
|
||||
if (forecast.value.lowestBalance < forecast.value.startingBalance * 0.15) return "warning"
|
||||
return "positive"
|
||||
})
|
||||
|
||||
loadForecast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Liquiditätsprognose">
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
:loading="loading"
|
||||
variant="soft"
|
||||
@click="loadForecast"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent class="min-w-0 space-y-6 overflow-x-hidden overflow-y-auto p-4 md:p-6">
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
:title="error"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex min-h-[360px] items-center justify-center text-sm text-gray-500">
|
||||
Liquiditätsprognose wird erstellt...
|
||||
</div>
|
||||
|
||||
<template v-else-if="forecast">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<UCard>
|
||||
<p class="text-sm text-gray-500">Aktuelle Liquidität</p>
|
||||
<p class="mt-2 text-2xl font-semibold">{{ useCurrency(forecast.startingBalance) }}</p>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<p class="text-sm text-gray-500">Prognose in {{ forecast.horizonDays }} Tagen</p>
|
||||
<p
|
||||
class="mt-2 text-2xl font-semibold"
|
||||
:class="forecast.endingBalance < 0 ? 'text-rose-600' : 'text-primary-600'"
|
||||
>
|
||||
{{ useCurrency(forecast.endingBalance) }}
|
||||
</p>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<p class="text-sm text-gray-500">Niedrigster Stand</p>
|
||||
<p
|
||||
class="mt-2 text-2xl font-semibold"
|
||||
:class="forecast.lowestBalance < 0 ? 'text-rose-600' : 'text-orange-500'"
|
||||
>
|
||||
{{ useCurrency(forecast.lowestBalance) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ formatDate(forecast.lowestBalanceDate) }}</p>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<p class="text-sm text-gray-500">KI-Erkennung</p>
|
||||
<p class="mt-2 text-2xl font-semibold">{{ forecast.recurring.length }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ forecast.ai?.enabled ? `${forecast.ai.candidates} KI-Kandidaten berücksichtigt` : "Fallback ohne KI-Key genutzt" }}
|
||||
</p>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
:color="riskTone === 'danger' ? 'error' : riskTone === 'warning' ? 'warning' : 'success'"
|
||||
:icon="riskTone === 'danger' ? 'i-heroicons-exclamation-triangle' : 'i-heroicons-chart-bar-square'"
|
||||
:title="riskTone === 'danger' ? 'Liquiditätsengpass möglich' : riskTone === 'warning' ? 'Liquidität wird knapp' : 'Liquidität bleibt positiv'"
|
||||
:description="`Die Prognose kombiniert aktuelle Kontostände, offene Belege und erkannte regelmäßige Bankbewegungen bis ${formatDate(dayjs().add(forecast.horizonDays, 'day'))}.`"
|
||||
/>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="font-semibold">Liquiditätsverlauf</h2>
|
||||
<p class="text-sm text-gray-500">Täglicher prognostizierter Kontostand</p>
|
||||
</div>
|
||||
<div class="flex gap-3 text-sm">
|
||||
<span class="text-primary-600">+ {{ useCurrency(forecast.totalIncome) }}</span>
|
||||
<span class="text-rose-600">{{ useCurrency(forecast.totalExpense) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="h-[360px]">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="font-semibold">Nächste Zahlungsereignisse</h2>
|
||||
<p class="text-sm text-gray-500">Offene Belege und prognostizierte regelmäßige Bewegungen</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="upcomingEvents.length" class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<div
|
||||
v-for="event in upcomingEvents"
|
||||
:key="`${event.source}-${event.sourceId || event.label}-${event.date}-${event.amount}`"
|
||||
class="grid gap-3 py-3 sm:grid-cols-[110px_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium">{{ event.label }}</p>
|
||||
<p class="text-xs text-gray-500">{{ sourceLabels[event.source] || event.source }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-right font-semibold"
|
||||
:class="event.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
|
||||
>
|
||||
{{ useCurrency(event.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="py-8 text-center text-sm text-gray-500">Keine Zahlungsereignisse im Prognosezeitraum erkannt.</p>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="font-semibold">Regelmäßige Bewegungen</h2>
|
||||
<p class="text-sm text-gray-500">Aus Bankumsätzen erkannt, inklusive KI-Analyse</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="forecast.recurring.length" class="space-y-3">
|
||||
<div
|
||||
v-for="item in forecast.recurring"
|
||||
:key="`${item.label}-${item.amount}-${item.nextDate}`"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium">{{ item.label }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ intervalLabels[item.interval] || item.interval }} ab {{ formatDate(item.nextDate) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-right font-semibold"
|
||||
:class="item.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
|
||||
>
|
||||
{{ useCurrency(item.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="py-8 text-center text-sm text-gray-500">Noch keine regelmäßigen Bewegungen erkannt.</p>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement } from 'chart.js'
|
||||
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, Filler } from 'chart.js'
|
||||
export default defineNuxtPlugin(() => {
|
||||
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement)
|
||||
})
|
||||
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement, Filler)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user