533 lines
23 KiB
JavaScript
533 lines
23 KiB
JavaScript
// ── Home Assistant MQTT Integration ──────────────────────────────────────────
|
||
// Verbindet sich zum Mosquitto-Broker (HA Add-on) und meldet DickenDock-Entitäten
|
||
// 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 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'],
|
||
name: 'DickenDock',
|
||
manufacturer: 'Flo',
|
||
model: 'DockStation',
|
||
};
|
||
|
||
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) {
|
||
console.log('MQTT: MQTT_HOST nicht gesetzt – Home-Assistant-Integration deaktiviert.');
|
||
return;
|
||
}
|
||
|
||
client = mqtt.connect({
|
||
host: process.env.MQTT_HOST,
|
||
port: parseInt(process.env.MQTT_PORT || '1883', 10),
|
||
username: process.env.MQTT_USERNAME,
|
||
password: process.env.MQTT_PASSWORD,
|
||
will: { topic: AVAILABILITY_TOPIC, payload: 'offline', retain: true },
|
||
reconnectPeriod: 5000,
|
||
});
|
||
|
||
client.on('connect', () => {
|
||
console.log(`MQTT verbunden: ${process.env.MQTT_HOST}:${process.env.MQTT_PORT || 1883}`);
|
||
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') {
|
||
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);
|
||
}
|
||
});
|
||
|
||
client.on('error', (e) => console.error('MQTT Fehler:', e.message));
|
||
}
|
||
|
||
function publishDiscovery() {
|
||
if (!client) return;
|
||
const entities = [
|
||
// ── KöPi ──────────────────────────────────────────────────────────────
|
||
['sensor', 'dickendock_koepi_angebot', {
|
||
name: 'KöPi Angebote',
|
||
unique_id: 'dickendock_koepi_bestes_angebot',
|
||
state_topic: `${BASE_TOPIC}/koepi/state`,
|
||
json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`,
|
||
icon: 'mdi:beer',
|
||
availability_topic: AVAILABILITY_TOPIC,
|
||
device: DEVICE,
|
||
}],
|
||
['binary_sensor', 'dickendock_koepi_changed', {
|
||
name: 'KöPi Angebot geändert',
|
||
unique_id: 'dickendock_koepi_changed',
|
||
state_topic: `${BASE_TOPIC}/koepi/changed`,
|
||
payload_on: 'ON',
|
||
payload_off: 'OFF',
|
||
icon: 'mdi:bell-alert',
|
||
availability_topic: AVAILABILITY_TOPIC,
|
||
device: DEVICE,
|
||
}],
|
||
['button', 'dickendock_koepi_check', {
|
||
name: 'KöPi Check jetzt',
|
||
unique_id: 'dickendock_koepi_check',
|
||
command_topic: KOEPI_CHECK_CMD_TOPIC,
|
||
icon: 'mdi:refresh',
|
||
availability_topic: AVAILABILITY_TOPIC,
|
||
device: DEVICE,
|
||
}],
|
||
['sensor', 'dickendock_koepi_start', {
|
||
name: 'KöPi Angebot Start',
|
||
unique_id: 'dickendock_koepi_start',
|
||
state_topic: `${BASE_TOPIC}/koepi/start`,
|
||
device_class: 'date',
|
||
icon: 'mdi:calendar-start',
|
||
availability_topic: AVAILABILITY_TOPIC,
|
||
device: DEVICE,
|
||
}],
|
||
['sensor', 'dickendock_koepi_ende', {
|
||
name: 'KöPi Angebot Ende',
|
||
unique_id: 'dickendock_koepi_ende',
|
||
state_topic: `${BASE_TOPIC}/koepi/ende`,
|
||
device_class: 'date',
|
||
icon: 'mdi:calendar-end',
|
||
availability_topic: AVAILABILITY_TOPIC,
|
||
device: DEVICE,
|
||
}],
|
||
|
||
// ── Media ─────────────────────────────────────────────────────────────
|
||
['sensor', 'dickendock_media_anfragen', {
|
||
name: 'Media Anfragen offen',
|
||
unique_id: 'dickendock_media_anfragen',
|
||
state_topic: `${BASE_TOPIC}/media/anfragen`,
|
||
json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`,
|
||
icon: 'mdi:movie-open-star',
|
||
unit_of_measurement: 'Anfragen',
|
||
state_class: 'measurement',
|
||
availability_topic: AVAILABILITY_TOPIC,
|
||
device: DEVICE,
|
||
}],
|
||
['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,
|
||
}],
|
||
|
||
// ── 3D ────────────────────────────────────────────────────────────────
|
||
['sensor', 'dickendock_3d_umsatz_monat', {
|
||
name: '3D Umsatz Monat',
|
||
unique_id: 'dickendock_3ddruck_umsatz_monat',
|
||
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
|
||
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,
|
||
}],
|
||
];
|
||
for (const [component, objectId, payload] of entities) {
|
||
client.publish(`homeassistant/${component}/${objectId}/config`, JSON.stringify(payload), { retain: true });
|
||
}
|
||
}
|
||
|
||
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(',', '.'));
|
||
return Number.isNaN(n) ? Infinity : n;
|
||
}
|
||
|
||
// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel.
|
||
// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur
|
||
// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen).
|
||
function parseGermanDate(str, ref = new Date()) {
|
||
const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/);
|
||
if (!m) return null;
|
||
const day = parseInt(m[1], 10);
|
||
const month = parseInt(m[2], 10) - 1;
|
||
let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear();
|
||
const d = new Date(year, month, day);
|
||
if (!m[3]) {
|
||
const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth());
|
||
if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint
|
||
else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint
|
||
}
|
||
return d;
|
||
}
|
||
|
||
// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin
|
||
// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum)
|
||
function extractDateRange(offers) {
|
||
let start = null, end = null;
|
||
for (const o of offers) {
|
||
const text = o.dateRange || '';
|
||
const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]);
|
||
if (found.length >= 2) {
|
||
const d1 = parseGermanDate(found[0]);
|
||
const d2 = parseGermanDate(found[1]);
|
||
if (d1 && (!start || d1 < start)) start = d1;
|
||
if (d2 && (!end || d2 > end)) end = d2;
|
||
} else if (found.length === 1) {
|
||
const d1 = parseGermanDate(found[0]);
|
||
if (d1 && (!end || d1 > end)) end = d1;
|
||
}
|
||
}
|
||
return { start, end };
|
||
}
|
||
|
||
function toIsoDate(d) {
|
||
if (!d) return null;
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
}
|
||
|
||
// Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen
|
||
function publishKoepiState({ offers, changed }) {
|
||
if (!client?.connected) return;
|
||
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
||
|
||
let state = list.length
|
||
? list.map(o => `${o.retailer}: ${o.price}`).join(' | ')
|
||
: 'keine Angebote';
|
||
if (state.length > 255) state = state.slice(0, 252) + '…';
|
||
|
||
client.publish(`${BASE_TOPIC}/koepi/state`, state, { retain: true });
|
||
client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({
|
||
angebote: list,
|
||
anzahl: list.length,
|
||
letzter_check: new Date().toISOString(),
|
||
}), { retain: true });
|
||
client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { retain: true });
|
||
|
||
const { start, end } = extractDateRange(list);
|
||
client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true });
|
||
client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { retain: true });
|
||
}
|
||
|
||
// Wird jede Minute vom Scheduler in index.js aufgerufen
|
||
function publishPresence() {
|
||
if (!client?.connected) return;
|
||
const online = db.prepare(`
|
||
SELECT username FROM users WHERE last_active_at > datetime('now','localtime','-5 minutes')
|
||
`).all();
|
||
client.publish(`${BASE_TOPIC}/presence/state`, String(online.length), { retain: true });
|
||
client.publish(`${BASE_TOPIC}/presence/attributes`, JSON.stringify({
|
||
nutzer: online.map(u => u.username),
|
||
}), { retain: true });
|
||
}
|
||
|
||
// 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 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
|
||
`).all();
|
||
|
||
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;
|
||
}
|
||
|
||
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
|
||
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
|
||
function getOrderRevenue(order) {
|
||
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
|
||
const r = db.prepare(`
|
||
SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s
|
||
FROM order_items WHERE order_id=? AND custom_price IS NOT NULL
|
||
`).get(order.id);
|
||
return r?.s || 0;
|
||
}
|
||
|
||
// 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();
|
||
const pad = n => String(n).padStart(2, '0');
|
||
const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||
|
||
const allOrders = db.prepare('SELECT * FROM orders').all();
|
||
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 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 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;
|
||
|
||
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, 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,
|
||
publishMediaAnfragen,
|
||
publishDruckStatistik,
|
||
setKoepiCheckHandler,
|
||
setMediaAckHandler,
|
||
setMediaAckAllHandler,
|
||
};
|