diff --git a/Abrechnung/README.md b/Abrechnung/README.md new file mode 100644 index 0000000..4cd05d4 --- /dev/null +++ b/Abrechnung/README.md @@ -0,0 +1,40 @@ +# Abrechnung + +IP-Symcon-Modul zur Erstellung von Energie- und Nebenkostenabrechnungen als PDF. + +## Funktion + +- erstellt pro Benutzer eine Abrechnung +- berechnet Stromkosten aus Archivwerten in 15-Minuten-Schritten +- berücksichtigt Netz-, Solar- und Einspeisetarife +- berechnet zusätzliche Zählerkosten für Warmwasser, Kaltwasser und Wärme +- erzeugt optional einen Schweizer QR-Einzahlungsschein +- unterstützt MWST-Ausweis + +## Konfiguration + +| Property | Beschreibung | +|---|---| +| `Users` | Benutzerliste | +| `PowerMeters` | Stromzählerliste | +| `WaterMeters` | Nebenkostenzähler | +| `Tariffs` | Tarifliste | +| `LogoMediaID` | Logo für PDF-Kopf | +| `PropertyText` | Liegenschaftstext | +| `FooterText` | PDF-Fusszeile | +| `EnableVAT` | aktiviert MWST | +| `VATPercentage` | MWST-Satz | +| `CreditorName` | Zahlungsempfänger | +| `CreditorAddress` | Adresse Zahlungsempfänger | +| `CreditorCity` | PLZ/Ort Zahlungsempfänger | +| `BankIBAN` | IBAN für QR-Zahlteil | + +## Bedienung + +| Ident/Action | Beschreibung | +|---|---| +| `FromDate` | Startdatum | +| `ToDate` | Enddatum | +| `StartBilling` | erzeugt das PDF | +| `LastResult` | letzter Lauf | +| `InvoicePDF` | erzeugtes PDF-Dokument | diff --git a/Abrechnung/module.php b/Abrechnung/module.php index f7c9c24..65ed869 100644 --- a/Abrechnung/module.php +++ b/Abrechnung/module.php @@ -28,9 +28,9 @@ class InvoicePDF extends TCPDF if (!$this->printFooterText) { return; // Auf QR-Seite keine Rechnungs-Fusszeile drucken! } - $this->SetY(-15); + $this->SetY(-18); $this->SetFont('dejavusans', '', 8); - $this->Cell(0, 10, $this->footerText, 0, 0, 'C'); + $this->MultiCell(0, 8, $this->footerText, 0, 'C', false, 1); } } @@ -58,23 +58,303 @@ class Abrechnung extends IPSModule $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 .= '
TarifartGültig vonGültig bisPreis
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) @@ -95,9 +375,37 @@ class Abrechnung extends IPSModule 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(); @@ -129,6 +437,11 @@ public function GenerateInvoices() $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; } @@ -153,6 +466,7 @@ public function GenerateInvoices() 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); @@ -165,69 +479,65 @@ public function GenerateInvoices() private function BuildUserInvoice($pdf, $user, $power, $water, $tariffs, $from, $to) { - // Kopfbereich - $html = " -

Elektro- und Nebenkostenabrechnung

-

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

- -
- - - - - - + + + - + -
- Zählpunkte:
"; - - // Alle Zählerpunkte des Users auflisten + $meterNames = []; foreach ($power as $m) { - if ($m['user_id'] == $user['id']) { - $html .= htmlspecialchars($m['name']) . "
"; + if (($m['user_id'] ?? null) == ($user['id'] ?? null)) { + $meterNames[] = $this->h($m['name'] ?? ''); } } foreach ($water as $m) { - if ($m['user_id'] == $user['id']) { - $html .= htmlspecialchars($m['name']) . "
"; + if (($m['user_id'] ?? null) == ($user['id'] ?? null)) { + $meterNames[] = $this->h($m['name'] ?? ''); } } - $html .= " + $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:
- {$user['name']}
- {$user['address']}
- {$user['city']}
+
+ Rechnungsadresse
+ " . $this->h($user['name'] ?? '') . "
+ " . $this->h($user['address'] ?? '') . "
+ " . $this->h($user['city'] ?? '') . "
-

- Abrechnungszeitraum: " . date('d.m.Y', $from) . " – " . date('d.m.Y', $to) . " -

-

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

Elektrizität

"; + $html .= "

Elektrizität

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

Angewendete Elektrizitätstarife:

-
    "; + $html .= "

    Angewendete Elektrizitätstarife

      "; foreach ($appliedTariffs as $t) { + if ($t['start'] === null || $t['end'] === null) { + continue; + } $html .= "
    • " - . date('d.m.Y', $t['start']) . " – " . date('d.m.Y', $t['end']) - . " — " . number_format($t['price'], 2) . " Rp/kWh" + . $this->h($t['unit_type'] ?? '') . ': ' + . date('d.m.Y', $t['start']) . ' - ' . date('d.m.Y', $t['end']) + . ' - ' . number_format($t['price'], 3, '.', "'") . ' Rp/kWh' . "
    • "; } - $html .= "



    "; + $html .= "

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

Nebenkosten

"; + $html .= "

Nebenkosten

"; $additionalResult = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to); $html .= $additionalResult['html']; @@ -236,126 +546,127 @@ public function GenerateInvoices() // ========================= Gesamttotal & MWST ========================= $subTotal = $totalPower + $totalAdditional; $enableVAT = $this->ReadPropertyBoolean('EnableVAT'); - - $html .= ""; - + + $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) . "
Zwischentotal (exkl. MWST):CHF " . number_format($subTotal, 2, '.', "'") . "Total exkl. MWST" . $this->FormatCurrency($subTotal) . "
zuzüglich " . number_format($vatPercent, 1, '.', "'") . " % MWST:CHF " . number_format($vatAmount, 2, '.', "'") . "MWST " . number_format($vatPercent, 1, '.', "'") . " %" . $this->FormatCurrency($vatAmount) . "

Gesamttotal (inkl. MWST):

CHF " . number_format($grandTotal, 2, '.', "'") . "
Gesamttotal inkl. MWST" . $this->FormatCurrency($grandTotal) . "
"; } else { $grandTotal = $subTotal; $html .= " -
Gesamttotal:CHF " . number_format($grandTotal, 2, '.', "'") . "
Gesamttotal" . $this->FormatCurrency($grandTotal) . "

Nicht mehrwertsteuerpflichtig.
Nicht mehrwertsteuerpflichtig.
- "; + "; } $pdf->writeHTML($html, true, false, true, false, ''); - // WICHTIG: Total zurückgeben für den Einzahlungsschein return $grandTotal; } - // ====================== QR-Einzahlungsschein (Schweiz) ====================== -private function BuildQRBill($pdf, $user, $amount) + private function BuildQRBill($pdf, $user, $amount) { $iban = str_replace(' ', '', $this->ReadPropertyString('BankIBAN')); - + if (empty($iban) || $amount <= 0) { return; } - $pdf->AddPage(); // Schliesst die vorherige Seite (Fusszeile wird dort noch gedruckt) - $pdf->printFooterText = false; // Fusszeile für diese QR-Seite AUSSCHALTEN + $pdf->AddPage(); + $pdf->printFooterText = false; $pdf->SetAutoPageBreak(false); - // Wir nutzen Helvetica für den QR-Schein (Standard gemäss Richtlinien) $font = 'helvetica'; - $yStart = 192; + $yStart = 192; + $amountText = number_format($amount, 2, '.', ' '); - // Trennlinien zeichnen (Scherensymbol) $pdf->SetLineStyle(['dash' => '2,2', 'color' => [0, 0, 0], 'width' => 0.1]); - $pdf->Line(0, $yStart, 210, $yStart); // Horizontal - $pdf->Line(62, $yStart, 62, 297); // Vertikal - $pdf->SetLineStyle(['dash' => 0, 'color' => [0, 0, 0]]); // Reset + $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'); - $bankIBAN_formatted = wordwrap($iban, 4, ' ', true); + $bankIBANFormatted = wordwrap($iban, 4, ' ', true); + $debtorLines = [ + $user['name'] ?? '', + $user['address'] ?? '', + $user['city'] ?? '' + ]; - // --- LINKER TEIL: Empfangsschein (0-62mm) --- + // Linker Teil: Empfangsschein $pdf->SetFont($font, 'B', 11); $pdf->SetXY(5, $yStart + 5); $pdf->Cell(52, 5, 'Empfangsschein', 0, 1); - $pdf->SetFont($font, 'B', 6); - $pdf->SetXY(5, $yStart + 12); - $pdf->Cell(52, 3, 'Konto / Zahlbar an', 0, 1); - $pdf->SetFont($font, '', 8); - $pdf->SetX(5); $pdf->Cell(52, 3, $bankIBAN_formatted, 0, 1); - $pdf->SetX(5); $pdf->Cell(52, 3, $creditorName, 0, 1); - $pdf->SetX(5); $pdf->Cell(52, 3, $creditorCity, 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 + 35); - $pdf->Cell(52, 3, 'Zahlbar durch', 0, 1); - $pdf->SetFont($font, '', 8); - $pdf->SetX(5); $pdf->Cell(52, 3, $user['name'] ?? '', 0, 1); - $pdf->SetX(5); $pdf->Cell(52, 3, $user['address'] ?? '', 0, 1); - $pdf->SetX(5); $pdf->Cell(52, 3, $user['city'] ?? '', 0, 1); - - // Währung und Betrag unten Links - $pdf->SetFont($font, 'B', 6); - $pdf->SetXY(5, $yStart + 65); + $pdf->SetXY(5, $yStart + 68); $pdf->Cell(15, 3, 'Währung', 0, 0); - $pdf->Cell(20, 3, 'Betrag', 0, 1); + $pdf->Cell(30, 3, 'Betrag', 0, 1); $pdf->SetFont($font, '', 8); $pdf->SetX(5); $pdf->Cell(15, 4, 'CHF', 0, 0); - $pdf->Cell(20, 4, number_format($amount, 2, '.', ' '), 0, 1); // Leerzeichen bei Tausender + $pdf->Cell(30, 4, $amountText, 0, 1); - // Annahmestelle $pdf->SetFont($font, 'B', 6); - $pdf->SetXY(38, $yStart + 85); + $pdf->SetXY(38, $yStart + 90); $pdf->Cell(19, 3, 'Annahmestelle', 0, 1, 'R'); - - // --- RECHTER TEIL: Zahlteil --- + // Rechter Teil: Zahlteil $pdf->SetFont($font, 'B', 11); $pdf->SetXY(67, $yStart + 5); $pdf->Cell(50, 5, 'Zahlteil', 0, 1); - // QR Code rendern $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'); - // Schweizer Kreuz über den QR Code zeichnen (Spezifikation: 7x7mm inkl. weissem Rand) - $cX = 67 + 23; // Zentrum X des QR Codes (Position 67 + Hälfte von 46) - $cY = $yStart + 17 + 23; // Zentrum Y des QR Codes (Position 17 + Hälfte von 46) + $cX = 67 + 23; + $cY = $yStart + 17 + 23; $pdf->SetFillColor(255, 255, 255); $pdf->Rect($cX - 3.5, $cY - 3.5, 7, 7, 'F'); @@ -365,41 +676,48 @@ private function BuildQRBill($pdf, $user, $amount) $pdf->Rect($cX - 0.6, $cY - 2, 1.2, 4, 'F'); $pdf->Rect($cX - 2, $cY - 0.6, 4, 1.2, 'F'); - // Währung und Betrag unter dem QR Code $pdf->SetFont($font, 'B', 8); $pdf->SetXY(67, $yStart + 68); $pdf->Cell(15, 4, 'Währung', 0, 0); - $pdf->Cell(20, 4, 'Betrag', 0, 1); + $pdf->Cell(30, 4, 'Betrag', 0, 1); $pdf->SetFont($font, '', 10); $pdf->SetX(67); $pdf->Cell(15, 5, 'CHF', 0, 0); - $pdf->Cell(20, 5, number_format($amount, 2, '.', ' '), 0, 1); + $pdf->Cell(30, 5, $amountText, 0, 1); - // --- RECHTE TEXTSPALTE (ab 118mm) --- - $pdf->SetFont($font, 'B', 8); - $pdf->SetXY(118, $yStart + 5); - $pdf->Cell(80, 4, 'Konto / Zahlbar an', 0, 1); - $pdf->SetFont($font, '', 10); - $pdf->SetX(118); $pdf->Cell(80, 4, $bankIBAN_formatted, 0, 1); - $pdf->SetX(118); $pdf->Cell(80, 4, $creditorName, 0, 1); - $pdf->SetX(118); $pdf->Cell(80, 4, $creditorCity, 0, 1); + $this->WriteQrTextBlock($pdf, 118, $yStart + 5, 84, 'Konto / Zahlbar an', [ + $bankIBANFormatted, + $creditorName, + $creditorAddress, + $creditorCity + ], 8, 9, 4); - $pdf->SetFont($font, 'B', 8); - $pdf->SetXY(118, $yStart + 26); - $pdf->Cell(80, 4, 'Zusätzliche Informationen', 0, 1); - $pdf->SetFont($font, '', 10); - $pdf->SetX(118); $pdf->Cell(80, 4, 'Abrechnung Liegenschaft', 0, 1); + $this->WriteQrTextBlock($pdf, 118, $yStart + 34, 84, 'Zusätzliche Informationen', [ + 'Abrechnung Liegenschaft' + ], 8, 9, 4); - $pdf->SetFont($font, 'B', 8); - $pdf->SetXY(118, $yStart + 43); - $pdf->Cell(80, 4, 'Zahlbar durch', 0, 1); - $pdf->SetFont($font, '', 10); - $pdf->SetX(118); $pdf->Cell(80, 4, $user['name'] ?? '', 0, 1); - $pdf->SetX(118); $pdf->Cell(80, 4, $user['address'] ?? '', 0, 1); - $pdf->SetX(118); $pdf->Cell(80, 4, $user['city'] ?? '', 0, 1); + $this->WriteQrTextBlock($pdf, 118, $yStart + 50, 84, 'Zahlbar durch', $debtorLines, 8, 9, 4); + + $pdf->SetAutoPageBreak(true, 20); } -private function GenerateQRString($iban, $amount, $user) + 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); @@ -600,170 +918,159 @@ private function GenerateQRString($iban, $amount, $user) } private function GetCalculatedPowerCosts($userId) { - $html = " - - - - - - - - - - - - - - - - - "; - if (empty($this->powerCostCache) || !isset($this->powerCostCache[$userId])) { - $html .= "
IDImport (kWh)Export (kWh)ZEV-Haus (kWh)Netz-Haus (kWh)Solar-Netz (kWh)Solar-ZEV (kWh)Kauf Solar (CHF)Kauf Netz (CHF)Verkauf Netz(CHF)Verkauf ZEV(CHF)Total CHF
Keine Stromzähler für diesen Benutzer
"; - return ['html' => $html, 'sum' => 0.0]; + 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; - $rowClass = ($rowIndex % 2 === 0) ? "row-even" : "row-odd"; - $rowIndex++; + foreach ($totals as $key => $_) { + $totals[$key] += (float)($a[$key] ?? 0.0); + } - // Datenzeile - $html .= " - {$a['name']} - " . number_format($a['imp'], 3, '.', "'") . " - " . number_format($a['exp'], 3, '.', "'") . " - " . number_format($a['solar_bezug'], 3, '.', "'") . " - " . number_format($a['netz_bezug'], 3, '.', "'") . " - " . number_format($a['solareinspeisung'], 3, '.', "'") . " - " . number_format($a['solarverkauf'], 3, '.', "'") . " - " . number_format($a['cost_solar'], 2, '.', "'") . " - " . number_format($a['cost_grid'], 2, '.', "'") . " - " . number_format($a['rev_feedin'], 2, '.', "'") . " - " . number_format($a['rev_zev'], 2, '.', "'") . " - " . number_format($subtotal, 2, '.', "'") . " + $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) . " "; - // Leerzeile - $html .= ""; + $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) . " + "; } - // Gesamttotal - $html .= " - - Total - - - - - - - - - - - " . number_format($sum, 2, '.', "'") . " + $html = " + + + + + + + + + + " . $energyRows . " + + + + + + + + -
ZählpunktImport kWhExport kWhSolarbezug kWhNetzbezug kWhEinspeisung kWhZEV-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ählpunktKauf Solar CHFKauf Netz CHFGutschrift Netz CHFGutschrift ZEV CHFTotal 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 = " - - - - - - - - - - - -"; +
IDTypDatum vonDatum bisZähler StartZähler EndeVerbrauchTarifKosten CHF
+ + + + + + + + + + + "; - $total = 0.0; + $total = 0.0; $usedTariffs = []; + $hasRows = false; foreach ($waterMeters as $m) { - if ($m['user_id'] != $userId) { + 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']); } - $html .= " - - - - - - - - - - "; + if (!$hasRows) { + $html .= ""; + } + + $html .= " + + + + +
ZählerTypVonBisStartEndeVerbrauchTarifKosten CHF
TotalCHF " . number_format($total, 2) . ".-
Keine Nebenkostenzähler für diesen Benutzer
Zwischentotal Nebenkosten" . $this->FormatCurrency($total) . "
"; if (!empty($usedTariffs)) { - $html .= "

