feat: add account hierarchy, budget types (GWG/Anlagen/Instandhaltung), and Buchhaltung UI overhaul with collapsible tree, pending badge, and konto detail page
This commit is contained in:
@@ -127,6 +127,42 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
async getKontenTree(req: Request, res: Response): Promise<void> {
|
||||
const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined;
|
||||
if (!haushaltsjahrId || isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
|
||||
try {
|
||||
const tree = await buchhaltungService.getKontenTree(haushaltsjahrId);
|
||||
res.json(tree);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getKontenTree', { error });
|
||||
res.status(500).json({ error: 'Fehler beim Laden des Kontenbaums' });
|
||||
}
|
||||
}
|
||||
|
||||
async getKontoDetail(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 detail = await buchhaltungService.getKontoDetail(id);
|
||||
if (!detail) { res.status(404).json({ error: 'Konto nicht gefunden' }); return; }
|
||||
res.json(detail);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getKontoDetail', { error });
|
||||
res.status(500).json({ error: 'Fehler beim Laden des Kontos' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined;
|
||||
const count = await buchhaltungService.getPendingCount(haushaltsjahrId);
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getPendingCount', { error });
|
||||
res.status(500).json({ error: 'Fehler' });
|
||||
}
|
||||
}
|
||||
|
||||
async createKonto(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.createKonto(req.body, req.user!.id);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 1. Add parent_id for account hierarchy
|
||||
ALTER TABLE buchhaltung_konten ADD COLUMN parent_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_buch_konten_parent ON buchhaltung_konten(parent_id);
|
||||
|
||||
-- 2. Replace budget_betrag with three type-specific budget columns
|
||||
ALTER TABLE buchhaltung_konten ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE buchhaltung_konten ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE buchhaltung_konten ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
-- Migrate existing budget to GWG as default
|
||||
UPDATE buchhaltung_konten SET budget_gwg = COALESCE(budget_betrag, 0);
|
||||
ALTER TABLE buchhaltung_konten DROP COLUMN budget_betrag;
|
||||
|
||||
-- 3. Add ausgaben_typ to transactions (nullable: einnahmen have no type)
|
||||
ALTER TABLE buchhaltung_transaktionen ADD COLUMN ausgaben_typ TEXT CHECK (ausgaben_typ IN ('gwg', 'anlagen', 'instandhaltung'));
|
||||
|
||||
-- 4. Add wiederkehrend_id to track auto-generated transactions
|
||||
ALTER TABLE buchhaltung_transaktionen ADD COLUMN wiederkehrend_id INT REFERENCES buchhaltung_wiederkehrend(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_buch_trans_wiederkehrend ON buchhaltung_transaktionen(wiederkehrend_id);
|
||||
|
||||
-- 5. Update planpositionen to have type-specific budgets
|
||||
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
UPDATE buchhaltung_planpositionen SET budget_gwg = COALESCE(plan_betrag, 0);
|
||||
ALTER TABLE buchhaltung_planpositionen DROP COLUMN plan_betrag;
|
||||
@@ -8,6 +8,7 @@ const router = Router();
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
|
||||
router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController));
|
||||
|
||||
// ── Haushaltsjahre ─────────────────────────────────────────────────────────────
|
||||
router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController));
|
||||
@@ -25,8 +26,10 @@ router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:man
|
||||
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
|
||||
|
||||
// ── Konten ────────────────────────────────────────────────────────────────────
|
||||
router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController));
|
||||
router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController));
|
||||
router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController));
|
||||
router.get('/konten/:id/detail', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoDetail.bind(buchhaltungController));
|
||||
router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController));
|
||||
router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController));
|
||||
router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController));
|
||||
|
||||
@@ -245,9 +245,11 @@ async function deactivateBankkonto(id: number) {
|
||||
async function getAllKonten(haushaltsjahrId: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
|
||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||
k.parent_id, pk.bezeichnung AS parent_bezeichnung
|
||||
FROM buchhaltung_konten k
|
||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
||||
ORDER BY k.kontonummer`,
|
||||
[haushaltsjahrId]
|
||||
@@ -262,9 +264,11 @@ async function getAllKonten(haushaltsjahrId: number) {
|
||||
async function getKontoById(id: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
|
||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||
pk.bezeichnung AS parent_bezeichnung
|
||||
FROM buchhaltung_konten k
|
||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||
WHERE k.id = $1`,
|
||||
[id]
|
||||
);
|
||||
@@ -275,16 +279,100 @@ async function getKontoById(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getKontenTree(haushaltsjahrId: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT k.*,
|
||||
kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||
pk.bezeichnung AS parent_bezeichnung,
|
||||
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_gwg,
|
||||
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_anlagen,
|
||||
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung,
|
||||
COALESCE(SUM(CASE WHEN t.typ='einnahme' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS einnahmen_betrag
|
||||
FROM buchhaltung_konten k
|
||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1
|
||||
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
||||
GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung
|
||||
ORDER BY k.kontonummer`,
|
||||
[haushaltsjahrId]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getKontenTree failed', { error, haushaltsjahrId });
|
||||
throw new Error('Kontenbaum konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getKontoDetail(kontoId: number) {
|
||||
try {
|
||||
const kontoResult = await pool.query(
|
||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||
pk.bezeichnung AS parent_bezeichnung
|
||||
FROM buchhaltung_konten k
|
||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||
WHERE k.id = $1`,
|
||||
[kontoId]
|
||||
);
|
||||
if (!kontoResult.rows[0]) return null;
|
||||
|
||||
const childrenResult = await pool.query(
|
||||
`SELECT * FROM buchhaltung_konten WHERE parent_id = $1 AND aktiv = TRUE ORDER BY kontonummer`,
|
||||
[kontoId]
|
||||
);
|
||||
|
||||
const transaktionenResult = 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.konto_id = $1
|
||||
ORDER BY t.datum DESC, t.id DESC`,
|
||||
[kontoId]
|
||||
);
|
||||
|
||||
return {
|
||||
konto: kontoResult.rows[0],
|
||||
children: childrenResult.rows,
|
||||
transaktionen: transaktionenResult.rows,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getKontoDetail failed', { error, kontoId });
|
||||
throw new Error('Kontodetails konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPendingCount(haushaltsjahrId?: number): Promise<number> {
|
||||
try {
|
||||
let query = `SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE status = 'entwurf'`;
|
||||
const params: unknown[] = [];
|
||||
if (haushaltsjahrId) {
|
||||
query += ` AND haushaltsjahr_id = $1`;
|
||||
params.push(haushaltsjahrId);
|
||||
}
|
||||
const result = await pool.query(query, params);
|
||||
return parseInt(result.rows[0].count, 10);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getPendingCount failed', { error });
|
||||
throw new Error('Anzahl offener Entwürfe konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createKonto(
|
||||
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: number; notizen?: string },
|
||||
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string },
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 0, data.notizen || null, userId]
|
||||
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
@@ -295,17 +383,20 @@ async function createKonto(
|
||||
|
||||
async function updateKonto(
|
||||
id: number,
|
||||
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: number; notizen?: string }
|
||||
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string }
|
||||
) {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); }
|
||||
if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); }
|
||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||
if (data.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); }
|
||||
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
||||
if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); }
|
||||
if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); }
|
||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||
if (data.parent_id !== undefined) { fields.push(`parent_id = $${idx++}`); values.push(data.parent_id || null); }
|
||||
if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); }
|
||||
if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); }
|
||||
if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); }
|
||||
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
@@ -348,7 +439,7 @@ async function getBudgetUtilisation(id: number) {
|
||||
const row = result.rows[0];
|
||||
const gebucht = parseFloat(row.gebucht_betrag);
|
||||
const ausstehend = parseFloat(row.ausstehend_betrag);
|
||||
const budget = parseFloat(row.budget_betrag);
|
||||
const budget = parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung);
|
||||
return {
|
||||
...row,
|
||||
gebucht_betrag: gebucht,
|
||||
@@ -451,14 +542,15 @@ async function createTransaktion(
|
||||
empfaenger_auftraggeber?: string;
|
||||
verwendungszweck?: string;
|
||||
beleg_nr?: string;
|
||||
ausgaben_typ?: string | null;
|
||||
},
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, ausgaben_typ, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.haushaltsjahr_id,
|
||||
@@ -471,6 +563,7 @@ async function createTransaktion(
|
||||
data.empfaenger_auftraggeber || null,
|
||||
data.verwendungszweck || null,
|
||||
data.beleg_nr || null,
|
||||
data.typ === 'ausgabe' ? (data.ausgaben_typ || null) : null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
@@ -798,9 +891,9 @@ async function getOverview(haushaltsjahrId: number) {
|
||||
...row,
|
||||
gebucht_betrag: parseFloat(row.gebucht_betrag),
|
||||
ausstehend_betrag: parseFloat(row.ausstehend_betrag),
|
||||
verfuegbar_betrag: parseFloat(row.budget_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
|
||||
auslastung_prozent: parseFloat(row.budget_betrag) > 0
|
||||
? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100)
|
||||
verfuegbar_betrag: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
|
||||
auslastung_prozent: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) > 0
|
||||
? Math.round((parseFloat(row.gebucht_betrag) / (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung))) * 100)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
@@ -1019,6 +1112,9 @@ const buchhaltungService = {
|
||||
deactivateBankkonto,
|
||||
getAllKonten,
|
||||
getKontoById,
|
||||
getKontenTree,
|
||||
getKontoDetail,
|
||||
getPendingCount,
|
||||
createKonto,
|
||||
updateKonto,
|
||||
deleteKonto,
|
||||
|
||||
Reference in New Issue
Block a user