3.0.0 #220
@@ -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">
|
||||||
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
|
<p class="text-xs text-gray-500">
|
||||||
</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user