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) => {
|
||||
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." })
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user