v1.16: SparkReceipt-Schnittstelle - Belege per Webhook importieren und als Kostenposition uebernehmen
This commit is contained in:
@@ -164,6 +164,25 @@ BEGIN
|
||||
ALTER TABLE costs ADD CONSTRAINT costs_building_fkey
|
||||
FOREIGN KEY (building) REFERENCES buildings(name) ON UPDATE CASCADE;
|
||||
END $$;
|
||||
|
||||
-- Gescannte Belege aus SparkReceipt (Cloud-Archiv), per Webhook importiert.
|
||||
-- (quelle, externe_id) eindeutig → wiederholte Events aktualisieren statt zu duplizieren.
|
||||
CREATE TABLE IF NOT EXISTS belege (
|
||||
id TEXT PRIMARY KEY,
|
||||
quelle TEXT NOT NULL DEFAULT 'sparkreceipt',
|
||||
externe_id TEXT NOT NULL,
|
||||
haendler TEXT NOT NULL DEFAULT '',
|
||||
betrag NUMERIC(10,2),
|
||||
waehrung TEXT NOT NULL DEFAULT 'EUR',
|
||||
datum TEXT NOT NULL DEFAULT '',
|
||||
jahr INT,
|
||||
kategorie TEXT NOT NULL DEFAULT '',
|
||||
notiz TEXT NOT NULL DEFAULT '',
|
||||
datei_url TEXT NOT NULL DEFAULT '',
|
||||
zugeordnet BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
erfasst_am TEXT NOT NULL,
|
||||
UNIQUE (quelle, externe_id)
|
||||
);
|
||||
`
|
||||
|
||||
async function init() {
|
||||
|
||||
+81
-1
@@ -21,6 +21,8 @@ const COOKIE_NAME = 'mp_session'
|
||||
// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert)
|
||||
const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
|
||||
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
|
||||
// Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert)
|
||||
const SPARKRECEIPT_WEBHOOK_TOKEN = process.env.SPARKRECEIPT_WEBHOOK_TOKEN || ''
|
||||
// PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert)
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
||||
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei
|
||||
@@ -182,6 +184,12 @@ const mapKosten = (k) => ({
|
||||
kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar,
|
||||
notiz: k.notiz, erfasstAm: k.erfasst_am,
|
||||
})
|
||||
const mapBeleg = (b) => ({
|
||||
id: b.id, quelle: b.quelle, externeId: b.externe_id, haendler: b.haendler,
|
||||
betrag: b.betrag === null ? null : Number(b.betrag), waehrung: b.waehrung,
|
||||
datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz,
|
||||
dateiUrl: b.datei_url, zugeordnet: b.zugeordnet, erfasstAm: b.erfasst_am,
|
||||
})
|
||||
|
||||
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
|
||||
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
|
||||
@@ -285,7 +293,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] =
|
||||
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege] =
|
||||
await Promise.all([
|
||||
pool.query('SELECT * FROM units ORDER BY id'),
|
||||
istAdmin
|
||||
@@ -319,6 +327,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart')
|
||||
: pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]),
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
|
||||
: { rows: [] },
|
||||
])
|
||||
// Threads mit ihren Nachrichten zusammenführen
|
||||
const nachrichtenProThread = {}
|
||||
@@ -341,6 +352,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
meters: meters.rows.map(mapMeter),
|
||||
buildings: buildings.rows.map(mapBuilding),
|
||||
kosten: costs.rows.map(mapKosten),
|
||||
belege: belege.rows.map(mapBeleg),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -712,6 +724,74 @@ app.delete('/api/costs/:id', auth, adminOnly, async (req, res, next) => {
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ── Beleg-Import (SparkReceipt-Webhook) ──────────────────────
|
||||
/** Tolerantes Extrahieren der Belegfelder: SparkReceipt schachtelt die
|
||||
* Daten teils unter „document", teils flach – Aliase abdecken. */
|
||||
function belegFelder(body) {
|
||||
const d = (body && typeof body === 'object' && (body.document || body.receipt || body.data)) || body || {}
|
||||
const nimm = (...keys) => {
|
||||
for (const k of keys) {
|
||||
if (d[k] !== undefined && d[k] !== null && d[k] !== '') return d[k]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const betragRoh = nimm('total', 'amount', 'betrag', 'total_amount', 'sum', 'totalAmount')
|
||||
const datumRoh = String(nimm('date', 'datum', 'purchase_date', 'created_at', 'document_date') ?? '')
|
||||
const jahrRoh = nimm('year', 'jahr')
|
||||
return {
|
||||
externeId: String(nimm('id', 'document_id', 'documentId', 'receipt_id') ?? ''),
|
||||
haendler: String(nimm('merchant', 'vendor', 'haendler', 'supplier', 'store_name', 'merchant_name', 'name') ?? ''),
|
||||
betrag: betragRoh === undefined ? null : Number(betragRoh),
|
||||
waehrung: String(nimm('currency', 'waehrung') ?? 'EUR'),
|
||||
datum: datumRoh,
|
||||
jahr: jahrRoh !== undefined ? Number(jahrRoh) : (datumRoh.match(/(19|20)\d{2}/) ? Number(datumRoh.match(/(19|20)\d{2}/)[0]) : null),
|
||||
kategorie: String(nimm('category', 'kategorie') ?? ''),
|
||||
notiz: String(nimm('notes', 'note', 'description', 'memo') ?? ''),
|
||||
dateiUrl: String(nimm('file_url', 'fileUrl', 'image_url', 'receipt_url', 'document_url', 'url') ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/belege/webhook', async (req, res) => {
|
||||
try {
|
||||
if (!SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(503).json({ fehler: 'Beleg-Import ist nicht konfiguriert.' })
|
||||
// SparkReceipt sendet nur eine URL (keine Custom-Headers) → Token per ?token=
|
||||
const token = String(req.query.token || req.headers['x-webhook-token'] || '')
|
||||
if (token !== SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(401).json({ fehler: 'Ungültiger Token.' })
|
||||
const event = String(req.body?.event || req.body?.event_type || req.body?.type || '')
|
||||
if (/delete/i.test(event)) return res.json({ ok: true, ignoriert: event }) // Löschungen nicht importieren
|
||||
const f = belegFelder(req.body)
|
||||
if (!f.externeId) return res.status(400).json({ fehler: 'Keine Dokument-ID im Event.' })
|
||||
if (f.betrag !== null && !Number.isFinite(f.betrag)) return res.status(400).json({ fehler: 'Betrag ungültig.' })
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am)
|
||||
VALUES ($1,'sparkreceipt',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
ON CONFLICT (quelle, externe_id) DO UPDATE SET
|
||||
haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung,
|
||||
datum = EXCLUDED.datum, jahr = EXCLUDED.jahr, kategorie = EXCLUDED.kategorie,
|
||||
notiz = EXCLUDED.notiz, datei_url = EXCLUDED.datei_url
|
||||
RETURNING *`,
|
||||
[`b-${Date.now()}`, f.externeId, f.haendler, f.betrag, f.waehrung, f.datum, f.jahr,
|
||||
f.kategorie, f.notiz, f.dateiUrl, deutschesDatumZeit()],
|
||||
)
|
||||
res.json({ ok: true, beleg: mapBeleg(rows[0]), event: event || 'unknown' })
|
||||
} catch (e) {
|
||||
console.error('[belege] Webhook-Fehler:', e)
|
||||
res.status(500).json({ fehler: 'Interner Serverfehler.' })
|
||||
}
|
||||
})
|
||||
|
||||
// Zugeordnet-Markierung für die Verwaltung (nach Übernahme als Kostenposition)
|
||||
app.patch('/api/belege/:id', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const zugeordnet = Boolean((req.body || {}).zugeordnet)
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE belege SET zugeordnet = $1 WHERE id = $2 RETURNING *', [zugeordnet, req.params.id],
|
||||
)
|
||||
if (!rows.length) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' })
|
||||
res.json({ beleg: mapBeleg(rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
||||
function pruefeHaus(b) {
|
||||
const fehler = []
|
||||
|
||||
Reference in New Issue
Block a user