v1.14: Steuermodul - umlegbare und nicht umlegbare Kosten nach Haus und Wohnung

This commit is contained in:
2026-10-04 12:18:26 +02:00
parent 39eb247456
commit b57e9276f8
10 changed files with 606 additions and 4 deletions
+14
View File
@@ -139,6 +139,20 @@ CREATE TABLE IF NOT EXISTS buildings (
wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll)
quadratmeter NUMERIC(9,1) NOT NULL -- Gesamtwohnfläche in m²
);
CREATE TABLE IF NOT EXISTS costs (
id TEXT PRIMARY KEY,
building TEXT NOT NULL REFERENCES buildings(name), -- Haus
unit_id TEXT REFERENCES units(id), -- NULL = hausweite Position
jahr INT NOT NULL,
kostenart TEXT NOT NULL,
betrag NUMERIC(10,2) NOT NULL,
umlegbar BOOLEAN NOT NULL DEFAULT TRUE, -- umlegbar / nicht umlegbar
notiz TEXT NOT NULL DEFAULT '',
erfasst_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_costs_building ON costs(building);
CREATE INDEX IF NOT EXISTS idx_costs_unit ON costs(unit_id);
`
async function init() {
+91 -2
View File
@@ -12,7 +12,7 @@ const fs = require('fs')
const path = require('path')
const multer = require('multer')
const { pool, init, sessionsAufraeumen } = require('./db')
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
const mail = require('./mail')
const PORT = Number(process.env.PORT || 3000)
@@ -177,6 +177,11 @@ const mapBuilding = (b) => ({
etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten,
quadratmeter: Number(b.quadratmeter),
})
const mapKosten = (k) => ({
id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr,
kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar,
notiz: k.notiz, erfasstAm: k.erfasst_am,
})
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
@@ -280,7 +285,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
try {
const istAdmin = req.user.rolle === 'verwaltung'
const einheit = req.user.einheit_id
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings] =
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs] =
await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'),
istAdmin
@@ -311,6 +316,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
istAdmin
? pool.query('SELECT * FROM buildings ORDER BY name')
: { rows: [] },
istAdmin
? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart')
: pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]),
])
// Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {}
@@ -332,6 +340,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
codes: codes.rows.map(mapCode),
meters: meters.rows.map(mapMeter),
buildings: buildings.rows.map(mapBuilding),
kosten: costs.rows.map(mapKosten),
})
} catch (e) { next(e) }
})
@@ -625,6 +634,84 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => {
} catch (e) { next(e) }
})
// ── Steuer & Kosten (Verwaltung) ─────────────────────────────
const KOSTEN_ARTEN = [
'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege',
'Hausreinigung', 'Hauswart', 'Aufzug', 'Versicherung', 'Grundsteuer',
'Heizung/Warmwasser', 'Kabelanschluss', 'Verwaltung', 'Instandhaltung',
'Mietausfall', 'Sonstiges',
]
async function kostenGueltig(b, res) {
const kostenart = String(b.kostenart || '').trim()
if (!KOSTEN_ARTEN.includes(kostenart)) { res.status(400).json({ fehler: 'Ungültige Kostenart.' }); return null }
const jahr = Number(b.jahr)
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > new Date().getFullYear() + 1) { res.status(400).json({ fehler: 'Jahr ungültig.' }); return null }
const betrag = Number(b.betrag)
if (!Number.isFinite(betrag) || betrag < 0) { res.status(400).json({ fehler: 'Betrag ungültig.' }); return null }
// Haus folgt der Wohnung: unitId gesetzt → Wohnung muss existieren, Haus wird übernommen
let einheitId = b.unitId !== undefined ? (b.unitId || null) : undefined
let haus = String(b.building || '').trim()
if (einheitId) {
const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [einheitId])
if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
haus = u[0].building
}
if (!haus) { res.status(400).json({ fehler: 'Haus fehlt.' }); return null }
const { rows: h } = await pool.query('SELECT name FROM buildings WHERE name = $1', [haus])
if (!h.length) { res.status(400).json({ fehler: 'Haus unbekannt.' }); return null }
return { kostenart, jahr, betrag, einheitId, haus, notiz: String(b.notiz || '').trim() }
}
app.post('/api/costs', auth, adminOnly, async (req, res, next) => {
try {
const g = await kostenGueltig(req.body || {}, res)
if (!g) return
const { rows } = await pool.query(
`INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[`k-${Date.now()}`, g.haus, g.einheitId ?? null, g.jahr, g.kostenart, g.betrag,
req.body.umlegbar !== false, g.notiz, deutschesDatumZeit()],
)
res.json({ kosten: mapKosten(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/costs/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM costs WHERE id = $1', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Kostenposition nicht gefunden.' })
const k = rows[0]
const b = req.body || {}
const kandidat = {
kostenart: b.kostenart !== undefined ? b.kostenart : k.kostenart,
jahr: b.jahr !== undefined ? b.jahr : k.jahr,
betrag: b.betrag !== undefined ? b.betrag : k.betrag,
unitId: b.unitId !== undefined ? b.unitId : k.unit_id,
building: b.building !== undefined ? b.building : k.building,
notiz: b.notiz !== undefined ? b.notiz : k.notiz,
}
const g = await kostenGueltig(kandidat, res)
if (!g) return
const einheitId = g.einheitId === undefined ? k.unit_id : g.einheitId
const { rows: aktualisiert } = await pool.query(
`UPDATE costs SET building = $1, unit_id = $2, jahr = $3, kostenart = $4, betrag = $5, umlegbar = $6, notiz = $7
WHERE id = $8 RETURNING *`,
[g.haus, einheitId, g.jahr, g.kostenart, g.betrag,
b.umlegbar !== undefined ? Boolean(b.umlegbar) : k.umlegbar, g.notiz, req.params.id],
)
res.json({ kosten: mapKosten(aktualisiert[0]) })
} catch (e) { next(e) }
})
app.delete('/api/costs/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rowCount } = await pool.query('DELETE FROM costs WHERE id = $1', [req.params.id])
if (!rowCount) return res.status(404).json({ fehler: 'Kostenposition nicht gefunden.' })
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
function pruefeHaus(b) {
const fehler = []
@@ -1006,6 +1093,8 @@ async function start() {
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
const haeuserGeseedet = await seedeHaeuser()
if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt')
const kostenGeseedet = await seedeKosten()
if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt')
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
}
+27 -1
View File
@@ -174,4 +174,30 @@ const buildings = [
{ name: 'Am Park 5', adresse: 'Am Park 5', baujahr: 2019, stockwerke: 3, etagenhoehe: 2.60, wohneinheiten: 15, quadratmeter: 1110.0 },
]
module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings }
// Steuermodul: umlegbare und nicht umlegbare Kosten (Demo 2025/2026).
// unit_id null = hausweite Position, sonst Kosten der konkreten Wohnung.
const kosten = [
// Hauptstraße 12 – hausweit
{ id: 'k-1', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Wasser/Abwasser', betrag: 4860.00, umlegbar: true, notiz: 'Verbrauch abrechnungsreif nach Zählern', erfasst_am: '15.01.2026, 09:00' },
{ id: 'k-2', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Müllabfuhr', betrag: 2210.40, umlegbar: true, notiz: 'Stadtwerke, 4-Behälter-Service', erfasst_am: '15.01.2026, 09:02' },
{ id: 'k-3', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Hausreinigung', betrag: 3944.00, umlegbar: true, notiz: 'Treppenhaus wöchentlich', erfasst_am: '15.01.2026, 09:03' },
{ id: 'k-4', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Versicherung', betrag: 1872.00, umlegbar: true, notiz: 'Haus- und Grundbesitzerhaftpflicht', erfasst_am: '15.01.2026, 09:04' },
{ id: 'k-5', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Instandhaltung', betrag: 7380.00, umlegbar: false, notiz: 'Fassadensanierung Südseite (Verrechnung über Rücklage)', erfasst_am: '20.01.2026, 11:30' },
{ id: 'k-6', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Verwaltung', betrag: 5400.00, umlegbar: false, notiz: 'Anteilige Verwaltungskosten (nicht umlegbarer Teil)', erfasst_am: '20.01.2026, 11:31' },
// Hauptstraße 12 – Wohnung hs-7 (Anna Berger)
{ id: 'k-7', building: 'Hauptstraße 12', unit_id: 'hs-7', jahr: 2025, kostenart: 'Heizung/Warmwasser', betrag: 912.55, umlegbar: true, notiz: 'Verbrauch 2025 laut Heizkostenrechnung', erfasst_am: '22.01.2026, 14:10' },
// Ringweg 3 – hausweit
{ id: 'k-8', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Straßenreinigung/Winterdienst', betrag: 1284.00, umlegbar: true, notiz: '', erfasst_am: '16.01.2026, 10:00' },
{ id: 'k-9', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Gartenpflege', betrag: 2160.00, umlegbar: true, notiz: 'Hof- und Vorgartenpflege April–Oktober', erfasst_am: '16.01.2026, 10:01' },
{ id: 'k-10', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Grundsteuer', betrag: 1490.00, umlegbar: true, notiz: '', erfasst_am: '16.01.2026, 10:02' },
{ id: 'k-11', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Mietausfall', betrag: 2310.00, umlegbar: false, notiz: 'Leerstand Whg. 9 (März–Juli)', erfasst_am: '21.01.2026, 08:45' },
// Ringweg 3 – Wohnung rw-4
{ id: 'k-12', building: 'Ringweg 3', unit_id: 'rw-4', jahr: 2025, kostenart: 'Heizung/Warmwasser', betrag: 764.20, umlegbar: true, notiz: '', erfasst_am: '22.01.2026, 14:12' },
// Am Park 5 – hausweit
{ id: 'k-13', building: 'Am Park 5', unit_id: null, jahr: 2026, kostenart: 'Aufzug', betrag: 1980.00, umlegbar: true, notiz: 'Wartung + TÜV, vierteljährlich', erfasst_am: '03.10.2026, 09:20' },
{ id: 'k-14', building: 'Am Park 5', unit_id: null, jahr: 2026, kostenart: 'Kabelanschluss', betrag: 960.00, umlegbar: true, notiz: 'Hausvertrag Kabelnetz', erfasst_am: '03.10.2026, 09:21' },
{ id: 'k-15', building: 'Am Park 5', unit_id: null, jahr: 2026, kostenart: 'Instandhaltung', betrag: 3150.00, umlegbar: false, notiz: 'Garagentor-Antrieb erneuert', erfasst_am: '03.10.2026, 09:22' },
{ id: 'k-16', building: 'Am Park 5', unit_id: 'ap-2', jahr: 2026, kostenart: 'Heizung/Warmwasser', betrag: 388.10, umlegbar: true, notiz: 'Abschlag 1. Halbjahr', erfasst_am: '03.10.2026, 09:25' },
]
module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings, kosten }
+18 -1
View File
@@ -5,7 +5,7 @@
*/
const { pool } = require('./db')
const { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings } = require('./seed-data')
const { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings, kosten } = require('./seed-data')
const generated = require('../seed/generated.json')
async function seed() {
@@ -141,3 +141,20 @@ async function seedeHaeuser() {
}
module.exports = { seed, seedeZaehler, seedeHaeuser }
/** Kosten-Demo-Daten: idempotent – füllt die costs-Tabelle nur, wenn sie
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.14). */
async function seedeKosten() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM costs')
if (rows[0].n > 0) return false
for (const k of kosten) {
await pool.query(
`INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[k.id, k.building, k.unit_id, k.jahr, k.kostenart, k.betrag, k.umlegbar, k.notiz, k.erfasst_am],
)
}
return true
}
module.exports = { seed, seedeZaehler, seedeHaeuser, seedeKosten }