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
+3
View File
@@ -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/
+28
View File
@@ -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 +
+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)
+2 -2
View File
@@ -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() {
<div className="border-t border-white/10 p-4">
<div className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-slate-300/70">
<Building2 className="h-3.5 w-3.5 shrink-0" />
<span>3 Häuser · 50 Wohnungen</span>
<span>{buildings.length} {buildings.length === 1 ? 'Haus' : 'Häuser'} · {units.length} {units.length === 1 ? 'Wohnung' : 'Wohnungen'}</span>
</div>
<div className="flex items-center gap-2">
<button
+1 -1
View File
@@ -37,7 +37,7 @@ function NeuerBenutzerDialog() {
const [email, setEmail] = useState('')
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
const [pw, setPw] = useState('0815')
const [einheitId, setEinheitId] = useState('hs-7')
const [einheitId, setEinheitId] = useState(units[0]?.id ?? '')
const [fehler, setFehler] = useState<string | null>(null)
const submit = async () => {
+7 -7
View File
@@ -215,7 +215,7 @@ function TenantDashboard() {
}
function AdminDashboard() {
const { units, transactions, tickets, announcements } = useStore()
const { units, transactions, tickets, announcements, buildings: gebaeude } = useStore()
const rented = units.filter((u) => u.status === 'vermietet')
const occupancy = Math.round((rented.length / units.length) * 100)
const monthlyIncome = rented.reduce((s, u) => s + u.warmRent, 0)
@@ -229,10 +229,10 @@ function AdminDashboard() {
.reduce((s, t) => s + t.amount, 0),
}))
const buildings = ['Hauptstraße 12', 'Ringweg 3', 'Am Park 5'].map((b) => {
const us = units.filter((u) => u.building === b)
const buildings = gebaeude.map((g) => {
const us = units.filter((u) => u.building === g.name)
return {
name: b,
name: g.name,
Vermietet: us.filter((u) => u.status === 'vermietet').length,
Frei: us.filter((u) => u.status === 'frei').length,
}
@@ -243,13 +243,13 @@ function AdminDashboard() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Verwaltungs-Dashboard</h1>
<p className="text-sm text-slate-500">
Bestand: 3 Häuser · 50 Wohnungen · {rented.length} vermietet
Bestand: {buildings.length} {buildings.length === 1 ? 'Haus' : 'Häuser'} · {units.length} Wohneinheiten · {rented.length} vermietet
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Building2} label="Wohnungen gesamt" value="50" sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} />
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von 50 Wohnungen`} />
<StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} />
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von ${units.length} Wohneinheiten`} />
<StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" />
<StatCard
icon={CircleAlert}
+1 -1
View File
@@ -137,7 +137,7 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
<Label htmlFor="d-titel">Titel</Label>
<Input
id="d-titel"
placeholder="z. B. Mietvertrag – Hauptstraße 12, Whg. 7"
placeholder="z. B. Mietvertrag – Blücherstr. 59, Whg. 1"
value={titel}
onChange={(e) => setTitel(e.target.value)}
/>
+1 -2
View File
@@ -17,7 +17,6 @@ const features = [
const demoZugaenge = [
{ benutzername: 'j.hinterstein', label: 'Joseph Hinterstein (Verwaltung)' },
{ benutzername: 'a.berger', label: 'Anna Berger (Mieterin)' },
]
export default function Login() {
@@ -84,7 +83,7 @@ export default function Login() {
<div className="absolute inset-0 bg-gradient-to-t from-[#22333b]/85 via-transparent to-transparent" />
<div className="absolute bottom-0 p-6 text-white">
<div className="text-lg font-semibold">Ihr Zuhause. Ihr Portal.</div>
<div className="text-sm text-slate-200">3 Wohnanlagen · 50 Wohnungen · persönliche Verwaltung</div>
<div className="text-sm text-slate-200">Ihr Bestand. Ihre Mieter. Ein Portal.</div>
</div>
</div>
+1 -1
View File
@@ -112,7 +112,7 @@ export default function Messages() {
<p className="text-sm text-slate-500">
{role === 'tenant'
? 'Direktdraht zur Verwaltung – Antwort meist innerhalb eines Werktags'
: 'Mieter-Kommunikation über alle 50 Wohnungen'}
: 'Mieter-Kommunikation über den gesamten Bestand'}
</p>
</div>
<NewMessageDialog onCreated={setActiveId} />
+5 -5
View File
@@ -138,7 +138,7 @@ function TenantPayments() {
}
function AdminPayments() {
const { units, transactions } = useStore()
const { units, transactions, buildings } = useStore()
const [building, setBuilding] = useState('alle')
const list = units
@@ -161,7 +161,7 @@ function AdminPayments() {
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mietkonto & Zahlungen</h1>
<p className="text-sm text-slate-500">Forderungsmanagement für 50 Wohnungen</p>
<p className="text-sm text-slate-500">Forderungsmanagement für den gesamten Bestand</p>
</div>
<Select value={building} onValueChange={setBuilding}>
<SelectTrigger className="w-56">
@@ -169,9 +169,9 @@ function AdminPayments() {
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Häuser</SelectItem>
<SelectItem value="Hauptstraße 12">Hauptstraße 12</SelectItem>
<SelectItem value="Ringweg 3">Ringweg 3</SelectItem>
<SelectItem value="Am Park 5">Am Park 5</SelectItem>
{buildings.map((b) => (
<SelectItem key={b.name} value={b.name}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
+2 -2
View File
@@ -46,14 +46,14 @@ const fmtStand = (n: number) =>
n.toLocaleString('de-DE', { maximumFractionDigits: 3 })
function ZaehlerDialog({ meter }: { meter?: Meter }) {
const { addZaehler, updateZaehler, units } = useStore()
const { addZaehler, updateZaehler, units, buildings } = useStore()
const bearbeiten = Boolean(meter)
const [open, setOpen] = useState(false)
const [nummer, setNummer] = useState(meter?.nummer ?? '')
const [art, setArt] = useState<ZaehlerArt>(meter?.art ?? 'Strom')
const [stand, setStand] = useState(String(meter?.stand ?? '').replace('.', ','))
const [unitId, setUnitId] = useState<string>(meter?.unitId ?? 'haus')
const [building, setBuilding] = useState(meter?.building ?? 'Hauptstraße 12')
const [building, setBuilding] = useState(meter?.building ?? buildings[0]?.name ?? '')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
+3 -3
View File
@@ -38,9 +38,9 @@ import type {
import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
export const DEMO_TENANT = 'Anna Berger'
export const DEMO_UNIT_ID = 'hs-7'
/** Fallback: Mieter ohne Wohnungszuweisung sehen die erste Einheit des Bestands. */
export const DEMO_TENANT = ''
export const DEMO_UNIT_ID = ''
interface Bootstrap {
units: Unit[]