feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export
This commit is contained in:
@@ -201,6 +201,23 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
async getBankkontoStatement(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 filters = {
|
||||
von: req.query.von as string | undefined,
|
||||
bis: req.query.bis as string | undefined,
|
||||
};
|
||||
const data = await buchhaltungService.getBankkontoStatement(id, filters);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getBankkontoStatement', { error });
|
||||
res.status(500).json({ success: false, message: 'Kontoauszug konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Konten ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async listKonten(req: Request, res: Response): Promise<void> {
|
||||
@@ -411,6 +428,18 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async createTransfer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.createTransfer(req.body, req.user!.id);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.createTransfer', { error });
|
||||
res.status(500).json({ success: false, message: 'Umbuchung konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Belege ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async uploadBeleg(req: Request, res: Response): Promise<void> {
|
||||
@@ -566,6 +595,128 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Planung ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async listPlanungen(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.listPlanungen();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.listPlanungen', { error });
|
||||
res.status(500).json({ success: false, message: 'Planungen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPlanung(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.getPlanungById(id);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getPlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createPlanung(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.createPlanung(req.body, req.user!.id);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.createPlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlanung(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.updatePlanung(id, req.body);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.updatePlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlanung(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.deletePlanung(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.deletePlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createPlanposition(req: Request, res: Response): Promise<void> {
|
||||
const planungId = parseInt(param(req, 'id'), 10);
|
||||
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||
try {
|
||||
const data = await buchhaltungService.createPlanposition(planungId, req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.createPlanposition', { error });
|
||||
res.status(500).json({ success: false, message: 'Planposition konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlanposition(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.updatePlanposition(id, req.body);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Planposition nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.updatePlanposition', { error });
|
||||
res.status(500).json({ success: false, message: 'Planposition konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlanposition(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.deletePlanposition(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.deletePlanposition', { error });
|
||||
res.status(500).json({ success: false, message: 'Planposition konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async listPlanpositionen(req: Request, res: Response): Promise<void> {
|
||||
const planungId = parseInt(param(req, 'id'), 10);
|
||||
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||
try {
|
||||
const data = await buchhaltungService.getPlanpositionenByPlanung(planungId);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.listPlanpositionen', { error });
|
||||
res.status(500).json({ success: false, message: 'Planpositionen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createHaushaltsjahrFromPlan(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.createHaushaltsjahrFromPlan(id, req.user!.id);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('BuchhaltungController.createHaushaltsjahrFromPlan', { error });
|
||||
const status = error.statusCode || 500;
|
||||
res.status(status).json({ success: false, message: error.message || 'Haushaltsjahr konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Freigaben ────────────────────────────────────────────────────────────────
|
||||
|
||||
async requestFreigabe(req: Request, res: Response): Promise<void> {
|
||||
|
||||
18
backend/src/database/migrations/082_buchhaltung_transfer.sql
Normal file
18
backend/src/database/migrations/082_buchhaltung_transfer.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Migration 082: Add 'transfer' transaction type + target bank account column
|
||||
|
||||
-- 1. Drop and recreate the typ CHECK constraint to include 'transfer'
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
DROP CONSTRAINT IF EXISTS buchhaltung_transaktionen_typ_check;
|
||||
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
ADD CONSTRAINT buchhaltung_transaktionen_typ_check
|
||||
CHECK (typ IN ('einnahme', 'ausgabe', 'transfer'));
|
||||
|
||||
-- 2. Add target bank account column for transfers
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
ADD COLUMN IF NOT EXISTS transfer_ziel_bankkonto_id INT
|
||||
REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buch_trans_transfer_ziel
|
||||
ON buchhaltung_transaktionen(transfer_ziel_bankkonto_id)
|
||||
WHERE transfer_ziel_bankkonto_id IS NOT NULL;
|
||||
@@ -33,6 +33,7 @@ router.get('/bankkonten', authenticate, requirePermission('buchhaltung:vie
|
||||
router.post('/bankkonten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createBankkonto.bind(buchhaltungController));
|
||||
router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateBankkonto.bind(buchhaltungController));
|
||||
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
|
||||
router.get('/bankkonten/:id/transaktionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getBankkontoStatement.bind(buchhaltungController));
|
||||
|
||||
// ── Konten ────────────────────────────────────────────────────────────────────
|
||||
router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController));
|
||||
@@ -60,6 +61,18 @@ router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung
|
||||
router.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController));
|
||||
router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.bind(buchhaltungController));
|
||||
|
||||
// ── Planung ──────────────────────────────────────────────────────────────────
|
||||
router.get('/planung', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanungen.bind(buchhaltungController));
|
||||
router.post('/planung', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanung.bind(buchhaltungController));
|
||||
router.get('/planung/:id', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPlanung.bind(buchhaltungController));
|
||||
router.put('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanung.bind(buchhaltungController));
|
||||
router.delete('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanung.bind(buchhaltungController));
|
||||
router.get('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanpositionen.bind(buchhaltungController));
|
||||
router.post('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanposition.bind(buchhaltungController));
|
||||
router.put('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanposition.bind(buchhaltungController));
|
||||
router.delete('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanposition.bind(buchhaltungController));
|
||||
router.post('/planung/:id/create-haushaltsjahr', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createHaushaltsjahrFromPlan.bind(buchhaltungController));
|
||||
|
||||
// ── Audit ──────────────────────────────────────────────────────────────────────
|
||||
router.get('/audit/:transaktionId', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getAudit.bind(buchhaltungController));
|
||||
|
||||
@@ -70,6 +83,9 @@ router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'),
|
||||
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));
|
||||
|
||||
// ── Transfers ─────────────────────────────────────────────────────────────────
|
||||
router.post('/transfers', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransfer.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));
|
||||
|
||||
@@ -8,6 +8,7 @@ import fs from 'fs';
|
||||
import notificationService from './notification.service';
|
||||
import { permissionService } from './permission.service';
|
||||
import ausruestungsanfrageService from './ausruestungsanfrage.service';
|
||||
import buchhaltungService from './buchhaltung.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog (shared ausruestung_artikel via ausruestungsanfrageService)
|
||||
@@ -381,6 +382,17 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
|
||||
}
|
||||
}
|
||||
|
||||
// Block transition to abgeschlossen if positions have missing prices
|
||||
if (status === 'abgeschlossen') {
|
||||
const posCheck = await pool.query(
|
||||
`SELECT COUNT(*) FROM bestellpositionen WHERE bestellung_id = $1 AND (einzelpreis IS NULL OR einzelpreis = 0)`,
|
||||
[id]
|
||||
);
|
||||
if (parseInt(posCheck.rows[0].count) > 0) {
|
||||
throw new Error('Alle Positionen müssen einen Einzelpreis haben, bevor die Bestellung abgeschlossen werden kann.');
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = ['status = $1', 'aktualisiert_am = NOW()'];
|
||||
const params: unknown[] = [status];
|
||||
let paramIndex = 2;
|
||||
@@ -452,6 +464,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
|
||||
});
|
||||
}
|
||||
|
||||
// Create pending buchhaltung transaction when order is completed
|
||||
if (status === 'abgeschlossen') {
|
||||
buchhaltungService.createPendingFromBestellung(id, userId).catch(err =>
|
||||
logger.error('createPendingFromBestellung failed (non-fatal)', { err, orderId: id })
|
||||
);
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.updateOrderStatus failed', { error, id });
|
||||
|
||||
@@ -354,6 +354,98 @@ async function deactivateBankkonto(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getBankkontoStatement(
|
||||
bankkontoId: number,
|
||||
filters?: { von?: string; bis?: string }
|
||||
) {
|
||||
try {
|
||||
// 1. Get the bank account
|
||||
const kontoResult = await pool.query(
|
||||
`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`,
|
||||
[bankkontoId]
|
||||
);
|
||||
if (!kontoResult.rows[0]) return null;
|
||||
|
||||
// 2. Fetch all transactions involving this bank account (both directions for transfers)
|
||||
const conditions: string[] = [
|
||||
`(t.bankkonto_id = $1 OR t.transfer_ziel_bankkonto_id = $1)`
|
||||
];
|
||||
const values: unknown[] = [bankkontoId];
|
||||
let idx = 2;
|
||||
|
||||
if (filters?.von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.von); }
|
||||
if (filters?.bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.bis); }
|
||||
|
||||
// Only include booked/approved transactions for the statement
|
||||
conditions.push(`t.status IN ('gebucht', 'freigegeben')`);
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const txResult = await pool.query(
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_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
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
${where}
|
||||
ORDER BY t.datum ASC, t.id ASC`,
|
||||
values
|
||||
);
|
||||
|
||||
// 3. Compute running balance
|
||||
let laufenderSaldo = 0;
|
||||
let gesamteinnahmen = 0;
|
||||
let gesamtausgaben = 0;
|
||||
|
||||
const rows = txResult.rows.map(row => {
|
||||
const betrag = parseFloat(row.betrag);
|
||||
let aenderung = 0;
|
||||
|
||||
if (row.typ === 'transfer') {
|
||||
if (row.bankkonto_id === bankkontoId) {
|
||||
// Money leaving this account
|
||||
aenderung = -betrag;
|
||||
gesamtausgaben += betrag;
|
||||
} else {
|
||||
// Money arriving at this account (transfer_ziel_bankkonto_id matches)
|
||||
aenderung = betrag;
|
||||
gesamteinnahmen += betrag;
|
||||
}
|
||||
} else if (row.typ === 'einnahme') {
|
||||
aenderung = betrag;
|
||||
gesamteinnahmen += betrag;
|
||||
} else if (row.typ === 'ausgabe') {
|
||||
aenderung = -betrag;
|
||||
gesamtausgaben += betrag;
|
||||
}
|
||||
|
||||
laufenderSaldo += aenderung;
|
||||
|
||||
return {
|
||||
...row,
|
||||
aenderung,
|
||||
laufender_saldo: Math.round(laufenderSaldo * 100) / 100,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
konto: kontoResult.rows[0],
|
||||
rows,
|
||||
summary: {
|
||||
gesamteinnahmen: Math.round(gesamteinnahmen * 100) / 100,
|
||||
gesamtausgaben: Math.round(gesamtausgaben * 100) / 100,
|
||||
saldo: Math.round(laufenderSaldo * 100) / 100,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getBankkontoStatement failed', { error, bankkontoId });
|
||||
throw new Error('Kontoauszug konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Konten (Budget Accounts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -445,10 +537,12 @@ async function getKontoDetail(kontoId: number) {
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_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
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
WHERE t.konto_id = $1
|
||||
ORDER BY t.datum DESC, t.id DESC`,
|
||||
[kontoId]
|
||||
@@ -702,10 +796,12 @@ async function listTransaktionen(filters: {
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_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
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
${where}
|
||||
ORDER BY t.datum DESC, t.id DESC`,
|
||||
values
|
||||
@@ -723,10 +819,12 @@ async function getTransaktionById(id: number) {
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_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
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
@@ -909,6 +1007,59 @@ async function deleteTransaktion(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfers (Umbuchungen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createTransfer(
|
||||
data: {
|
||||
haushaltsjahr_id: number;
|
||||
bankkonto_id: number;
|
||||
transfer_ziel_bankkonto_id: number;
|
||||
betrag: number;
|
||||
datum: string;
|
||||
beschreibung?: string;
|
||||
beleg_nr?: string;
|
||||
},
|
||||
userId: string
|
||||
) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Debit row (money leaves source account)
|
||||
const debitResult = await client.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status)
|
||||
VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht')
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.bankkonto_id, data.transfer_ziel_bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId]
|
||||
);
|
||||
|
||||
// Credit row (money arrives at target account)
|
||||
const creditResult = await client.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status)
|
||||
VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht')
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.transfer_ziel_bankkonto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
await logAudit(debitResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId);
|
||||
await logAudit(creditResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId);
|
||||
|
||||
return { debit: debitResult.rows[0], credit: creditResult.rows[0] };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('BuchhaltungService.createTransfer failed', { error });
|
||||
throw new Error('Umbuchung konnte nicht erstellt werden');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Belege (Receipts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1435,6 +1586,331 @@ async function getErstattungLinks(transaktionId: number): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestellung → Buchhaltung Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createPendingFromBestellung(bestellungId: number, userId: string) {
|
||||
try {
|
||||
// 1. Fetch the bestellung with total
|
||||
const bestResult = await pool.query(
|
||||
`SELECT b.*, l.name AS lieferant_name, COALESCE(SUM(bp.menge * bp.einzelpreis), 0) AS gesamtbetrag
|
||||
FROM bestellungen b
|
||||
LEFT JOIN lieferanten l ON l.id = b.lieferant_id
|
||||
LEFT JOIN bestellpositionen bp ON bp.bestellung_id = b.id
|
||||
WHERE b.id = $1
|
||||
GROUP BY b.id, l.name`,
|
||||
[bestellungId]
|
||||
);
|
||||
if (!bestResult.rows[0]) {
|
||||
logger.warn('createPendingFromBestellung: Bestellung not found', { bestellungId });
|
||||
return;
|
||||
}
|
||||
const bestellung = bestResult.rows[0];
|
||||
const gesamtbetrag = parseFloat(bestellung.gesamtbetrag);
|
||||
if (gesamtbetrag <= 0) {
|
||||
logger.info('createPendingFromBestellung: Gesamtbetrag is 0, skipping', { bestellungId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find active Haushaltsjahr
|
||||
const hjResult = await pool.query(
|
||||
`SELECT id FROM buchhaltung_haushaltsjahre WHERE abgeschlossen = FALSE ORDER BY jahr DESC LIMIT 1`
|
||||
);
|
||||
if (!hjResult.rows[0]) {
|
||||
logger.info('createPendingFromBestellung: No active Haushaltsjahr found, skipping', { bestellungId });
|
||||
return;
|
||||
}
|
||||
const haushaltsjahrId = hjResult.rows[0].id;
|
||||
|
||||
// 3. Find default bank account
|
||||
const bkResult = await pool.query(
|
||||
`SELECT id FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC LIMIT 1`
|
||||
);
|
||||
const bankkontoId = bkResult.rows[0]?.id || null;
|
||||
|
||||
// 4. Insert pending transaction
|
||||
const label = bestellung.laufende_nummer ? `Bestellung #${bestellung.laufende_nummer}` : `Bestellung #${bestellung.id}`;
|
||||
await pool.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, bestellung_id, erstellt_von, status)
|
||||
VALUES ($1, $2, 'ausgabe', $3, NOW(), $4, $5, $6, $7, 'entwurf')`,
|
||||
[haushaltsjahrId, bankkontoId, gesamtbetrag, label, bestellung.lieferant_name || null, bestellungId, userId]
|
||||
);
|
||||
|
||||
logger.info('createPendingFromBestellung: Created pending transaction', { bestellungId, gesamtbetrag });
|
||||
} catch (error) {
|
||||
logger.error('createPendingFromBestellung failed (non-fatal)', { error, bestellungId });
|
||||
// Non-fatal: don't throw
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Planung (Budget Plans)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listPlanungen() {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
hj.bezeichnung AS haushaltsjahr_bezeichnung,
|
||||
hj.jahr AS haushaltsjahr_jahr,
|
||||
COALESCE(pos.positionen_count, 0) AS positionen_count,
|
||||
COALESCE(pos.total_gwg, 0) AS total_gwg,
|
||||
COALESCE(pos.total_anlagen, 0) AS total_anlagen,
|
||||
COALESCE(pos.total_instandhaltung, 0) AS total_instandhaltung
|
||||
FROM buchhaltung_planung p
|
||||
LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS positionen_count,
|
||||
SUM(budget_gwg) AS total_gwg,
|
||||
SUM(budget_anlagen) AS total_anlagen,
|
||||
SUM(budget_instandhaltung) AS total_instandhaltung
|
||||
FROM buchhaltung_planpositionen
|
||||
WHERE planung_id = p.id
|
||||
) pos ON true
|
||||
ORDER BY p.erstellt_am DESC`
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.listPlanungen failed', { error });
|
||||
throw new Error('Planungen konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlanungById(id: number) {
|
||||
try {
|
||||
const planungResult = await pool.query(
|
||||
`SELECT p.*,
|
||||
hj.bezeichnung AS haushaltsjahr_bezeichnung,
|
||||
hj.jahr AS haushaltsjahr_jahr
|
||||
FROM buchhaltung_planung p
|
||||
LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id
|
||||
WHERE p.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!planungResult.rows[0]) return null;
|
||||
|
||||
const positionenResult = await pool.query(
|
||||
`SELECT pp.*,
|
||||
k.bezeichnung AS konto_bezeichnung,
|
||||
k.kontonummer AS konto_kontonummer
|
||||
FROM buchhaltung_planpositionen pp
|
||||
LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id
|
||||
WHERE pp.planung_id = $1
|
||||
ORDER BY pp.sort_order, pp.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
...planungResult.rows[0],
|
||||
positionen: positionenResult.rows,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getPlanungById failed', { error, id });
|
||||
throw new Error('Planung konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlanung(data: { haushaltsjahr_id: number; bezeichnung: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_planung (haushaltsjahr_id, bezeichnung, erstellt_von)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.bezeichnung, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.createPlanung failed', { error });
|
||||
throw new Error('Planung konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlanung(id: number, data: { bezeichnung?: string; status?: string }) {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||
if (data.status !== undefined) { fields.push(`status = $${idx++}`); values.push(data.status); }
|
||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE buchhaltung_planung SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.updatePlanung failed', { error, id });
|
||||
throw new Error('Planung konnte nicht aktualisiert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlanung(id: number) {
|
||||
try {
|
||||
await pool.query(`DELETE FROM buchhaltung_planung WHERE id = $1`, [id]);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.deletePlanung failed', { error, id });
|
||||
throw new Error('Planung konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlanposition(planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_planpositionen (planung_id, konto_id, bezeichnung, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[planungId, data.konto_id || null, data.bezeichnung, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, data.sort_order || 0]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.createPlanposition failed', { error, planungId });
|
||||
throw new Error('Planposition konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlanposition(id: number, data: { konto_id?: number | null; bezeichnung?: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); }
|
||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||
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 (data.sort_order !== undefined) { fields.push(`sort_order = $${idx++}`); values.push(data.sort_order); }
|
||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE buchhaltung_planpositionen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.updatePlanposition failed', { error, id });
|
||||
throw new Error('Planposition konnte nicht aktualisiert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlanposition(id: number) {
|
||||
try {
|
||||
await pool.query(`DELETE FROM buchhaltung_planpositionen WHERE id = $1`, [id]);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.deletePlanposition failed', { error, id });
|
||||
throw new Error('Planposition konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlanpositionenByPlanung(planungId: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT pp.*,
|
||||
k.bezeichnung AS konto_bezeichnung,
|
||||
k.kontonummer AS konto_kontonummer
|
||||
FROM buchhaltung_planpositionen pp
|
||||
LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id
|
||||
WHERE pp.planung_id = $1
|
||||
ORDER BY pp.sort_order, pp.id`,
|
||||
[planungId]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getPlanpositionenByPlanung failed', { error, planungId });
|
||||
throw new Error('Planpositionen konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createHaushaltsjahrFromPlan(planungId: number, userId: string) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const planResult = await client.query(
|
||||
`SELECT p.*, hj.jahr, hj.beginn, hj.ende
|
||||
FROM buchhaltung_planung p
|
||||
LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id
|
||||
WHERE p.id = $1`,
|
||||
[planungId]
|
||||
);
|
||||
if (!planResult.rows[0]) throw new Error('Planung nicht gefunden');
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
const nextYear = plan.jahr + 1;
|
||||
|
||||
// Check if Haushaltsjahr for next year already exists
|
||||
const existingResult = await client.query(
|
||||
`SELECT id FROM buchhaltung_haushaltsjahre WHERE jahr = $1`,
|
||||
[nextYear]
|
||||
);
|
||||
if (existingResult.rows.length > 0) {
|
||||
throw Object.assign(new Error(`Haushaltsjahr ${nextYear} existiert bereits`), { statusCode: 409 });
|
||||
}
|
||||
|
||||
// Create new Haushaltsjahr
|
||||
const hjResult = await client.query(
|
||||
`INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[nextYear, `Haushaltsjahr ${nextYear}`, `${nextYear}-01-01`, `${nextYear}-12-31`, userId]
|
||||
);
|
||||
const newHj = hjResult.rows[0];
|
||||
|
||||
// Get all positions from the plan
|
||||
const posResult = await client.query(
|
||||
`SELECT pp.*, k.kontonummer, k.bezeichnung AS konto_bezeichnung, k.konto_typ_id, k.parent_id, k.kategorie_id, k.budget_typ
|
||||
FROM buchhaltung_planpositionen pp
|
||||
LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id
|
||||
WHERE pp.planung_id = $1
|
||||
ORDER BY pp.sort_order, pp.id`,
|
||||
[planungId]
|
||||
);
|
||||
|
||||
// Create konten in the new Haushaltsjahr from plan positions
|
||||
const kontoIdMap = new Map<number, number>(); // old konto_id -> new konto_id
|
||||
for (const pos of posResult.rows) {
|
||||
if (!pos.konto_id) continue;
|
||||
|
||||
// Map parent_id to new parent if it was already created
|
||||
const newParentId = pos.parent_id ? (kontoIdMap.get(pos.parent_id) || null) : null;
|
||||
|
||||
const kontoResult = await client.query(
|
||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, erstellt_von, kategorie_id, budget_typ)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (haushaltsjahr_id, kontonummer) DO UPDATE SET
|
||||
budget_gwg = EXCLUDED.budget_gwg,
|
||||
budget_anlagen = EXCLUDED.budget_anlagen,
|
||||
budget_instandhaltung = EXCLUDED.budget_instandhaltung
|
||||
RETURNING id`,
|
||||
[newHj.id, pos.konto_typ_id, pos.kontonummer, pos.konto_bezeichnung || pos.bezeichnung, newParentId, pos.budget_gwg, pos.budget_anlagen, pos.budget_instandhaltung, userId, pos.kategorie_id, pos.budget_typ || 'detailliert']
|
||||
);
|
||||
kontoIdMap.set(pos.konto_id, kontoResult.rows[0].id);
|
||||
}
|
||||
|
||||
// Mark plan as abgeschlossen
|
||||
await client.query(
|
||||
`UPDATE buchhaltung_planung SET status = 'abgeschlossen' WHERE id = $1`,
|
||||
[planungId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
await logAudit(null, 'haushaltsjahr_aus_plan_erstellt', { planung_id: planungId, haushaltsjahr_id: newHj.id, jahr: nextYear }, userId);
|
||||
|
||||
return { haushaltsjahr: newHj };
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('BuchhaltungService.createHaushaltsjahrFromPlan failed', { error, planungId });
|
||||
if (error.statusCode) throw error;
|
||||
throw new Error('Haushaltsjahr konnte nicht aus Planung erstellt werden');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1459,6 +1935,7 @@ const buchhaltungService = {
|
||||
createBankkonto,
|
||||
updateBankkonto,
|
||||
deactivateBankkonto,
|
||||
getBankkontoStatement,
|
||||
getAllKonten,
|
||||
getKontoById,
|
||||
getKontenTree,
|
||||
@@ -1475,6 +1952,7 @@ const buchhaltungService = {
|
||||
bookTransaktion,
|
||||
stornoTransaktion,
|
||||
deleteTransaktion,
|
||||
createTransfer,
|
||||
getBelegeByTransaktion,
|
||||
uploadBeleg,
|
||||
deleteBeleg,
|
||||
@@ -1495,6 +1973,17 @@ const buchhaltungService = {
|
||||
exportTransaktionenCsv,
|
||||
createErstattung,
|
||||
getErstattungLinks,
|
||||
createPendingFromBestellung,
|
||||
listPlanungen,
|
||||
getPlanungById,
|
||||
createPlanung,
|
||||
updatePlanung,
|
||||
deletePlanung,
|
||||
createPlanposition,
|
||||
updatePlanposition,
|
||||
deletePlanposition,
|
||||
getPlanpositionenByPlanung,
|
||||
createHaushaltsjahrFromPlan,
|
||||
};
|
||||
|
||||
export default buchhaltungService;
|
||||
|
||||
Reference in New Issue
Block a user