Files
Symcon_Belevo_Energiemanage…/Abrechnung/module.php
2025-12-05 09:42:50 +01:00

617 lines
20 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
{
private array $powerCostCache = [];
// ======================================================================
// MODULE INITIALISIERUNG
// ======================================================================
public function Create()
{
parent::Create();
// Properties
$this->RegisterPropertyString('Users', '[]');
$this->RegisterPropertyString('PowerMeters', '[]');
$this->RegisterPropertyString('WaterMeters', '[]');
$this->RegisterPropertyString('Tariffs', '[]');
// Zeitraum-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');
// PDF & Script
$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);
}
}
// ======================================================================
// ACTION HANDLER
// ======================================================================
public function RequestAction($Ident, $Value)
{
switch ($Ident) {
case 'FromDate':
case 'ToDate':
SetValue($this->GetIDForIdent($Ident), $Value);
break;
case 'StartBilling':
$content = $this->GenerateInvoices();
if (!$content) {
echo "❌ Fehler bei der PDF-Erstellung";
return;
}
$mediaID = $this->GetIDForIdent('InvoicePDF');
IPS_SetMediaContent($mediaID, base64_encode($content));
SetValue($this->GetIDForIdent('LastResult'), 'Abrechnung vom ' . date('d.m.Y H:i'));
echo "✅ PDF erfolgreich erstellt.";
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 berechnen
$this->CalculateAllPowerCosts($power, $tariffs, $from, $to);
// PDF Grundstruktur
$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);
// Für jeden Nutzer Seite erzeugen
foreach ($users as $user) {
$this->BuildUserInvoice($pdf, $user, $water, $tariffs, $from, $to);
}
return $pdf->Output('Abrechnung.pdf', 'S');
}
private function BuildUserInvoice($pdf, array $user, array $water, array $tariffs, int $from, int $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->GetCalculatedPowerCosts($user['id']);
$html .= $powerResult['html'];
// Nebenkosten (Wasser/Wärme)
$html .= "<h3>💧 Nebenkosten (Wasser/Wärme)</h3>";
$additional = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to);
$html .= $additional['html'];
// Gesamt
$grandTotal = $powerResult['sum'] + $additional['sum'];
$html .= "<h3 style='text-align:right;'>Gesamttotal:
<strong>" . number_format($grandTotal, 2) . " CHF</strong></h3>";
$pdf->writeHTML($html, true, false, true, false, '');
}
// ======================================================================
// STROMKOSTEN → GLOBAL EINMAL BERECHNEN
// ======================================================================
private function CalculateAllPowerCosts(array $powerMeters, array $tariffs, int $from, int $to)
{
$this->powerCostCache = [];
$metersByUser = [];
// User-Zuordnung
foreach ($powerMeters as $m) {
if (!isset($m['user_id'])) {
continue;
}
$userId = (int)$m['user_id'];
$name = $m['name'] ?? ('Meter_' . $userId);
$metersByUser[$userId][$name] = [
'importVar' => $m['var_consumption'] ?? null,
'exportVar' => $m['var_feed'] ?? null
];
$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 Raster
for ($ts = $from; $ts < $to; $ts += 900) {
$slotEnd = min($to, $ts + 900);
$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
foreach ($meters as $name => $mm) {
$impDelta = !empty($mm['importVar'])
? $this->getDeltaFromArchive((int)$mm['importVar'], $ts, $slotEnd)
: 0.0;
$expDelta = !empty($mm['exportVar'])
? $this->getDeltaFromArchive((int)$mm['exportVar'], $ts, $slotEnd)
: 0.0;
if ($impDelta > 0 || $expDelta > 0) {
$slot[$name] = ['imp' => $impDelta, 'exp' => $expDelta];
$impTotal += $impDelta;
$expTotal += $expDelta;
}
}
if ($impTotal == 0.0 && $expTotal == 0.0) {
continue;
}
// Verhältnis bestimmen
if ($impTotal <= $expTotal && $expTotal > 0) {
$ratio = $impTotal / $expTotal;
$pvCoversAll = true;
} elseif ($impTotal > 0) {
$ratio = $expTotal / $impTotal;
$pvCoversAll = false;
} else {
$ratio = 0;
$pvCoversAll = true;
}
// Werte verteilen
foreach ($slot as $name => $v) {
$imp = $v['imp'];
$exp = $v['exp'];
$entry = &$this->powerCostCache[$userId][$name];
$entry['imp'] += $imp;
$entry['exp'] += $exp;
if ($pvCoversAll) {
$entry['solar_bezug'] += $imp;
$entry['solareinspeisung'] += (1 - $ratio) * $exp;
$entry['solarverkauf'] += $ratio * $exp;
if ($pSolar !== null) {
$entry['cost_solar'] += ($imp * $pSolar) / 100;
}
if ($pFeed !== null) {
$entry['rev_feedin'] += ((1 - $ratio) * $exp * $pFeed) / 100;
}
} else {
$entry['solar_bezug'] += $ratio * $imp;
$entry['netz_bezug'] += (1 - $ratio) * $imp;
$entry['solarverkauf'] += $exp;
if ($pGrid !== null) {
$entry['cost_grid'] += ((1 - $ratio) * $imp * $pGrid) / 100;
}
if ($pSolar !== null) {
$entry['cost_solar'] += ($ratio * $imp * $pSolar) / 100;
}
if ($pFeed !== null) {
$entry['rev_feedin'] += ($exp * $pFeed) / 100;
}
}
}
}
}
}
// ======================================================================
// BEREITS BERECHNETE STROMKOSTEN → RÜCKGABE PRO USER
// ======================================================================
private function GetCalculatedPowerCosts(int $userId): array
{
$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>";
if (empty($this->powerCostCache) || !isset($this->powerCostCache[$userId])) {
$html .= "<tr><td colspan='11' align='center'>Keine Stromzähler gefunden</td></tr></table><br>";
return ['html' => $html, 'sum' => 0];
}
$sum = 0;
foreach ($this->powerCostCache[$userId] as $entry) {
$subtotal = $entry['cost_grid'] + $entry['cost_solar'] - $entry['rev_feedin'];
$sum += $subtotal;
$html .= "
<tr>
<td>{$entry['name']}</td>
<td align='right'>" . number_format($entry['imp'], 3) . "</td>
<td align='right'>" . number_format($entry['exp'], 3) . "</td>
<td align='right'>" . number_format($entry['solar_bezug'], 3) . "</td>
<td align='right'>" . number_format($entry['netz_bezug'], 3) . "</td>
<td align='right'>" . number_format($entry['solareinspeisung'], 3) . "</td>
<td align='right'>" . number_format($entry['solarverkauf'], 3) . "</td>
<td align='right'>" . number_format($entry['cost_solar'], 2) . "</td>
<td align='right'>" . number_format($entry['cost_grid'], 2) . "</td>
<td align='right'>" . number_format($entry['rev_feedin'], 2) . "</td>
<td align='right'><b>" . number_format($subtotal, 2) . "</b></td>
</tr>";
}
$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];
}
// ======================================================================
// WASSER / WÄRME
// ======================================================================
private function CalculateAdditionalCosts(array $waterMeters, array $tariffs, int $userId, int $from, int $to): array
{
$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;
$usedTariffs = [];
foreach ($waterMeters as $m) {
if ($m['user_id'] != $userId) {
continue;
}
$type = $m['meter_type'] ?? 'Warmwasser';
$row = $this->AddMeterToPDFRow($m, $tariffs, $from, $to, $type);
$html .= $row['row'];
$total += $row['value'];
$usedTariffs = array_merge($usedTariffs, $row['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];
}
private function AddMeterToPDFRow(array $meter, array $tariffs, int $from, int $to, string $type): array
{
$rows = '';
$totalCost = 0;
$usedTariffs = [];
$varId = $meter['var_consumption'];
if (!IPS_VariableExists($varId)) {
return ['row' => '', 'value' => 0, 'tariffs' => []];
}
// Finde passende Tarife
$filtered = array_filter(
$tariffs,
fn($t) => strtolower(trim($t['unit_type'] ?? '')) === strtolower(trim($type))
);
foreach ($filtered as &$t) {
$t['start_ts'] = $this->toUnixTs($t['start'], false);
$t['end_ts'] = $this->toUnixTs($t['end'], true);
}
unset($t);
usort($filtered, fn($a, $b) => ($a['start_ts'] ?? 0) <=> ($b['start_ts'] ?? 0));
// Zeitfenster aufsplitten
$current = $from;
while ($current < $to) {
$active = null;
foreach ($filtered as $t) {
if (($t['start_ts'] <= $current) && ($current <= $t['end_ts'])) {
$active = $t;
break;
}
}
if (!$active) {
$active = [
'start_ts' => $current,
'end_ts' => $to,
'price' => 0
];
}
$segmentEnd = min($active['end_ts'], $to);
$tariffPrice = floatval($active['price']);
// Eintrag merken für Zusammenfassung
$usedTariffs[$active['start_ts'] . "-" . $active['end_ts']] = [
'start' => $active['start_ts'],
'end' => $active['end_ts'],
'price' => $tariffPrice
];
// Werte aus Archiv
$startValue = $this->GetValueAt($varId, $current, true);
$endValue = $this->GetValueAt($varId, $segmentEnd, true);
if ($startValue === null || $endValue === null) {
break;
}
$verbrauch = max(0, $endValue - $startValue);
$kosten = ($tariffPrice / 100) * $verbrauch;
$totalCost += $kosten;
$rows .= "
<tr>
<td>{$meter['name']}</td>
<td>{$type}</td>
<td align='center'>" . date('d.m.Y', $current) . "</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>";
$current = ($active['end_ts'] < $to) ? ($active['end_ts'] + 1) : $to;
}
return [
'row' => $rows,
'value' => $totalCost,
'tariffs' => array_values($usedTariffs)
];
}
// ======================================================================
// HILFSFUNKTIONEN
// ======================================================================
private function GetValueAt(int $varId, int $timestamp, bool $nearestAfter = true)
{
$archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0];
if (!$archiveID || !IPS_VariableExists($varId)) {
return null;
}
$values = @AC_GetLoggedValues(
$archiveID,
$varId,
$timestamp - 2592000, // 30 Tage zurück
$timestamp + 2592000, // 30 Tage nach vorne
0
);
if (empty($values)) {
return floatval(GetValue($varId));
}
$closest = null;
foreach ($values as $v) {
if ($nearestAfter && $v['TimeStamp'] >= $timestamp) {
return $v['Value'];
}
if (!$nearestAfter && $v['TimeStamp'] <= $timestamp) {
$closest = $v['Value'];
}
}
return $closest ?? floatval(GetValue($varId));
}
private function getDeltaFromArchive(int $varId, int $tStart, int $tEnd): float
{
$startValue = $this->GetValueAt($varId, $tStart, false);
$endValue = $this->GetValueAt($varId, $tEnd, true);
if ($startValue === null || $endValue === null) {
return 0.0;
}
$diff = $endValue - $startValue;
return ($diff < 0) ? 0.0 : $diff;
}
private function toUnixTs($val, bool $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 getTariffPriceAt(array $tariffs, array $typeSynonyms, int $ts)
{
$wanted = array_map('strtolower', $typeSynonyms);
$matches = [];
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);
if (!$start || !$end) {
continue;
}
if ($start <= $ts && $ts <= $end) {
$matches[] = floatval($t['price']);
}
}
if (empty($matches)) {
return null;
}
return end($matches);
}
}
?>