Belege und Bankmuster in Liquiditätsprognose verwalten

Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
This commit is contained in:
2026-04-23 16:03:37 +02:00
parent cb71e9d294
commit aaf91ea15e
4 changed files with 132 additions and 10 deletions

View File

@@ -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." })

View File

@@ -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),

View File

@@ -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}

View File

@@ -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
</UButton>
<UButton
v-if="dismissedRecurringKeys.length"
icon="i-heroicons-eye"
variant="ghost"
@click="restoreDismissedRecurring"
>
Ausgeblendete wiederherstellen
</UButton>
</template>
</UDashboardNavbar>
@@ -203,7 +256,7 @@ loadForecast()
<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]"
class="grid gap-3 py-3 sm:grid-cols-[110px_minmax(0,1fr)_auto_auto]"
>
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
<div class="min-w-0">
@@ -216,6 +269,27 @@ loadForecast()
>
{{ useCurrency(event.amount) }}
</span>
<div class="flex justify-end gap-1">
<UButton
v-if="getEventRoute(event)"
size="xs"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="openEvent(event)"
>
Öffnen
</UButton>
<UButton
v-if="event.source === 'recurring_bankstatement' && event.recurringKey"
size="xs"
color="gray"
variant="ghost"
icon="i-heroicons-check-circle"
@click="dismissRecurringKey(event.recurringKey)"
>
Abschließen
</UButton>
</div>
</div>
</div>
@@ -250,9 +324,20 @@ loadForecast()
{{ useCurrency(item.amount) }}
</span>
</div>
<p class="mt-2 text-xs text-gray-500">
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
</p>
<div class="mt-2 flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-gray-500">
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
</p>
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-check-circle"
@click="dismissRecurringKey(item.key)"
>
Als abgeschlossen entfernen
</UButton>
</div>
</div>
</div>