3.0.0 #220
@@ -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