Angewendete Nebenkostentarife


"; } return ['html' => $html, 'sum' => $total]; @@ -771,74 +1078,78 @@ private function GenerateQRString($iban, $amount, $user) private function AddMeterToPDFRow($meter, $tariffs, $from, $to, $type) { - $rows = ''; - $totalCost = 0.0; - $usedTariffs = []; - $varId = $meter['var_consumption']; - - if (!IPS_VariableExists($varId)) { + $varId = (int)($meter['var_consumption'] ?? 0); + if ($varId <= 0 || !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)) - ); + $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'], false); - $endTs = $this->toUnixTs($t['end'], true); + $startTs = $this->toUnixTs($t['start'] ?? 0, false); + $endTs = $this->toUnixTs($t['end'] ?? 0, 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'] + 'end' => $endTs, + 'price' => (float)($t['price'] ?? 0), + 'unit_type' => $t['unit_type'] ?? $type ]; $usedTariffs[] = $activeTariff; - break; // erster passender Tarif reicht + break; } $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); + $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; + $kosten = round(($tariffPrice / 100) * $verbrauch, 2); - $rows .= " - {$meter['name']} - {$type} - " . date('d.m.Y', $from) . " - " . date('d.m.Y', $to) . " - " . number_format($startValue, 2) . " - " . number_format($endValue, 2) . " - " . number_format($verbrauch, 2) . " - " . number_format($tariffPrice, 2) . " - " . number_format($kosten, 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) . " "; - if ($rows === '') { - return ['row' => '', 'value' => 0, 'tariffs' => []]; - } + return ['row' => $row, 'value' => $kosten, 'tariffs' => array_values($usedTariffs)]; + } + // ====================== Hilfsfunktionen ====================== - return ['row' => $rows, 'value' => $totalCost, 'tariffs' => array_values($usedTariffs)]; + private function h($value) + { + return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } - // ====================== Hilfsfunktionen ====================== + 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) { @@ -898,10 +1209,22 @@ private function GenerateQRString($iban, $amount, $user) private function toUnixTs($val, $endOfDay = false) { if (is_int($val)) { - return $val; + return $endOfDay ? strtotime(date('Y-m-d 23:59:59', $val)) : $val; } if (is_numeric($val)) { - return (int)$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)) { @@ -969,7 +1292,8 @@ private function GenerateQRString($iban, $amount, $user) $result[] = [ 'start' => $start, 'end' => $end, - 'price' => (float)$t['price'] + 'price' => (float)$t['price'], + 'unit_type' => $t['unit_type'] ?? '' ]; } @@ -1006,4 +1330,4 @@ private function GenerateQRString($iban, $amount, $user) return $path; } } -?> \ No newline at end of file +?>