v1.36: Mieterverwaltung (Mieter 1:1 pro Wohnung, Kaution/Vertrag/Dokumente) + v1.37: KI-Assistent (N8N + Kimi, 6 Portal-Tools)
This commit is contained in:
@@ -66,19 +66,27 @@ async function abrechnungsDaten(pool, unitId, jahr) {
|
||||
}
|
||||
|
||||
const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0)
|
||||
const vorauszahlung = (Number(einheit.warm_rent) - Number(einheit.cold_rent)) * 12
|
||||
// Vorauszahlung aus dem Mieterobjekt (seit v1.36, vorher am Wohnungsobjekt)
|
||||
const { rows: mieterRows } = await pool.query(
|
||||
'SELECT name, vorname, kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [einheit.id],
|
||||
)
|
||||
const nkVorauszahlung = mieterRows.length
|
||||
? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete)
|
||||
: 0
|
||||
const mieterName = mieterRows.length ? `${mieterRows[0].vorname} ${mieterRows[0].name}`.trim() : ''
|
||||
const vorauszahlung = nkVorauszahlung * 12
|
||||
const differenz = summe - vorauszahlung
|
||||
|
||||
const { rows: zaehler } = await pool.query(
|
||||
`SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`,
|
||||
[einheit.id],
|
||||
)
|
||||
return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler }
|
||||
return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName }
|
||||
}
|
||||
|
||||
/** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */
|
||||
async function bauePdf(daten) {
|
||||
const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler } = daten
|
||||
const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName } = daten
|
||||
const dateiName = `ab-${einheit.id}-${jahr}.pdf`
|
||||
const vollerPfad = path.join(UPLOAD_DIR, dateiName)
|
||||
|
||||
@@ -111,7 +119,7 @@ async function bauePdf(daten) {
|
||||
y += 16
|
||||
doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y)
|
||||
y += 16
|
||||
doc.text(`Mieter: ${einheit.tenant || '–'}`, 50, y)
|
||||
doc.text(`Mieter: ${mieterName || '–'}`, 50, y)
|
||||
y += 16
|
||||
doc.text(`Erstellt am ${dDatum()}`, 50, y)
|
||||
y += 26
|
||||
@@ -152,7 +160,7 @@ async function bauePdf(daten) {
|
||||
y += 17
|
||||
}
|
||||
zeile('Summe umlagefähige Kosten', eur(summe), true)
|
||||
zeile(`Nebenkostenvorauszahlung (${eur(Number(einheit.warm_rent) - Number(einheit.cold_rent))}/Monat × 12)`, '- ' + eur(vorauszahlung))
|
||||
zeile(`Nebenkostenvorauszahlung (${eur(nkVorauszahlung)}/Monat × 12)`, '- ' + eur(vorauszahlung))
|
||||
y += 4
|
||||
doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
|
||||
const nachzahlung = differenz > 0
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/** KI-Assistent (v1.37): Verwaltung fragt im Portal, N8N + Kimi antworten.
|
||||
*
|
||||
* Ablauf:
|
||||
* Chat-UI → POST /api/assist (Session, adminOnly)
|
||||
* → Portal leitet Frage + Verlauf an den N8N-Webhook weiter
|
||||
* → N8N (AI-Agent) ruft die Tool-Endpunkte dieses Moduls ab
|
||||
* → Antwort zurück an das Portal → Chat-UI
|
||||
*
|
||||
* Umgebungsvariablen:
|
||||
* N8N_ASSIST_WEBHOOK Webhook-URL des N8N-Assist-Workflows (Portal → N8N)
|
||||
* ASSIST_SHARED_SECRET gemeinsames Geheimnis (Header x-assist-secret, Portal → N8N)
|
||||
* ASSIST_API_KEY gemeinsames Geheimnis (Header x-assist-key, N8N → Portal-Tools)
|
||||
*/
|
||||
|
||||
const { pool } = require('./db')
|
||||
|
||||
const N8N_ASSIST_WEBHOOK = process.env.N8N_ASSIST_WEBHOOK || ''
|
||||
const ASSIST_SHARED_SECRET = process.env.ASSIST_SHARED_SECRET || ''
|
||||
const ASSIST_API_KEY = process.env.ASSIST_API_KEY || ''
|
||||
|
||||
/** Holt die aktuellsten Einträge je Zähler (DISTINCT ON, seq absteigend). */
|
||||
async function letzteEintraege(onlyAnomalien) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT ON (r.meter_id) r.meter_id, r.stand, r.stand_am, r.anomalie, r.anomalie_grund
|
||||
FROM meter_readings r ORDER BY r.meter_id, r.seq DESC`,
|
||||
)
|
||||
return onlyAnomalien ? rows.filter((r) => r.anomalie) : rows
|
||||
}
|
||||
|
||||
/** Registriert alle Assist-Routen auf der Express-App. */
|
||||
function assistRoutenRegistrieren(app, { auth, adminOnly }) {
|
||||
// Middleware für die Tool-Endpunkte: N8N authentifiziert sich per Header.
|
||||
const assistKey = (req, res, next) => {
|
||||
if (!ASSIST_API_KEY) {
|
||||
return res.status(503).json({ fehler: 'Assistent nicht konfiguriert (ASSIST_API_KEY fehlt).' })
|
||||
}
|
||||
if (req.headers['x-assist-key'] !== ASSIST_API_KEY) {
|
||||
return res.status(401).json({ fehler: 'Ungültiger Assistent-Schlüssel.' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Chat: Frage der Verwaltung an N8N weiterleiten ──────────
|
||||
app.post('/api/assist', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
if (!N8N_ASSIST_WEBHOOK) {
|
||||
return res.status(503).json({
|
||||
fehler: 'Der KI-Assistent ist noch nicht konfiguriert (N8N_ASSIST_WEBHOOK fehlt).',
|
||||
})
|
||||
}
|
||||
const frage = String(req.body?.frage || '').trim()
|
||||
if (!frage) return res.status(400).json({ fehler: 'Frage fehlt.' })
|
||||
const verlauf = Array.isArray(req.body?.verlauf)
|
||||
? req.body.verlauf
|
||||
.filter((m) => m && ['user', 'assistant'].includes(m.rolle) && String(m.text || '').trim())
|
||||
.slice(-20)
|
||||
.map((m) => ({ rolle: m.rolle, text: String(m.text).slice(0, 4000) }))
|
||||
: []
|
||||
const antwort = await fetch(N8N_ASSIST_WEBHOOK, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...(ASSIST_SHARED_SECRET ? { 'x-assist-secret': ASSIST_SHARED_SECRET } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
frage,
|
||||
verlauf,
|
||||
benutzer: req.user.name,
|
||||
portal: process.env.PORTAL_URL || 'https://mieterportal.hocks.eu',
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
})
|
||||
if (!antwort.ok) {
|
||||
return res.status(502).json({ fehler: `Assistent antwortet nicht (N8N Status ${antwort.status}).` })
|
||||
}
|
||||
const inhaltTyp = antwort.headers.get('content-type') || ''
|
||||
const daten = inhaltTyp.includes('application/json') ? await antwort.json() : {}
|
||||
const text = String(daten.antwort ?? '').trim()
|
||||
if (!text) return res.status(502).json({ fehler: 'Assistent hat keine Antwort geliefert.' })
|
||||
res.json({ antwort: text })
|
||||
} catch (e) {
|
||||
if (e.name === 'TimeoutError' || e.name === 'AbortError') {
|
||||
return res.status(504).json({ fehler: 'Assistent braucht zu lange – bitte erneut versuchen.' })
|
||||
}
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Tools: kompakte JSON-Antworten für den N8N-AI-Agent ─────
|
||||
// Alle Endpunkte liefern bewusst wenige, flache Felder (Token sparen).
|
||||
|
||||
/** Leerstehende / unklare Wohneinheiten. */
|
||||
app.get('/api/assist/tools/leerstaende', assistKey, async (_req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.building, u.number, u.floor, u.size, u.status
|
||||
FROM units u
|
||||
WHERE u.status IN ('leerstand', 'unklar')
|
||||
ORDER BY u.building, u.floor, u.number`,
|
||||
)
|
||||
res.json({ anzahl: rows.length, einheiten: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
/** Monatliches Miet-Soll (Warmmieten aller Mieter), optional je Haus. */
|
||||
app.get('/api/assist/tools/mieteinnahmen', assistKey, async (req, res, next) => {
|
||||
try {
|
||||
const haus = String(req.query.haus || '').trim()
|
||||
const params = []
|
||||
let bedingung = ''
|
||||
if (haus) { params.push(`%${haus}%`); bedingung = `WHERE u.building ILIKE $1` }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.building, COUNT(*)::int AS mieter, SUM(m.warmmiete)::numeric(12,2) AS warmmiete_monat,
|
||||
SUM(m.kaltmiete)::numeric(12,2) AS kaltmiete_monat
|
||||
FROM mieter m JOIN units u ON u.id = m.unit_id
|
||||
${bedingung}
|
||||
GROUP BY u.building ORDER BY u.building`,
|
||||
params,
|
||||
)
|
||||
res.json({
|
||||
gesamt_warm_monat: rows.reduce((s, r) => s + Number(r.warmmiete_monat), 0).toFixed(2),
|
||||
gesamt_kalt_monat: rows.reduce((s, r) => s + Number(r.kaltmiete_monat), 0).toFixed(2),
|
||||
pro_haus: rows.map((r) => ({
|
||||
haus: r.building, mieter: r.mieter,
|
||||
warm_monat: Number(r.warmmiete_monat), kalt_monat: Number(r.kaltmiete_monat),
|
||||
})),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
/** Offene Schadensmeldungen. */
|
||||
app.get('/api/assist/tools/offene-schaeden', assistKey, async (_req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.id, t.title, t.category, t.priority, t.status, t.created_at, t.tenant, u.building, u.number
|
||||
FROM tickets t LEFT JOIN units u ON u.id = t.unit_id
|
||||
WHERE t.status <> 'erledigt'
|
||||
ORDER BY CASE t.priority WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, t.created_at`,
|
||||
)
|
||||
res.json({ anzahl: rows.length, schaeden: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
/** Mieterkontakt nach Name oder Wohnung (personenbezogene Daten – bewusst begrenzt). */
|
||||
app.get('/api/assist/tools/mieter-kontakt', assistKey, async (req, res, next) => {
|
||||
try {
|
||||
const name = String(req.query.name || '').trim()
|
||||
const wohnung = String(req.query.wohnung || '').trim()
|
||||
const params = []
|
||||
const teile = []
|
||||
if (name) { params.push(`%${name}%`); teile.push(`(m.name ILIKE $${params.length} OR m.vorname ILIKE $${params.length})`) }
|
||||
if (wohnung) { params.push(`%${wohnung}%`); teile.push(`(u.building ILIKE $${params.length} OR u.number ILIKE $${params.length})`) }
|
||||
if (!teile.length) return res.status(400).json({ fehler: 'Parameter "name" oder "wohnung" erforderlich.' })
|
||||
const { rows } = await pool.query(
|
||||
`SELECT m.vorname, m.name, m.telefon, m.email, u.building, u.number, m.vertragsstatus
|
||||
FROM mieter m JOIN units u ON u.id = m.unit_id
|
||||
WHERE ${teile.join(' AND ')}
|
||||
ORDER BY m.name LIMIT 10`,
|
||||
params,
|
||||
)
|
||||
res.json({ anzahl: rows.length, mieter: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
/** Zähler mit Anomalie im jeweils neuesten Verlaufseintrag. */
|
||||
app.get('/api/assist/tools/zaehler-anomalien', assistKey, async (_req, res, next) => {
|
||||
try {
|
||||
const anomalien = await letzteEintraege(true)
|
||||
if (!anomalien.length) return res.json({ anzahl: 0, anomalien: [] })
|
||||
const ids = anomalien.map((r) => r.meter_id)
|
||||
const { rows: zaehler } = await pool.query(
|
||||
`SELECT id, building, nummer, art, unit_id FROM meters WHERE id = ANY($1)`, [ids],
|
||||
)
|
||||
const map = new Map(zaehler.map((z) => [z.id, z]))
|
||||
res.json({
|
||||
anzahl: anomalien.length,
|
||||
anomalien: anomalien
|
||||
.map((r) => {
|
||||
const z = map.get(r.meter_id)
|
||||
return z && {
|
||||
zaehlernummer: z.nummer, art: z.art, haus: z.building,
|
||||
stand: Number(r.stand), stand_am: r.stand_am, grund: r.anomalie_grund,
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
/** Kosten je Haus/Jahr, umlegbar vs. nicht umlegbar. */
|
||||
app.get('/api/assist/tools/kosten', assistKey, async (req, res, next) => {
|
||||
try {
|
||||
const jahr = Number(req.query.jahr || new Date().getFullYear())
|
||||
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 2100) {
|
||||
return res.status(400).json({ fehler: 'Parameter "jahr" ungültig.' })
|
||||
}
|
||||
const haus = String(req.query.haus || '').trim()
|
||||
const params = [jahr]
|
||||
let bedingung = 'WHERE c.jahr = $1'
|
||||
if (haus) { params.push(`%${haus}%`); bedingung += ` AND c.building ILIKE $${params.length}` }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.building, c.kostenart, c.umlegbar, SUM(c.betrag)::numeric(12,2) AS betrag
|
||||
FROM costs c ${bedingung}
|
||||
GROUP BY c.building, c.kostenart, c.umlegbar
|
||||
ORDER BY c.building, c.kostenart`,
|
||||
params,
|
||||
)
|
||||
const umlegbar = rows.filter((r) => r.umlegbar).reduce((s, r) => s + Number(r.betrag), 0)
|
||||
const nichtUmlegbar = rows.filter((r) => !r.umlegbar).reduce((s, r) => s + Number(r.betrag), 0)
|
||||
res.json({
|
||||
jahr,
|
||||
gesamt_umlegbar: umlegbar.toFixed(2),
|
||||
gesamt_nicht_umlegbar: nichtUmlegbar.toFixed(2),
|
||||
positionen: rows.map((r) => ({
|
||||
haus: r.building, kostenart: r.kostenart,
|
||||
umlegbar: r.umlegbar, betrag: Number(r.betrag),
|
||||
})),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { assistRoutenRegistrieren }
|
||||
+41
-4
@@ -19,11 +19,18 @@ CREATE TABLE IF NOT EXISTS units (
|
||||
number TEXT NOT NULL,
|
||||
floor INT NOT NULL,
|
||||
size NUMERIC(8,2) NOT NULL,
|
||||
cold_rent NUMERIC(10,2) NOT NULL,
|
||||
warm_rent NUMERIC(10,2) NOT NULL,
|
||||
tenant TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('vermietet','frei'))
|
||||
cold_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- Altbestand, v1.36 in Tabelle mieter ausgelagert
|
||||
warm_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- dito
|
||||
tenant TEXT, -- dito (Mietername, Altbestand)
|
||||
status TEXT NOT NULL CHECK (status IN ('vermietet','leerstand','unklar'))
|
||||
);
|
||||
ALTER TABLE units ALTER COLUMN cold_rent DROP NOT NULL;
|
||||
ALTER TABLE units ALTER COLUMN warm_rent DROP NOT NULL;
|
||||
-- v1.36: Statuswerte erweitert (frei → leerstand, zusätzlich unklar)
|
||||
ALTER TABLE units DROP CONSTRAINT IF EXISTS units_status_check;
|
||||
ALTER TABLE units ADD CONSTRAINT units_status_check
|
||||
CHECK (status IN ('vermietet','leerstand','unklar')) NOT VALID;
|
||||
UPDATE units SET status = 'leerstand' WHERE status = 'frei';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -256,6 +263,36 @@ CREATE TABLE IF NOT EXISTS mietvertraege (
|
||||
geaendert_am TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id);
|
||||
|
||||
-- Mieter (v1.36): 1:1 zum Wohnungsobjekt – Kalt-/Warmmiete, Kontakt, Vertrag,
|
||||
-- Kaution und Mietzeitraum leben hier, nicht mehr am Wohnungsobjekt.
|
||||
CREATE TABLE IF NOT EXISTS mieter (
|
||||
id TEXT PRIMARY KEY,
|
||||
unit_id TEXT NOT NULL UNIQUE REFERENCES units(id),
|
||||
name TEXT NOT NULL,
|
||||
vorname TEXT NOT NULL DEFAULT '',
|
||||
telefon TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
kaltmiete NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
warmmiete NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
vertragsstatus TEXT NOT NULL DEFAULT 'aktiv'
|
||||
CHECK (vertragsstatus IN ('aktiv','befristet','gekuendigt')),
|
||||
gekuendigt_am TEXT NOT NULL DEFAULT '', -- „gekündigt zum"
|
||||
gekuendigt_von TEXT NOT NULL DEFAULT '' -- „gekündigt von": mieter | vermieter
|
||||
CHECK (gekuendigt_von IN ('','mieter','vermieter')),
|
||||
kaution_hoehe NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
kaution_art TEXT NOT NULL DEFAULT 'ueberwiesen'
|
||||
CHECK (kaution_art IN ('bar','ueberwiesen','sonstiges')),
|
||||
kaution_sonstiges TEXT NOT NULL DEFAULT '', -- Freitext bei „Andere"
|
||||
kautionszahlung TEXT NOT NULL DEFAULT 'komplett'
|
||||
CHECK (kautionszahlung IN ('komplett','raten')),
|
||||
kaution_raten INT NOT NULL DEFAULT 0, -- Anzahl der Raten
|
||||
mietbeginn_am TEXT NOT NULL DEFAULT '',
|
||||
mietende_am TEXT NOT NULL DEFAULT '',
|
||||
erstellt_am TEXT NOT NULL,
|
||||
geaendert_am TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mieter_unit ON mieter(unit_id);
|
||||
`
|
||||
|
||||
async function init() {
|
||||
|
||||
+268
-28
@@ -17,6 +17,7 @@ const mail = require('./mail')
|
||||
const { erzeugeAbrechnung } = require('./abrechnung')
|
||||
const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag')
|
||||
const zaehlerVerlauf = require('./zaehlerVerlauf')
|
||||
const { assistRoutenRegistrieren } = require('./assist')
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000)
|
||||
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
|
||||
@@ -154,10 +155,22 @@ async function sessionAnlegen(res, userId, req) {
|
||||
}
|
||||
|
||||
// ── Mapping-Helfer (snake_case → camelCase) ──────────────────
|
||||
const UNIT_STATUS = ['vermietet', 'leerstand', 'unklar']
|
||||
const mapUnit = (u) => ({
|
||||
id: u.id, building: u.building, number: u.number, floor: u.floor,
|
||||
size: Number(u.size), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent),
|
||||
tenant: u.tenant, status: u.status, heizungsart: u.heizungsart ?? '',
|
||||
size: Number(u.size), status: u.status, heizungsart: u.heizungsart ?? '',
|
||||
})
|
||||
const mapMieter = (m) => ({
|
||||
id: m.id, unitId: m.unit_id, name: m.name, vorname: m.vorname,
|
||||
telefon: m.telefon, email: m.email,
|
||||
kaltmiete: Number(m.kaltmiete), warmmiete: Number(m.warmmiete),
|
||||
vertragsstatus: m.vertragsstatus,
|
||||
gekuendigtAm: m.gekuendigt_am, gekuendigtVon: m.gekuendigt_von,
|
||||
kautionHoehe: Number(m.kaution_hoehe), kautionArt: m.kaution_art,
|
||||
kautionSonstiges: m.kaution_sonstiges,
|
||||
kautionszahlung: m.kautionszahlung, kautionRaten: m.kaution_raten,
|
||||
mietbeginnAm: m.mietbeginn_am, mietendeAm: m.mietende_am,
|
||||
erstelltAm: m.erstellt_am, geaendertAm: m.geaendert_am,
|
||||
})
|
||||
const mapTransaction = (t) => ({
|
||||
id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year,
|
||||
@@ -351,7 +364,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
try {
|
||||
const istAdmin = req.user.rolle === 'verwaltung'
|
||||
const einheit = req.user.einheit_id
|
||||
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege] =
|
||||
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege, mieter] =
|
||||
await Promise.all([
|
||||
pool.query('SELECT * FROM units ORDER BY id'),
|
||||
istAdmin
|
||||
@@ -391,6 +404,11 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC')
|
||||
: { rows: [] },
|
||||
// Mieter-Stammdaten sind personenbezogen: Verwaltung sieht alle,
|
||||
// angemeldete Mieter nur ihren eigenen Datensatz (z. B. Miete im Dashboard).
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM mieter ORDER BY name, vorname')
|
||||
: pool.query('SELECT * FROM mieter WHERE unit_id = $1', [einheit]),
|
||||
])
|
||||
// Threads mit ihren Nachrichten zusammenführen
|
||||
const nachrichtenProThread = {}
|
||||
@@ -415,6 +433,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
kosten: costs.rows.map(mapKosten),
|
||||
belege: belege.rows.map(mapBeleg),
|
||||
mietvertraege: mietvertraege.rows.map(mapMietvertrag),
|
||||
mieter: mieter.rows.map(mapMieter),
|
||||
einstellungen: await vermieterDaten(pool),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
@@ -879,13 +898,22 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => {
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PDF-Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31)
|
||||
/** Content-Type anhand der Dateiendung (PDF + Mieterdokument-Bilder). */
|
||||
function mimeTyp(dateiPfad) {
|
||||
const ende = path.extname(dateiPfad || '').toLowerCase()
|
||||
if (ende === '.png') return 'image/png'
|
||||
if (ende === '.jpg' || ende === '.jpeg') return 'image/jpeg'
|
||||
return 'application/pdf'
|
||||
}
|
||||
|
||||
// Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31);
|
||||
// seit v1.36 auch Bilder (Mieterdokumente wie Personalausweis-Scans).
|
||||
app.get('/api/documents/:id/preview', auth, async (req, res, next) => {
|
||||
try {
|
||||
const gefunden = await dokumentMitDatei(req, res)
|
||||
if (!gefunden) return
|
||||
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(gefunden.dokument.file_name || gefunden.dokument.title)}`)
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Type', mimeTyp(gefunden.pfad))
|
||||
res.sendFile(gefunden.pfad)
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -1343,17 +1371,14 @@ app.post('/api/units', auth, adminOnly, async (req, res, next) => {
|
||||
const warmRent = Number(u.warmRent)
|
||||
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' })
|
||||
if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' })
|
||||
if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' })
|
||||
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
|
||||
const status = u.status === 'frei' ? 'frei' : 'vermietet'
|
||||
const status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand'
|
||||
if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' })
|
||||
const heizung = String(u.heizungsart || '').trim()
|
||||
const id = `u-${Date.now()}`
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status, heizungsart)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
|
||||
[id, building, String(u.number).trim(), floor, size, coldRent, warmRent,
|
||||
String(u.tenant || '').trim() || null, status, heizung],
|
||||
`INSERT INTO units (id, building, number, floor, size, status, heizungsart)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[id, building, String(u.number).trim(), floor, size, status, heizung],
|
||||
)
|
||||
res.json({ unit: mapUnit(rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
@@ -1371,20 +1396,15 @@ app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => {
|
||||
if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' })
|
||||
const floor = Number(u.floor)
|
||||
const size = Number(u.size)
|
||||
const coldRent = Number(u.coldRent)
|
||||
const warmRent = Number(u.warmRent)
|
||||
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' })
|
||||
if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' })
|
||||
if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' })
|
||||
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
|
||||
const status = u.status === 'frei' ? 'frei' : 'vermietet'
|
||||
const status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand'
|
||||
if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' })
|
||||
const heizung = String(u.heizungsart || '').trim()
|
||||
const { rows: neu } = await pool.query(
|
||||
`UPDATE units SET building = $1, number = $2, floor = $3, size = $4, cold_rent = $5,
|
||||
warm_rent = $6, tenant = $7, status = $8, heizungsart = $9 WHERE id = $10 RETURNING *`,
|
||||
[building, String(u.number).trim(), floor, size, coldRent, warmRent,
|
||||
String(u.tenant || '').trim() || null, status, heizung, req.params.id],
|
||||
`UPDATE units SET building = $1, number = $2, floor = $3, size = $4,
|
||||
status = $5, heizungsart = $6 WHERE id = $7 RETURNING *`,
|
||||
[building, String(u.number).trim(), floor, size, status, heizung, req.params.id],
|
||||
)
|
||||
res.json({ unit: mapUnit(neu[0]) })
|
||||
} catch (e) { next(e) }
|
||||
@@ -1407,6 +1427,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
|
||||
['registration_codes', 'einheit_id', 'Registrierungscodes'],
|
||||
['belege', 'unit_id', 'Belege'],
|
||||
['mietvertraege', 'unit_id', 'Mietverträge'],
|
||||
['mieter', 'unit_id', 'Mieter'],
|
||||
]) {
|
||||
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id])
|
||||
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
|
||||
@@ -1419,6 +1440,174 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ── Mieter (v1.36) ───────────────────────────────────────────
|
||||
const VERTRAGSSTATUS = ['aktiv', 'befristet', 'gekuendigt']
|
||||
const KAUTION_ARTEN = ['bar', 'ueberwiesen', 'sonstiges']
|
||||
const KAUTIONSZAHLUNG = ['komplett', 'raten']
|
||||
const MIETER_DOKUMENT_KATEGORIEN = ['Personalausweis', 'SCHUFA-Auskunft', 'Bankauszug', 'Sonstiges']
|
||||
|
||||
/** Uploads für Mieterdokumente: PDF + Bilder (Scan vom Handy).
|
||||
* Dateiname pro Mieter + Kategorie eindeutig: <mieterId>-<kategorie>.<endung> */
|
||||
const uploadDokument = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (req, file, cb) => {
|
||||
const kategorie = String(req.query.kategorie || 'sonstiges').toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
||||
const ende = path.extname(file.originalname || '').toLowerCase()
|
||||
const endung = ['.pdf', '.jpg', '.jpeg', '.png'].includes(ende) ? ende : '.pdf'
|
||||
cb(null, `${req.params.id}-${kategorie}${endung}`)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: UPLOAD_MAX_BYTES, files: 1 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ok = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.mimetype)
|
||||
|| /\.(pdf|jpe?g|png)$/i.test(file.originalname || '')
|
||||
cb(ok ? null : new Error('Nur PDF- und Bilddateien (JPG/PNG) sind erlaubt.'), ok)
|
||||
},
|
||||
})
|
||||
|
||||
async function mieterGueltig(b, res) {
|
||||
const unitId = String(b.unitId || '').trim()
|
||||
const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
|
||||
if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
|
||||
const name = String(b.name || '').trim()
|
||||
if (!name) { res.status(400).json({ fehler: 'Name des Mieters fehlt.' }); return null }
|
||||
const kaltmiete = Number(b.kaltmiete ?? 0)
|
||||
const warmmiete = Number(b.warmmiete ?? 0)
|
||||
const kautionHoehe = Number(b.kautionHoehe ?? 0)
|
||||
if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null }
|
||||
if (!Number.isFinite(warmmiete) || warmmiete < 0) { res.status(400).json({ fehler: 'Warmmiete ungültig.' }); return null }
|
||||
if (!Number.isFinite(kautionHoehe) || kautionHoehe < 0) { res.status(400).json({ fehler: 'Kautionshöhe ungültig.' }); return null }
|
||||
const vertragsstatus = VERTRAGSSTATUS.includes(b.vertragsstatus) ? b.vertragsstatus : 'aktiv'
|
||||
const gekuendigtVon = ['', 'mieter', 'vermieter'].includes(b.gekuendigtVon) ? b.gekuendigtVon : ''
|
||||
const kautionArt = KAUTION_ARTEN.includes(b.kautionArt) ? b.kautionArt : 'ueberwiesen'
|
||||
const kautionszahlung = KAUTIONSZAHLUNG.includes(b.kautionszahlung) ? b.kautionszahlung : 'komplett'
|
||||
const kautionRaten = Number(b.kautionRaten ?? 0)
|
||||
const datum = (wert) => datumDeutsch(wert) || String(wert || '').trim()
|
||||
return {
|
||||
unitId, einheit: u[0], name,
|
||||
vorname: String(b.vorname || '').trim(),
|
||||
telefon: String(b.telefon || '').trim(),
|
||||
email: String(b.email || '').trim(),
|
||||
kaltmiete, warmmiete, vertragsstatus,
|
||||
gekuendigtAm: vertragsstatus === 'gekuendigt' ? datum(b.gekuendigtAm) : '',
|
||||
gekuendigtVon: vertragsstatus === 'gekuendigt' ? gekuendigtVon : '',
|
||||
kautionHoehe, kautionArt,
|
||||
kautionSonstiges: kautionArt === 'sonstiges' ? String(b.kautionSonstiges || '').trim() : '',
|
||||
kautionszahlung,
|
||||
kautionRaten: kautionszahlung === 'raten' && Number.isInteger(kautionRaten) && kautionRaten > 0 ? kautionRaten : 0,
|
||||
mietbeginnAm: datum(b.mietbeginnAm), mietendeAm: datum(b.mietendeAm),
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/mieter', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const g = await mieterGueltig(req.body || {}, res)
|
||||
if (!g) return
|
||||
const { rows: belegt } = await pool.query('SELECT 1 FROM mieter WHERE unit_id = $1', [g.unitId])
|
||||
if (belegt.length) return res.status(400).json({ fehler: 'Zu dieser Wohnung ist bereits ein Mieter angelegt.' })
|
||||
const id = `mi-${Date.now()}`
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO mieter (id, unit_id, name, vorname, telefon, email, kaltmiete, warmmiete,
|
||||
vertragsstatus, gekuendigt_am, gekuendigt_von,
|
||||
kaution_hoehe, kaution_art, kaution_sonstiges, kautionszahlung, kaution_raten,
|
||||
mietbeginn_am, mietende_am, erstellt_am, geaendert_am)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19) RETURNING *`,
|
||||
[id, g.unitId, g.name, g.vorname, g.telefon, g.email, g.kaltmiete, g.warmmiete,
|
||||
g.vertragsstatus, g.gekuendigtAm, g.gekuendigtVon,
|
||||
g.kautionHoehe, g.kautionArt, g.kautionSonstiges, g.kautionszahlung, g.kautionRaten,
|
||||
g.mietbeginnAm, g.mietendeAm, deutschesDatum()],
|
||||
)
|
||||
// Wohnung folgt dem Mieter-Status
|
||||
await pool.query('UPDATE units SET status = $1 WHERE id = $2', ['vermietet', g.unitId])
|
||||
res.json({ mieter: mapMieter(rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
app.patch('/api/mieter/:id', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const { rows: alt } = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id])
|
||||
if (!alt.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
|
||||
const g = await mieterGueltig({ ...mapMieter(alt[0]), ...(req.body || {}) }, res)
|
||||
if (!g) return
|
||||
const { rows: belegt } = await pool.query('SELECT 1 FROM mieter WHERE unit_id = $1 AND id <> $2', [g.unitId, req.params.id])
|
||||
if (belegt.length) return res.status(400).json({ fehler: 'Die gewählte Wohnung ist bereits anderweitig vergeben.' })
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE mieter SET unit_id=$1, name=$2, vorname=$3, telefon=$4, email=$5, kaltmiete=$6, warmmiete=$7,
|
||||
vertragsstatus=$8, gekuendigt_am=$9, gekuendigt_von=$10,
|
||||
kaution_hoehe=$11, kaution_art=$12, kaution_sonstiges=$13, kautionszahlung=$14, kaution_raten=$15,
|
||||
mietbeginn_am=$16, mietende_am=$17, geaendert_am=$18
|
||||
WHERE id=$19 RETURNING *`,
|
||||
[g.unitId, g.name, g.vorname, g.telefon, g.email, g.kaltmiete, g.warmmiete,
|
||||
g.vertragsstatus, g.gekuendigtAm, g.gekuendigtVon,
|
||||
g.kautionHoehe, g.kautionArt, g.kautionSonstiges, g.kautionszahlung, g.kautionRaten,
|
||||
g.mietbeginnAm, g.mietendeAm, deutschesDatum(), req.params.id],
|
||||
)
|
||||
res.json({ mieter: mapMieter(rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
app.delete('/api/mieter/:id', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING unit_id', [req.params.id])
|
||||
if (!rows.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
|
||||
await pool.query('UPDATE units SET status = $1 WHERE id = $2', ['leerstand', rows[0].unit_id])
|
||||
// Mieterdokumente (Scans/Ausweise) mitlöschen: DB-Zeilen + Dateien
|
||||
const { rows: dokus } = await pool.query(
|
||||
`DELETE FROM documents WHERE id LIKE 'd-md-' || $1 || '-%' RETURNING file_path`, [req.params.id],
|
||||
)
|
||||
for (const d of dokus) {
|
||||
if (d.file_path) {
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, path.basename(String(d.file_path)))) } catch { /* Datei bereits weg */ }
|
||||
}
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// Mieterdokument hochladen (Personalausweis, SCHUFA, Bankauszug …) – pro
|
||||
// Mieter und Kategorie gibt es genau einen Slot; erneutes Hochladen ersetzt.
|
||||
app.post('/api/mieter/:id/dokumente', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const { rows: mieterRows } = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id])
|
||||
if (!mieterRows.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
|
||||
const m = mieterRows[0]
|
||||
const kategorie = String(req.query.kategorie || 'Sonstiges')
|
||||
if (!MIETER_DOKUMENT_KATEGORIEN.includes(kategorie)) {
|
||||
return res.status(400).json({ fehler: 'Ungültige Dokumentenkategorie.' })
|
||||
}
|
||||
uploadDokument.single('file')(req, res, async (fehler) => {
|
||||
try {
|
||||
if (fehler) return res.status(400).json({ fehler: 'Upload fehlgeschlagen – nur PDF und Bilder (JPG/PNG) bis 25 MB erlaubt.' })
|
||||
if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' })
|
||||
const groesseBytes = req.file.size
|
||||
const groesse = groesseBytes > 1024
|
||||
? `${(groesseBytes / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB`
|
||||
: `${groesseBytes} B`
|
||||
const titel = `${kategorie} – ${[m.vorname, m.name].filter(Boolean).join(' ')}`
|
||||
const dokumentId = `d-md-${m.id}-${kategorie.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
|
||||
const dateiName = req.file.filename // aus multer storage: <mieterId>-<kategorie>.<endung>
|
||||
const originalName = String(req.file.originalname || dateiName)
|
||||
const { rows: vorhanden } = await pool.query('SELECT id FROM documents WHERE id = $1', [dokumentId])
|
||||
if (vorhanden.length) {
|
||||
// Ersetzen: auch file_path tauschen (Endung kann von PDF zu Bild wechseln)
|
||||
await pool.query(
|
||||
`UPDATE documents SET title = $1, file_name = $2, size = $3, date = $4, file_path = $5 WHERE id = $6`,
|
||||
[titel, originalName, groesse, deutschesDatum(), dateiName, dokumentId],
|
||||
)
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
[dokumentId, m.unit_id, titel, kategorie, groesse, deutschesDatum(), originalName, dateiName],
|
||||
)
|
||||
}
|
||||
res.json({ dokument: mapDocument((await pool.query('SELECT * FROM documents WHERE id = $1', [dokumentId])).rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ── Nachrichten (Threads) ────────────────────────────────────
|
||||
app.post('/api/threads', auth, async (req, res, next) => {
|
||||
try {
|
||||
@@ -1737,8 +1926,14 @@ async function mietvertragGueltig(b, res) {
|
||||
const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
|
||||
if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
|
||||
const einheit = u[0]
|
||||
const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : Number(einheit.cold_rent)
|
||||
const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : Number(einheit.warm_rent) - Number(einheit.cold_rent)
|
||||
// Miet-Voreinstellungen aus dem Mieterobjekt (seit v1.36; z. B. Nachmietung)
|
||||
const { rows: mieterRows } = await pool.query(
|
||||
'SELECT kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [unitId],
|
||||
)
|
||||
const mietKalt = mieterRows.length ? Number(mieterRows[0].kaltmiete) : 0
|
||||
const mietNk = mieterRows.length ? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete) : 0
|
||||
const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : mietKalt
|
||||
const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : mietNk
|
||||
if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null }
|
||||
if (!Number.isFinite(nebenkosten) || nebenkosten < 0) { res.status(400).json({ fehler: 'Nebenkosten ungültig.' }); return null }
|
||||
const kaution = Number(b.kaution ?? 0)
|
||||
@@ -1935,11 +2130,31 @@ app.post('/api/mietvertraege/:id/aktivieren', auth, adminOnly, async (req, res,
|
||||
[benutzerId, benutzername, vertrag.mieter_name, SHA256_0815,
|
||||
vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null],
|
||||
)
|
||||
// Wohnung als vermietet führen
|
||||
await pool.query(
|
||||
`UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`,
|
||||
[vertrag.mieter_name, einheit.id],
|
||||
)
|
||||
// Mieterobjekt anlegen/aktualisieren + Wohnung als vermietet führen (v1.36)
|
||||
const nameTeile = vertrag.mieter_name.trim().split(/\s+/)
|
||||
const vorname = nameTeile.length > 1 ? nameTeile.slice(0, -1).join(' ') : ''
|
||||
const nachname = nameTeile.length > 1 ? nameTeile[nameTeile.length - 1] : nameTeile[0]
|
||||
const { rows: mieterVorhanden } = await pool.query('SELECT id FROM mieter WHERE unit_id = $1', [einheit.id])
|
||||
if (mieterVorhanden.length) {
|
||||
await pool.query(
|
||||
`UPDATE mieter SET name=$1, vorname=$2, email=$3, telefon=$4, kaltmiete=$5, warmmiete=$6,
|
||||
vertragsstatus='aktiv', mietbeginn_am=$7, geaendert_am=$8 WHERE unit_id=$9`,
|
||||
[nachname, vorname, vertrag.mieter_email, vertrag.mieter_telefon,
|
||||
vertrag.kaltmiete, Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten),
|
||||
vertrag.beginn_am, deutschesDatum(), einheit.id],
|
||||
)
|
||||
} else {
|
||||
const mieterId = `mi-${Date.now()}`
|
||||
await pool.query(
|
||||
`INSERT INTO mieter (id, unit_id, name, vorname, telefon, email, kaltmiete, warmmiete,
|
||||
vertragsstatus, mietbeginn_am, erstellt_am, geaendert_am)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'aktiv',$9,$10,$10)`,
|
||||
[mieterId, einheit.id, nachname, vorname, vertrag.mieter_telefon, vertrag.mieter_email,
|
||||
vertrag.kaltmiete, Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten),
|
||||
vertrag.beginn_am, deutschesDatum()],
|
||||
)
|
||||
}
|
||||
await pool.query(`UPDATE units SET status = 'vermietet' WHERE id = $1`, [einheit.id])
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`,
|
||||
[benutzerId, deutschesDatum(), req.params.id],
|
||||
@@ -2032,6 +2247,29 @@ async function haeuserFelderMigrieren() {
|
||||
if (rows.length) console.log(`[api] Häuser: ${rows.length} Adresse(n) in Einzelfelder zerlegt`)
|
||||
}
|
||||
|
||||
/** Einmal-Migration v1.36: Mieterdaten aus dem Wohnungsobjekt (Altbestand:
|
||||
* units.tenant/cold_rent/warm_rent) in die neue Tabelle mieter überführen. */
|
||||
async function mieterAusUnitsMigrieren() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.* FROM units u
|
||||
WHERE u.tenant IS NOT NULL AND u.tenant <> ''
|
||||
AND NOT EXISTS (SELECT 1 FROM mieter m WHERE m.unit_id = u.id)`,
|
||||
)
|
||||
for (const u of rows) {
|
||||
const teile = String(u.tenant).trim().split(/\s+/)
|
||||
const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : ''
|
||||
const nachname = teile.length > 1 ? teile[teile.length - 1] : teile[0]
|
||||
await pool.query(
|
||||
`INSERT INTO mieter (id, unit_id, name, vorname, kaltmiete, warmmiete, vertragsstatus,
|
||||
mietbeginn_am, erstellt_am, geaendert_am)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,'aktiv','',$7,$7)`,
|
||||
[`mi-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, u.id, nachname, vorname,
|
||||
Number(u.cold_rent ?? 0), Number(u.warm_rent ?? 0), deutschesDatum()],
|
||||
)
|
||||
}
|
||||
if (rows.length) console.log(`[api] Mieter: ${rows.length} Datensatz/Datensätze aus Wohnungen übernommen`)
|
||||
}
|
||||
|
||||
async function warteAufDatenbank() {
|
||||
let letzterFehler = null
|
||||
for (let versuch = 1; versuch <= 30; versuch++) {
|
||||
@@ -2060,6 +2298,8 @@ async function start() {
|
||||
const kostenGeseedet = await seedeKosten()
|
||||
if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt')
|
||||
await haeuserFelderMigrieren()
|
||||
await mieterAusUnitsMigrieren()
|
||||
assistRoutenRegistrieren(app, { auth, adminOnly })
|
||||
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
|
||||
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
|
||||
}
|
||||
|
||||
+20
-6
@@ -17,11 +17,24 @@ async function seed() {
|
||||
await client.query('BEGIN')
|
||||
|
||||
for (const u of generated.units) {
|
||||
// v1.36: Wohnung schlank (kein Mieter/Miete mehr am Objekt); Mieter
|
||||
// werden als eigene Datensätze (Tabelle mieter) angelegt.
|
||||
const status = u.status === 'vermietet' ? 'vermietet' : 'leerstand'
|
||||
await client.query(
|
||||
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[u.id, u.building, u.number, u.floor, u.size, u.coldRent, u.warmRent, u.tenant, u.status],
|
||||
`INSERT INTO units (id, building, number, floor, size, status) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[u.id, u.building, u.number, u.floor, u.size, status],
|
||||
)
|
||||
if (u.tenant) {
|
||||
const teile = String(u.tenant).trim().split(/\s+/)
|
||||
const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : ''
|
||||
const nachname = teile.length > 1 ? teile[teile.length - 1] : teile[0]
|
||||
await client.query(
|
||||
`INSERT INTO mieter (id, unit_id, name, vorname, kaltmiete, warmmiete, vertragsstatus,
|
||||
mietbeginn_am, erstellt_am, geaendert_am)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,'aktiv','',$7,$7)`,
|
||||
[`mi-${u.id}`, u.id, nachname, vorname, Number(u.coldRent ?? 0), Number(u.warmRent ?? 0), new Date().toLocaleDateString('de-DE')],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of generated.transactions) {
|
||||
@@ -32,9 +45,10 @@ async function seed() {
|
||||
)
|
||||
}
|
||||
|
||||
// Mieter-Namen in Tickets/Threads aus der units-Tabelle nachziehen
|
||||
const { rows: unitRows } = await client.query('SELECT id, tenant FROM units')
|
||||
const mieterVon = Object.fromEntries(unitRows.map((u) => [u.id, u.tenant]))
|
||||
// Mieter-Namen in Tickets/Threads aus den Seed-Daten nachziehen
|
||||
const mieterVon = Object.fromEntries(
|
||||
generated.units.filter((u) => u.tenant).map((u) => [u.id, u.tenant]),
|
||||
)
|
||||
|
||||
for (const t of tickets) {
|
||||
await client.query(
|
||||
|
||||
Reference in New Issue
Block a user