QR-Zahlteil auf SIX 2.3 aktualisieren
Tests / test (push) Successful in 49s

This commit is contained in:
dh
2026-09-17 09:29:02 +00:00
parent d36bd29c31
commit b7c4b16987
37 changed files with 3959 additions and 3908 deletions
+219 -51
View File
@@ -55,10 +55,13 @@ class Verbrauchskostenreport extends IPSModule
$this->RegisterPropertyBoolean('EnableVAT', false);
$this->RegisterPropertyFloat('VATPercentage', 8.1);
// Neue Felder für den QR-Schein
$this->RegisterPropertyString('CreditorName', 'Belevo AG');
$this->RegisterPropertyString('CreditorAddress', 'Musterstrasse 1');
$this->RegisterPropertyString('CreditorCity', '6122 Menznau');
// 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->RegisterTariffVariableProfiles();
@@ -703,12 +706,15 @@ form.onsubmit=function(){
<table>
<thead>
<tr>
<th style='width:18%'>Name</th>
<th style='width:18%'>Adresse</th>
<th style='width:18%'>Ort</th>
<th style='width:19%'>Stromzaehler</th>
<th style='width:19%'>Nebenzaehler</th>
<th style='width:8%'>Aktion</th>
<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>
@@ -831,8 +837,11 @@ refreshPickers();
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_address[]" value="' . $this->h($user['address'] ?? '') . '"></td>'
. '<td><input name="user_city[]" value="' . $this->h($user['city'] ?? '') . '"></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>'
@@ -923,13 +932,18 @@ refreshPickers();
{
$ids = $post['user_id'] ?? [];
$names = $post['user_name'] ?? [];
$addresses = $post['user_address'] ?? [];
$cities = $post['user_city'] ?? [];
$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($addresses) || !is_array($cities) || !is_array($rowKeys)) {
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)) {
@@ -968,18 +982,31 @@ refreshPickers();
}
$assignedPower = [];
$assignedWater = [];
$count = max(count($ids), count($names), count($addresses), count($cities), count($rowKeys));
$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] ?? ''));
$address = trim((string)($addresses[$i] ?? ''));
$city = trim((string)($cities[$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 === '' && $address === '' && $city === '' && empty($selectedPower) && empty($selectedWater)) {
if ($id === '' && $name === '' && $street === '' && $buildingNumber === ''
&& $postalCode === '' && $town === '' && empty($selectedPower) && empty($selectedWater)) {
continue;
}
if ($id === '') {
@@ -993,8 +1020,12 @@ refreshPickers();
$user = $existingUsers[$id] ?? [];
$user['id'] = $id;
$user['name'] = $name;
$user['address'] = $address;
$user['city'] = $city;
$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) {
@@ -1144,11 +1175,96 @@ refreshPickers();
}
}
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);
@@ -1419,7 +1535,14 @@ refreshPickers();
$periodText = date('d.m.Y', $from) . ' - ' . date('d.m.Y', $to);
$meterText = empty($meterNames) ? '-' : implode("\n", $meterNames);
$addressText = trim(($user['name'] ?? '') . "\n" . ($user['address'] ?? '') . "\n" . ($user['city'] ?? ''));
$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);
@@ -1754,14 +1877,26 @@ refreshPickers();
$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');
$creditorAddress = $this->ReadPropertyString('CreditorAddress');
$creditorCity = $this->ReadPropertyString('CreditorCity');
$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'] ?? '',
$user['address'] ?? '',
$user['city'] ?? ''
trim((string)($user['street'] ?? '') . ' ' . (string)($user['building_number'] ?? '')),
trim((string)($user['postal_code'] ?? '') . ' ' . (string)($user['town'] ?? '')),
strtoupper((string)($user['country'] ?? 'CH'))
];
// Linker Teil: Empfangsschein
@@ -1772,8 +1907,9 @@ refreshPickers();
$this->WriteQrTextBlock($pdf, 5, $yStart + 12, 52, 'Konto / Zahlbar an', [
$bankIBANFormatted,
$creditorName,
$creditorAddress,
$creditorCity
$creditorStreetLine,
$creditorTownLine,
$creditorCountry
], 6, 7, 3.2);
$this->WriteQrTextBlock($pdf, 5, $yStart + 39, 52, 'Zahlbar durch', $debtorLines, 6, 7, 3.2);
@@ -1823,8 +1959,9 @@ refreshPickers();
$this->WriteQrTextBlock($pdf, 118, $yStart + 5, 84, 'Konto / Zahlbar an', [
$bankIBANFormatted,
$creditorName,
$creditorAddress,
$creditorCity
$creditorStreetLine,
$creditorTownLine,
$creditorCountry
], 8, 9, 4);
$this->WriteQrTextBlock($pdf, 118, $yStart + 34, 84, 'Zusätzliche Informationen', [
@@ -1852,31 +1989,62 @@ refreshPickers();
$pdf->MultiCell($width, $lineHeight, $line, 0, 'L', false, 1);
}
}
private function GenerateQRString($iban, $amount, $user)
{
// Daten nach SIX Swiss Payment Standard beschneiden (max 70 Zeichen pro Zeile)
$creditorName = mb_substr($this->ReadPropertyString('CreditorName'), 0, 70);
$creditorStr = mb_substr($this->ReadPropertyString('CreditorAddress'), 0, 70);
$creditorCity = mb_substr($this->ReadPropertyString('CreditorCity'), 0, 70);
$sanitize = function ($value, $maxLength) {
$value = preg_replace('/[\r\n]+/', ' ', trim((string)$value));
return mb_substr((string)$value, 0, $maxLength);
};
// Fallback falls ein Mieter keinen Namen oder Ort hinterlegt hat (Banken verlangen das zwingend)
$debtorName = mb_substr(empty($user['name']) ? 'Unbekannt' : $user['name'], 0, 70);
$debtorStr = mb_substr($user['address'] ?? '', 0, 70);
$debtorCity = mb_substr(empty($user['city']) ? 'Unbekannt' : $user['city'], 0, 70);
$amountStr = number_format($amount, 2, '.', '');
// EPC / Swiss QR Code String Format (Exakte Reihenfolge ist kritisch!)
$lines = [
'SPC', '0200', '1', $iban, 'K',
$creditorName, $creditorStr, $creditorCity, '', '', 'CH',
'', '', '', '', '', '', '', // Ultimate Creditor (leer)
$amountStr, 'CHF', 'K',
$debtorName, $debtorStr, $debtorCity, '', '', 'CH',
'NON', '', // Reference Type (NON = Ohne Referenz)
'Verbrauchskostenreport Liegenschaft', // 30. Unstrukturierte Mitteilung
'EPD' // 31. Trailer (Zwingend exakt 'EPD' am Schluss!)
$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);
}