From 62accb5a86b99124bee4b2dd0aabc856c1c02415 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 17 Mar 2026 18:14:09 +0100 Subject: [PATCH] =?UTF-8?q?Vorschl=C3=A4ge=20System=20in=20Bankbuchungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/banking.ts | 198 ++++++++ backend/src/routes/resources/main.ts | 20 +- frontend/composables/useFunctions.js | 7 +- frontend/pages/banking/index.vue | 428 +++++++++++++++++- .../banking/statements/[mode]/[[id]].vue | 324 ++++++++++++- 5 files changed, 967 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index 22b68c1..9755b09 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -29,6 +29,13 @@ export default async function bankingRoutes(server: FastifyInstance) { const normalizeIban = (value?: string | null) => String(value || "").replace(/\s+/g, "").toUpperCase() + const normalizeName = (value?: string | null) => + String(value || "") + .toLowerCase() + .replace(/[^a-z0-9äöüß]+/gi, " ") + .replace(/\s+/g, " ") + .trim() + const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => { if (!statement) return null @@ -60,6 +67,26 @@ export default async function bankingRoutes(server: FastifyInstance) { return null } + const pickPartnerReference = (statement: any, partnerType: "customer" | "vendor") => { + if (!statement) return null + + const prefersDebit = partnerType === "customer" + ? Number(statement.amount) >= 0 + : Number(statement.amount) > 0 + + const primary = prefersDebit + ? { iban: statement.debIban, name: statement.debName } + : { iban: statement.credIban, name: statement.credName } + const fallback = prefersDebit + ? { iban: statement.credIban, name: statement.credName } + : { iban: statement.debIban, name: statement.debName } + + return { + iban: normalizeIban(primary.iban) || normalizeIban(fallback.iban) || null, + name: String(primary.name || fallback.name || "").trim() || null, + } + } + const mergePartnerIban = (infoData: Record, iban: string, bankAccountId?: number | null) => { if (!iban && !bankAccountId) return infoData || {} const info = infoData && typeof infoData === "object" ? { ...infoData } : {} @@ -239,6 +266,177 @@ export default async function bankingRoutes(server: FastifyInstance) { } }) + server.get("/banking/statements/:id/suggestions", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const statementId = Number(id) + if (!statementId) return reply.code(400).send({ error: "Invalid statement id" }) + + const [statement] = await server.db + .select() + .from(bankstatements) + .where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, req.user.tenant_id))) + .limit(1) + + if (!statement) return reply.code(404).send({ error: "Statement not found" }) + + const partnerType: "customer" | "vendor" = Number(statement.amount) >= 0 ? "customer" : "vendor" + const partnerRef = pickPartnerReference(statement, partnerType) + + const suggestions: Array> = [] + let matchedBankAccountId: number | null = null + + if (partnerRef?.iban) { + const allAccounts = await server.db + .select({ + id: entitybankaccounts.id, + ibanEncrypted: entitybankaccounts.ibanEncrypted, + }) + .from(entitybankaccounts) + .where(eq(entitybankaccounts.tenant, req.user.tenant_id)) + + const matchingAccount = allAccounts.find((row) => { + if (!row.ibanEncrypted) return false + try { + return normalizeIban(decrypt(row.ibanEncrypted as any)) === partnerRef.iban + } catch { + return false + } + }) + + matchedBankAccountId = matchingAccount?.id ? Number(matchingAccount.id) : null + } + + if (partnerType === "customer") { + const customerRows = await server.db + .select({ + id: customers.id, + name: customers.name, + customerNumber: customers.customerNumber, + infoData: customers.infoData, + }) + .from(customers) + .where(and(eq(customers.tenant, req.user.tenant_id), eq(customers.archived, false))) + + for (const row of customerRows) { + const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record : {} + const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : [] + const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : [] + const normalizedEntityName = normalizeName(row.name) + const normalizedStatementName = normalizeName(partnerRef?.name) + + const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false + const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false + const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName + const partialNameMatch = normalizedEntityName && normalizedStatementName + ? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName) + : false + + let score = 0 + let reason = "" + + if (matchesBankAccountId && matchesIban) { + score = 100 + reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein" + } else if (matchesBankAccountId) { + score = 95 + reason = "Hinterlegte Bankverbindung passt zur IBAN" + } else if (matchesIban) { + score = 90 + reason = "IBAN wurde bereits bei diesem Kunden verwendet" + } else if (exactNameMatch) { + score = 60 + reason = "Name passt exakt zur Buchung" + } else if (partialNameMatch) { + score = 45 + reason = "Name aehnelt der Buchung" + } + + if (!score) continue + + suggestions.push({ + type: "customer", + id: row.id, + name: row.name, + number: row.customerNumber, + score, + reason, + }) + } + } else { + const vendorRows = await server.db + .select({ + id: vendors.id, + name: vendors.name, + vendorNumber: vendors.vendorNumber, + infoData: vendors.infoData, + }) + .from(vendors) + .where(and(eq(vendors.tenant, req.user.tenant_id), eq(vendors.archived, false))) + + for (const row of vendorRows) { + const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record : {} + const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : [] + const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : [] + const normalizedEntityName = normalizeName(row.name) + const normalizedStatementName = normalizeName(partnerRef?.name) + + const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false + const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false + const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName + const partialNameMatch = normalizedEntityName && normalizedStatementName + ? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName) + : false + + let score = 0 + let reason = "" + + if (matchesBankAccountId && matchesIban) { + score = 100 + reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein" + } else if (matchesBankAccountId) { + score = 95 + reason = "Hinterlegte Bankverbindung passt zur IBAN" + } else if (matchesIban) { + score = 90 + reason = "IBAN wurde bereits bei diesem Lieferanten verwendet" + } else if (exactNameMatch) { + score = 60 + reason = "Name passt exakt zur Buchung" + } else if (partialNameMatch) { + score = 45 + reason = "Name aehnelt der Buchung" + } + + if (!score) continue + + suggestions.push({ + type: "vendor", + id: row.id, + name: row.name, + number: row.vendorNumber, + score, + reason, + }) + } + } + + suggestions.sort((a, b) => b.score - a.score || String(a.name).localeCompare(String(b.name), "de")) + + return reply.send({ + partnerType, + partnerName: partnerRef?.name || null, + partnerIban: partnerRef?.iban || null, + suggestions: suggestions.slice(0, 5), + }) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to load statement suggestions" }) + } + }) + const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => { if (!createdDocumentId) return diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index d354c4a..a4e6407 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -130,6 +130,12 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any) return whereCond } +function getTenantColumn(resource: string, table: any) { + const config = resourceConfig[resource] + const tenantKey = config?.tenantKey || "tenant" + return table[tenantKey] +} + function isDateLikeField(key: string) { if (key === "deliveryDateType") return false if (key.includes("_at") || key.endsWith("At")) return true @@ -241,9 +247,13 @@ export default async function resourceRoutes(server: FastifyInstance) { const { resource } = req.params as { resource: string } const config = resourceConfig[resource] + if (!config) { + return reply.code(404).send({ error: "Unknown resource" }) + } const table = config.table - let whereCond: any = eq(table.tenant, tenantId) + const tenantColumn = getTenantColumn(resource, table) + let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined whereCond = applyResourceWhereFilters(resource, table, whereCond) let q = server.db.select().from(table).$dynamic() @@ -345,13 +355,17 @@ export default async function resourceRoutes(server: FastifyInstance) { const { resource } = req.params as { resource: string }; const config = resourceConfig[resource]; + if (!config) { + return reply.code(404).send({ error: "Unknown resource" }); + } const table = config.table; const { queryConfig } = req; const { pagination, sort, filters } = queryConfig; const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; }; - let whereCond: any = eq(table.tenant, tenantId); + const tenantColumn = getTenantColumn(resource, table); + let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; whereCond = applyResourceWhereFilters(resource, table, whereCond) const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; @@ -451,7 +465,7 @@ export default async function resourceRoutes(server: FastifyInstance) { } }); } - let distinctWhereCond: any = eq(table.tenant, tenantId) + let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) if (search) { diff --git a/frontend/composables/useFunctions.js b/frontend/composables/useFunctions.js index 703363c..e3dab39 100644 --- a/frontend/composables/useFunctions.js +++ b/frontend/composables/useFunctions.js @@ -92,5 +92,10 @@ export const useFunctions = () => { return await useNuxtApp().$api(`/api/banking/iban/${encodeURIComponent(normalized)}`) } - return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useCreatePDF} + const useBankingStatementSuggestions = async (statementId) => { + if (!statementId) return { suggestions: [] } + return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`) + } + + return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF} } diff --git a/frontend/pages/banking/index.vue b/frontend/pages/banking/index.vue index 9a40fc9..a05c8b5 100644 --- a/frontend/pages/banking/index.vue +++ b/frontend/pages/banking/index.vue @@ -12,9 +12,18 @@ const route = useRoute() const bankstatements = ref([]) const bankaccounts = ref([]) +const customers = ref([]) +const vendors = ref([]) +const entitybankaccounts = ref([]) +const createddocuments = ref([]) +const incominginvoices = ref([]) +const openDocuments = ref([]) +const openIncomingInvoices = ref([]) const filterAccount = ref([]) const isSyncing = ref(false) const loadingDocs = ref(true) // Startet im Ladezustand +const suggestionsModalOpen = ref(false) +const selectedSuggestionRowId = ref(null) // Zeitraum-Optionen const periodOptions = [ @@ -32,16 +41,42 @@ const dateRange = ref({ end: $dayjs().endOf('month').format('YYYY-MM-DD') }) +const setDateRangeFieldToToday = (field) => { + dateRange.value[field] = $dayjs().format('YYYY-MM-DD') +} + const setupPage = async () => { loadingDocs.value = true try { - const [statements, accounts] = await Promise.all([ + const [statements, accounts, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([ useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false), - useEntities("bankaccounts").select() + useEntities("bankaccounts").select(), + useEntities("customers").select(), + useEntities("vendors").select(), + useEntities("entitybankaccounts").select(), + useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"), + useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)") ]) bankstatements.value = statements bankaccounts.value = accounts + customers.value = customerItems + vendors.value = vendorItems + entitybankaccounts.value = entityBankItems + createddocuments.value = documentItems + incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht") + + const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices") + openDocuments.value = documents.filter(i => i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, createddocuments.value).toFixed(2)) + .map(i => ({ + ...i, + docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value), + statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)), + openSum: (useSum().getCreatedDocumentSum(i, createddocuments.value) - Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0))).toFixed(2) + })) + + openIncomingInvoices.value = invoiceItems + .filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)) if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) { filterAccount.value = bankaccounts.value @@ -126,6 +161,213 @@ const calculateOpenSum = (statement) => { return (statement.amount - allocated).toFixed(2); } +const getInvoiceSum = (invoice, onlyOpenSum) => { + let sum = 0 + if (invoice.accounts) { + invoice.accounts.forEach(account => { + sum += (account.amountTax || 0) + sum += (account.amountNet || 0) + }) + } + + if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0)) + + if (invoice.expense) { + return (sum * -1).toFixed(2) + } else { + return sum.toFixed(2) + } +} + +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))] +const getMatchingPurposeParts = (value, candidates = []) => { + const haystack = normalizeSuggestionText(value) + if (!haystack) return [] + + return [...new Set( + candidates + .flatMap(candidate => getSuggestionTokens(candidate)) + .filter(token => haystack.includes(token)) + )].slice(0, 3) +} + +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" + return null +} + +const isWithinAmountTolerance = (candidateSum, remaining) => { + const reference = Math.abs(Number(remaining)) + const candidate = Math.abs(Number(candidateSum)) + if (!reference || !candidate) return false + + const difference = Math.abs(candidate - reference) + return difference / reference <= 0.1 +} + +const resolveMatchedBankAccountId = (statement) => { + const partnerIban = normalizeIban(statement.amount >= 0 ? (statement.debIban || statement.credIban) : (statement.credIban || statement.debIban)) + if (!partnerIban) return null + return entitybankaccounts.value.find(account => normalizeIban(account.iban) === partnerIban)?.id || null +} + +const getTopEntitySuggestion = (statement) => { + const isCustomer = Number(statement.amount) >= 0 + const partnerName = normalizeSuggestionText(isCustomer ? (statement.debName || statement.credName) : (statement.credName || statement.debName)) + const partnerIban = normalizeIban(isCustomer ? (statement.debIban || statement.credIban) : (statement.credIban || statement.debIban)) + const matchedBankAccountId = resolveMatchedBankAccountId(statement) + const sourceItems = isCustomer ? customers.value : vendors.value + + const suggestions = sourceItems.map(item => { + const infoData = item.infoData || {} + const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : [] + const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map(iban => normalizeIban(iban)) : [] + const normalizedEntityName = normalizeSuggestionText(item.name) + const exactNameMatch = normalizedEntityName && partnerName && normalizedEntityName === partnerName + const partialNameMatch = normalizedEntityName && partnerName + ? normalizedEntityName.includes(partnerName) || partnerName.includes(normalizedEntityName) + : false + + let score = 0 + let reason = "" + + if (matchedBankAccountId && bankAccountIds.includes(matchedBankAccountId) && partnerIban && bankingIbans.includes(partnerIban)) { + score = 100 + reason = "IBAN und Bankkonto passen" + } else if (matchedBankAccountId && bankAccountIds.includes(matchedBankAccountId)) { + score = 95 + reason = "Bankkonto passt" + } else if (partnerIban && bankingIbans.includes(partnerIban)) { + score = 90 + reason = "IBAN bekannt" + } else if (exactNameMatch) { + score = 60 + reason = "Name passt" + } else if (partialNameMatch) { + score = 45 + reason = "Name aehnlich" + } + + return { + type: isCustomer ? "customer" : "vendor", + id: item.id, + name: item.name, + number: isCustomer ? item.customerNumber : item.vendorNumber, + score, + reason + } + }).filter(item => item.score > 0) + .sort((a, b) => b.score - a.score) + + return suggestions[0] || null +} + +const getDocumentSuggestionMeta = (statement, document, suggestion) => { + let score = 0 + const remaining = Math.abs(Number(calculateOpenSum(statement))) + const openSum = Math.abs(Number(document.openSum)) + const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description]) + const amountLabel = getAmountMatchLabel(openSum, remaining) + const withinTolerance = isWithinAmountTolerance(openSum, remaining) + + if (!withinTolerance) { + return { score: 0, amountLabel: null, purposeMatches: [] } + } + + if (openSum === remaining) score += 100 + else score += Math.max(0, 60 - Math.abs(openSum - remaining)) + + if (suggestion && document.customer?.id === suggestion.id) score += 80 + if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10) + + return { score, amountLabel, purposeMatches } +} + +const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => { + let score = 0 + const remaining = Math.abs(Number(calculateOpenSum(statement))) + const openSum = Math.abs(Number(getInvoiceSum(invoice, true))) + const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description]) + const amountLabel = getAmountMatchLabel(openSum, remaining) + const withinTolerance = isWithinAmountTolerance(openSum, remaining) + + if (!withinTolerance) { + return { score: 0, amountLabel: null, purposeMatches: [] } + } + + if (openSum === remaining) score += 100 + else score += Math.max(0, 60 - Math.abs(openSum - remaining)) + + if (suggestion && invoice.vendor?.id === suggestion.id) score += 80 + if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10) + + return { score, amountLabel, purposeMatches } +} + +const getRowSuggestions = (statement) => { + const entitySuggestion = getTopEntitySuggestion(statement) + + const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value) + .map(document => { + const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null) + return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` } + }) + .filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey)) + .filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0) + .sort((a, b) => b.suggestionScore - a.suggestionScore)[0] || null + + const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value) + .map(invoice => { + const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null) + return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` } + }) + .filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey)) + .filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0) + .sort((a, b) => b.suggestionScore - a.suggestionScore)[0] || null + + return { + topDocument, + topIncomingInvoice + } +} + +const rowSuggestions = computed(() => { + const entries = filteredRows.value.map(row => [row.id, getRowSuggestions(row)]) + return Object.fromEntries(entries) +}) + +const dismissedSuggestions = computed(() => tempStore.settings?.banking?.dismissedSuggestions || {}) + +const getDismissedSuggestionKeys = (statementId) => { + const key = String(statementId || "") + const values = dismissedSuggestions.value?.[key] + return Array.isArray(values) ? values : [] +} + +const isSuggestionDismissed = (statementId, suggestionKey) => { + return getDismissedSuggestionKeys(statementId).includes(suggestionKey) +} + +const rowsWithSuggestions = computed(() => { + return filteredRows.value + .map((row) => ({ + row, + suggestions: rowSuggestions.value[row.id] + })) + .filter(({ suggestions }) => suggestions?.topDocument || suggestions?.topIncomingInvoice) +}) + +const suggestionCount = computed(() => rowsWithSuggestions.value.length) + +const selectedSuggestionRow = computed(() => { + return rowsWithSuggestions.value.find((entry) => entry.row.id === selectedSuggestionRowId.value) || rowsWithSuggestions.value[0] || null +}) + const filteredRows = computed(() => { if (!bankstatements.value.length) return [] @@ -162,6 +404,60 @@ const filteredRows = computed(() => { const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")} €` +const saveAllocation = async (allocation) => { + await $api("/api/banking/statements", { + method: "POST", + body: { data: allocation } + }) + await setupPage() +} + +const handleAssignDocument = async (row, document, event) => { + event.stopPropagation() + await saveAllocation({ + createddocument: document.id, + bankstatement: row.id, + amount: Number(Number(document.openSum) < Number(calculateOpenSum(row)) ? document.openSum : calculateOpenSum(row)), + description: "Automatischer Vorschlag" + }) +} + +const handleAssignIncomingInvoice = async (row, invoice, event) => { + event.stopPropagation() + await saveAllocation({ + incominginvoice: invoice.id, + bankstatement: row.id, + amount: Number(Math.abs(getInvoiceSum(invoice, true)) > Math.abs(Number(calculateOpenSum(row))) ? calculateOpenSum(row) : getInvoiceSum(invoice, true)), + description: "Automatischer Vorschlag" + }) +} + +const dismissSuggestion = (row, suggestionKey, event) => { + event.stopPropagation() + const bankingSettings = tempStore.settings?.banking || {} + const dismissed = { ...(bankingSettings.dismissedSuggestions || {}) } + const rowKey = String(row.id || "") + const existing = Array.isArray(dismissed[rowKey]) ? dismissed[rowKey] : [] + if (!existing.includes(suggestionKey)) dismissed[rowKey] = [...existing, suggestionKey] + + tempStore.modifySettings("banking", { + ...bankingSettings, + dismissedSuggestions: dismissed + }) + + if (selectedSuggestionRowId.value === row.id) { + const remaining = getDismissedSuggestionKeys(row.id).concat([suggestionKey]) + const current = rowSuggestions.value[row.id] + const noMoreSuggestions = (!current?.topDocument || remaining.includes(current.topDocument.suggestionKey)) + && (!current?.topIncomingInvoice || remaining.includes(current.topIncomingInvoice.suggestionKey)) + + if (noMoreSuggestions) { + const nextRow = rowsWithSuggestions.value.find((entry) => entry.row.id !== row.id) + selectedSuggestionRowId.value = nextRow?.row.id || null + } + } +} + onMounted(() => { setupPage() }) @@ -170,6 +466,15 @@ onMounted(() => { diff --git a/frontend/pages/banking/statements/[mode]/[[id]].vue b/frontend/pages/banking/statements/[mode]/[[id]].vue index 6b20c98..c76616d 100644 --- a/frontend/pages/banking/statements/[mode]/[[id]].vue +++ b/frontend/pages/banking/statements/[mode]/[[id]].vue @@ -21,6 +21,7 @@ const openDocuments = ref([]) const allocatedDocuments = ref([]) const openIncomingInvoices = ref([]) const allocatedIncomingInvoices = ref([]) +const statementSuggestions = ref({ suggestions: [] }) const customers = ref([]) const vendors = ref([]) @@ -67,6 +68,12 @@ const setup = async () => { openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)) + if (itemInfo.value?.id) { + statementSuggestions.value = await useFunctions().useBankingStatementSuggestions(itemInfo.value.id) + } else { + statementSuggestions.value = { suggestions: [] } + } + loading.value = false } @@ -163,6 +170,225 @@ const filteredIncomingInvoices = computed(() => { return useSearch(searchString.value, openIncomingInvoices.value.filter(i => i.state === "Gebucht")) }) +const dismissedSuggestions = computed(() => tempStore.settings?.banking?.dismissedSuggestions || {}) + +const getDismissedSuggestionKeys = (statementId) => { + const key = String(statementId || "") + const values = dismissedSuggestions.value?.[key] + return Array.isArray(values) ? values : [] +} + +const isSuggestionDismissed = (statementId, suggestionKey) => { + return getDismissedSuggestionKeys(statementId).includes(suggestionKey) +} + +const dismissSuggestion = (statementId, suggestionKey) => { + const bankingSettings = tempStore.settings?.banking || {} + const dismissed = { ...(bankingSettings.dismissedSuggestions || {}) } + const rowKey = String(statementId || "") + const existing = Array.isArray(dismissed[rowKey]) ? dismissed[rowKey] : [] + if (!existing.includes(suggestionKey)) dismissed[rowKey] = [...existing, suggestionKey] + + tempStore.modifySettings("banking", { + ...bankingSettings, + dismissedSuggestions: dismissed + }) +} + +const topEntitySuggestion = computed(() => { + const suggestions = Array.isArray(statementSuggestions.value?.suggestions) ? statementSuggestions.value.suggestions : [] + return suggestions[0] || null +}) + +const normalizeSuggestionText = (value) => { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9äöüß]+/gi, " ") + .replace(/\s+/g, " ") + .trim() +} + +const getSuggestionTokens = (value) => { + return [...new Set( + normalizeSuggestionText(value) + .split(" ") + .filter((token) => token.length >= 4) + )] +} + +const getMatchingPurposeParts = (value, candidates = []) => { + const haystack = normalizeSuggestionText(value) + if (!haystack) return [] + + return [...new Set( + candidates + .flatMap(candidate => getSuggestionTokens(candidate)) + .filter(token => haystack.includes(token)) + )].slice(0, 3) +} + +const getAmountMatchLabel = (openSum, remaining) => { + const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining))) + if (difference < 0.01) return "Betrag exakt passend" + if (difference <= 1) return `Betrag fast passend (${displayCurrency(difference)} Abweichung)` + if (difference <= 5) return `Betrag aehnlich (${displayCurrency(difference)} Abweichung)` + return null +} + +const isWithinAmountTolerance = (candidateSum, remaining) => { + const reference = Math.abs(Number(remaining)) + const candidate = Math.abs(Number(candidateSum)) + if (!reference || !candidate) return false + + const difference = Math.abs(candidate - reference) + return difference / reference <= 0.1 +} + +const getDocumentSuggestionMeta = (document, suggestion) => { + let score = 0 + const remaining = Math.abs(Number(calculateOpenSum.value)) + const openSum = Math.abs(Number(document.openSum)) + const purposeMatches = getMatchingPurposeParts(itemInfo.value.text, [ + document.documentNumber, + document.title, + document.description + ]) + const amountLabel = getAmountMatchLabel(openSum, remaining) + const withinTolerance = isWithinAmountTolerance(openSum, remaining) + + if (!withinTolerance) { + return { + score: 0, + purposeMatches: [], + amountLabel: null + } + } + + if (openSum === remaining) score += 100 + else score += Math.max(0, 60 - Math.abs(openSum - remaining)) + + if (suggestion && document.customer?.id === suggestion.id) score += 80 + if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10) + + return { + score, + purposeMatches, + amountLabel + } +} + +const getInvoiceSuggestionMeta = (invoice, suggestion) => { + let score = 0 + const remaining = Math.abs(Number(calculateOpenSum.value)) + const openSum = Math.abs(Number(getInvoiceSum(invoice, true))) + const purposeMatches = getMatchingPurposeParts(itemInfo.value.text, [ + invoice.reference, + invoice.description + ]) + const amountLabel = getAmountMatchLabel(openSum, remaining) + const withinTolerance = isWithinAmountTolerance(openSum, remaining) + + if (!withinTolerance) { + return { + score: 0, + purposeMatches: [], + amountLabel: null + } + } + + if (openSum === remaining) score += 100 + else score += Math.max(0, 60 - Math.abs(openSum - remaining)) + + if (suggestion && invoice.vendor?.id === suggestion.id) score += 80 + if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10) + + return { + score, + purposeMatches, + amountLabel + } +} + +const suggestedDocuments = computed(() => { + if (topEntitySuggestion.value?.type !== "customer") return [] + + return openDocuments.value + .filter((document) => document.customer?.id === topEntitySuggestion.value.id) + .map((document) => { + const meta = getDocumentSuggestionMeta(document, topEntitySuggestion.value) + return { + ...document, + suggestionScore: meta.score, + suggestionPurposeMatches: meta.purposeMatches, + suggestionAmountLabel: meta.amountLabel, + suggestionKey: `document:${document.id}` + } + }) + .filter((document) => !isSuggestionDismissed(itemInfo.value?.id, document.suggestionKey)) + .sort((a, b) => b.suggestionScore - a.suggestionScore) + .slice(0, 3) +}) + +const suggestedIncomingInvoices = computed(() => { + if (topEntitySuggestion.value?.type !== "vendor") return [] + + return openIncomingInvoices.value + .filter((invoice) => invoice.vendor?.id === topEntitySuggestion.value.id) + .map((invoice) => { + const meta = getInvoiceSuggestionMeta(invoice, topEntitySuggestion.value) + return { + ...invoice, + suggestionScore: meta.score, + suggestionPurposeMatches: meta.purposeMatches, + suggestionAmountLabel: meta.amountLabel, + suggestionKey: `invoice:${invoice.id}` + } + }) + .filter((invoice) => !isSuggestionDismissed(itemInfo.value?.id, invoice.suggestionKey)) + .sort((a, b) => b.suggestionScore - a.suggestionScore) + .slice(0, 3) +}) + +const fallbackSuggestedDocuments = computed(() => { + if (topEntitySuggestion.value) return [] + + return openDocuments.value + .map((document) => { + const meta = getDocumentSuggestionMeta(document, null) + return { + ...document, + suggestionScore: meta.score, + suggestionPurposeMatches: meta.purposeMatches, + suggestionAmountLabel: meta.amountLabel, + suggestionKey: `document:${document.id}` + } + }) + .filter((document) => !isSuggestionDismissed(itemInfo.value?.id, document.suggestionKey)) + .filter((document) => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0) + .sort((a, b) => b.suggestionScore - a.suggestionScore) + .slice(0, 3) +}) + +const fallbackSuggestedIncomingInvoices = computed(() => { + if (topEntitySuggestion.value) return [] + + return openIncomingInvoices.value + .map((invoice) => { + const meta = getInvoiceSuggestionMeta(invoice, null) + return { + ...invoice, + suggestionScore: meta.score, + suggestionPurposeMatches: meta.purposeMatches, + suggestionAmountLabel: meta.amountLabel, + suggestionKey: `invoice:${invoice.id}` + } + }) + .filter((invoice) => !isSuggestionDismissed(itemInfo.value?.id, invoice.suggestionKey)) + .filter((invoice) => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0) + .sort((a, b) => b.suggestionScore - a.suggestionScore) + .slice(0, 3) +}) + const archiveStatement = async () => { let temp = {...itemInfo.value} delete temp.statementallocations @@ -442,6 +668,102 @@ setup()
+
+
+
+
Automatischer Vorschlag
+
+ {{ topEntitySuggestion.type === 'customer' ? 'Kunde' : 'Lieferant' }}: + {{ topEntitySuggestion.number }} - {{ topEntitySuggestion.name }} +
+
+ {{ topEntitySuggestion.reason }} +
+
+ {{ statementSuggestions.partnerName || 'Unbekannter Partner' }} + | {{ separateIBAN(statementSuggestions.partnerIban) }} +
+
+
+ +
+
Automatische Belegvorschlaege
+
+ Kein eindeutiger Kunde oder Lieferant erkannt. Vorschlaege basieren auf Betrag und Verwendungszweck. +
+
+ +
+
+
+
{{ document.documentNumber }}
+
{{ document.customer?.name }} | Offen {{ displayCurrency(document.openSum) }}
+
+ + {{ document.suggestionAmountLabel }} + + + VWZ: {{ match }} + +
+
+ + Rechnung zuweisen + + + Ablehnen + +
+ +
+
+
{{ invoice.vendor?.name }}
+
Ref: {{ invoice.reference || '-' }} | Offen {{ displayCurrency(getInvoiceSum(invoice, true)) }}
+
+ + {{ invoice.suggestionAmountLabel }} + + + VWZ: {{ match }} + +
+
+ + Beleg zuweisen + + + Ablehnen + +
+
+
+

@@ -598,4 +920,4 @@ setup() .dark ::-webkit-scrollbar-thumb { background: #475569; } - \ No newline at end of file +