Die Klasse factXrech-class.php basiert im Prinzip auf der Klasse control-class.php. Sie wurde aber ganz bewusst nicht als Erweiterung formuliert, sondern ist für den Anwendungsfall XRechnung und Factur-X als ein Ersatz für die control-class.php zu verstehen. Zu viele der Methoden unterscheiden sich in Details.
Als Motivation für die Einführung der Klasse können noch einmal kurz die ersten Kapitel der Beschreibung der Klasse control-class.php gelesen werden.
Die im Quellcode nachfolgend dargestellte Klasse factXrech-class übernimmt eine Reihe von immer wiederkehrenden Aufgaben im Umgang mit Kontrolldateien. Als ersten Überblick sind dies:
Das Skript sollte vorzugsweise über diesen Link factXrech-class.zip geladen werden, weil zum einen beim Laden über den Reiter des Quellcodes die Unicode-BOM verlorengeht. 2) Zum anderen benötigt die Klasse für ihre korrekte Funktion aber noch eine ganze Reihe weiterer Dateien, die in dem Archiv mit enthalten sind.
Bevor nun die einzelnen Methoden beschrieben werden, hier die vollständige Klasse:
<?php class factXrech { const DATE_FIELD = 0; const CURRENCY_FIELD = 1; const STRING_FIELD = 2; const COUNTRY_FIELD = 3; const LANGUAGE_FIELD = 4; const UNIT_FIELD = 5; const QUANTITY_FIELD = 6; const MAIL_FIELD = 7; const INVOICE_TEMPLATE = 1; const ITEM_TEMPLATE = 2; const FACTURX = "F"; const XRECHNUNG = "X"; private $type; /* Factur-X (F) vs XRechnung (X) */ private $errorCount = 0; /* number of error messages sent */ private $index = 0; /* line index from control file */ private $matches = array (); /* info from control file */ private $xmlInvoice = ""; /* xml for factur-X / XRechnung invoice */ private $xmlItems = ""; /* xml for billed items */ private $xmlFile; /* name of xml file to create */ private $unitCodeMap = null; /* unit names to XRechnung mapping */ private $countryCodeMap = null; /* country names to XRechnung mapping */ private $languageCodeMap = null; /* language names to XRechnung mapping */ /*----------------------------------------------------------------------------*/ public function __construct ($type, $zone, $ctlPath) { date_default_timezone_set ($zone); /* prepare log file time stamping */ if (! file_exists ($ctlPath)) $this->error ("'$ctlPath' not found", 1); if (($type == "F") || ($type == "X")) $this->type = $type; /* remember type of invoice */ else { $this->error ("Illegal type of invoice - using Factur-X", 0); $this->type = "F"; } $path = ($this->type == "F") ? "Factur-X" : "XRechnung"; $this->xmlInvoice = file_get_contents ("xml/$path/invoice.xml"); $this->xmlItems = file_get_contents ("xml/$path/item.xml"); if (($this->xmlInvoice === false) || ($this->xmlItems === false)) $this->error ("Can't read required xml templates", 1); $ctl = file_get_contents ($ctlPath); /* ctl file coded in UTF-8 */ preg_match_all ("/\[([0-9a-zA-Z]{3,10})\](.+)\r/u", $ctl, $this->matches, PREG_SET_ORDER); $this->xmlFile = "converter/" . (($this->type == "F") ? "factur-x.xml" : "xrechnung.xml"); } public function __destruct () { if (file_exists ($this->xmlFile)) unlink ($this->xmlFile); if (file_exists ("temp.pdf")) unlink ("temp.pdf"); if (file_exists ("invoice.pdf")) unlink ("invoice.pdf"); } /*----------------------------------------------------------------------------*/ public function xmlfile () /* name of xml file to be used */ { return $this->xmlFile; /* subdir needed for ghostscript */ } /*----------------------------------------------------------------------------*/ public function error ($msg, $severity = 0) { file_put_contents ("factxrech.log", "[" . date ('Y-m-d H:i:s', time ()) . "] " . $msg . "\n", FILE_APPEND); if ($severity != 0) die (); $this->errorCount++; } /*----------------------------------------------------------------------------*/ public function errorsFound () { return $this->errorCount; } /*----------------------------------------------------------------------------*/ private function readMapFile ($filename) { $result = array (); $handle = fopen ($filename, "r"); if ($handle) { while (($line = fgets ($handle)) !== false) { $parts = explode (":", $line); if (count ($parts) == 2) $result [trim ($parts [0])] = trim ($parts [1]); } fclose ($handle); } else $this->error ("could not open " . $filename, 1); return $result; } /*----------------------------------------------------------------------------*/ private function unitToUnitCode ($unit) { if ($this->unitCodeMap == null) $this->unitCodeMap = $this->readMapFile("mappings/unitCodes.txt"); return $this->unitCodeMap [$unit]; } /*----------------------------------------------------------------------------*/ private function countryToCountryID ($country) { if ($this->countryCodeMap == null) $this->countryCodeMap = $this->readMapFile("mappings/countryCodes.txt"); return $this->countryCodeMap [$country]; } /*----------------------------------------------------------------------------*/ private function langToLangID ($language) { if ($this->languageCodeMap == null) $this->languageCodeMap = $this->readMapFile("mappings/languageCodes.txt"); return $this->languageCodeMap [$language]; } /*----------------------------------------------------------------------------*/ public function formatDate ($datestring) { $d = trim ($datestring); if (preg_match ('/(\d?\d)\.(\d?\d)\.(\d\d)/', $d, $match)) if ($this->type == "X") return sprintf ("20%02d-%02d-%02d", $match [3], $match [2], $match [1]); else return sprintf ("20%02d%02d%02d", $match [3], $match [2], $match [1]); $this->error ("Illegal date string '$datestring'", 0); return "0000.01.01"; /* not a date */ } /*----------------------------------------------------------------------------*/ public function formatQuantity ($quant) { $q = trim ($quant); $q = str_replace ('.', '', $q); /* remove thousands */ $q = str_replace (',', '.', $q); /* convert fraction */ if (preg_match ('/[-\.0-9]+/u', $q)) /* is numeric */ { if (strpos ($q, "-") != false) /* negative */ return sprintf ("-%01.2f", abs ((float) $q)); return sprintf ("%01.2f", (float) $q); } $this->error ("Illegal quantity string '$quant'", 0); return "0.00"; /* not a number */ } /*----------------------------------------------------------------------------*/ public function formatCurrency ($currency) { $c = trim ($currency); $c = str_replace ('.', '', $c); /* remove thousands */ $c = str_replace (',', '.', $c); /* convert fraction */ if (preg_match ('/[-\.0-9]+/u', $c)) /* is numeric */ { if (strpos ($c, "-") != false) /* negative */ return sprintf ("-%01.2f", abs ((float) $c)); return sprintf ("%01.2f", (float) $c); } $this->error ("Illegal currency string '$currency'", 0); return "0.00"; /* not a number */ } /*----------------------------------------------------------------------------*/ public function getIndex () { return $this->matches [$this->index] [1]; } /*----------------------------------------------------------------------------*/ public function setIndex ($lineIndex) { for ($idx = 0; $idx < count ($this->matches); $idx++) if ($this->matches[$idx][1] == $lineIndex) break; if ($idx == count ($this->matches)) return ""; $this->index = $idx; return trim ($this->matches [$idx] [2]); } /*----------------------------------------------------------------------------*/ public function getLine ($lineIndex) { $line = ""; do { for ($idx = 0; $idx < count ($this->matches); $idx++) if ($this->matches [$idx] [1] == $lineIndex) break; if ($idx == count ($this->matches)) return ""; $lineIndex = $this->matches [$idx + 1] [1]; $lineidx = substr ($this->matches [$idx] [1], 0, 6); $line .= $this->matches [$idx] [2]; } while (substr ($lineIndex, 0, 6) == $lineidx); return $line; } /*----------------------------------------------------------------------------*/ public function fetch ($lineIndex, &$target, $regEx, $idx, $fieldType = self::STRING_FIELD) { $line = ($lineIndex == "") ? $this->matches [$this->index++] [2] /* current line */ : $this->setIndex ($lineIndex); /* requested line */ if (preg_match ("/" . $regEx . "/u", $line, $m) == 1) switch ($fieldType) { case self::CURRENCY_FIELD: $target [$idx] = $this->formatCurrency ($m [1]); break; case self::DATE_FIELD: $target [$idx] = $this->formatDate ($m [1]); break; case self::LANGUAGE_FIELD: $target [$idx] = $this->langToLangID ($m [1]); break; case self::COUNTRY_FIELD: $target [$idx] = $this->countryToCountryID ($m [1]); break; case self::UNIT_FIELD: $target [$idx] = $this->unitToUnitCode ($m [1]); break; case self::QUANTITY_FIELD: $target [$idx] = $this->formatQuantity ($m [1]); break; case self::STRING_FIELD: $target [$idx] = $m [1]; break; case self::MAIL_FIELD: if (filter_var ($m [1], FILTER_VALIDATE_EMAIL)) $target [$idx] = $m [1]; else $target [$idx] = "?"; break; default: $target [$idx] = ""; } else $target [$idx] = ""; } /*----------------------------------------------------------------------------*/ public function substitute ($tmpl, $vars) { switch ($tmpl) { case self::INVOICE_TEMPLATE: $line = $this->xmlInvoice; break; case self::ITEM_TEMPLATE: $line = $this->xmlItems; break; default: $line = ""; break; } /* Replace placeholders in template, uppercase $key is already xml */ foreach ($vars as $key => $value) { if ($key != strtoupper ($key)) /* mixed case name */ $value = htmlentities ($value, ENT_XML1, "UTF-8"); $line = str_replace( "@$key@" , $value , $line); } /* Remove empty lines forced by including other xml-file content */ return str_replace ("\r\n\r\n", "\r\n", $line); } /*----------------------------------------------------------------------------*/ public function createXML ($xml, $validate) { /* Write XML file with all the XRechnung data */ file_put_contents ($this->xmlfile (), $xml); if ($validate == 1) /* validation of xml requested */ { exec ("java -jar validator/validationtool-1.5.0-standalone.jar " . "-r validator -s validator/scenarios.xml " . "-h " . $this->xmlfile () . " -o .", $output, $result); file_put_contents ("validation.txt", implode ("\r\n", $output)); if ($this->type == "F") unlink ("factur-x-report.xml"); else unlink ("xrechnung-report.xml"); if ($result != 0) /* validator returns number of invalid invoices */ { $this->error ("Rechnung kann nicht validiert werden", 0); return 0; } } return 1; /* success with writing xml file (including validation) */ } /*----------------------------------------------------------------------------*/ public function createPDF ($pcl, $company, $reference, $keywords) { if (! file_exists ($pcl)) $this->error ("'$pcl' not found", 1); /* Convert PCL file of invoice to PDF/A3 format using GhostPCL */ system ("\"converter/gpcl6win64.exe\" \"-otemp.pdf\" " . "-sPDFCompatibillityPolicy=2 -sDEVICE=pdfwrite -dPDFA=3 " . "-dCompressPages=false -dCompressFonts=false \"$pcl\""); /* Write postscript file to setup creation time of xml document and meta data for pdf file creation */ file_put_contents ("converter/date.ps", "%!PS\n/InvoiceDate (D:" . date ('YmdHis') . substr (date ('P'), 0, 3) . "'00') def\n" . "[ /Title ($company - $reference)\n" . " /Author ($company)\n" . " /Subject (Elektronische Rechnung im Format " . (($this->type == "F") ? "Factur-X \(ZUGFeRD 2.2\)" : "X-Rechnung") . ")\n" . " /Keywords ($keywords)\n" . " /Creator (print2forms)\n" . " /DOCINFO\n" . "pdfmark\n" . "%%EOF"); /* Convert PDF/A3 to ZUGFeRD format using GhostScript */ $internal = "invoice.pdf"; system ("converter\gswin64.exe " . "--permit-file-read=converter/ " . "-sDEVICE=pdfwrite " . "-dPDFA=3 " . "-sColorConversionStrategy=RGB " . "-sZUGFeRDXMLFile=" . $this->xmlfile () . " " . "-sZUGFeRDProfile=converter/iccprofiles_default_rgb.icc " . "-sZUGFeRDVersion=2p1 " . "-sZUGFeRDConformanceLevel=" . (($this->type == "F") ? "ZUGFeRD" : "XRECHNUNG") . " " . "-o $internal " . "temp.pdf " . "converter/date.ps " . "converter/embedding.ps"); return $internal; /* internal name of the pdf file as result */ } } ?>
Der Konstruktor der Klasse benötigt als Parameter drei Werte. Zuerst muss festgelegt werden, ob eine Rechnung im Format Factur-X oder im Format XRechnung erzeugt werden soll. Dazu sind zwei Konstanten definiert: factXrech::FACTURX und factXrech::XRECHNUNG.
Als zweiter Parameter wird eine in der jeweiligen PHP-Installation bekannte Zeitzone angegeben. Die korrekte Zeitzone wird für die Log-Datei benötigt. Falls Fehler auftreten, ist so sichergestellt, dass die in der Log-Datei angegebenen Zeitstempel tatsächlich in der lokalen Zeit sind. Ausserdem werden so korrekte Zeitstempel für das Einbetten der XML-Datei in die PDF-Datei garantiert.
Als letzten Parameter benötigt der Konstruktor die Kontrolldatei inklusive absolutem Zugriffspfad.
Es wird überprüft, ob der Zugriff auf die Kontrolldatei gegeben ist, und wenn ja, wird die Kontrolldatei komplett in einer internen Struktur abgelegt. Alle Methoden der Klasse greifen nur auf diese interne Struktur zu. Es erfolgt kein weiterer Zugriff auf die eigentliche Kontrolldatei.
Der Konstruktor liest dann auch gleich die nötigen Vorlagen für die XML-Dateien ein.
$fxr = new factXrech (factXrech::XRECHNUNG, "Europe/Berlin", "$spool\\$document.ctl");
Hier werden eventuell angelegte Zwischendatei wieder entfernt. Falls es während der Inbetriebnahme eines Skripts hilfreich ist, die Zwischendateien zu analysieren, müssen die betreffenden Zeilen des Destruktors gegebenenfalls auskommentiert werden. Der Destruktor wird implizit beim Ende des Skriptes aufgerufen. Wahlweise kann das auch explizit erfolgen.
$fxr = null;
Die Methode akzeptiert als Parameter eine Zeichenkette, die in die Log-Datei geschrieben wird. Der optionale zweite Parameter sorgt dafür, dass die Ausführung des Skriptes beendet wird, wenn er einen Wert ungleich Null hat. Damit kann bei besonders schwerwiegenden, irreparablen Fehlern (z.B. Fehlen der Kontrolldatei) sofort abgebrochen werden, ohne das jedesmal ausprogrammieren zu müssen.
if ($em->sendmail ($data, $message) == 0) $fxr->error ("Sending e-mail failed '$message'");
Gibt einen internen Fehlerzähler zurück. der mit jeder Instanziierung eines Objekts der Klasse zurückgesetzt wurde. Wird hier ein Wert ungleich Null zurückgeliefert, ist im bisherigen Verlauf des Skriptes zumindest einmal eine Fehlermeldung in die Log-Datei geschrieben worden.
if ($fxr->errorsFound () != 0) exit (-1);
Die Methode setzt einen internen Positionsindex auf den als Parameter übergebenen Wert. Der Parameter muss eine zehnstellig Hexadezimalzahl sein. Dies ist die Vorbereitung für nachfolgende Aufrufe der Methode fetch ohne Positionsindex.
$fxr->setIndex ("01063100FD"); /* position to start with item table */ while ($fxr->getIndex () != "010D1A00C6") /* loop through list of items */ { /* Process list of invoice items */ $fxr->fetch ("", $vars, "(\d+)", "LineID"); $fxr->fetch ("", $vars, "(.*)", "SellerAssignedID"); /* ... */ }
Die Methode gibt den aktuellen Wert eines internen Positionsindex als zehnstellig Hexadezimalzahl zurück. In der Regel wird damit kontrolliert, ob die Methode fetch bereits das Ende des zu lesenden Bereichs erreicht hat.
Die Methode braucht als Parameter zunächst den zu suchenden Positionsindex als zehnstellige Hexadezimalzahl. Zur Speicherung des extrahierten Textes wird danach die Adresse eines Arrays übergeben. Ein regulärer Ausdruck sucht innerhalb des Textes an dieser Position die gewünschte Information 3). Der letzte Parameter definiert einen Namen, unter dem der extrahierte Text im Array abgespeichert wird.
Wird als Parameter für den Positionsindex eine leere Zeichenkette angegeben, wird der interne Positionsindex als Referenz genommen. Gleichzeitig wird der interne Positionsindex auf die nächste Zeile innerhalb der Kontrolldatei weitergeschoben. Damit kann die Kontrolldatei quasi sequentiell bearbeitet werden. 4)
Als optionaler fünfter Parameter kann eine spezielle Formatierung des extrahierten Textes angefordert werden. Im Normalfall wird der Text unverändert abgespeichert. Wird einer der vordefinierten Werte factXrech::DATE_FIELD, factXrech::CURRENCY_FIELD, factXrech::STRING_FIELD 5), factXrech::COUNTRY_FIELD, factXrech::LANGUAGE_FIELD, factXrech::UNIT_FIELD, factXrech::QUANTITY_FIELD oder factXrech::MAIL_FIELD angegeben, erfolgt eine gemäss den Spezifikationen von Factur-X oder XRechnung entsprechende Formatierung.
$fxr->fetch ("0101720846", $vars, " +(\d\d\.\d\d\.\d{4})", "InvoiceDate", factXrech::DATE_FIELD);
Die Methode liefert als Resultat die Zeichenkette am als Parameter übergebenen Positionsindex. Eine Besonderheit von getLine ist, dass tatsächlich eine Zeile zurückgegeben wird. Das wird dadurch erreicht, dass die Methode alle Positionen mit gleichlautendem vertikalen Wert aufsammelt und zu einer Zeile verbindet. 6)
... [010E7300C6]innerhalb 30 Tag [010E730281]en netto bis 04.07.2018, innerhalb 14 Tagen 2% Skonto bis 19.06.2018 ...
$vars ["PaymentTerms"] = $fxr->getLine ("010E7300C6");
Die Methode akzeptiert eine Zeichenkette, in der sich ein Datum befindet. Dieses liegt in Form von ein- oder zweistelligen Zahlen für die Tage und den Monat, sowie einer zweistelligen Jahresangabe vor, jeweils durch Punkte voneinander getrennt. Die Jahresangabe wird mit einer vorgestellten '20' ergänzt.
Ist das im konkreten Anwendungsfall aufgrund der Daten aus der Kontrolldatei nicht passend, muss der reguläre Ausdruck eventuell angepasst werden.
$vars ["DueDate"] = $fxr->formatDate (substr ($vars ["PaymentTerms"], 29, 10));
Die Methode akzeptiert eine Zeichenkette, in der sich ein Mengenangabe befindet. Eine valide Mengenangabe ist eine Zahl mit oder ohne Nachkommastellen. Optional kann die Zahl auch Tausendertrennungen in Form von Punkten beinhalten.
Die Methode ist so programmiert, dass sowohl ein voranstehendes Minus als auch nachfolgendes Minus erkannt werden, und dann eine negative Zahl als Resultat erzeugen.
$vars ["InvoicedQuantity"] = $fxr->formatQuantity ($quant);
Die Methode akzeptiert eine Zeichenkette, in der sich eine Zahl befindet, die in einer Währung angegeben ist. Eine valide Währungsangabe ist eine Zahl mit oder ohne Nachkommastellen. Optional kann die Zahl auch Tausendertrennungen in Form von Punkten beinhalten.
Die Methode ist so programmiert, dass sowohl ein voranstehendes Minus als auch nachfolgendes Minus erkannt werden, und dann eine negative Zahl als Resultat erzeugen.
$vars ["PriceAmount"] = $fxr->formatCurrency ($price);
Die Methode sucht in einer der vorgegebenen Vorlagen für die XML-Daten nach den Bezeichnern aus einer Liste von extrahierten Texten. Als erster Parameter wird die Vorlage ausgewählt. Es stehen dafür zwei Konstanten zur Verfügung: factxrech::INVOICE_TEMPLATE für die Vorlage der gesamten Rechnung und factXrech::ITEM_TEMPLATE für die Vorlage einer Rechnungsposition. Die Vorlagen enthalten an den entsprechenden Positionen Platzhalter für die Daten aus der Kontrolldatei. Platzhalter sind Namen, die in '@'-Zeichen eingeklammert sind. Sie entsprechen den Schlüsseln innerhalb des Arrays mit den aufgesammelten Texten.
Anschliessend geht die Methode sequentiell durch das Array, welches als zweiter Parameter übergeben wird, und ersetzt die Platzhalter, die genau so heissen wie die Schlüssel im Array, gegen den Text der unter diesem Schlüssel abgelegt worden ist. 7)
Eine Besonderheit dabei ist natürlich, dass die aus den Druckdaten extrahierten Texte gemäss den Vorgaben von XML umkodiert werden müssen ('&' → '&', …). Das ist nicht ganz so trivial, da auch der Fall berücksichtigt werden muss, dass der gegen den Platzhalter getauschte Text bereits gültiges XML ist. Deshalb wird die Umkodierung nur für Platzhalter durchgeführt, die nicht komplett aus Grossbuchstaben bestehen. 8)
$vars ["ITEMS"] .= $fxr->substitute (factXrech::ITEM_TEMPLATE, $itemvars);
Die Methode schreibt zunächst einmal die als ersten Parameter übergebene Zeichenkette in eine interne XML-Datei. Ist der zweite Parameter eine Eins, wird diese XML-Datei auch sogleich validiert. Das Ergebnis der Validation mit dem KoSIT Validator wird als Resultat zurückgegeben. Ein Rückgabewert von Null zeigt, dass die Validation fehlgeschlagen ist. 9)
Im Falle des Fehlschlags kann das Prüfprotokoll zur Fehlerbehebung herangezogen werden. Im Skriptverzeichnis stehen dann die Dateien factur-x-report.html respektive xrechnung-report.html, die mit jedem Browser angezeigt und inspiziert werden können. 10)
$fxr->createXML ($fxr->substitute (factXrech::INVOICE_TEMPLATE, $vars), 1);
Die Methode erzeugt aus der als erstem Parameter übergebenen PCL-Datei eine PDF-Datei. Als zweiter Parameter wird eine Zeichenkette übergeben, die als Autor in den Metadaten der PDF-Datei eingetragen wird. Der dritte Parameter ist eine Zeichenkette, die als Betreff in den Metadaten der PDF-Datei eingetragen wird. Der vierte Parameter ist eine Zeichenkette, die als Schlüsselwörter in den Metadaten der PDF-Datei eingetragen wird. 11)
Für die Einbettung der elektronischen Rechnung in die PDF-Datei erzeugt und benutzt die Methode eine PostScript-Datei. 12)
$title = "Rechnung {$vars ["DocumentID"]} vom $invoicedate"; $keywords = "Muster_GmbH {$vars ["DocumentID"]} $invoicedate {$vars ["CustomerID"]}"; $resultfile = $fxr->createPDF ("$document.pcl", "Muster GmbH", $title, $keywords);