v1.25: Zaehlerverlauf pro Zaehler inkl. Anomalitaetserkennung mit E-Mail-Benachrichtigung

This commit is contained in:
2026-10-04 16:18:26 +02:00
parent 29d32deef5
commit ca12bc9f46
8 changed files with 561 additions and 16 deletions
+84 -2
View File
@@ -15,6 +15,7 @@ const { pool, init, sessionsAufraeumen } = require('./db')
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
const mail = require('./mail')
const { erzeugeAbrechnung } = require('./abrechnung')
const zaehlerVerlauf = require('./zaehlerVerlauf')
const PORT = Number(process.env.PORT || 3000)
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
@@ -181,6 +182,7 @@ const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date:
const mapMeter = (m) => ({
id: m.id, unitId: m.unit_id, building: m.building, nummer: m.nummer, art: m.art,
stand: Number(m.stand), standAm: m.stand_am, geaendertAm: m.geaendert_am,
letzteAnomalie: m.letzte_anomalie === undefined ? undefined : Boolean(m.letzte_anomalie),
})
const mapBuilding = (b) => ({
name: b.name, adresse: b.adresse, baujahr: b.baujahr, stockwerke: b.stockwerke,
@@ -353,8 +355,8 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
? pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC')
: { rows: [] },
istAdmin
? pool.query('SELECT * FROM meters ORDER BY building, nummer')
: pool.query('SELECT * FROM meters WHERE unit_id = $1 ORDER BY nummer', [einheit]),
? pool.query(`SELECT *, (SELECT anomalie FROM meter_readings r WHERE r.meter_id = meters.id ORDER BY r.seq DESC LIMIT 1) AS letzte_anomalie FROM meters ORDER BY building, nummer`)
: pool.query(`SELECT *, (SELECT anomalie FROM meter_readings r WHERE r.meter_id = meters.id ORDER BY r.seq DESC LIMIT 1) AS letzte_anomalie FROM meters WHERE unit_id = $1 ORDER BY nummer`, [einheit]),
istAdmin
? pool.query('SELECT * FROM buildings ORDER BY name')
: { rows: [] },
@@ -565,6 +567,11 @@ app.post('/api/meters', auth, adminOnly, async (req, res, next) => {
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[id, einheitId, haus, String(nummer).trim(), art, standZahl, deutschesDatum(), deutschesDatumZeit()],
)
// Anfangsstand als erster Verlaufseintrag
await zaehlerVerlauf.standEintragen({
meter: rows[0], stand: standZahl, standAm: deutschesDatum(),
erfasstVon: req.user.name, quelle: 'erstbefund',
})
res.json({ meter: mapMeter(rows[0]) })
} catch (e) { next(e) }
})
@@ -601,9 +608,83 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => {
[neu.nummer, neu.art, neu.stand, neu.unit_id, neu.building,
standGeaendert ? deutschesDatum() : m.stand_am, deutschesDatumZeit(), req.params.id],
)
// Geänderter Stand → neuer Verlaufseintrag (inkl. Anomalieprüfung + Mail)
if (standGeaendert) {
await zaehlerVerlauf.standEintragen({
meter: aktualisiert[0], stand: neu.stand, standAm: deutschesDatum(),
erfasstVon: req.user.name, quelle: 'manuel',
})
}
res.json({ meter: mapMeter(aktualisiert[0]) })
} catch (e) { next(e) }
})
// ── Zählerverlauf (v1.25): Liste pro Zähler + Stand eintragen ──
async function meterFuer(req, res) {
const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id])
if (!rows.length) { res.status(404).json({ fehler: 'Zähler nicht gefunden.' }); return null }
const m = rows[0]
// Mieter dürfen nur die Zähler ihrer eigenen Wohneinheit sehen/pflegen
if (req.user.rolle !== 'verwaltung' && m.unit_id !== req.user.einheit_id) {
res.status(403).json({ fehler: 'Kein Zugriff auf diesen Zähler.' })
return null
}
return m
}
app.get('/api/meters/:id/readings', auth, async (req, res, next) => {
try {
const m = await meterFuer(req, res)
if (!m) return
const verlauf = await zaehlerVerlauf.verlaufLaden(m.id)
res.json({ readings: verlauf.map(zaehlerVerlauf.mapReading) })
} catch (e) { next(e) }
})
app.post('/api/meters/:id/readings', auth, async (req, res, next) => {
try {
const m = await meterFuer(req, res)
if (!m) return
const { stand, standAm } = req.body || {}
const standZahl = Number(stand)
if (!Number.isFinite(standZahl) || standZahl < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' })
const datum = String(standAm || '').trim() || deutschesDatum()
if (!zaehlerVerlauf.parseDatum(datum)) return res.status(400).json({ fehler: 'Ungültiges Ablesedatum.' })
const { eintrag, anomalie, grund } = await zaehlerVerlauf.standEintragen({
meter: m, stand: standZahl, standAm: datum,
erfasstVon: req.user.name, quelle: req.user.rolle === 'verwaltung' ? 'manuel' : 'mieter',
})
// meters.stand bleibt der aktuellste Wert (Cache für die Übersicht)
const { rows: aktualisiert } = await pool.query(
`UPDATE meters SET stand = $1, stand_am = $2, geaendert_am = $3 WHERE id = $4 RETURNING *`,
[standZahl, datum, deutschesDatumZeit(), m.id],
)
res.json({ eintrag, anomalie, grund, meter: mapMeter(aktualisiert[0]) })
} catch (e) { next(e) }
})
// Einzelnen Verlaufseintrag löschen (Korrektur falscher Erfassungen, Verwaltung)
app.delete('/api/meters/:id/readings/:rid', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query(
'DELETE FROM meter_readings WHERE id = $1 AND meter_id = $2 RETURNING *',
[req.params.rid, req.params.id],
)
if (!rows.length) return res.status(404).json({ fehler: 'Eintrag nicht gefunden.' })
// meters-Cache aus dem neuesten verbleibenden Eintrag neu aufbauen
const { rows: letzter } = await pool.query(
'SELECT stand, stand_am FROM meter_readings WHERE meter_id = $1 ORDER BY seq DESC LIMIT 1',
[req.params.id],
)
if (letzter.length) {
await pool.query(
'UPDATE meters SET stand = $1, stand_am = $2, geaendert_am = $3 WHERE id = $4',
[letzter[0].stand, letzter[0].stand_am, deutschesDatumZeit(), req.params.id],
)
}
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Dokumente (Verwaltung) ───────────────────────────────────
const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
@@ -1412,6 +1493,7 @@ async function start() {
if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)')
const zaehlerGeseedet = await seedeZaehler()
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
await zaehlerVerlauf.erstbefundFehlende()
const haeuserGeseedet = await seedeHaeuser()
if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt')
const kostenGeseedet = await seedeKosten()