Files
Symcon_Belevo_Energiemanage…/Abrechnung/module.php
2025-12-05 11:38:53 +01:00

733 lines
26 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();
$this->RegisterPropertyString('Users', '[]');
$this->RegisterPropertyString('PowerMeters', '[]');
$this->RegisterPropertyString('WaterMeters', '[]');
$this->RegisterPropertyString('Tariffs', '[]');
$this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1);
$this->RegisterVariableInteger('ToDate', 'Enddatum', '~UnixTimestamp', 2);
$this->RegisterVariableString('LastResult', 'Letzte Abrechnung', '', 3);
$this->EnableAction('FromDate');
$this->EnableAction('ToDate');
$this->RegisterScript(
'StartBilling',
'Abrechnung starten',
"<?php IPS_RequestAction(" . $this->InstanceID . ", 'StartBilling', ''); ?>"
);
$this->RegisterMediaDocument('InvoicePDF', 'Letzte Rechnung', 'pdf');
}
public function ApplyChanges()
{
parent::ApplyChanges();
}
private function RegisterMediaDocument($Ident, $Name, $Extension, $Position = 0)
{
$mid = @IPS_GetObjectIDByIdent($Ident, $this->InstanceID);
if ($mid === false) {
$mid = IPS_CreateMedia(5);
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)
{
switch ($Ident) {
case 'FromDate':
case 'ToDate':
SetValue($this->GetIDForIdent($Ident), $Value);
break;
case 'StartBilling':
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'));
echo "✅ PDF erfolgreich erstellt.";
} catch (Throwable $e) {
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')) {
return false;
}
// Stromkosten einmal für alle User berechnen (15-Minuten-Logik)
$this->CalculateAllPowerCosts($power, $tariffs, $from, $to);
$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();
$pdf->SetFont('dejavusans', '', 8);
// Titel
$html = "
<h2>Elektro- und Nebenkostenabrechnung</h2>
<p><strong>Zählpunkte:</strong><br>";
foreach ($power as $pm) {
if ($pm['user_id'] == $user['id']) {
$html .= "" . htmlspecialchars($pm['name']) . "<br>";
}
}
foreach ($water as $wm) {
if ($wm['user_id'] == $user['id']) {
$html .= "" . htmlspecialchars($wm['name']) . "<br>";
}
}
$html .= "</p>
<p><strong>Rechnungsadresse:</strong><br>"
. htmlspecialchars($user['name']) . "<br>"
. htmlspecialchars($user['address']) . "<br>"
. htmlspecialchars($user['city']) . "<br></p>
<p><strong>Zeitraum:</strong> " . date('d.m.Y', $from) . " " . date('d.m.Y', $to) . "</p>
<br><h3>Elektrizität</h3>";
// Stromkosten
$powerResult = $this->GetCalculatedPowerCosts($user['id']);
$html .= $this->FormatPowerTable($powerResult);
// Nebenkosten
$html .= "<h3>Nebenkosten</h3>";
$additionalResult = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to);
$html .= $this->FormatAdditionalTable($additionalResult);
// Gesamtsumme
$grand = $powerResult['sum'] + $additionalResult['sum'];
$html .= "
<table width='100%' cellpadding='3' cellspacing='0' style='font-size:8px; margin-top:10px;'>
<tr>
<td width='80%' align='right'><strong>Gesamttotal:</strong></td>
<td width='20%' align='right'><strong>" . number_format($grand, 2) . " CHF</strong></td>
</tr>
</table>";
// Render PDF
$pdf->writeHTML($html, true, false, true, false, '');
}
/* ============================ STROM SCHÖNE TABELLE ============================ */
private function FormatPowerTable($result)
{
$html = "
<table border='1' cellspacing='0' cellpadding='2' width='100%' style='font-size:6px;'>
<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>Solareinspeisung (kWh)</th>
<th>Solarverkauf (kWh)</th>
<th>Kosten Solar (CHF)</th>
<th>Kosten Netz (CHF)</th>
<th>Einnahmen (CHF)</th>
<th>Total (CHF)</th>
</tr>";
foreach ($result['data'] ?? $this->powerCostCache as $user => $meters) {
foreach ($meters as $m) {
$total = $m['cost_grid'] + $m['cost_solar'] - $m['rev_feedin'];
$html .= "
<tr>
<td>{$m['name']}</td>
<td align='right'>" . number_format($m['imp'], 3) . "</td>
<td align='right'>" . number_format($m['exp'], 3) . "</td>
<td align='right'>" . number_format($m['solar_bezug'], 3) . "</td>
<td align='right'>" . number_format($m['netz_bezug'], 3) . "</td>
<td align='right'>" . number_format($m['solareinspeisung'], 3) . "</td>
<td align='right'>" . number_format($m['solarverkauf'], 3) . "</td>
<td align='right'>" . number_format($m['cost_solar'], 2) . "</td>
<td align='right'>" . number_format($m['cost_grid'], 2) . "</td>
<td align='right'>" . number_format($m['rev_feedin'], 2) . "</td>
<td align='right'><strong>" . number_format($total, 2) . "</strong></td>
</tr>";
}
break;
}
$html .= "</table>";
// Total in separater Tabelle
$html .= "
<table width='100%' cellpadding='3' cellspacing='0' style='font-size:7px; margin-top:4px;'>
<tr>
<td width='80%' align='right'><strong>Total Elektrizität:</strong></td>
<td width='20%' align='right'><strong>" . number_format($result['sum'], 2) . " CHF</strong></td>
</tr>
</table>";
return $html;
}
/* ========================== NEBENKOSTEN SCHÖNE TABELLE ========================== */
private function FormatAdditionalTable($result)
{
$html = "
<table border='1' cellspacing='0' cellpadding='2' width='100%' style='font-size:6px;'>
<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>
" . $result['html'] . "
</table>";
// Total
$html .= "
<table width='100%' cellpadding='3' cellspacing='0' style='font-size:7px; margin-top:4px;'>
<tr>
<td width='80%' align='right'><strong>Total Nebenkosten:</strong></td>
<td width='20%' align='right'><strong>" . number_format($result['sum'], 2) . " CHF</strong></td>
</tr>
</table>";
return $html;
}
// ====================== Stromkosten (15-Minuten, alle User) ======================
private function CalculateAllPowerCosts($powerMeters, $tariffs, $from, $to)
{
$this->powerCostCache = [];
// Zähler je User aufbauen
$metersByUser = [];
foreach ($powerMeters as $m) {
if (!isset($m['user_id'])) {
continue;
}
$userId = (int)$m['user_id'];
$name = $m['name'] ?? ('Meter_' . $m['user_id']);
if (!isset($metersByUser[$userId])) {
$metersByUser[$userId] = [];
}
$metersByUser[$userId][$name] = [
'importVar' => $m['var_consumption'] ?? null,
'exportVar' => $m['var_feed'] ?? null
];
if (!isset($this->powerCostCache[$userId])) {
$this->powerCostCache[$userId] = [];
}
$this->powerCostCache[$userId][$name] = [
'name' => $name,
'imp' => 0.0,
'exp' => 0.0,
'solar_bezug' => 0.0,
'netz_bezug' => 0.0,
'solareinspeisung' => 0.0,
'solarverkauf' => 0.0,
'cost_solar' => 0.0,
'cost_grid' => 0.0,
'rev_feedin' => 0.0
];
}
if (empty($metersByUser)) {
return;
}
// 15-Minuten-Schritte
for ($ts = $from; $ts < $to; $ts += 900) {
$slotEnd = min($to, $ts + 900);
// Tarife (gleich für alle User)
$pGrid = $this->getTariffPriceAt($tariffs, ['Netztarif'], $ts);
$pSolar = $this->getTariffPriceAt($tariffs, ['Solartarif'], $ts);
$pFeed = $this->getTariffPriceAt($tariffs, ['Einspeisetarif'], $ts);
foreach ($metersByUser as $userId => $meters) {
$impTotal = 0.0;
$expTotal = 0.0;
$slot = [];
// Deltas je Zähler des Users
foreach ($meters as $name => $mm) {
$impDelta = 0.0;
$expDelta = 0.0;
if (!empty($mm['importVar']) && IPS_VariableExists((int)$mm['importVar'])) {
$impDelta = $this->getDeltaFromArchive((int)$mm['importVar'], $ts, $slotEnd);
}
if (!empty($mm['exportVar']) && IPS_VariableExists((int)$mm['exportVar'])) {
$expDelta = $this->getDeltaFromArchive((int)$mm['exportVar'], $ts, $slotEnd);
}
if ($impDelta > 0.0 || $expDelta > 0.0) {
$slot[$name] = ['imp' => $impDelta, 'exp' => $expDelta];
$impTotal += $impDelta;
$expTotal += $expDelta;
}
}
if ($impTotal == 0.0 && $expTotal == 0.0) {
continue;
}
// Verhältnis PV / Netz pro User
if ($impTotal <= $expTotal && $expTotal > 0.0) {
$ratio = $impTotal / $expTotal;
$pvCoversAll = true;
} elseif ($impTotal > 0.0) {
$ratio = $expTotal / $impTotal;
$pvCoversAll = false;
} else {
$ratio = 0.0;
$pvCoversAll = true;
}
// Werte pro Zähler verteilen
foreach ($slot as $name => $v) {
$imp = $v['imp'];
$exp = $v['exp'];
$this->powerCostCache[$userId][$name]['imp'] += $imp;
$this->powerCostCache[$userId][$name]['exp'] += $exp;
if ($pvCoversAll) {
// PV deckt gesamten Verbrauch
$this->powerCostCache[$userId][$name]['solar_bezug'] += $imp;
$this->powerCostCache[$userId][$name]['solareinspeisung'] += (1 - $ratio) * $exp;
$this->powerCostCache[$userId][$name]['solarverkauf'] += $ratio * $exp;
if ($pSolar !== null) {
$this->powerCostCache[$userId][$name]['cost_solar'] += ($imp * $pSolar) / 100.0;
}
if ($pFeed !== null) {
$this->powerCostCache[$userId][$name]['rev_feedin'] += ((1 - $ratio) * $exp * $pFeed) / 100.0;
}
} else {
// Teil Netzbezug
$this->powerCostCache[$userId][$name]['solar_bezug'] += $ratio * $imp;
$this->powerCostCache[$userId][$name]['netz_bezug'] += (1 - $ratio) * $imp;
$this->powerCostCache[$userId][$name]['solarverkauf'] += $exp;
if ($pGrid !== null) {
$this->powerCostCache[$userId][$name]['cost_grid'] += ((1 - $ratio) * $imp * $pGrid) / 100.0;
}
if ($pSolar !== null) {
$this->powerCostCache[$userId][$name]['cost_solar'] += ($ratio * $imp * $pSolar) / 100.0;
}
if ($pFeed !== null) {
$this->powerCostCache[$userId][$name]['rev_feedin'] += ($exp * $pFeed) / 100.0;
}
}
}
}
}
}
private function GetCalculatedPowerCosts($userId)
{
$html = "
<table border='1' cellspacing='0' cellpadding='2' width='100%' style='font-size:6px;'>
<tr style='background-color:#f0f0f0;'>
<th>Zähler</th>
<th>Import</th>
<th>Export</th>
<th>Solarbez.</th>
<th>Netzbez.</th>
<th>Solar Eins.</th>
<th>Solarverk.</th>
<th>Solar CHF</th>
<th>Netz CHF</th>
<th>Einspeis. CHF</th>
<th>Total CHF</th>
</tr>";
if (empty($this->powerCostCache) || !isset($this->powerCostCache[$userId])) {
$html .= "<tr><td colspan='11' align='center'>Keine Stromzähler für diesen Benutzer</td></tr></table><br>";
return ['html' => $html, 'sum' => 0.0];
}
$sum = 0.0;
foreach ($this->powerCostCache[$userId] as $name => $a) {
$subtotal = $a['cost_grid'] + $a['cost_solar'] - $a['rev_feedin'];
$sum += $subtotal;
$html .= "<tr>
<td>{$a['name']}</td>
<td align='right'>" . number_format($a['imp'], 3) . "</td>
<td align='right'>" . number_format($a['exp'], 3) . "</td>
<td align='right'>" . number_format($a['solar_bezug'], 3) . "</td>
<td align='right'>" . number_format($a['netz_bezug'], 3) . "</td>
<td align='right'>" . number_format($a['solareinspeisung'], 3) . "</td>
<td align='right'>" . number_format($a['solarverkauf'], 3) . "</td>
<td align='right'>" . number_format($a['cost_solar'], 2) . "</td>
<td align='right'>" . number_format($a['cost_grid'], 2) . "</td>
<td align='right'>" . number_format($a['rev_feedin'], 2) . "</td>
<td align='right'><b>" . number_format($subtotal, 2) . " CHF</b></td>
</tr>";
}
$html .= "<tr style='background-color:#f9f9f9; font-weight:bold;'>
<td colspan='10' align='right'><b>Total Elektrizität:</b></td>
<td align='right'><b>" . number_format($sum, 2) . " CHF</b></td>
</tr></table><br>" ;
return ['html' => $html, 'sum' => $sum];
}
// ====================== Nebenkosten Wasser/Wärme ======================
private function CalculateAdditionalCosts($waterMeters, $tariffs, $userId, $from, $to)
{
$html = "
<table border='1' cellspacing='0' cellpadding='2' width='100%' style='font-size:6px;'>
<tr style='background-color:#f0f0f0;'>
<th>Zähler</th>
<th>Typ</th>
<th>Start</th>
<th>Ende</th>
<th>Stand Start</th>
<th>Stand Ende</th>
<th>Verbrauch</th>
<th>Tarif</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'><b>Total Nebenkosten:</b></td>
<td align='right'><b>" . number_format($total, 2) . " CHF</b></td>
</tr></table><br>";
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];
}
private function AddMeterToPDFRow($meter, $tariffs, $from, $to, $type)
{
$rows = '';
$totalCost = 0.0;
$usedTariffs = [];
$varId = $meter['var_consumption'];
if (!IPS_VariableExists($varId)) {
return ['row' => '', 'value' => 0, 'tariffs' => []];
}
// Relevante Tarife nach Typ filtern
$filteredTariffs = array_filter($tariffs, fn($t) =>
strtolower(trim($t['unit_type'] ?? '')) === strtolower(trim($type))
);
$activeTariff = null;
foreach ($filteredTariffs as $t) {
$startTs = $this->toUnixTs($t['start'], false);
$endTs = $this->toUnixTs($t['end'], true);
if ($startTs === null || $endTs === null) {
continue;
}
// Tarif überlappt den Abrechnungszeitraum
if ($endTs < $from || $startTs > $to) {
continue;
}
$activeTariff = [
'start' => $startTs,
'end' => $endTs,
'price' => (float)$t['price']
];
$usedTariffs[] = $activeTariff;
break; // erster passender Tarif reicht
}
$tariffPrice = $activeTariff ? $activeTariff['price'] : 0.0;
// Verbrauch = letzter Wert vor/gleich "to" minus letzter Wert vor/gleich "from"
$startValue = $this->GetValueAt($varId, $from, false);
$endValue = $this->GetValueAt($varId, $to, false);
if ($startValue === null || $endValue === null) {
return ['row' => '', 'value' => 0, 'tariffs' => []];
}
$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', $from) . "</td>
<td align='center'>" . date('d.m.Y', $to) . "</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 ($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;
}
// 30 Tage Fenster um den Zeitpunkt
$values = @AC_GetLoggedValues($archiveID, $varId, $timestamp - 30 * 86400, $timestamp + 30 * 86400, 0);
if (empty($values)) {
return (float)GetValue($varId);
}
$closest = null;
foreach ($values as $v) {
if ($nearestAfter && $v['TimeStamp'] >= $timestamp) {
// erster Wert NACH oder GENAU zum Timestamp
$closest = $v['Value'];
break;
} elseif (!$nearestAfter && $v['TimeStamp'] <= $timestamp) {
// letzter Wert DAVOR oder GENAU zum Timestamp
$closest = $v['Value'];
}
}
return $closest ?? (float)GetValue($varId);
}
private function getDeltaFromArchive(int $varId, int $tStart, int $tEnd): float
{
// Beide Werte: immer letzter geloggter Wert VOR/GENAU zum Zeitpunkt
$startValue = $this->GetValueAt($varId, $tStart, false);
$endValue = $this->GetValueAt($varId, $tEnd, false);
if ($startValue === null || $endValue === null) {
return 0.0;
}
$diff = $endValue - $startValue;
if ($diff < 0) {
// Sicherheitsnetz bei Zähler-Reset
$diff = 0.0;
}
return (float)$diff;
}
private function toUnixTs($val, $endOfDay = false)
{
if (is_int($val)) {
return $val;
}
if (is_numeric($val)) {
return (int)$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)
{
if (!is_int($tStart)) {
$tStart = strtotime($tStart);
}
if (!is_int($tEnd)) {
$tEnd = strtotime($tEnd);
}
$archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0];
if (!$archiveID || !IPS_VariableExists($varId)) {
return 0.0;
}
$values = @AC_GetLoggedValues($archiveID, $varId, $tStart - 86400, $tEnd + 86400, 0);
if (empty($values)) {
return 0.0;
}
usort($values, fn($a, $b) => (int)$a['TimeStamp'] <=> (int)$b['TimeStamp']);
$vStart = null;
$vEnd = null;
foreach ($values as $v) {
if ($v['TimeStamp'] <= $tStart) {
$vStart = $v['Value'];
}
if ($v['TimeStamp'] <= $tEnd) {
$vEnd = $v['Value'];
}
if ($v['TimeStamp'] > $tEnd) {
break;
}
}
if ($vStart === null) {
$vStart = (float)GetValue($varId);
}
if ($vEnd === null) {
$vEnd = (float)GetValue($varId);
}
$diff = $vEnd - $vStart;
return ($diff < 0) ? 0.0 : $diff;
}
private function getTariffPriceAt($tariffs, $typeSynonyms, $ts)
{
$wanted = array_map('strtolower', $typeSynonyms);
$cands = [];
foreach ($tariffs as $t) {
$u = strtolower(trim($t['unit_type'] ?? ''));
if (!in_array($u, $wanted, true)) {
continue;
}
$s = $this->toUnixTs($t['start'], false);
$e = $this->toUnixTs($t['end'], true);
if (!$s || !$e) {
continue;
}
if ($s <= $ts && $ts <= $e) {
$cands[] = (float)$t['price'];
}
}
if (empty($cands)) {
return null;
}
return end($cands);
}
private function CollectTariffsForUser($tariffs, $types)
{
$result = [];
$wanted = array_map('strtolower', $types);
foreach ($tariffs as $t) {
$type = strtolower(trim($t['unit_type'] ?? ''));
if (!in_array($type, $wanted)) continue;
$start = $this->toUnixTs($t['start'], false);
$end = $this->toUnixTs($t['end'], true);
$result[] = [
'start' => $start,
'end' => $end,
'price' => (float)$t['price']
];
}
return $result;
}
}
?>