Fix #xml-rechnung: XML-Download (EN 16931 / XRechnung 3.0) in der Rechnungs-Detailansicht
XML-Symbol neben dem PDF-Symbol lädt die Rechnung als reine XML-Datei (Profil XRechnung 3.0, CIUS von EN 16931). Fehlende Stamm- oder Rechnungsdaten verhindern den Download nicht, werden aber mit BT-Nummer in der Detailansicht ausgewiesen. Die PDF-Einbettung bleibt bei Factur-X EN16931.
This commit is contained in:
parent
519befa36e
commit
117c9a5c88
7 changed files with 475 additions and 35 deletions
20
Changes.md
20
Changes.md
|
|
@ -2,6 +2,26 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-07-31 – XML-Rechnung (EN 16931) als Download in der Rechnungs-Detailansicht
|
||||
|
||||
- Neben dem PDF-Symbol steht in der Detailansicht einer Rechnung jetzt ein XML-Symbol, das die Rechnungsdaten als reine EN16931-XML-Datei (`Rechnung-<Nr>.xml` bzw. `Stornorechnung-<Nr>.xml`) zum Download anbietet. Bisher war das EN16931-XML nur in die PDF eingebettet und nicht einzeln zu bekommen.
|
||||
- **`src/Services/ZugferdService.php`:** Der Dokumentaufbau steckt jetzt in `buildDocument()`; `attachToPdf()` bettet das Ergebnis wie bisher in die PDF ein, neu liefert `createXml()` dasselbe Dokument als XML-String. Alle Feldzugriffe sind gegen fehlende Schlüssel und `null` abgesichert, sodass auch unvollständige Rechnungen (z.B. Entwürfe ohne Rechnungsnummer) ein wohlgeformtes XML ergeben statt eines Fehlers.
|
||||
- **Neue Methode `collectMissingData()`:** Prüft die für EN 16931 relevanten Angaben und liefert die fehlenden getrennt nach Stammdaten (BT-27, BT-35/37/38, BT-31/32, BT-84) und Rechnungsdaten (BT-1, BT-2, BT-9, BT-10, BT-44, BT-50/52/53/55, BT-72, BG-25) zurück.
|
||||
- **`src/Controllers/InvoiceController.php`, `public/index.php`:** Neue Action `xml()` und Route `GET /invoices/{id}/xml`; die Detailansicht bekommt zusätzlich die Liste der fehlenden Angaben übergeben.
|
||||
- **`src/Views/invoices/show.php`:** XML-Download-Symbol neben dem PDF-Symbol (auch bei Entwürfen sichtbar). Fehlen Angaben, erscheint über der Aktionsleiste ein Hinweisbanner mit den betroffenen Feldern samt BT-Nummer, und das Symbol wird in Warnfarbe dargestellt – der Download bleibt in jedem Fall möglich.
|
||||
- **Fix nebenbei:** `mapEinheit()` verglich Einheiten ohne den im UI üblichen Punkt („Std.", „Stk."), sodass Stunden im ZUGFeRD-XML bislang als Stück (`C62`) statt als `HUR` ausgewiesen wurden. Das betraf auch das in die PDF eingebettete XML.
|
||||
- **`tests/72_InvoiceXmlExportTest.php`** (neu): Prüft Profilkennung, Rechnungsnummer, Käufer- und Verkäuferdaten, Positionen, Bruttosumme und Einheiten-Mapping im erzeugten XML sowie die Meldung fehlender Stamm-/Rechnungsdaten und die Anbindung in Route und Detailansicht.
|
||||
|
||||
### Nachtrag: Konformität zur XRechnung-CIUS hergestellt
|
||||
|
||||
Der E-Rechnungsvalidator (KoSIT-Prüfkonfiguration) meldete für die heruntergeladene Datei einen Fehler und eine Warnung. Die EN16931-Schematron-Prüfung war bereits fehlerfrei – beanstandet wurden ausschließlich die zusätzlichen deutschen Regeln der CIUS XRechnung:
|
||||
|
||||
- **BR-DE-2 (Fehler):** Die Gruppe „SELLER CONTACT" (BG-6) fehlte. `ZugferdService` setzt jetzt Ansprechpartner (BT-41, aus `inhaber` mit Rückfall auf `firma`), Telefon (BT-42) und E-Mail (BT-43) aus den Firmeneinstellungen.
|
||||
- **BR-DE-21 (Warnung):** Die Spezifikationskennung (BT-24) soll der XRechnung entsprechen. Der XML-Download verwendet dafür jetzt das Profil **XRechnung 3.0** (`ZugferdProfiles::PROFILE_XRECHNUNG_3`) statt des reinen EN16931-Profils. XRechnung ist eine CIUS von EN 16931, die Datei bleibt damit normkonform und erfüllt zusätzlich die BR-DE-Regeln.
|
||||
- **PEPPOL-EN16931-R001/R010/R020 (Hinweise):** Geschäftsprozess (BT-23) sowie die elektronischen Adressen von Verkäufer (BT-34) und Käufer (BT-49, aus der Kunden-E-Mail) werden jetzt mit ausgegeben.
|
||||
- **Die PDF-Einbettung bleibt unverändert** beim Profil EN16931 (Factur-X, `factur-x.xml`) – dem üblichen Format für hybride B2B-Rechnungen. Die neuen Kontakt- und Adressangaben stehen aber auch dort, das PDF-XML wird dadurch nur vollständiger.
|
||||
- `collectMissingData()` prüft zusätzlich Ansprechpartner (BT-41), Telefon (BT-42), E-Mail (BT-43/BT-34) und die Kunden-E-Mail (BT-49); Test 72 deckt die neuen Elemente sowie eine XSD-Prüfung des erzeugten XML ab.
|
||||
|
||||
## 2026-07-31 – Firmenlogo persistent in `data/logo`, Nutzdaten gebündelt unter `data/`
|
||||
|
||||
- Fix #116 hatte die Beleg-Uploads aus dem Web-Root nach `data/uploads` verschoben – inklusive des Unterordners `logo/`. Angepasst wurden damals aber nur Beleg-, Bank- und Invoice-Code; `Setting`, `SettingsController` und `PdfInvoiceService` zeigten weiterhin auf `public/static/uploads/logo/`. Dadurch war das hochgeladene Firmenlogo ab diesem Zeitpunkt nicht mehr auffindbar (Datei vorhanden, Pfad falsch) und ein Neu-Hochladen legte es erneut im Web-Root ab, wo es bei jedem Update verloren ging.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
|
|||
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Bezahlt / Storniert
|
||||
- Buchungsworkflow: Finalisierung erzeugt automatisch einen Beleg und eine Buchung in der Belegverwaltung
|
||||
- **ZUGFeRD EN16931 (E-Rechnung)**: Jede generierte PDF enthält ein eingebettetes `factur-x.xml` nach EU-Norm EN 16931; Pflichtangaben laut § 14 UStG vollständig abgedeckt; Validierung möglich unter [portal.e-rechnung-bund.de](https://portal.e-rechnung-bund.de/validator); Bibliothek: `horstoeko/zugferd` v1.x
|
||||
- **XML-Rechnung als Einzeldatei**: In der Detailansicht liefert das XML-Symbol neben dem PDF-Symbol die reine XML-Rechnung (`Rechnung-<Nr>.xml`, Route `/invoices/{id}/xml`). Verwendet wird das Profil **XRechnung 3.0** – eine CIUS von EN 16931, die zusätzlich die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem [E-Rechnungsvalidator](https://erechnungsvalidator.service-bw.de) ohne Beanstandung akzeptiert wird. Fehlen Stamm- oder Rechnungsdaten, wird die Datei trotzdem ausgeliefert; die Detailansicht listet die fehlenden Angaben mit ihrer BT-Nummer auf und färbt das Symbol als Warnung ein.
|
||||
- Für XRechnung zwingend sind unter anderem der Ansprechpartner des Verkäufers (BG-6: Inhaber, Telefon, E-Mail aus den Firmeneinstellungen) sowie die Kundennummer als Käuferreferenz (BT-10); fehlen sie, weist die Detailansicht darauf hin.
|
||||
- E-Mail-Versand via SMTP direkt aus der Anwendung
|
||||
- **Stornorechnung (§ 14 UStG)**: Finalisierte, gesendete oder bezahlte Rechnungen können storniert werden; dabei entsteht automatisch eine nummerierte Stornorechnung mit negierten Beträgen (`storno_of_id`), GoBD-konforme Stornobuchungen heben die Originalbuchungen auf; Querverweise in der Detailansicht, angepasstes PDF-Layout (Titel „STORNORECHNUNG", Referenzzeile)
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ $router->get('/invoices/{id}/edit', fn($p) => (new InvoiceController())->edit($p
|
|||
$router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p));
|
||||
$router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p));
|
||||
$router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p));
|
||||
$router->get('/invoices/{id}/xml', fn($p) => (new InvoiceController())->xml($p));
|
||||
$router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p));
|
||||
$router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p));
|
||||
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use Steuerkram\Models\Account;
|
|||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Services\EmailService;
|
||||
use Steuerkram\Services\PdfInvoiceService;
|
||||
use Steuerkram\Services\ZugferdService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class InvoiceController extends BaseController
|
||||
|
|
@ -137,6 +138,9 @@ class InvoiceController extends BaseController
|
|||
? \Steuerkram\Models\Customer::find((int)$invoice['customer_id'])
|
||||
: null;
|
||||
|
||||
// Fehlende Pflichtangaben für die EN16931-XML-Rechnung ermitteln
|
||||
$xmlHinweise = (new ZugferdService())->collectMissingData($invoice, $lines, $settings);
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
|
||||
|
|
@ -144,6 +148,7 @@ class InvoiceController extends BaseController
|
|||
'invoice' => $invoice,
|
||||
'lines' => $lines,
|
||||
'settings' => $settings,
|
||||
'xmlHinweise' => $xmlHinweise,
|
||||
'stornorechnung' => $stornorechnung ?: null,
|
||||
'originalrechnung' => $originalrechnung,
|
||||
'bookings' => $bookings,
|
||||
|
|
@ -350,6 +355,32 @@ class InvoiceController extends BaseController
|
|||
echo $pdf;
|
||||
}
|
||||
|
||||
public function xml(array $params): void
|
||||
{
|
||||
$id = (int)$params['id'];
|
||||
$invoice = Invoice::find($id);
|
||||
if (!$invoice) { http_response_code(404); return; }
|
||||
|
||||
$lines = Invoice::lines($id);
|
||||
$settings = Setting::get();
|
||||
|
||||
try {
|
||||
$xml = (new ZugferdService())->createXml($invoice, $lines, $settings);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Steuerkram] Fehler in InvoiceController::xml(): ' . $e->getMessage());
|
||||
View::redirect("/invoices/{$id}", 'XML-Rechnung konnte nicht erzeugt werden.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-';
|
||||
$filename = $prefix . ($invoice['rechnungs_nr'] ?: 'Entwurf') . '.xml';
|
||||
|
||||
header('Content-Type: application/xml; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . strlen($xml));
|
||||
echo $xml;
|
||||
}
|
||||
|
||||
public function book(array $params): void
|
||||
{
|
||||
$id = (int)$params['id'];
|
||||
|
|
|
|||
|
|
@ -11,20 +11,132 @@ use horstoeko\zugferd\ZugferdProfiles;
|
|||
|
||||
class ZugferdService
|
||||
{
|
||||
/** Geschäftsprozess (BT-23) – von PEPPOL/XRechnung erwartet */
|
||||
private const BUSINESS_PROCESS = 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0';
|
||||
|
||||
/**
|
||||
* Bettet ZUGFeRD EN16931-XML in den übergebenen PDF-String ein
|
||||
* und gibt das resultierende PDF als String zurück.
|
||||
*/
|
||||
public function attachToPdf(string $pdfContent, array $inv, array $lines, array $settings): string
|
||||
{
|
||||
$doc = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931);
|
||||
$doc = $this->buildDocument($inv, $lines, $settings);
|
||||
|
||||
$rechnungsDatum = new DateTime($inv['rechnungs_datum']);
|
||||
$pdfBuilder = new ZugferdDocumentPdfBuilder($doc, $pdfContent);
|
||||
$pdfBuilder->generateDocument();
|
||||
|
||||
return $pdfBuilder->downloadString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die reine XML-Rechnung (ohne PDF) als String.
|
||||
*
|
||||
* Verwendet das Profil XRechnung 3.0 – eine CIUS von EN 16931, die zusätzlich
|
||||
* die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem
|
||||
* E-Rechnungsvalidator ohne Beanstandung akzeptiert wird.
|
||||
*/
|
||||
public function createXml(array $inv, array $lines, array $settings): string
|
||||
{
|
||||
return $this->buildDocument($inv, $lines, $settings, ZugferdProfiles::PROFILE_XRECHNUNG_3)->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, welche für EN 16931 bzw. die XRechnung-CIUS nötigen Stamm- und
|
||||
* Rechnungsdaten fehlen.
|
||||
*
|
||||
* @return array{Stammdaten: string[], Rechnungsdaten: string[]} nur nicht-leere Bereiche
|
||||
*/
|
||||
public function collectMissingData(array $inv, array $lines, array $settings): array
|
||||
{
|
||||
$stamm = [];
|
||||
if (trim((string)($settings['firma'] ?? '')) === '') $stamm[] = 'Firmenname (BT-27)';
|
||||
if (trim((string)($settings['strasse'] ?? '')) === '') $stamm[] = 'Straße des Verkäufers (BT-35)';
|
||||
if (trim((string)($settings['plz'] ?? '')) === '') $stamm[] = 'PLZ des Verkäufers (BT-38)';
|
||||
if (trim((string)($settings['ort'] ?? '')) === '') $stamm[] = 'Ort des Verkäufers (BT-37)';
|
||||
if (trim((string)($settings['ust_id'] ?? '')) === '' && trim((string)($settings['steuernummer'] ?? '')) === '') {
|
||||
$stamm[] = 'USt-IdNr. oder Steuernummer (BT-31/BT-32)';
|
||||
}
|
||||
if (trim((string)($settings['iban'] ?? '')) === '') {
|
||||
$stamm[] = 'IBAN für die Zahlungsangaben (BT-84)';
|
||||
}
|
||||
if ($this->sellerContactName($settings) === '') {
|
||||
$stamm[] = 'Inhaber als Ansprechpartner (BT-41)';
|
||||
}
|
||||
if (trim((string)($settings['telefon'] ?? '')) === '') {
|
||||
$stamm[] = 'Telefonnummer des Ansprechpartners (BT-42)';
|
||||
}
|
||||
if (trim((string)($settings['email'] ?? '')) === '') {
|
||||
$stamm[] = 'E-Mail-Adresse des Ansprechpartners (BT-43/BT-34)';
|
||||
}
|
||||
|
||||
$rechnung = [];
|
||||
if (trim((string)($inv['rechnungs_nr'] ?? '')) === '') {
|
||||
$rechnung[] = 'Rechnungsnummer (BT-1) – Rechnung ist noch ein Entwurf';
|
||||
}
|
||||
if (trim((string)($inv['rechnungs_datum'] ?? '')) === '') {
|
||||
$rechnung[] = 'Rechnungsdatum (BT-2)';
|
||||
}
|
||||
if ($this->buyerName($inv) === '') {
|
||||
$rechnung[] = 'Name des Kunden (BT-44)';
|
||||
}
|
||||
if (trim((string)($inv['strasse'] ?? '')) === '') $rechnung[] = 'Straße des Kunden (BT-50)';
|
||||
if (trim((string)($inv['plz'] ?? '')) === '') $rechnung[] = 'PLZ des Kunden (BT-53)';
|
||||
if (trim((string)($inv['ort'] ?? '')) === '') $rechnung[] = 'Ort des Kunden (BT-52)';
|
||||
if (trim((string)($inv['land'] ?? '')) === '') $rechnung[] = 'Land des Kunden (BT-55) – es wird ersatzweise DE gesetzt';
|
||||
if (trim((string)($inv['kundennummer'] ?? '')) === '') {
|
||||
$rechnung[] = 'Kundennummer als Käuferreferenz (BT-10)';
|
||||
}
|
||||
if (trim((string)($inv['email'] ?? '')) === '') {
|
||||
$rechnung[] = 'E-Mail-Adresse des Kunden als elektronische Adresse (BT-49)';
|
||||
}
|
||||
if (trim((string)($inv['faellig_datum'] ?? '')) === '') {
|
||||
$rechnung[] = 'Fälligkeitsdatum (BT-9)';
|
||||
}
|
||||
if (trim((string)($inv['leistungszeitraum'] ?? '')) === '') {
|
||||
$rechnung[] = 'Leistungsdatum/-zeitraum (BT-72) – es wird ersatzweise das Rechnungsdatum gesetzt';
|
||||
}
|
||||
|
||||
$positionen = array_filter($lines, fn($l) => ($l['type'] ?? 'position') !== 'heading');
|
||||
if (empty($positionen)) {
|
||||
$rechnung[] = 'Mindestens eine Rechnungsposition (BG-25)';
|
||||
}
|
||||
foreach ($positionen as $line) {
|
||||
if (trim((string)($line['einheit'] ?? '')) === '') {
|
||||
$rechnung[] = 'Einheit mindestens einer Position (BT-130) – es wird ersatzweise "Stück" gesetzt';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (trim((string)($inv['land'] ?? 'DE')) !== 'DE' && trim((string)($inv['ust_id'] ?? '')) === '') {
|
||||
$rechnung[] = 'USt-IdNr. des ausländischen Kunden (BT-48)';
|
||||
}
|
||||
|
||||
$result = [];
|
||||
if ($stamm) $result['Stammdaten'] = $stamm;
|
||||
if ($rechnung) $result['Rechnungsdaten'] = $rechnung;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das EN16931-Dokument aus Rechnungs-, Positions- und Stammdaten auf.
|
||||
* Fehlende Daten führen nicht zum Abbruch – sie bleiben im XML leer.
|
||||
*/
|
||||
private function buildDocument(
|
||||
array $inv,
|
||||
array $lines,
|
||||
array $settings,
|
||||
int $profile = ZugferdProfiles::PROFILE_EN16931
|
||||
): ZugferdDocumentBuilder {
|
||||
$doc = ZugferdDocumentBuilder::createNew($profile);
|
||||
$doc->setDocumentBusinessProcess(self::BUSINESS_PROCESS);
|
||||
|
||||
$rechnungsDatum = !empty($inv['rechnungs_datum']) ? new DateTime($inv['rechnungs_datum']) : new DateTime();
|
||||
$faelligDatum = !empty($inv['faellig_datum']) ? new DateTime($inv['faellig_datum']) : null;
|
||||
|
||||
// Dokumentkopf
|
||||
$doc->setDocumentInformation(
|
||||
$inv['rechnungs_nr'],
|
||||
(string)($inv['rechnungs_nr'] ?? ''),
|
||||
'380', // UN/EDIFACT: Handelsrechnung
|
||||
$rechnungsDatum,
|
||||
'EUR'
|
||||
|
|
@ -35,16 +147,16 @@ class ZugferdService
|
|||
$doc->setDocumentSupplyChainEvent($deliveryDate);
|
||||
|
||||
// Käuferreferenz (BT-10)
|
||||
$doc->setDocumentBuyerReference($inv['kundennummer']);
|
||||
$doc->setDocumentBuyerReference((string)($inv['kundennummer'] ?? ''));
|
||||
|
||||
// Verkäufer (BG-4)
|
||||
$doc->setDocumentSeller($settings['firma'] ?? '');
|
||||
$doc->setDocumentSeller((string)($settings['firma'] ?? ''));
|
||||
$doc->setDocumentSellerAddress(
|
||||
$settings['strasse'] ?: null,
|
||||
($settings['strasse'] ?? '') ?: null,
|
||||
null,
|
||||
null,
|
||||
$settings['plz'] ?: null,
|
||||
$settings['ort'] ?: null,
|
||||
($settings['plz'] ?? '') ?: null,
|
||||
($settings['ort'] ?? '') ?: null,
|
||||
'DE'
|
||||
);
|
||||
if (!empty($settings['ust_id'])) {
|
||||
|
|
@ -53,22 +165,36 @@ class ZugferdService
|
|||
$doc->addDocumentSellerTaxNumber($settings['steuernummer']);
|
||||
}
|
||||
|
||||
// Ansprechpartner des Verkäufers (BG-6) – für XRechnung Pflicht (BR-DE-2/5/6/7)
|
||||
$doc->setDocumentSellerContact(
|
||||
$this->sellerContactName($settings) ?: null,
|
||||
null,
|
||||
($settings['telefon'] ?? '') ?: null,
|
||||
null,
|
||||
($settings['email'] ?? '') ?: null
|
||||
);
|
||||
// Elektronische Adresse des Verkäufers (BT-34), Schema "EM" = E-Mail
|
||||
if (!empty($settings['email'])) {
|
||||
$doc->setDocumentSellerCommunication('EM', $settings['email']);
|
||||
}
|
||||
|
||||
// Käufer (BG-7)
|
||||
$buyerName = !empty($inv['firma'])
|
||||
? $inv['firma']
|
||||
: trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . ($inv['nachname'] ?? ''));
|
||||
$doc->setDocumentBuyer($buyerName, $inv['kundennummer']);
|
||||
$doc->setDocumentBuyer($this->buyerName($inv), (string)($inv['kundennummer'] ?? ''));
|
||||
$doc->setDocumentBuyerAddress(
|
||||
$inv['strasse'] ?: null,
|
||||
($inv['strasse'] ?? '') ?: null,
|
||||
null,
|
||||
null,
|
||||
$inv['plz'] ?: null,
|
||||
$inv['ort'] ?: null,
|
||||
$inv['land'] ?: 'DE'
|
||||
($inv['plz'] ?? '') ?: null,
|
||||
($inv['ort'] ?? '') ?: null,
|
||||
($inv['land'] ?? '') ?: 'DE'
|
||||
);
|
||||
if (!empty($inv['ust_id'])) {
|
||||
$doc->addDocumentBuyerVATRegistrationNumber($inv['ust_id']);
|
||||
}
|
||||
// Elektronische Adresse des Käufers (BT-49)
|
||||
if (!empty($inv['email'])) {
|
||||
$doc->setDocumentBuyerCommunication('EM', $inv['email']);
|
||||
}
|
||||
|
||||
// Zahlungsart: Überweisung (BG-16)
|
||||
$iban = preg_replace('/\s+/', '', $settings['iban'] ?? '');
|
||||
|
|
@ -79,7 +205,7 @@ class ZugferdService
|
|||
$settings['bank_name'] ?? null,
|
||||
null,
|
||||
$bic ?: null,
|
||||
$inv['rechnungs_nr']
|
||||
(string)($inv['rechnungs_nr'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -94,13 +220,13 @@ class ZugferdService
|
|||
if (($line['type'] ?? 'position') === 'heading') {
|
||||
continue;
|
||||
}
|
||||
$rate = (float)$line['ust_satz'];
|
||||
$rate = (float)($line['ust_satz'] ?? 0);
|
||||
$key = number_format($rate, 2);
|
||||
if (!isset($taxGroups[$key])) {
|
||||
$taxGroups[$key] = ['rate' => $rate, 'basis' => 0.0, 'tax' => 0.0];
|
||||
}
|
||||
$taxGroups[$key]['basis'] += (float)$line['betrag_netto'];
|
||||
$taxGroups[$key]['tax'] += (float)$line['betrag_ust'];
|
||||
$taxGroups[$key]['basis'] += (float)($line['betrag_netto'] ?? 0);
|
||||
$taxGroups[$key]['tax'] += (float)($line['betrag_ust'] ?? 0);
|
||||
}
|
||||
foreach ($taxGroups as $group) {
|
||||
$categoryCode = $group['rate'] > 0 ? 'S' : 'E';
|
||||
|
|
@ -116,14 +242,16 @@ class ZugferdService
|
|||
}
|
||||
|
||||
// Gesamtsummen (BG-22)
|
||||
$brutto = round((float)($inv['betrag_brutto'] ?? 0), 2);
|
||||
$netto = round((float)($inv['betrag_netto'] ?? 0), 2);
|
||||
$doc->setDocumentSummation(
|
||||
round((float)$inv['betrag_brutto'], 2), // Brutto (Zahlbetrag)
|
||||
round((float)$inv['betrag_brutto'], 2), // fällig
|
||||
round((float)$inv['betrag_netto'], 2), // Positionssumme Netto
|
||||
$brutto, // Brutto (Zahlbetrag)
|
||||
$brutto, // fällig
|
||||
$netto, // Positionssumme Netto
|
||||
null,
|
||||
null,
|
||||
round((float)$inv['betrag_netto'], 2), // Steuerbemessungsgrundlage
|
||||
round((float)$inv['betrag_ust'], 2) // Steuer gesamt
|
||||
$netto, // Steuerbemessungsgrundlage
|
||||
round((float)($inv['betrag_ust'] ?? 0), 2) // Steuer gesamt
|
||||
);
|
||||
|
||||
// Positionen (BG-25) – Überschriften werden übersprungen
|
||||
|
|
@ -134,22 +262,33 @@ class ZugferdService
|
|||
}
|
||||
$posNr++;
|
||||
$doc->addNewPosition((string)$posNr);
|
||||
$doc->setDocumentPositionProductDetails($line['bezeichnung']);
|
||||
$doc->setDocumentPositionNetPrice((float)$line['einzelpreis']);
|
||||
$doc->setDocumentPositionProductDetails((string)($line['bezeichnung'] ?? ''));
|
||||
$doc->setDocumentPositionNetPrice((float)($line['einzelpreis'] ?? 0));
|
||||
$doc->setDocumentPositionQuantity(
|
||||
(float)$line['menge'],
|
||||
$this->mapEinheit($line['einheit'] ?? '')
|
||||
(float)($line['menge'] ?? 0),
|
||||
$this->mapEinheit((string)($line['einheit'] ?? ''))
|
||||
);
|
||||
$rate = (float)$line['ust_satz'];
|
||||
$rate = (float)($line['ust_satz'] ?? 0);
|
||||
$categoryCode = $rate > 0 ? 'S' : 'E';
|
||||
$doc->addDocumentPositionTax($categoryCode, 'VAT', $rate);
|
||||
$doc->setDocumentPositionLineSummation(round((float)$line['betrag_netto'], 2));
|
||||
$doc->setDocumentPositionLineSummation(round((float)($line['betrag_netto'] ?? 0), 2));
|
||||
}
|
||||
|
||||
$pdfBuilder = new ZugferdDocumentPdfBuilder($doc, $pdfContent);
|
||||
$pdfBuilder->generateDocument();
|
||||
return $doc;
|
||||
}
|
||||
|
||||
return $pdfBuilder->downloadString();
|
||||
private function sellerContactName(array $settings): string
|
||||
{
|
||||
$name = trim((string)($settings['inhaber'] ?? ''));
|
||||
return $name !== '' ? $name : trim((string)($settings['firma'] ?? ''));
|
||||
}
|
||||
|
||||
private function buyerName(array $inv): string
|
||||
{
|
||||
if (trim((string)($inv['firma'] ?? '')) !== '') {
|
||||
return trim((string)$inv['firma']);
|
||||
}
|
||||
return trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . ($inv['nachname'] ?? ''));
|
||||
}
|
||||
|
||||
private function parseLeistungszeitraumDate(string $lz, DateTime $fallback): DateTime
|
||||
|
|
@ -168,7 +307,8 @@ class ZugferdService
|
|||
|
||||
private function mapEinheit(string $einheit): string
|
||||
{
|
||||
return match (strtolower(trim($einheit))) {
|
||||
// Abkürzungen im UI enden häufig auf einen Punkt ("Std.", "Stk.")
|
||||
return match (rtrim(strtolower(trim($einheit)), '.')) {
|
||||
'h', 'std', 'stunden', 'stunde' => 'HUR',
|
||||
'tag', 'tage', 'd' => 'DAY',
|
||||
'km' => 'KMT',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ $customerName = !empty($invoice['firma'])
|
|||
|
||||
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
|
||||
|
||||
$xmlHinweise = $xmlHinweise ?? [];
|
||||
$xmlTooltip = empty($xmlHinweise)
|
||||
? 'XML-Rechnung (EN 16931 / XRechnung 3.0) herunterladen'
|
||||
: 'XML-Rechnung (EN 16931 / XRechnung 3.0) herunterladen – es fehlen Angaben, siehe Hinweis oben';
|
||||
|
||||
$badgeClass = match($invoice['status']) {
|
||||
'Bezahlt' => 'badge-green',
|
||||
'Storniert' => 'badge-red',
|
||||
|
|
@ -30,6 +35,27 @@ $badgeClass = match($invoice['status']) {
|
|||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Hinweis auf fehlende Daten für die XML-Rechnung (EN 16931) -->
|
||||
<?php if (!empty($xmlHinweise)): ?>
|
||||
<div class="alert alert-warning" style="margin-bottom:.75rem;padding:.6rem 1rem;background:#fff3cd;border-left:3px solid #f59e0b;font-size:.8rem">
|
||||
<strong>XML-Rechnung (EN 16931 / XRechnung): unvollständige Daten.</strong>
|
||||
Der Download ist trotzdem möglich, die Datei erfüllt die Norm aber nur eingeschränkt.
|
||||
<?php foreach ($xmlHinweise as $bereich => $felder): ?>
|
||||
<div style="margin-top:.4rem">
|
||||
<em><?= $h($bereich) ?>:</em>
|
||||
<ul style="margin:.2rem 0 0 1.2rem">
|
||||
<?php foreach ($felder as $feld): ?>
|
||||
<li><?= $h($feld) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (isset($xmlHinweise['Stammdaten'])): ?>
|
||||
<div style="margin-top:.4rem">Stammdaten pflegen unter <a href="/settings">Einstellungen</a>.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Aktionsleiste -->
|
||||
<div class="toolbar mb-2">
|
||||
<div class="toolbar-left">
|
||||
|
|
@ -63,6 +89,17 @@ $badgeClass = match($invoice['status']) {
|
|||
</svg>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="/invoices/<?= $h($invoice['id']) ?>/xml"
|
||||
style="background:none;border:none;cursor:pointer;color:<?= empty($xmlHinweise) ? 'var(--color-muted)' : 'var(--color-warning)' ?>;padding:.2rem;line-height:1;display:inline-flex;align-items:center"
|
||||
title="<?= $h($xmlTooltip) ?>">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<text x="12" y="18.5" text-anchor="middle" font-size="6.5" font-family="sans-serif"
|
||||
font-weight="700" fill="currentColor" stroke="none">XML</text>
|
||||
</svg>
|
||||
</a>
|
||||
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert'])): ?>
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
|
||||
|
|
|
|||
209
tests/72_InvoiceXmlExportTest.php
Normal file
209
tests/72_InvoiceXmlExportTest.php
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test 72: XML-Rechnung (EN 16931) aus der Rechnungs-Detailansicht.
|
||||
*
|
||||
* Prüft die XML-Erzeugung über ZugferdService::createXml(), den Hinweis auf
|
||||
* fehlende Stamm-/Rechnungsdaten sowie Route und Icon in der Detailansicht.
|
||||
*/
|
||||
class InvoiceXmlExportTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 72: XML-Rechnung (EN 16931) – Download und Hinweis auf fehlende Daten';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$svc = new \Steuerkram\Services\ZugferdService();
|
||||
|
||||
// ── Testrechnung mit vollständigen Daten ────────────────────────────
|
||||
$customerId = \Steuerkram\Models\Customer::create([
|
||||
'nachname' => 'XMLmann',
|
||||
'firma' => 'XML Test GmbH',
|
||||
'strasse' => 'Normstraße 16931',
|
||||
'plz' => '54321',
|
||||
'ort' => 'Normstadt',
|
||||
'land' => 'DE',
|
||||
'email' => 'buchhaltung@xmltest.example',
|
||||
'payment_days' => 14,
|
||||
]);
|
||||
$invoiceId = \Steuerkram\Models\Invoice::create([
|
||||
'customer_id' => $customerId,
|
||||
'betreff' => 'XML-Testrechnung',
|
||||
'rechnungs_datum' => date('Y-m-d'),
|
||||
'leistungszeitraum' => date('m.Y'),
|
||||
'lines' => [
|
||||
['bezeichnung' => 'Beratung', 'menge' => 4, 'einheit' => 'Std.', 'einzelpreis' => 100.00, 'ust_satz' => 19.0],
|
||||
['bezeichnung' => 'Fahrtkosten', 'menge' => 50, 'einheit' => 'km', 'einzelpreis' => 0.30, 'ust_satz' => 19.0],
|
||||
],
|
||||
]);
|
||||
\Steuerkram\Database::execute(
|
||||
"UPDATE invoices SET rechnungs_nr='AR-XML-TEST', status='Finalisiert' WHERE id=?",
|
||||
[$invoiceId]
|
||||
);
|
||||
|
||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||
$lines = \Steuerkram\Models\Invoice::lines($invoiceId);
|
||||
|
||||
$settings = [
|
||||
'firma' => 'Muster Einzelunternehmen',
|
||||
'inhaber' => 'Erika Mustermann',
|
||||
'strasse' => 'Hauptstraße 1',
|
||||
'plz' => '10115',
|
||||
'ort' => 'Berlin',
|
||||
'telefon' => '+49 30 1234567',
|
||||
'email' => 'rechnung@muster.example',
|
||||
'ust_id' => 'DE123456789',
|
||||
'iban' => 'DE02120300000000202051',
|
||||
'bic' => 'BYLADEM1001',
|
||||
'bank_name' => 'Testbank',
|
||||
];
|
||||
|
||||
// ── XML wird erzeugt und ist wohlgeformt ────────────────────────────
|
||||
$xml = $svc->createXml($invoice, $lines, $settings);
|
||||
$this->assertGreaterThan(1000, strlen($xml), 'XML wurde erzeugt');
|
||||
|
||||
$dom = new \DOMDocument();
|
||||
$this->assert($dom->loadXML($xml), 'XML ist wohlgeformt');
|
||||
|
||||
// Profilkennung (BT-24): XRechnung 3.0 als CIUS von EN 16931 – erfüllt BR-DE-21
|
||||
$this->assert(
|
||||
str_contains($xml, 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0'),
|
||||
'XML trägt die Kennung der XRechnung 3.0 (CIUS von EN 16931, BT-24)'
|
||||
);
|
||||
$this->assert(str_contains($xml, 'AR-XML-TEST'), 'Rechnungsnummer (BT-1) im XML enthalten');
|
||||
$this->assert(str_contains($xml, 'XML Test GmbH'), 'Kundenname (BT-44) im XML enthalten');
|
||||
$this->assert(str_contains($xml, 'DE123456789'), 'USt-IdNr. des Verkäufers (BT-31) im XML enthalten');
|
||||
$this->assert(str_contains($xml, 'DE02120300000000202051'), 'IBAN (BT-84) im XML enthalten');
|
||||
|
||||
// ── Beträge und Positionen prüfen ───────────────────────────────────
|
||||
$xp = new \DOMXPath($dom);
|
||||
$xp->registerNamespace('rsm', 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100');
|
||||
$xp->registerNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
|
||||
|
||||
$positionen = $xp->query('//ram:IncludedSupplyChainTradeLineItem');
|
||||
$this->assertEquals(2, $positionen->length, 'Beide Positionen (BG-25) im XML enthalten');
|
||||
|
||||
$brutto = $xp->query('//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount');
|
||||
$this->assertEquals(
|
||||
round((float)$invoice['betrag_brutto'], 2),
|
||||
round((float)$brutto->item(0)->nodeValue, 2),
|
||||
'Bruttobetrag (BT-112) stimmt mit der Rechnung überein'
|
||||
);
|
||||
|
||||
$einheiten = [];
|
||||
foreach ($xp->query('//ram:BilledQuantity') as $node) {
|
||||
$einheiten[] = $node->getAttribute('unitCode');
|
||||
}
|
||||
$this->assertEquals(['HUR', 'KMT'], $einheiten, 'Einheiten auf UN/ECE-Codes gemappt (Std.→HUR, km→KMT)');
|
||||
|
||||
// ── Pflichtangaben der XRechnung-CIUS (BR-DE / PEPPOL) ──────────────
|
||||
$this->assertEquals(
|
||||
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
|
||||
$xp->query('//ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID')->item(0)?->nodeValue,
|
||||
'Geschäftsprozess (BT-23) gesetzt – PEPPOL-EN16931-R001'
|
||||
);
|
||||
$this->assertEquals(
|
||||
1,
|
||||
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact')->length,
|
||||
'Ansprechpartner des Verkäufers (BG-6) vorhanden – BR-DE-2'
|
||||
);
|
||||
$this->assertEquals(
|
||||
'Erika Mustermann',
|
||||
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact/ram:PersonName')->item(0)?->nodeValue,
|
||||
'Kontaktname (BT-41) aus dem Inhaber übernommen – BR-DE-5'
|
||||
);
|
||||
$this->assertNotEmpty(
|
||||
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact//ram:CompleteNumber')->item(0)?->nodeValue,
|
||||
'Telefonnummer des Ansprechpartners (BT-42) vorhanden – BR-DE-6'
|
||||
);
|
||||
$this->assertEquals(
|
||||
'rechnung@muster.example',
|
||||
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact//ram:EmailURIUniversalCommunication/ram:URIID')->item(0)?->nodeValue,
|
||||
'E-Mail des Ansprechpartners (BT-43) vorhanden – BR-DE-7'
|
||||
);
|
||||
|
||||
$sellerUri = $xp->query('//ram:SellerTradeParty/ram:URIUniversalCommunication/ram:URIID')->item(0);
|
||||
$buyerUri = $xp->query('//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID')->item(0);
|
||||
$this->assertEquals('rechnung@muster.example', $sellerUri?->nodeValue, 'Elektronische Adresse des Verkäufers (BT-34)');
|
||||
$this->assertEquals('EM', $sellerUri?->getAttribute('schemeID'), 'BT-34 mit Schema "EM" gekennzeichnet');
|
||||
$this->assertEquals('buchhaltung@xmltest.example', $buyerUri?->nodeValue, 'Elektronische Adresse des Käufers (BT-49)');
|
||||
$this->assertEquals('EM', $buyerUri?->getAttribute('schemeID'), 'BT-49 mit Schema "EM" gekennzeichnet');
|
||||
|
||||
// ── Prüfung gegen das CII-Schema (entspricht dem Schritt val-xsd) ───
|
||||
$xsd = dirname(__DIR__) . '/vendor/horstoeko/zugferd/src/schema/FACTUR-X_EN16931.xsd';
|
||||
if (is_file($xsd)) {
|
||||
$prevErrors = libxml_use_internal_errors(true);
|
||||
$this->assert($dom->schemaValidate($xsd), 'XML ist schemakonform zum CII-XSD');
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prevErrors);
|
||||
}
|
||||
|
||||
// ── PDF-Einbettung bleibt beim Factur-X-Profil EN16931 ──────────────
|
||||
$refl = new \ReflectionMethod(\Steuerkram\Services\ZugferdService::class, 'buildDocument');
|
||||
$this->assertEquals(
|
||||
\horstoeko\zugferd\ZugferdProfiles::PROFILE_EN16931,
|
||||
$refl->getParameters()[3]->getDefaultValue(),
|
||||
'PDF-Einbettung verwendet unverändert das Profil EN16931 (Factur-X)'
|
||||
);
|
||||
|
||||
// ── Vollständige Daten → keine Hinweise ─────────────────────────────
|
||||
$this->assert(
|
||||
$svc->collectMissingData($invoice, $lines, $settings) === [],
|
||||
'Bei vollständigen Daten werden keine fehlenden Angaben gemeldet'
|
||||
);
|
||||
|
||||
// ── Unvollständige Daten: Download trotzdem möglich + Hinweise ──────
|
||||
$entwurf = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||
$entwurf['rechnungs_nr'] = null;
|
||||
$entwurf['faellig_datum'] = null;
|
||||
$entwurf['leistungszeitraum'] = null;
|
||||
$entwurf['strasse'] = '';
|
||||
$entwurf['email'] = '';
|
||||
|
||||
$xmlUnvollstaendig = $svc->createXml($entwurf, $lines, ['firma' => 'Muster Einzelunternehmen']);
|
||||
$this->assertGreaterThan(500, strlen($xmlUnvollstaendig), 'XML wird auch bei fehlenden Daten erzeugt');
|
||||
$domU = new \DOMDocument();
|
||||
$this->assert($domU->loadXML($xmlUnvollstaendig), 'XML bei fehlenden Daten ist wohlgeformt');
|
||||
|
||||
$hinweise = $svc->collectMissingData($entwurf, $lines, ['firma' => 'Muster Einzelunternehmen']);
|
||||
$this->assertNotEmpty($hinweise['Stammdaten'] ?? [], 'Fehlende Stammdaten werden gemeldet');
|
||||
$this->assertNotEmpty($hinweise['Rechnungsdaten'] ?? [], 'Fehlende Rechnungsdaten werden gemeldet');
|
||||
|
||||
$alleRechnung = implode(' | ', $hinweise['Rechnungsdaten']);
|
||||
$alleStamm = implode(' | ', $hinweise['Stammdaten']);
|
||||
$this->assert(str_contains($alleRechnung, 'BT-1'), 'Fehlende Rechnungsnummer (BT-1) wird benannt');
|
||||
$this->assert(str_contains($alleRechnung, 'BT-9'), 'Fehlendes Fälligkeitsdatum (BT-9) wird benannt');
|
||||
$this->assert(str_contains($alleRechnung, 'BT-50'), 'Fehlende Kundenstraße (BT-50) wird benannt');
|
||||
$this->assert(str_contains($alleRechnung, 'BT-49'), 'Fehlende E-Mail-Adresse des Kunden (BT-49) wird benannt');
|
||||
$this->assert(str_contains($alleStamm, 'BT-31'), 'Fehlende USt-IdNr./Steuernummer wird benannt');
|
||||
$this->assert(str_contains($alleStamm, 'BT-84'), 'Fehlende IBAN wird benannt');
|
||||
$this->assert(str_contains($alleStamm, 'BT-42'), 'Fehlende Telefonnummer des Ansprechpartners wird benannt');
|
||||
$this->assert(str_contains($alleStamm, 'BT-43'), 'Fehlende E-Mail des Ansprechpartners wird benannt');
|
||||
|
||||
// ── Leere Rechnung ohne jede Angabe bricht nicht ab ─────────────────
|
||||
$leer = ['id' => 0, 'rechnungs_datum' => date('Y-m-d')];
|
||||
$domL = new \DOMDocument();
|
||||
$this->assert(
|
||||
$domL->loadXML($svc->createXml($leer, [], [])),
|
||||
'Auch ohne Stamm- und Positionsdaten entsteht ein wohlgeformtes XML'
|
||||
);
|
||||
|
||||
// ── Route und Icon in der Detailansicht ─────────────────────────────
|
||||
$routes = (string)file_get_contents(dirname(__DIR__) . '/public/index.php');
|
||||
$this->assert(
|
||||
str_contains($routes, "\$router->get('/invoices/{id}/xml'"),
|
||||
'Route GET /invoices/{id}/xml ist registriert'
|
||||
);
|
||||
|
||||
$showView = (string)file_get_contents(dirname(__DIR__) . '/src/Views/invoices/show.php');
|
||||
$this->assert(
|
||||
str_contains($showView, '/xml"') && str_contains($showView, '>XML</text>'),
|
||||
'Detailansicht enthält das XML-Download-Symbol'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($showView, 'XML-Rechnung (EN 16931 / XRechnung): unvollständige Daten'),
|
||||
'Detailansicht weist auf fehlende Stamm-/Rechnungsdaten hin'
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue