Vorschläge System in Bankbuchungen
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user