Vorschläge System in Bankbuchungen
This commit is contained in:
@@ -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<string, any>, 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<Record<string, any>> = []
|
||||
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<string, any> : {}
|
||||
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<string, any> : {}
|
||||
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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
<template>
|
||||
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
|
||||
<template #right>
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-sparkles"
|
||||
@click="suggestionsModalOpen = true"
|
||||
>
|
||||
Vorschlaege
|
||||
<UBadge color="primary" variant="solid" size="xs" class="ml-2">{{ suggestionCount }}</UBadge>
|
||||
</UButton>
|
||||
<UButton
|
||||
label="Bankabruf"
|
||||
icon="i-heroicons-arrow-path"
|
||||
@@ -208,8 +513,14 @@ onMounted(() => {
|
||||
icon="i-heroicons-calendar-days"
|
||||
/>
|
||||
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
|
||||
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
|
||||
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
|
||||
<div class="flex items-center gap-1">
|
||||
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
|
||||
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('start')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
|
||||
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('end')" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
|
||||
{{ $dayjs(dateRange.start).format('DD.MM.') }} - {{ $dayjs(dateRange.end).format('DD.MM.YYYY') }}
|
||||
@@ -289,4 +600,111 @@ onMounted(() => {
|
||||
</table>
|
||||
</div>
|
||||
<PageLeaveGuard :when="isSyncing"/>
|
||||
</template>
|
||||
|
||||
<UModal v-model="suggestionsModalOpen" :ui="{ width: 'sm:max-w-6xl' }">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-lg font-semibold">Vorschlaege fuer Bankbuchungen</div>
|
||||
<div class="text-sm text-gray-500">Direkte Zuweisung von passenden Rechnungen und Eingangsbelegen</div>
|
||||
</div>
|
||||
<UBadge color="primary" variant="subtle">{{ suggestionCount }}</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="rowsWithSuggestions.length > 0" class="grid grid-cols-1 lg:grid-cols-3 gap-4 min-h-[520px]">
|
||||
<div class="lg:col-span-1 border rounded-lg overflow-hidden dark:border-gray-800">
|
||||
<div
|
||||
v-for="entry in rowsWithSuggestions"
|
||||
:key="entry.row.id"
|
||||
class="p-3 border-b dark:border-gray-800 cursor-pointer transition-colors"
|
||||
:class="selectedSuggestionRowId === entry.row.id || (!selectedSuggestionRowId && selectedSuggestionRow?.row.id === entry.row.id) ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-900/50'"
|
||||
@click="selectedSuggestionRowId = entry.row.id"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium truncate">{{ entry.row.amount < 0 ? entry.row.credName : entry.row.debName }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ entry.row.text || 'Ohne Verwendungszweck' }}</div>
|
||||
</div>
|
||||
<div class="text-xs font-mono whitespace-nowrap" :class="entry.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">
|
||||
{{ displayCurrency(entry.row.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<UBadge v-if="entry.suggestions?.topDocument" size="xs" color="emerald" variant="subtle">Rechnung</UBadge>
|
||||
<UBadge v-if="entry.suggestions?.topIncomingInvoice" size="xs" color="rose" variant="subtle">Eingangsbeleg</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2 space-y-3" v-if="selectedSuggestionRow">
|
||||
<div class="rounded-lg border dark:border-gray-800 p-4 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">{{ selectedSuggestionRow.row.amount < 0 ? selectedSuggestionRow.row.credName : selectedSuggestionRow.row.debName }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ selectedSuggestionRow.row.text || 'Ohne Verwendungszweck' }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ $dayjs(selectedSuggestionRow.row.valueDate).format("DD.MM.YYYY") }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-mono font-semibold" :class="selectedSuggestionRow.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">{{ displayCurrency(selectedSuggestionRow.row.amount) }}</div>
|
||||
<div class="text-xs text-gray-400">Offen {{ displayCurrency(calculateOpenSum(selectedSuggestionRow.row)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSuggestionRow.suggestions?.topDocument" class="rounded-lg border border-emerald-200 bg-emerald-50/70 px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-emerald-700">Rechnung {{ selectedSuggestionRow.suggestions.topDocument.documentNumber }}</div>
|
||||
<div class="text-xs text-gray-600 mt-1">{{ selectedSuggestionRow.suggestions.topDocument.customer?.name }} | Offen {{ displayCurrency(selectedSuggestionRow.suggestions.topDocument.openSum) }}</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1" v-if="selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel || selectedSuggestionRow.suggestions.topDocument.suggestionPurposeMatches?.length">
|
||||
<UBadge v-if="selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
||||
{{ selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel }}
|
||||
</UBadge>
|
||||
<UBadge v-for="match in selectedSuggestionRow.suggestions.topDocument.suggestionPurposeMatches" :key="`modal-doc-${selectedSuggestionRow.row.id}-${match}`" size="xs" color="amber" variant="subtle">
|
||||
VWZ: {{ match }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<UButton 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)">
|
||||
Ablehnen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSuggestionRow.suggestions?.topIncomingInvoice" class="rounded-lg border border-rose-200 bg-rose-50/70 px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-rose-700">Eingangsbeleg {{ selectedSuggestionRow.suggestions.topIncomingInvoice.reference || '-' }}</div>
|
||||
<div class="text-xs text-gray-600 mt-1">{{ selectedSuggestionRow.suggestions.topIncomingInvoice.vendor?.name }} | Offen {{ displayCurrency(getInvoiceSum(selectedSuggestionRow.suggestions.topIncomingInvoice, true)) }}</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1" v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel || selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionPurposeMatches?.length">
|
||||
<UBadge v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
||||
{{ selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel }}
|
||||
</UBadge>
|
||||
<UBadge v-for="match in selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionPurposeMatches" :key="`modal-invoice-${selectedSuggestionRow.row.id}-${match}`" size="xs" color="amber" variant="subtle">
|
||||
VWZ: {{ match }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" color="rose" @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)">
|
||||
Ablehnen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-10 text-center text-gray-400">
|
||||
<UIcon name="i-heroicons-sparkles" class="w-10 h-10 mx-auto mb-2 opacity-30"/>
|
||||
<p>Keine Vorschlaege fuer die aktuelle Filterung vorhanden.</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -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