v1.34: Eigenobjekt-Kennzeichen, Belegzuordnung nutzt Mietobjekt-Adresse; Ticket-Delete Fix
This commit is contained in:
@@ -189,6 +189,30 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 8. Verlauf / offene Punkte
|
||||||
|
|
||||||
|
**Erledigt 04.10. (v1.34):** SparkReceipt-Zuordnung nutzt Mietobjekt-Adresse.
|
||||||
|
Problem: auf eingehenden Rechnungen steht als Adresse der Rechnungsempfänger
|
||||||
|
(Rott 250 = eigene Verwaltungsadresse, als Haus gepflegt) – die sekundäre
|
||||||
|
Adress-Zuordnung matchte damit immer das falsche Haus. Lösung: Gebäude
|
||||||
|
bekommen das Kennzeichen eigenobjekt (DB: buildings.eigenobjekt BOOLEAN,
|
||||||
|
Default FALSE); belegAutoZuordnung schließt solche Häuser aus
|
||||||
|
(WHERE eigenobjekt IS NOT TRUE) – primäre Stromzählernummer-Matchung
|
||||||
|
bleibt unverändert. POST/PATCH /api/buildings und mapBuilding liefern/
|
||||||
|
speichern eigenobjekt; PATCH merged via mapBuilding-Altbestand, bleibt
|
||||||
|
also erhalten wenn nicht mitsendet. Bestand.tsx: neuer Abschnitt
|
||||||
|
„Zuordnung Belege" mit Checkbox „Eigenobjekt / Rechnungsempfänger"
|
||||||
|
(Erklärung im Hint), Badge „Eigenobjekt" in Haus-Karte und Listenzeile.
|
||||||
|
Verwaltung muss Rott 250 einmalig als Eigenobjekt markieren; bereits
|
||||||
|
falsch zugeordnete Belege lassen sich einzeln per „Analysieren" neu
|
||||||
|
zuordnen (Schutz nur für 'manuell'). E2E: Webhook mit nur Empfängeradresse
|
||||||
|
→ keine Zuordnung statt Rott 250; mit Mietobjekt-Adresse im Text →
|
||||||
|
Zuordnung aufs Mietobjekt.
|
||||||
|
Fix in v1.33 mitnachgetragen: DELETE /api/tickets/:id löscht jetzt in
|
||||||
|
einer Transaktion zuerst messages, dann threads (threads.ticket_id hat
|
||||||
|
KEIN ON DELETE CASCADE), dann das Ticket – vorher 500 durch FK-Verletzung.
|
||||||
|
Blocker-Liste von DELETE /api/units/:id um transactions, tickets,
|
||||||
|
threads, registration_codes und belege erweitert (alle FKs auf units.id
|
||||||
|
ohne Cascade, vorher möglicher 500 statt 400er-Hinweis).
|
||||||
|
|
||||||
**Erledigt 04.10. (v1.33):** Einträge in allen Listen löschbar. Neue
|
**Erledigt 04.10. (v1.33):** Einträge in allen Listen löschbar. Neue
|
||||||
adminOnly-DELETE-Endpunkte in server/src/index.js: /api/tickets/:id
|
adminOnly-DELETE-Endpunkte in server/src/index.js: /api/tickets/:id
|
||||||
(kaskadiert threads + messages), /api/meters/:id (kaskadiert meter_readings),
|
(kaskadiert threads + messages), /api/meters/:id (kaskadiert meter_readings),
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ ALTER TABLE buildings ADD COLUMN IF NOT EXISTS grundstuecksflaeche NUMERIC(9,1);
|
|||||||
-- v1.29: Heizungsart (Dropdown) in Haus- und Wohnungsverwaltung
|
-- v1.29: Heizungsart (Dropdown) in Haus- und Wohnungsverwaltung
|
||||||
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS heizungsart TEXT NOT NULL DEFAULT '';
|
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS heizungsart TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE units ADD COLUMN IF NOT EXISTS heizungsart TEXT NOT NULL DEFAULT '';
|
ALTER TABLE units ADD COLUMN IF NOT EXISTS heizungsart TEXT NOT NULL DEFAULT '';
|
||||||
|
-- v1.34: Eigenobjekt (z. B. Rechnungsempfänger-Adresse der Verwaltung) –
|
||||||
|
-- wird von der automatischen Beleg-Zuordnung ausgeschlossen
|
||||||
|
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS eigenobjekt BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS costs (
|
CREATE TABLE IF NOT EXISTS costs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
+37
-13
@@ -195,6 +195,7 @@ const mapBuilding = (b) => ({
|
|||||||
grundstuecksflaeche: b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined
|
grundstuecksflaeche: b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined
|
||||||
? null : Number(b.grundstuecksflaeche),
|
? null : Number(b.grundstuecksflaeche),
|
||||||
heizungsart: b.heizungsart ?? '',
|
heizungsart: b.heizungsart ?? '',
|
||||||
|
eigenobjekt: Boolean(b.eigenobjekt),
|
||||||
})
|
})
|
||||||
const mapKosten = (k) => ({
|
const mapKosten = (k) => ({
|
||||||
id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr,
|
id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr,
|
||||||
@@ -555,15 +556,26 @@ app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
|
|||||||
|
|
||||||
// Schadensmeldung löschen inkl. verknüpfter Unterhaltung und Nachrichten (v1.33)
|
// Schadensmeldung löschen inkl. verknüpfter Unterhaltung und Nachrichten (v1.33)
|
||||||
app.delete('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
|
app.delete('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
|
||||||
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query('DELETE FROM tickets WHERE id = $1 RETURNING id', [req.params.id])
|
await client.query('BEGIN')
|
||||||
if (!rows.length) return res.status(404).json({ fehler: 'Schadensmeldung nicht gefunden.' })
|
const { rows: threads } = await client.query('DELETE FROM threads WHERE ticket_id = $1 RETURNING id', [req.params.id])
|
||||||
const { rows: threads } = await pool.query('DELETE FROM threads WHERE ticket_id = $1 RETURNING id', [req.params.id])
|
|
||||||
for (const th of threads) {
|
for (const th of threads) {
|
||||||
await pool.query('DELETE FROM messages WHERE thread_id = $1', [th.id])
|
await client.query('DELETE FROM messages WHERE thread_id = $1', [th.id])
|
||||||
}
|
}
|
||||||
|
const { rows } = await client.query('DELETE FROM tickets WHERE id = $1 RETURNING id', [req.params.id])
|
||||||
|
if (!rows.length) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
return res.status(404).json({ fehler: 'Schadensmeldung nicht gefunden.' })
|
||||||
|
}
|
||||||
|
await client.query('COMMIT')
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (e) { next(e) }
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {})
|
||||||
|
next(e)
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Zählerstände (Strom / Wasser / Gas) ─────────────────────
|
// ── Zählerstände (Strom / Wasser / Gas) ─────────────────────
|
||||||
@@ -1041,8 +1053,12 @@ async function belegAutoZuordnung(text) {
|
|||||||
return fund
|
return fund
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sekundär: Hausname/Adresse im Volltext
|
// Sekundär: Hausname/Adresse im Volltext. Eigenobjekte (z. B. die
|
||||||
const { rows: haeuser } = await pool.query('SELECT name, adresse FROM buildings')
|
// Rechnungsempfänger-Adresse der Verwaltung) werden übersprungen – sonst
|
||||||
|
// landet jeder Beleg, auf dem die Empfängeradresse steht, am falschen Haus.
|
||||||
|
const { rows: haeuser } = await pool.query(
|
||||||
|
'SELECT name, adresse FROM buildings WHERE eigenobjekt IS NOT TRUE',
|
||||||
|
)
|
||||||
for (const h of haeuser) {
|
for (const h of haeuser) {
|
||||||
const name = normalisieren((h.name || '').trim())
|
const name = normalisieren((h.name || '').trim())
|
||||||
const adr = normalisieren((h.adresse || '').trim())
|
const adr = normalisieren((h.adresse || '').trim())
|
||||||
@@ -1211,12 +1227,14 @@ app.post('/api/buildings', auth, adminOnly, async (req, res, next) => {
|
|||||||
const heizung = String(b.heizungsart || '').trim()
|
const heizung = String(b.heizungsart || '').trim()
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO buildings (name, adresse, strasse, hausnummer, plz, ort, lage,
|
`INSERT INTO buildings (name, adresse, strasse, hausnummer, plz, ort, lage,
|
||||||
baujahr, stockwerke, etagenhoehe, wohneinheiten, wohnflaeche, grundstuecksflaeche, heizungsart)
|
baujahr, stockwerke, etagenhoehe, wohneinheiten, wohnflaeche, grundstuecksflaeche, heizungsart,
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
|
eigenobjekt)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||||
[name, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(),
|
[name, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(),
|
||||||
String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(),
|
String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(),
|
||||||
Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe),
|
Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe),
|
||||||
Number(b.wohneinheiten), Number(b.wohnflaeche), grund, heizung],
|
Number(b.wohneinheiten), Number(b.wohnflaeche), grund, heizung,
|
||||||
|
Boolean(b.eigenobjekt)],
|
||||||
)
|
)
|
||||||
res.json({ building: mapBuilding(rows[0]) })
|
res.json({ building: mapBuilding(rows[0]) })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
@@ -1245,12 +1263,13 @@ app.patch('/api/buildings/:name', auth, adminOnly, async (req, res, next) => {
|
|||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE buildings SET name = $1, adresse = $2, strasse = $3, hausnummer = $4, plz = $5, ort = $6, lage = $7,
|
`UPDATE buildings SET name = $1, adresse = $2, strasse = $3, hausnummer = $4, plz = $5, ort = $6, lage = $7,
|
||||||
baujahr = $8, stockwerke = $9, etagenhoehe = $10, wohneinheiten = $11,
|
baujahr = $8, stockwerke = $9, etagenhoehe = $10, wohneinheiten = $11,
|
||||||
wohnflaeche = $12, grundstuecksflaeche = $13, heizungsart = $14
|
wohnflaeche = $12, grundstuecksflaeche = $13, heizungsart = $14,
|
||||||
WHERE name = $15`,
|
eigenobjekt = $15
|
||||||
|
WHERE name = $16`,
|
||||||
[neuName, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(),
|
[neuName, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(),
|
||||||
String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(),
|
String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(),
|
||||||
Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe), Number(b.wohneinheiten),
|
Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe), Number(b.wohneinheiten),
|
||||||
Number(b.wohnflaeche), grund, heizung, req.params.name],
|
Number(b.wohnflaeche), grund, heizung, Boolean(b.eigenobjekt), req.params.name],
|
||||||
)
|
)
|
||||||
// Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen
|
// Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen
|
||||||
if (neuName !== alt.name) {
|
if (neuName !== alt.name) {
|
||||||
@@ -1362,6 +1381,11 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
|
|||||||
['documents', 'unit_id', 'Dokumente'],
|
['documents', 'unit_id', 'Dokumente'],
|
||||||
['costs', 'unit_id', 'Kostenpositionen'],
|
['costs', 'unit_id', 'Kostenpositionen'],
|
||||||
['users', 'einheit_id', 'zugeordnete Benutzer'],
|
['users', 'einheit_id', 'zugeordnete Benutzer'],
|
||||||
|
['transactions', 'unit_id', 'Zahlungen'],
|
||||||
|
['tickets', 'unit_id', 'Schadensmeldungen'],
|
||||||
|
['threads', 'unit_id', 'Unterhaltungen'],
|
||||||
|
['registration_codes', 'einheit_id', 'Registrierungscodes'],
|
||||||
|
['belege', 'unit_id', 'Belege'],
|
||||||
]) {
|
]) {
|
||||||
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id])
|
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}`)
|
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
|
||||||
|
|||||||
+34
-1
@@ -73,6 +73,7 @@ function HausDialog({ haus }: { haus?: Building }) {
|
|||||||
haus?.grundstuecksflaeche != null ? String(haus.grundstuecksflaeche).replace('.', ',') : '',
|
haus?.grundstuecksflaeche != null ? String(haus.grundstuecksflaeche).replace('.', ',') : '',
|
||||||
)
|
)
|
||||||
const [heizungsart, setHeizungsart] = useState(haus?.heizungsart ?? '')
|
const [heizungsart, setHeizungsart] = useState(haus?.heizungsart ?? '')
|
||||||
|
const [eigenobjekt, setEigenobjekt] = useState(haus?.eigenobjekt ?? false)
|
||||||
const [sende, setSende] = useState(false)
|
const [sende, setSende] = useState(false)
|
||||||
const [fehler, setFehler] = useState<string | null>(null)
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ function HausDialog({ haus }: { haus?: Building }) {
|
|||||||
wohnflaeche: zahl(wohnflaeche),
|
wohnflaeche: zahl(wohnflaeche),
|
||||||
grundstuecksflaeche: grundstuecksflaeche.trim() ? zahl(grundstuecksflaeche) : null,
|
grundstuecksflaeche: grundstuecksflaeche.trim() ? zahl(grundstuecksflaeche) : null,
|
||||||
heizungsart,
|
heizungsart,
|
||||||
|
eigenobjekt,
|
||||||
}
|
}
|
||||||
const err = bearbeiten ? await updateHaus(haus!.name, payload) : await addHaus(payload)
|
const err = bearbeiten ? await updateHaus(haus!.name, payload) : await addHaus(payload)
|
||||||
setSende(false)
|
setSende(false)
|
||||||
@@ -157,6 +159,25 @@ function HausDialog({ haus }: { haus?: Building }) {
|
|||||||
<Feld id="h-wohnfl" label="Wohnfläche gesamt (m²)" inputMode="decimal" placeholder="z. B. 980" value={wohnflaeche} onChange={(e) => setWohnflaeche(e.target.value)} />
|
<Feld id="h-wohnfl" label="Wohnfläche gesamt (m²)" inputMode="decimal" placeholder="z. B. 980" value={wohnflaeche} onChange={(e) => setWohnflaeche(e.target.value)} />
|
||||||
<Feld id="h-grundfl" label="Grundstücksfläche (m²)" hint="Optional" inputMode="decimal" placeholder="z. B. 1.250" value={grundstuecksflaeche} onChange={(e) => setGrundstuecksflaeche(e.target.value)} />
|
<Feld id="h-grundfl" label="Grundstücksfläche (m²)" hint="Optional" inputMode="decimal" placeholder="z. B. 1.250" value={grundstuecksflaeche} onChange={(e) => setGrundstuecksflaeche(e.target.value)} />
|
||||||
</Abschnitt>
|
</Abschnitt>
|
||||||
|
<Abschnitt titel="Zuordnung Belege">
|
||||||
|
<label className="flex cursor-pointer items-start gap-2.5 text-sm">
|
||||||
|
<input
|
||||||
|
id="h-eigenobjekt"
|
||||||
|
type="checkbox"
|
||||||
|
checked={eigenobjekt}
|
||||||
|
onChange={(e) => setEigenobjekt(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-slate-300 accent-teal-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-slate-800 dark:text-slate-100">Eigenobjekt / Rechnungsempfänger</span>
|
||||||
|
<span className="block text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Diese Adresse steht auf eingehenden Rechnungen als Empfänger (z. B. die eigene Verwaltungsadresse).
|
||||||
|
Eigenobjekte werden bei der automatischen Beleg-Zuordnung übersprungen – die Zuordnung erfolgt dann
|
||||||
|
über die Adresse bzw. Stromzählernummer des Mietobjekts.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</Abschnitt>
|
||||||
{fehler && (
|
{fehler && (
|
||||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
||||||
{fehler}
|
{fehler}
|
||||||
@@ -530,6 +551,11 @@ export default function Bestand() {
|
|||||||
· Grundstück {h.grundstuecksflaeche.toLocaleString('de-DE')} m²
|
· Grundstück {h.grundstuecksflaeche.toLocaleString('de-DE')} m²
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{h.eigenobjekt && (
|
||||||
|
<Badge variant="secondary" className="bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300">
|
||||||
|
Eigenobjekt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -635,7 +661,14 @@ export default function Bestand() {
|
|||||||
<td className="px-3 py-2.5 text-slate-400">
|
<td className="px-3 py-2.5 text-slate-400">
|
||||||
{aufgeklappt ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{aufgeklappt ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 font-medium text-slate-800 dark:text-slate-100">{h.adresse}</td>
|
<td className="px-3 py-2.5 font-medium text-slate-800 dark:text-slate-100">
|
||||||
|
{h.adresse}
|
||||||
|
{h.eigenobjekt && (
|
||||||
|
<Badge variant="secondary" className="ml-2 bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300">
|
||||||
|
Eigenobjekt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 md:table-cell">{h.lage || '–'}</td>
|
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 md:table-cell">{h.lage || '–'}</td>
|
||||||
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 sm:table-cell">{h.baujahr}</td>
|
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 sm:table-cell">{h.baujahr}</td>
|
||||||
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 lg:table-cell">{h.stockwerke}</td>
|
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 lg:table-cell">{h.stockwerke}</td>
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export interface Building {
|
|||||||
grundstuecksflaeche: number | null // in m², optional
|
grundstuecksflaeche: number | null // in m², optional
|
||||||
/** Heizungsart des Hauses (Dropdown, '' = nicht angegeben) */
|
/** Heizungsart des Hauses (Dropdown, '' = nicht angegeben) */
|
||||||
heizungsart: string
|
heizungsart: string
|
||||||
|
/** Eigenobjekt (z. B. Rechnungsempfänger-Adresse der Verwaltung) –
|
||||||
|
* wird von der automatischen Beleg-Zuordnung ausgeschlossen */
|
||||||
|
eigenobjekt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formular-Daten für Neuanlage/Bearbeitung (Name/Adresse entfallen). */
|
/** Formular-Daten für Neuanlage/Bearbeitung (Name/Adresse entfallen). */
|
||||||
|
|||||||
Reference in New Issue
Block a user