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

@@ -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

View File

@@ -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) {