3.0.0 #220

Merged
flfeders merged 676 commits from dev into main 2026-09-20 16:10:48 +00:00
4 changed files with 132 additions and 10 deletions
Showing only changes of commit aaf91ea15e - Show all commits

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">
<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>