KI-AGENT: Kassenbucheintrag vereinfacht anlegen

This commit is contained in:
2026-05-28 16:43:03 +02:00
parent f2055d59eb
commit 2a5071b15a
2 changed files with 31 additions and 208 deletions

View File

@@ -260,7 +260,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
} }
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." }) if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." }) const bookingDate = body.date && dayjs(body.date).isValid() ? dayjs(body.date) : dayjs()
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." }) if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." }) if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
@@ -273,8 +273,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." }) if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId) const hasCounterInput = Boolean(body.counterType || body.counterId)
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." }) const counterPayload = hasCounterInput
? buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
: null
if (hasCounterInput && !counterPayload) return reply.code(400).send({ error: "Bitte ein gültiges Gegenkonto auswählen." })
const signedAmount = body.direction === "income" const signedAmount = body.direction === "income"
? Math.abs(Number(body.amount)) ? Math.abs(Number(body.amount))
@@ -284,8 +287,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
const created = await server.db.transaction(async (tx) => { const created = await server.db.transaction(async (tx) => {
const insertedStatements = await tx.insert(bankstatements).values({ const insertedStatements = await tx.insert(bankstatements).values({
account: cashbookId, account: cashbookId,
date: dayjs(body.date).format("YYYY-MM-DD"), date: bookingDate.format("YYYY-MM-DD"),
valueDate: dayjs(body.date).format("YYYY-MM-DD"), valueDate: bookingDate.format("YYYY-MM-DD"),
amount: signedAmount, amount: signedAmount,
tenant: req.user.tenant_id, tenant: req.user.tenant_id,
text: description, text: description,
@@ -296,7 +299,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
}).returning() }).returning()
const statement = insertedStatements[0] const statement = insertedStatements[0]
const insertedAllocations = await tx.insert(statementallocations).values({ const insertedAllocations = counterPayload
? await tx.insert(statementallocations).values({
bankstatement: statement.id, bankstatement: statement.id,
amount: signedAmount, amount: signedAmount,
tenant: req.user.tenant_id, tenant: req.user.tenant_id,
@@ -304,10 +308,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null, datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
...counterPayload, ...counterPayload,
}).returning() }).returning()
: []
return { return {
statement, statement,
allocation: insertedAllocations[0], allocation: insertedAllocations[0] || null,
} }
}) })

View File

