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

671 lines
23 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();
// Kopfbereich
$html = "
<h1 style='text-align:center; margin-bottom:0;'>Elektro- und Nebenkostenabrechnung</h1>
<hr>
<table width='100%' style='font-size:10px; margin-top:5px;'>
<tr>
<td width='60%'>
<strong>Zählpunkte:</strong><br>";
// Alle Zählerpunkte des Users auflisten
foreach ($power as $m) {
if ($m['user_id'] == $user['id']) {
$html .= htmlspecialchars($m['name']) . "<br>";
}
}
foreach ($water as $m) {
if ($m['user_id'] == $user['id']) {
$html .= htmlspecialchars($m['name']) . "<br>";
}
}
$html .= "
</td>
<td width='40%'>
<strong>Rechnungsadresse:</strong><br>
{$user['name']}<br>
{$user['address']}<br>
{$user['city']}<br>
</td>
</tr>
</table>
<p style='font-size:10px; margin-top:10px;'>
<strong>Abrechnungszeitraum:</strong> " . date('d.m.Y', $from) . " " . date('d.m.Y', $to) . "
</p>
<br>
";
// ========================= Elektrizität =========================
$html .= "<h2 style='margin-bottom:3px;'>Elektrizität</h2>";
$powerResult = $this->GetCalculatedPowerCosts($user['id']);
$html .= $powerResult['html'];
$totalPower = $powerResult['sum'];
// ========== Stromtarife anzeigen ==========
$appliedTariffs = $this->CollectTariffsForUser($tariffs, ['Netztarif','Solartarif','Einspeisetarif']);
if (!empty($appliedTariffs)) {
$html .= "<p style='font-size:8px; margin-top:4px;'><strong>Angewendete Elektrizitätstarife:</strong></p><ul style='font-size:7px;'>";
foreach ($appliedTariffs as $t) {
$html .= "<li>"
. date('d.m.Y', $t['start']) . "" . date('d.m.Y', $t['end'])
. " — <strong>" . number_format($t['price'], 2) . " Rp/kWh</strong>"
. "</li>";
}
$html .= "</ul><br>";
}
// ========================= Nebenkosten =========================
$html .= "<h2 style='margin-bottom:3px;'>Nebenkosten</h2>";
$additionalResult = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to);
$html .= $additionalResult['html'];
$totalAdditional = $additionalResult['sum'];
// ========================= Gesamttotal =========================
$grandTotal = $totalPower + $totalAdditional;
$html .= "
<h2 style='text-align:right; margin-top:10px;'>
Gesamttotal: <strong>" . number_format($grandTotal, 2) . " CHF</strong>
</h2>
";
$pdf->writeHTML($html, true, false, true, false, '');
}
// ====================== 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;
}
}
?>