v1.21: Live-Stammdaten aus Master-Data-CSV importiert, Sync verworfen, Demo-Reste entfernt

This commit is contained in:
2026-10-04 13:50:13 +02:00
parent 26372d563c
commit 497abfcd4b
13 changed files with 246 additions and 27 deletions
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Einmaliger Import der Mieter-Stammdaten aus der SharePoint-Export-CSV
(„Master Data V") in die PostgreSQL-Datenbank des Mieterportals.
Liest die CSV von stdin, schreibt SQL auf stdout:
python import-masterdata.py < \"Master Data V.csv\" | \\
docker exec -i mp-db psql -U mp -d mieterportal
ACHTUNG: Ersetzt die Demo-Daten (Häuser, Wohnungen, Mieter, Buchungen,
Meldungen, Unterhaltungen, Dokumente, Zähler, Kosten, Belege, Codes).
Verwaltungs-Konten, Ankündigungen und Sessions der Verwaltung bleiben.
IDs sind deterministisch (Slug aus Haus + WE) – das Skript ist damit
fehlerverträglich wiederholbar, solange die CSV unverändert bleibt.
Die CSV gehört NICHT ins Git (PII) – siehe .gitignore.
"""
import csv
import io
import re
import secrets
import sys
from datetime import date
HEUTE = f'{date.today().day}.{date.today().month}.{date.today().year}'
def q(wert):
"""SQL-Escaping für Textfelder."""
if wert is None:
return 'NULL'
return "'" + str(wert).replace("'", "''") + "'"
def slug(text):
s = str(text or '').strip().lower()
s = (s.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
s = re.sub(r'[^a-z0-9]+', '-', s).strip('-')
return s or 'x'
def zahl(wert):
"""Deutsche Zahlenformate: '2.700' -> 2700, '71,4' -> 71.4, '' -> 0"""
s = str(wert or '').strip()
if not s:
return 0.0
s = s.replace('.', '').replace(',', '.')
try:
return float(s)
except ValueError:
return 0.0
def jahr(wert):
"""'1.905' -> 1905, leer/0 -> None"""
s = str(wert or '').strip()
if not s:
return None
s = s.replace('.', '')
try:
j = int(s)
except ValueError:
return None
return j if 1500 < j < 2100 else None
def etage(lage):
s = (lage or '').strip().lower()
if s.startswith('eg') or 'hinterhof' in s:
return 0
m = re.search(r'(\d+)\.\s*og', s)
if m:
return int(m.group(1))
if s.startswith('dg'):
return 4
return 0
def wohnungsnummer(we):
"""'WE01' -> '1', 'Garage 1' bleibt, 'Zimmer 2' bleibt."""
s = str(we or '').strip()
m = re.match(r'^WE0*(\d+)$', s, re.IGNORECASE)
if m:
return str(int(m.group(1)))
return s
def main():
eingabe = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8-sig')
zeilen = list(csv.DictReader(eingabe))
if not zeilen:
sys.exit('CSV ist leer oder konnte nicht gelesen werden.')
sql = []
sql.append('BEGIN;')
# buildings: NOT NULL lockern – die CSV liefert nicht alle Pflichtfelder
for spalte in ('baujahr', 'stockwerke', 'etagenhoehe', 'wohneinheiten', 'quadratmeter'):
sql.append(f'ALTER TABLE buildings ALTER COLUMN {spalte} DROP NOT NULL;')
# ── Demo-Daten entfernen (Reihenfolge wegen Fremdschlüsseln) ──
sql += [
'DELETE FROM threads;',
'DELETE FROM tickets;',
'DELETE FROM transactions;',
'DELETE FROM costs;',
'DELETE FROM belege;',
'DELETE FROM documents;',
'DELETE FROM meters;',
'DELETE FROM registration_codes;',
"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE rolle = 'mieter');",
"DELETE FROM users WHERE rolle = 'mieter';",
'DELETE FROM units;',
'DELETE FROM buildings;',
]
# ── Häuser ──
haeuser = []
for z in zeilen:
t = z['Titel'].strip()
if t and t not in haeuser:
haeuser.append(t)
for t in haeuser:
zeilen_haus = [z for z in zeilen if z['Titel'].strip() == t]
we_zeilen = [z for z in zeilen_haus if z['WE'].strip()]
strasse = zeilen_haus[0]['Straße'].strip()
plz = zeilen_haus[0]['PLZ'].strip().replace('.', '')
stadt = zeilen_haus[0]['Stadt'].strip()
adresse = ', '.join(x for x in (strasse, f'{plz} {stadt}'.strip()) if x)
bj = jahr(zeilen_haus[0]['Baujahr'])
qm = sum(zahl(z['m²']) for z in we_zeilen) or None
wohnungen = len(we_zeilen)
sql.append(
'INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)'
f' VALUES ({q(t)}, {q(adresse)}, {bj if bj else "NULL"}, NULL, NULL, {wohnungen}, {qm if qm else "NULL"});',
)
# ── Wohneinheiten + Mieter ──
warnungen = []
for z in zeilen:
we = z['WE'].strip()
if not we:
continue
titel = z['Titel'].strip()
einheit_id = f"{slug(titel)}-{slug(we)}"
nummer = wohnungsnummer(we)
mieter = z['Name des Mieters'].strip()
kalt = zahl(z['Nettokaltmiete'])
warm = kalt + zahl(z['Nebenkosten'])
sql.append(
'INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status)'
f' VALUES ({q(einheit_id)}, {q(titel)}, {q(nummer)}, {etage(z["Lage im Objekt"])},'
f' {zahl(z["m²"])}, {kalt}, {warm}, {q(mieter) if mieter else "NULL"},'
f" '{('vermietet' if mieter else 'frei')}');",
)
if mieter:
nr = z['Nr. Mietpartei'].strip() or slug(we)
benutzername = f"{slug(titel)}-{nr}"
# Zufälliger unbekannter Hash -> Mieter kann sich erst nach
# Passwort-Reset durch die Verwaltung (Button in /benutzer) anmelden.
passwort_hash = secrets.token_hex(32)
sql.append(
'INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am)'
f" VALUES ({q('b-' + einheit_id)}, {q(benutzername)}, {q(mieter)}, {q(passwort_hash)},"
f" 'mieter', '', {q(einheit_id)}, TRUE, {q(HEUTE)});",
)
# Zähler werden NICHT importiert: die CSV-Zählerspalten sind leer/'0'
if z['Stromzähler'].strip() not in ('', '0'):
warnungen.append(f"Stromzähler in {titel}/{we}: {z['Stromzähler'].strip()} (nicht importiert)")
sql.append('COMMIT;')
sys.stdout.write('\n'.join(sql) + '\n')
print(f'-- {len(haeuser)} Häuser, '
f"{sum(1 for z in zeilen if z['WE'].strip())} Wohneinheiten, "
f"{sum(1 for z in zeilen if z['Name des Mieters'].strip())} Mieter", file=sys.stderr)
for w in warnungen:
print('-- HINWEIS: ' + w, file=sys.stderr)
if __name__ == '__main__':
main()
+11 -3
View File
@@ -107,12 +107,20 @@ async function seed() {
module.exports = { seed }
/** Der Demo-Bestand (Einheit hs-7) muss existieren, sonst gehören die
* Demo-Nachseeds (Zähler/Häuser/Kosten) nicht in diese Datenbank – z. B. nach
* dem Live-Import (v1.21) darf nichts mehr nachgeseedet werden. */
async function demoBestandVorhanden() {
const { rows } = await pool.query(`SELECT 1 FROM units WHERE id = 'hs-7'`)
return rows.length > 0
}
/** Zähler-Demo-Daten: idempotent – läuft bei jedem Start und füllt die
* Zähler-Tabelle nur, wenn sie leer ist (auch nachträglich auf bestehenden
* Datenbanken, z. B. nach dem v1.9-Update). */
async function seedeZaehler() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM meters')
if (rows[0].n > 0) return false
if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false
for (const z of meters) {
await pool.query(
`INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am)
@@ -129,7 +137,7 @@ module.exports = { seed, seedeZaehler }
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.10). */
async function seedeHaeuser() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM buildings')
if (rows[0].n > 0) return false
if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false
for (const h of buildings) {
await pool.query(
`INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)
@@ -146,7 +154,7 @@ module.exports = { seed, seedeZaehler, seedeHaeuser }
* 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
if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false
for (const k of kosten) {
await pool.query(
`INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am)