feat: HA-Entitaeten mit Bereichs-Praefixen, Media-Anfragen quittierbar per Button, umfangreiche 3D-Statistik

This commit is contained in:
2026-07-10 19:57:10 +02:00
parent 12094901a4
commit bbf937c671
3 changed files with 329 additions and 106 deletions

View File

@@ -257,12 +257,23 @@ server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
// ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── // ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
const db = require('./db'); const db = require('./db');
const koepiRoutes = require('./tools/koepi/routes'); const koepiRoutes = require('./tools/koepi/routes');
const mediaRoutes = require('./tools/media/routes');
const mqttClient = require('./mqtt'); const mqttClient = require('./mqtt');
mqttClient.connectMqtt(); mqttClient.connectMqtt();
// HA-Button "KöPi Check jetzt" löst denselben Check wie der 06:00-Cron aus // 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) // (respektiert den normalen Diff — Pushover geht nur bei echter Änderung raus)
mqttClient.setKoepiCheckHandler(() => koepiRoutes.runDailyCheck()); 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 = {}) { async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
try { try {
@@ -290,7 +301,6 @@ setTimeout(() => {
async function runScheduler() { async function runScheduler() {
mqttClient.publishPresence(); mqttClient.publishPresence();
mqttClient.publishMediaAnfragen(); mqttClient.publishMediaAnfragen();
mqttClient.publishBestellungen();
mqttClient.publishDruckStatistik(); mqttClient.publishDruckStatistik();
try { try {
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;

View File

@@ -3,6 +3,10 @@
// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten // per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten
// erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT". // 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 // 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). // der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops).
@@ -12,6 +16,9 @@ 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 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 = { const DEVICE = {
identifiers: ['dickendock'], identifiers: ['dickendock'],
@@ -22,6 +29,12 @@ const DEVICE = {
let client = null; 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() { function connectMqtt() {
if (!process.env.MQTT_HOST) { if (!process.env.MQTT_HOST) {
@@ -43,12 +56,22 @@ function connectMqtt() {
client.publish(AVAILABILITY_TOPIC, 'online', { retain: true }); client.publish(AVAILABILITY_TOPIC, 'online', { retain: true });
publishDiscovery(); publishDiscovery();
client.subscribe(KOEPI_CHECK_CMD_TOPIC); client.subscribe(KOEPI_CHECK_CMD_TOPIC);
client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC);
client.subscribe(MEDIA_ACK_SUBSCRIBE);
}); });
client.on('message', async (topic) => { client.on('message', async (topic) => {
try {
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') { if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
try { await koepiCheckHandler(); } await koepiCheckHandler();
catch (e) { console.error('MQTT KöPi-Check Fehler:', e.message); } } 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() { function publishDiscovery() {
if (!client) return; if (!client) return;
const entities = [ const entities = [
// ── KöPi ──────────────────────────────────────────────────────────────
['sensor', 'dickendock_koepi_angebot', { ['sensor', 'dickendock_koepi_angebot', {
name: 'KöPi Angebote', name: 'KöPi Angebote',
unique_id: 'dickendock_koepi_bestes_angebot', unique_id: 'dickendock_koepi_bestes_angebot',
@@ -103,17 +127,8 @@ function publishDiscovery() {
availability_topic: AVAILABILITY_TOPIC, availability_topic: AVAILABILITY_TOPIC,
device: DEVICE, device: DEVICE,
}], }],
['sensor', 'dickendock_online', {
name: 'DickenDock Nutzer online', // ── Media ─────────────────────────────────────────────────────────────
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,
}],
['sensor', 'dickendock_media_anfragen', { ['sensor', 'dickendock_media_anfragen', {
name: 'Media Anfragen offen', name: 'Media Anfragen offen',
unique_id: 'dickendock_media_anfragen', unique_id: 'dickendock_media_anfragen',
@@ -125,25 +140,161 @@ function publishDiscovery() {
availability_topic: AVAILABILITY_TOPIC, availability_topic: AVAILABILITY_TOPIC,
device: DEVICE, device: DEVICE,
}], }],
['sensor', 'dickendock_bestellungen_offen', { ['button', 'dickendock_media_ack_all', {
name: 'Bestellungen offen', name: 'Media Alle quittieren',
unique_id: 'dickendock_bestellungen_offen', unique_id: 'dickendock_media_ack_all',
state_topic: `${BASE_TOPIC}/bestellungen/offen`, command_topic: MEDIA_ACK_ALL_CMD_TOPIC,
json_attributes_topic: `${BASE_TOPIC}/bestellungen/attributes`, icon: 'mdi:check-all',
icon: 'mdi:cube-send',
unit_of_measurement: 'Bestellungen',
state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, availability_topic: AVAILABILITY_TOPIC,
device: DEVICE, 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', unique_id: 'dickendock_3ddruck_umsatz_monat',
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`, state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
json_attributes_topic: `${BASE_TOPIC}/druck/attributes`, icon: 'mdi:cash-multiple',
icon: 'mdi:printer-3d', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
device_class: 'monetary', availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
unit_of_measurement: '€', }],
['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', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, availability_topic: AVAILABILITY_TOPIC,
device: DEVICE, device: DEVICE,
@@ -154,6 +305,8 @@ function publishDiscovery() {
} }
} }
function round2(n) { return Math.round((n || 0) * 100) / 100; }
function parsePrice(str) { function parsePrice(str) {
if (!str) return Infinity; if (!str) return Infinity;
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.')); 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 (d1 && (!start || d1 < start)) start = d1;
if (d2 && (!end || d2 > end)) end = d2; if (d2 && (!end || d2 > end)) end = d2;
} else if (found.length === 1) { } else if (found.length === 1) {
// Nur ein Datum im Text gefunden → als Enddatum werten
const d1 = parseGermanDate(found[0]); const d1 = parseGermanDate(found[0]);
if (d1 && (!end || d1 > end)) end = d1; if (d1 && (!end || d1 > end)) end = d1;
} }
@@ -210,7 +362,6 @@ function publishKoepiState({ offers, changed }) {
if (!client?.connected) return; if (!client?.connected) return;
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price)); 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 let state = list.length
? list.map(o => `${o.retailer}: ${o.price}`).join(' | ') ? list.map(o => `${o.retailer}: ${o.price}`).join(' | ')
: 'keine Angebote'; : 'keine Angebote';
@@ -241,41 +392,47 @@ function publishPresence() {
}), { retain: true }); }), { retain: true });
} }
// Wird jede Minute vom Scheduler aufgerufen — offene (unquittierte) Media-Favoriten, // Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen.
// systemweit über alle Nutzer (spiegelt die Admin-Ansicht in "Media/Favoriten") // 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() { function publishMediaAnfragen() {
if (!client?.connected) return; if (!client?.connected) return;
const offen = db.prepare('SELECT COUNT(*) AS n FROM movie_favorites WHERE acknowledged=0').get().n; const open = db.prepare(`
const liste = db.prepare(` SELECT f.id, f.title, f.added_at, u.username
SELECT f.title, u.username, f.added_at
FROM movie_favorites f JOIN users u ON u.id = f.user_id FROM movie_favorites f JOIN users u ON u.id = f.user_id
WHERE f.acknowledged = 0 WHERE f.acknowledged = 0
ORDER BY f.added_at DESC LIMIT 20 ORDER BY f.added_at DESC
`).all(); `).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, client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true });
// systemweit über alle Nutzer, Status-Verteilung als Attribut client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true });
function publishBestellungen() {
if (!client?.connected) return; const currentIds = new Set(open.map(f => String(f.id)));
const orders = db.prepare('SELECT status, bezahlt, abgeholt FROM orders').all();
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 }; // Neue Buttons für neu hinzugekommene offene Anfragen anlegen
for (const o of orders) { for (const fav of open) {
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++; const id = String(fav.id);
else if (o.bezahlt) byStatus.bezahlt++; if (!publishedMediaAckIds.has(id)) {
else if (o.status in byStatus) byStatus[o.status]++; const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255);
} client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({
const offen = orders.length - byStatus.abgeschlossen; name: label,
client.publish(`${BASE_TOPIC}/bestellungen/offen`, String(offen), { retain: true }); unique_id: `dickendock_media_ack_${id}`,
client.publish(`${BASE_TOPIC}/bestellungen/attributes`, JSON.stringify({ command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`,
...byStatus, icon: 'mdi:check-circle-outline',
gesamt: orders.length, availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}), { retain: true }); }), { retain: true });
} }
}
function round2(n) { return Math.round((n || 0) * 100) / 100; } // 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;
}
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der // Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js) // Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
@@ -288,8 +445,19 @@ function getOrderRevenue(order) {
return r?.s || 0; return r?.s || 0;
} }
// Wird jede Minute vom Scheduler aufgerufen — 3D-Druck-Umsatz im laufenden Kalendermonat, // Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js
// systemweit über alle Nutzer (bezahlte Aufträge nach bezahlt_am, sonst created_at) 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() { function publishDruckStatistik() {
if (!client?.connected) return; if (!client?.connected) return;
const now = new Date(); const now = new Date();
@@ -298,32 +466,67 @@ function publishDruckStatistik() {
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const allOrders = db.prepare('SELECT * FROM orders').all(); const allOrders = db.prepare('SELECT * FROM orders').all();
const bezahltDiesenMonat = allOrders.filter(o => { const paidAll = allOrders.filter(o => !!o.bezahlt);
if (!o.bezahlt) return false; const paidMonat = paidAll.filter(o => {
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10); const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
return d >= monatsAnfang && d <= heute; return d >= monatsAnfang && d <= heute;
}); });
const umsatzMonat = bezahltDiesenMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
const ausgabenMonat = db.prepare(` const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0);
SELECT COALESCE(SUM(amount), 0) AS s FROM expenses WHERE date>=? AND date<=? const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
`).get(monatsAnfang, heute).s; 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 }); const nettoGesamt = rohgewinnGesamt - ausgabenGesamt;
client.publish(`${BASE_TOPIC}/druck/attributes`, JSON.stringify({ const nettoMonat = rohgewinnMonat - ausgabenMonat;
ausgaben_monat: round2(ausgabenMonat),
netto_monat: round2(umsatzMonat - ausgabenMonat), const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
bezahlte_auftraege_monat: bezahltDiesenMonat.length, for (const o of allOrders) {
offene_auftraege_gesamt: offeneAuftraege, if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
}), { retain: true }); 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 // Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; }
module.exports = { module.exports = {
connectMqtt, publishKoepiState, publishPresence, setKoepiCheckHandler, connectMqtt,
publishMediaAnfragen, publishBestellungen, publishDruckStatistik, publishKoepiState,
publishPresence,
publishMediaAnfragen,
publishDruckStatistik,
setKoepiCheckHandler,
setMediaAckHandler,
setMediaAckAllHandler,
}; };

View File

@@ -881,15 +881,34 @@ router.get('/favorites/unacked', authenticate, (req, res) => {
// POST /favorites/acknowledge-all Admin: alle quittieren // ── Quittier-Logik (wiederverwendbar: HTTP-Routen + MQTT-Buttons) ───────────
router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => { function ackFavorite(id) {
// Betroffene Favoriten vor dem Update holen (für Pushover-Benachrichtigungen) 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(); 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(); 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 { try {
// User mit Pushover-Konfiguration ermitteln
const userIds = [...new Set(toAck.map(f => f.user_id))]; const userIds = [...new Set(toAck.map(f => f.user_id))];
for (const userId of userIds) { for (const userId of userIds) {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); 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(() => {});
} }
} 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) // 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 // POST /favorites/:id/acknowledge Admin: einzelnen quittieren
router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => { 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' }); 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 }); 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 // 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 = router;
module.exports.ackFavorite = ackFavorite;
module.exports.ackAllFavorites = ackAllFavorites;