diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts
index f6f1aff..1511d52 100644
--- a/backend/src/routes/functions.ts
+++ b/backend/src/routes/functions.ts
@@ -308,8 +308,14 @@ export default async function functionRoutes(server: FastifyInstance) {
})
server.get('/functions/liquidity-forecast', async (req, reply) => {
+ const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
+ const ignoredKeys = String(ignoredRecurringKeys || "")
+ .split(",")
+ .map((key) => key.trim())
+ .filter(Boolean)
+
try {
- return await generateLiquidityForecast(server, req.user.tenant_id)
+ return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
diff --git a/backend/src/utils/liquidityForecast.ts b/backend/src/utils/liquidityForecast.ts
index a32934c..0d7efe2 100644
--- a/backend/src/utils/liquidityForecast.ts
+++ b/backend/src/utils/liquidityForecast.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
+import { createHash } from "node:crypto";
import {
bankaccounts,
@@ -22,10 +23,12 @@ type ForecastEvent = {
label: string;
source: ForecastEventSource;
sourceId?: number | string | null;
+ recurringKey?: string;
confidence?: number;
};
type RecurringCandidate = {
+ key?: string;
label: string;
amount: number;
interval: "weekly" | "monthly" | "quarterly" | "yearly";
@@ -56,6 +59,17 @@ const normalizeText = (value: unknown) => String(value || "")
.replace(/\s+/g, " ")
.trim();
+const getRecurringKey = (candidate: RecurringCandidate) => {
+ const rawKey = [
+ Math.sign(candidate.amount),
+ Math.round(Math.abs(candidate.amount) * 100),
+ normalizeText(candidate.label),
+ candidate.interval,
+ ].join("|");
+
+ return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
+};
+
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
if (interval === "weekly") return date.add(1, "week");
if (interval === "quarterly") return date.add(3, "month");
@@ -246,6 +260,10 @@ const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: Recurring
});
return [...merged.values()]
+ .map((candidate) => ({
+ ...candidate,
+ key: getRecurringKey(candidate),
+ }))
.filter((candidate) => Math.abs(candidate.amount) >= 1)
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};
@@ -263,6 +281,7 @@ const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.D
amount: roundMoney(candidate.amount),
label: candidate.label,
source: "recurring_bankstatement",
+ recurringKey: candidate.key,
confidence: candidate.confidence,
});
date = addInterval(date, candidate.interval);
@@ -273,7 +292,11 @@ const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.D
return events;
};
-export const generateLiquidityForecast = async (server: FastifyInstance, tenantId: number) => {
+export const generateLiquidityForecast = async (
+ server: FastifyInstance,
+ tenantId: number,
+ ignoredRecurringKeys: string[] = []
+) => {
const today = dayjs().startOf("day");
const endDate = today.add(FORECAST_DAYS, "day");
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
@@ -387,7 +410,9 @@ export const generateLiquidityForecast = async (server: FastifyInstance, tenantI
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
- const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring);
+ const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
+ const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
+ .filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
const events = [
...openEvents,
...expandRecurringEvents(recurring, endDate),
diff --git a/frontend/composables/useFunctions.js b/frontend/composables/useFunctions.js
index 4673e65..777301e 100644
--- a/frontend/composables/useFunctions.js
+++ b/frontend/composables/useFunctions.js
@@ -97,8 +97,14 @@ export const useFunctions = () => {
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
}
- const useLiquidityForecast = async () => {
- return await useNuxtApp().$api("/api/functions/liquidity-forecast")
+ const useLiquidityForecast = async (ignoredRecurringKeys = []) => {
+ const query = new URLSearchParams()
+ if (ignoredRecurringKeys.length) {
+ query.set("ignoredRecurringKeys", ignoredRecurringKeys.join(","))
+ }
+
+ const suffix = query.toString() ? `?${query.toString()}` : ""
+ return await useNuxtApp().$api(`/api/functions/liquidity-forecast${suffix}`)
}
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF}
diff --git a/frontend/pages/accounting/liquidity.vue b/frontend/pages/accounting/liquidity.vue
index f32cc72..c6501dc 100644
--- a/frontend/pages/accounting/liquidity.vue
+++ b/frontend/pages/accounting/liquidity.vue
@@ -3,11 +3,23 @@ import dayjs from "dayjs"
import { Line } from "vue-chartjs"
const toast = useToast()
+const tempStore = useTempStore()
const forecast = ref(null)
const loading = ref(true)
const error = ref("")
+const dismissedRecurringKeys = computed(() => {
+ return tempStore.settings?.liquidityForecast?.dismissedRecurringKeys || []
+})
+
+const storeDismissedRecurringKeys = (keys) => {
+ tempStore.modifySettings("liquidityForecast", {
+ ...(tempStore.settings?.liquidityForecast || {}),
+ dismissedRecurringKeys: [...new Set(keys)].filter(Boolean)
+ })
+}
+
const sourceLabels = {
open_createddocument: "Offene Ausgangsrechnung",
open_incominginvoice: "Offener Eingangsbeleg",
@@ -26,7 +38,7 @@ const loadForecast = async () => {
error.value = ""
try {
- forecast.value = await useFunctions().useLiquidityForecast()
+ forecast.value = await useFunctions().useLiquidityForecast(dismissedRecurringKeys.value)
} catch (err) {
error.value = "Die Liquiditätsprognose konnte nicht geladen werden."
toast.add({
@@ -41,6 +53,39 @@ const loadForecast = async () => {
const formatDate = (value) => dayjs(value).format("DD.MM.YYYY")
+const getEventRoute = (event) => {
+ if (event.source === "open_createddocument" && event.sourceId) return `/createDocument/show/${event.sourceId}`
+ if (event.source === "open_incominginvoice" && event.sourceId) return `/incomingInvoices/show/${event.sourceId}`
+ return null
+}
+
+const openEvent = (event) => {
+ const route = getEventRoute(event)
+ if (route) navigateTo(route)
+}
+
+const dismissRecurringKey = async (key) => {
+ if (!key) return
+
+ storeDismissedRecurringKeys([...dismissedRecurringKeys.value, key])
+ toast.add({
+ title: "Bankbewegung abgeschlossen",
+ description: "Das erkannte Muster wird aus der Liquiditätsprognose entfernt.",
+ color: "success"
+ })
+ await loadForecast()
+}
+
+const restoreDismissedRecurring = async () => {
+ storeDismissedRecurringKeys([])
+ toast.add({
+ title: "Bankbewegungen wiederhergestellt",
+ description: "Ausgeblendete Muster werden wieder in der Prognose berücksichtigt.",
+ color: "success"
+ })
+ await loadForecast()
+}
+
const chartData = computed(() => ({
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
datasets: [
@@ -112,6 +157,14 @@ loadForecast()
>
Aktualisieren
+
- Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }} -
++ Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }} +
+