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 + + Ausgeblendete wiederherstellen + @@ -203,7 +256,7 @@ loadForecast()
{{ formatDate(event.date) }}
@@ -216,6 +269,27 @@ loadForecast() > {{ useCurrency(event.amount) }} +
+ + Öffnen + + + Abschließen + +
@@ -250,9 +324,20 @@ loadForecast() {{ useCurrency(item.amount) }} -

- Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }} -

+
+

+ Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }} +

+ + Als abgeschlossen entfernen + +