From b91c9d0fd86a755de573936a7362a8ef1cbea4b0 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 28 May 2026 16:35:50 +0200 Subject: [PATCH] KI-AGENT: Kassenbuch ins Bankportal integrieren --- backend/src/routes/banking.ts | 9 +- frontend/pages/banking/index.vue | 355 +++++++++++++++++++++++++++++-- 2 files changed, 342 insertions(+), 22 deletions(-) diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index d5b1684..aebe488 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -285,6 +285,7 @@ export default async function bankingRoutes(server: FastifyInstance) { const insertedStatements = await tx.insert(bankstatements).values({ account: cashbookId, date: dayjs(body.date).format("YYYY-MM-DD"), + valueDate: dayjs(body.date).format("YYYY-MM-DD"), amount: signedAmount, tenant: req.user.tenant_id, text: description, @@ -687,7 +688,7 @@ export default async function bankingRoutes(server: FastifyInstance) { if (matchesBankAccountId && matchesIban) { score = 100 - reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein" + reason = "IBAN und hinterlegte Bankverbindung stimmen überein" } else if (matchesBankAccountId) { score = 95 reason = "Hinterlegte Bankverbindung passt zur IBAN" @@ -699,7 +700,7 @@ export default async function bankingRoutes(server: FastifyInstance) { reason = "Name passt exakt zur Buchung" } else if (partialNameMatch) { score = 45 - reason = "Name aehnelt der Buchung" + reason = "Name ähnelt der Buchung" } if (!score) continue @@ -743,7 +744,7 @@ export default async function bankingRoutes(server: FastifyInstance) { if (matchesBankAccountId && matchesIban) { score = 100 - reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein" + reason = "IBAN und hinterlegte Bankverbindung stimmen überein" } else if (matchesBankAccountId) { score = 95 reason = "Hinterlegte Bankverbindung passt zur IBAN" @@ -755,7 +756,7 @@ export default async function bankingRoutes(server: FastifyInstance) { reason = "Name passt exakt zur Buchung" } else if (partialNameMatch) { score = 45 - reason = "Name aehnelt der Buchung" + reason = "Name ähnelt der Buchung" } if (!score) continue diff --git a/frontend/pages/banking/index.vue b/frontend/pages/banking/index.vue index 461090f..53d4076 100644 --- a/frontend/pages/banking/index.vue +++ b/frontend/pages/banking/index.vue @@ -14,8 +14,10 @@ const route = useRoute() const bankstatements = ref([]) const bankaccounts = ref([]) +const accounts = ref([]) const customers = ref([]) const vendors = ref([]) +const ownaccounts = ref([]) const entitybankaccounts = ref([]) const createddocuments = ref([]) const incominginvoices = ref([]) @@ -23,9 +25,15 @@ const openDocuments = ref([]) const openIncomingInvoices = ref([]) const filterAccount = ref([]) const isSyncing = ref(false) +const cashbookBookingModalOpen = ref(false) +const savingCashbookBooking = ref(false) const loadingDocs = ref(true) // Startet im Ladezustand const suggestionsModalOpen = ref(false) const selectedSuggestionRowId = ref(null) +const counterSearch = ref("") +const expandedGroups = ref([]) + +const CASHBOOK_BANK_ID = "fedeo-cashbook" // Zeitraum-Optionen const periodOptions = [ @@ -52,6 +60,23 @@ const dateRange = ref({ end: $dayjs().endOf('month').format('YYYY-MM-DD') }) +const cashbookForm = reactive({ + date: $dayjs().format("YYYY-MM-DD"), + direction: "expense", + amount: null, + counter: "", + datevTaxKey: "__none__", + description: "" +}) + +const DATEV_TAX_KEY_ITEMS = [ + { value: "__none__", label: "Ohne Steuerschlüssel" }, + { value: "9", label: "9 - Vorsteuer 19 %" }, + { value: "8", label: "8 - Vorsteuer 7 %" }, + { value: "19", label: "19 - EU Vorsteuer 19 %" }, + { value: "18", label: "18 - EU Vorsteuer 7 %" } +] + const getCalendarValue = (value) => { if (!value) return undefined @@ -72,20 +97,27 @@ const setDateRangeFieldToToday = (field) => { const setupPage = async () => { loadingDocs.value = true try { - const [statements, accounts, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([ + const [statements, bankAccountItems, accountItems, customerItems, vendorItems, ownAccountItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([ useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false), useEntities("bankaccounts").select(), + useEntities("accounts").selectSpecial("*", "number", true), useEntities("customers").select(), useEntities("vendors").select(), + useEntities("ownaccounts").select(), useEntities("entitybankaccounts").select(), useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"), useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)") ]) bankstatements.value = statements - bankaccounts.value = accounts + bankaccounts.value = (bankAccountItems || []).map((account) => ({ + ...account, + displayLabel: getBankAccountLabel(account) + })) + accounts.value = accountItems customers.value = customerItems vendors.value = vendorItems + ownaccounts.value = ownAccountItems entitybankaccounts.value = entityBankItems createddocuments.value = documentItems incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht") @@ -179,7 +211,7 @@ const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur of const shouldShowMonthDivider = (row, index) => { if (index === 0) return true; const prevRow = filteredRows.value[index - 1]; - return $dayjs(row.valueDate).format('MMMM YYYY') !== $dayjs(prevRow.valueDate).format('MMMM YYYY'); + return $dayjs(getStatementDate(row)).format('MMMM YYYY') !== $dayjs(getStatementDate(prevRow)).format('MMMM YYYY'); } const calculateOpenSum = (statement) => { @@ -205,6 +237,149 @@ const getInvoiceSum = (invoice, onlyOpenSum) => { } } +const getStatementDate = (statement) => statement.valueDate || statement.date +const isCashbookAccount = (account) => account?.bankId === CASHBOOK_BANK_ID +const getBankAccountId = (account) => typeof account === "object" ? account?.id : account +const resolveBankAccount = (accountOrId) => { + if (!accountOrId) return null + if (typeof accountOrId === "object") return accountOrId + return bankaccounts.value.find((account) => account.id === accountOrId) || null +} +const getBankAccountLabel = (account) => { + if (!account) return "" + if (isCashbookAccount(account)) return `${account.name || account.ownerName || "Kassenbuch"} - Konto ${account.datevNumber || account.iban}` + return `${account.name || account.ownerName || "Bankkonto"} - ${account.iban}` +} +const selectedCashbookAccount = computed(() => { + if (filterAccount.value.length !== 1) return null + const account = resolveBankAccount(filterAccount.value[0]) + return isCashbookAccount(account) ? account : null +}) +const currentCashbookBalance = computed(() => { + if (!selectedCashbookAccount.value) return 0 + const openingBalance = Number(selectedCashbookAccount.value.balance || 0) + const movementSum = bankstatements.value + .filter((statement) => statement.account === selectedCashbookAccount.value.id) + .reduce((sum, statement) => sum + Number(statement.amount || 0), 0) + return openingBalance + movementSum +}) +const getIncomingInvoiceGross = (invoice) => Number((invoice.accounts || []).reduce((sum, account) => { + return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0) +}, 0)) +const getIncomingInvoiceOpenAmount = (invoice) => { + const gross = getIncomingInvoiceGross(invoice) + const allocated = Number((invoice.statementallocations || []).reduce((sum, allocation) => sum + Number(allocation.amount || 0), 0)) + return Math.abs(gross) - Math.abs(allocated) +} +const buildCashbookEntries = (rows, type, labelBuilder) => + (rows || []).map((item) => ({ + key: `${type}:${item.id}`, + id: item.id, + type, + number: item.number || item.vendorNumber || item.customerNumber || item.reference || "", + name: item.label || item.name || item.vendor?.name || "", + label: labelBuilder(item), + typeLabel: + type === "account" + ? "Sachkonten" + : type === "vendor" + ? "Kreditoren" + : type === "customer" + ? "Debitoren" + : type === "incominginvoice" + ? "Eingangsbelege" + : "Zusätzliche Konten" + })) +const cashbookEntryGroups = computed(() => ([ + { + key: "account", + label: "Sachkonten", + entries: buildCashbookEntries(accounts.value, "account", (item) => `${item.number} - ${item.label}`) + }, + { + key: "vendor", + label: "Kreditoren", + entries: buildCashbookEntries(vendors.value, "vendor", (item) => `${item.vendorNumber || "ohne Nr."} - ${item.name}`) + }, + { + key: "customer", + label: "Debitoren", + entries: buildCashbookEntries(customers.value, "customer", (item) => `${item.customerNumber || "ohne Nr."} - ${item.name}`) + }, + { + key: "ownaccount", + label: "Zusätzliche Konten", + entries: buildCashbookEntries(ownaccounts.value, "ownaccount", (item) => `${item.number} - ${item.name}`) + }, + { + key: "incominginvoice", + label: "Eingangsbelege", + entries: buildCashbookEntries( + incominginvoices.value.filter((invoice) => !invoice.archived && getIncomingInvoiceOpenAmount(invoice) > 0.004), + "incominginvoice", + (item) => `${item.reference || "Ohne Referenz"} - ${item.vendor?.name || "Ohne Lieferant"} - Offen ${displayCurrency(getIncomingInvoiceOpenAmount(item))}` + ) + } +])) +const groupedCashbookCounterEntries = computed(() => + cashbookEntryGroups.value.map((group) => ({ + ...group, + entries: group.entries.filter((entry) => { + const search = normalizeSuggestionText(counterSearch.value) + if (!search) return true + return [entry.number, entry.name, entry.label, entry.typeLabel] + .some((value) => normalizeSuggestionText(value).includes(search)) + }) + })).filter((group) => group.entries.length > 0) +) +const selectedCashbookCounter = computed(() => cashbookEntryGroups.value.flatMap((group) => group.entries).find((entry) => entry.key === cashbookForm.counter)) +const isCashbookGroupExpanded = (groupKey) => expandedGroups.value.includes(groupKey) +const toggleCashbookGroupExpanded = (groupKey) => { + if (expandedGroups.value.includes(groupKey)) { + expandedGroups.value = expandedGroups.value.filter((item) => item !== groupKey) + return + } + expandedGroups.value = [...expandedGroups.value, groupKey] +} +const visibleCashbookEntries = (group) => { + if (group.entries.length <= 5 || isCashbookGroupExpanded(group.key)) return group.entries + return group.entries.slice(0, 5) +} +const saveCashbookBooking = async () => { + if (!selectedCashbookAccount.value || !cashbookForm.date || !cashbookForm.amount || !cashbookForm.counter) { + toast.add({ title: "Bitte Kasse, Datum, Betrag und Gegenkonto auswählen.", color: "warning" }) + return + } + + const [counterType, counterId] = String(cashbookForm.counter).split(":") + savingCashbookBooking.value = true + try { + await $api(`/api/banking/cashbooks/${selectedCashbookAccount.value.id}/bookings`, { + method: "POST", + body: { + date: cashbookForm.date, + direction: cashbookForm.direction, + amount: Number(cashbookForm.amount), + counterType, + counterId, + datevTaxKey: cashbookForm.datevTaxKey === "__none__" ? null : cashbookForm.datevTaxKey, + description: cashbookForm.description + } + }) + toast.add({ title: "Kassenbuchung erstellt." }) + cashbookForm.amount = null + cashbookForm.counter = "" + cashbookForm.datevTaxKey = "__none__" + cashbookForm.description = "" + counterSearch.value = "" + expandedGroups.value = [] + cashbookBookingModalOpen.value = false + await setupPage() + } finally { + savingCashbookBooking.value = false + } +} + const normalizeIban = (value) => String(value || "").replace(/\s+/g, "").toUpperCase() const normalizeSuggestionText = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9äöüß]+/gi, " ").replace(/\s+/g, " ").trim() const getSuggestionTokens = (value) => [...new Set(normalizeSuggestionText(value).split(" ").filter((token) => token.length >= 4))] @@ -223,7 +398,7 @@ const getAmountMatchLabel = (openSum, remaining) => { const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining))) if (difference < 0.01) return "Betrag exakt" if (difference <= 1) return "Betrag fast passend" - if (difference <= 5) return "Betrag aehnlich" + if (difference <= 5) return "Betrag ähnlich" return null } @@ -276,7 +451,7 @@ const getTopEntitySuggestion = (statement) => { reason = "Name passt" } else if (partialNameMatch) { score = 45 - reason = "Name aehnlich" + reason = "Name ähnlich" } return { @@ -401,10 +576,10 @@ const filteredRows = computed(() => { // Filterung nach Datum if (dateRange.value.start) { - temp = temp.filter(i => $dayjs(i.valueDate).isSameOrAfter($dayjs(dateRange.value.start), 'day')) + temp = temp.filter(i => $dayjs(getStatementDate(i)).isSameOrAfter($dayjs(dateRange.value.start), 'day')) } if (dateRange.value.end) { - temp = temp.filter(i => $dayjs(i.valueDate).isSameOrBefore($dayjs(dateRange.value.end), 'day')) + temp = temp.filter(i => $dayjs(getStatementDate(i)).isSameOrBefore($dayjs(dateRange.value.end), 'day')) } // Status Filter @@ -419,13 +594,13 @@ const filteredRows = computed(() => { } // Konto Filter & Suche - let results = temp.filter(i => filterAccount.value.find(x => x.id === i.account)) + let results = temp.filter(i => filterAccount.value.find(x => getBankAccountId(x) === i.account)) if (searchString.value) { results = useSearch(searchString.value, results) } - return results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).unix()) + return results.sort((a, b) => $dayjs(getStatementDate(b)).unix() - $dayjs(getStatementDate(a)).unix()) }) const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")} €` @@ -492,13 +667,22 @@ onMounted(() => {