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
+17
View File
@@ -138,6 +138,23 @@ CREATE TABLE IF NOT EXISTS meters (
);
CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id);
-- Zählerverlauf (v1.25): jeder abgelesene Stand bleibt als Listeneintrag
-- bestehen; meters.stand ist jeweils der aktuellste Wert (Cache).
CREATE TABLE IF NOT EXISTS meter_readings (
id TEXT PRIMARY KEY,
seq BIGSERIAL, -- Einfügereihenfolge (verlässliche Sortierung)
meter_id TEXT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
stand NUMERIC(12,3) NOT NULL,
stand_am TEXT NOT NULL, -- ISO yyyy-mm-dd oder de-DE
erfasst_von TEXT NOT NULL DEFAULT '', -- Name des erfassenden Nutzers
quelle TEXT NOT NULL DEFAULT 'manuel',
CHECK (quelle IN ('erstbefund','manuel','mieter')),
anomalie BOOLEAN NOT NULL DEFAULT FALSE,
anomalie_grund TEXT, -- gesetzt bei Auffälligkeit
erstellt_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meter_readings_meter ON meter_readings(meter_id);
CREATE TABLE IF NOT EXISTS buildings (
name TEXT PRIMARY KEY, -- eindeutiger Hausname (z. B. "Hauptstraße 12")
adresse TEXT NOT NULL,
+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()
+174
View File
@@ -0,0 +1,174 @@
/** Zählerverlauf + Anomalitätserkennung (v1.25).
*
* Jeder erfasste Zählerstand wird in meter_readings als eigener Eintrag
* abgelegt; meters.stand bleibt der aktuellste Wert (Cache für die Übersicht).
*
* Anomalitäten (je Eintrag in anomalie_grund vermerkt, Verwaltung per E-Mail
* benachrichtigt):
* - Ruecklauf neuer Stand liegt unter dem letzten Stand
* - Datum in Zukunft Ablesedatum liegt nach heute
* - Rueckdatiert Ablesedatum liegt vor dem vorherigen Eintrag
* - Stillstand gleicher Stand, ≥ 60 Tage seit dem Vorgaenger
* - Verbrauchsspitze Tagesverbrauch > 4× dem bisherigen Median
* (nur bei ≥ 2 vergangenen Intervallen, Mindestdelta 10)
*/
const mail = require('./mail')
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
/** Parst 'dd.mm.yyyy' oder 'yyyy-mm-dd' zu Date (UTC-Mittag, robust). */
function parseDatum(s) {
if (!s) return null
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s).trim())
if (iso) return new Date(Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12))
const de = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(s).trim())
if (de) return new Date(Date.UTC(Number(de[3]), Number(de[2]) - 1, Number(de[1]), 12))
const fallback = new Date(s)
return Number.isNaN(fallback.getTime()) ? null : fallback
}
const tageZwischen = (a, b) => Math.round((b - a) / 86400000)
const runde = (n, stellen = 1) => Math.round(n * 10 ** stellen) / 10 ** stellen
/** Median eines numerischen Arrays (leer → null). */
function median(werte) {
if (!werte.length) return null
const s = [...werte].sort((a, b) => a - b)
const mitte = Math.floor(s.length / 2)
return s.length % 2 ? s[mitte] : (s[mitte - 1] + s[mitte]) / 2
}
/** Wertet einen neuen Stand gegen den bisherigen Verlauf aus. */
function pruefeAnomalie(verlauf, neuerStand, standAm) {
// Verlauf: [{stand, stand_am}] – älteste zuerst, ohne den neuen Wert.
if (!verlauf.length) return { anomalie: false, grund: null }
const letzter = verlauf[verlauf.length - 1]
const neuerTag = parseDatum(standAm)
const letzterTag = parseDatum(letzter.stand_am)
const heute = new Date()
const heuteUtc = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate(), 12))
if (neuerTag) {
if (neuerTag > heuteUtc) {
return { anomalie: true, grund: 'Ablesedatum liegt in der Zukunft' }
}
if (letzterTag && neuerTag < letzterTag) {
return { anomalie: true, grund: `Ablesedatum (${standAm}) liegt vor dem vorherigen Eintrag (${letzter.stand_am})` }
}
}
const delta = neuerStand - Number(letzter.stand)
if (delta < 0) {
return {
anomalie: true,
grund: `Rückläufiger Stand: ${runde(neuerStand)} liegt um ${runde(-delta)} unter dem letzten Stand ${runde(Number(letzter.stand))}`,
}
}
const tage = neuerTag && letzterTag ? Math.max(1, tageZwischen(letzterTag, neuerTag)) : null
if (delta === 0 && tage !== null && tage >= 60) {
return { anomalie: true, grund: `Kein Verbrauch seit ${tage} Tagen (Stand unverändert: ${runde(neuerStand)})` }
}
// Verbrauchsspitze: Tagesraten der bisherigen Intervalle, dann Median.
const raten = []
for (let i = 1; i < verlauf.length; i++) {
const von = parseDatum(verlauf[i - 1].stand_am)
const bis = parseDatum(verlauf[i].stand_am)
const t = von && bis ? tageZwischen(von, bis) : 0
const d = Number(verlauf[i].stand) - Number(verlauf[i - 1].stand)
if (t > 0 && d > 0) raten.push(d / t)
}
const med = median(raten)
if (med !== null && tage !== null && delta >= 10) {
const rate = delta / tage
if (rate > 4 * med) {
return {
anomalie: true,
grund: `Auffällig hoher Verbrauch: ca. ${runde(rate)} Einheiten/Tag, üblich sind ca. ${runde(med)} (4-facher Mittelwert überschritten)`,
}
}
}
return { anomalie: false, grund: null }
}
/** Lädt den Verlauf eines Zählers (älteste zuerst). */
async function verlaufLaden(meterId) {
const { rows } = await require('./db').pool.query(
'SELECT * FROM meter_readings WHERE meter_id = $1 ORDER BY seq',
[meterId],
)
return rows
}
const mapReading = (r) => ({
id: r.id,
stand: Number(r.stand),
standAm: r.stand_am,
erfasstVon: r.erfasst_von,
quelle: r.quelle,
anomalie: r.anomalie,
anomalieGrund: r.anomalie_grund ?? null,
erstelltAm: r.erstellt_am,
})
/** Trägt einen Stand in den Verlauf ein, prüft auf Anomalien und
* benachrichtigt bei Auffälligkeit die Verwaltung per E-Mail.
* Gibt { eintrag, anomalie, grund } zurück. */
async function standEintragen({ meter, stand, standAm, erfasstVon, quelle }) {
const pool = require('./db').pool
const verlauf = await verlaufLaden(meter.id)
const { anomalie, grund } = pruefeAnomalie(verlauf, stand, standAm)
const { rows } = await pool.query(
`INSERT INTO meter_readings (id, meter_id, stand, stand_am, erfasst_von, quelle, anomalie, anomalie_grund, erstellt_am)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[`zr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, meter.id, stand, standAm,
erfasstVon, quelle, anomalie, grund, deutschesDatumZeit()],
)
if (anomalie) {
const html = mail.baueHtmlMail({
titel: 'Auffälligkeit im Zählerverlauf',
untertitel: 'Automatische Anomalitätserkennung',
einleitung: `Der Zähler ${meter.nummer} (${meter.art}) hat einen auffälligen Verlaufseintrag erhalten. Bitte prüfen Sie den erfassten Stand.`,
statusBox: { text: grund, hintergrund: '#fef2f2', farbe: '#b91c1c' },
zeilen: [
['Zähler', `${meter.nummer} (${meter.art})`],
['Haus', meter.building],
['Neuer Stand', String(stand)],
['Ablesedatum', standAm],
['Erfasst von', erfasstVon],
],
aktionenHtml: mail.buttonHtml('Zum Zähler-Modul', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/zaehler`),
})
const text =
`Auffälligkeit beim Zähler ${meter.nummer} (${meter.art}), Haus ${meter.building}:\n` +
`${grund}\n\nNeuer Stand: ${stand} (${standAm}), erfasst von ${erfasstVon}.\n` +
`${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/zaehler`
mail.benachrichtigeVerwaltung(`Zähler ${meter.nummer}: ${grund}`, text, html)
.catch(() => {})
}
return { eintrag: mapReading(rows[0]), anomalie, grund }
}
/** Einmal-Rückbau: Bestehende Zähler ohne Verlauf erhalten einen Erstbefund. */
async function erstbefundFehlende() {
const pool = require('./db').pool
const { rows } = await pool.query(
`SELECT m.* FROM meters m
WHERE NOT EXISTS (SELECT 1 FROM meter_readings r WHERE r.meter_id = m.id)`,
)
for (const m of rows) {
await pool.query(
`INSERT INTO meter_readings (id, meter_id, stand, stand_am, erfasst_von, quelle, erstellt_am)
VALUES ($1,$2,$3,$4,$5,'erstbefund',$6)`,
[`zr-init-${m.id}`, m.id, m.stand, m.stand_am, 'System (Übernahme)', m.geaendert_am],
)
}
if (rows.length) console.log(`[api] Zählerverlauf: ${rows.length} Erstbefund(e) ergänzt`)
}
module.exports = { pruefeAnomalie, verlaufLaden, standEintragen, erstbefundFehlende, mapReading, parseDatum }