Vorschläge System in Bankbuchungen

This commit is contained in:
2026-03-17 18:14:09 +01:00
parent 8c935c6101
commit 62accb5a86
5 changed files with 967 additions and 10 deletions

View File

@@ -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()
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<div v-if="Number(calculateOpenSum) !== 0 && (topEntitySuggestion || fallbackSuggestedDocuments.length > 0 || fallbackSuggestedIncomingInvoices.length > 0)" class="rounded-xl border border-primary-200 bg-primary-50/70 dark:bg-primary-900/10 dark:border-primary-800 p-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div v-if="topEntitySuggestion">
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatischer Vorschlag</div>
<div class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{{ topEntitySuggestion.type === 'customer' ? 'Kunde' : 'Lieferant' }}:
{{ topEntitySuggestion.number }} - {{ topEntitySuggestion.name }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300 mt-1">
{{ topEntitySuggestion.reason }}
</div>
<div class="text-xs text-gray-500 mt-1" v-if="statementSuggestions.partnerIban || statementSuggestions.partnerName">
{{ statementSuggestions.partnerName || 'Unbekannter Partner' }}
<span v-if="statementSuggestions.partnerIban">| {{ separateIBAN(statementSuggestions.partnerIban) }}</span>
</div>
</div>
</div>
<div v-if="!topEntitySuggestion" class="mb-3">
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatische Belegvorschlaege</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Kein eindeutiger Kunde oder Lieferant erkannt. Vorschlaege basieren auf Betrag und Verwendungszweck.
</div>
</div>
<div v-if="suggestedDocuments.length > 0 || suggestedIncomingInvoices.length > 0 || fallbackSuggestedDocuments.length > 0 || fallbackSuggestedIncomingInvoices.length > 0" class="mt-4 grid gap-2">
<div
v-for="document in (topEntitySuggestion ? suggestedDocuments : fallbackSuggestedDocuments)"
:key="`suggested-document-${document.id}`"
class="flex items-center justify-between rounded-lg border border-white/70 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2"
>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ document.documentNumber }}</div>
<div class="text-xs text-gray-500">{{ document.customer?.name }} | Offen {{ displayCurrency(document.openSum) }}</div>
<div class="mt-1 flex flex-wrap gap-1" v-if="document.suggestionAmountLabel || document.suggestionPurposeMatches?.length">
<UBadge v-if="document.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
{{ document.suggestionAmountLabel }}
</UBadge>
<UBadge v-for="match in document.suggestionPurposeMatches" :key="`doc-match-${document.id}-${match}`" size="xs" color="amber" variant="subtle">
VWZ: {{ match }}
</UBadge>
</div>
</div>
<UButton
size="xs"
color="primary"
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription || 'Automatischer Vorschlag'})"
>
Rechnung zuweisen
</UButton>
<UButton
size="xs"
color="gray"
variant="ghost"
@click="dismissSuggestion(itemInfo.id, document.suggestionKey)"
>
Ablehnen
</UButton>
</div>
<div
v-for="invoice in (topEntitySuggestion ? suggestedIncomingInvoices : fallbackSuggestedIncomingInvoices)"
:key="`suggested-invoice-${invoice.id}`"
class="flex items-center justify-between rounded-lg border border-white/70 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2"
>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ invoice.vendor?.name }}</div>
<div class="text-xs text-gray-500">Ref: {{ invoice.reference || '-' }} | Offen {{ displayCurrency(getInvoiceSum(invoice, true)) }}</div>
<div class="mt-1 flex flex-wrap gap-1" v-if="invoice.suggestionAmountLabel || invoice.suggestionPurposeMatches?.length">
<UBadge v-if="invoice.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
{{ invoice.suggestionAmountLabel }}
</UBadge>
<UBadge v-for="match in invoice.suggestionPurposeMatches" :key="`invoice-match-${invoice.id}-${match}`" size="xs" color="amber" variant="subtle">
VWZ: {{ match }}
</UBadge>
</div>
</div>
<UButton
size="xs"
color="rose"
@click="saveAllocation({incominginvoice: invoice.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(invoice,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(invoice,true)), description: allocationDescription || 'Automatischer Vorschlag'})"
>
Beleg zuweisen
</UButton>
<UButton
size="xs"
color="gray"
variant="ghost"
@click="dismissSuggestion(itemInfo.id, invoice.suggestionKey)"
>
Ablehnen
</UButton>
</div>
</div>
</div>
<div v-if="filteredDocuments.length > 0">
<h3 class="text-xs font-bold text-gray-500 uppercase mb-2 pl-1 flex items-center gap-2">
<UIcon name="i-heroicons-document-arrow-up"/>
@@ -598,4 +920,4 @@ setup()
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
</style>
</style>