feat: add Buchhaltung dashboard widget, CSV export, Bestellungen linking, recurring bookings, and approval workflow
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user