feat: HA-Entitaeten mit Bereichs-Praefixen, Media-Anfragen quittierbar per Button, umfangreiche 3D-Statistik
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
// 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).
|
||||
|
||||
@@ -12,6 +16,9 @@ const db = require('./db');
|
||||
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 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'],
|
||||
@@ -22,6 +29,12 @@ const DEVICE = {
|
||||
|
||||
let client = null;
|
||||
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) => {
|
||||
try {
|
||||
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
|
||||
try { await koepiCheckHandler(); }
|
||||
catch (e) { console.error('MQTT KöPi-Check Fehler:', e.message); }
|
||||
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]++;
|
||||
}
|
||||
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,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// 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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user