diff --git a/.gitignore b/.gitignore index 98a2d85..6807887 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ tmp-* mp-dist.tar.gz shots/ *.bak-v* +# Live-Stammdaten (PII) – nur lokal, nicht ins Git +Master Data V.csv +import-*.sql .vite _ref/ diff --git a/kimi.md b/kimi.md index 45098dc..0196b17 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,34 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.21):** Live-Stammdaten aus SharePoint-Export-CSV +importiert (KEIN Dauersync – Nutzer-Entscheidung: einmaliger Import, +Pflege danach nur noch im Portal). CSV „Master Data V" (53 Zeilen, +37 Spalten, 12 Häuser in Krefeld, 46 Wohneinheiten inkl. Garagen/Zimmer, +43 Mietparteien) liegt NUR lokal im Workspace (PII, in .gitignore). +Import-Skript server/import/import-masterdata.py (stdin CSV → stdout SQL, +deterministische IDs slug(haus)-slug(we), deutsche Zahlenformate +'2.700'→2700 / '71,4'→71.4 / Baujahr '1.905'→1905, PLZ '47.829'→47829, +Etage aus 'Lage im Objekt' EG/1. OG/DG, WE01→'1'). buildings-NOT-NULL +(baujahr/stockwerke/etagenhoehe/wohneinheiten/quadratmeter) per ALTER +gelockert. Import ersetzt komplett: threads/tickets/transactions/costs/ +belege/documents/meters/codes + Mieter-User + units + buildings; +Verwaltungs-User, Ankündigungen bleiben. Mieter bekommen Zufalls-Passwort- +Hash (unloggbar bis Passwort-Reset auf 0815 durch Verwaltung in /benutzer), +email='' (Mailversand überspringt leere Adressen, v1.20-Profil zum +Nachpflegen). Zähler NICHT importiert (CSV-Spalten nur '0'). +Sync-Infrastruktur (v1.21-Vorentwicklung) komplett entfernt: mp-sync-cron +Container gelöscht, portal.env von SP_*/INTERN_SYNC_TOKEN bereinigt, +Code aus Arbeitsbaum entfernt (nie gepusht). WICHTIG seed.js-Fix: +Demo-Nachseeds (seedeZaehler/Haeuser/Kosten) prüften nur Tabellen-Leere +→ FK-Crashloop nach Import (hs-7 fehlt). Fix: demoBestandVorhanden() +gate auf units.id='hs-7'. Frontend: hartkodierte Demo-Zahlen entfernt – +AppLayout-Footer + Dashboard (Bestandszählung, Belegungsquote) jetzt +dynamisch aus buildings/units, Hauslisten in Payments/Zaehler/Benutzer- +Verwaltung dynamisch, Login-Demo-Zugang a.berger entfernt (existiert +nicht mehr), DEMO_UNIT_ID='' (Fallback = erste Einheit). E2E: Bootstrap +12 Häuser/46 Einheiten/43 Mieter, API healthy. + **Erledigt 04.10. (v1.20):** Stammdaten-Selbstpflege. users-Tabelle um telefon/kontoinhaber/iban/bankname erweitert (ALTER ... ADD COLUMN IF NOT EXISTS). PATCH /api/auth/profil (auth, eigene Daten): E-Mail (Pflicht + diff --git a/server/import/import-masterdata.py b/server/import/import-masterdata.py new file mode 100644 index 0000000..19f12fd --- /dev/null +++ b/server/import/import-masterdata.py @@ -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() diff --git a/server/src/seed.js b/server/src/seed.js index 7726200..eab164b 100644 --- a/server/src/seed.js +++ b/server/src/seed.js @@ -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) diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index ff596e4..75d7fd4 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -28,7 +28,7 @@ const navItems = [ ] export default function AppLayout() { - const { role, me, logout, threads } = useStore() + const { role, me, logout, threads, buildings, units } = useStore() const navigate = useNavigate() const unread = threads.filter((t) => role === 'tenant' ? t.unreadByTenant : t.unreadByAdmin, @@ -96,7 +96,7 @@ export default function AppLayout() {
Forderungsmanagement für 50 Wohnungen
+Forderungsmanagement für den gesamten Bestand