logoFile !== null && file_exists($this->logoFile)) { $this->Image($this->logoFile, 15, 10, 40); $this->SetY(28); } else { $this->SetY(15); } } public function Footer() { if (!$this->printFooterText) { return; // Auf QR-Seite keine Rechnungs-Fusszeile drucken! } $this->SetY(-18); $this->SetFont('dejavusans', '', 8); $this->MultiCell(0, 8, $this->footerText, 0, 'C', false, 1); } } class Abrechnung extends IPSModule { public function Create() { parent::Create(); $this->RegisterPropertyString('Users', '[]'); $this->RegisterPropertyString('PowerMeters', '[]'); $this->RegisterPropertyString('WaterMeters', '[]'); $this->RegisterPropertyString('Tariffs', '[]'); $this->RegisterPropertyInteger('LogoMediaID', 0); $this->RegisterPropertyString('PropertyText', 'Liegenschaft'); $this->RegisterPropertyString('FooterText', 'Belevo AG • 6122 Menznau • www.belevo.ch'); // NEU: MWST Variablen registrieren (Standard 8.1%) $this->RegisterPropertyBoolean('EnableVAT', false); $this->RegisterPropertyFloat('VATPercentage', 8.1); // Neue Felder für den QR-Schein $this->RegisterPropertyString('CreditorName', 'Belevo AG'); $this->RegisterPropertyString('CreditorAddress', 'Musterstrasse 1'); $this->RegisterPropertyString('CreditorCity', '6122 Menznau'); $this->RegisterPropertyString('BankIBAN', ''); $this->RegisterTariffVariableProfiles(); $this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1); $this->RegisterVariableInteger('ToDate', 'Enddatum', '~UnixTimestamp', 2); $this->RegisterVariableString('LastResult', 'Letzte Abrechnung', '', 3); $this->RegisterVariableString('VisuTariffOverview', 'Tarifübersicht', '~HTMLBox', 10); $this->RegisterVariableInteger('VisuTariffType', 'Tarifart', 'BELEVO.TariffType', 11); $this->RegisterVariableInteger('VisuTariffStart', 'Tarif Startdatum', '~UnixTimestamp', 12); $this->RegisterVariableInteger('VisuTariffEnd', 'Tarif Enddatum', '~UnixTimestamp', 13); $this->RegisterVariableFloat('VisuTariffPrice', 'Tarif Rp/Einheit', 'BELEVO.TariffPrice', 14); $this->RegisterVariableString('VisuTariffStatus', 'Tarif Status', '', 15); $this->EnableAction('FromDate'); $this->EnableAction('ToDate'); $this->EnableAction('VisuTariffType'); $this->EnableAction('VisuTariffStart'); $this->EnableAction('VisuTariffEnd'); $this->EnableAction('VisuTariffPrice'); $this->RegisterScript( 'StartBilling', 'Abrechnung starten', "InstanceID . ", 'StartBilling', ''); ?>" ); $this->RegisterScript( 'SaveVisuTariff', 'Tarif speichern', "InstanceID . ", 'SaveVisuTariff', ''); ?>" ); $this->RegisterScript( 'DeleteVisuTariff', 'Tarif löschen', "InstanceID . ", 'DeleteVisuTariff', ''); ?>" ); $this->RegisterMediaDocument('InvoicePDF', 'Letzte Rechnung', 'pdf'); } public function ApplyChanges() { parent::ApplyChanges(); $this->RegisterTariffVariableProfiles(); $this->EnsureVisuTariffObjects(); $this->InitializeVisuTariffDefaults(); $this->UpdateTariffOverview(); } private function EnsureVisuTariffObjects() { $this->RegisterVariableString('VisuTariffOverview', 'Tarifübersicht', '~HTMLBox', 10); $this->RegisterVariableInteger('VisuTariffType', 'Tarifart', 'BELEVO.TariffType', 11); $this->RegisterVariableInteger('VisuTariffStart', 'Tarif Startdatum', '~UnixTimestamp', 12); $this->RegisterVariableInteger('VisuTariffEnd', 'Tarif Enddatum', '~UnixTimestamp', 13); $this->RegisterVariableFloat('VisuTariffPrice', 'Tarif Rp/Einheit', 'BELEVO.TariffPrice', 14); $this->RegisterVariableString('VisuTariffStatus', 'Tarif Status', '', 15); $this->EnableAction('VisuTariffType'); $this->EnableAction('VisuTariffStart'); $this->EnableAction('VisuTariffEnd'); $this->EnableAction('VisuTariffPrice'); $this->RegisterScript( 'SaveVisuTariff', 'Tarif speichern', "InstanceID . ", 'SaveVisuTariff', ''); ?>" ); $this->RegisterScript( 'DeleteVisuTariff', 'Tarif löschen', "InstanceID . ", 'DeleteVisuTariff', ''); ?>" ); } private function RegisterTariffVariableProfiles() { $typeProfile = 'BELEVO.TariffType'; $types = $this->GetTariffTypes(); if (!IPS_VariableProfileExists($typeProfile)) { IPS_CreateVariableProfile($typeProfile, 1); } IPS_SetVariableProfileValues($typeProfile, 0, count($types) - 1, 1); foreach ($types as $index => $caption) { IPS_SetVariableProfileAssociation($typeProfile, $index, $caption, '', -1); } $priceProfile = 'BELEVO.TariffPrice'; if (!IPS_VariableProfileExists($priceProfile)) { IPS_CreateVariableProfile($priceProfile, 2); } IPS_SetVariableProfileDigits($priceProfile, 3); IPS_SetVariableProfileText($priceProfile, '', ' Rp/Einheit'); IPS_SetVariableProfileValues($priceProfile, 0, 0, 0); } private function GetTariffTypes() { return [ 'Netztarif', 'Einspeisetarif', 'Solartarif', 'Warmwasser', 'Kaltwasser', 'Wärme' ]; } private function ReadTariffs() { $tariffs = json_decode($this->ReadPropertyString('Tariffs'), true); return is_array($tariffs) ? array_values($tariffs) : []; } private function WriteTariffs(array $tariffs) { usort($tariffs, function ($a, $b) { $typeCmp = strcmp((string)($a['unit_type'] ?? ''), (string)($b['unit_type'] ?? '')); if ($typeCmp !== 0) { return $typeCmp; } return ($this->toUnixTs($a['start'] ?? 0, false) ?? 0) <=> ($this->toUnixTs($b['start'] ?? 0, false) ?? 0); }); IPS_SetProperty($this->InstanceID, 'Tariffs', json_encode(array_values($tariffs), JSON_UNESCAPED_UNICODE)); IPS_ApplyChanges($this->InstanceID); $this->UpdateTariffOverview(); } private function InitializeVisuTariffDefaults() { $startID = @IPS_GetObjectIDByIdent('VisuTariffStart', $this->InstanceID); $endID = @IPS_GetObjectIDByIdent('VisuTariffEnd', $this->InstanceID); $typeID = @IPS_GetObjectIDByIdent('VisuTariffType', $this->InstanceID); $priceID = @IPS_GetObjectIDByIdent('VisuTariffPrice', $this->InstanceID); if ($startID === false || $endID === false || $typeID === false || $priceID === false) { return; } if ((int)GetValue($startID) > 0 && (int)GetValue($endID) > 0) { return; } $tariffs = $this->ReadTariffs(); $first = $tariffs[0] ?? null; if (is_array($first)) { $start = $this->toUnixTs($first['start'] ?? 0, false) ?? strtotime(date('Y-01-01 00:00:00')); $end = $this->toUnixTs($first['end'] ?? 0, false) ?? strtotime(date('Y-12-31 00:00:00')); SetValue($typeID, $this->GetTariffTypeIndex((string)($first['unit_type'] ?? 'Netztarif'))); SetValue($priceID, (float)($first['price'] ?? 0)); } else { $start = strtotime(date('Y-01-01 00:00:00')); $end = strtotime(date('Y-12-31 00:00:00')); SetValue($typeID, 0); SetValue($priceID, 0.0); } SetValue($startID, $start); SetValue($endID, $end); } private function SaveVisuTariffFromVariables() { $tariff = $this->BuildTariffFromVisuVariables(); $tariffs = $this->ReadTariffs(); $replaced = false; foreach ($tariffs as $index => $existing) { if (!$this->IsSameTariffSlot($existing, $tariff)) { continue; } $tariffs[$index] = $tariff; $replaced = true; break; } if (!$replaced) { $tariffs[] = $tariff; } $this->WriteTariffs($tariffs); return ($replaced ? 'Tarif aktualisiert: ' : 'Tarif gespeichert: ') . $tariff['unit_type'] . ' ' . date('d.m.Y', $tariff['start']) . ' - ' . date('d.m.Y', $tariff['end']) . ', ' . number_format($tariff['price'], 3, '.', "'") . ' Rp/Einheit'; } private function DeleteVisuTariffFromVariables() { $tariff = $this->BuildTariffFromVisuVariables(); $tariffs = $this->ReadTariffs(); $filtered = []; $deleted = false; foreach ($tariffs as $existing) { if ($this->IsSameTariffSlot($existing, $tariff)) { $deleted = true; continue; } $filtered[] = $existing; } if (!$deleted) { return 'Kein passender Tarif gefunden.'; } $this->WriteTariffs($filtered); return 'Tarif gelöscht: ' . $tariff['unit_type'] . ' ' . date('d.m.Y', $tariff['start']) . ' - ' . date('d.m.Y', $tariff['end']); } private function BuildTariffFromVisuVariables() { $types = $this->GetTariffTypes(); $typeIndex = (int)GetValue($this->GetIDForIdent('VisuTariffType')); $type = $types[$typeIndex] ?? $types[0]; $start = (int)GetValue($this->GetIDForIdent('VisuTariffStart')); $end = (int)GetValue($this->GetIDForIdent('VisuTariffEnd')); $price = (float)GetValue($this->GetIDForIdent('VisuTariffPrice')); if ($start > 0) { $start = strtotime(date('Y-m-d 00:00:00', $start)); } if ($end > 0) { $end = strtotime(date('Y-m-d 00:00:00', $end)); } if ($start <= 0 || $end <= 0) { throw new Exception('Start- und Enddatum müssen gesetzt sein.'); } if ($end < $start) { throw new Exception('Enddatum darf nicht vor dem Startdatum liegen.'); } if ($price < 0) { throw new Exception('Tarifpreis darf nicht negativ sein.'); } return [ 'start' => $start, 'end' => $end, 'price' => round($price, 3), 'unit_type' => $type ]; } private function IsSameTariffSlot($a, $b) { return strtolower(trim((string)($a['unit_type'] ?? ''))) === strtolower(trim((string)($b['unit_type'] ?? ''))) && ($this->toUnixTs($a['start'] ?? 0, false) ?? 0) === ($this->toUnixTs($b['start'] ?? 0, false) ?? 0) && ($this->toUnixTs($a['end'] ?? 0, false) ?? 0) === ($this->toUnixTs($b['end'] ?? 0, false) ?? 0); } private function GetTariffTypeIndex($type) { foreach ($this->GetTariffTypes() as $index => $tariffType) { if (strtolower($tariffType) === strtolower(trim($type))) { return $index; } } return 0; } private function UpdateTariffOverview() { $overviewID = @IPS_GetObjectIDByIdent('VisuTariffOverview', $this->InstanceID); if ($overviewID === false) { return; } $tariffs = $this->ReadTariffs(); $html = ""; if (empty($tariffs)) { $html .= ""; } else { foreach ($tariffs as $tariff) { $start = $this->toUnixTs($tariff['start'] ?? 0, false); $end = $this->toUnixTs($tariff['end'] ?? 0, false); $html .= ""; } } $html .= '
Tarifart Gültig von Gültig bis Preis
Keine Tarife erfasst.
" . $this->h($tariff['unit_type'] ?? '') . " " . ($start ? date('d.m.Y', $start) : '-') . " " . ($end ? date('d.m.Y', $end) : '-') . " " . number_format((float)($tariff['price'] ?? 0), 3, '.', "'") . " Rp/Einheit
'; SetValue($overviewID, $html); } 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': case 'VisuTariffType': case 'VisuTariffStart': case 'VisuTariffEnd': case 'VisuTariffPrice': SetValue($this->GetIDForIdent($Ident), $Value); break; case 'SaveVisuTariff': try { $message = $this->SaveVisuTariffFromVariables(); SetValue($this->GetIDForIdent('VisuTariffStatus'), $message); echo $message; } catch (Throwable $e) { $message = 'Fehler beim Speichern des Tarifs: ' . $e->getMessage(); SetValue($this->GetIDForIdent('VisuTariffStatus'), $message); echo $message; } break; case 'DeleteVisuTariff': try { $message = $this->DeleteVisuTariffFromVariables(); SetValue($this->GetIDForIdent('VisuTariffStatus'), $message); echo $message; } catch (Throwable $e) { $message = 'Fehler beim Löschen des Tarifs: ' . $e->getMessage(); SetValue($this->GetIDForIdent('VisuTariffStatus'), $message); echo $message; } 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); $users = is_array($users) ? $users : []; $power = is_array($power) ? $power : []; $water = is_array($water) ? $water : []; $tariffs = is_array($tariffs) ? $tariffs : []; if (!class_exists('TCPDF')) { return false; } // Stromkosten einmal für alle User berechnen (15-Minuten-Logik) $this->CalculateAllPowerCosts($power, $tariffs, $from, $to); // Logo & Fusszeile vorbereiten $logoFile = $this->GetLogoFile(); $footerText = $this->ReadPropertyString('FooterText'); // PDF-Objekt $pdf = new InvoicePDF('P', 'mm', 'A4', true, 'UTF-8', false); $pdf->logoFile = $logoFile; $pdf->footerText = $footerText; $pdf->SetCreator('IPSymcon Abrechnung'); // Oben mehr Platz für Logo lassen $pdf->SetMargins(15, 35, 15); $pdf->SetAutoPageBreak(true, 20); $pdf->SetFont('dejavusans', '', 8); foreach ($users as $user) { $pdf->AddPage(); $pdf->SetAutoPageBreak(true, 20); $pdf->printFooterText = true; // Fusszeile für diese Rechnungsseite EINSCHALTEN $grandTotal = $this->BuildUserInvoice($pdf, $user, $power, $water, $tariffs, $from, $to); $this->BuildQRBill($pdf, $user, $grandTotal); } return $pdf->Output('Abrechnung.pdf', 'S'); } private function BuildUserInvoice($pdf, $user, $power, $water, $tariffs, $from, $to) { $meterNames = []; foreach ($power as $m) { if (($m['user_id'] ?? null) == ($user['id'] ?? null)) { $meterNames[] = $this->h($m['name'] ?? ''); } } foreach ($water as $m) { if (($m['user_id'] ?? null) == ($user['id'] ?? null)) { $meterNames[] = $this->h($m['name'] ?? ''); } } $meterText = empty($meterNames) ? '-' : implode('
', $meterNames); $periodText = date('d.m.Y', $from) . ' - ' . date('d.m.Y', $to); $html = "

Elektro- und Nebenkostenabrechnung

Liegenschaft: " . $this->h($this->ReadPropertyString('PropertyText')) . "

Abrechnungszeitraum
" . $periodText . "

Zählpunkte
" . $meterText . "
Rechnungsadresse
" . $this->h($user['name'] ?? '') . "
" . $this->h($user['address'] ?? '') . "
" . $this->h($user['city'] ?? '') . "

"; // ========================= Elektrizität ========================= $html .= "

Elektrizität

"; $powerResult = $this->GetCalculatedPowerCosts($user['id']); $html .= $powerResult['html']; $totalPower = $powerResult['sum']; $appliedTariffs = $this->CollectTariffsForUser($tariffs, ['Netztarif', 'Solartarif', 'Einspeisetarif']); if (!empty($appliedTariffs)) { $html .= "

Angewendete Elektrizitätstarife


"; } // ========================= Nebenkosten ========================= $html .= "

Nebenkosten

"; $additionalResult = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to); $html .= $additionalResult['html']; $totalAdditional = $additionalResult['sum']; // ========================= Gesamttotal & MWST ========================= $subTotal = $totalPower + $totalAdditional; $enableVAT = $this->ReadPropertyBoolean('EnableVAT'); $html .= "
"; if ($enableVAT) { $vatPercent = $this->ReadPropertyFloat('VATPercentage'); $vatAmount = $subTotal * ($vatPercent / 100); $grandTotal = $subTotal + $vatAmount; $html .= "
Zusammenfassung
Zwischentotal Elektrizität " . $this->FormatCurrency($totalPower) . "
Zwischentotal Nebenkosten " . $this->FormatCurrency($totalAdditional) . "
Total exkl. MWST " . $this->FormatCurrency($subTotal) . "
MWST " . number_format($vatPercent, 1, '.', "'") . " % " . $this->FormatCurrency($vatAmount) . "
Gesamttotal inkl. MWST " . $this->FormatCurrency($grandTotal) . "
"; } else { $grandTotal = $subTotal; $html .= " Gesamttotal " . $this->FormatCurrency($grandTotal) . " Nicht mehrwertsteuerpflichtig. "; } $pdf->writeHTML($html, true, false, true, false, ''); return $grandTotal; } // ====================== QR-Einzahlungsschein (Schweiz) ====================== private function BuildQRBill($pdf, $user, $amount) { $iban = str_replace(' ', '', $this->ReadPropertyString('BankIBAN')); if (empty($iban) || $amount <= 0) { return; } $pdf->AddPage(); $pdf->printFooterText = false; $pdf->SetAutoPageBreak(false); $font = 'helvetica'; $yStart = 192; $amountText = number_format($amount, 2, '.', ' '); $pdf->SetLineStyle(['dash' => '2,2', 'color' => [0, 0, 0], 'width' => 0.1]); $pdf->Line(0, $yStart, 210, $yStart); $pdf->Line(62, $yStart, 62, 297); $pdf->SetLineStyle(['dash' => 0, 'color' => [0, 0, 0]]); $creditorName = $this->ReadPropertyString('CreditorName'); $creditorAddress = $this->ReadPropertyString('CreditorAddress'); $creditorCity = $this->ReadPropertyString('CreditorCity'); $bankIBANFormatted = wordwrap($iban, 4, ' ', true); $debtorLines = [ $user['name'] ?? '', $user['address'] ?? '', $user['city'] ?? '' ]; // Linker Teil: Empfangsschein $pdf->SetFont($font, 'B', 11); $pdf->SetXY(5, $yStart + 5); $pdf->Cell(52, 5, 'Empfangsschein', 0, 1); $this->WriteQrTextBlock($pdf, 5, $yStart + 12, 52, 'Konto / Zahlbar an', [ $bankIBANFormatted, $creditorName, $creditorAddress, $creditorCity ], 6, 7, 3.2); $this->WriteQrTextBlock($pdf, 5, $yStart + 39, 52, 'Zahlbar durch', $debtorLines, 6, 7, 3.2); $pdf->SetFont($font, 'B', 6); $pdf->SetXY(5, $yStart + 68); $pdf->Cell(15, 3, 'Währung', 0, 0); $pdf->Cell(30, 3, 'Betrag', 0, 1); $pdf->SetFont($font, '', 8); $pdf->SetX(5); $pdf->Cell(15, 4, 'CHF', 0, 0); $pdf->Cell(30, 4, $amountText, 0, 1); $pdf->SetFont($font, 'B', 6); $pdf->SetXY(38, $yStart + 90); $pdf->Cell(19, 3, 'Annahmestelle', 0, 1, 'R'); // Rechter Teil: Zahlteil $pdf->SetFont($font, 'B', 11); $pdf->SetXY(67, $yStart + 5); $pdf->Cell(50, 5, 'Zahlteil', 0, 1); $qrString = $this->GenerateQRString($iban, $amount, $user); $style = ['border' => false, 'padding' => 0, 'fgcolor' => [0, 0, 0], 'bgcolor' => false]; $pdf->write2DBarcode($qrString, 'QRCODE,M', 67, $yStart + 17, 46, 46, $style, 'N'); $cX = 67 + 23; $cY = $yStart + 17 + 23; $pdf->SetFillColor(255, 255, 255); $pdf->Rect($cX - 3.5, $cY - 3.5, 7, 7, 'F'); $pdf->SetFillColor(0, 0, 0); $pdf->Rect($cX - 3, $cY - 3, 6, 6, 'F'); $pdf->SetFillColor(255, 255, 255); $pdf->Rect($cX - 0.6, $cY - 2, 1.2, 4, 'F'); $pdf->Rect($cX - 2, $cY - 0.6, 4, 1.2, 'F'); $pdf->SetFont($font, 'B', 8); $pdf->SetXY(67, $yStart + 68); $pdf->Cell(15, 4, 'Währung', 0, 0); $pdf->Cell(30, 4, 'Betrag', 0, 1); $pdf->SetFont($font, '', 10); $pdf->SetX(67); $pdf->Cell(15, 5, 'CHF', 0, 0); $pdf->Cell(30, 5, $amountText, 0, 1); $this->WriteQrTextBlock($pdf, 118, $yStart + 5, 84, 'Konto / Zahlbar an', [ $bankIBANFormatted, $creditorName, $creditorAddress, $creditorCity ], 8, 9, 4); $this->WriteQrTextBlock($pdf, 118, $yStart + 34, 84, 'Zusätzliche Informationen', [ 'Abrechnung Liegenschaft' ], 8, 9, 4); $this->WriteQrTextBlock($pdf, 118, $yStart + 50, 84, 'Zahlbar durch', $debtorLines, 8, 9, 4); $pdf->SetAutoPageBreak(true, 20); } private function WriteQrTextBlock($pdf, $x, $y, $width, $label, array $lines, $labelSize = 6, $textSize = 8, $lineHeight = 3.5) { $pdf->SetFont('helvetica', 'B', $labelSize); $pdf->SetXY($x, $y); $pdf->MultiCell($width, 3, $label, 0, 'L', false, 1); $pdf->SetFont('helvetica', '', $textSize); foreach ($lines as $line) { $line = trim((string)$line); if ($line === '') { continue; } $pdf->SetX($x); $pdf->MultiCell($width, $lineHeight, $line, 0, 'L', false, 1); } } private function GenerateQRString($iban, $amount, $user) { // Daten nach SIX Swiss Payment Standard beschneiden (max 70 Zeichen pro Zeile) $creditorName = mb_substr($this->ReadPropertyString('CreditorName'), 0, 70); $creditorStr = mb_substr($this->ReadPropertyString('CreditorAddress'), 0, 70); $creditorCity = mb_substr($this->ReadPropertyString('CreditorCity'), 0, 70); // Fallback falls ein Mieter keinen Namen oder Ort hinterlegt hat (Banken verlangen das zwingend) $debtorName = mb_substr(empty($user['name']) ? 'Unbekannt' : $user['name'], 0, 70); $debtorStr = mb_substr($user['address'] ?? '', 0, 70); $debtorCity = mb_substr(empty($user['city']) ? 'Unbekannt' : $user['city'], 0, 70); $amountStr = number_format($amount, 2, '.', ''); // EPC / Swiss QR Code String Format (Exakte Reihenfolge ist kritisch!) $lines = [ 'SPC', '0200', '1', $iban, 'K', $creditorName, $creditorStr, $creditorCity, '', '', 'CH', '', '', '', '', '', '', '', // Ultimate Creditor (leer) $amountStr, 'CHF', 'K', $debtorName, $debtorStr, $debtorCity, '', '', 'CH', 'NON', '', // Reference Type (NON = Ohne Referenz) 'Abrechnung Liegenschaft', // 30. Unstrukturierte Mitteilung 'EPD' // 31. Trailer (Zwingend exakt 'EPD' am Schluss!) ]; return implode("\r\n", $lines); } // ====================== 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, 'rev_zev' => 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); // ========================================================== // 1) GLOBAL DELTAS EINMAL BERECHNEN // ========================================================== $globalImpTotal = 0.0; $globalExpTotal = 0.0; $globalSlot = []; foreach ($powerMeters as $m) { $name = $m['name']; $impDelta = 0.0; $expDelta = 0.0; // IMPORT if (!empty($m['var_consumption']) && IPS_VariableExists((int)$m['var_consumption'])) { $impDelta = $this->getDeltaFromArchive((int)$m['var_consumption'], $ts, $slotEnd); } // EXPORT if (!empty($m['var_feed']) && IPS_VariableExists((int)$m['var_feed'])) { $expDelta = $this->getDeltaFromArchive((int)$m['var_feed'], $ts, $slotEnd); } if ($impDelta > 0.0 || $expDelta > 0.0) { $globalSlot[$name] = [ 'imp' => $impDelta, 'exp' => $expDelta, 'user_id' => (int)$m['user_id'] ]; $globalImpTotal += $impDelta; $globalExpTotal += $expDelta; } } // Wenn keine Imp/Exp vorhanden → nächster Slot if ($globalImpTotal == 0.0 && $globalExpTotal == 0.0) { continue; } // ========================================================== // 2) Verhältnis PV / Netz // ========================================================== if ($globalImpTotal == 0.0 && $globalExpTotal > 0.0) { // nur Export → PV deckt alles $ratio = 0.0; $pvCoversAll = true; } elseif ($globalExpTotal == 0.0 && $globalImpTotal > 0.0) { // nur Import → Netz deckt alles $ratio = 0.0; $pvCoversAll = false; } elseif ($globalImpTotal <= $globalExpTotal) { // PV produziert genug $ratio = $globalExpTotal ? ($globalImpTotal / $globalExpTotal) : 0.0; $pvCoversAll = true; } else { // Netzbezug notwendig $ratio = $globalImpTotal ? ($globalExpTotal / $globalImpTotal) : 0.0; $pvCoversAll = false; } // ========================================================== // 3) PRO USER VERTEILEN // ========================================================== foreach ($globalSlot as $name => $v) { $userId = $v['user_id']; // Falls User nicht im Cache ist → ignorieren if (!isset($this->powerCostCache[$userId][$name])) { continue; } $imp = $v['imp']; $exp = $v['exp']; // Totale speichern $this->powerCostCache[$userId][$name]['imp'] += $imp; $this->powerCostCache[$userId][$name]['exp'] += $exp; // PV deckt alles if ($pvCoversAll) { $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; $this->powerCostCache[$userId][$name]['rev_zev'] += ($ratio * $exp * $pSolar) / 100.0; } // Netz deckt einen Teil } else { $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_zev'] += ($exp * $pSolar) / 100.0; } } } } } private function GetCalculatedPowerCosts($userId) { if (empty($this->powerCostCache) || !isset($this->powerCostCache[$userId])) { return [ 'html' => "
Keine Stromzähler für diesen Benutzer

", 'sum' => 0.0 ]; } $energyRows = ''; $amountRows = ''; $sum = 0.0; $totals = [ '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, 'rev_zev' => 0.0 ]; $rowIndex = 0; foreach ($this->powerCostCache[$userId] as $name => $a) { $subtotal = $a['cost_grid'] + $a['cost_solar'] - ($a['rev_feedin'] + $a['rev_zev']); $sum += $subtotal; foreach ($totals as $key => $_) { $totals[$key] += (float)($a[$key] ?? 0.0); } $rowStyle = ($rowIndex % 2 === 0) ? " style='background-color:#fbfbfb;'" : ''; $rowIndex++; $label = $this->h($a['name'] ?? $name); $energyRows .= " " . $label . " " . $this->FormatNumber($a['imp'], 3) . " " . $this->FormatNumber($a['exp'], 3) . " " . $this->FormatNumber($a['solar_bezug'], 3) . " " . $this->FormatNumber($a['netz_bezug'], 3) . " " . $this->FormatNumber($a['solareinspeisung'], 3) . " " . $this->FormatNumber($a['solarverkauf'], 3) . " "; $amountRows .= " " . $label . " " . $this->FormatCurrency($a['cost_solar'], false) . " " . $this->FormatCurrency($a['cost_grid'], false) . " " . $this->FormatCurrency($a['rev_feedin'], false) . " " . $this->FormatCurrency($a['rev_zev'], false) . " " . $this->FormatCurrency($subtotal, false) . " "; } $html = " " . $energyRows . "
Zählpunkt Import kWh Export kWh Solarbezug kWh Netzbezug kWh Einspeisung kWh ZEV-Verkauf kWh
Total " . $this->FormatNumber($totals['imp'], 3) . " " . $this->FormatNumber($totals['exp'], 3) . " " . $this->FormatNumber($totals['solar_bezug'], 3) . " " . $this->FormatNumber($totals['netz_bezug'], 3) . " " . $this->FormatNumber($totals['solareinspeisung'], 3) . " " . $this->FormatNumber($totals['solarverkauf'], 3) . "

" . $amountRows . "
Zählpunkt Kauf Solar CHF Kauf Netz CHF Gutschrift Netz CHF Gutschrift ZEV CHF Total CHF
Zwischentotal Elektrizität " . $this->FormatCurrency($sum, false) . "

"; return ['html' => $html, 'sum' => $sum]; } // ====================== Nebenkosten Wasser/Wärme ====================== private function CalculateAdditionalCosts($waterMeters, $tariffs, $userId, $from, $to) { $html = " "; $total = 0.0; $usedTariffs = []; $hasRows = false; foreach ($waterMeters as $m) { if (($m['user_id'] ?? null) != $userId) { continue; } $type = $m['meter_type'] ?? 'Warmwasser'; $cost = $this->AddMeterToPDFRow($m, $tariffs, $from, $to, $type); if ($cost['row'] !== '') { $hasRows = true; } $html .= $cost['row']; $total += $cost['value']; $usedTariffs = array_merge($usedTariffs, $cost['tariffs']); } if (!$hasRows) { $html .= ""; } $html .= "
Zähler Typ Von Bis Start Ende Verbrauch Tarif Kosten CHF
Keine Nebenkostenzähler für diesen Benutzer
Zwischentotal Nebenkosten " . $this->FormatCurrency($total) . "
"; if (!empty($usedTariffs)) { $html .= "

Angewendete Nebenkostentarife


"; } return ['html' => $html, 'sum' => $total]; } private function AddMeterToPDFRow($meter, $tariffs, $from, $to, $type) { $varId = (int)($meter['var_consumption'] ?? 0); if ($varId <= 0 || !IPS_VariableExists($varId)) { return ['row' => '', 'value' => 0, 'tariffs' => []]; } $filteredTariffs = array_filter($tariffs, function ($t) use ($type) { return strtolower(trim($t['unit_type'] ?? '')) === strtolower(trim($type)); }); $usedTariffs = []; $activeTariff = null; foreach ($filteredTariffs as $t) { $startTs = $this->toUnixTs($t['start'] ?? 0, false); $endTs = $this->toUnixTs($t['end'] ?? 0, true); if ($startTs === null || $endTs === null) { continue; } if ($endTs < $from || $startTs > $to) { continue; } $activeTariff = [ 'start' => $startTs, 'end' => $endTs, 'price' => (float)($t['price'] ?? 0), 'unit_type' => $t['unit_type'] ?? $type ]; $usedTariffs[] = $activeTariff; break; } $tariffPrice = $activeTariff ? $activeTariff['price'] : 0.0; $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); $row = " " . $this->h($meter['name'] ?? '') . " " . $this->h($type) . " " . date('d.m.Y', $from) . " " . date('d.m.Y', $to) . " " . $this->FormatNumber($startValue, 2) . " " . $this->FormatNumber($endValue, 2) . " " . $this->FormatNumber($verbrauch, 2) . " " . number_format($tariffPrice, 3, '.', "'") . " " . $this->FormatCurrency($kosten, false) . " "; return ['row' => $row, 'value' => $kosten, 'tariffs' => array_values($usedTariffs)]; } // ====================== Hilfsfunktionen ====================== private function h($value) { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } private function FormatNumber($value, $digits = 2) { return number_format((float)$value, $digits, '.', "'"); } private function FormatCurrency($value, $withCurrency = true) { $amount = number_format((float)$value, 2, '.', "'"); return $withCurrency ? 'CHF ' . $amount : $amount; } private function GetValueAt($varId, $timestamp, $nearestAfter = true) { $archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0]; if (!$archiveID || !IPS_VariableExists($varId)) { return null; } if ($nearestAfter) { // Erster Wert NACH oder GENAU ab Timestamp $values = @AC_GetLoggedValues( $archiveID, $varId, $timestamp, // start time(), // end 1 // LIMIT ); } else { // Letzter Wert DAVOR oder GENAU bis Timestamp $values = @AC_GetLoggedValues( $archiveID, $varId, 0, // start $timestamp, // end 1 // LIMIT ); } if (!empty($values)) { return (float)$values[0]['Value']; } // Fallback → Live-Wert return (float)GetValue($varId); } private function getDeltaFromArchive(int $varId, int $tStart, int $tEnd): float { // Werte holen $startValue = $this->GetValueAt($varId, $tStart, false); $endValue = $this->GetValueAt($varId, $tEnd, false); if ($startValue === null || $endValue === null) { return 0.0; } // Delta berechnen $diff = $endValue - $startValue; if ($diff < 0) { $diff = 0.0; } return (float)$diff; } private function toUnixTs($val, $endOfDay = false) { if (is_int($val)) { return $endOfDay ? strtotime(date('Y-m-d 23:59:59', $val)) : $val; } if (is_numeric($val)) { $ts = (int)$val; return $endOfDay ? strtotime(date('Y-m-d 23:59:59', $ts)) : $ts; } if (is_array($val) && isset($val['year'], $val['month'], $val['day'])) { $time = $endOfDay ? '23:59:59' : '00:00:00'; return strtotime(sprintf( '%04d-%02d-%02d %s', $val['year'], $val['month'], $val['day'], $time )); } 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($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, true)) { continue; } $start = $this->toUnixTs($t['start'], false); $end = $this->toUnixTs($t['end'], true); $result[] = [ 'start' => $start, 'end' => $end, 'price' => (float)$t['price'], 'unit_type' => $t['unit_type'] ?? '' ]; } return $result; } private function GetLogoFile() { $mediaID = (int)$this->ReadPropertyInteger('LogoMediaID'); if ($mediaID <= 0 || !IPS_MediaExists($mediaID)) { return null; } $media = IPS_GetMedia($mediaID); $ext = 'png'; if (!empty($media['MediaFile'])) { $extFromFile = pathinfo($media['MediaFile'], PATHINFO_EXTENSION); if ($extFromFile !== '') { $ext = $extFromFile; } } $path = IPS_GetKernelDir() . 'media/logo_' . $mediaID . '.' . $ext; // Bild aus der Symcon-Media-Datenbank extrahieren $raw = IPS_GetMediaContent($mediaID); // base64 $bin = base64_decode($raw); if ($bin === false) { return null; } file_put_contents($path, $bin); return $path; } } ?>