ZUGFeRD-Umwandlung an das PDF Service delegiert, lokale ZUGFeRD-Klassen entfernt
- PdfServiceClient ruft POST /api/invoices/sign auf (Basis-URL über PDF_SERVICE_URL, Standard http://localhost:8084); ZIP, Dateiname und Validierungsergebnis kommen aus der Response - zugferd-Paket samt Test entfernt, Mustang-Abhängigkeiten aus der pom.xml gestrichen - README und .env.example entsprechend angepasst Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,6 @@ MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admi
|
||||
|
||||
# HTTP-Port der Anwendung (Standard: 8083)
|
||||
SERVER_PORT=8083
|
||||
|
||||
# Basis-URL des PDF Service (ZUGFeRD-Umwandlung, Standard: http://localhost:8084)
|
||||
PDF_SERVICE_URL=http://localhost:8084
|
||||
|
||||
@@ -36,20 +36,20 @@ mvn clean package -Pproduction
|
||||
java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
## REST-API
|
||||
## PDF Service
|
||||
|
||||
Die REST-API (`/api/invoices/sign` und `/api/invoices/validate`) ist in das
|
||||
eigenständige Projekt **PDF Service** ausgelagert
|
||||
(`../PDF Service`, Port 8084). Dieses Tool enthält nur noch die Weboberfläche;
|
||||
Rechnungen entstehen hier ausschließlich aus gespeicherten Templates.
|
||||
Die ZUGFeRD-Umwandlung (PDF/A-3 + EN16931-XML, Mustangproject) übernimmt das
|
||||
eigenständige Projekt **PDF Service** (`../PDF Service`, Port 8084). Dieses Tool
|
||||
rendert die Rechnung aus dem Template und ruft dann dessen REST-API
|
||||
(`POST /api/invoices/sign`) auf — der Service muss dafür laufen. Die Basis-URL
|
||||
ist über `PDF_SERVICE_URL` konfigurierbar (Standard `http://localhost:8084`).
|
||||
|
||||
## Architektur
|
||||
|
||||
- [ZugferdService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java) — Kernlogik:
|
||||
PDF → PDF/A-3 + EN16931-XML (Mustangproject), Validierung, Verpackung als ZIP.
|
||||
Nicht-PDF/A-Eingaben (der Normalfall) werden tolerant konvertiert.
|
||||
- [ZugferdValidationService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java) — Mustang-Validator
|
||||
(XSD + Schematron + veraPDF), liefert Status und XML-Prüfbericht
|
||||
- [TemplatePdfService](src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java) — rendert
|
||||
die Canvas-Templates (JSON aus MongoDB) über iText html2pdf zu einem PDF
|
||||
- [PdfServiceClient](src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java) — ruft den
|
||||
PDF Service auf, der daraus die ZUGFeRD-E-Rechnung als ZIP erzeugt
|
||||
- [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche
|
||||
- [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten
|
||||
|
||||
@@ -59,6 +59,6 @@ Rechnungen entstehen hier ausschließlich aus gespeicherten Templates.
|
||||
mvn test
|
||||
```
|
||||
|
||||
[ZugferdServiceTest](src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java) erzeugt ein
|
||||
Test-PDF, konvertiert es und prüft, dass das ZIP ein PDF/A-3 mit eingebettetem
|
||||
`factur-x.xml` (EN16931-Profil) enthält.
|
||||
Die Tests decken das Template-Rendering ([TemplatePdfServiceTest](src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java),
|
||||
[TemplateVariablesTest](src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java)) und die
|
||||
USt-IdNr.-Validierung ab; die ZUGFeRD-Umwandlung wird im Projekt PDF Service getestet.
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<vaadin.version>24.7.4</vaadin.version>
|
||||
<mustang.version>2.24.0</mustang.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -54,13 +53,6 @@
|
||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Mustangproject: Open-Source-Referenzimplementierung von ZUGFeRD / Factur-X -->
|
||||
<dependency>
|
||||
<groupId>org.mustangproject</groupId>
|
||||
<artifactId>library</artifactId>
|
||||
<version>${mustang.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators
|
||||
(HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. -->
|
||||
<dependency>
|
||||
@@ -68,11 +60,6 @@
|
||||
<artifactId>html2pdf</artifactId>
|
||||
<version>5.0.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mustangproject</groupId>
|
||||
<artifactId>validator</artifactId>
|
||||
<version>${mustang.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.assecutor.pdftool.pdfservice;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Client für die REST-API des PDF Service (eigenständiges Projekt), der aus
|
||||
* einem Rechnungs-PDF und den Metadaten die ZUGFeRD-E-Rechnung als ZIP erzeugt
|
||||
* ({@code POST /api/invoices/sign}).
|
||||
*/
|
||||
@Service
|
||||
public class PdfServiceClient {
|
||||
|
||||
/** Response-Header des PDF Service mit dem Ergebnis der Mustang-Validierung. */
|
||||
private static final String VALIDATION_HEADER = "X-Zugferd-Valid";
|
||||
|
||||
private final RestClient restClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String baseUrl;
|
||||
|
||||
/** Ergebnis der Umwandlung: das ZIP, sein Dateiname und das Validierungsergebnis. */
|
||||
public record SignResult(byte[] zip, String zipFileName, boolean valid) {
|
||||
}
|
||||
|
||||
public PdfServiceClient(ObjectMapper objectMapper,
|
||||
@Value("${pdfservice.url:http://localhost:8084}") String baseUrl) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.baseUrl = baseUrl;
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(Duration.ofSeconds(5));
|
||||
// Die Umwandlung (PDF/A-Konvertierung + Validierung) kann einige Sekunden dauern
|
||||
requestFactory.setReadTimeout(Duration.ofSeconds(120));
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.requestFactory(requestFactory)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt das PDF über den PDF Service in eine ZUGFeRD-E-Rechnung um. Das
|
||||
* ZIP wird auch bei fehlgeschlagener Validierung (HTTP 422) geliefert;
|
||||
* {@link SignResult#valid()} meldet das Ergebnis.
|
||||
*
|
||||
* @throws PdfServiceException wenn der Service nicht erreichbar ist oder
|
||||
* die Anfrage ablehnt (z. B. HTTP 400)
|
||||
*/
|
||||
public SignResult sign(byte[] pdf, InvoiceMetadata metadata) {
|
||||
String metadataJson;
|
||||
try {
|
||||
metadataJson = objectMapper.writeValueAsString(metadata);
|
||||
} catch (Exception e) {
|
||||
throw new PdfServiceException("Die Rechnungsdaten konnten nicht serialisiert werden: "
|
||||
+ e.getMessage(), e);
|
||||
}
|
||||
|
||||
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
|
||||
parts.add("file", new ByteArrayResource(pdf) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "rechnung.pdf";
|
||||
}
|
||||
});
|
||||
HttpHeaders metadataHeaders = new HttpHeaders();
|
||||
metadataHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||
parts.add("metadata", new HttpEntity<>(metadataJson, metadataHeaders));
|
||||
|
||||
ResponseEntity<byte[]> response;
|
||||
try {
|
||||
response = restClient.post()
|
||||
.uri("/api/invoices/sign")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(parts)
|
||||
.retrieve()
|
||||
// 422 = ungültige Rechnung, das ZIP kommt trotzdem mit
|
||||
.onStatus(status -> status == HttpStatus.UNPROCESSABLE_ENTITY, (request, res) -> {
|
||||
})
|
||||
.onStatus(org.springframework.http.HttpStatusCode::isError, (request, res) -> {
|
||||
throw new PdfServiceException(errorMessage(res.getStatusCode().value(),
|
||||
new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
})
|
||||
.toEntity(byte[].class);
|
||||
} catch (ResourceAccessException e) {
|
||||
throw new PdfServiceException("Der PDF Service ist nicht erreichbar (" + baseUrl + "): "
|
||||
+ e.getMessage(), e);
|
||||
}
|
||||
|
||||
byte[] zip = response.getBody();
|
||||
if (zip == null || zip.length == 0) {
|
||||
throw new PdfServiceException("Der PDF Service hat ein leeres ZIP geliefert.");
|
||||
}
|
||||
boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER));
|
||||
return new SignResult(zip, zipFileName(response.getHeaders(), metadata), valid);
|
||||
}
|
||||
|
||||
/** Dateiname aus dem Content-Disposition-Header, sonst aus der Rechnungsnummer. */
|
||||
private static String zipFileName(HttpHeaders headers, InvoiceMetadata metadata) {
|
||||
ContentDisposition disposition = headers.getContentDisposition();
|
||||
if (disposition.getFilename() != null && !disposition.getFilename().isBlank()) {
|
||||
return disposition.getFilename();
|
||||
}
|
||||
return metadata.invoiceNumber() + "-zugferd.zip";
|
||||
}
|
||||
|
||||
/** Fehlermeldung aus dem JSON-Body ({@code {"error": "..."}}) des PDF Service. */
|
||||
private String errorMessage(int status, String body) {
|
||||
try {
|
||||
Map<?, ?> json = objectMapper.readValue(body, Map.class);
|
||||
Object error = json.get("error");
|
||||
if (error != null) {
|
||||
return "Der PDF Service hat die Anfrage abgelehnt: " + error;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Body war kein JSON — Rohtext verwenden
|
||||
}
|
||||
return "Der PDF Service hat die Anfrage abgelehnt (HTTP " + status + "): " + body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.assecutor.pdftool.pdfservice;
|
||||
|
||||
/** Fehler beim Aufruf des PDF Service (nicht erreichbar oder Ablehnung der Anfrage). */
|
||||
public class PdfServiceException extends RuntimeException {
|
||||
|
||||
public PdfServiceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public PdfServiceException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,8 @@ import de.assecutor.pdftool.template.InvoiceTemplateService;
|
||||
import de.assecutor.pdftool.template.TemplatePdfService;
|
||||
import de.assecutor.pdftool.template.TemplateRenderException;
|
||||
import de.assecutor.pdftool.template.TemplateVariables;
|
||||
import de.assecutor.pdftool.zugferd.ZugferdConversionException;
|
||||
import de.assecutor.pdftool.zugferd.ZugferdResult;
|
||||
import de.assecutor.pdftool.zugferd.ZugferdService;
|
||||
import de.assecutor.pdftool.pdfservice.PdfServiceClient;
|
||||
import de.assecutor.pdftool.pdfservice.PdfServiceException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigDecimal;
|
||||
@@ -63,7 +62,7 @@ public class MainView extends VerticalLayout {
|
||||
|
||||
private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100");
|
||||
|
||||
private final ZugferdService zugferdService;
|
||||
private final PdfServiceClient pdfServiceClient;
|
||||
private final CapturedInvoiceData capturedInvoiceData;
|
||||
private final InvoiceTemplateService invoiceTemplateService;
|
||||
private final TemplatePdfService templatePdfService;
|
||||
@@ -117,10 +116,10 @@ public class MainView extends VerticalLayout {
|
||||
*/
|
||||
private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>();
|
||||
|
||||
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData,
|
||||
public MainView(PdfServiceClient pdfServiceClient, CapturedInvoiceData capturedInvoiceData,
|
||||
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
|
||||
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) {
|
||||
this.zugferdService = zugferdService;
|
||||
this.pdfServiceClient = pdfServiceClient;
|
||||
this.capturedInvoiceData = capturedInvoiceData;
|
||||
this.invoiceTemplateService = invoiceTemplateService;
|
||||
this.templatePdfService = templatePdfService;
|
||||
@@ -686,12 +685,12 @@ public class MainView extends VerticalLayout {
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/** Wandelt das Quell-PDF in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */
|
||||
/** Wandelt das Quell-PDF über den PDF Service in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */
|
||||
private void convertAndOfferDownload(byte[] sourcePdf) {
|
||||
try {
|
||||
InvoiceMetadata metadata = collectMetadata();
|
||||
capturedInvoiceData.setItems(metadata.items());
|
||||
ZugferdResult result = zugferdService.createZugferdZip(sourcePdf, metadata);
|
||||
PdfServiceClient.SignResult result = pdfServiceClient.sign(sourcePdf, metadata);
|
||||
|
||||
StreamResource resource = new StreamResource(result.zipFileName(),
|
||||
() -> new ByteArrayInputStream(result.zip()));
|
||||
@@ -709,7 +708,7 @@ public class MainView extends VerticalLayout {
|
||||
Notification.Position.MIDDLE)
|
||||
.addThemeVariants(NotificationVariant.LUMO_WARNING);
|
||||
}
|
||||
} catch (ZugferdConversionException e) {
|
||||
} catch (PdfServiceException e) {
|
||||
showErrors(List.of(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package de.assecutor.pdftool.zugferd;
|
||||
|
||||
public class ZugferdConversionException extends RuntimeException {
|
||||
|
||||
public ZugferdConversionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ZugferdConversionException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package de.assecutor.pdftool.zugferd;
|
||||
|
||||
/**
|
||||
* Ergebnis einer ZUGFeRD-Konvertierung: die ZIP-Datei (ZUGFeRD-PDF, Factur-X-XML,
|
||||
* Prüfbericht) sowie das Ergebnis der Mustang-Validierung.
|
||||
*/
|
||||
public record ZugferdResult(byte[] zip, String zipFileName, boolean valid, String validationReport) {
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package de.assecutor.pdftool.zugferd;
|
||||
|
||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.mustangproject.BankDetails;
|
||||
import org.mustangproject.Contact;
|
||||
import org.mustangproject.Invoice;
|
||||
import org.mustangproject.Item;
|
||||
import org.mustangproject.Product;
|
||||
import org.mustangproject.SchemedID;
|
||||
import org.mustangproject.TradeParty;
|
||||
import org.mustangproject.ZUGFeRD.IZUGFeRDExporter;
|
||||
import org.mustangproject.ZUGFeRD.Profiles;
|
||||
import org.mustangproject.ZUGFeRD.ZUGFeRD2PullProvider;
|
||||
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1;
|
||||
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromPDFA;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* Wandelt ein herkömmliches Rechnungs-PDF in eine rechtskonforme ZUGFeRD-Rechnung
|
||||
* (PDF/A-3 mit eingebettetem EN16931-XML, Profil EN 16931) um, validiert das
|
||||
* Ergebnis mit dem Mustang-Validator und verpackt PDF, Factur-X-XML und
|
||||
* Prüfbericht in eine ZIP-Datei.
|
||||
*/
|
||||
@Service
|
||||
public class ZugferdService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ZugferdService.class);
|
||||
|
||||
private static final String PROFILE = "EN16931";
|
||||
private static final String PRODUCER = "Assecutor Data Service GmbH Invoice Tool";
|
||||
|
||||
/** Business Process (BT-23), von PEPPOL-EN16931-R001 gefordert. */
|
||||
private static final String BUSINESS_PROCESS = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0";
|
||||
|
||||
/** EAS-Schema "EM" = E-Mail-Adresse für die elektronische Adresse (BT-34/BT-49). */
|
||||
private static final String EAS_EMAIL = "EM";
|
||||
|
||||
private final ZugferdValidationService validationService;
|
||||
|
||||
public ZugferdService(ZugferdValidationService validationService) {
|
||||
this.validationService = validationService;
|
||||
}
|
||||
|
||||
public ZugferdResult createZugferdZip(byte[] sourcePdf, InvoiceMetadata metadata) {
|
||||
validatePdf(sourcePdf);
|
||||
sourcePdf = ensurePageResources(sourcePdf);
|
||||
Invoice invoice = buildInvoice(metadata);
|
||||
|
||||
byte[] zugferdPdf = embedXmlIntoPdf(sourcePdf, invoice);
|
||||
byte[] facturXml = generateXml(invoice);
|
||||
|
||||
String baseName = sanitizeFileName(metadata.invoiceNumber());
|
||||
String pdfName = baseName + "-zugferd.pdf";
|
||||
ZugferdValidationService.ValidationResult validation = validationService.validate(zugferdPdf, pdfName);
|
||||
|
||||
byte[] zip = zip(
|
||||
pdfName, zugferdPdf,
|
||||
"factur-x.xml", facturXml,
|
||||
"validation-report.xml", validation.reportXml().getBytes(StandardCharsets.UTF_8));
|
||||
return new ZugferdResult(zip, baseName + ".zip", validation.valid(), validation.reportXml());
|
||||
}
|
||||
|
||||
private void validatePdf(byte[] pdf) {
|
||||
if (pdf == null || pdf.length < 5
|
||||
|| !"%PDF-".equals(new String(pdf, 0, 5, StandardCharsets.US_ASCII))) {
|
||||
throw new ZugferdConversionException("Die hochgeladene Datei ist kein gültiges PDF.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mustang setzt bei jeder Seite ein Resources-Dictionary voraus und stürzt
|
||||
* sonst mit einer NullPointerException ab. Seiten ohne Resources (z.B. aus
|
||||
* manchen PDF-Generatoren) erhalten deshalb vorab ein leeres Dictionary.
|
||||
*/
|
||||
private byte[] ensurePageResources(byte[] pdf) {
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
boolean changed = false;
|
||||
for (PDPage page : doc.getPages()) {
|
||||
if (page.getResources() == null) {
|
||||
page.setResources(new PDResources());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return pdf;
|
||||
}
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ZugferdConversionException("Das PDF konnte nicht gelesen werden: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private Invoice buildInvoice(InvoiceMetadata m) {
|
||||
LocalDate delivery = m.deliveryDate() != null ? m.deliveryDate() : m.issueDate();
|
||||
LocalDate due = m.dueDate() != null ? m.dueDate() : m.issueDate().plusDays(14);
|
||||
String currency = m.currency() != null && !m.currency().isBlank() ? m.currency() : "EUR";
|
||||
|
||||
Invoice invoice = new Invoice()
|
||||
.setDocumentName("Rechnung")
|
||||
.setBusinessProcessId(BUSINESS_PROCESS)
|
||||
.setNumber(m.invoiceNumber())
|
||||
.setIssueDate(toDate(m.issueDate()))
|
||||
.setDeliveryDate(toDate(delivery))
|
||||
.setDueDate(toDate(due))
|
||||
.setCurrency(currency)
|
||||
.setSender(toSender(m))
|
||||
.setRecipient(toTradeParty(m.recipient()));
|
||||
|
||||
if (m.paymentTerms() != null && !m.paymentTerms().isBlank()) {
|
||||
invoice.setPaymentTermDescription(m.paymentTerms());
|
||||
}
|
||||
if (m.buyerReference() != null && !m.buyerReference().isBlank()) {
|
||||
// Käuferreferenz / Leitweg-ID (BT-10)
|
||||
invoice.setReferenceNumber(m.buyerReference().trim());
|
||||
}
|
||||
|
||||
for (InvoiceMetadata.LineItem li : m.items()) {
|
||||
// Einheit "C62" = Stück (UN/ECE Recommendation 20)
|
||||
Product product = new Product(li.description(), "", "C62", li.vatPercent());
|
||||
invoice.addItem(new Item(product, li.unitPriceNet(), li.quantity()));
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/** Rechnungssteller inkl. Verkäufer-Kontakt (BG-6) und Zahlungsverbindung (BG-16). */
|
||||
private TradeParty toSender(InvoiceMetadata m) {
|
||||
InvoiceMetadata.Party p = m.sender();
|
||||
TradeParty party = toTradeParty(p);
|
||||
party.setContact(new Contact(p.name(), p.phone(), p.email()));
|
||||
if (m.iban() != null && !m.iban().isBlank()) {
|
||||
String iban = m.iban().replaceAll("\\s", "").toUpperCase();
|
||||
party.addBankDetails(m.bic() != null && !m.bic().isBlank()
|
||||
? new BankDetails(iban, m.bic().trim().toUpperCase())
|
||||
: new BankDetails(iban));
|
||||
}
|
||||
return party;
|
||||
}
|
||||
|
||||
private TradeParty toTradeParty(InvoiceMetadata.Party p) {
|
||||
TradeParty party = new TradeParty(p.name(), p.street(), p.zip(), p.city(), p.countryCode());
|
||||
if (p.vatId() != null && !p.vatId().isBlank()) {
|
||||
party.addVATID(p.vatId());
|
||||
}
|
||||
if (p.email() != null && !p.email().isBlank()) {
|
||||
party.setEmail(p.email());
|
||||
party.addUriUniversalCommunicationID(new SchemedID(EAS_EMAIL, p.email()));
|
||||
}
|
||||
return party;
|
||||
}
|
||||
|
||||
private byte[] embedXmlIntoPdf(byte[] sourcePdf, Invoice invoice) {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
IZUGFeRDExporter exporter = loadExporter(sourcePdf)) {
|
||||
exporter.setProducer(PRODUCER)
|
||||
.setCreator(PRODUCER)
|
||||
.setProfile(Profiles.getByName(PROFILE))
|
||||
.setTransaction(invoice);
|
||||
exporter.export(out);
|
||||
return out.toByteArray();
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// Mustang wirft bei problematischen PDFs auch unchecked Exceptions —
|
||||
// ohne diesen Catch würde daraus ein HTTP 500 statt einer Fehlermeldung.
|
||||
throw new ZugferdConversionException(
|
||||
"Das PDF konnte nicht in eine ZUGFeRD-Rechnung umgewandelt werden: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht zuerst die automatische PDF/A-Erkennung; ist das PDF kein PDF/A
|
||||
* (der Normalfall bei ERP-Ausdrucken), wird es tolerant als PDF/A-1
|
||||
* interpretiert und nach PDF/A-3 konvertiert.
|
||||
*/
|
||||
private IZUGFeRDExporter loadExporter(byte[] sourcePdf) throws IOException {
|
||||
ZUGFeRDExporterFromPDFA exporter = new ZUGFeRDExporterFromPDFA();
|
||||
try {
|
||||
exporter.load(new ByteArrayInputStream(sourcePdf));
|
||||
return exporter;
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
// Nicht exporter.close(): vor erfolgreichem load() hält er keine Ressourcen
|
||||
// und close() würde eine RuntimeException werfen.
|
||||
log.info("Eingabe ist kein PDF/A, konvertiere tolerant: {}", e.getMessage());
|
||||
ZUGFeRDExporterFromA1 fallback = new ZUGFeRDExporterFromA1();
|
||||
fallback.ignorePDFAErrors();
|
||||
fallback.load(new ByteArrayInputStream(sourcePdf));
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] generateXml(Invoice invoice) {
|
||||
ZUGFeRD2PullProvider provider = new ZUGFeRD2PullProvider();
|
||||
provider.setProfile(Profiles.getByName(PROFILE));
|
||||
provider.generateXML(invoice);
|
||||
return provider.getXML();
|
||||
}
|
||||
|
||||
/**
|
||||
* Packt die übergebenen Einträge (abwechselnd Dateiname als String und Inhalt
|
||||
* als byte[]) in eine ZIP-Datei.
|
||||
*/
|
||||
private byte[] zip(Object... namesAndContents) {
|
||||
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ZipOutputStream zos = new ZipOutputStream(bos)) {
|
||||
for (int i = 0; i < namesAndContents.length; i += 2) {
|
||||
zos.putNextEntry(new ZipEntry((String) namesAndContents[i]));
|
||||
zos.write((byte[]) namesAndContents[i + 1]);
|
||||
zos.closeEntry();
|
||||
}
|
||||
zos.finish();
|
||||
return bos.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new ZugferdConversionException("ZIP-Datei konnte nicht erstellt werden.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String name) {
|
||||
String cleaned = name.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
return cleaned.isBlank() ? "rechnung" : cleaned;
|
||||
}
|
||||
|
||||
private static Date toDate(LocalDate date) {
|
||||
return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package de.assecutor.pdftool.zugferd;
|
||||
|
||||
import org.mustangproject.validator.ZUGFeRDValidator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Validiert ZUGFeRD-Rechnungen (PDF oder XML) mit dem Mustang-Validator gegen
|
||||
* XSD-Schema und die EN16931-Schematron-Regeln sowie das PDF gegen PDF/A-3
|
||||
* (veraPDF). Liefert das Prüfergebnis samt XML-Prüfbericht.
|
||||
*/
|
||||
@Service
|
||||
public class ZugferdValidationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ZugferdValidationService.class);
|
||||
|
||||
public ValidationResult validate(byte[] content, String fileName) {
|
||||
try {
|
||||
// ZUGFeRDValidator hält Zustand pro Prüfung und ist nicht threadsicher,
|
||||
// daher pro Aufruf eine neue Instanz
|
||||
ZUGFeRDValidator validator = new ZUGFeRDValidator();
|
||||
String report = validator.validate(content, fileName);
|
||||
boolean valid = validator.wasCompletelyValid();
|
||||
if (!valid) {
|
||||
log.warn("ZUGFeRD-Validierung von {} fehlgeschlagen:\n{}", fileName, report);
|
||||
}
|
||||
return new ValidationResult(valid, report);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Validierung von {} nicht durchführbar", fileName, e);
|
||||
return new ValidationResult(false,
|
||||
"<validation><error>Validierung nicht durchführbar: " + e.getMessage() + "</error></validation>");
|
||||
}
|
||||
}
|
||||
|
||||
public record ValidationResult(boolean valid, String reportXml) {
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ server.servlet.session.cookie.name=PDFTOOL_SESSION
|
||||
# Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering)
|
||||
pdftool.version=@project.version@
|
||||
|
||||
# PDF Service: erzeugt aus dem gerenderten Rechnungs-PDF die ZUGFeRD-E-Rechnung
|
||||
# (eigenständiges Projekt "PDF Service", POST /api/invoices/sign)
|
||||
pdfservice.url=${PDF_SERVICE_URL:http://localhost:8084}
|
||||
|
||||
# Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit
|
||||
# eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB
|
||||
# überschreiten; ein abgelehnter Request führt im Browser sonst zu einem
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package de.assecutor.pdftool.zugferd;
|
||||
|
||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ZugferdServiceTest {
|
||||
|
||||
private final ZugferdService service = new ZugferdService(new ZugferdValidationService());
|
||||
|
||||
@Test
|
||||
void createsZipWithZugferdPdfAndFacturXml() throws Exception {
|
||||
byte[] sourcePdf = createSamplePdf();
|
||||
|
||||
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
|
||||
|
||||
assertEquals("RE-2026-0815.zip", result.zipFileName());
|
||||
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
|
||||
|
||||
Map<String, byte[]> entries = readZip(result.zip());
|
||||
assertEquals(3, entries.size());
|
||||
byte[] pdf = entries.get("RE-2026-0815-zugferd.pdf");
|
||||
byte[] xml = entries.get("factur-x.xml");
|
||||
byte[] report = entries.get("validation-report.xml");
|
||||
assertNotNull(pdf, "ZUGFeRD-PDF fehlt im ZIP");
|
||||
assertNotNull(xml, "Factur-X-XML fehlt im ZIP");
|
||||
assertNotNull(report, "Prüfbericht fehlt im ZIP");
|
||||
|
||||
String xmlText = new String(xml, StandardCharsets.UTF_8);
|
||||
assertTrue(xmlText.contains("RE-2026-0815"), "Rechnungsnummer fehlt im XML");
|
||||
assertTrue(xmlText.contains("CrossIndustryInvoice"), "Kein CII-XML");
|
||||
assertTrue(xmlText.contains("urn:cen.eu:en16931:2017"), "EN16931-Profil fehlt");
|
||||
|
||||
// Das erzeugte PDF muss das XML als eingebettete Datei enthalten
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
String names = doc.getDocumentCatalog().getNames().getEmbeddedFiles().getNames().keySet().toString();
|
||||
assertTrue(names.contains("factur-x.xml"), "factur-x.xml nicht ins PDF eingebettet: " + names);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsPdfWhosePageHasNoResources() throws Exception {
|
||||
// Mustang 2.17 stürzt ohne Resources-Dictionary mit einer NPE ab —
|
||||
// der Service muss solche PDFs vorab reparieren.
|
||||
byte[] sourcePdf;
|
||||
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(out);
|
||||
sourcePdf = out.toByteArray();
|
||||
}
|
||||
|
||||
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
|
||||
|
||||
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPdfInput() {
|
||||
assertThrows(ZugferdConversionException.class,
|
||||
() -> service.createZugferdZip("kein pdf".getBytes(StandardCharsets.UTF_8), sampleMetadata()));
|
||||
}
|
||||
|
||||
private static InvoiceMetadata sampleMetadata() {
|
||||
return new InvoiceMetadata(
|
||||
"RE-2026-0815",
|
||||
LocalDate.of(2026, 7, 8),
|
||||
LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2026, 7, 22),
|
||||
"EUR",
|
||||
"Zahlbar innerhalb von 14 Tagen ohne Abzug.",
|
||||
"KR-2026-042",
|
||||
"DE75512108001245126199",
|
||||
null,
|
||||
new InvoiceMetadata.Party("Assecutor Data Service GmbH", "Gerhart-Hauptmann-Weg 14",
|
||||
"21502", "Geesthacht", "DE", "DE261094748", "rechnung@example.com",
|
||||
"+49 40 18 123 771 0"),
|
||||
new InvoiceMetadata.Party("Kunde AG", "Beispielweg 2", "10115", "Berlin", "DE", null,
|
||||
"einkauf@example.com", null),
|
||||
List.of(new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE,
|
||||
new BigDecimal("1500.00"), new BigDecimal("19")))
|
||||
);
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
// PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei
|
||||
PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream(
|
||||
"/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true);
|
||||
cs.beginText();
|
||||
cs.setFont(font, 12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("Rechnung RE-2026-0815");
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, byte[]> readZip(byte[] zip) throws Exception {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries.put(entry.getName(), zis.readAllBytes());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user