feat(accounting): Skontobedingungen auf Belegen und automatische Skonto-Vorschläge
Ausgangs- und Eingangsbelege erhalten Skontosatz und Frist, mit Vorgaben bei Kunden und Lieferanten. Das PDF ergänzt automatisch einen Skontohinweis (oder nutzt neue Textvorlagen-Variablen); bei Schlussrechnungen gilt der Skonto für die Restforderung. Skontobedingungen werden aus E-Rechnungen (#SKONTO# und ZUGFeRD) sowie von der KI-Vorbereitung übernommen. Die Bankzuordnung erkennt Zahlungen abzüglich Skonto innerhalb der Frist und bietet sie in der Vorschlagsübersicht direkt an. BWA-Erlöse und -Aufwand berücksichtigen gebuchten Skonto im Zeitraum der Zahlung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
|
||||
import { parseDate } from "@internationalized/date"
|
||||
|
||||
const {$api, $dayjs} = useNuxtApp()
|
||||
@@ -387,25 +387,52 @@ const getTopEntitySuggestion = (statement) => {
|
||||
return suggestions[0] || null
|
||||
}
|
||||
|
||||
// Zahlung entspricht dem offenen Betrag abzüglich vereinbartem Skonto und liegt in der Frist.
|
||||
const getCashDiscountMatch = (statement, kind, entity) => {
|
||||
const paymentDate = getStatementDate(statement) ? $dayjs(getStatementDate(statement)).format("YYYY-MM-DD") : null
|
||||
if (kind === "createddocument") {
|
||||
const days = entity.cashDiscountDays
|
||||
return matchCashDiscountPayment({
|
||||
open: entity.openSum,
|
||||
payment: calculateOpenSum(statement),
|
||||
percent: entity.cashDiscountPercent,
|
||||
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
|
||||
? $dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
|
||||
: null,
|
||||
paymentDate
|
||||
})
|
||||
}
|
||||
return matchCashDiscountPayment({
|
||||
open: getInvoiceSum(entity, true),
|
||||
payment: calculateOpenSum(statement),
|
||||
percent: entity.cashDiscountPercent,
|
||||
dueDate: entity.cashDiscountDueDate || null,
|
||||
paymentDate
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
const cashDiscountMatch = getCashDiscountMatch(statement, "createddocument", document)
|
||||
const amountLabel = cashDiscountMatch
|
||||
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
|
||||
: getAmountMatchLabel(openSum, remaining)
|
||||
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
|
||||
|
||||
if (!withinTolerance) {
|
||||
return { score: 0, amountLabel: null, purposeMatches: [] }
|
||||
return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
|
||||
}
|
||||
|
||||
if (openSum === remaining) score += 100
|
||||
if (openSum === remaining || cashDiscountMatch) 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 }
|
||||
return { score, amountLabel, purposeMatches, cashDiscountMatch }
|
||||
}
|
||||
|
||||
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
||||
@@ -413,20 +440,23 @@ const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
||||
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)
|
||||
const cashDiscountMatch = getCashDiscountMatch(statement, "incominginvoice", invoice)
|
||||
const amountLabel = cashDiscountMatch
|
||||
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
|
||||
: getAmountMatchLabel(openSum, remaining)
|
||||
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
|
||||
|
||||
if (!withinTolerance) {
|
||||
return { score: 0, amountLabel: null, purposeMatches: [] }
|
||||
return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
|
||||
}
|
||||
|
||||
if (openSum === remaining) score += 100
|
||||
if (openSum === remaining || cashDiscountMatch) 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 }
|
||||
return { score, amountLabel, purposeMatches, cashDiscountMatch }
|
||||
}
|
||||
|
||||
const getRowSuggestions = (statement) => {
|
||||
@@ -435,7 +465,7 @@ const getRowSuggestions = (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}` }
|
||||
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `document:${document.id}` }
|
||||
})
|
||||
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
|
||||
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
||||
@@ -444,7 +474,7 @@ const getRowSuggestions = (statement) => {
|
||||
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}` }
|
||||
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `invoice:${invoice.id}` }
|
||||
})
|
||||
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
|
||||
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
||||
@@ -552,6 +582,24 @@ const handleAssignIncomingInvoice = async (row, invoice, event) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Bucht die Zahlung zusammen mit dem vereinbarten Skonto, wenn Betrag und Frist zu den Skontobedingungen passen.
|
||||
const handleAssignWithCashDiscount = async (row, kind, entity, event) => {
|
||||
event.stopPropagation()
|
||||
const match = entity.suggestionCashDiscount
|
||||
if (!match) return
|
||||
try {
|
||||
await saveAllocation({
|
||||
[kind]: entity.id,
|
||||
bankstatement: row.id,
|
||||
amount: Number(calculateOpenSum(row)),
|
||||
cashDiscountAmount: match.discount,
|
||||
description: "Automatischer Vorschlag mit Skonto"
|
||||
})
|
||||
} catch (err) {
|
||||
toast.add({ title: "Skonto konnte nicht gebucht werden", description: err?.data?.error, color: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const dismissSuggestion = (row, suggestionKey, event) => {
|
||||
event.stopPropagation()
|
||||
const bankingSettings = tempStore.settings?.banking || {}
|
||||
@@ -928,7 +976,15 @@ onMounted(() => {
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
|
||||
<UButton
|
||||
v-if="selectedSuggestionRow.suggestions.topDocument.suggestionCashDiscount"
|
||||
size="sm"
|
||||
color="warning"
|
||||
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'createddocument', selectedSuggestionRow.suggestions.topDocument, $event)"
|
||||
>
|
||||
Mit Skonto zuweisen
|
||||
</UButton>
|
||||
<UButton v-else size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
|
||||
Zuweisen
|
||||
</UButton>
|
||||
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
|
||||
@@ -951,7 +1007,15 @@ onMounted(() => {
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
|
||||
<UButton
|
||||
v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionCashDiscount"
|
||||
size="sm"
|
||||
color="warning"
|
||||
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'incominginvoice', selectedSuggestionRow.suggestions.topIncomingInvoice, $event)"
|
||||
>
|
||||
Mit Skonto zuweisen
|
||||
</UButton>
|
||||
<UButton v-else size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
|
||||
Zuweisen
|
||||
</UButton>
|
||||
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">
|
||||
|
||||
Reference in New Issue
Block a user