diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index 011c1a3..e8bbc7d 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -8,9 +8,108 @@ import { files, filetags, incominginvoices, + vendors, } from "../../../db/schema" -import { eq, and, isNull, not } from "drizzle-orm" +import { eq, and, isNull, not, desc } from "drizzle-orm" + +type InvoiceAccount = { + account?: number | null + description?: string | null + taxType?: string | number | null +} + +const normalizeAccounts = (accounts: unknown): InvoiceAccount[] => { + if (!Array.isArray(accounts)) return [] + return accounts + .map((entry: any) => ({ + account: typeof entry?.account === "number" ? entry.account : null, + description: typeof entry?.description === "string" ? entry.description : null, + taxType: entry?.taxType ?? null, + })) + .filter((entry) => entry.account !== null || entry.description || entry.taxType !== null) +} + +const buildLearningContext = (historicalInvoices: any[]) => { + if (!historicalInvoices.length) return null + + const vendorProfiles = new Map + accountUsage: Map + sampleDescriptions: string[] + }>() + + const recentExamples: any[] = [] + + for (const invoice of historicalInvoices) { + const accounts = normalizeAccounts(invoice.accounts) + const vendorId = typeof invoice.vendorId === "number" ? invoice.vendorId : null + const vendorName = typeof invoice.vendorName === "string" ? invoice.vendorName : "Unknown" + + if (vendorId) { + if (!vendorProfiles.has(vendorId)) { + vendorProfiles.set(vendorId, { + vendorName, + paymentTypes: new Map(), + accountUsage: new Map(), + sampleDescriptions: [], + }) + } + + const profile = vendorProfiles.get(vendorId)! + if (invoice.paymentType) { + const key = String(invoice.paymentType) + profile.paymentTypes.set(key, (profile.paymentTypes.get(key) ?? 0) + 1) + } + for (const account of accounts) { + if (typeof account.account === "number") { + profile.accountUsage.set(account.account, (profile.accountUsage.get(account.account) ?? 0) + 1) + } + } + if (invoice.description && profile.sampleDescriptions.length < 3) { + profile.sampleDescriptions.push(String(invoice.description).slice(0, 120)) + } + } + + if (recentExamples.length < 20) { + recentExamples.push({ + vendorId, + vendorName, + paymentType: invoice.paymentType ?? null, + accounts: accounts.map((entry) => ({ + account: entry.account, + description: entry.description ?? null, + taxType: entry.taxType ?? null, + })), + }) + } + } + + const vendorPatterns = Array.from(vendorProfiles.entries()) + .map(([vendorId, profile]) => { + const commonPaymentType = Array.from(profile.paymentTypes.entries()) + .sort((a, b) => b[1] - a[1])[0]?.[0] ?? null + const topAccounts = Array.from(profile.accountUsage.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + .map(([accountId, count]) => ({ accountId, count })) + + return { + vendorId, + vendorName: profile.vendorName, + commonPaymentType, + topAccounts, + sampleDescriptions: profile.sampleDescriptions, + } + }) + .slice(0, 50) + + return JSON.stringify({ + vendorPatterns, + recentExamples, + }) +} export function prepareIncomingInvoices(server: FastifyInstance) { const processInvoices = async (tenantId:number) => { @@ -72,13 +171,34 @@ export function prepareIncomingInvoices(server: FastifyInstance) { continue } + const historicalInvoices = await server.db + .select({ + vendorId: incominginvoices.vendor, + vendorName: vendors.name, + paymentType: incominginvoices.paymentType, + description: incominginvoices.description, + accounts: incominginvoices.accounts, + }) + .from(incominginvoices) + .leftJoin(vendors, eq(incominginvoices.vendor, vendors.id)) + .where( + and( + eq(incominginvoices.tenant, tenantId), + eq(incominginvoices.archived, false) + ) + ) + .orderBy(desc(incominginvoices.createdAt)) + .limit(120) + + const learningContext = buildLearningContext(historicalInvoices) + // ------------------------------------------------------------- // 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen // ------------------------------------------------------------- for (const file of filesRes) { console.log(`Processing file ${file.id} for tenant ${tenantId}`) - const data = await getInvoiceDataFromGPT(server,file, tenantId) + const data = await getInvoiceDataFromGPT(server,file, tenantId, learningContext ?? undefined) if (!data) { server.log.warn(`GPT returned no data for file ${file.id}`) diff --git a/backend/src/utils/gpt.ts b/backend/src/utils/gpt.ts index 208c050..0d73eeb 100644 --- a/backend/src/utils/gpt.ts +++ b/backend/src/utils/gpt.ts @@ -91,7 +91,8 @@ const InstructionFormat = z.object({ export const getInvoiceDataFromGPT = async function ( server: FastifyInstance, file: any, - tenantId: number + tenantId: number, + learningContext?: string ) { await initOpenAi(); @@ -188,8 +189,13 @@ export const getInvoiceDataFromGPT = async function ( "You extract structured invoice data.\n\n" + `VENDORS: ${JSON.stringify(vendorList)}\n` + `ACCOUNTS: ${JSON.stringify(accountList)}\n\n` + + (learningContext + ? `HISTORICAL_PATTERNS: ${learningContext}\n\n` + : "") + "Match issuer by name to vendor.id.\n" + "Match invoice items to account id based on label/number.\n" + + "Use historical patterns as soft hints for vendor/account/payment mapping.\n" + + "Do not invent values when the invoice text contradicts the hints.\n" + "Convert dates to YYYY-MM-DD.\n" + "Keep invoice items in original order.\n", }, diff --git a/frontend/pages/incomingInvoices/[mode]/[id].vue b/frontend/pages/incomingInvoices/[mode]/[id].vue index 75d74e7..6851d05 100644 --- a/frontend/pages/incomingInvoices/[mode]/[id].vue +++ b/frontend/pages/incomingInvoices/[mode]/[id].vue @@ -118,21 +118,35 @@ const totalCalculated = computed(() => { return { totalNet, totalAmount19Tax, totalAmount7Tax, totalGross } }) +const hasAmount = (value) => value !== null && value !== undefined && value !== "" + const recalculateItem = (item, source) => { const taxRate = Number(taxOptions.value.find(i => i.key === item.taxType)?.percentage || 0); - - if (source === 'net') { + const calculateFromNet = () => { + if(!hasAmount(item.amountNet)) return item.amountTax = Number((item.amountNet * (taxRate/100)).toFixed(2)) item.amountGross = Number((Number(item.amountNet) + item.amountTax).toFixed(2)) - } else if (source === 'gross') { + } + const calculateFromGross = () => { + if(!hasAmount(item.amountGross)) return item.amountNet = Number((item.amountGross / (1 + taxRate/100)).toFixed(2)) item.amountTax = Number((item.amountGross - item.amountNet).toFixed(2)) - } else if (source === 'taxType') { - if(item.amountNet) { - item.amountTax = Number((item.amountNet * (taxRate/100)).toFixed(2)) - item.amountGross = Number((Number(item.amountNet) + item.amountTax).toFixed(2)) - } } + + if (source === 'net') { + calculateFromNet() + } else if (source === 'gross') { + calculateFromGross() + } else if (source === 'taxType' || source === 'manual') { + if(hasAmount(item.amountNet)) calculateFromNet() + else if(hasAmount(item.amountGross)) calculateFromGross() + } +} + +const moveGrossToNet = (item) => { + if(!hasAmount(item.amountGross)) return + item.amountNet = Number(item.amountGross) + recalculateItem(item, 'net') } // --- Saving --- @@ -401,24 +415,35 @@ const findIncomingInvoiceErrors = computed(() => { -
- +
+
-
+
+ + + + + +
+ +
{
-
+
@@ -439,6 +464,27 @@ const findIncomingInvoiceErrors = computed(() => {
+
+ + Steuer berechnen + + + Brutto als Netto + +
+
@@ -527,4 +573,4 @@ const findIncomingInvoiceErrors = computed(() => { .resize { resize: both; } - \ No newline at end of file + diff --git a/frontend/pages/settings/banking/index.vue b/frontend/pages/settings/banking/index.vue index d550833..476618e 100644 --- a/frontend/pages/settings/banking/index.vue +++ b/frontend/pages/settings/banking/index.vue @@ -4,7 +4,6 @@ const profileStore = useProfileStore() const route = useRoute() const router = useRouter() const url = useRequestURL() -const supabase = useSupabaseClient() const toast = useToast() const showAddBankRequisition = ref(false) @@ -49,36 +48,45 @@ const generateLink = async (bankId) => { } const addAccount = async (account) => { - let accountData = { - accountId: account.id, - ownerName: account.owner_name, - iban: account.iban, - tenant: profileStore.currentTenant, - bankId: account.institution_id - } + let accountData = { + accountId: account.id, + ownerName: account.owner_name, + iban: account.iban, + tenant: profileStore.currentTenant, + bankId: account.institution_id + } - const {data,error} = await supabase.from("bankaccounts").insert(accountData).select() - if(error) { - toast.add({title: "Es gab einen Fehler bei hinzufügen des Accounts", color:"rose"}) - } else if(data) { - toast.add({title: "Account erfolgreich hinzugefügt"}) + try { + // Nutzung von useEntities statt direktem DB-Call + // true als 2. Parameter verhindert den Redirect, da wir im Modal sind + const res = await useEntities("bankaccounts").create(accountData, true) + + if(res) { + // useEntities feuert bereits einen Success-Toast ("X hinzugefügt") + // Wir laden die Seite neu, um die Buttons im Modal zu aktualisieren (Hinzufügen -> Aktualisieren) + await setupPage() } + } catch (error) { + console.error(error) + toast.add({title: "Es gab einen Fehler beim Hinzufügen des Accounts", color:"rose"}) + } } const updateAccount = async (account) => { - let bankaccountId = bankaccounts.value.find(i => i.iban === account.iban).id + let bankaccountId = bankaccounts.value.find(i => i.iban === account.iban).id - const res = await useEntities("bankaccounts").update(bankaccountId, {accountId: account.id, expired: false}) + // Fehlerbehandlung analog zu addAccount verbessert + try { + const res = await useEntities("bankaccounts").update(bankaccountId, {accountId: account.id, expired: false}, true) - if(!res) { - console.log(error) - toast.add({title: "Es gab einen Fehler bei aktualisieren des Accounts", color:"rose"}) - } else { - toast.add({title: "Account erfolgreich aktualisiert"}) - reqData.value = null - setupPage() - } + // useEntities feuert bereits einen Success-Toast + // reqData.value = null // Das würde das Modal leeren, ggf. gewünscht? Im Original war es drin. + setupPage() + } catch (error) { + console.log(error) + toast.add({title: "Es gab einen Fehler beim Aktualisieren des Accounts", color:"rose"}) + } } setupPage() @@ -88,23 +96,23 @@ setupPage()
{{account.iban}} - {{account.owner_name}} @@ -170,16 +179,9 @@ setupPage() - - - - Ausgelaufen Aktiv Aktualisieren