feat: add Buchhaltung dashboard widget, CSV export, Bestellungen linking, recurring bookings, and approval workflow

This commit is contained in:
Matthias Hochmeister
2026-03-28 20:34:53 +01:00
parent c1fca5cc67
commit bc39963746
10 changed files with 750 additions and 5 deletions

View File

@@ -335,6 +335,109 @@ class BuchhaltungController {
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' });
}
}
// ── Wiederkehrend ────────────────────────────────────────────────────────────
async listWiederkehrend(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllWiederkehrend();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchungen konnten nicht geladen werden' });
}
}
async createWiederkehrend(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createWiederkehrend(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht erstellt werden' });
}
}
async updateWiederkehrend(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.updateWiederkehrend(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Wiederkehrende Buchung nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht aktualisiert werden' });
}
}
async deleteWiederkehrend(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
await buchhaltungService.deleteWiederkehrend(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht gelöscht werden' });
}
}
// ── CSV Export ───────────────────────────────────────────────────────────────
async exportCsv(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const csv = await buchhaltungService.exportTransaktionenCsv(haushaltsjahrId);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="transaktionen_${haushaltsjahrId}.csv"`);
res.send(csv);
} catch (error) {
logger.error('BuchhaltungController.exportCsv', { error });
res.status(500).json({ success: false, message: 'CSV-Export fehlgeschlagen' });
}
}
// ── Freigaben ────────────────────────────────────────────────────────────────
async requestFreigabe(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.createFreigabe(id, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.requestFreigabe', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht angefragt werden' });
}
}
async approveFreigabe(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.approveFreigabe(id, req.body.kommentar, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Freigabe nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.approveFreigabe', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht genehmigt werden' });
}
}
async rejectFreigabe(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.rejectFreigabe(id, req.body.kommentar, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Freigabe nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.rejectFreigabe', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht abgelehnt werden' });
}
}
}
export default new BuchhaltungController();

View File

@@ -38,6 +38,19 @@ router.delete('/belege/:id', authenticate, requirePermission('buchhaltung:delete
router.get('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.getEinstellungen.bind(buchhaltungController));
router.put('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.setEinstellungen.bind(buchhaltungController));
// ── Wiederkehrend ─────────────────────────────────────────────────────────────
router.get('/wiederkehrend', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listWiederkehrend.bind(buchhaltungController));
router.post('/wiederkehrend', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createWiederkehrend.bind(buchhaltungController));
router.patch('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateWiederkehrend.bind(buchhaltungController));
router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteWiederkehrend.bind(buchhaltungController));
// ── CSV Export ─────────────────────────────────────────────────────────────────
router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController));
// ── Freigaben ─────────────────────────────────────────────────────────────────
router.patch('/freigaben/:id/genehmigen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.approveFreigabe.bind(buchhaltungController));
router.patch('/freigaben/:id/ablehnen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.rejectFreigabe.bind(buchhaltungController));
// ── Transaktionen (list/create — before /:id) ─────────────────────────────────
router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController));
router.post('/', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransaktion.bind(buchhaltungController));
@@ -48,6 +61,7 @@ router.patch('/:id', authenticate, requirePermission('buchhaltung:edit'),
router.delete('/:id', authenticate, requirePermission('buchhaltung:delete'), buchhaltungController.deleteTransaktion.bind(buchhaltungController));
router.post('/:id/buchen', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.buchenTransaktion.bind(buchhaltungController));
router.post('/:id/storno', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.stornoTransaktion.bind(buchhaltungController));
router.post('/:id/freigabe', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.requestFreigabe.bind(buchhaltungController));
router.post('/:id/belege', authenticate, requirePermission('buchhaltung:create'), uploadBuchhaltung.single('datei'), buchhaltungController.uploadBeleg.bind(buchhaltungController));
export default router;

View File

@@ -810,6 +810,196 @@ async function getOverview(haushaltsjahrId: number) {
}
}
// ---------------------------------------------------------------------------
// Wiederkehrend (Recurring Bookings)
// ---------------------------------------------------------------------------
async function getAllWiederkehrend() {
try {
const result = await pool.query(
`SELECT w.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
FROM buchhaltung_wiederkehrend w
LEFT JOIN buchhaltung_konten k ON w.konto_id = k.id
LEFT JOIN buchhaltung_bankkonten bk ON w.bankkonto_id = bk.id
ORDER BY w.naechste_ausfuehrung, w.bezeichnung`
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getAllWiederkehrend failed', { error });
throw new Error('Wiederkehrende Buchungen konnten nicht geladen werden');
}
}
async function getWiederkehrendById(id: number) {
try {
const result = await pool.query(
`SELECT * FROM buchhaltung_wiederkehrend WHERE id = $1`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.getWiederkehrendById failed', { error, id });
throw new Error('Wiederkehrende Buchung konnte nicht geladen werden');
}
}
async function createWiederkehrend(
data: {
bezeichnung: string;
konto_id?: number | null;
bankkonto_id?: number | null;
typ: 'einnahme' | 'ausgabe';
betrag: number;
beschreibung?: string;
empfaenger_auftraggeber?: string;
intervall: string;
naechste_ausfuehrung: string;
aktiv?: boolean;
},
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_wiederkehrend
(bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
data.bezeichnung,
data.konto_id || null,
data.bankkonto_id || null,
data.typ,
data.betrag,
data.beschreibung || null,
data.empfaenger_auftraggeber || null,
data.intervall,
data.naechste_ausfuehrung,
data.aktiv !== false,
userId,
]
);
return result.rows[0];
} catch (error) {
logger.error('BuchhaltungService.createWiederkehrend failed', { error });
throw new Error('Wiederkehrende Buchung konnte nicht erstellt werden');
}
}
async function updateWiederkehrend(
id: number,
data: {
bezeichnung?: string;
konto_id?: number | null;
bankkonto_id?: number | null;
typ?: string;
betrag?: number;
beschreibung?: string;
empfaenger_auftraggeber?: string;
intervall?: string;
naechste_ausfuehrung?: string;
aktiv?: boolean;
}
) {
try {
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); }
if (data.bankkonto_id !== undefined) { fields.push(`bankkonto_id = $${idx++}`); values.push(data.bankkonto_id); }
if (data.typ !== undefined) { fields.push(`typ = $${idx++}`); values.push(data.typ); }
if (data.betrag !== undefined) { fields.push(`betrag = $${idx++}`); values.push(data.betrag); }
if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung || null); }
if (data.empfaenger_auftraggeber !== undefined) { fields.push(`empfaenger_auftraggeber = $${idx++}`); values.push(data.empfaenger_auftraggeber || null); }
if (data.intervall !== undefined) { fields.push(`intervall = $${idx++}`); values.push(data.intervall); }
if (data.naechste_ausfuehrung !== undefined) { fields.push(`naechste_ausfuehrung = $${idx++}`); values.push(data.naechste_ausfuehrung); }
if (data.aktiv !== undefined) { fields.push(`aktiv = $${idx++}`); values.push(data.aktiv); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
values.push(id);
const result = await pool.query(
`UPDATE buchhaltung_wiederkehrend SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return result.rows[0] || null;
} catch (error) {
logger.error('BuchhaltungService.updateWiederkehrend failed', { error, id });
throw new Error('Wiederkehrende Buchung konnte nicht aktualisiert werden');
}
}
async function deleteWiederkehrend(id: number) {
try {
await pool.query(`DELETE FROM buchhaltung_wiederkehrend WHERE id = $1`, [id]);
} catch (error) {
logger.error('BuchhaltungService.deleteWiederkehrend failed', { error, id });
throw new Error('Wiederkehrende Buchung konnte nicht gelöscht werden');
}
}
// ---------------------------------------------------------------------------
// CSV Export
// ---------------------------------------------------------------------------
async function exportTransaktionenCsv(haushaltsjahrId: number): Promise<string> {
try {
const result = await pool.query(
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
FROM buchhaltung_transaktionen t
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
WHERE t.haushaltsjahr_id = $1
ORDER BY t.datum DESC, t.id DESC`,
[haushaltsjahrId]
);
const statusLabels: Record<string, string> = {
entwurf: 'Entwurf',
gebucht: 'Gebucht',
freigegeben: 'Freigegeben',
storniert: 'Storniert',
};
const header = [
'Nr.', 'Datum', 'Typ', 'Beschreibung', 'Empfänger/Auftraggeber',
'Verwendungszweck', 'Belegnummer', 'Konto', 'Bankkonto', 'Betrag', 'Status',
].join(';');
const rows = result.rows.map(row => {
const betrag = (row.typ === 'ausgabe' ? '-' : '') + parseFloat(row.betrag).toFixed(2).replace('.', ',');
const konto = row.konto_kontonummer ? `${row.konto_kontonummer} ${row.konto_bezeichnung}` : '';
const datum = row.datum ? new Date(row.datum).toLocaleDateString('de-DE') : '';
const escCsv = (val: unknown) => {
const s = String(val ?? '');
return s.includes(';') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
};
return [
row.laufende_nummer ?? `E${row.id}`,
datum,
row.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe',
escCsv(row.beschreibung),
escCsv(row.empfaenger_auftraggeber),
escCsv(row.verwendungszweck),
escCsv(row.beleg_nr),
escCsv(konto),
escCsv(row.bankkonto_bezeichnung),
betrag,
statusLabels[row.status] || row.status,
].join(';');
});
return '\uFEFF' + header + '\r\n' + rows.join('\r\n');
} catch (error) {
logger.error('BuchhaltungService.exportTransaktionenCsv failed', { error });
throw new Error('CSV-Export fehlgeschlagen');
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
@@ -852,6 +1042,12 @@ const buchhaltungService = {
logAudit,
getAuditByTransaktion,
getOverview,
getAllWiederkehrend,
getWiederkehrendById,
createWiederkehrend,
updateWiederkehrend,
deleteWiederkehrend,
exportTransaktionenCsv,
};
export default buchhaltungService;