Steuerkram/tests/72_InvoiceXmlExportTest.php
fds2610-bot 117c9a5c88 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.
2026-07-31 18:20:16 +02:00

209 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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'
);
}
}