From bbf937c671f8a7896b1b99485e3aac91e1dc2acd Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 10 Jul 2026 19:57:10 +0200 Subject: [PATCH] feat: HA-Entitaeten mit Bereichs-Praefixen, Media-Anfragen quittierbar per Button, umfangreiche 3D-Statistik --- backend/src/index.js | 12 +- backend/src/mqtt.js | 363 +++++++++++++++++++++++------- backend/src/tools/media/routes.js | 60 +++-- 3 files changed, 329 insertions(+), 106 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index ef5d57a..56ae2af 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -257,12 +257,23 @@ server.listen(4000, () => console.log('πŸš€ Dicken Dock lΓ€uft auf Port 4000')); // ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── const db = require('./db'); const koepiRoutes = require('./tools/koepi/routes'); +const mediaRoutes = require('./tools/media/routes'); const mqttClient = require('./mqtt'); mqttClient.connectMqtt(); // HA-Button "KΓΆPi Check jetzt" lΓΆst denselben Check wie der 06:00-Cron aus // (respektiert den normalen Diff β€” Pushover geht nur bei echter Γ„nderung raus) mqttClient.setKoepiCheckHandler(() => koepiRoutes.runDailyCheck()); +// HA-Buttons "Media Quittieren" / "Media Alle quittieren" β€” danach sofort den +// Media-Anfragen-Sensor + die dynamischen Buttons in HA aktualisieren +mqttClient.setMediaAckHandler((id) => { + mediaRoutes.ackFavorite(id); + mqttClient.publishMediaAnfragen(); +}); +mqttClient.setMediaAckAllHandler(() => { + mediaRoutes.ackAllFavorites(); + mqttClient.publishMediaAnfragen(); +}); async function sendPushoverMsg(userKey, appToken, message, opts = {}) { try { @@ -290,7 +301,6 @@ setTimeout(() => { async function runScheduler() { mqttClient.publishPresence(); mqttClient.publishMediaAnfragen(); - mqttClient.publishBestellungen(); mqttClient.publishDruckStatistik(); try { const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; diff --git a/backend/src/mqtt.js b/backend/src/mqtt.js index f507ee1..be921f4 100644 --- a/backend/src/mqtt.js +++ b/backend/src/mqtt.js @@ -3,15 +3,22 @@ // per HA MQTT Discovery an. Kein Custom Component in HA nΓΆtig β€” EntitΓ€ten // erscheinen automatisch unter "Einstellungen β†’ GerΓ€te & Dienste β†’ MQTT". // +// Namenskonvention: alle Entity-Namen beginnen mit ihrem Bereich ("KΓΆPi", "Media", +// "3D", "System"), damit sie in Home Assistant alphabetisch sortiert gruppiert +// untereinander stehen. +// // Aktiv nur, wenn MQTT_HOST in der .env gesetzt ist. Ohne Konfiguration bleibt // der Rest der App unverΓ€ndert lauffΓ€hig (alle publish-Funktionen sind No-Ops). const mqtt = require('mqtt'); const db = require('./db'); -const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock'; +const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock'; const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`; -const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`; +const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`; +const MEDIA_ACK_ALL_CMD_TOPIC = `${BASE_TOPIC}/media/ack_all/set`; +const MEDIA_ACK_TOPIC_PREFIX = `${BASE_TOPIC}/media/ack/`; // + {id}/set +const MEDIA_ACK_SUBSCRIBE = `${BASE_TOPIC}/media/ack/+/set`; const DEVICE = { identifiers: ['dickendock'], @@ -21,7 +28,13 @@ const DEVICE = { }; let client = null; -let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden +let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden +let mediaAckHandler = null; // (id) => void β€” einzelnen Favoriten quittieren +let mediaAckAllHandler = null; // () => void β€” alle Favoriten quittieren + +// Zuletzt published dynamische Media-Quittier-Buttons (zum sauberen Retracten +// wenn ein Favorit quittiert/gelΓΆscht wurde und der Button verschwinden soll) +let publishedMediaAckIds = new Set(); function connectMqtt() { if (!process.env.MQTT_HOST) { @@ -43,12 +56,22 @@ function connectMqtt() { client.publish(AVAILABILITY_TOPIC, 'online', { retain: true }); publishDiscovery(); client.subscribe(KOEPI_CHECK_CMD_TOPIC); + client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC); + client.subscribe(MEDIA_ACK_SUBSCRIBE); }); client.on('message', async (topic) => { - if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') { - try { await koepiCheckHandler(); } - catch (e) { console.error('MQTT KΓΆPi-Check Fehler:', e.message); } + try { + if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') { + await koepiCheckHandler(); + } else if (topic === MEDIA_ACK_ALL_CMD_TOPIC && typeof mediaAckAllHandler === 'function') { + await mediaAckAllHandler(); + } else if (topic.startsWith(MEDIA_ACK_TOPIC_PREFIX) && typeof mediaAckHandler === 'function') { + const id = topic.slice(MEDIA_ACK_TOPIC_PREFIX.length).replace(/\/set$/, ''); + if (id) await mediaAckHandler(id); + } + } catch (e) { + console.error('MQTT Command-Fehler:', e.message); } }); @@ -58,6 +81,7 @@ function connectMqtt() { function publishDiscovery() { if (!client) return; const entities = [ + // ── KΓΆPi ────────────────────────────────────────────────────────────── ['sensor', 'dickendock_koepi_angebot', { name: 'KΓΆPi Angebote', unique_id: 'dickendock_koepi_bestes_angebot', @@ -103,17 +127,8 @@ function publishDiscovery() { availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], - ['sensor', 'dickendock_online', { - name: 'DickenDock Nutzer online', - unique_id: 'dickendock_nutzer_online', - state_topic: `${BASE_TOPIC}/presence/state`, - json_attributes_topic: `${BASE_TOPIC}/presence/attributes`, - icon: 'mdi:account-multiple', - unit_of_measurement: 'Nutzer', - state_class: 'measurement', - availability_topic: AVAILABILITY_TOPIC, - device: DEVICE, - }], + + // ── Media ───────────────────────────────────────────────────────────── ['sensor', 'dickendock_media_anfragen', { name: 'Media Anfragen offen', unique_id: 'dickendock_media_anfragen', @@ -125,25 +140,161 @@ function publishDiscovery() { availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], - ['sensor', 'dickendock_bestellungen_offen', { - name: 'Bestellungen offen', - unique_id: 'dickendock_bestellungen_offen', - state_topic: `${BASE_TOPIC}/bestellungen/offen`, - json_attributes_topic: `${BASE_TOPIC}/bestellungen/attributes`, - icon: 'mdi:cube-send', - unit_of_measurement: 'Bestellungen', - state_class: 'measurement', + ['button', 'dickendock_media_ack_all', { + name: 'Media Alle quittieren', + unique_id: 'dickendock_media_ack_all', + command_topic: MEDIA_ACK_ALL_CMD_TOPIC, + icon: 'mdi:check-all', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], - ['sensor', 'dickendock_3ddruck_umsatz_monat', { - name: '3D-Druck Umsatz (Monat)', + + // ── 3D ──────────────────────────────────────────────────────────────── + ['sensor', 'dickendock_3d_umsatz_monat', { + name: '3D Umsatz Monat', unique_id: 'dickendock_3ddruck_umsatz_monat', state_topic: `${BASE_TOPIC}/druck/umsatz_monat`, - json_attributes_topic: `${BASE_TOPIC}/druck/attributes`, - icon: 'mdi:printer-3d', - device_class: 'monetary', - unit_of_measurement: '€', + icon: 'mdi:cash-multiple', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_umsatz_gesamt', { + name: '3D Umsatz Gesamt', + unique_id: 'dickendock_3d_umsatz_gesamt', + state_topic: `${BASE_TOPIC}/druck/umsatz_gesamt`, + icon: 'mdi:cash-multiple', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_grundkosten_monat', { + name: '3D Grundkosten Monat', + unique_id: 'dickendock_3d_grundkosten_monat', + state_topic: `${BASE_TOPIC}/druck/grundkosten_monat`, + icon: 'mdi:currency-eur-off', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_grundkosten_gesamt', { + name: '3D Grundkosten Gesamt', + unique_id: 'dickendock_3d_grundkosten_gesamt', + state_topic: `${BASE_TOPIC}/druck/grundkosten_gesamt`, + icon: 'mdi:currency-eur-off', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_rohgewinn_monat', { + name: '3D Rohgewinn Monat', + unique_id: 'dickendock_3d_rohgewinn_monat', + state_topic: `${BASE_TOPIC}/druck/rohgewinn_monat`, + icon: 'mdi:chart-line', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_rohgewinn_gesamt', { + name: '3D Rohgewinn Gesamt', + unique_id: 'dickendock_3d_rohgewinn_gesamt', + state_topic: `${BASE_TOPIC}/druck/rohgewinn_gesamt`, + icon: 'mdi:chart-line', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_ausgaben_monat', { + name: '3D Ausgaben Monat', + unique_id: 'dickendock_3d_ausgaben_monat', + state_topic: `${BASE_TOPIC}/druck/ausgaben_monat`, + icon: 'mdi:receipt-text-minus', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_ausgaben_gesamt', { + name: '3D Ausgaben Gesamt', + unique_id: 'dickendock_3d_ausgaben_gesamt', + state_topic: `${BASE_TOPIC}/druck/ausgaben_gesamt`, + icon: 'mdi:receipt-text-minus', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_nettogewinn_monat', { + name: '3D Nettogewinn Monat', + unique_id: 'dickendock_3d_nettogewinn_monat', + state_topic: `${BASE_TOPIC}/druck/nettogewinn_monat`, + icon: 'mdi:cash-check', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_nettogewinn_gesamt', { + name: '3D Nettogewinn Gesamt', + unique_id: 'dickendock_3d_nettogewinn_gesamt', + state_topic: `${BASE_TOPIC}/druck/nettogewinn_gesamt`, + icon: 'mdi:cash-check', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_offen', { + name: '3D Bestellungen offen', + unique_id: 'dickendock_bestellungen_offen', + state_topic: `${BASE_TOPIC}/druck/bestellungen_offen`, + icon: 'mdi:cube-send', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_warteliste', { + name: '3D Bestellungen Warteliste', + unique_id: 'dickendock_3d_bestellungen_warteliste', + state_topic: `${BASE_TOPIC}/druck/bestellungen_warteliste`, + icon: 'mdi:timer-sand', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_in_arbeit', { + name: '3D Bestellungen In Arbeit', + unique_id: 'dickendock_3d_bestellungen_in_arbeit', + state_topic: `${BASE_TOPIC}/druck/bestellungen_in_arbeit`, + icon: 'mdi:printer-3d-nozzle', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_fertig', { + name: '3D Bestellungen Fertig', + unique_id: 'dickendock_3d_bestellungen_fertig', + state_topic: `${BASE_TOPIC}/druck/bestellungen_fertig`, + icon: 'mdi:check-circle-outline', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_bezahlt', { + name: '3D Bestellungen Bezahlt', + unique_id: 'dickendock_3d_bestellungen_bezahlt', + state_topic: `${BASE_TOPIC}/druck/bestellungen_bezahlt`, + icon: 'mdi:cash-check', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_abgeschlossen', { + name: '3D Bestellungen Abgeschlossen', + unique_id: 'dickendock_3d_bestellungen_abgeschlossen', + state_topic: `${BASE_TOPIC}/druck/bestellungen_abgeschlossen`, + icon: 'mdi:archive-check-outline', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_gesamt', { + name: '3D Bestellungen Gesamt', + unique_id: 'dickendock_3d_bestellungen_gesamt', + state_topic: `${BASE_TOPIC}/druck/bestellungen_gesamt`, + icon: 'mdi:cube-outline', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + + // ── System ──────────────────────────────────────────────────────────── + ['sensor', 'dickendock_system_online', { + name: 'System Nutzer online', + unique_id: 'dickendock_nutzer_online', + state_topic: `${BASE_TOPIC}/presence/state`, + json_attributes_topic: `${BASE_TOPIC}/presence/attributes`, + icon: 'mdi:account-multiple', + unit_of_measurement: 'Nutzer', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, @@ -154,6 +305,8 @@ function publishDiscovery() { } } +function round2(n) { return Math.round((n || 0) * 100) / 100; } + function parsePrice(str) { if (!str) return Infinity; const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.')); @@ -191,7 +344,6 @@ function extractDateRange(offers) { if (d1 && (!start || d1 < start)) start = d1; if (d2 && (!end || d2 > end)) end = d2; } else if (found.length === 1) { - // Nur ein Datum im Text gefunden β†’ als Enddatum werten const d1 = parseGermanDate(found[0]); if (d1 && (!end || d1 > end)) end = d1; } @@ -210,7 +362,6 @@ function publishKoepiState({ offers, changed }) { if (!client?.connected) return; const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price)); - // State: alle Angebote als kompakte Zeile, auf HA-State-Limit (255 Zeichen) gekΓΌrzt let state = list.length ? list.map(o => `${o.retailer}: ${o.price}`).join(' | ') : 'keine Angebote'; @@ -241,42 +392,48 @@ function publishPresence() { }), { retain: true }); } -// Wird jede Minute vom Scheduler aufgerufen β€” offene (unquittierte) Media-Favoriten, -// systemweit ΓΌber alle Nutzer (spiegelt die Admin-Ansicht in "Media/Favoriten") +// Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen. +// Offene (unquittierte) Media-Favoriten, systemweit ΓΌber alle Nutzer. +// Legt zusΓ€tzlich pro offener Anfrage einen eigenen "Quittieren"-Button in HA an +// und entfernt Buttons fΓΌr Anfragen, die inzwischen quittiert/gelΓΆscht wurden. function publishMediaAnfragen() { if (!client?.connected) return; - const offen = db.prepare('SELECT COUNT(*) AS n FROM movie_favorites WHERE acknowledged=0').get().n; - const liste = db.prepare(` - SELECT f.title, u.username, f.added_at + const open = db.prepare(` + SELECT f.id, f.title, f.added_at, u.username FROM movie_favorites f JOIN users u ON u.id = f.user_id WHERE f.acknowledged = 0 - ORDER BY f.added_at DESC LIMIT 20 + ORDER BY f.added_at DESC `).all(); - client.publish(`${BASE_TOPIC}/media/anfragen`, String(offen), { retain: true }); - client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: liste }), { retain: true }); -} -// Wird jede Minute vom Scheduler aufgerufen β€” offene 3D-Druck-Bestellungen, -// systemweit ΓΌber alle Nutzer, Status-Verteilung als Attribut -function publishBestellungen() { - if (!client?.connected) return; - const orders = db.prepare('SELECT status, bezahlt, abgeholt FROM orders').all(); - const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 }; - for (const o of orders) { - if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++; - else if (o.bezahlt) byStatus.bezahlt++; - else if (o.status in byStatus) byStatus[o.status]++; + client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true }); + client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true }); + + const currentIds = new Set(open.map(f => String(f.id))); + + // Neue Buttons fΓΌr neu hinzugekommene offene Anfragen anlegen + for (const fav of open) { + const id = String(fav.id); + if (!publishedMediaAckIds.has(id)) { + const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255); + client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({ + name: label, + unique_id: `dickendock_media_ack_${id}`, + command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`, + icon: 'mdi:check-circle-outline', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }), { retain: true }); + } } - const offen = orders.length - byStatus.abgeschlossen; - client.publish(`${BASE_TOPIC}/bestellungen/offen`, String(offen), { retain: true }); - client.publish(`${BASE_TOPIC}/bestellungen/attributes`, JSON.stringify({ - ...byStatus, - gesamt: orders.length, - }), { retain: true }); + // Buttons fΓΌr inzwischen quittierte/gelΓΆschte Anfragen zurΓΌckziehen (leeres retained Payload) + for (const id of publishedMediaAckIds) { + if (!currentIds.has(id)) { + client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true }); + } + } + publishedMediaAckIds = currentIds; } -function round2(n) { return Math.round((n || 0) * 100) / 100; } - // Umsatz eines einzelnen Auftrags β€” Festpreis falls gesetzt, sonst Summe der // Positions-Festpreise (spiegelt die Logik aus statistik/routes.js) function getOrderRevenue(order) { @@ -288,8 +445,19 @@ function getOrderRevenue(order) { return r?.s || 0; } -// Wird jede Minute vom Scheduler aufgerufen β€” 3D-Druck-Umsatz im laufenden Kalendermonat, -// systemweit ΓΌber alle Nutzer (bezahlte AuftrΓ€ge nach bezahlt_am, sonst created_at) +// Grundkosten (Material/Zeit-Basis) eines Auftrags β€” spiegelt statistik/routes.js +function getOrderBaseCost(orderId) { + const r = db.prepare(` + SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) AS s + FROM order_items oi + LEFT JOIN calculations c ON c.id = oi.calculation_id + WHERE oi.order_id = ? + `).get(orderId); + return r?.s || 0; +} + +// Wird jede Minute vom Scheduler aufgerufen β€” umfangreiche 3D-Druck-Statistik, +// systemweit ΓΌber alle Nutzer, jeweils fΓΌr den laufenden Monat und komplett (all-time) function publishDruckStatistik() { if (!client?.connected) return; const now = new Date(); @@ -298,32 +466,67 @@ function publishDruckStatistik() { const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; const allOrders = db.prepare('SELECT * FROM orders').all(); - const bezahltDiesenMonat = allOrders.filter(o => { - if (!o.bezahlt) return false; + const paidAll = allOrders.filter(o => !!o.bezahlt); + const paidMonat = paidAll.filter(o => { const d = (o.bezahlt_am || o.created_at || '').slice(0, 10); return d >= monatsAnfang && d <= heute; }); - const umsatzMonat = bezahltDiesenMonat.reduce((s, o) => s + getOrderRevenue(o), 0); - const ausgabenMonat = db.prepare(` - SELECT COALESCE(SUM(amount), 0) AS s FROM expenses WHERE date>=? AND date<=? - `).get(monatsAnfang, heute).s; + const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0); + const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0); + const kostenGesamt = paidAll.reduce((s, o) => s + getOrderBaseCost(o.id), 0); + const kostenMonat = paidMonat.reduce((s, o) => s + getOrderBaseCost(o.id), 0); + const rohgewinnGesamt = umsatzGesamt - kostenGesamt; + const rohgewinnMonat = umsatzMonat - kostenMonat; - const offeneAuftraege = allOrders.filter(o => !(o.bezahlt && o.abgeholt)).length; + const ausgabenGesamt = db.prepare('SELECT COALESCE(SUM(amount),0) AS s FROM expenses').get().s; + const ausgabenMonat = db.prepare( + 'SELECT COALESCE(SUM(amount),0) AS s FROM expenses WHERE date>=? AND date<=?' + ).get(monatsAnfang, heute).s; - client.publish(`${BASE_TOPIC}/druck/umsatz_monat`, String(round2(umsatzMonat)), { retain: true }); - client.publish(`${BASE_TOPIC}/druck/attributes`, JSON.stringify({ - ausgaben_monat: round2(ausgabenMonat), - netto_monat: round2(umsatzMonat - ausgabenMonat), - bezahlte_auftraege_monat: bezahltDiesenMonat.length, - offene_auftraege_gesamt: offeneAuftraege, - }), { retain: true }); + const nettoGesamt = rohgewinnGesamt - ausgabenGesamt; + const nettoMonat = rohgewinnMonat - ausgabenMonat; + + const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 }; + for (const o of allOrders) { + if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++; + else if (o.bezahlt) byStatus.bezahlt++; + else if (o.status in byStatus) byStatus[o.status]++; + } + const offen = allOrders.length - byStatus.abgeschlossen; + + const pub = (topic, val) => client.publish(`${BASE_TOPIC}/${topic}`, String(val), { retain: true }); + pub('druck/umsatz_monat', round2(umsatzMonat)); + pub('druck/umsatz_gesamt', round2(umsatzGesamt)); + pub('druck/grundkosten_monat', round2(kostenMonat)); + pub('druck/grundkosten_gesamt', round2(kostenGesamt)); + pub('druck/rohgewinn_monat', round2(rohgewinnMonat)); + pub('druck/rohgewinn_gesamt', round2(rohgewinnGesamt)); + pub('druck/ausgaben_monat', round2(ausgabenMonat)); + pub('druck/ausgaben_gesamt', round2(ausgabenGesamt)); + pub('druck/nettogewinn_monat', round2(nettoMonat)); + pub('druck/nettogewinn_gesamt', round2(nettoGesamt)); + pub('druck/bestellungen_offen', offen); + pub('druck/bestellungen_warteliste', byStatus.warteliste); + pub('druck/bestellungen_in_arbeit', byStatus.in_arbeit); + pub('druck/bestellungen_fertig', byStatus.fertig); + pub('druck/bestellungen_bezahlt', byStatus.bezahlt); + pub('druck/bestellungen_abgeschlossen', byStatus.abgeschlossen); + pub('druck/bestellungen_gesamt', allOrders.length); } -// Wird von index.js gesetzt, damit der HA-Button ohne Require-Zirkel den Check auslΓΆsen kann -function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } +// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden +function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } +function setMediaAckHandler(fn) { mediaAckHandler = fn; } +function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; } module.exports = { - connectMqtt, publishKoepiState, publishPresence, setKoepiCheckHandler, - publishMediaAnfragen, publishBestellungen, publishDruckStatistik, + connectMqtt, + publishKoepiState, + publishPresence, + publishMediaAnfragen, + publishDruckStatistik, + setKoepiCheckHandler, + setMediaAckHandler, + setMediaAckAllHandler, }; diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 73d3b7e..5ca6294 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -881,15 +881,34 @@ router.get('/favorites/unacked', authenticate, (req, res) => { -// POST /favorites/acknowledge-all – Admin: alle quittieren -router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => { - // Betroffene Favoriten vor dem Update holen (fΓΌr Pushover-Benachrichtigungen) +// ── Quittier-Logik (wiederverwendbar: HTTP-Routen + MQTT-Buttons) ─────────── +function ackFavorite(id) { + const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(id); + if (!fav) return null; + db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(fav.id); + try { + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id); + if (poCfg?.app_token && poCfg?.user_key) { + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + title: 'βœ… Favorit bestΓ€tigt', + message: `"${fav.title}" wurde von einem Admin bestΓ€tigt.`, + priority: 0, + }), + }).catch(() => {}); + } + } catch {} + return fav; +} + +function ackAllFavorites() { const toAck = db.prepare('SELECT id, user_id, title FROM movie_favorites WHERE acknowledged=0').all(); db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run(); - res.json({ ok: true }); - // Pushover an jeden betroffenen User (fire & forget, pro User gruppiert) try { - // User mit Pushover-Konfiguration ermitteln const userIds = [...new Set(toAck.map(f => f.user_id))]; for (const userId of userIds) { const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); @@ -911,6 +930,13 @@ router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) }).catch(() => {}); } } catch {} + return toAck.length; +} + +// POST /favorites/acknowledge-all – Admin: alle quittieren +router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => { + ackAllFavorites(); + res.json({ ok: true }); }); // POST /favorites – Favorit hinzufΓΌgen (alle User inkl. Admin bekommen Pushover) @@ -958,27 +984,9 @@ router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { // POST /favorites/:id/acknowledge – Admin: einzelnen quittieren router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => { - const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(req.params.id); + const fav = ackFavorite(req.params.id); if (!fav) return res.status(404).json({ error: 'Nicht gefunden' }); - db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(fav.id); res.json({ ok: true }); - // Pushover an den User schicken falls eingerichtet (fire & forget) - try { - const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id); - if (poCfg?.app_token && poCfg?.user_key) { - fetch('https://api.pushover.net/1/messages.json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token: poCfg.app_token, - user: poCfg.user_key, - title: 'βœ… Favorit bestΓ€tigt', - message: `"${fav.title}" wurde von einem Admin bestΓ€tigt.`, - priority: 0, - }), - }).catch(() => {}); - } - } catch {} }); // POST /favorites/:id/unacknowledge – Admin: Quittierung rΓΌckgΓ€ngig machen @@ -1238,3 +1246,5 @@ router.get('/debug-xrel-tv', authenticate, async (req, res) => { }); module.exports = router; +module.exports.ackFavorite = ackFavorite; +module.exports.ackAllFavorites = ackAllFavorites;