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:
@@ -308,8 +308,14 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
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 {
|
try {
|
||||||
return await generateLiquidityForecast(server, req.user.tenant_id)
|
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.log.error(err)
|
req.log.error(err)
|
||||||
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
|
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
import { zodResponseFormat } from "openai/helpers/zod";
|
||||||
import { and, desc, eq, gte } from "drizzle-orm";
|
import { and, desc, eq, gte } from "drizzle-orm";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankaccounts,
|
bankaccounts,
|
||||||
@@ -22,10 +23,12 @@ type ForecastEvent = {
|
|||||||
label: string;
|
label: string;
|
||||||
source: ForecastEventSource;
|
source: ForecastEventSource;
|
||||||
sourceId?: number | string | null;
|
sourceId?: number | string | null;
|
||||||
|
recurringKey?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecurringCandidate = {
|
type RecurringCandidate = {
|
||||||
|
key?: string;
|
||||||
label: string;
|
label: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
interval: "weekly" | "monthly" | "quarterly" | "yearly";
|
interval: "weekly" | "monthly" | "quarterly" | "yearly";
|
||||||
@@ -56,6 +59,17 @@ const normalizeText = (value: unknown) => String(value || "")
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.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"]) => {
|
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
|
||||||
if (interval === "weekly") return date.add(1, "week");
|
if (interval === "weekly") return date.add(1, "week");
|
||||||
if (interval === "quarterly") return date.add(3, "month");
|
if (interval === "quarterly") return date.add(3, "month");
|
||||||
@@ -246,6 +260,10 @@ const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: Recurring
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...merged.values()]
|
return [...merged.values()]
|
||||||
|
.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
key: getRecurringKey(candidate),
|
||||||
|
}))
|
||||||
.filter((candidate) => Math.abs(candidate.amount) >= 1)
|
.filter((candidate) => Math.abs(candidate.amount) >= 1)
|
||||||
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
|
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
|
||||||
};
|
};
|
||||||
@@ -263,6 +281,7 @@ const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.D
|
|||||||
amount: roundMoney(candidate.amount),
|
amount: roundMoney(candidate.amount),
|
||||||
label: candidate.label,
|
label: candidate.label,
|
||||||
source: "recurring_bankstatement",
|
source: "recurring_bankstatement",
|
||||||
|
recurringKey: candidate.key,
|
||||||
confidence: candidate.confidence,
|
confidence: candidate.confidence,
|
||||||
});
|
});
|
||||||
date = addInterval(date, candidate.interval);
|
date = addInterval(date, candidate.interval);
|
||||||
@@ -273,7 +292,11 @@ const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.D
|
|||||||
return events;
|
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 today = dayjs().startOf("day");
|
||||||
const endDate = today.add(FORECAST_DAYS, "day");
|
const endDate = today.add(FORECAST_DAYS, "day");
|
||||||
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
|
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 heuristicRecurring = detectRecurringHeuristically(activeStatements);
|
||||||
const aiRecurring = await detectRecurringWithAi(server, 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 = [
|
const events = [
|
||||||
...openEvents,
|
...openEvents,
|
||||||
...expandRecurringEvents(recurring, endDate),
|
...expandRecurringEvents(recurring, endDate),
|
||||||
|
|||||||
@@ -97,8 +97,14 @@ export const useFunctions = () => {
|
|||||||
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useLiquidityForecast = async () => {
|
const useLiquidityForecast = async (ignoredRecurringKeys = []) => {
|
||||||
return await useNuxtApp().$api("/api/functions/liquidity-forecast")
|
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}
|
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF}
|
||||||
|
|||||||
@@ -3,11 +3,23 @@ import dayjs from "dayjs"
|
|||||||
import { Line } from "vue-chartjs"
|
import { Line } from "vue-chartjs"
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const tempStore = useTempStore()
|
||||||
|
|
||||||
const forecast = ref(null)
|
const forecast = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref("")
|
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 = {
|
const sourceLabels = {
|
||||||
open_createddocument: "Offene Ausgangsrechnung",
|
open_createddocument: "Offene Ausgangsrechnung",
|
||||||
open_incominginvoice: "Offener Eingangsbeleg",
|
open_incominginvoice: "Offener Eingangsbeleg",
|
||||||
@@ -26,7 +38,7 @@ const loadForecast = async () => {
|
|||||||
error.value = ""
|
error.value = ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
forecast.value = await useFunctions().useLiquidityForecast()
|
forecast.value = await useFunctions().useLiquidityForecast(dismissedRecurringKeys.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = "Die Liquiditätsprognose konnte nicht geladen werden."
|
error.value = "Die Liquiditätsprognose konnte nicht geladen werden."
|
||||||
toast.add({
|
toast.add({
|
||||||
@@ -41,6 +53,39 @@ const loadForecast = async () => {
|
|||||||
|
|
||||||
const formatDate = (value) => dayjs(value).format("DD.MM.YYYY")
|
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(() => ({
|
const chartData = computed(() => ({
|
||||||
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
|
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -112,6 +157,14 @@ loadForecast()
|
|||||||
>
|
>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="dismissedRecurringKeys.length"
|
||||||
|
icon="i-heroicons-eye"
|
||||||
|
variant="ghost"
|
||||||
|
@click="restoreDismissedRecurring"
|
||||||
|
>
|
||||||
|
Ausgeblendete wiederherstellen
|
||||||
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
@@ -203,7 +256,7 @@ loadForecast()
|
|||||||
<div
|
<div
|
||||||
v-for="event in upcomingEvents"
|
v-for="event in upcomingEvents"
|
||||||
:key="`${event.source}-${event.sourceId || event.label}-${event.date}-${event.amount}`"
|
: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>
|
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -216,6 +269,27 @@ loadForecast()
|
|||||||
>
|
>
|
||||||
{{ useCurrency(event.amount) }}
|
{{ useCurrency(event.amount) }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,9 +324,20 @@ loadForecast()
|
|||||||
{{ useCurrency(item.amount) }}
|
{{ useCurrency(item.amount) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500">
|
<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 }}
|
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
|
||||||
</p>
|
</p>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-check-circle"
|
||||||
|
@click="dismissRecurringKey(item.key)"
|
||||||
|
>
|
||||||
|
Als abgeschlossen entfernen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user