@@ -14,10 +14,8 @@ const route = useRoute()
const bankstatements = ref([]) const bankstatements = ref([])
const bankaccounts = ref([]) const bankaccounts = ref([])
const accounts = ref([])
const customers = ref([]) const customers = ref([])
const vendors = ref([]) const vendors = ref([])
const ownaccounts = ref([])
const entitybankaccounts = ref([]) const entitybankaccounts = ref([])
const createddocuments = ref([]) const createddocuments = ref([])
const incominginvoices = ref([]) const incominginvoices = ref([])
@@ -31,8 +29,6 @@ const savingCashbookBooking = ref(false)
const loadingDocs = ref(true) // Startet im Ladezustand const loadingDocs = ref(true) // Startet im Ladezustand
const suggestionsModalOpen = ref(false) const suggestionsModalOpen = ref(false)
const selectedSuggestionRowId = ref(null) const selectedSuggestionRowId = ref(null)
const counterSearch = ref("")
const expandedGroups = ref([])
const CASHBOOK_BANK_ID = "fedeo-cashbook" const CASHBOOK_BANK_ID = "fedeo-cashbook"
@@ -62,22 +58,10 @@ const dateRange = ref({
}) })
const cashbookForm = reactive({ const cashbookForm = reactive({
date: $dayjs().format("YYYY-MM-DD"),
direction: "expense", direction: "expense",
amount: null, amount: null
counter: "",
datevTaxKey: "__none__",
description: ""
}) })
const DATEV_TAX_KEY_ITEMS = [
{ value: "__none__", label: "Ohne Steuerschlüssel" },
{ value: "9", label: "9 - Vorsteuer 19 %" },
{ value: "8", label: "8 - Vorsteuer 7 %" },
{ value: "19", label: "19 - EU Vorsteuer 19 %" },
{ value: "18", label: "18 - EU Vorsteuer 7 %" }
]
const getCalendarValue = (value) => { const getCalendarValue = (value) => {
if (!value) return undefined if (!value) return undefined
@@ -98,13 +82,11 @@ const setDateRangeFieldToToday = (field) => {
const setupPage = async () => { const setupPage = async () => {
loadingDocs.value = true loadingDocs.value = true
try { try {
const [statements, bankAccountItems, accountItems, customerItems, vendorItems, ownAccountItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([ const [statements, bankAccountItems, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false), useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
useEntities("bankaccounts").select(), useEntities("bankaccounts").select(),
useEntities("accounts").selectSpecial("*", "number", true),
useEntities("customers").select(), useEntities("customers").select(),
useEntities("vendors").select(), useEntities("vendors").select(),
useEntities("ownaccounts").select(),
useEntities("entitybankaccounts").select(), useEntities("entitybankaccounts").select(),
useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"), useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"),
useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)") useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")
@@ -115,10 +97,8 @@ const setupPage = async () => {
...account, ...account,
displayLabel: getBankAccountLabel(account) displayLabel: getBankAccountLabel(account)
})) }))
accounts.value = accountItems
customers.value = customerItems customers.value = customerItems
vendors.value = vendorItems vendors.value = vendorItems
ownaccounts.value = ownAccountItems
entitybankaccounts.value = entityBankItems entitybankaccounts.value = entityBankItems
createddocuments.value = documentItems createddocuments.value = documentItems
incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht") incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht")
@@ -276,118 +256,25 @@ const currentCashbookBalance = computed(() => {
.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) .reduce((sum, statement) => sum + Number(statement.amount || 0), 0)
return openingBalance + movementSum return openingBalance + movementSum
}) })
const getIncomingInvoiceGross = (invoice) => Number((invoice.accounts || []).reduce((sum, account) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0)
}, 0))
const getIncomingInvoiceOpenAmount = (invoice) => {
const gross = getIncomingInvoiceGross(invoice)
const allocated = Number((invoice.statementallocations || []).reduce((sum, allocation) => sum + Number(allocation.amount || 0), 0))
return Math.abs(gross) - Math.abs(allocated)
}
const buildCashbookEntries = (rows, type, labelBuilder) =>
(rows || []).map((item) => ({
key: `${type}:${item.id}`,
id: item.id,
type,
number: item.number || item.vendorNumber || item.customerNumber || item.reference || "",
name: item.label || item.name || item.vendor?.name || "",
label: labelBuilder(item),
typeLabel:
type === "account"
? "Sachkonten"
: type === "vendor"
? "Kreditoren"
: type === "customer"
? "Debitoren"
: type === "incominginvoice"
? "Eingangsbelege"
: "Zusätzliche Konten"
}))
const cashbookEntryGroups = computed(() => ([
{
key: "account",
label: "Sachkonten",
entries: buildCashbookEntries(accounts.value, "account", (item) => `${item.number} - ${item.label}`)
},
{
key: "vendor",
label: "Kreditoren",
entries: buildCashbookEntries(vendors.value, "vendor", (item) => `${item.vendorNumber || "ohne Nr."} - ${item.name}`)
},
{
key: "customer",
label: "Debitoren",
entries: buildCashbookEntries(customers.value, "customer", (item) => `${item.customerNumber || "ohne Nr."} - ${item.name}`)
},
{
key: "ownaccount",
label: "Zusätzliche Konten",
entries: buildCashbookEntries(ownaccounts.value, "ownaccount", (item) => `${item.number} - ${item.name}`)
},
{
key: "incominginvoice",
label: "Eingangsbelege",
entries: buildCashbookEntries(
incominginvoices.value.filter((invoice) => !invoice.archived && getIncomingInvoiceOpenAmount(invoice) > 0.004),
"incominginvoice",
(item) => `${item.reference || "Ohne Referenz"} - ${item.vendor?.name || "Ohne Lieferant"} - Offen ${displayCurrency(getIncomingInvoiceOpenAmount(item))}`
)
}
]))
const groupedCashbookCounterEntries = computed(() =>
cashbookEntryGroups.value.map((group) => ({
...group,
entries: group.entries.filter((entry) => {
const search = normalizeSuggestionText(counterSearch.value)
if (!search) return true
return [entry.number, entry.name, entry.label, entry.typeLabel]
.some((value) => normalizeSuggestionText(value).includes(search))
})
})).filter((group) => group.entries.length > 0)
)
const selectedCashbookCounter = computed(() => cashbookEntryGroups.value.flatMap((group) => group.entries).find((entry) => entry.key === cashbookForm.counter))
const isCashbookGroupExpanded = (groupKey) => expandedGroups.value.includes(groupKey)
const toggleCashbookGroupExpanded = (groupKey) => {
if (expandedGroups.value.includes(groupKey)) {
expandedGroups.value = expandedGroups.value.filter((item) => item !== groupKey)
return
}
expandedGroups.value = [...expandedGroups.value, groupKey]
}
const visibleCashbookEntries = (group) => {
if (group.entries.length <= 5 || isCashbookGroupExpanded(group.key)) return group.entries
return group.entries.slice(0, 5)
}
const saveCashbookBooking = async () => { const saveCashbookBooking = async () => {
if (!selectedCashbookAccount.value || !cashbookForm.date || !cashbookForm.amount || !cashbookForm.counter) { if (!selectedCashbookAccount.value || !cashbookForm.amount) {
toast.add({ title: "Bitte Kasse, Datum, Betrag und Gegenkonto auswählen.", color: "warning" }) toast.add({ title: "Bitte Kasse und Betrag auswählen.", color: "warning" })
return return
} }
const [counterType, counterId] = String(cashbookForm.counter).split(":")
savingCashbookBooking.value = true savingCashbookBooking.value = true
try { try {
await $api(`/api/banking/cashbooks/${selectedCashbookAccount.value.id}/bookings`, { const created = await $api(`/api/banking/cashbooks/${selectedCashbookAccount.value.id}/bookings`, {
method: "POST", method: "POST",
body: { body: {
date: cashbookForm.date,
direction: cashbookForm.direction, direction: cashbookForm.direction,
amount: Number(cashbookForm.amount), amount: Number(cashbookForm.amount)
counterType,
counterId,
datevTaxKey: cashbookForm.datevTaxKey === "__none__" ? null : cashbookForm.datevTaxKey,
description: cashbookForm.description
} }
}) })
toast.add({ title: "Kassenbuchung erstellt." }) toast.add({ title: "Kassenbuchung erstellt." })
cashbookForm.amount = null cashbookForm.amount = null
cashbookForm.counter = ""
cashbookForm.datevTaxKey = "__none__"
cashbookForm.description = ""
counterSearch.value = ""
expandedGroups.value = []
cashbookBookingModalOpen.value = false cashbookBookingModalOpen.value = false
await setupPage() await router.push(`/banking/statements/edit/${created.statement.id}`)
} finally { } finally {
savingCashbookBooking.value = false savingCashbookBooking.value = false
} }
@@ -876,7 +763,7 @@ onMounted(() => {
</div> </div>
<PageLeaveGuard :when="isSyncing"/> <PageLeaveGuard :when="isSyncing"/>
<UModal v-model:open="cashbookBookingModalOpen" :ui="{ width: 'sm:max-w-5xl' }"> <UModal v-model:open="cashbookBookingModalOpen" :ui="{ width: 'sm:max-w-lg' }">
<template #content> <template #content>
<UCard> <UCard>
<template #header> <template #header>
@@ -896,27 +783,12 @@ onMounted(() => {
</div> </div>
</template> </template>
<div class="space-y-6"> <div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-3"> <div class="space-y-3">
<UFormField label="Buchungsdatum">
<UInput v-model="cashbookForm.date" type="date" />
</UFormField>
<UFormField label="Betrag"> <UFormField label="Betrag">
<UInput v-model="cashbookForm.amount" type="number" min="0" step="0.01" placeholder="0,00" /> <UInput v-model="cashbookForm.amount" type="number" min="0" step="0.01" placeholder="0,00" />
</UFormField> </UFormField>
<UFormField label="DATEV-Steuerschlüssel"> <div class="grid gap-3 sm:grid-cols-2">
<USelect
v-model="cashbookForm.datevTaxKey"
:items="DATEV_TAX_KEY_ITEMS"
value-key="value"
label-key="label"
class="w-full"
/>
</UFormField>
</div>
<div class="grid gap-6 xl:grid-cols-[220px_minmax(0,1fr)]">
<div class="space-y-3">
<button <button
type="button" type="button"
class="w-full rounded-lg border px-4 py-3 text-left transition" class="w-full rounded-lg border px-4 py-3 text-left transition"
@@ -940,60 +812,6 @@ onMounted(() => {
<div class="text-sm text-gray-500">Bargeld wird aus der Kasse entnommen.</div> <div class="text-sm text-gray-500">Bargeld wird aus der Kasse entnommen.</div>
</button> </button>
</div> </div>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">Gegenkonto</h3>
<p class="text-sm text-gray-500">Sachkonto, Kreditor, Debitor, Eingangsbeleg oder zusätzliches Konto.</p>
</div>
<UInput v-model="counterSearch" icon="i-heroicons-magnifying-glass" placeholder="Gegenkonto durchsuchen..." class="max-w-sm" />
</div>
<div class="max-h-[360px] space-y-4 overflow-y-auto pr-1">
<div v-for="group in groupedCashbookCounterEntries" :key="group.key" class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">{{ group.label }}</div>
<div class="grid gap-2 md:grid-cols-2">
<button
v-for="entry in visibleCashbookEntries(group)"
:key="entry.key"
type="button"
class="rounded-lg border px-3 py-2 text-left transition"
:class="cashbookForm.counter === entry.key
? 'border-primary-500 bg-primary-50 dark:bg-primary-950/30'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900'"
@click="cashbookForm.counter = entry.key"
>
<div class="flex min-w-0 items-center gap-2">
<span class="font-mono text-sm">{{ entry.number }}</span>
<span class="truncate text-sm">{{ entry.name }}</span>
</div>
</button>
</div>
<UButton
v-if="group.entries.length > 5"
size="xs"
color="neutral"
variant="ghost"
:label="isCashbookGroupExpanded(group.key) ? 'Weniger anzeigen' : `${group.entries.length - 5} weitere anzeigen`"
@click="toggleCashbookGroupExpanded(group.key)"
/>
</div>
</div>
<UAlert
v-if="selectedCashbookCounter"
color="primary"
variant="soft"
icon="i-heroicons-arrows-right-left"
:title="cashbookForm.direction === 'income' ? `${selectedCashbookAccount?.datevNumber} an ${selectedCashbookCounter.number}` : `${selectedCashbookCounter.number} an ${selectedCashbookAccount?.datevNumber}`"
:description="`${selectedCashbookCounter.typeLabel.slice(0, -1)} ${selectedCashbookCounter.name} wird als Gegenkonto verwendet.`"
/>
<UFormField label="Beschreibung">
<UTextarea v-model="cashbookForm.description" placeholder="z. B. Bareinkauf Büromaterial" autoresize class="w-full" />
</UFormField>
</div>
</div> </div>
</div> </div>
@@ -1003,7 +821,7 @@ onMounted(() => {
Abbrechen Abbrechen
</UButton> </UButton>
<UButton color="primary" icon="i-heroicons-check" :loading="savingCashbookBooking" @click="saveCashbookBooking"> <UButton color="primary" icon="i-heroicons-check" :loading="savingCashbookBooking" @click="saveCashbookBooking">
Eintrag speichern Anlegen und bearbeiten
</UButton> </UButton>
</div> </div>
</template> </template>