2852 lines
106 KiB
PHP
2852 lines
106 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
include_once __DIR__ . '/libs/vendor/autoload.php'; // TCPDF via Composer
|
|
|
|
/**
|
|
* Eigene TCPDF-Klasse mit Logo im Header und Text in der Fusszeile
|
|
*/
|
|
|
|
class VerbrauchskostenreportPDF extends TCPDF
|
|
{
|
|
public $logoFile = null;
|
|
public $footerText = '';
|
|
public $printFooterText = true; // Neu: Steuert ob die Fusszeile gedruckt wird
|
|
|
|
public function Header()
|
|
{
|
|
if ($this->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 Report-Fusszeile drucken!
|
|
}
|
|
$this->SetY(-18);
|
|
$this->SetFont('dejavusans', '', 8);
|
|
$this->MultiCell(0, 8, $this->footerText, 0, 'C', false, 1);
|
|
}
|
|
}
|
|
|
|
class Verbrauchskostenreport extends IPSModule
|
|
{
|
|
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
|
|
private const LIZENZ_RETRY_SEKUNDEN = 900;
|
|
private const KATALOG_GRUNDLIZENZ = 'cost_report';
|
|
private const KATALOG_STROMZAEHLER = 'cost_meter_power';
|
|
private const KATALOG_NEBENZAEHLER = 'cost_meter_aux';
|
|
|
|
/** @var array<int, array<string, array<string, float|string>>> */
|
|
private array $powerCostCache = [];
|
|
|
|
public function Create()
|
|
{
|
|
parent::Create();
|
|
|
|
$this->RegisterPropertyString('Users', '[]');
|
|
$this->RegisterPropertyString('PowerMeters', '[]');
|
|
$this->RegisterPropertyString('WaterMeters', '[]');
|
|
$this->RegisterPropertyString('Tariffs', '[]');
|
|
$this->RegisterPropertyString('Lizenzcode', '');
|
|
$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);
|
|
|
|
// Strukturierte Adressfelder für den QR-Zahlteil nach SIX SPS 2.3
|
|
$this->RegisterPropertyString('CreditorName', '');
|
|
$this->RegisterPropertyString('CreditorStreet', '');
|
|
$this->RegisterPropertyString('CreditorBuildingNumber', '');
|
|
$this->RegisterPropertyString('CreditorPostalCode', '');
|
|
$this->RegisterPropertyString('CreditorTown', '');
|
|
$this->RegisterPropertyString('CreditorCountry', 'CH');
|
|
$this->RegisterPropertyString('BankIBAN', '');
|
|
|
|
$this->RegisterAttributeString('LizenzInstallationID', '');
|
|
$this->RegisterAttributeString('LizenzLease', '{}');
|
|
$this->RegisterAttributeString('LizenzcodeHash', '');
|
|
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
|
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
|
if ($this->ReadAttributeString('LizenzInstallationID') === '') {
|
|
$this->WriteAttributeString('LizenzInstallationID', $this->ErzeugeUuidV4());
|
|
}
|
|
|
|
$this->RegisterTariffVariableProfiles();
|
|
|
|
$this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1);
|
|
$this->RegisterVariableInteger('ToDate', 'Enddatum', '~UnixTimestamp', 2);
|
|
$this->RegisterVariableString('LastResult', 'Letzter Report', '', 3);
|
|
$this->RegisterVariableString('VisuTariffOverview', 'Tarife bearbeiten', '~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->RegisterVariableString('VisuTariffPayload', 'Tarif Payload', '', 16);
|
|
$this->RegisterVariableString('VisuUserOverview', 'Benutzer bearbeiten', '~HTMLBox', 20);
|
|
$this->RegisterVariableString('VisuUserStatus', 'Benutzer Status', '', 21);
|
|
$this->EnableAction('FromDate');
|
|
$this->EnableAction('ToDate');
|
|
$this->EnableAction('VisuTariffType');
|
|
$this->EnableAction('VisuTariffStart');
|
|
$this->EnableAction('VisuTariffEnd');
|
|
$this->EnableAction('VisuTariffPrice');
|
|
$this->EnableAction('VisuTariffPayload');
|
|
|
|
$this->RegisterScript(
|
|
'CreateReport',
|
|
'Report erstellen',
|
|
"<?php IPS_RequestAction(" . $this->InstanceID . ", 'CreateReport', ''); ?>"
|
|
);
|
|
$this->EnsureVisuTariffObjects();
|
|
$this->EnsureVisuUserObjects();
|
|
$this->RegisterMediaDocument('ReportPDF', 'Letzter Report', 'pdf');
|
|
}
|
|
|
|
public function ApplyChanges()
|
|
{
|
|
parent::ApplyChanges();
|
|
$this->RegisterTariffVariableProfiles();
|
|
$this->RegisterTariffWebhook();
|
|
$this->EnsureVisuTariffObjects();
|
|
$this->EnsureVisuUserObjects();
|
|
$this->InitializeVisuTariffDefaults();
|
|
$this->UpdateTariffOverview();
|
|
$this->UpdateUserOverview();
|
|
$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false);
|
|
$this->AktualisiereLizenzForm();
|
|
}
|
|
|
|
public function Destroy()
|
|
{
|
|
$this->UnregisterTariffWebhook();
|
|
parent::Destroy();
|
|
}
|
|
|
|
private function RegisterTariffWebhook()
|
|
{
|
|
$webhook = $this->GetTariffWebhookPath();
|
|
|
|
if (method_exists($this, 'RegisterHook')) {
|
|
$this->RegisterHook($webhook);
|
|
return;
|
|
}
|
|
|
|
$ids = @IPS_GetInstanceListByModuleID('{015A6EB8-D6E5-4B93-B496-0D3F77AE9FE1}');
|
|
if (empty($ids)) {
|
|
return;
|
|
}
|
|
|
|
$hookID = $ids[0];
|
|
$hooks = json_decode(IPS_GetProperty($hookID, 'Hooks'), true);
|
|
$hooks = is_array($hooks) ? $hooks : [];
|
|
$found = false;
|
|
|
|
foreach ($hooks as $index => $hook) {
|
|
if (($hook['Hook'] ?? '') !== $webhook) {
|
|
continue;
|
|
}
|
|
$hooks[$index]['TargetID'] = $this->InstanceID;
|
|
$found = true;
|
|
break;
|
|
}
|
|
|
|
if (!$found) {
|
|
$hooks[] = [
|
|
'Hook' => $webhook,
|
|
'TargetID' => $this->InstanceID
|
|
];
|
|
}
|
|
|
|
IPS_SetProperty($hookID, 'Hooks', json_encode($hooks));
|
|
IPS_ApplyChanges($hookID);
|
|
}
|
|
|
|
private function UnregisterTariffWebhook()
|
|
{
|
|
$webhook = $this->GetTariffWebhookPath();
|
|
|
|
if (method_exists($this, 'UnregisterHook')) {
|
|
$this->UnregisterHook($webhook);
|
|
return;
|
|
}
|
|
|
|
$ids = @IPS_GetInstanceListByModuleID('{015A6EB8-D6E5-4B93-B496-0D3F77AE9FE1}');
|
|
if (empty($ids)) {
|
|
return;
|
|
}
|
|
|
|
$hookID = $ids[0];
|
|
$hooks = json_decode(IPS_GetProperty($hookID, 'Hooks'), true);
|
|
if (!is_array($hooks)) {
|
|
return;
|
|
}
|
|
|
|
$filteredHooks = array_values(array_filter($hooks, function ($hook) use ($webhook) {
|
|
return ($hook['Hook'] ?? '') !== $webhook
|
|
|| (int)($hook['TargetID'] ?? 0) !== $this->InstanceID;
|
|
}));
|
|
if (count($filteredHooks) === count($hooks)) {
|
|
return;
|
|
}
|
|
|
|
IPS_SetProperty($hookID, 'Hooks', json_encode($filteredHooks));
|
|
IPS_ApplyChanges($hookID);
|
|
}
|
|
|
|
private function GetTariffWebhookPath()
|
|
{
|
|
return '/hook/belevo_verbrauchskostenreport_tariffs_' . $this->InstanceID;
|
|
}
|
|
|
|
private function EnsureVisuTariffObjects()
|
|
{
|
|
$this->RegisterVariableString('VisuTariffOverview', 'Tarife bearbeiten', '~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->RegisterVariableString('VisuTariffPayload', 'Tarif Payload', '', 16);
|
|
|
|
$this->EnableAction('VisuTariffType');
|
|
$this->EnableAction('VisuTariffStart');
|
|
$this->EnableAction('VisuTariffEnd');
|
|
$this->EnableAction('VisuTariffPrice');
|
|
$this->EnableAction('VisuTariffPayload');
|
|
|
|
foreach (['VisuTariffType', 'VisuTariffStart', 'VisuTariffEnd', 'VisuTariffPrice', 'VisuTariffStatus', 'VisuTariffPayload', 'SaveVisuTariff', 'DeleteVisuTariff'] as $ident) {
|
|
$this->HideObjectByIdent($ident);
|
|
}
|
|
}
|
|
|
|
private function EnsureVisuUserObjects()
|
|
{
|
|
$this->RegisterVariableString('VisuUserOverview', 'Benutzer bearbeiten', '~HTMLBox', 20);
|
|
$this->RegisterVariableString('VisuUserStatus', 'Benutzer Status', '', 21);
|
|
$this->HideObjectByIdent('VisuUserStatus');
|
|
}
|
|
|
|
private function HideObjectByIdent($Ident)
|
|
{
|
|
$id = @IPS_GetObjectIDByIdent($Ident, $this->InstanceID);
|
|
if ($id !== false) {
|
|
IPS_SetHidden($id, true);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
$types = $this->GetTariffTypes();
|
|
$tariffs = $this->ReadTariffs();
|
|
$rows = '';
|
|
|
|
foreach ($tariffs as $tariff) {
|
|
$rows .= $this->BuildTariffEditorRow(
|
|
(string)($tariff['unit_type'] ?? 'Netztarif'),
|
|
$this->FormatDateInput($this->toUnixTs($tariff['start'] ?? 0, false)),
|
|
$this->FormatDateInput($this->toUnixTs($tariff['end'] ?? 0, false)),
|
|
number_format((float)($tariff['price'] ?? 0), 3, '.', '')
|
|
);
|
|
}
|
|
|
|
if ($rows === '') {
|
|
$rows = $this->BuildTariffEditorRow('Netztarif', date('Y-01-01'), date('Y-12-31'), '0.000');
|
|
}
|
|
|
|
$typeOptions = '';
|
|
foreach ($types as $type) {
|
|
$typeOptions .= '<option value="' . $this->h($type) . '">' . $this->h($type) . '</option>';
|
|
}
|
|
|
|
$webhookPath = $this->GetTariffWebhookPath();
|
|
$status = $this->h(@GetValue($this->GetIDForIdent('VisuTariffStatus')));
|
|
|
|
$html = "
|
|
<div class='belevo-tariffs'>
|
|
<style>
|
|
.belevo-tariffs{font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#202124;max-width:980px}
|
|
.belevo-tariffs table{width:100%;border-collapse:collapse;background:#fff}
|
|
.belevo-tariffs th{background:#f3f4f6;border:1px solid #d6d9de;text-align:left;padding:7px;font-weight:700}
|
|
.belevo-tariffs td{border:1px solid #e0e2e6;padding:5px;vertical-align:middle}
|
|
.belevo-tariffs input,.belevo-tariffs select{width:100%;box-sizing:border-box;border:1px solid #c7cbd1;border-radius:3px;padding:5px;font:inherit;background:#fff}
|
|
.belevo-tariffs .num{text-align:right}
|
|
.belevo-tariffs .actions{display:flex;gap:8px;margin-top:10px;align-items:center}
|
|
.belevo-tariffs button{border:1px solid #9aa0a6;background:#f8f9fa;border-radius:3px;padding:6px 10px;font:inherit;cursor:pointer}
|
|
.belevo-tariffs button.primary{background:#1f4e79;border-color:#1f4e79;color:#fff;font-weight:700}
|
|
.belevo-tariffs button.danger{color:#9b1c1c;border-color:#d7a6a6;background:#fff5f5}
|
|
.belevo-tariffs .status{margin-left:8px;color:#4b5563}
|
|
</style>
|
|
<form method='post' action='" . $this->h($webhookPath) . "' target='belevo_tariff_save_frame'>
|
|
<input type='hidden' name='editor' value='tariffs'>
|
|
<input type='hidden' name='payload' value=''>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style='width:23%'>Tarifart</th>
|
|
<th style='width:20%'>Startdatum</th>
|
|
<th style='width:20%'>Enddatum</th>
|
|
<th style='width:22%'>Rp/Einheit</th>
|
|
<th style='width:15%'>Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>" . $rows . "</tbody>
|
|
</table>
|
|
<div class='actions'>
|
|
<button type='button' data-action='add'>Zeile hinzufügen</button>
|
|
<button type='submit' data-action='save' class='primary'>Tarife speichern</button>
|
|
<span class='status'>" . $status . "</span>
|
|
</div>
|
|
</form>
|
|
<iframe name='belevo_tariff_save_frame' style='display:none;width:0;height:0;border:0'></iframe>
|
|
<template data-row-template>" . $this->BuildTariffEditorRow('Netztarif', date('Y-01-01'), date('Y-12-31'), '0.000') . "</template>
|
|
<script>
|
|
(function(){
|
|
var root=document.currentScript.closest('.belevo-tariffs');
|
|
if(!root||root.dataset.bound==='1'){return;}
|
|
root.dataset.bound='1';
|
|
var form=root.querySelector('form');
|
|
var tbody=root.querySelector('tbody');
|
|
var status=root.querySelector('.status');
|
|
window.addEventListener('message',function(event){
|
|
if(event&&event.data&&event.data.type==='belevoTariffSave'){
|
|
status.textContent=event.data.message||'Gespeichert.';
|
|
}
|
|
});
|
|
function bindDelete(row){
|
|
var btn=row.querySelector('[data-action=delete]');
|
|
if(btn){btn.onclick=function(){row.parentNode.removeChild(row);};}
|
|
}
|
|
Array.prototype.forEach.call(tbody.querySelectorAll('tr'),bindDelete);
|
|
root.querySelector('[data-action=add]').onclick=function(){
|
|
var tpl=root.querySelector('template[data-row-template]').innerHTML;
|
|
var temp=document.createElement('tbody');
|
|
temp.innerHTML=tpl;
|
|
var row=temp.querySelector('tr');
|
|
tbody.appendChild(row);
|
|
bindDelete(row);
|
|
};
|
|
form.onsubmit=function(){
|
|
var rows=[];
|
|
Array.prototype.forEach.call(tbody.querySelectorAll('tr'),function(row){
|
|
var type=row.querySelector('[data-field=unit_type]').value;
|
|
var start=row.querySelector('[data-field=start]').value;
|
|
var end=row.querySelector('[data-field=end]').value;
|
|
var price=row.querySelector('[data-field=price]').value.replace(',', '.');
|
|
if(type&&start&&end&&price!==''){
|
|
rows.push({unit_type:type,start:start,end:end,price:price});
|
|
}
|
|
});
|
|
form.querySelector('input[name=payload]').value=JSON.stringify(rows);
|
|
status.textContent='Speichern gesendet.';
|
|
return true;
|
|
};
|
|
})();
|
|
</script>
|
|
</div>";
|
|
|
|
SetValue($overviewID, $html);
|
|
}
|
|
|
|
private function BuildTariffEditorRow($type, $start, $end, $price)
|
|
{
|
|
$options = '';
|
|
foreach ($this->GetTariffTypes() as $tariffType) {
|
|
$selected = strtolower($tariffType) === strtolower((string)$type) ? ' selected' : '';
|
|
$options .= '<option value="' . $this->h($tariffType) . '"' . $selected . '>' . $this->h($tariffType) . '</option>';
|
|
}
|
|
|
|
return '<tr>'
|
|
. '<td><select data-field="unit_type" name="unit_type[]">' . $options . '</select></td>'
|
|
. '<td><input data-field="start" name="start[]" type="date" value="' . $this->h($start) . '"></td>'
|
|
. '<td><input data-field="end" name="end[]" type="date" value="' . $this->h($end) . '"></td>'
|
|
. '<td><input data-field="price" name="price[]" class="num" type="number" min="0" step="0.001" value="' . $this->h($price) . '"></td>'
|
|
. '<td><button type="button" class="danger" data-action="delete">Löschen</button></td>'
|
|
. '</tr>';
|
|
}
|
|
|
|
private function SaveTariffTableFromPayload($payload)
|
|
{
|
|
$rows = json_decode((string)$payload, true);
|
|
if (!is_array($rows)) {
|
|
throw new Exception('Ungültige Tarifdaten.');
|
|
}
|
|
|
|
$allowedTypes = $this->GetTariffTypes();
|
|
$tariffs = [];
|
|
|
|
foreach ($rows as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$type = trim((string)($row['unit_type'] ?? ''));
|
|
$startText = trim((string)($row['start'] ?? ''));
|
|
$endText = trim((string)($row['end'] ?? ''));
|
|
$priceText = str_replace(',', '.', trim((string)($row['price'] ?? '')));
|
|
|
|
if ($type === '' && $startText === '' && $endText === '' && $priceText === '') {
|
|
continue;
|
|
}
|
|
if (!in_array($type, $allowedTypes, true)) {
|
|
throw new Exception('Ungültige Tarifart: ' . $type);
|
|
}
|
|
if ($startText === '' || $endText === '') {
|
|
throw new Exception('Start- und Enddatum müssen gesetzt sein.');
|
|
}
|
|
if (!is_numeric($priceText) || (float)$priceText < 0) {
|
|
throw new Exception('Tarifpreis ungültig.');
|
|
}
|
|
|
|
$start = strtotime($startText . ' 00:00:00');
|
|
$end = strtotime($endText . ' 00:00:00');
|
|
if ($start === false || $end === false || $end < $start) {
|
|
throw new Exception('Datumsbereich ungültig.');
|
|
}
|
|
|
|
$tariffs[] = [
|
|
'start' => $start,
|
|
'end' => $end,
|
|
'price' => round((float)$priceText, 3),
|
|
'unit_type' => $type
|
|
];
|
|
}
|
|
|
|
$this->WriteTariffs($tariffs);
|
|
return count($tariffs) . ' Tarif(e) gespeichert.';
|
|
}
|
|
|
|
private function SaveTariffTableFromPost(array $post)
|
|
{
|
|
$types = $post['unit_type'] ?? [];
|
|
$starts = $post['start'] ?? [];
|
|
$ends = $post['end'] ?? [];
|
|
$prices = $post['price'] ?? [];
|
|
|
|
if (!is_array($types) || !is_array($starts) || !is_array($ends) || !is_array($prices)) {
|
|
throw new Exception('Ungültige Formularwerte.');
|
|
}
|
|
|
|
$rows = [];
|
|
$count = max(count($types), count($starts), count($ends), count($prices));
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$rows[] = [
|
|
'unit_type' => $types[$i] ?? '',
|
|
'start' => $starts[$i] ?? '',
|
|
'end' => $ends[$i] ?? '',
|
|
'price' => $prices[$i] ?? ''
|
|
];
|
|
}
|
|
|
|
return $this->SaveTariffTableFromPayload(json_encode($rows));
|
|
}
|
|
|
|
private function AktualisiereLizenz(string $lizenzcode, bool $erzwingen): bool
|
|
{
|
|
$code = $this->NormalisiereLizenzcode($lizenzcode);
|
|
if (!$this->IstLizenzcodeGueltig($code)) {
|
|
$this->WriteAttributeString(
|
|
'LizenzLetzterFehler',
|
|
$code === '' ? 'Verbrauchskostenlizenzcode fehlt.' : 'Verbrauchskostenlizenzcode ist ungültig.'
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$installationID = $this->ReadAttributeString('LizenzInstallationID');
|
|
if ($installationID === '') {
|
|
$installationID = $this->ErzeugeUuidV4();
|
|
$this->WriteAttributeString('LizenzInstallationID', $installationID);
|
|
}
|
|
|
|
$codeHash = hash('sha256', $code);
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
$lease = $this->LeseLizenzLease();
|
|
$lokalGueltig = false;
|
|
if ($gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash)) {
|
|
try {
|
|
$this->PruefeLizenzLease($lease, $installationID);
|
|
$lokalGueltig = true;
|
|
} catch (Throwable $fehler) {
|
|
$lokalGueltig = false;
|
|
}
|
|
}
|
|
|
|
$erneuernAb = strtotime((string)($lease['refreshAfter'] ?? '')) ?: 0;
|
|
if (!$erzwingen && $lokalGueltig && time() < $erneuernAb) {
|
|
return true;
|
|
}
|
|
if (!$erzwingen && time() < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) {
|
|
return $lokalGueltig;
|
|
}
|
|
|
|
$this->WriteAttributeInteger(
|
|
'LizenzNaechsterVersuch',
|
|
time() + self::LIZENZ_RETRY_SEKUNDEN
|
|
);
|
|
try {
|
|
$neueLease = $this->RufeLizenzserverAuf($code, $installationID);
|
|
$this->PruefeLizenzLease($neueLease, $installationID);
|
|
$this->WriteAttributeString(
|
|
'LizenzLease',
|
|
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
|
);
|
|
$this->WriteAttributeString('LizenzcodeHash', $codeHash);
|
|
$this->WriteAttributeString('LizenzLetzterFehler', '');
|
|
$this->WriteAttributeInteger(
|
|
'LizenzNaechsterVersuch',
|
|
strtotime((string)$neueLease['refreshAfter']) ?: time() + 86400
|
|
);
|
|
return true;
|
|
} catch (Throwable $fehler) {
|
|
$this->WriteAttributeString(
|
|
'LizenzLetzterFehler',
|
|
substr($fehler->getMessage(), 0, 500)
|
|
);
|
|
return $lokalGueltig;
|
|
}
|
|
}
|
|
|
|
private function AktualisiereLizenzForm(?string $lizenzcode = null): void
|
|
{
|
|
$this->UpdateFormField(
|
|
'LizenzInstallationID',
|
|
'caption',
|
|
'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID')
|
|
);
|
|
$this->UpdateFormField(
|
|
'LizenzInformation',
|
|
'caption',
|
|
$this->LizenzStatustext($lizenzcode)
|
|
);
|
|
}
|
|
|
|
private function LizenzStatustext(?string $lizenzcode = null): string
|
|
{
|
|
$code = $this->NormalisiereLizenzcode(
|
|
$lizenzcode ?? $this->ReadPropertyString('Lizenzcode')
|
|
);
|
|
if ($code === '') {
|
|
return 'Verbrauchskostenlizenzcode fehlt.';
|
|
}
|
|
if (!$this->IstLizenzcodeGueltig($code)) {
|
|
return 'Verbrauchskostenlizenzcode ist ungültig.';
|
|
}
|
|
|
|
$hash = $this->ReadAttributeString('LizenzcodeHash');
|
|
$fehler = $this->ReadAttributeString('LizenzLetzterFehler');
|
|
if ($hash === '' || !hash_equals($hash, hash('sha256', $code))) {
|
|
return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestätigt.';
|
|
}
|
|
|
|
try {
|
|
$lease = $this->LeseLizenzLease();
|
|
$this->PruefeLizenzLease(
|
|
$lease,
|
|
$this->ReadAttributeString('LizenzInstallationID')
|
|
);
|
|
$status = sprintf(
|
|
'Aktiv: %d Stromzähler und %d Wärme-/Wasserzähler lizenziert.',
|
|
$this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER),
|
|
$this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER)
|
|
);
|
|
if ($fehler !== '') {
|
|
$status .= ' Online-Prüfung fehlgeschlagen; lokale Freigabe ist noch gültig. Fehler: '
|
|
. $fehler;
|
|
}
|
|
return $status;
|
|
} catch (Throwable $ungueltig) {
|
|
return 'Nicht freigegeben: ' . $ungueltig->getMessage();
|
|
}
|
|
}
|
|
|
|
private function PruefeLizenzLease(array $lease, string $installationID): void
|
|
{
|
|
if (($lease['status'] ?? null) !== 'active') {
|
|
throw new RuntimeException('Die Verbrauchskostenlizenz ist nicht aktiv.');
|
|
}
|
|
if (($lease['installationId'] ?? null) !== $installationID) {
|
|
throw new RuntimeException('Die Verbrauchskostenlizenz gehört zu einer anderen Installation.');
|
|
}
|
|
$offlineBis = strtotime((string)($lease['offlineUntil'] ?? ''));
|
|
if ($offlineBis === false || time() >= $offlineBis) {
|
|
throw new RuntimeException('Die Offline-Freigabe ist abgelaufen.');
|
|
}
|
|
if ($this->LizenzMenge($lease, self::KATALOG_GRUNDLIZENZ) < 1) {
|
|
throw new RuntimeException('Die Grundlizenz für den Verbrauchskostenreport fehlt.');
|
|
}
|
|
|
|
$strom = count($this->LeseJsonListe('PowerMeters'));
|
|
$neben = count($this->LeseJsonListe('WaterMeters'));
|
|
$stromLizenziert = $this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER);
|
|
$nebenLizenziert = $this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER);
|
|
if ($strom > $stromLizenziert) {
|
|
throw new RuntimeException(sprintf(
|
|
'Stromzähler: %d konfiguriert, aber nur %d lizenziert.',
|
|
$strom,
|
|
$stromLizenziert
|
|
));
|
|
}
|
|
if ($neben > $nebenLizenziert) {
|
|
throw new RuntimeException(sprintf(
|
|
'Wärme-/Wasserzähler: %d konfiguriert, aber nur %d lizenziert.',
|
|
$neben,
|
|
$nebenLizenziert
|
|
));
|
|
}
|
|
}
|
|
|
|
private function LizenzMenge(array $lease, string $kennung): int
|
|
{
|
|
$menge = 0;
|
|
foreach (($lease['entitlements'] ?? []) as $berechtigung) {
|
|
if (!is_array($berechtigung) || ($berechtigung['status'] ?? null) !== 'active') {
|
|
continue;
|
|
}
|
|
$schluessel = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null;
|
|
$anzahl = $berechtigung['quantity'] ?? 0;
|
|
if ($schluessel === $kennung && is_int($anzahl) && $anzahl > 0) {
|
|
$menge += $anzahl;
|
|
}
|
|
}
|
|
return $menge;
|
|
}
|
|
|
|
private function LeseJsonListe(string $property): array
|
|
{
|
|
try {
|
|
$werte = json_decode(
|
|
$this->ReadPropertyString($property),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
return is_array($werte) ? array_values($werte) : [];
|
|
} catch (Throwable $fehler) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function LeseLizenzLease(): array
|
|
{
|
|
try {
|
|
$lease = json_decode(
|
|
$this->ReadAttributeString('LizenzLease'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
return is_array($lease) ? $lease : [];
|
|
} catch (Throwable $fehler) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function RufeLizenzserverAuf(string $lizenzcode, string $installationID): array
|
|
{
|
|
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.');
|
|
}
|
|
$nutzlast = json_encode([
|
|
'code' => $lizenzcode,
|
|
'installationId' => $installationID,
|
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $nutzlast,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
|
|
CURLOPT_USERAGENT => 'Enelix-Verbrauchskostenreport/0.1',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl));
|
|
}
|
|
if (strlen($antwort) > 65536) {
|
|
throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.');
|
|
}
|
|
$httpStatus = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($daten)) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
throw new RuntimeException(
|
|
'Lizenzserver HTTP ' . $httpStatus . ': '
|
|
. (is_string($daten['error'] ?? null) ? $daten['error'] : 'Unbekannter Fehler')
|
|
);
|
|
}
|
|
return $daten;
|
|
}
|
|
|
|
private function NormalisiereLizenzcode(string $code): string
|
|
{
|
|
return strtoupper((string)preg_replace('/[^A-Z0-9]/i', '', $code));
|
|
}
|
|
|
|
private function IstLizenzcodeGueltig(string $code): bool
|
|
{
|
|
return preg_match('/^ENX[A-HJ-NP-Z2-9]{16}$/', $code) === 1;
|
|
}
|
|
|
|
private function ErzeugeUuidV4(): string
|
|
{
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
$hex = bin2hex($bytes);
|
|
return sprintf(
|
|
'%s-%s-%s-%s-%s',
|
|
substr($hex, 0, 8),
|
|
substr($hex, 8, 4),
|
|
substr($hex, 12, 4),
|
|
substr($hex, 16, 4),
|
|
substr($hex, 20, 12)
|
|
);
|
|
}
|
|
|
|
private function ReadUsers()
|
|
{
|
|
$users = json_decode($this->ReadPropertyString('Users'), true);
|
|
return is_array($users) ? array_values($users) : [];
|
|
}
|
|
|
|
private function ReadPowerMeters()
|
|
{
|
|
$meters = json_decode($this->ReadPropertyString('PowerMeters'), true);
|
|
return is_array($meters) ? array_values($meters) : [];
|
|
}
|
|
|
|
private function ReadWaterMeters()
|
|
{
|
|
$meters = json_decode($this->ReadPropertyString('WaterMeters'), true);
|
|
return is_array($meters) ? array_values($meters) : [];
|
|
}
|
|
|
|
private function WriteUsersAndMeterAssignments(array $users, array $powerMeters, array $waterMeters)
|
|
{
|
|
IPS_SetProperty($this->InstanceID, 'Users', json_encode(array_values($users), JSON_UNESCAPED_UNICODE));
|
|
IPS_SetProperty($this->InstanceID, 'PowerMeters', json_encode(array_values($powerMeters), JSON_UNESCAPED_UNICODE));
|
|
IPS_SetProperty($this->InstanceID, 'WaterMeters', json_encode(array_values($waterMeters), JSON_UNESCAPED_UNICODE));
|
|
IPS_ApplyChanges($this->InstanceID);
|
|
$this->UpdateUserOverview();
|
|
}
|
|
|
|
private function UpdateUserOverview()
|
|
{
|
|
$overviewID = @IPS_GetObjectIDByIdent('VisuUserOverview', $this->InstanceID);
|
|
if ($overviewID === false) {
|
|
return;
|
|
}
|
|
|
|
$users = $this->ReadUsers();
|
|
$powerMeters = $this->ReadPowerMeters();
|
|
$waterMeters = $this->ReadWaterMeters();
|
|
$rows = '';
|
|
|
|
foreach ($users as $index => $user) {
|
|
$rows .= $this->BuildUserEditorRow((string)$index, is_array($user) ? $user : [], $powerMeters, $waterMeters);
|
|
}
|
|
|
|
if ($rows === '') {
|
|
$rows = $this->BuildUserEditorRow('0', [], $powerMeters, $waterMeters);
|
|
}
|
|
|
|
$webhookPath = $this->GetTariffWebhookPath();
|
|
$statusID = @IPS_GetObjectIDByIdent('VisuUserStatus', $this->InstanceID);
|
|
$status = $statusID !== false ? $this->h(@GetValue($statusID)) : '';
|
|
$template = $this->BuildUserEditorRow('__KEY__', [], $powerMeters, $waterMeters);
|
|
|
|
$html = "
|
|
<div class='belevo-users'>
|
|
<style>
|
|
.belevo-users{font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#202124;max-width:1220px;margin-top:18px}
|
|
.belevo-users table{width:100%;border-collapse:collapse;background:#fff}
|
|
.belevo-users th{background:#f3f4f6;border:1px solid #d6d9de;text-align:left;padding:7px;font-weight:700}
|
|
.belevo-users td{border:1px solid #e0e2e6;padding:5px;vertical-align:top}
|
|
.belevo-users input,.belevo-users select{width:100%;box-sizing:border-box;border:1px solid #c7cbd1;border-radius:3px;padding:5px;font:inherit;background:#fff}
|
|
.belevo-users option:disabled{color:#9aa0a6;background:#f1f3f4}
|
|
.belevo-users .meter-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
|
|
.belevo-users .meter-tag{display:inline-flex;align-items:center;gap:5px;border:1px solid #c7cbd1;background:#f8f9fa;border-radius:3px;padding:3px 6px;max-width:100%}
|
|
.belevo-users .meter-tag span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.belevo-users .meter-tag button{border:0;background:transparent;color:#9b1c1c;padding:0 2px;font-weight:700}
|
|
.belevo-users .actions{display:flex;gap:8px;margin-top:10px;align-items:center}
|
|
.belevo-users button{border:1px solid #9aa0a6;background:#f8f9fa;border-radius:3px;padding:6px 10px;font:inherit;cursor:pointer}
|
|
.belevo-users button.primary{background:#1f4e79;border-color:#1f4e79;color:#fff;font-weight:700}
|
|
.belevo-users button.danger{color:#9b1c1c;border-color:#d7a6a6;background:#fff5f5}
|
|
.belevo-users .status{margin-left:8px;color:#4b5563}
|
|
</style>
|
|
<form method='post' action='" . $this->h($webhookPath) . "' target='belevo_user_save_frame'>
|
|
<input type='hidden' name='editor' value='users'>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style='width:13%'>Name</th>
|
|
<th style='width:13%'>Strasse</th>
|
|
<th style='width:6%'>Nr.</th>
|
|
<th style='width:7%'>PLZ</th>
|
|
<th style='width:11%'>Ort</th>
|
|
<th style='width:6%'>Land</th>
|
|
<th style='width:17%'>Stromzaehler</th>
|
|
<th style='width:17%'>Nebenzaehler</th>
|
|
<th style='width:10%'>Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>" . $rows . "</tbody>
|
|
</table>
|
|
<div class='actions'>
|
|
<button type='button' data-action='add-user'>Zeile hinzufuegen</button>
|
|
<button type='submit' class='primary'>Benutzer speichern</button>
|
|
<span class='status'>" . $status . "</span>
|
|
</div>
|
|
</form>
|
|
<iframe name='belevo_user_save_frame' style='display:none;width:0;height:0;border:0'></iframe>
|
|
<template data-user-row-template>" . $template . "</template>
|
|
<script>
|
|
(function(){
|
|
var root=document.currentScript.closest('.belevo-users');
|
|
if(!root||root.dataset.bound==='1'){return;}
|
|
root.dataset.bound='1';
|
|
var tbody=root.querySelector('tbody');
|
|
var status=root.querySelector('.status');
|
|
var counter=0;
|
|
window.addEventListener('message',function(event){
|
|
if(event&&event.data&&event.data.type==='belevoUserSave'){
|
|
status.textContent=event.data.message||'Gespeichert.';
|
|
}
|
|
});
|
|
function bindDelete(row){
|
|
var btn=row.querySelector('[data-action=delete-user]');
|
|
if(btn){btn.onclick=function(){row.parentNode.removeChild(row);};}
|
|
}
|
|
function selectedByKind(){
|
|
var selected={power:{},water:{}};
|
|
Array.prototype.forEach.call(root.querySelectorAll('.meter-picker'),function(picker){
|
|
var kind=picker.getAttribute('data-kind');
|
|
Array.prototype.forEach.call(picker.querySelectorAll('.meter-tag'),function(tag){
|
|
var value=tag.getAttribute('data-value');
|
|
if(value!==''){selected[kind][value]=true;}
|
|
});
|
|
});
|
|
return selected;
|
|
}
|
|
function refreshPickers(){
|
|
var selected=selectedByKind();
|
|
Array.prototype.forEach.call(root.querySelectorAll('.meter-picker'),function(picker){
|
|
var kind=picker.getAttribute('data-kind');
|
|
var select=picker.querySelector('select[data-action=add-meter]');
|
|
Array.prototype.forEach.call(select.options,function(option){
|
|
if(option.value===''){return;}
|
|
var base=option.getAttribute('data-label')||option.textContent;
|
|
var assigned=!!selected[kind][option.value];
|
|
option.disabled=assigned;
|
|
option.textContent=assigned?base+' (vergeben)':base;
|
|
});
|
|
select.value='';
|
|
});
|
|
}
|
|
function addMeterTag(picker,value,label){
|
|
if(value===''){return;}
|
|
if(picker.querySelector('.meter-tag[data-value=\"'+value+'\"]')){return;}
|
|
var tag=document.createElement('span');
|
|
tag.className='meter-tag';
|
|
tag.setAttribute('data-value',value);
|
|
var input=document.createElement('input');
|
|
input.type='hidden';
|
|
input.name=picker.getAttribute('data-field-name');
|
|
input.value=value;
|
|
var text=document.createElement('span');
|
|
text.textContent=label;
|
|
var remove=document.createElement('button');
|
|
remove.type='button';
|
|
remove.setAttribute('data-action','remove-meter');
|
|
remove.textContent='x';
|
|
tag.appendChild(input);
|
|
tag.appendChild(text);
|
|
tag.appendChild(remove);
|
|
picker.querySelector('.meter-tags').appendChild(tag);
|
|
refreshPickers();
|
|
}
|
|
Array.prototype.forEach.call(tbody.querySelectorAll('tr'),bindDelete);
|
|
root.querySelector('[data-action=add-user]').onclick=function(){
|
|
var key='new_'+Date.now()+'_'+(counter++);
|
|
var tpl=root.querySelector('template[data-user-row-template]').innerHTML.replace(/__KEY__/g,key);
|
|
var temp=document.createElement('tbody');
|
|
temp.innerHTML=tpl;
|
|
var row=temp.querySelector('tr');
|
|
tbody.appendChild(row);
|
|
bindDelete(row);
|
|
refreshPickers();
|
|
};
|
|
root.addEventListener('change',function(event){
|
|
var select=event.target;
|
|
if(!select||select.getAttribute('data-action')!=='add-meter'){return;}
|
|
var option=select.options[select.selectedIndex];
|
|
if(!option||!option.value){return;}
|
|
addMeterTag(select.closest('.meter-picker'),option.value,option.getAttribute('data-label')||option.textContent);
|
|
});
|
|
root.addEventListener('click',function(event){
|
|
var btn=event.target;
|
|
if(!btn||btn.getAttribute('data-action')!=='remove-meter'){return;}
|
|
var tag=btn.closest('.meter-tag');
|
|
if(tag&&tag.parentNode){tag.parentNode.removeChild(tag);}
|
|
refreshPickers();
|
|
});
|
|
root.querySelector('form').onsubmit=function(){
|
|
status.textContent='Speichern gesendet.';
|
|
return true;
|
|
};
|
|
refreshPickers();
|
|
})();
|
|
</script>
|
|
</div>";
|
|
|
|
SetValue($overviewID, $html);
|
|
}
|
|
|
|
private function BuildUserEditorRow($rowKey, array $user, array $powerMeters, array $waterMeters)
|
|
{
|
|
$userId = trim((string)($user['id'] ?? ''));
|
|
$powerSelected = $this->GetMeterIndexesForUser($powerMeters, $userId);
|
|
$waterSelected = $this->GetMeterIndexesForUser($waterMeters, $userId);
|
|
|
|
return '<tr>'
|
|
. '<td><input type="hidden" name="user_id[]" value="' . $this->h($userId) . '"><input type="hidden" name="row_key[]" value="' . $this->h($rowKey) . '"><input name="user_name[]" value="' . $this->h($user['name'] ?? '') . '"></td>'
|
|
. '<td><input name="user_street[]" value="' . $this->h($user['street'] ?? '') . '"></td>'
|
|
. '<td><input name="user_building_number[]" value="' . $this->h($user['building_number'] ?? '') . '"></td>'
|
|
. '<td><input name="user_postal_code[]" value="' . $this->h($user['postal_code'] ?? '') . '"></td>'
|
|
. '<td><input name="user_town[]" value="' . $this->h($user['town'] ?? '') . '"></td>'
|
|
. '<td><input name="user_country[]" value="' . $this->h($user['country'] ?? 'CH') . '"></td>'
|
|
. '<td>' . $this->BuildMeterPicker('power', 'power_assign', $rowKey, $powerMeters, $powerSelected) . '</td>'
|
|
. '<td>' . $this->BuildMeterPicker('water', 'water_assign', $rowKey, $waterMeters, $waterSelected) . '</td>'
|
|
. '<td><button type="button" class="danger" data-action="delete-user">Loeschen</button></td>'
|
|
. '</tr>';
|
|
}
|
|
|
|
private function GetMeterIndexesForUser(array $meters, $userId)
|
|
{
|
|
if ($userId === '') {
|
|
return [];
|
|
}
|
|
|
|
$indexes = [];
|
|
foreach ($meters as $index => $meter) {
|
|
if ((string)($meter['user_id'] ?? '') === (string)$userId) {
|
|
$indexes[] = (string)$index;
|
|
}
|
|
}
|
|
return $indexes;
|
|
}
|
|
|
|
private function BuildMeterPicker($kind, $fieldName, $rowKey, array $meters, array $selectedIndexes)
|
|
{
|
|
$inputName = $fieldName . '[' . $rowKey . '][]';
|
|
$tags = '';
|
|
|
|
foreach ($selectedIndexes as $selectedIndex) {
|
|
if (!array_key_exists((int)$selectedIndex, $meters)) {
|
|
continue;
|
|
}
|
|
$label = $this->GetMeterLabel($meters[(int)$selectedIndex], (int)$selectedIndex);
|
|
$tags .= '<span class="meter-tag" data-value="' . $this->h($selectedIndex) . '">'
|
|
. '<input type="hidden" name="' . $this->h($inputName) . '" value="' . $this->h($selectedIndex) . '">'
|
|
. '<span>' . $this->h($label) . '</span>'
|
|
. '<button type="button" data-action="remove-meter">x</button>'
|
|
. '</span>';
|
|
}
|
|
|
|
return '<div class="meter-picker" data-kind="' . $this->h($kind) . '" data-field-name="' . $this->h($inputName) . '">'
|
|
. '<select data-action="add-meter">'
|
|
. '<option value="">Zaehler waehlen...</option>'
|
|
. $this->BuildMeterOptions($meters)
|
|
. '</select>'
|
|
. '<div class="meter-tags">' . $tags . '</div>'
|
|
. '</div>';
|
|
}
|
|
|
|
private function BuildMeterOptions(array $meters)
|
|
{
|
|
if (empty($meters)) {
|
|
return '<option value="" disabled>Keine Zaehler</option>';
|
|
}
|
|
|
|
$options = '';
|
|
foreach ($meters as $index => $meter) {
|
|
$key = (string)$index;
|
|
$label = $this->GetMeterLabel($meter, $index);
|
|
$assigned = trim((string)($meter['user_id'] ?? '')) !== '';
|
|
$options .= '<option value="' . $this->h($key) . '" data-label="' . $this->h($label) . '"' . ($assigned ? ' disabled' : '') . '>' . $this->h($assigned ? $label . ' (vergeben)' : $label) . '</option>';
|
|
}
|
|
return $options;
|
|
}
|
|
|
|
private function GetMeterLabel(array $meter, $index)
|
|
{
|
|
$id = trim((string)($meter['id'] ?? ''));
|
|
$name = trim((string)($meter['name'] ?? ''));
|
|
$type = trim((string)($meter['meter_type'] ?? ''));
|
|
|
|
if ($name === '' && $id === '') {
|
|
$label = 'Zaehler ' . ((int)$index + 1);
|
|
} elseif ($name === '') {
|
|
$label = $id;
|
|
} else {
|
|
$label = $name;
|
|
if ($id !== '' && $id !== $name) {
|
|
$label .= ' [' . $id . ']';
|
|
}
|
|
}
|
|
|
|
if ($type !== '') {
|
|
$label .= ' - ' . $type;
|
|
}
|
|
return $label;
|
|
}
|
|
|
|
private function SaveUsersTableFromPost(array $post)
|
|
{
|
|
$ids = $post['user_id'] ?? [];
|
|
$names = $post['user_name'] ?? [];
|
|
$streets = $post['user_street'] ?? [];
|
|
$buildingNumbers = $post['user_building_number'] ?? [];
|
|
$postalCodes = $post['user_postal_code'] ?? [];
|
|
$towns = $post['user_town'] ?? [];
|
|
$countries = $post['user_country'] ?? [];
|
|
$rowKeys = $post['row_key'] ?? [];
|
|
$powerAssign = $post['power_assign'] ?? [];
|
|
$waterAssign = $post['water_assign'] ?? [];
|
|
|
|
if (!is_array($ids) || !is_array($names) || !is_array($streets)
|
|
|| !is_array($buildingNumbers) || !is_array($postalCodes)
|
|
|| !is_array($towns) || !is_array($countries) || !is_array($rowKeys)) {
|
|
throw new Exception('Ungueltige Benutzerwerte.');
|
|
}
|
|
if (!is_array($powerAssign)) {
|
|
$powerAssign = [];
|
|
}
|
|
if (!is_array($waterAssign)) {
|
|
$waterAssign = [];
|
|
}
|
|
|
|
$existingUsers = [];
|
|
foreach ($this->ReadUsers() as $existingUser) {
|
|
if (!is_array($existingUser)) {
|
|
continue;
|
|
}
|
|
$existingId = (string)($existingUser['id'] ?? '');
|
|
if ($existingId !== '') {
|
|
$existingUsers[$existingId] = $existingUser;
|
|
}
|
|
}
|
|
|
|
$powerMeters = $this->ReadPowerMeters();
|
|
$waterMeters = $this->ReadWaterMeters();
|
|
foreach ($powerMeters as &$powerMeter) {
|
|
unset($powerMeter['user_id']);
|
|
}
|
|
unset($powerMeter);
|
|
foreach ($waterMeters as &$waterMeter) {
|
|
unset($waterMeter['user_id']);
|
|
}
|
|
unset($waterMeter);
|
|
|
|
$users = [];
|
|
$seenUsers = [];
|
|
foreach (array_keys($existingUsers) as $existingId) {
|
|
$seenUsers[(string)$existingId] = false;
|
|
}
|
|
$assignedPower = [];
|
|
$assignedWater = [];
|
|
$count = max(
|
|
count($ids),
|
|
count($names),
|
|
count($streets),
|
|
count($buildingNumbers),
|
|
count($postalCodes),
|
|
count($towns),
|
|
count($countries),
|
|
count($rowKeys)
|
|
);
|
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$rowKey = (string)($rowKeys[$i] ?? $i);
|
|
$id = trim((string)($ids[$i] ?? ''));
|
|
$name = trim((string)($names[$i] ?? ''));
|
|
$street = trim((string)($streets[$i] ?? ''));
|
|
$buildingNumber = trim((string)($buildingNumbers[$i] ?? ''));
|
|
$postalCode = trim((string)($postalCodes[$i] ?? ''));
|
|
$town = trim((string)($towns[$i] ?? ''));
|
|
$country = strtoupper(trim((string)($countries[$i] ?? 'CH')));
|
|
$selectedPower = $this->NormalizeMeterSelection($powerAssign[$rowKey] ?? []);
|
|
$selectedWater = $this->NormalizeMeterSelection($waterAssign[$rowKey] ?? []);
|
|
|
|
if ($id === '' && $name === '' && $street === '' && $buildingNumber === ''
|
|
&& $postalCode === '' && $town === '' && empty($selectedPower) && empty($selectedWater)) {
|
|
continue;
|
|
}
|
|
if ($id === '') {
|
|
$id = $this->GenerateUniqueUserId($seenUsers);
|
|
}
|
|
if (isset($seenUsers[$id]) && $seenUsers[$id] === true) {
|
|
throw new Exception('Benutzer-ID doppelt: ' . $id);
|
|
}
|
|
$seenUsers[$id] = true;
|
|
|
|
$user = $existingUsers[$id] ?? [];
|
|
$user['id'] = $id;
|
|
$user['name'] = $name;
|
|
$user['street'] = $street;
|
|
$user['building_number'] = $buildingNumber;
|
|
$user['postal_code'] = $postalCode;
|
|
$user['town'] = $town;
|
|
$user['country'] = $country;
|
|
unset($user['address'], $user['city']);
|
|
$users[] = $user;
|
|
|
|
foreach ($selectedPower as $meterIndex) {
|
|
if (!array_key_exists($meterIndex, $powerMeters)) {
|
|
throw new Exception('Stromzaehler nicht gefunden.');
|
|
}
|
|
if (isset($assignedPower[$meterIndex])) {
|
|
throw new Exception('Stromzaehler mehrfach zugewiesen: ' . $this->GetMeterLabel($powerMeters[$meterIndex], $meterIndex));
|
|
}
|
|
$powerMeters[$meterIndex]['user_id'] = $id;
|
|
$assignedPower[$meterIndex] = true;
|
|
}
|
|
|
|
foreach ($selectedWater as $meterIndex) {
|
|
if (!array_key_exists($meterIndex, $waterMeters)) {
|
|
throw new Exception('Nebenzaehler nicht gefunden.');
|
|
}
|
|
if (isset($assignedWater[$meterIndex])) {
|
|
throw new Exception('Nebenzaehler mehrfach zugewiesen: ' . $this->GetMeterLabel($waterMeters[$meterIndex], $meterIndex));
|
|
}
|
|
$waterMeters[$meterIndex]['user_id'] = $id;
|
|
$assignedWater[$meterIndex] = true;
|
|
}
|
|
}
|
|
|
|
$this->WriteUsersAndMeterAssignments($users, $powerMeters, $waterMeters);
|
|
return count($users) . ' Benutzer gespeichert.';
|
|
}
|
|
|
|
private function GenerateUniqueUserId(array $usedIds)
|
|
{
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$id = (string)random_int(100000, 999999999);
|
|
if (!isset($usedIds[$id])) {
|
|
return $id;
|
|
}
|
|
}
|
|
|
|
do {
|
|
$id = (string)time() . (string)random_int(1000, 9999);
|
|
} while (isset($usedIds[$id]));
|
|
|
|
return $id;
|
|
}
|
|
|
|
private function NormalizeMeterSelection($selection)
|
|
{
|
|
if (!is_array($selection)) {
|
|
$selection = [$selection];
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($selection as $value) {
|
|
$value = trim((string)$value);
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
if (!ctype_digit($value)) {
|
|
throw new Exception('Ungueltige Zaehlerauswahl.');
|
|
}
|
|
$result[] = (int)$value;
|
|
}
|
|
return array_values(array_unique($result));
|
|
}
|
|
|
|
private function ValidateReportConfiguration(array $users, array $powerMeters, array $waterMeters, array $tariffs, $from, $to)
|
|
{
|
|
$errors = [];
|
|
$from = (int)$from;
|
|
$to = (int)$to;
|
|
|
|
if ($from <= 0 || $to <= 0 || $to <= $from) {
|
|
$errors[] = 'Berichtszeitraum ist ungueltig.';
|
|
}
|
|
|
|
$userIds = [];
|
|
foreach ($users as $index => $user) {
|
|
if (!is_array($user)) {
|
|
continue;
|
|
}
|
|
$id = trim((string)($user['id'] ?? ''));
|
|
if ($id === '') {
|
|
$errors[] = 'Benutzer ohne interne ID gefunden.';
|
|
continue;
|
|
}
|
|
if (isset($userIds[$id])) {
|
|
$errors[] = 'Benutzer-ID doppelt: ' . $id;
|
|
}
|
|
$userIds[$id] = true;
|
|
}
|
|
|
|
if (empty($userIds)) {
|
|
$errors[] = 'Keine Benutzer konfiguriert.';
|
|
}
|
|
|
|
$hasAnyMeter = false;
|
|
$hasPowerConsumption = false;
|
|
$hasPowerFeed = false;
|
|
foreach ($powerMeters as $index => $meter) {
|
|
if (!is_array($meter)) {
|
|
continue;
|
|
}
|
|
$hasAnyMeter = true;
|
|
$userId = trim((string)($meter['user_id'] ?? ''));
|
|
if ($userId === '' || !isset($userIds[$userId])) {
|
|
$errors[] = 'Stromzaehler nicht zugewiesen: ' . $this->GetMeterLabel($meter, $index);
|
|
}
|
|
if (!empty($meter['var_consumption'])) {
|
|
$hasPowerConsumption = true;
|
|
}
|
|
if (!empty($meter['var_feed'])) {
|
|
$hasPowerFeed = true;
|
|
}
|
|
}
|
|
|
|
$requiredAdditionalTariffs = [];
|
|
foreach ($waterMeters as $index => $meter) {
|
|
if (!is_array($meter)) {
|
|
continue;
|
|
}
|
|
$hasAnyMeter = true;
|
|
$userId = trim((string)($meter['user_id'] ?? ''));
|
|
if ($userId === '' || !isset($userIds[$userId])) {
|
|
$errors[] = 'Nebenzaehler nicht zugewiesen: ' . $this->GetMeterLabel($meter, $index);
|
|
}
|
|
$type = trim((string)($meter['meter_type'] ?? 'Warmwasser'));
|
|
if ($type === '') {
|
|
$type = 'Warmwasser';
|
|
}
|
|
$requiredAdditionalTariffs[$type] = true;
|
|
}
|
|
|
|
if (!$hasAnyMeter) {
|
|
$errors[] = 'Keine Zaehler konfiguriert.';
|
|
}
|
|
|
|
if ($from > 0 && $to > $from) {
|
|
if ($hasPowerConsumption || $hasPowerFeed) {
|
|
$this->RequireTariffCoverage($tariffs, 'Netztarif', $from, $to, $errors);
|
|
}
|
|
if ($hasPowerFeed) {
|
|
$this->RequireTariffCoverage($tariffs, 'Solartarif', $from, $to, $errors);
|
|
$this->RequireTariffCoverage($tariffs, 'Einspeisetarif', $from, $to, $errors);
|
|
}
|
|
foreach (array_keys($requiredAdditionalTariffs) as $type) {
|
|
$this->RequireTariffCoverage($tariffs, $type, $from, $to, $errors);
|
|
}
|
|
}
|
|
|
|
if (trim($this->ReadPropertyString('BankIBAN')) !== '') {
|
|
$this->ValidateQRConfiguration($users, $errors);
|
|
}
|
|
|
|
if (!empty($errors)) {
|
|
throw new Exception(implode(' ', array_values(array_unique($errors))));
|
|
}
|
|
}
|
|
|
|
private function ValidateQRConfiguration(array $users, array &$errors)
|
|
{
|
|
$iban = strtoupper(str_replace(' ', '', $this->ReadPropertyString('BankIBAN')));
|
|
if (!preg_match('/^(CH|LI)[0-9]{19}$/', $iban) || !$this->IsValidIBAN($iban)) {
|
|
$errors[] = 'Die IBAN für den QR-Zahlteil ist ungültig.';
|
|
} elseif ($this->IsQRIBAN($iban)) {
|
|
$errors[] = 'Eine QR-IBAN erfordert eine QR-Referenz; für den Report ist eine normale CH/LI-IBAN zu verwenden.';
|
|
}
|
|
|
|
$this->ValidateStructuredQRAddress([
|
|
'name' => $this->ReadPropertyString('CreditorName'),
|
|
'street' => $this->ReadPropertyString('CreditorStreet'),
|
|
'building_number' => $this->ReadPropertyString('CreditorBuildingNumber'),
|
|
'postal_code' => $this->ReadPropertyString('CreditorPostalCode'),
|
|
'town' => $this->ReadPropertyString('CreditorTown'),
|
|
'country' => $this->ReadPropertyString('CreditorCountry')
|
|
], 'Zahlungsempfänger', $errors);
|
|
|
|
foreach ($users as $index => $user) {
|
|
if (!is_array($user)) {
|
|
continue;
|
|
}
|
|
$label = 'Benutzer ' . trim((string)($user['id'] ?? ($index + 1)));
|
|
$this->ValidateStructuredQRAddress($user, $label, $errors);
|
|
}
|
|
}
|
|
|
|
private function ValidateStructuredQRAddress(array $address, $label, array &$errors)
|
|
{
|
|
$name = trim((string)($address['name'] ?? ''));
|
|
$postalCode = trim((string)($address['postal_code'] ?? ''));
|
|
$town = trim((string)($address['town'] ?? ''));
|
|
$country = strtoupper(trim((string)($address['country'] ?? '')));
|
|
|
|
if ($name === '') {
|
|
$errors[] = $label . ': Name fehlt für den QR-Zahlteil.';
|
|
}
|
|
if ($postalCode === '') {
|
|
$errors[] = $label . ': PLZ fehlt für den QR-Zahlteil.';
|
|
}
|
|
if ($town === '') {
|
|
$errors[] = $label . ': Ort fehlt für den QR-Zahlteil.';
|
|
}
|
|
if (!preg_match('/^[A-Z]{2}$/', $country)) {
|
|
$errors[] = $label . ': Land muss als ISO-2-Code angegeben werden.';
|
|
}
|
|
|
|
$limits = [
|
|
'name' => 70,
|
|
'street' => 70,
|
|
'building_number' => 16,
|
|
'postal_code' => 16,
|
|
'town' => 35
|
|
];
|
|
foreach ($limits as $field => $limit) {
|
|
if (mb_strlen(trim((string)($address[$field] ?? ''))) > $limit) {
|
|
$errors[] = $label . ': Feld ' . $field . ' überschreitet ' . $limit . ' Zeichen.';
|
|
}
|
|
}
|
|
}
|
|
|
|
private function IsValidIBAN($iban)
|
|
{
|
|
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
|
|
$remainder = 0;
|
|
for ($i = 0, $length = strlen($rearranged); $i < $length; $i++) {
|
|
$character = $rearranged[$i];
|
|
$numeric = ctype_alpha($character) ? (string)(ord($character) - 55) : $character;
|
|
for ($j = 0, $numericLength = strlen($numeric); $j < $numericLength; $j++) {
|
|
$remainder = (($remainder * 10) + (int)$numeric[$j]) % 97;
|
|
}
|
|
}
|
|
return $remainder === 1;
|
|
}
|
|
|
|
private function IsQRIBAN($iban)
|
|
{
|
|
$iid = (int)substr($iban, 4, 5);
|
|
return $iid >= 30000 && $iid <= 31999;
|
|
}
|
|
|
|
private function RequireTariffCoverage(array $tariffs, $type, $from, $to, array &$errors)
|
|
{
|
|
$gap = $this->GetTariffCoverageGap($tariffs, $type, (int)$from, (int)$to);
|
|
if ($gap === null) {
|
|
return;
|
|
}
|
|
|
|
$errors[] = 'Tarif fehlt durchgehend: ' . $type . ' (' . date('d.m.Y H:i', $gap[0]) . ' - ' . date('d.m.Y H:i', $gap[1]) . ').';
|
|
}
|
|
|
|
private function GetTariffCoverageGap(array $tariffs, $type, $from, $to)
|
|
{
|
|
$periodStart = (int)$from;
|
|
$periodEnd = (int)$to;
|
|
if ($periodEnd > $periodStart) {
|
|
$periodEnd--;
|
|
}
|
|
|
|
if ($periodStart <= 0 || $periodEnd < $periodStart) {
|
|
return [$periodStart, $periodEnd];
|
|
}
|
|
|
|
$intervals = [];
|
|
foreach ($tariffs as $tariff) {
|
|
if (!is_array($tariff)) {
|
|
continue;
|
|
}
|
|
if (strtolower(trim((string)($tariff['unit_type'] ?? ''))) !== strtolower(trim((string)$type))) {
|
|
continue;
|
|
}
|
|
|
|
$start = $this->toUnixTs($tariff['start'] ?? 0, false);
|
|
$end = $this->toUnixTs($tariff['end'] ?? 0, true);
|
|
if ($start === null || $end === null || $end < $periodStart || $start > $periodEnd) {
|
|
continue;
|
|
}
|
|
|
|
$intervals[] = [
|
|
max($periodStart, (int)$start),
|
|
min($periodEnd, (int)$end)
|
|
];
|
|
}
|
|
|
|
usort($intervals, function ($a, $b) {
|
|
return $a[0] <=> $b[0];
|
|
});
|
|
|
|
$cursor = $periodStart;
|
|
foreach ($intervals as $interval) {
|
|
if ($interval[0] > $cursor) {
|
|
return [$cursor, min($periodEnd, $interval[0] - 1)];
|
|
}
|
|
if ($interval[1] >= $cursor) {
|
|
$cursor = $interval[1] + 1;
|
|
}
|
|
if ($cursor > $periodEnd) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return [$cursor, $periodEnd];
|
|
}
|
|
|
|
private function FormatDateInput($timestamp)
|
|
{
|
|
return $timestamp ? date('Y-m-d', (int)$timestamp) : '';
|
|
}
|
|
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 'FormLizenzPruefen':
|
|
if (!is_string($Value)) {
|
|
throw new InvalidArgumentException('Der Lizenzcode muss als Text übergeben werden.');
|
|
}
|
|
$this->AktualisiereLizenz($Value, true);
|
|
$this->AktualisiereLizenzForm($Value);
|
|
break;
|
|
|
|
case 'FromDate':
|
|
case 'ToDate':
|
|
case 'VisuTariffType':
|
|
case 'VisuTariffStart':
|
|
case 'VisuTariffEnd':
|
|
case 'VisuTariffPrice':
|
|
SetValue($this->GetIDForIdent($Ident), $Value);
|
|
break;
|
|
|
|
case 'VisuTariffPayload':
|
|
SetValue($this->GetIDForIdent('VisuTariffPayload'), (string)$Value);
|
|
try {
|
|
$message = $this->SaveTariffTableFromPayload($Value);
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
} catch (Throwable $e) {
|
|
$message = 'Fehler beim Speichern der Tarifliste: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
}
|
|
break;
|
|
|
|
case 'SaveVisuTariff':
|
|
try {
|
|
$message = $this->SaveVisuTariffFromVariables();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
} catch (Throwable $e) {
|
|
$message = 'Fehler beim Speichern des Tarifs: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
}
|
|
break;
|
|
|
|
case 'DeleteVisuTariff':
|
|
try {
|
|
$message = $this->DeleteVisuTariffFromVariables();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
} catch (Throwable $e) {
|
|
$message = 'Fehler beim Löschen des Tarifs: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
}
|
|
break;
|
|
|
|
case 'SaveTariffTable':
|
|
try {
|
|
$message = $this->SaveTariffTableFromPayload($Value);
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
} catch (Throwable $e) {
|
|
$message = 'Fehler beim Speichern der Tarifliste: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
}
|
|
break;
|
|
|
|
case 'CreateReport':
|
|
try {
|
|
$pdfContent = $this->GenerateReport();
|
|
if (!$pdfContent) {
|
|
SetValue($this->GetIDForIdent('LastResult'), 'Report konnte nicht erstellt werden');
|
|
return;
|
|
}
|
|
|
|
$mediaID = $this->GetIDForIdent('ReportPDF');
|
|
IPS_SetMediaContent($mediaID, base64_encode($pdfContent));
|
|
SetValue($this->GetIDForIdent('LastResult'), 'Report erfolgreich erstellt');
|
|
} catch (Throwable $e) {
|
|
$message = 'Report konnte nicht erstellt werden: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('LastResult'), $message);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
public function ProcessHookData()
|
|
{
|
|
$message = '';
|
|
$postMessageType = 'belevoTariffSave';
|
|
try {
|
|
$editor = (string)($_POST['editor'] ?? 'tariffs');
|
|
if ($editor === 'users') {
|
|
$postMessageType = 'belevoUserSave';
|
|
$message = $this->SaveUsersTableFromPost($_POST);
|
|
SetValue($this->GetIDForIdent('VisuUserStatus'), $message);
|
|
$this->UpdateUserOverview();
|
|
} else {
|
|
$payload = $_POST['payload'] ?? '';
|
|
if (trim((string)$payload) !== '') {
|
|
$message = $this->SaveTariffTableFromPayload($payload);
|
|
} else {
|
|
$message = $this->SaveTariffTableFromPost($_POST);
|
|
}
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
$this->UpdateTariffOverview();
|
|
}
|
|
} catch (Throwable $e) {
|
|
if ($postMessageType === 'belevoUserSave') {
|
|
$message = 'Fehler beim Speichern der Benutzerliste: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('VisuUserStatus'), $message);
|
|
$this->UpdateUserOverview();
|
|
} else {
|
|
$message = 'Fehler beim Speichern der Tarifliste: ' . $e->getMessage();
|
|
SetValue($this->GetIDForIdent('VisuTariffStatus'), $message);
|
|
$this->UpdateTariffOverview();
|
|
}
|
|
}
|
|
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
echo '<!doctype html><html><body><script>try{parent.postMessage({type:' . json_encode($postMessageType) . ',message:' . json_encode($message) . '}, "*");}catch(e){}</script>' . $this->h($message) . '</body></html>';
|
|
}
|
|
|
|
// ====================== PDF-Erstellung ======================
|
|
|
|
public function GenerateReport()
|
|
{
|
|
if (!$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false)) {
|
|
throw new RuntimeException($this->LizenzStatustext());
|
|
}
|
|
|
|
$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 : [];
|
|
|
|
$this->ValidateReportConfiguration($users, $power, $water, $tariffs, (int)$from, (int)$to);
|
|
|
|
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 VerbrauchskostenreportPDF('P', 'mm', 'A4', true, 'UTF-8', false);
|
|
$pdf->logoFile = $logoFile;
|
|
$pdf->footerText = $footerText;
|
|
|
|
$pdf->SetCreator('IP-Symcon Verbrauchskostenreport');
|
|
// 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 Reportseite EINSCHALTEN
|
|
|
|
$grandTotal = $this->BuildUserReport($pdf, $user, $power, $water, $tariffs, $from, $to);
|
|
|
|
$this->BuildQRPaymentSection($pdf, $user, $grandTotal);
|
|
}
|
|
|
|
return $pdf->Output('Verbrauchskostenreport.pdf', 'S');
|
|
}
|
|
|
|
private function BuildUserReport($pdf, $user, $power, $water, $tariffs, $from, $to)
|
|
{
|
|
$pdf->SetTextColor(20, 20, 20);
|
|
$pdf->SetDrawColor(190, 190, 190);
|
|
$pdf->SetLineWidth(0.15);
|
|
$pdf->SetFont('dejavusans', 'B', 16);
|
|
$pdf->SetY(max($pdf->GetY(), 38));
|
|
$pdf->Cell(0, 8, 'Energie- und Nebenkostenreport', 0, 1, 'L');
|
|
|
|
$pdf->SetFont('dejavusans', '', 8.5);
|
|
$pdf->Cell(0, 5, 'Liegenschaft: ' . $this->ReadPropertyString('PropertyText'), 0, 1, 'L');
|
|
$pdf->Ln(3);
|
|
|
|
$meterNames = [];
|
|
foreach ($power as $m) {
|
|
if (($m['user_id'] ?? null) == ($user['id'] ?? null)) {
|
|
$meterNames[] = (string)($m['name'] ?? '');
|
|
}
|
|
}
|
|
foreach ($water as $m) {
|
|
if (($m['user_id'] ?? null) == ($user['id'] ?? null)) {
|
|
$meterNames[] = (string)($m['name'] ?? '');
|
|
}
|
|
}
|
|
|
|
$periodText = date('d.m.Y', $from) . ' - ' . date('d.m.Y', $to);
|
|
$meterText = empty($meterNames) ? '-' : implode("\n", $meterNames);
|
|
$addressText = implode("\n", array_filter([
|
|
trim((string)($user['name'] ?? '')),
|
|
trim((string)($user['street'] ?? '') . ' ' . (string)($user['building_number'] ?? '')),
|
|
trim((string)($user['postal_code'] ?? '') . ' ' . (string)($user['town'] ?? '')),
|
|
strtoupper(trim((string)($user['country'] ?? 'CH')))
|
|
], function ($value) {
|
|
return $value !== '';
|
|
}));
|
|
$meterLineCount = max(1, count(preg_split('/\r\n|\r|\n/', $meterText)));
|
|
$addressLineCount = max(1, count(preg_split('/\r\n|\r|\n/', $addressText)));
|
|
$meterBlockHeight = 22 + ($meterLineCount * 4.6);
|
|
$addressBlockHeight = 10 + ($addressLineCount * 4.6);
|
|
$infoBoxHeight = max(
|
|
32,
|
|
$meterBlockHeight,
|
|
$addressBlockHeight,
|
|
22 + $pdf->getStringHeight(82, $meterText),
|
|
10 + $pdf->getStringHeight(82, $addressText)
|
|
);
|
|
|
|
$y = $pdf->GetY();
|
|
$pdf->SetFillColor(248, 249, 250);
|
|
$pdf->Rect(15, $y, 88, $infoBoxHeight, 'DF');
|
|
$pdf->Rect(107, $y, 88, $infoBoxHeight, 'D');
|
|
$pdf->SetFont('dejavusans', 'B', 8);
|
|
$pdf->SetXY(18, $y + 3);
|
|
$pdf->MultiCell(82, 4, "Berichtszeitraum", 0, 'L');
|
|
$pdf->SetFont('dejavusans', '', 8);
|
|
$pdf->SetX(18);
|
|
$pdf->MultiCell(82, 4, $periodText, 0, 'L');
|
|
$pdf->SetFont('dejavusans', 'B', 8);
|
|
$pdf->SetX(18);
|
|
$pdf->MultiCell(82, 4, "Zählpunkte", 0, 'L');
|
|
$pdf->SetFont('dejavusans', '', 8);
|
|
$pdf->SetX(18);
|
|
$pdf->MultiCell(82, 4, $meterText, 0, 'L');
|
|
|
|
$pdf->SetFont('dejavusans', 'B', 8);
|
|
$pdf->SetXY(110, $y + 3);
|
|
$pdf->MultiCell(82, 4, "Kontaktadresse", 0, 'L');
|
|
$pdf->SetFont('dejavusans', '', 8);
|
|
$pdf->SetX(110);
|
|
$pdf->MultiCell(82, 4, $addressText, 0, 'L');
|
|
$pdf->SetY($y + $infoBoxHeight + 6);
|
|
|
|
$this->PdfSectionTitle($pdf, 'Elektrizität');
|
|
$powerResult = $this->GetCalculatedPowerCosts($user['id']);
|
|
$this->WritePowerPdfTables($pdf, $powerResult);
|
|
$totalPower = $powerResult['sum'];
|
|
|
|
$appliedTariffs = $this->CollectTariffsForUser($tariffs, ['Netztarif', 'Solartarif', 'Einspeisetarif']);
|
|
$this->WriteTariffPdfTable($pdf, 'Angewendete Elektrizitätstarife', $appliedTariffs, 'Rp/kWh');
|
|
|
|
$this->PdfSectionTitle($pdf, 'Nebenkosten');
|
|
$additionalResult = $this->CalculateAdditionalCosts($water, $tariffs, $user['id'], $from, $to);
|
|
$this->WriteAdditionalPdfTable($pdf, $additionalResult);
|
|
$totalAdditional = $additionalResult['sum'];
|
|
$this->WriteTariffPdfTable($pdf, 'Angewendete Nebenkostentarife', $additionalResult['tariffs'], 'Rp/Einheit');
|
|
|
|
$subTotal = $totalPower + $totalAdditional;
|
|
$enableVAT = $this->ReadPropertyBoolean('EnableVAT');
|
|
$summaryRows = [
|
|
['Zwischentotal Elektrizität', $this->FormatCurrency($totalPower)],
|
|
['Zwischentotal Nebenkosten', $this->FormatCurrency($totalAdditional)]
|
|
];
|
|
|
|
if ($enableVAT) {
|
|
$vatPercent = $this->ReadPropertyFloat('VATPercentage');
|
|
$vatAmount = $subTotal * ($vatPercent / 100);
|
|
$grandTotal = $subTotal + $vatAmount;
|
|
$summaryRows[] = ['Total exkl. MWST', $this->FormatCurrency($subTotal)];
|
|
$summaryRows[] = ['MWST ' . number_format($vatPercent, 1, '.', "'") . ' %', $this->FormatCurrency($vatAmount)];
|
|
$summaryRows[] = ['Gesamttotal inkl. MWST', $this->FormatCurrency($grandTotal)];
|
|
} else {
|
|
$grandTotal = $subTotal;
|
|
$summaryRows[] = ['Gesamttotal', $this->FormatCurrency($grandTotal)];
|
|
}
|
|
|
|
$this->WriteSummaryPdfTable($pdf, $summaryRows);
|
|
|
|
if (!$enableVAT) {
|
|
$pdf->SetFont('dejavusans', '', 7.5);
|
|
$pdf->SetTextColor(80, 80, 80);
|
|
$pdf->Cell(0, 5, 'Nicht mehrwertsteuerpflichtig.', 0, 1, 'L');
|
|
$pdf->SetTextColor(20, 20, 20);
|
|
}
|
|
|
|
return $grandTotal;
|
|
}
|
|
|
|
private function PdfSectionTitle($pdf, $title)
|
|
{
|
|
$this->PdfEnsureSpace($pdf, 12);
|
|
$pdf->Ln(2);
|
|
$pdf->SetFont('dejavusans', 'B', 11);
|
|
$pdf->SetFillColor(235, 238, 242);
|
|
$pdf->Cell(0, 7, $title, 0, 1, 'L', true);
|
|
$pdf->Ln(1);
|
|
}
|
|
|
|
private function PdfEnsureSpace($pdf, $height)
|
|
{
|
|
if ($pdf->GetY() + $height > 276) {
|
|
$pdf->AddPage();
|
|
$pdf->SetY(38);
|
|
}
|
|
}
|
|
|
|
private function PdfTableHeader($pdf, array $columns)
|
|
{
|
|
$this->PdfEnsureSpace($pdf, 8);
|
|
$pdf->SetFont('dejavusans', 'B', 7.2);
|
|
$pdf->SetFillColor(231, 234, 238);
|
|
$values = array_map(function ($column) {
|
|
return $column['label'];
|
|
}, $columns);
|
|
$this->PdfTableRow($pdf, $columns, $values, true, true);
|
|
}
|
|
|
|
private function PdfTableRow($pdf, array $columns, array $values, $bold = false, $fill = false)
|
|
{
|
|
$pdf->SetFont('dejavusans', $bold ? 'B' : '', 7.2);
|
|
$lineHeights = [];
|
|
foreach ($columns as $index => $column) {
|
|
$lineHeights[] = $pdf->getStringHeight($column['width'] - 2, (string)($values[$index] ?? '')) + 2;
|
|
}
|
|
$height = max(5.5, max($lineHeights));
|
|
$this->PdfEnsureSpace($pdf, $height + 1);
|
|
|
|
$x = 15;
|
|
$y = $pdf->GetY();
|
|
$pdf->SetFillColor($fill ? 245 : 255, $fill ? 246 : 255, $fill ? 248 : 255);
|
|
|
|
foreach ($columns as $index => $column) {
|
|
$align = $column['align'] ?? 'L';
|
|
$pdf->MultiCell(
|
|
$column['width'],
|
|
$height,
|
|
(string)($values[$index] ?? ''),
|
|
1,
|
|
$align,
|
|
$fill,
|
|
0,
|
|
$x,
|
|
$y,
|
|
true,
|
|
0,
|
|
false,
|
|
true,
|
|
$height,
|
|
'M'
|
|
);
|
|
$x += $column['width'];
|
|
}
|
|
$pdf->SetY($y + $height);
|
|
}
|
|
|
|
private function WritePowerPdfTables($pdf, array $result)
|
|
{
|
|
$rows = $result['rows'];
|
|
if (empty($rows)) {
|
|
$this->PdfTableRow($pdf, [['width' => 180, 'align' => 'C']], ['Keine Stromzähler für diesen Benutzer.']);
|
|
return;
|
|
}
|
|
|
|
$energyColumns = [
|
|
['width' => 48, 'label' => 'Zählpunkt'],
|
|
['width' => 30, 'label' => 'Import kWh', 'align' => 'R'],
|
|
['width' => 30, 'label' => 'Export kWh', 'align' => 'R'],
|
|
['width' => 36, 'label' => 'Solarbezug kWh', 'align' => 'R'],
|
|
['width' => 36, 'label' => 'Netzbezug kWh', 'align' => 'R']
|
|
];
|
|
$this->PdfTableHeader($pdf, $energyColumns);
|
|
foreach ($rows as $row) {
|
|
$this->PdfTableRow($pdf, $energyColumns, [
|
|
$row['name'],
|
|
$this->FormatNumber($row['imp'], 3),
|
|
$this->FormatNumber($row['exp'], 3),
|
|
$this->FormatNumber($row['solar_bezug'], 3),
|
|
$this->FormatNumber($row['netz_bezug'], 3)
|
|
]);
|
|
}
|
|
$this->PdfTableRow($pdf, $energyColumns, [
|
|
'Total',
|
|
$this->FormatNumber($result['totals']['imp'], 3),
|
|
$this->FormatNumber($result['totals']['exp'], 3),
|
|
$this->FormatNumber($result['totals']['solar_bezug'], 3),
|
|
$this->FormatNumber($result['totals']['netz_bezug'], 3)
|
|
], true, true);
|
|
|
|
$pdf->Ln(2);
|
|
$exportColumns = [
|
|
['width' => 60, 'label' => 'Zählpunkt'],
|
|
['width' => 60, 'label' => 'Einspeisung Netz kWh', 'align' => 'R'],
|
|
['width' => 60, 'label' => 'ZEV-Verkauf kWh', 'align' => 'R']
|
|
];
|
|
$this->PdfTableHeader($pdf, $exportColumns);
|
|
foreach ($rows as $row) {
|
|
$this->PdfTableRow($pdf, $exportColumns, [
|
|
$row['name'],
|
|
$this->FormatNumber($row['solareinspeisung'], 3),
|
|
$this->FormatNumber($row['solarverkauf'], 3)
|
|
]);
|
|
}
|
|
$this->PdfTableRow($pdf, $exportColumns, [
|
|
'Total',
|
|
$this->FormatNumber($result['totals']['solareinspeisung'], 3),
|
|
$this->FormatNumber($result['totals']['solarverkauf'], 3)
|
|
], true, true);
|
|
|
|
$pdf->Ln(2);
|
|
$amountColumns = [
|
|
['width' => 42, 'label' => 'Zählpunkt'],
|
|
['width' => 25, 'label' => 'Solar CHF', 'align' => 'R'],
|
|
['width' => 25, 'label' => 'Netz CHF', 'align' => 'R'],
|
|
['width' => 31, 'label' => 'Gutschrift Netz', 'align' => 'R'],
|
|
['width' => 31, 'label' => 'Gutschrift ZEV', 'align' => 'R'],
|
|
['width' => 26, 'label' => 'Total CHF', 'align' => 'R']
|
|
];
|
|
$this->PdfTableHeader($pdf, $amountColumns);
|
|
foreach ($rows as $row) {
|
|
$this->PdfTableRow($pdf, $amountColumns, [
|
|
$row['name'],
|
|
$this->FormatCurrency($row['cost_solar'], false),
|
|
$this->FormatCurrency($row['cost_grid'], false),
|
|
$this->FormatCurrency($row['rev_feedin'], false),
|
|
$this->FormatCurrency($row['rev_zev'], false),
|
|
$this->FormatCurrency($row['subtotal'], false)
|
|
]);
|
|
}
|
|
$this->PdfTableRow($pdf, $amountColumns, [
|
|
'Zwischentotal Elektrizität',
|
|
$this->FormatCurrency($result['totals']['cost_solar'], false),
|
|
$this->FormatCurrency($result['totals']['cost_grid'], false),
|
|
$this->FormatCurrency($result['totals']['rev_feedin'], false),
|
|
$this->FormatCurrency($result['totals']['rev_zev'], false),
|
|
$this->FormatCurrency($result['sum'], false)
|
|
], true, true);
|
|
}
|
|
|
|
private function WriteAdditionalPdfTable($pdf, array $result)
|
|
{
|
|
$columns = [
|
|
['width' => 30, 'label' => 'Zähler'],
|
|
['width' => 20, 'label' => 'Typ'],
|
|
['width' => 30, 'label' => 'Zeitraum'],
|
|
['width' => 20, 'label' => 'Start', 'align' => 'R'],
|
|
['width' => 20, 'label' => 'Ende', 'align' => 'R'],
|
|
['width' => 20, 'label' => 'Verbrauch', 'align' => 'R'],
|
|
['width' => 18, 'label' => 'Tarif', 'align' => 'R'],
|
|
['width' => 22, 'label' => 'Kosten CHF', 'align' => 'R']
|
|
];
|
|
|
|
$this->PdfTableHeader($pdf, $columns);
|
|
if (empty($result['rows'])) {
|
|
$this->PdfTableRow($pdf, [['width' => 180, 'align' => 'C']], ['Keine Nebenkostenzähler für diesen Benutzer.']);
|
|
} else {
|
|
foreach ($result['rows'] as $row) {
|
|
$this->PdfTableRow($pdf, $columns, [
|
|
$row['name'],
|
|
$row['type'],
|
|
date('d.m.Y', $row['from']) . "\n" . date('d.m.Y', $row['to']),
|
|
$this->FormatNumber($row['start_value'], 2),
|
|
$this->FormatNumber($row['end_value'], 2),
|
|
$this->FormatNumber($row['consumption'], 2),
|
|
number_format($row['tariff_price'], 3, '.', "'"),
|
|
$this->FormatCurrency($row['cost'], false)
|
|
]);
|
|
}
|
|
}
|
|
|
|
$this->PdfTableRow($pdf, $columns, [
|
|
'Zwischentotal Nebenkosten', '', '', '', '', '', '', $this->FormatCurrency($result['sum'], false)
|
|
], true, true);
|
|
}
|
|
|
|
private function WriteTariffPdfTable($pdf, $title, array $tariffs, $unitLabel)
|
|
{
|
|
if (empty($tariffs)) {
|
|
return;
|
|
}
|
|
|
|
$pdf->Ln(2);
|
|
$pdf->SetFont('dejavusans', 'B', 8);
|
|
$pdf->Cell(0, 5, $title, 0, 1, 'L');
|
|
$columns = [
|
|
['width' => 48, 'label' => 'Tarifart'],
|
|
['width' => 38, 'label' => 'Von'],
|
|
['width' => 38, 'label' => 'Bis'],
|
|
['width' => 32, 'label' => 'Preis', 'align' => 'R'],
|
|
['width' => 24, 'label' => 'Einheit']
|
|
];
|
|
$this->PdfTableHeader($pdf, $columns);
|
|
foreach ($tariffs as $tariff) {
|
|
if (($tariff['start'] ?? null) === null || ($tariff['end'] ?? null) === null) {
|
|
continue;
|
|
}
|
|
$this->PdfTableRow($pdf, $columns, [
|
|
$tariff['unit_type'] ?? '',
|
|
date('d.m.Y', $tariff['start']),
|
|
date('d.m.Y', $tariff['end']),
|
|
number_format((float)($tariff['price'] ?? 0), 3, '.', "'"),
|
|
$unitLabel
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function WriteSummaryPdfTable($pdf, array $rows)
|
|
{
|
|
$this->PdfSectionTitle($pdf, 'Zusammenfassung');
|
|
$columns = [
|
|
['width' => 130, 'label' => 'Position'],
|
|
['width' => 50, 'label' => 'Betrag', 'align' => 'R']
|
|
];
|
|
foreach ($rows as $index => $row) {
|
|
$isTotal = $index === count($rows) - 1;
|
|
$this->PdfTableRow($pdf, $columns, [$row[0], $row[1]], $isTotal, $isTotal);
|
|
}
|
|
}
|
|
// ====================== QR-Zahlteil (Schweiz) ======================
|
|
|
|
private function BuildQRPaymentSection($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]]);
|
|
|
|
if ($amount > 999999999.99) {
|
|
throw new Exception('Der Betrag überschreitet das Maximum für den QR-Zahlteil.');
|
|
}
|
|
|
|
$creditorName = $this->ReadPropertyString('CreditorName');
|
|
$creditorStreetLine = trim(
|
|
$this->ReadPropertyString('CreditorStreet') . ' '
|
|
. $this->ReadPropertyString('CreditorBuildingNumber')
|
|
);
|
|
$creditorTownLine = trim(
|
|
$this->ReadPropertyString('CreditorPostalCode') . ' '
|
|
. $this->ReadPropertyString('CreditorTown')
|
|
);
|
|
$creditorCountry = strtoupper($this->ReadPropertyString('CreditorCountry'));
|
|
$bankIBANFormatted = wordwrap($iban, 4, ' ', true);
|
|
$debtorLines = [
|
|
$user['name'] ?? '',
|
|
trim((string)($user['street'] ?? '') . ' ' . (string)($user['building_number'] ?? '')),
|
|
trim((string)($user['postal_code'] ?? '') . ' ' . (string)($user['town'] ?? '')),
|
|
strtoupper((string)($user['country'] ?? 'CH'))
|
|
];
|
|
|
|
// 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,
|
|
$creditorStreetLine,
|
|
$creditorTownLine,
|
|
$creditorCountry
|
|
], 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,
|
|
$creditorStreetLine,
|
|
$creditorTownLine,
|
|
$creditorCountry
|
|
], 8, 9, 4);
|
|
|
|
$this->WriteQrTextBlock($pdf, 118, $yStart + 34, 84, 'Zusätzliche Informationen', [
|
|
'Verbrauchskostenreport 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)
|
|
{
|
|
$sanitize = function ($value, $maxLength) {
|
|
$value = preg_replace('/[\r\n]+/', ' ', trim((string)$value));
|
|
return mb_substr((string)$value, 0, $maxLength);
|
|
};
|
|
|
|
$creditor = [
|
|
'name' => $sanitize($this->ReadPropertyString('CreditorName'), 70),
|
|
'street' => $sanitize($this->ReadPropertyString('CreditorStreet'), 70),
|
|
'building_number' => $sanitize($this->ReadPropertyString('CreditorBuildingNumber'), 16),
|
|
'postal_code' => $sanitize($this->ReadPropertyString('CreditorPostalCode'), 16),
|
|
'town' => $sanitize($this->ReadPropertyString('CreditorTown'), 35),
|
|
'country' => strtoupper($sanitize($this->ReadPropertyString('CreditorCountry'), 2))
|
|
];
|
|
$debtor = [
|
|
'name' => $sanitize($user['name'] ?? '', 70),
|
|
'street' => $sanitize($user['street'] ?? '', 70),
|
|
'building_number' => $sanitize($user['building_number'] ?? '', 16),
|
|
'postal_code' => $sanitize($user['postal_code'] ?? '', 16),
|
|
'town' => $sanitize($user['town'] ?? '', 35),
|
|
'country' => strtoupper($sanitize($user['country'] ?? 'CH', 2))
|
|
];
|
|
|
|
$lines = [
|
|
'SPC',
|
|
'0200',
|
|
'1',
|
|
$iban,
|
|
'S',
|
|
$creditor['name'],
|
|
$creditor['street'],
|
|
$creditor['building_number'],
|
|
$creditor['postal_code'],
|
|
$creditor['town'],
|
|
$creditor['country'],
|
|
'', '', '', '', '', '', '', // Ultimate Creditor
|
|
number_format($amount, 2, '.', ''),
|
|
'CHF',
|
|
'S',
|
|
$debtor['name'],
|
|
$debtor['street'],
|
|
$debtor['building_number'],
|
|
$debtor['postal_code'],
|
|
$debtor['town'],
|
|
$debtor['country'],
|
|
'NON',
|
|
'',
|
|
$sanitize('Verbrauchskostenreport Liegenschaft', 140),
|
|
'EPD'
|
|
];
|
|
|
|
if (count($lines) !== 31) {
|
|
throw new LogicException('Swiss-QR-Payload muss genau 31 Basisfelder enthalten.');
|
|
}
|
|
|
|
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)
|
|
{
|
|
$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
|
|
];
|
|
$rows = [];
|
|
$sum = 0.0;
|
|
|
|
if (empty($this->powerCostCache) || !isset($this->powerCostCache[$userId])) {
|
|
return ['rows' => [], 'totals' => $totals, 'sum' => 0.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);
|
|
}
|
|
|
|
$rows[] = [
|
|
'name' => (string)($a['name'] ?? $name),
|
|
'imp' => (float)$a['imp'],
|
|
'exp' => (float)$a['exp'],
|
|
'solar_bezug' => (float)$a['solar_bezug'],
|
|
'netz_bezug' => (float)$a['netz_bezug'],
|
|
'solareinspeisung' => (float)$a['solareinspeisung'],
|
|
'solarverkauf' => (float)$a['solarverkauf'],
|
|
'cost_solar' => (float)$a['cost_solar'],
|
|
'cost_grid' => (float)$a['cost_grid'],
|
|
'rev_feedin' => (float)$a['rev_feedin'],
|
|
'rev_zev' => (float)$a['rev_zev'],
|
|
'subtotal' => (float)$subtotal
|
|
];
|
|
}
|
|
|
|
return ['rows' => $rows, 'totals' => $totals, 'sum' => $sum];
|
|
}
|
|
// ====================== Nebenkosten Wasser/Wärme ======================
|
|
|
|
private function CalculateAdditionalCosts($waterMeters, $tariffs, $userId, $from, $to)
|
|
{
|
|
$rows = [];
|
|
$total = 0.0;
|
|
$usedTariffs = [];
|
|
|
|
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 (!empty($cost['row'])) {
|
|
$rows[] = $cost['row'];
|
|
}
|
|
$total += $cost['value'];
|
|
$usedTariffs = array_merge($usedTariffs, $cost['tariffs']);
|
|
}
|
|
|
|
return ['rows' => $rows, 'sum' => $total, 'tariffs' => $usedTariffs];
|
|
}
|
|
|
|
private function AddMeterToPDFRow($meter, $tariffs, $from, $to, $type)
|
|
{
|
|
$varId = (int)($meter['var_consumption'] ?? 0);
|
|
if ($varId <= 0 || !IPS_VariableExists($varId)) {
|
|
return ['row' => null, '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' => null, 'value' => 0, 'tariffs' => []];
|
|
}
|
|
|
|
$verbrauch = max(0, $endValue - $startValue);
|
|
$kosten = round(($tariffPrice / 100) * $verbrauch, 2);
|
|
|
|
return [
|
|
'row' => [
|
|
'name' => (string)($meter['name'] ?? ''),
|
|
'type' => (string)$type,
|
|
'from' => (int)$from,
|
|
'to' => (int)$to,
|
|
'start_value' => (float)$startValue,
|
|
'end_value' => (float)$endValue,
|
|
'consumption' => (float)$verbrauch,
|
|
'tariff_price' => (float)$tariffPrice,
|
|
'cost' => (float)$kosten
|
|
],
|
|
'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;
|
|
}
|
|
}
|
|
?>
|