Files
Symcon_Belevo_Energiemanage…/Abrechnung/module.php
2025-12-05 07:43:22 +01:00

562 lines
22 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); // 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)
{
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;
$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, '');
}
// ====================== Nebenkosten ======================
private function CalculatePowerCosts($powerMeters, $tariffs, $userId, $from, $to)
{
IPS_LogMessage("ABR", "====================== START CalculatePowerCosts ======================");
IPS_LogMessage("ABR", "UserId: $userId | From: $from (" . date("d.m.Y H:i", $from) . ") | To: $to (" . date("d.m.Y H:i", $to) . ")");
// ---- Tarife Debug ----
IPS_LogMessage("ABR", "Geladene Tarife:");
foreach ($tariffs as $t) {
IPS_LogMessage("ABR", " Tarif: type=" . ($t['unit_type'] ?? '?') .
" price=" . ($t['price'] ?? '?') .
" start=" . json_encode($t['start']) .
" end=" . json_encode($t['end']));
}
// ---- Stromzähler für diesen Benutzer sammeln ----
$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>Solareinspeisung (kWh)</th>
<th>Solarverkauf (kWh)</th>
<th>Kosten Solar (CHF)</th>
<th>Kosten Netz (CHF)</th>
<th>Ertrag Einspeisung (CHF)</th>
<th>Summe (CHF)</th>
</tr>";
$acc = [];
$meters = [];
IPS_LogMessage("ABR", "Suche nach Stromzählern für userId=$userId");
foreach ($powerMeters as $m) {
IPS_LogMessage("ABR", " Prüfe Zähler: name=" . $m['name'] . " user_id=" . $m['user_id']);
if ($m['user_id'] != $userId)
continue;
$name = $m['name'];
$meters[$name] = [
'name' => $name,
'imp' => $m['var_import'] ?? null,
'exp' => $m['var_export'] ?? null
];
IPS_LogMessage("ABR", " → Zugeordnet: $name | ImportVar=" . $meters[$name]['imp'] . " ExportVar=" . $meters[$name]['exp']);
$acc[$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($meters)) {
IPS_LogMessage("ABR", "⚠ Keine Stromzähler für diesen Benutzer gefunden!");
$html .= "<tr><td colspan='11' align='center'>Keine Stromzähler für diesen Benutzer</td></tr></table><br>";
return ['html' => $html, 'sum' => 0.0];
}
IPS_LogMessage("ABR", "Gefundene Stromzähler: " . implode(", ", array_keys($meters)));
// ==== 15-MINUTEN INTERVALL-BERECHNUNG ====
IPS_LogMessage("ABR", "Beginne 15-Minuten Analyse...");
for ($ts = $from; $ts < $to; $ts += 900) {
$slotEnd = min($to, $ts + 900);
IPS_LogMessage("ABR", "----- Intervall " . date("d.m.Y H:i", $ts) . " - " . date("d.m.Y H:i", $slotEnd) . " -----");
// Tarife für dieses Intervall
$pGrid = $this->getTariffPriceAt($tariffs, ['Netztarif'], $ts);
$pSolar = $this->getTariffPriceAt($tariffs, ['Solartarif'], $ts);
$pFeed = $this->getTariffPriceAt($tariffs, ['Einspeisetarif'], $ts);
IPS_LogMessage("ABR", " Tarife: Grid=" . var_export($pGrid, true) .
" Solar=" . var_export($pSolar, true) . " FeedIn=" . var_export($pFeed, true));
$impTotal = 0.0;
$expTotal = 0.0;
$slot = [];
// --- Zählerwerte lesen ---
foreach ($meters as $name => $mm) {
IPS_LogMessage("ABR", " → Zähler $name: Lade Werte Import=" . $mm['imp'] . " Export=" . $mm['exp']);
// Import delta
$impDelta = 0.0;
if (!empty($mm['imp']) && IPS_VariableExists((int)$mm['imp'])) {
$impDelta = $this->readDelta((int)$mm['imp'], $ts, $slotEnd);
IPS_LogMessage("ABR", " Import-Delta = $impDelta");
} else {
IPS_LogMessage("ABR", " ⚠ Import-Variable existiert nicht!");
}
// Export delta
$expDelta = 0.0;
if (!empty($mm['exp']) && IPS_VariableExists((int)$mm['exp'])) {
$expDelta = $this->readDelta((int)$mm['exp'], $ts, $slotEnd);
IPS_LogMessage("ABR", " Export-Delta = $expDelta");
} else {
IPS_LogMessage("ABR", " ⚠ Export-Variable existiert nicht!");
}
if ($impDelta > 0 || $expDelta > 0) {
$slot[$name] = ['imp' => $impDelta, 'exp' => $expDelta];
$impTotal += $impDelta;
$expTotal += $expDelta;
IPS_LogMessage("ABR", " → Intervall Messung: imp=$impDelta exp=$expDelta");
} else {
IPS_LogMessage("ABR", " → Kein Verbrauch/Einspeisung im Intervall");
}
}
IPS_LogMessage("ABR", " SUMME Intervall: impTotal=$impTotal | expTotal=$expTotal");
if ($impTotal == 0 && $expTotal == 0) {
IPS_LogMessage("ABR", " → Intervall übersprungen (keine Werte)");
continue;
}
// ---- Verhältnis bestimmen ----
if ($impTotal <= $expTotal && $expTotal > 0) {
$ratio = $impTotal / $expTotal;
IPS_LogMessage("ABR", " Fall: solar deckt Verbrauch → ratio=$ratio");
} elseif ($impTotal > 0) {
$ratio = $expTotal / $impTotal;
IPS_LogMessage("ABR", " Fall: teilweiser Netzbezug → ratio=$ratio");
} else {
IPS_LogMessage("ABR", " Fall: keine klare Zuordnung, ratio=0");
$ratio = 0.0;
}
// ---- Werte pro Zähler verrechnen ----
foreach ($slot as $name => $v) {
$imp = $v['imp'];
$exp = $v['exp'];
IPS_LogMessage("ABR", " → Verrechne für $name: imp=$imp exp=$exp ratio=$ratio");
// Summen Import/Export
$acc[$name]['imp'] += $imp;
$acc[$name]['exp'] += $exp;
if ($impTotal <= $expTotal) {
IPS_LogMessage("ABR", " → PV deckt alles");
$acc[$name]['solar_bezug'] += $imp;
$acc[$name]['solareinspeisung'] += (1 - $ratio) * $exp;
$acc[$name]['solarverkauf'] += $ratio * $exp;
if ($pSolar !== null)
$acc[$name]['cost_solar'] += ($imp * $pSolar) / 100.0;
if ($pFeed !== null)
$acc[$name]['rev_feedin'] += ((1 - $ratio) * $exp * $pFeed) / 100.0;
} else {
IPS_LogMessage("ABR", " → Netzbezug notwendig");
$acc[$name]['solar_bezug'] += $ratio * $imp;
$acc[$name]['netz_bezug'] += (1 - $ratio) * $imp;
$acc[$name]['solarverkauf'] += $exp;
if ($pGrid !== null)
$acc[$name]['cost_grid'] += ((1 - $ratio) * $imp * $pGrid) / 100.0;
if ($pSolar !== null)
$acc[$name]['cost_solar'] += ($ratio * $imp * $pSolar) / 100.0;
if ($pFeed !== null)
$acc[$name]['rev_feedin'] += ($exp * $pFeed) / 100.0;
}
IPS_LogMessage("ABR", " → Ergebnis $name: solar_bezug=" . $acc[$name]['solar_bezug'] .
" netz_bezug=" . $acc[$name]['netz_bezug'] .
" solarE=" . $acc[$name]['solareinspeisung'] .
" solarV=" . $acc[$name]['solarverkauf'] .
" costGrid=" . $acc[$name]['cost_grid'] .
" costSolar=" . $acc[$name]['cost_solar'] .
" revFeed=" . $acc[$name]['rev_feedin']);
}
IPS_LogMessage("ABR", "-------------------------------------------------------------------");
}
IPS_LogMessage("ABR", "==== END 15-Minuten Analyse ====");
// ---- Tabelle aufbauen ----
$sum = 0.0;
foreach ($acc as $name => $a) {
$subtotal = $a['cost_grid'] + $a['cost_solar'] - $a['rev_feedin'];
$sum += $subtotal;
IPS_LogMessage("ABR", "FINAL $name: " . json_encode($a));
$html .= "<tr>
<td>{$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) . "</b></td>
</tr>";
}
IPS_LogMessage("ABR", "====================== END CalculatePowerCosts ======================");
$html .= "<tr style='background-color:#f9f9f9; font-weight:bold;'>
<td colspan='10' align='right'>Total Stromkosten:</td>
<td align='right'>" . number_format($sum, 2) . "</td>
</tr></table><br>";
return ['html' => $html, 'sum' => $sum];
}
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>";
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];
}
// ====================== Kernlogik für Wasser/Wärme ======================
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' => []];
$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']);
$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)
{
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) => intval($a['TimeStamp']) <=> intval($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 = floatval(GetValue($varId));
if ($vEnd === null) $vEnd = floatval(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)) 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;
return end($cands);
}
}
?>