Files
Symcon_Belevo_Energiemanage…/Abrechnung/module.php
2025-11-06 10:40:51 +01:00

507 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
include_once __DIR__ . '/libs/vendor/autoload.php'; // TCPDF via Composer
class Abrechnung extends IPSModule
{
public function Create()
{
parent::Create();
// Eigenschaften
$this->RegisterPropertyString('Users', '[]');
$this->RegisterPropertyString('PowerMeters', '[]');
$this->RegisterPropertyString('WaterMeters', '[]');
$this->RegisterPropertyString('Tariffs', '[]');
// Variablen
$this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1);
$this->RegisterVariableInteger('ToDate', 'Enddatum', '~UnixTimestamp', 2);
$this->RegisterVariableString('LastResult', 'Letzte Abrechnung', '', 3);
$this->EnableAction('FromDate');
$this->EnableAction('ToDate');
// Abrechnungs-Button
$this->RegisterScript('StartBilling', 'Abrechnung starten', "<?php IPS_RequestAction(" . $this->InstanceID . ", 'StartBilling', ''); ?>");
// 🧾 Media-Objekt für PDF-Ergebnis
$this->RegisterMediaDocument('InvoicePDF', 'Letzte Rechnung', 'pdf');
}
public function ApplyChanges()
{
parent::ApplyChanges();
IPS_LogMessage('Abrechnung', 'Modul geladen');
}
private function RegisterMediaDocument($Ident, $Name, $Extension, $Position = 0)
{
$mid = @IPS_GetObjectIDByIdent($Ident, $this->InstanceID);
if ($mid === false) {
$mid = IPS_CreateMedia(5); // 5 = Document
IPS_SetParent($mid, $this->InstanceID);
IPS_SetIdent($mid, $Ident);
IPS_SetName($mid, $Name);
IPS_SetPosition($mid, $Position);
IPS_SetMediaFile($mid, 'media/' . $mid . '.' . $Extension, false);
}
}
public function RequestAction($Ident, $Value)
{
IPS_LogMessage('Abrechnung', "RequestAction: $Ident");
switch ($Ident) {
case 'FromDate':
case 'ToDate':
SetValue($this->GetIDForIdent($Ident), $Value);
break;
case 'StartBilling':
IPS_LogMessage('Abrechnung', 'Starte Abrechnung...');
try {
$pdfContent = $this->GenerateInvoices();
if (!$pdfContent) {
echo "❌ Fehler bei der PDF-Erstellung";
return;
}
$mediaID = $this->GetIDForIdent('InvoicePDF');
IPS_SetMediaContent($mediaID, base64_encode($pdfContent));
SetValue($this->GetIDForIdent('LastResult'), 'Abrechnung vom ' . date('d.m.Y H:i'));
IPS_LogMessage('Abrechnung', '✅ Abrechnung erfolgreich erstellt');
echo "✅ PDF erfolgreich erstellt.";
} catch (Throwable $e) {
IPS_LogMessage('Abrechnung', '💥 Fehler: ' . $e->getMessage());
echo "❌ Ausnahmefehler: " . $e->getMessage();
}
break;
}
}
// ====================== PDF-Erstellung ======================
public function GenerateInvoices()
{
$from = GetValue($this->GetIDForIdent('FromDate'));
$to = GetValue($this->GetIDForIdent('ToDate'));
$users = json_decode($this->ReadPropertyString('Users'), true);
$power = json_decode($this->ReadPropertyString('PowerMeters'), true);
$water = json_decode($this->ReadPropertyString('WaterMeters'), true);
$tariffs = json_decode($this->ReadPropertyString('Tariffs'), true);
if (!class_exists('TCPDF')) {
IPS_LogMessage('Abrechnung', '❌ TCPDF nicht gefunden');
return false;
}
$pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
$pdf->SetCreator('IPSymcon Abrechnung');
$pdf->SetMargins(15, 15, 15);
$pdf->SetAutoPageBreak(true, 20);
$pdf->SetFont('dejavusans', '', 9);
foreach ($users as $user) {
$this->BuildUserInvoice($pdf, $user, $power, $water, $tariffs, $from, $to);
}
return $pdf->Output('Abrechnung.pdf', 'S');
}
private function BuildUserInvoice($pdf, $user, $power, $water, $tariffs, $from, $to)
{
$pdf->AddPage();
$html = "
<h2>Rechnung für {$user['name']}</h2>
<p>{$user['address']}<br>{$user['city']}</p>
<p><strong>Zeitraum:</strong> " . date('d.m.Y', $from) . " " . date('d.m.Y', $to) . "</p><br>";
// ⚡ Stromkosten
$html .= "<h3>⚡ Stromkosten</h3>";
$powerResult = $this->CalculatePowerCosts($power, $tariffs, $user['id'], $from, $to);
$html .= $powerResult['html'];
$totalPower = $powerResult['sum'];
// 💧 Nebenkosten
$html .= "<h3>💧 Nebenkosten (Wasser/Wärme)</h3>";
$additionalResult = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to);
$html .= $additionalResult['html'];
$totalAdditional = $additionalResult['sum'];
$grandTotal = $totalPower + $totalAdditional;
$html .= "<h3 style='text-align:right;'>Gesamttotal: <strong>" . number_format($grandTotal, 2) . " CHF</strong></h3>";
$pdf->writeHTML($html, true, false, true, false, '');
}
// ====================== Stromkosten ======================
private function CalculatePowerCosts($powerMeters, $tariffs, $userId, $from, $to)
{
// Tabelle Kopf
$html = "<table border='1' cellspacing='0' cellpadding='3' width='100%' style='font-size:8px;'>
<tr style='background-color:#f0f0f0;'>
<th>Zähler</th>
<th>Import (kWh)</th>
<th>Export (kWh)</th>
<th>Solarbezug (kWh)</th>
<th>Netzbezug (kWh)</th>
<th>PV-Einspeisung (kWh)</th>
<th>PV-Verkauf (kWh)</th>
<th>Kosten Solar (CHF)</th>
<th>Kosten Netz (CHF)</th>
<th>Ertrag Einspeise (CHF)</th>
<th>Ertrag Verkauf (CHF)</th>
<th>Summe (CHF)</th>
</tr>";
// Sammler je Zähler
$acc = []; // name => arrays
// Index Zähler für User
$meters = [];
foreach ($powerMeters as $m) {
if ($m['user_id'] != $userId) continue;
$name = $m['name'];
$meters[$name] = [
'name' => $name,
'imp' => $m['var_import'] ?? null,
'exp' => $m['var_export'] ?? null
];
$acc[$name] = [
'imp'=>0.0,'exp'=>0.0,
'solar_bezug'=>0.0,'netz_bezug'=>0.0,
'pv_einspeisung'=>0.0,'pv_verkauf'=>0.0,
'cost_solar'=>0.0,'cost_grid'=>0.0,
'rev_feedin'=>0.0,'rev_solar_sale'=>0.0
];
}
if (empty($meters)) {
$html .= "<tr><td colspan='12' align='center'>Keine Stromzähler für diesen Benutzer</td></tr></table><br>";
return ['html'=>$html, 'sum'=>0.0];
}
// 15-Minuten-Schritte
for ($ts = $from; $ts < $to; $ts += 900) {
$slotEnd = min($to, $ts + 900);
// Deltas pro Zähler & Summen
$impTotal = 0.0;
$expTotal = 0.0;
$slot = []; // name => ['imp'=>x,'exp'=>y]
foreach ($meters as $name => $mm) {
$impDelta = 0.0;
$expDelta = 0.0;
if (!empty($mm['imp']) && IPS_VariableExists((int)$mm['imp'])) {
$impDelta = $this->readDelta((int)$mm['imp'], $ts, $slotEnd);
}
if (!empty($mm['exp']) && IPS_VariableExists((int)$mm['exp'])) {
$expDelta = $this->readDelta((int)$mm['exp'], $ts, $slotEnd);
}
// kWh annehmen (oder deine Einheit hier keine Umrechnung)
$impDelta = max(0.0, $impDelta);
$expDelta = max(0.0, $expDelta);
$slot[$name] = ['imp'=>$impDelta, 'exp'=>$expDelta];
$impTotal += $impDelta;
$expTotal += $expDelta;
}
// Tarife zum Slot-Start
$pGrid = $this->getTariffPriceAt($tariffs, ['netztarif','netzstrom','strombezug','netz'], $ts); // Rp/kWh
$pSolar = $this->getTariffPriceAt($tariffs, ['solar','solarstom','pv','eigenverbrauch'], $ts); // Rp/kWh
$pFeed = $this->getTariffPriceAt($tariffs, ['einspeisung','feedin','einspeisetarif'], $ts); // Rp/kWh
$pSolarSell = $this->getTariffPriceAt($tariffs, ['solarverkauf','pv-verkauf'], $ts); // Rp/kWh
if ($pSolarSell === null) $pSolarSell = $pSolar; // falls kein eigener Solarverkaufstarif
// Falls alles 0 Slot überspringen
if ($impTotal <= 0.0 && $expTotal <= 0.0) continue;
// Case A: impTotal <= expTotal
if ($impTotal <= $expTotal) {
$ratio = ($expTotal > 0.0) ? ($impTotal / $expTotal) : 0.0;
foreach ($meters as $name => $_) {
$imp = $slot[$name]['imp'];
$exp = $slot[$name]['exp'];
// Energiemengen
$acc[$name]['imp'] += $imp;
$acc[$name]['exp'] += $exp;
$acc[$name]['solar_bezug'] += $imp; // kompletter Import deckt sich aus PV (bis exp reicht)
$acc[$name]['netz_bezug'] += 0.0;
$acc[$name]['pv_verkauf'] += $ratio * $exp; // Anteil Export, der im Haus bleibt (verkauft als Solar)
$acc[$name]['pv_einspeisung'] += max(0.0, (1.0 - $ratio) * $exp); // Überschuss ins Netz
// Kosten / Erträge (Rp → CHF)
$acc[$name]['cost_grid'] += 0.0;
if ($pSolarSell !== null) $acc[$name]['cost_solar'] += ($imp * $pSolarSell) / 100.0;
if ($pSolar !== null) $acc[$name]['rev_feedin'] += ($ratio * $exp * $pSolar) / 100.0;
if ($pFeed !== null) $acc[$name]['rev_solar_sale'] += (max(0.0, (1.0 - $ratio) * $exp) * $pFeed) / 100.0;
}
}
// Case B: impTotal > expTotal
else {
$ratio = ($impTotal > 0.0) ? ($expTotal / $impTotal) : 0.0;
foreach ($meters as $name => $_) {
$imp = $slot[$name]['imp'];
$exp = $slot[$name]['exp'];
// Energiemengen
$acc[$name]['imp'] += $imp;
$acc[$name]['exp'] += $exp;
$acc[$name]['solar_bezug'] += $ratio * $imp; // Teil des Imports aus PV
$acc[$name]['netz_bezug'] += max(0.0, (1.0 - $ratio) * $imp);
$acc[$name]['pv_verkauf'] += $exp; // gesamter Export wird intern verkauft
// keine Einspeisung in diesem Zweig lt. Vorgabe
// Kosten / Erträge
if ($pGrid !== null) $acc[$name]['cost_grid'] += (max(0.0, (1.0 - $ratio) * $imp) * $pGrid) / 100.0;
if ($pSolar !== null) $acc[$name]['cost_solar'] += (($ratio * $imp) * $pSolar) / 100.0;
if ($pSolarSell !== null) $acc[$name]['rev_feedin'] += ($exp * $pSolarSell) / 100.0; // „Solareinspeiseertrag“ laut Vorgabe
// Solarverkauf-Ertrag bleibt hier 0 (lt. Spezifikation)
}
}
}
// Ausgabe-Zeilen je Zähler
$grand = 0.0;
foreach ($acc as $name => $v) {
$sum = $v['cost_solar'] + $v['cost_grid'] - $v['rev_feedin'] - $v['rev_solar_sale'];
$grand += $sum;
$html .= "<tr>
<td>{$name}</td>
<td align='right'>" . number_format($v['imp'], 3) . "</td>
<td align='right'>" . number_format($v['exp'], 3) . "</td>
<td align='right'>" . number_format($v['solar_bezug'], 3) . "</td>
<td align='right'>" . number_format($v['netz_bezug'], 3) . "</td>
<td align='right'>" . number_format($v['pv_einspeisung'], 3) . "</td>
<td align='right'>" . number_format($v['pv_verkauf'], 3) . "</td>
<td align='right'>" . number_format($v['cost_solar'], 2) . "</td>
<td align='right'>" . number_format($v['cost_grid'], 2) . "</td>
<td align='right'>" . number_format($v['rev_feedin'], 2) . "</td>
<td align='right'>" . number_format($v['rev_solar_sale'], 2) . "</td>
<td align='right'>" . number_format($sum, 2) . "</td>
</tr>";
}
// Total
$html .= "<tr style='background-color:#f9f9f9; font-weight:bold;'>
<td colspan='11' align='right'>Total Stromkosten:</td>
<td align='right'>" . number_format($grand, 2) . "</td>
</tr></table><br>";
return ['html'=>$html, 'sum'=>$grand];
}
// ====================== Nebenkosten ======================
private function CalculateAdditionalCosts($waterMeters, $tariffs, $userId, $from, $to)
{
$html = "<table border='1' cellspacing='0' cellpadding='3' width='100%' style='font-size:8px;'>
<tr style='background-color:#f0f0f0;'>
<th>Zähler</th><th>Typ</th><th>Start</th><th>Ende</th>
<th>Zähler Start</th><th>Zähler Ende</th><th>Verbrauch</th><th>Tarif (Rp)</th><th>Kosten (CHF)</th>
</tr>";
$total = 0.0;
$usedTariffs = [];
foreach ($waterMeters as $m) {
if ($m['user_id'] != $userId) continue;
$type = $m['meter_type'] ?? 'Warmwasser';
$cost = $this->AddMeterToPDFRow($m, $tariffs, $from, $to, $type);
$html .= $cost['row'];
$total += $cost['value'];
$usedTariffs = array_merge($usedTariffs, $cost['tariffs']);
}
$html .= "<tr style='background-color:#f9f9f9; font-weight:bold;'>
<td colspan='8' align='right'>Total Nebenkosten:</td>
<td align='right'>" . number_format($total, 2) . "</td>
</tr></table><br>";
// 👇 Tarifliste anhängen
if (!empty($usedTariffs)) {
$html .= "<p><strong>Angewendete Nebenkostentarife:</strong></p><ul>";
foreach ($usedTariffs as $t) {
$html .= "<li>" . date('d.m.Y', $t['start']) . " " . date('d.m.Y', $t['end']) . ": " .
number_format($t['price'], 2) . " Rp</li>";
}
$html .= "</ul><br>";
}
return ['html' => $html, 'sum' => $total];
}
// ====================== Kernberechnung ======================
private function AddMeterToPDFRow($meter, $tariffs, $from, $to, $type)
{
$rows = '';
$totalCost = 0.0;
$usedTariffs = []; // 👈 neu: Liste der verwendeten Tarife
$varId = $meter['var_consumption'];
if (!IPS_VariableExists($varId)) return ['row' => '', 'value' => 0, 'tariffs' => []];
$filteredTariffs = array_filter($tariffs, fn($t) =>
strtolower(trim($t['unit_type'] ?? '')) === strtolower(trim($type))
);
foreach ($filteredTariffs as &$t) {
$t['start_ts'] = $this->toUnixTs($t['start'], false);
$t['end_ts'] = $this->toUnixTs($t['end'], true);
}
unset($t);
usort($filteredTariffs, fn($a, $b) => ($a['start_ts'] ?? 0) <=> ($b['start_ts'] ?? 0));
$currentStart = $from;
while ($currentStart < $to) {
$activeTariff = null;
foreach ($filteredTariffs as $t) {
if (($t['start_ts'] ?? 0) <= $currentStart && $currentStart <= ($t['end_ts'] ?? 0)) {
$activeTariff = $t;
break;
}
}
if (!$activeTariff) {
$activeTariff = ['start_ts' => $currentStart, 'end_ts' => $to, 'price' => 0.0];
}
$tariffEnd = intval($activeTariff['end_ts']);
$tariffPrice = floatval($activeTariff['price']);
// 👇 Tarif in Liste aufnehmen (nur wenn noch nicht drin)
$key = $activeTariff['start_ts'] . '-' . $activeTariff['end_ts'] . '-' . $tariffPrice;
if (!isset($usedTariffs[$key])) {
$usedTariffs[$key] = [
'start' => $activeTariff['start_ts'],
'end' => $activeTariff['end_ts'],
'price' => $tariffPrice
];
}
$startValue = $this->GetValueAt($varId, $currentStart, true);
$segmentEnd = ($tariffEnd < $to) ? $tariffEnd : $to;
$endValue = $this->GetValueAt($varId, $segmentEnd, true);
if ($startValue === null || $endValue === null) break;
$verbrauch = max(0, $endValue - $startValue);
$kosten = round(($tariffPrice / 100) * $verbrauch, 2);
$totalCost += $kosten;
$rows .= "<tr>
<td>{$meter['name']}</td>
<td>{$type}</td>
<td align='center'>" . date('d.m.Y', $currentStart) . "</td>
<td align='center'>" . date('d.m.Y', $segmentEnd) . "</td>
<td align='right'>" . number_format($startValue, 2) . "</td>
<td align='right'>" . number_format($endValue, 2) . "</td>
<td align='right'>" . number_format($verbrauch, 2) . "</td>
<td align='right'>" . number_format($tariffPrice, 2) . "</td>
<td align='right'>" . number_format($kosten, 2) . "</td>
</tr>";
if ($tariffEnd < $to) $currentStart = $tariffEnd + 1;
else break;
}
if ($rows === '') return ['row' => '', 'value' => 0, 'tariffs' => []];
return ['row' => $rows, 'value' => $totalCost, 'tariffs' => array_values($usedTariffs)];
}
// ====================== Hilfsfunktionen ======================
private function GetValueAt($varId, $timestamp, $nearestAfter = true)
{
$archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0];
if (!$archiveID || !IPS_VariableExists($varId)) return null;
$values = @AC_GetLoggedValues($archiveID, $varId, $timestamp - 30 * 86400, $timestamp + 30 * 86400, 0);
if (empty($values)) return floatval(GetValue($varId));
$closest = null;
foreach ($values as $v) {
if ($nearestAfter && $v['TimeStamp'] >= $timestamp) {
$closest = $v['Value'];
break;
} elseif (!$nearestAfter && $v['TimeStamp'] <= $timestamp) {
$closest = $v['Value'];
}
}
return $closest ?? floatval(GetValue($varId));
}
private function toUnixTs($val, $endOfDay = false)
{
if (is_int($val)) return $val;
if (is_numeric($val)) return intval($val);
if (is_string($val)) {
$s = trim($val);
if ($s !== '' && $s[0] === '{') {
$obj = json_decode($s, true);
if (isset($obj['year'], $obj['month'], $obj['day'])) {
$time = $endOfDay ? '23:59:59' : '00:00:00';
return strtotime(sprintf('%04d-%02d-%02d %s', $obj['year'], $obj['month'], $obj['day'], $time));
}
}
$ts = strtotime($s);
return $ts === false ? null : $ts;
}
return null;
}
private function readDelta($varId, $tStart, $tEnd)
{
// Start: erster Wert NACH/AB tStart
$vStart = $this->GetValueAt($varId, $tStart, true);
// Ende: letzter Wert VOR/ BIS tEnd
$vEnd = $this->GetValueAt($varId, $tEnd, false);
if ($vStart === null || $vEnd === null) return 0.0;
return max(0.0, floatval($vEnd) - floatval($vStart));
}
private function getTariffPriceAt($tariffs, $typeSynonyms, $ts)
{
// passender unit_type
$wanted = array_map('strtolower', $typeSynonyms);
$cands = [];
foreach ($tariffs as $t) {
$u = strtolower(trim($t['unit_type'] ?? ''));
if (!in_array($u, $wanted)) continue;
$s = $this->toUnixTs($t['start'], false);
$e = $this->toUnixTs($t['end'], true);
if (!$s || !$e) continue;
if ($s <= $ts && $ts <= $e) {
$cands[] = floatval($t['price']);
}
}
if (empty($cands)) return null;
// bei Überschneidungen: ersten / günstigsten / letzten hier: letzten Eintrag priorisieren
return end($cands);
}
}