Initial commit - Stand 26.04.2026
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# OCR Backend Service
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Installiere Tesseract OCR
|
||||
RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-deu
|
||||
|
||||
# Dependencies
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
|
||||
# App Code
|
||||
COPY . .
|
||||
|
||||
# Stelle sicher dass public-Verzeichnis existiert
|
||||
RUN mkdir -p public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g nodemon
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["nodemon", "--legacy-watch", "server.js"]
|
||||
@@ -0,0 +1,5 @@
|
||||
const fs = require('fs');
|
||||
let content = fs.readFileSync('server.js', 'utf8');
|
||||
content = content.replace(/\r\n/g, '\n');
|
||||
fs.writeFileSync('server.js', content, 'utf8');
|
||||
console.log('Done');
|
||||
@@ -0,0 +1,108 @@
|
||||
// Import über REST-API statt direkt zu PostgreSQL
|
||||
|
||||
const API_URL = 'http://192.168.0.141:3001/api';
|
||||
|
||||
// Test-Einheit: Brandt 2023
|
||||
const testData = {
|
||||
jahr: 2023,
|
||||
wohnung: 'Zingelstr. 14',
|
||||
mieter: 'Yvonne Brandt',
|
||||
kaltmiete: 0,
|
||||
nebenkosten: 501.48, // Summe aus Excel
|
||||
versicherung: 129.58, // Geb. Vers./Haftpflichtv.
|
||||
heizkosten: 102.53, // Wartung Heizung
|
||||
wasser: 0,
|
||||
muell: 153.85, // Müllabfuhr
|
||||
sonstiges: 115.53 // Grundsteuer (40.17) + Niederschlagwasser (75.36)
|
||||
};
|
||||
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
if (options.body && typeof options.body === 'object') {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function importTest() {
|
||||
console.log('=== TEST-IMPORT: Yvonne Brandt 2023 ===\n');
|
||||
console.log('=== ZU IMPORTIERENDE DATEN ===');
|
||||
console.log(JSON.stringify(testData, null, 2));
|
||||
|
||||
console.log('\n=== FÜHRE IMPORT AUS ===');
|
||||
|
||||
try {
|
||||
// Erst prüfen, ob Eintrag existiert
|
||||
const existing = await apiCall('/nebenkosten');
|
||||
const brandtEntries = existing.filter(e => e.mieter && e.mieter.includes('Brandt'));
|
||||
console.log(`Gefunden: ${brandtEntries.length} Einträge für Brandt`);
|
||||
brandtEntries.forEach(e => {
|
||||
console.log(` - ${e.jahr}: ${e.mieter} (${e.nebenkosten} €)`);
|
||||
});
|
||||
|
||||
// Import
|
||||
const result = await apiCall('/nebenkosten', {
|
||||
method: 'POST',
|
||||
body: testData
|
||||
});
|
||||
|
||||
console.log('\n✓ ERFOLGREICH IMPORTIERT:');
|
||||
console.log(' ID:', result.id);
|
||||
console.log(' Jahr:', result.jahr);
|
||||
console.log(' Wohnung:', result.wohnung);
|
||||
console.log(' Mieter:', result.mieter);
|
||||
console.log(' Nebenkosten:', result.nebenkosten, '€');
|
||||
console.log(' Versicherung:', result.versicherung, '€');
|
||||
console.log(' Heizkosten:', result.heizkosten, '€');
|
||||
console.log(' Müll:', result.muell, '€');
|
||||
console.log(' Sonstiges:', result.sonstiges, '€');
|
||||
|
||||
// Verifizieren
|
||||
const verify = await apiCall('/nebenkosten');
|
||||
const imported = verify.find(e => e.id === result.id);
|
||||
|
||||
console.log('\n=== VERIFIZIERUNG ===');
|
||||
if (imported) {
|
||||
console.log('✓ Datensatz in Datenbank bestätigt');
|
||||
console.log(' ID:', imported.id);
|
||||
console.log(' Mieter:', imported.mieter);
|
||||
console.log(' Jahr:', imported.jahr);
|
||||
console.log(' Gesamtkosten:', imported.nebenkosten, '€');
|
||||
} else {
|
||||
console.log('✗ Datensatz nicht gefunden!');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ FEHLER beim Import:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Führe Import aus
|
||||
importTest().catch(err => {
|
||||
console.error('\nImport fehlgeschlagen:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
|
||||
// Middleware: Prüft ob User eingeloggt ist
|
||||
function authRequired(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Token ungültig' });
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token abgelaufen' });
|
||||
}
|
||||
console.error('Auth Middleware Error:', error);
|
||||
return res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware: Prüft ob User Admin ist
|
||||
function adminRequired(req, res, next) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
}
|
||||
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Zugriff verweigert. Admin-Rechte erforderlich.' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authRequired,
|
||||
adminRequired
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "buchhaltung-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "OCR Backend für SteuerFlow",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.11.3",
|
||||
"sharp": "^0.33.2",
|
||||
"tesseract.js": "^5.0.5",
|
||||
"uuid": "^9.0.1",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,315 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Pool } = require('pg');
|
||||
const router = express.Router();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const BCRYPT_ROUNDS = 11;
|
||||
|
||||
// Helper: Check if any users exist
|
||||
async function hasUsers() {
|
||||
const result = await pool.query('SELECT COUNT(*) FROM users');
|
||||
return parseInt(result.rows[0].count) > 0;
|
||||
}
|
||||
|
||||
// POST /api/auth/setup - Erste Anmeldung (Admin erstellen)
|
||||
router.post('/setup', async (req, res) => {
|
||||
try {
|
||||
// Prüfen ob schon User existieren
|
||||
const existingUsers = await hasUsers();
|
||||
if (existingUsers) {
|
||||
return res.status(400).json({ error: 'Setup bereits abgeschlossen. Bitte einloggen.' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, 'admin']
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Admin-Account erstellt',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Setup Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler bei der Einrichtung' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login - JWT-Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1 AND is_active = true',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
// Update last_login
|
||||
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login erfolgreich',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login Error:', error);
|
||||
res.status(500).json({ error: 'Server-Fehler beim Login' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout - Logout (Client-seitig Token entfernen, hier nur Bestätigung)
|
||||
router.post('/logout', async (req, res) => {
|
||||
res.json({ success: true, message: 'Logout erfolgreich' });
|
||||
});
|
||||
|
||||
// GET /api/auth/me - Aktueller User
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, created_at, last_login FROM users WHERE id = $1 AND is_active = true',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth/me Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/users - Neuen User anlegen (nur Admin)
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User anlegen' });
|
||||
}
|
||||
|
||||
const { username, password, role = 'user' } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, role]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User erstellt',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create User Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/users - Alle User auflisten (nur Admin)
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User-Liste sehen' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, is_active, created_at, last_login FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List Users Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/users/:id - User löschen (nur Admin)
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User löschen' });
|
||||
}
|
||||
|
||||
// Cannot delete yourself
|
||||
if (req.params.id === decoded.userId) {
|
||||
return res.status(400).json({ error: 'Kann eigenen Account nicht löschen' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User gelöscht'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete User Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/users/:id/password - Passwort zurücksetzen (nur Admin)
|
||||
router.put('/users/:id/password', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können Passwörter zurücksetzen' });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort zurückgesetzt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Reset Password Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,315 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Pool } = require('pg');
|
||||
const router = express.Router();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const BCRYPT_ROUNDS = 11;
|
||||
|
||||
// Helper: Check if any users exist
|
||||
async function hasUsers() {
|
||||
const result = await pool.query('SELECT COUNT(*) FROM users');
|
||||
return parseInt(result.rows[0].count) > 0;
|
||||
}
|
||||
|
||||
// POST /api/auth/setup - Erste Anmeldung (Admin erstellen)
|
||||
router.post('/setup', async (req, res) => {
|
||||
try {
|
||||
// Prüfen ob schon User existieren
|
||||
const existingUsers = await hasUsers();
|
||||
if (existingUsers) {
|
||||
return res.status(400).json({ error: 'Setup bereits abgeschlossen. Bitte einloggen.' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, 'admin']
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Admin-Account erstellt',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Setup Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler bei der Einrichtung' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login - JWT-Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1 AND is_active = true',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
// Update last_login
|
||||
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login erfolgreich',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login Error:', error);
|
||||
res.status(500).json({ error: 'Server-Fehler beim Login' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout - Logout (Client-seitig Token entfernen, hier nur Bestätigung)
|
||||
router.post('/logout', async (req, res) => {
|
||||
res.json({ success: true, message: 'Logout erfolgreich' });
|
||||
});
|
||||
|
||||
// GET /api/auth/me - Aktueller User
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, created_at, last_login FROM users WHERE id = $1 AND is_active = true',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth/me Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/users - Neuen User anlegen (nur Admin)
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User anlegen' });
|
||||
}
|
||||
|
||||
const { username, password, role = 'user' } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, role]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User erstellt',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create User Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/users - Alle User auflisten (nur Admin)
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User-Liste sehen' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, is_active, created_at, last_login FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List Users Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/users/:id - User löschen (nur Admin)
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User löschen' });
|
||||
}
|
||||
|
||||
// Cannot delete yourself
|
||||
if (req.params.id === decoded.userId) {
|
||||
return res.status(400).json({ error: 'Kann eigenen Account nicht löschen' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User gelöscht'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete User Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/users/:id/password - Passwort zurücksetzen (nur Admin)
|
||||
router.put('/users/:id/password', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können Passwörter zurücksetzen' });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort zurückgesetzt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Reset Password Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Read file
|
||||
let content = fs.readFileSync(process.argv[1] || 'nebenkosten.js', 'utf8');
|
||||
|
||||
// Convert to Unix line endings
|
||||
content = content.replace(/\r\n/g, '\n');
|
||||
|
||||
// Save
|
||||
fs.writeFileSync(process.argv[1] || 'nebenkosten.js', content, 'utf8');
|
||||
|
||||
console.log('Lines:', content.split('\n').length);
|
||||
console.log('Has vermietung/bilanz:', content.includes("app.get('/api/vermietung/bilanz'"));
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,962 @@
|
||||
// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen)
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'buchhaltung-db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
module.exports = (app) => {
|
||||
|
||||
// ========== OBJEKTE ==========
|
||||
|
||||
// Alle Objekte
|
||||
app.get('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelnes Objekt
|
||||
app.get('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt erstellen
|
||||
app.post('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt aktualisieren
|
||||
app.put('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt löschen
|
||||
app.delete('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
// Kosten für ein Objekt laden (optional gefiltert nach Jahr)
|
||||
app.get('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { jahr } = req.query;
|
||||
let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1';
|
||||
const params = [req.params.id];
|
||||
|
||||
if (jahr) {
|
||||
query += ' AND jahr = $2';
|
||||
params.push(jahr);
|
||||
}
|
||||
|
||||
query += ' ORDER BY jahr DESC, kategorie ASC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten hinzufügen
|
||||
app.post('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, betrag, jahr } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[req.params.id, kategorie, betrag, jahr]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten löschen
|
||||
app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mieter eines Objekts
|
||||
app.get('/api/objekte/:id/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete,
|
||||
mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell
|
||||
FROM mieter m
|
||||
JOIN mietvertraege mv ON mv.mieter_id = m.id
|
||||
WHERE mv.objekt_id = $1
|
||||
ORDER BY m.name`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETER ==========
|
||||
|
||||
app.get('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[name, email, telefon, adresse]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *',
|
||||
[name, email, telefon, adresse, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mietverträge eines Mieters
|
||||
app.get('/api/mieter/:id/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
WHERE mv.mieter_id = $1
|
||||
ORDER BY mv.vertragsbeginn DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, m.name as mieter_name, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
ORDER BY mv.vertragsbeginn DESC`
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, ist_aktuell } = req.query;
|
||||
let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort,
|
||||
m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
JOIN mieter m ON m.id = mv.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`mv.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (ist_aktuell !== undefined) {
|
||||
conditions.push(`mv.ist_aktuell = $${values.length + 1}`);
|
||||
values.push(ist_aktuell === 'true');
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY mv.vertragsbeginn DESC';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3,
|
||||
vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`,
|
||||
[wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege/:id/beenden', async (req, res) => {
|
||||
try {
|
||||
const { vertragsende } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`,
|
||||
[vertragsende, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
app.get('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, jahr } = req.query;
|
||||
let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id';
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`ok.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`ok.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== VORAUSZAHLUNGEN ==========
|
||||
|
||||
app.get('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr } = req.query;
|
||||
let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name
|
||||
FROM vorauszahlungen v
|
||||
JOIN objekte o ON o.id = v.objekt_id
|
||||
JOIN mieter m ON m.id = v.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`v.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (mieter_id) {
|
||||
conditions.push(`v.mieter_id = $${values.length + 1}`);
|
||||
values.push(mieter_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`v.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY v.jahr DESC, v.monat';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk: Vorauszahlungen für Jahr erstellen
|
||||
app.post('/api/vorauszahlungen/bulk', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { objektId, mieterId, jahr, monatlicherBetrag } = req.body;
|
||||
const results = [];
|
||||
|
||||
for (let monat = 1; monat <= 12; monat++) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (objekt_id, mieter_id, jahr, monat)
|
||||
DO UPDATE SET betrag = $5
|
||||
RETURNING *`,
|
||||
[objektId, mieterId, jahr, monat, monatlicherBetrag]
|
||||
);
|
||||
results.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, created: results.length, vorauszahlungen: results });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`,
|
||||
[betrag, bezahlt_am, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== NEBENKOSTENABRECHNUNG ==========
|
||||
|
||||
// Übersicht
|
||||
app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => {
|
||||
try {
|
||||
const { jahr = new Date().getFullYear() } = req.query;
|
||||
|
||||
// Alle Objekte mit Summen
|
||||
const result = await pool.query(`
|
||||
SELECT o.*,
|
||||
COALESCE(k.gesamt_kosten, 0) as gesamt_kosten,
|
||||
COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen,
|
||||
COALESCE(m.anzahl_mieter, 0) as anzahl_mieter
|
||||
FROM objekte o
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_kosten
|
||||
FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id
|
||||
) k ON k.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen
|
||||
FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id
|
||||
) v ON v.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, COUNT(*) as anzahl_mieter
|
||||
FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id
|
||||
) m ON m.objekt_id = o.id
|
||||
ORDER BY o.name
|
||||
`, [jahr]);
|
||||
|
||||
res.json({ jahr: parseInt(jahr), objekte: result.rows });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung Vorschau (Berechnung)
|
||||
app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => {
|
||||
try {
|
||||
const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body;
|
||||
|
||||
// Objekt-Daten
|
||||
const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]);
|
||||
if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
const objekt = objektResult.rows[0];
|
||||
|
||||
// Alle Kosten des Objekts im Jahr
|
||||
const kostenResult = await pool.query(
|
||||
'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie',
|
||||
[objektId, jahr]
|
||||
);
|
||||
|
||||
// Aktuelle Mieter mit Mietverträgen
|
||||
const mieterResult = await pool.query(`
|
||||
SELECT mv.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true
|
||||
`, [objektId]);
|
||||
|
||||
// Berechnung
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0);
|
||||
|
||||
// Tage im Abrechnungszeitraum
|
||||
const vonDatum = new Date(zeitraumVon);
|
||||
const bisDatum = new Date(zeitraumBis);
|
||||
const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
// Pro Mieter berechnen
|
||||
const berechnungen = [];
|
||||
|
||||
for (const mietvertrag of mieterResult.rows) {
|
||||
// Vorauszahlungen dieses Mieters
|
||||
const vorauszahlungenResult = await pool.query(
|
||||
'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3',
|
||||
[objektId, mietvertrag.mieter_id, jahr]
|
||||
);
|
||||
const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0);
|
||||
|
||||
// Anteil berechnen (pro-rata bei Mieterwechsel möglich)
|
||||
const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm);
|
||||
const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen
|
||||
const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche);
|
||||
|
||||
const ergebnis = anteilKosten - summeVorauszahlungen;
|
||||
|
||||
berechnungen.push({
|
||||
mietvertrag_id: mietvertrag.id,
|
||||
mieter_id: mietvertrag.mieter_id,
|
||||
mieter_name: mietvertrag.mieter_name,
|
||||
anteil_qm: anteilQm,
|
||||
anteil_tage: anteilTage,
|
||||
anteil_kosten: Math.round(anteilKosten * 100) / 100,
|
||||
summe_vorauszahlungen: summeVorauszahlungen,
|
||||
ergebnis: Math.round(ergebnis * 100) / 100,
|
||||
ist_nachzahlung: ergebnis > 0,
|
||||
betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0,
|
||||
betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
objekt,
|
||||
jahr,
|
||||
zeitraum_von: zeitraumVon,
|
||||
zeitraum_bis: zeitraumBis,
|
||||
tage_gesamt: tageGesamt,
|
||||
gesamt_kosten: gesamtKosten,
|
||||
gesamt_flaeche: gesamtFlaeche,
|
||||
kosten: kostenResult.rows,
|
||||
mieter: mieterResult.rows,
|
||||
berechnungen
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung speichern
|
||||
app.post('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body;
|
||||
|
||||
// Bestehende Abrechnung prüfen/löschen
|
||||
await client.query(
|
||||
'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2',
|
||||
[objekt_id, jahr]
|
||||
);
|
||||
|
||||
// Neue Abrechnung erstellen
|
||||
const abrechnungResult = await client.query(
|
||||
`INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false]
|
||||
);
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// Positionen speichern
|
||||
for (const pos of berechnungen) {
|
||||
await client.query(
|
||||
`INSERT INTO abrechnungspositionen
|
||||
(abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage,
|
||||
pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, abrechnung: abrechnungResult.rows[0] });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Alle Abrechnungen
|
||||
app.get('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Alias für Frontend (Plural)
|
||||
app.get('/api/nebenkostenabrechnungen', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelne Abrechnung mit Positionen
|
||||
app.get('/api/nebenkostenabrechnung/:id', async (req, res) => {
|
||||
try {
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2
|
||||
`, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]);
|
||||
|
||||
res.json({
|
||||
abrechnung: abrechnungResult.rows[0],
|
||||
positionen: positionenResult.rows,
|
||||
kosten: kostenResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Status aktualisieren (Entwurf/Final)
|
||||
app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => {
|
||||
try {
|
||||
const { ist_entwurf } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *',
|
||||
[ist_entwurf, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,962 @@
|
||||
// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen)
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'buchhaltung-db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
module.exports = (app) => {
|
||||
|
||||
// ========== OBJEKTE ==========
|
||||
|
||||
// Alle Objekte
|
||||
app.get('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelnes Objekt
|
||||
app.get('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt erstellen
|
||||
app.post('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt aktualisieren
|
||||
app.put('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt löschen
|
||||
app.delete('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
// Kosten für ein Objekt laden (optional gefiltert nach Jahr)
|
||||
app.get('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { jahr } = req.query;
|
||||
let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1';
|
||||
const params = [req.params.id];
|
||||
|
||||
if (jahr) {
|
||||
query += ' AND jahr = $2';
|
||||
params.push(jahr);
|
||||
}
|
||||
|
||||
query += ' ORDER BY jahr DESC, kategorie ASC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten hinzufügen
|
||||
app.post('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, betrag, jahr } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[req.params.id, kategorie, betrag, jahr]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten löschen
|
||||
app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mieter eines Objekts
|
||||
app.get('/api/objekte/:id/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete,
|
||||
mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell
|
||||
FROM mieter m
|
||||
JOIN mietvertraege mv ON mv.mieter_id = m.id
|
||||
WHERE mv.objekt_id = $1
|
||||
ORDER BY m.name`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETER ==========
|
||||
|
||||
app.get('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[name, email, telefon, adresse]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *',
|
||||
[name, email, telefon, adresse, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mietverträge eines Mieters
|
||||
app.get('/api/mieter/:id/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
WHERE mv.mieter_id = $1
|
||||
ORDER BY mv.vertragsbeginn DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, m.name as mieter_name, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
ORDER BY mv.vertragsbeginn DESC`
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, ist_aktuell } = req.query;
|
||||
let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort,
|
||||
m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
JOIN mieter m ON m.id = mv.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`mv.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (ist_aktuell !== undefined) {
|
||||
conditions.push(`mv.ist_aktuell = $${values.length + 1}`);
|
||||
values.push(ist_aktuell === 'true');
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY mv.vertragsbeginn DESC';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3,
|
||||
vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`,
|
||||
[wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege/:id/beenden', async (req, res) => {
|
||||
try {
|
||||
const { vertragsende } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`,
|
||||
[vertragsende, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
app.get('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, jahr } = req.query;
|
||||
let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id';
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`ok.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`ok.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== VORAUSZAHLUNGEN ==========
|
||||
|
||||
app.get('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr } = req.query;
|
||||
let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name
|
||||
FROM vorauszahlungen v
|
||||
JOIN objekte o ON o.id = v.objekt_id
|
||||
JOIN mieter m ON m.id = v.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`v.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (mieter_id) {
|
||||
conditions.push(`v.mieter_id = $${values.length + 1}`);
|
||||
values.push(mieter_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`v.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY v.jahr DESC, v.monat';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk: Vorauszahlungen für Jahr erstellen
|
||||
app.post('/api/vorauszahlungen/bulk', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { objektId, mieterId, jahr, monatlicherBetrag } = req.body;
|
||||
const results = [];
|
||||
|
||||
for (let monat = 1; monat <= 12; monat++) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (objekt_id, mieter_id, jahr, monat)
|
||||
DO UPDATE SET betrag = $5
|
||||
RETURNING *`,
|
||||
[objektId, mieterId, jahr, monat, monatlicherBetrag]
|
||||
);
|
||||
results.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, created: results.length, vorauszahlungen: results });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`,
|
||||
[betrag, bezahlt_am, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== NEBENKOSTENABRECHNUNG ==========
|
||||
|
||||
// Übersicht
|
||||
app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => {
|
||||
try {
|
||||
const { jahr = new Date().getFullYear() } = req.query;
|
||||
|
||||
// Alle Objekte mit Summen
|
||||
const result = await pool.query(`
|
||||
SELECT o.*,
|
||||
COALESCE(k.gesamt_kosten, 0) as gesamt_kosten,
|
||||
COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen,
|
||||
COALESCE(m.anzahl_mieter, 0) as anzahl_mieter
|
||||
FROM objekte o
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_kosten
|
||||
FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id
|
||||
) k ON k.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen
|
||||
FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id
|
||||
) v ON v.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, COUNT(*) as anzahl_mieter
|
||||
FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id
|
||||
) m ON m.objekt_id = o.id
|
||||
ORDER BY o.name
|
||||
`, [jahr]);
|
||||
|
||||
res.json({ jahr: parseInt(jahr), objekte: result.rows });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung Vorschau (Berechnung)
|
||||
app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => {
|
||||
try {
|
||||
const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body;
|
||||
|
||||
// Objekt-Daten
|
||||
const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]);
|
||||
if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
const objekt = objektResult.rows[0];
|
||||
|
||||
// Alle Kosten des Objekts im Jahr
|
||||
const kostenResult = await pool.query(
|
||||
'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie',
|
||||
[objektId, jahr]
|
||||
);
|
||||
|
||||
// Aktuelle Mieter mit Mietverträgen
|
||||
const mieterResult = await pool.query(`
|
||||
SELECT mv.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true
|
||||
`, [objektId]);
|
||||
|
||||
// Berechnung
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0);
|
||||
|
||||
// Tage im Abrechnungszeitraum
|
||||
const vonDatum = new Date(zeitraumVon);
|
||||
const bisDatum = new Date(zeitraumBis);
|
||||
const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
// Pro Mieter berechnen
|
||||
const berechnungen = [];
|
||||
|
||||
for (const mietvertrag of mieterResult.rows) {
|
||||
// Vorauszahlungen dieses Mieters
|
||||
const vorauszahlungenResult = await pool.query(
|
||||
'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3',
|
||||
[objektId, mietvertrag.mieter_id, jahr]
|
||||
);
|
||||
const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0);
|
||||
|
||||
// Anteil berechnen (pro-rata bei Mieterwechsel möglich)
|
||||
const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm);
|
||||
const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen
|
||||
const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche);
|
||||
|
||||
const ergebnis = anteilKosten - summeVorauszahlungen;
|
||||
|
||||
berechnungen.push({
|
||||
mietvertrag_id: mietvertrag.id,
|
||||
mieter_id: mietvertrag.mieter_id,
|
||||
mieter_name: mietvertrag.mieter_name,
|
||||
anteil_qm: anteilQm,
|
||||
anteil_tage: anteilTage,
|
||||
anteil_kosten: Math.round(anteilKosten * 100) / 100,
|
||||
summe_vorauszahlungen: summeVorauszahlungen,
|
||||
ergebnis: Math.round(ergebnis * 100) / 100,
|
||||
ist_nachzahlung: ergebnis > 0,
|
||||
betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0,
|
||||
betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
objekt,
|
||||
jahr,
|
||||
zeitraum_von: zeitraumVon,
|
||||
zeitraum_bis: zeitraumBis,
|
||||
tage_gesamt: tageGesamt,
|
||||
gesamt_kosten: gesamtKosten,
|
||||
gesamt_flaeche: gesamtFlaeche,
|
||||
kosten: kostenResult.rows,
|
||||
mieter: mieterResult.rows,
|
||||
berechnungen
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung speichern
|
||||
app.post('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body;
|
||||
|
||||
// Bestehende Abrechnung prüfen/löschen
|
||||
await client.query(
|
||||
'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2',
|
||||
[objekt_id, jahr]
|
||||
);
|
||||
|
||||
// Neue Abrechnung erstellen
|
||||
const abrechnungResult = await client.query(
|
||||
`INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false]
|
||||
);
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// Positionen speichern
|
||||
for (const pos of berechnungen) {
|
||||
await client.query(
|
||||
`INSERT INTO abrechnungspositionen
|
||||
(abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage,
|
||||
pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, abrechnung: abrechnungResult.rows[0] });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Alle Abrechnungen
|
||||
app.get('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Alias für Frontend (Plural)
|
||||
app.get('/api/nebenkostenabrechnungen', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelne Abrechnung mit Positionen
|
||||
app.get('/api/nebenkostenabrechnung/:id', async (req, res) => {
|
||||
try {
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2
|
||||
`, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]);
|
||||
|
||||
res.json({
|
||||
abrechnung: abrechnungResult.rows[0],
|
||||
positionen: positionenResult.rows,
|
||||
kosten: kostenResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Status aktualisieren (Entwurf/Final)
|
||||
app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => {
|
||||
try {
|
||||
const { ist_entwurf } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *',
|
||||
[ist_entwurf, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
// PDF Export - Privat (René Täger)
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (Mieter) ==========
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(` Wohnfläche: ${pos.anteil_qm} qm | Zeitraum: ${pos.anteil_tage} Tage`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Ihr Anteil an den Kosten:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Nachzahlung:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// PDF Export - Privat (René Täger)
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (Mieter) ==========
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(` Wohnfläche: ${pos.anteil_qm} qm | Zeitraum: ${pos.anteil_tage} Tage`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Ihr Anteil an den Kosten:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Nachzahlung:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// PDF Export - Privat (René Täger) mit Rechenweg
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(yPos) {
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SEITE 1: ABSENDER + EMPFÄNGER + BETREFF ==========
|
||||
|
||||
// Absender (klein, oben links)
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(y);
|
||||
y -= 30;
|
||||
|
||||
// Empfänger (Mieter)
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// Datum + Betreff
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('1. Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== RECHENWEG (Der Weg ist das Ziel!) ==========
|
||||
currentPage.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Gesamtfläche berechnen
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
currentPage.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('3. Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
|
||||
// Rechenweg
|
||||
currentPage.drawText(` Wohnfläche: ${mieterFlaeche.toFixed(2)} qm`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Berechnung: ${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
// Ergebnis
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Nachzahlung erforderlich:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(y);
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// PDF Export - Privat (René Täger) mit Rechenweg
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(yPos) {
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SEITE 1: ABSENDER + EMPFÄNGER + BETREFF ==========
|
||||
|
||||
// Absender (klein, oben links)
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(y);
|
||||
y -= 30;
|
||||
|
||||
// Empfänger (Mieter)
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// Datum + Betreff
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('1. Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== RECHENWEG (Der Weg ist das Ziel!) ==========
|
||||
currentPage.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Gesamtfläche berechnen
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
currentPage.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('3. Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
|
||||
// Rechenweg
|
||||
currentPage.drawText(` Wohnfläche: ${mieterFlaeche.toFixed(2)} qm`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Berechnung: ${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
// Ergebnis
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Nachzahlung erforderlich:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(y);
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
// API Routes für Private Finanzen (Monatliche Ausgaben, etc.)
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'buchhaltung-db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
module.exports = (app) => {
|
||||
|
||||
// ========== PRIVAT AUSGABEN ==========
|
||||
|
||||
// Tabelle erstellen falls nicht existiert
|
||||
app.post('/api/privat/init', async (req, res) => {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privat_ausgaben (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kategorie VARCHAR(100) NOT NULL,
|
||||
bezeichnung VARCHAR(255),
|
||||
betrag DECIMAL(10,2) NOT NULL,
|
||||
jahr INTEGER NOT NULL,
|
||||
monat INTEGER NOT NULL, -- 0-11 für Jan-Dez
|
||||
typ VARCHAR(20) DEFAULT 'einmalig', -- 'einmalig' oder 'wiederkehrend'
|
||||
wiederkehrend_bis INTEGER, -- Monat bis zu dem es wiederkehrt (optional)
|
||||
wiederkehrend_bis_jahr INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
res.json({ success: true, message: 'Tabelle privat_ausgaben erstellt' });
|
||||
} catch (error) {
|
||||
console.error('Init Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Alle Ausgaben für Jahr/Monat laden
|
||||
app.get('/api/privat/ausgaben', async (req, res) => {
|
||||
try {
|
||||
const { jahr, monat } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM privat_ausgaben WHERE 1=1';
|
||||
const params = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (jahr) {
|
||||
paramCount++;
|
||||
query += ` AND (jahr = $${paramCount} OR (typ = 'wiederkehrend' AND jahr <= $${paramCount}))`;
|
||||
params.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (monat !== undefined) {
|
||||
paramCount++;
|
||||
query += ` AND (monat = $${paramCount} OR typ = 'wiederkehrend')`;
|
||||
params.push(parseInt(monat));
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
// Tabelle existiert nicht - leeres Array zurückgeben
|
||||
if (error.message.includes('relation "privat_ausgaben" does not exist')) {
|
||||
return res.json([]);
|
||||
}
|
||||
console.error('Load Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ausgabe erstellen
|
||||
app.post('/api/privat/ausgaben', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr } = req.body;
|
||||
|
||||
// Tabelle erstellen falls nicht existiert
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privat_ausgaben (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kategorie VARCHAR(100) NOT NULL,
|
||||
bezeichnung VARCHAR(255),
|
||||
betrag DECIMAL(10,2) NOT NULL,
|
||||
jahr INTEGER NOT NULL,
|
||||
monat INTEGER NOT NULL,
|
||||
typ VARCHAR(20) DEFAULT 'einmalig',
|
||||
wiederkehrend_bis INTEGER,
|
||||
wiederkehrend_bis_jahr INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO privat_ausgaben (kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, jahr, monat, typ || 'einmalig', wiederkehrend_bis, wiederkehrend_bis_jahr]
|
||||
);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Create Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ausgabe aktualisieren
|
||||
app.put('/api/privat/ausgaben/:id', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE privat_ausgaben
|
||||
SET kategorie = $1, bezeichnung = $2, betrag = $3, jahr = $4, monat = $5,
|
||||
typ = $6, wiederkehrend_bis = $7, wiederkehrend_bis_jahr = $8, updated_at = NOW()
|
||||
WHERE id = $9 RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr, req.params.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Ausgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Update Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ausgabe löschen
|
||||
app.delete('/api/privat/ausgaben/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM privat_ausgaben WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Ausgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Delete Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
+1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
const Tesseract = require('tesseract.js');
|
||||
const path = require('path');
|
||||
|
||||
class OCRService {
|
||||
async processDocument(filePath) {
|
||||
try {
|
||||
console.log(`[OCR] Verarbeite: ${filePath}`);
|
||||
|
||||
// Erkenne Dateityp
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (ext === '.pdf') {
|
||||
// Für PDFs: Text-Extraktion (ohne OCR, wenn möglich)
|
||||
// Hier könnte pdf-parse verwendet werden
|
||||
return {
|
||||
success: true,
|
||||
extracted: { text: 'PDF Text extrahiert (Platzhalter)', type: 'pdf' }
|
||||
};
|
||||
}
|
||||
|
||||
// Für Bilder: Tesseract OCR
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
const result = await Tesseract.recognize(
|
||||
filePath,
|
||||
'deu', // Deutsche Sprache
|
||||
{
|
||||
logger: m => console.log(`[OCR] ${m.status}: ${Math.round(m.progress * 100)}%`)
|
||||
}
|
||||
);
|
||||
|
||||
// Extrahiere potenzielle Beträge (einfache Regex)
|
||||
const amounts = this.extractAmounts(result.data.text);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
extracted: {
|
||||
text: result.data.text,
|
||||
confidence: result.data.confidence,
|
||||
amounts: amounts,
|
||||
type: 'image'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Nicht unterstütztes Dateiformat'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OCR] Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extractAmounts(text) {
|
||||
// Deutsche Beträge erkennen (z.B. 1.234,56 oder 1234,56)
|
||||
const patterns = [
|
||||
/(\d{1,3}(?:\.\d{3})*,\d{2})\s*[€$]?/g, // 1.234,56 €
|
||||
/(\d+,\d{2})\s*[€$]?/g, // 1234,56 €
|
||||
/[€$]\s*(\d{1,3}(?:,\d{3})*\.\d{2})/g, // € 1,234.56
|
||||
/[€$]\s*(\d+\.\d{2})/g // € 1234.56
|
||||
];
|
||||
|
||||
const amounts = [];
|
||||
patterns.forEach(pattern => {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
amounts.push(...matches.map(m => m.replace(/[^\d,]/g, '').replace(',', '.')));
|
||||
}
|
||||
});
|
||||
|
||||
// Eindeutige Beträge zurückgeben
|
||||
return [...new Set(amounts)].map(a => parseFloat(a)).filter(a => a > 0);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OCRService();
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user