fix(accounting): Belegvorbereitung im Hintergrund ausführen und Dubletten verhindern
- „Belege vorbereiten“ startet den Lauf im Hintergrund (202) und fragt den Status ab; ein laufender Vorgang wird nicht erneut gestartet und beim Wiederöffnen der Seite weiterverfolgt - Eingangsbeleg und Dateiverknüpfung werden in einer Transaktion angelegt; ist die Datei bereits verknüpft, wird der neue Beleg verworfen - Fehler einzelner Dateien brechen den Lauf nicht mehr ab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,15 @@ export type IncomingInvoicePreparationResult = {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IncomingInvoicePreparationStatus = {
|
||||||
|
running: boolean
|
||||||
|
startedAt: string | null
|
||||||
|
finishedAt: string | null
|
||||||
|
result: IncomingInvoicePreparationResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileAlreadyPreparedError extends Error {}
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number, fileIds?: string[]) => {
|
const processInvoices = async (tenantId:number, fileIds?: string[]) => {
|
||||||
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
|
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
|
||||||
@@ -162,6 +171,23 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
|
try {
|
||||||
|
await processFile(tenantId, file, result)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FileAlreadyPreparedError) {
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
|
||||||
|
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
let fileData: Buffer
|
let fileData: Buffer
|
||||||
@@ -171,7 +197,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||||
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
|
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||||
@@ -201,7 +227,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (electronicInvoice.container === "xml") {
|
if (electronicInvoice.container === "xml") {
|
||||||
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
|
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,51 +238,45 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
|
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// 4️⃣ IncomingInvoice erstellen
|
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
|
||||||
|
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
|
||||||
|
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
const inserted = await server.db
|
const newInvoice = await server.db.transaction(async (tx) => {
|
||||||
|
const [invoice] = await tx
|
||||||
.insert(incominginvoices)
|
.insert(incominginvoices)
|
||||||
.values(itemInfo)
|
.values(itemInfo)
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
const newInvoice = inserted?.[0]
|
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
|
||||||
|
|
||||||
if (!newInvoice) {
|
const linked = await tx
|
||||||
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
|
|
||||||
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 5️⃣ Datei mit incominginvoice-ID verbinden
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
await server.db
|
|
||||||
.update(files)
|
.update(files)
|
||||||
.set({ incominginvoice: newInvoice.id })
|
.set({ incominginvoice: invoice.id })
|
||||||
.where(eq(files.id, file.id))
|
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
|
||||||
|
.returning({ id: files.id })
|
||||||
|
|
||||||
|
if (!linked.length) throw new FileAlreadyPreparedError()
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
})
|
||||||
|
|
||||||
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
|
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
|
||||||
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
|
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
|
||||||
const running = new Map<number, Promise<unknown>>()
|
const running = new Map<number, Promise<unknown>>()
|
||||||
|
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
|
||||||
|
|
||||||
return {
|
const run = async (tenant:number, options: { fileIds?: string[] } = {}) => {
|
||||||
run: async (tenant:number, options: { fileIds?: string[] } = {}) => {
|
|
||||||
const previous = running.get(tenant) || Promise.resolve()
|
const previous = running.get(tenant) || Promise.resolve()
|
||||||
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
|
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
|
||||||
running.set(tenant, current)
|
running.set(tenant, current)
|
||||||
@@ -268,6 +288,35 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
if (running.get(tenant) === current) running.delete(tenant)
|
if (running.get(tenant) === current) running.delete(tenant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = (tenant: number): IncomingInvoicePreparationStatus =>
|
||||||
|
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
|
||||||
|
|
||||||
|
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
|
||||||
|
const start = (tenant: number): IncomingInvoicePreparationStatus => {
|
||||||
|
const current = status(tenant)
|
||||||
|
if (current.running) return current
|
||||||
|
|
||||||
|
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
|
||||||
|
backgroundRuns.set(tenant, next)
|
||||||
|
|
||||||
|
run(tenant)
|
||||||
|
.then((result) => {
|
||||||
|
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
|
||||||
|
backgroundRuns.set(tenant, {
|
||||||
|
...next,
|
||||||
|
running: false,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { run, start, status }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,8 +369,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
||||||
|
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
|
||||||
|
})
|
||||||
|
|
||||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
server.get('/functions/services/prepareincominginvoices', async (req) => {
|
||||||
|
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
||||||
|
|||||||
45
backend/tests/incomingInvoicePreparation.test.ts
Normal file
45
backend/tests/incomingInvoicePreparation.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
|
||||||
|
|
||||||
|
function server(tenantLookups: { count: number }) {
|
||||||
|
let release: () => void = () => undefined
|
||||||
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||||
|
const db: any = {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: async () => {
|
||||||
|
tenantLookups.count += 1
|
||||||
|
await gate
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
|
||||||
|
return { server: { db, log } as any, release: () => release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("start runs the preparation in the background and does not start a second run", async () => {
|
||||||
|
const lookups = { count: 0 }
|
||||||
|
const { server: srv, release } = server(lookups)
|
||||||
|
const service = prepareIncomingInvoices(srv)
|
||||||
|
|
||||||
|
const first = service.start(7)
|
||||||
|
const second = service.start(7)
|
||||||
|
assert.equal(first.running, true)
|
||||||
|
assert.equal(second.startedAt, first.startedAt)
|
||||||
|
assert.equal(service.status(8).running, false)
|
||||||
|
|
||||||
|
release()
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
|
||||||
|
const status = service.status(7)
|
||||||
|
assert.equal(lookups.count, 1)
|
||||||
|
assert.equal(status.running, false)
|
||||||
|
assert.ok(status.finishedAt)
|
||||||
|
assert.equal(status.result?.error, "Mandant nicht gefunden")
|
||||||
|
})
|
||||||
@@ -64,35 +64,89 @@ const setupPage = async () => {
|
|||||||
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Funktion zum Vorbereiten der Belege
|
// Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt.
|
||||||
const prepareInvoices = async () => {
|
const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
|
||||||
isPreparing.value = true
|
let preparationPollTimer = null
|
||||||
try {
|
|
||||||
await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
|
|
||||||
|
|
||||||
toast.add({
|
const showPreparationResult = (status) => {
|
||||||
title: 'Erfolg',
|
const result = status?.result
|
||||||
description: 'Eingangsbelege wurden vorbereitet.',
|
if (result?.error && !result.created?.length) {
|
||||||
icon: 'i-heroicons-check-circle',
|
|
||||||
color: 'green'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Liste neu laden
|
|
||||||
await setupPage()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Fehler',
|
title: 'Fehler',
|
||||||
description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.',
|
description: result.error,
|
||||||
icon: 'i-heroicons-exclamation-circle',
|
icon: 'i-heroicons-exclamation-circle',
|
||||||
color: 'red'
|
color: 'red'
|
||||||
})
|
})
|
||||||
} finally {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = result?.created?.length || 0
|
||||||
|
const skipped = result?.skipped?.length || 0
|
||||||
|
toast.add({
|
||||||
|
title: 'Belegvorbereitung abgeschlossen',
|
||||||
|
description: `${created} Eingangsbeleg(e) vorbereitet${skipped ? `, ${skipped} Datei(en) übersprungen` : ''}.`,
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
color: 'green'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollPreparation = (notify) => {
|
||||||
|
clearTimeout(preparationPollTimer)
|
||||||
|
preparationPollTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const status = await $api(preparationEndpoint)
|
||||||
|
if (status?.running) {
|
||||||
|
pollPreparation(notify)
|
||||||
|
return
|
||||||
|
}
|
||||||
isPreparing.value = false
|
isPreparing.value = false
|
||||||
|
if (notify) showPreparationResult(status)
|
||||||
|
await setupPage()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
pollPreparation(notify)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareInvoices = async () => {
|
||||||
|
isPreparing.value = true
|
||||||
|
try {
|
||||||
|
await $api(preparationEndpoint, { method: 'POST' })
|
||||||
|
toast.add({
|
||||||
|
title: 'Belegvorbereitung gestartet',
|
||||||
|
description: 'Die Belege werden im Hintergrund vorbereitet.',
|
||||||
|
icon: 'i-heroicons-sparkles'
|
||||||
|
})
|
||||||
|
pollPreparation(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
isPreparing.value = false
|
||||||
|
toast.add({
|
||||||
|
title: 'Fehler',
|
||||||
|
description: 'Die Belegvorbereitung konnte nicht gestartet werden.',
|
||||||
|
icon: 'i-heroicons-exclamation-circle',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumePreparationStatus = async () => {
|
||||||
|
try {
|
||||||
|
const status = await $api(preparationEndpoint)
|
||||||
|
if (status?.running) {
|
||||||
|
isPreparing.value = true
|
||||||
|
pollPreparation(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => clearTimeout(preparationPollTimer))
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
|
resumePreparationStatus()
|
||||||
|
|
||||||
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
||||||
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
|
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user